@bongos/core 1.19.364 → 1.19.365
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +33 -23
- package/docs/adr/0191-majority-and-the-author-who-may-not-decide-alone.md +142 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +61 -61
- package/docs/copy-registry.json +64 -64
- package/modules/government/board.js +199 -31
- package/modules/government/config.js +26 -5
- package/modules/government/routes/government.js +6 -1
- package/modules/hall-ui/public/government.js +27 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/government_board_amendment.mjs +37 -8
- package/tests/government_board_close.mjs +13 -2
- package/tests/government_board_majority.mjs +276 -0
- package/tests/government_board_vote.mjs +35 -4
- package/tests/government_board_vote_ui.mjs +18 -3
- package/tests/government_config.mjs +9 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// tests/government_board_majority.mjs — the `majority` pass rule, and the author
|
|
2
|
+
// rule that replaced the flat anti-self refusal (task 1003273).
|
|
3
|
+
//
|
|
4
|
+
// TWO OWNER DECISIONS OF 2026-08-25, pinned here:
|
|
5
|
+
// A. This project decides by MAJORITY. Strictly more than half of the RESOLVED
|
|
6
|
+
// MEMBERSHIP must vote yes. Silence is NOT assent — an item that never
|
|
7
|
+
// reaches a majority RETURNS at expiry. That is the whole difference from
|
|
8
|
+
// `consent`, which config.js documents as letting an empty window pass
|
|
9
|
+
// unopposed.
|
|
10
|
+
// B. An author MAY vote on their own idea, and may NEVER DECIDE ALONE. The old
|
|
11
|
+
// rule refused the cast outright and produced a dead end: every open item was
|
|
12
|
+
// authored by the one member reading them, so the room showed nine sittings
|
|
13
|
+
// and no ballot.
|
|
14
|
+
//
|
|
15
|
+
// THE PERMISSIVE ANSWERS ARE ASSERTED AS DELIBERATELY AS THE RESTRICTIVE ONES.
|
|
16
|
+
// modules/government/CLAUDE.md warns that a later well-meaning "tightening" must
|
|
17
|
+
// fail a test by design — an author's cast landing, and majority being a legal
|
|
18
|
+
// pass_rule, are exactly the two things such a change would quietly undo.
|
|
19
|
+
//
|
|
20
|
+
// DB-free (db monkeypatched — the sibling board tests' idiom).
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
30
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
31
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
32
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
hasMajority, majorityDecided, tallyOutcome, shouldCloseAfterVote,
|
|
36
|
+
castVote, maybeCloseAfterVote, closeItem,
|
|
37
|
+
} = board;
|
|
38
|
+
|
|
39
|
+
async function withDb(stubs, fn) {
|
|
40
|
+
const originals = {};
|
|
41
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
42
|
+
try { return await fn(); }
|
|
43
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MAJORITY = {
|
|
47
|
+
membership: 'rank:archon', pass_rule: 'majority',
|
|
48
|
+
window_minutes: 4320, close_early_on_full_turnout: true,
|
|
49
|
+
};
|
|
50
|
+
const FIRST = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
51
|
+
|
|
52
|
+
const yes = (v) => ({ voter_id: String(v), direction: 'yes' });
|
|
53
|
+
const no = (v, section = 'fit') => ({ voter_id: String(v), direction: 'no', reason_md: 'r', section_key: section });
|
|
54
|
+
const member = (id) => ({ builder_id: String(id), github_login: `m${id}`, display_name: `M${id}` });
|
|
55
|
+
// The live board this project actually has: 4 active archons.
|
|
56
|
+
const FOUR = [member(1000025), member(1000094), member(1000095), member(3)];
|
|
57
|
+
|
|
58
|
+
const ideaItem = (over = {}) => ({
|
|
59
|
+
id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
|
|
60
|
+
constitution: MAJORITY, closed_at: null, outcome: null, expired: false, ...over,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── A. more than half, never half ───────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
test('majority is STRICTLY more than half — a tie is not a majority', () => {
|
|
66
|
+
// The 4-archon board this project runs: 3 carries, 2 does not.
|
|
67
|
+
assert.equal(hasMajority(3, 4), true);
|
|
68
|
+
assert.equal(hasMajority(2, 4), false, 'two of four is a tie, not a majority');
|
|
69
|
+
// Odd boards, and the degenerate ones.
|
|
70
|
+
assert.equal(hasMajority(2, 3), true);
|
|
71
|
+
assert.equal(hasMajority(1, 3), false);
|
|
72
|
+
assert.equal(hasMajority(1, 1), true);
|
|
73
|
+
assert.equal(hasMajority(0, 0), false, 'an empty board carries nothing');
|
|
74
|
+
assert.equal(hasMajority(1, 0), false);
|
|
75
|
+
assert.equal(hasMajority(0, 4), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the denominator is the MEMBERSHIP, not the votes cast', () => {
|
|
79
|
+
// Two yes votes out of nine members is not a majority, however unanimous the
|
|
80
|
+
// turnout was. This is the rule that stops low turnout quietly passing things.
|
|
81
|
+
assert.equal(
|
|
82
|
+
tallyOutcome([yes(1), yes(2)], { constitution: MAJORITY, members: Array.from({ length: 9 }, (_, i) => member(i + 1)), authorId: '99' }),
|
|
83
|
+
'returned',
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
tallyOutcome([yes(1), yes(2)], { constitution: MAJORITY, members: [member(1), member(2), member(3)], authorId: '99' }),
|
|
87
|
+
'passed',
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── A. silence is not assent ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
test('a majority sitting with ZERO votes RETURNS — the opposite of the other two rules', () => {
|
|
94
|
+
// The single most important difference. Under first_ratifier/consent an unvoted
|
|
95
|
+
// item PASSES, which is why a clock is unsafe there: the expiry sweep would
|
|
96
|
+
// ratify things nobody read. Under majority the same expiry returns them.
|
|
97
|
+
assert.equal(tallyOutcome([], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }), 'returned');
|
|
98
|
+
// The contrast, asserted in the same breath so the distinction cannot rot:
|
|
99
|
+
assert.equal(tallyOutcome([], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'passed');
|
|
100
|
+
assert.equal(tallyOutcome([], { constitution: { pass_rule: 'consent' }, members: FOUR, authorId: null }), 'passed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a short-of-majority sitting returns even with no objection at all', () => {
|
|
104
|
+
// Nobody voted no; it simply never carried. Under the legacy rules this would
|
|
105
|
+
// have passed, since only a reasoned objection returns an item there.
|
|
106
|
+
assert.equal(tallyOutcome([yes(1000094)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }), 'returned');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── B. the author may vote, and may never decide alone ──────────────────────
|
|
110
|
+
|
|
111
|
+
test('an author MAY cast on their own idea, and it is recorded', async () => {
|
|
112
|
+
let written = null;
|
|
113
|
+
await withDb({
|
|
114
|
+
getBoardItemForVote: async () => ideaItem(),
|
|
115
|
+
builderHoldsRankActive: async () => true,
|
|
116
|
+
getBoardItemAuthor: async () => '1000025',
|
|
117
|
+
insertBoardVote: async (args) => { written = args; return { id: '1', voter_id: args.voterId, direction: args.direction }; },
|
|
118
|
+
insertVoteKarma: async () => { throw new Error('a self-cast must not be paid karma'); },
|
|
119
|
+
}, async () => {
|
|
120
|
+
const out = await castVote({ itemId: 11, voterId: '1000025', direction: 'yes' });
|
|
121
|
+
assert.equal(out.ok, true, 'the cast the OLD rule refused must now land');
|
|
122
|
+
assert.equal(out.self_cast, true);
|
|
123
|
+
assert.equal(out.karma_delta, 0);
|
|
124
|
+
assert.ok(written, 'the vote is really written, not merely tolerated');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a majority made ONLY of the author cannot carry the item', () => {
|
|
129
|
+
// A one-member board where that member is the author: 1 of 1 IS a majority, and
|
|
130
|
+
// it still must not pass — this is the guard that survives any constitution.
|
|
131
|
+
assert.equal(
|
|
132
|
+
tallyOutcome([yes(1000025)], { constitution: MAJORITY, members: [member(1000025)], authorId: '1000025' }),
|
|
133
|
+
'returned',
|
|
134
|
+
);
|
|
135
|
+
// The same votes with the author being someone else DO carry it, so the
|
|
136
|
+
// assertion above is about authorship and not about arithmetic.
|
|
137
|
+
assert.equal(
|
|
138
|
+
tallyOutcome([yes(1000025)], { constitution: MAJORITY, members: [member(1000025)], authorId: '999' }),
|
|
139
|
+
'passed',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the author's yes COUNTS toward the majority — it just cannot be all of it", () => {
|
|
144
|
+
// 4 members, majority = 3. Author + 2 peers = 3 → carries.
|
|
145
|
+
assert.equal(
|
|
146
|
+
tallyOutcome([yes(1000025), yes(1000094), yes(1000095)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
147
|
+
'passed',
|
|
148
|
+
);
|
|
149
|
+
// The 3 peers without the author also carry it, obviously.
|
|
150
|
+
assert.equal(
|
|
151
|
+
tallyOutcome([yes(1000094), yes(1000095), yes(3)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
152
|
+
'passed',
|
|
153
|
+
);
|
|
154
|
+
// But author + 1 peer is only 2 of 4 — a tie, not a majority.
|
|
155
|
+
assert.equal(
|
|
156
|
+
tallyOutcome([yes(1000025), yes(1000094)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
157
|
+
'returned',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('an author cannot make their own idea WORSE by voting on it', () => {
|
|
162
|
+
// Their 'no' is excluded from the objection test, under every rule. Otherwise
|
|
163
|
+
// the safest act available to an author would be silence, which is a strange
|
|
164
|
+
// incentive to build into a board.
|
|
165
|
+
assert.equal(tallyOutcome([no(1000025)], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'passed');
|
|
166
|
+
// A PEER's no returns it, which is the rule that must survive.
|
|
167
|
+
assert.equal(tallyOutcome([no(1000094)], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'returned');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── B. the close trigger needs a voice that is not the author's ─────────────
|
|
171
|
+
|
|
172
|
+
test("an author's cast never ends the sitting, under any rule", () => {
|
|
173
|
+
for (const constitution of [FIRST, MAJORITY, { ...MAJORITY, pass_rule: 'consent' }]) {
|
|
174
|
+
assert.equal(
|
|
175
|
+
shouldCloseAfterVote({ constitution, votes: [yes(1000025)], members: FOUR, authorId: '1000025' }),
|
|
176
|
+
false,
|
|
177
|
+
`${constitution.pass_rule}: the author alone must not close it`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
// A single NON-author vote closes it under first_ratifier, as it always did.
|
|
181
|
+
assert.equal(
|
|
182
|
+
shouldCloseAfterVote({ constitution: FIRST, votes: [yes(1000094)], members: FOUR, authorId: '1000025' }),
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the whole live deadlock, end to end: the author votes, and the item stays open for a real voice', async () => {
|
|
188
|
+
// This is the nine-items situation with the new rule. The author can now put a
|
|
189
|
+
// position on the record — which the old rule denied them — and the sitting
|
|
190
|
+
// still waits for someone else, which is the point.
|
|
191
|
+
let votes = [];
|
|
192
|
+
await withDb({
|
|
193
|
+
getBoardItemForVote: async () => ideaItem(),
|
|
194
|
+
builderHoldsRankActive: async () => true,
|
|
195
|
+
getBoardItemAuthor: async () => '1000025',
|
|
196
|
+
insertBoardVote: async (a) => { const v = { id: String(votes.length + 1), voter_id: a.voterId, direction: a.direction }; votes.push(v); return v; },
|
|
197
|
+
insertVoteKarma: async () => {},
|
|
198
|
+
getVotesForItem: async () => votes,
|
|
199
|
+
getActiveBuildersWithRank: async () => FOUR,
|
|
200
|
+
closeBoardItem: async () => { throw new Error('must not close yet'); },
|
|
201
|
+
}, async () => {
|
|
202
|
+
await castVote({ itemId: 11, voterId: '1000025', direction: 'yes' });
|
|
203
|
+
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
204
|
+
// A second archon joins: 2 of 4 is a tie, still not a majority.
|
|
205
|
+
await castVote({ itemId: 11, voterId: '1000094', direction: 'yes' });
|
|
206
|
+
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the third yes carries it, and the close pays the author', async () => {
|
|
211
|
+
const votes = [yes(1000025), yes(1000094), yes(1000095)];
|
|
212
|
+
let paid = null;
|
|
213
|
+
await withDb({
|
|
214
|
+
getVotesForItem: async () => votes,
|
|
215
|
+
getBoardItemAuthor: async () => '1000025',
|
|
216
|
+
getActiveBuildersWithRank: async () => FOUR,
|
|
217
|
+
getIdeaCompleteness: async () => 100,
|
|
218
|
+
closeBoardItem: async ({ outcome }) => ({ ...ideaItem(), closed_at: 'T', outcome }),
|
|
219
|
+
insertAuthorRatifyKarma: async (a) => { paid = a; return true; },
|
|
220
|
+
}, async () => {
|
|
221
|
+
const out = await closeItem(ideaItem(), { cause: 'vote' });
|
|
222
|
+
assert.equal(out.outcome, 'passed', '3 of 4, with two non-author voices');
|
|
223
|
+
assert.ok(paid, 'ratification pays the author, as it always did');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── the early close: a sitting whose answer is fixed ────────────────────────
|
|
228
|
+
|
|
229
|
+
test('a decided majority closes early rather than idling', () => {
|
|
230
|
+
// Carried already (3 of 4, with peers among them) — no later vote can change it.
|
|
231
|
+
assert.equal(majorityDecided({ votes: [yes(1000094), yes(1000095), yes(3)], members: FOUR, authorId: '1000025' }), true);
|
|
232
|
+
// Unreachable already: 3 of 4 voted no, so even a yes from the last member
|
|
233
|
+
// cannot reach 3. There is no vote-changing in v1, so this is final.
|
|
234
|
+
assert.equal(majorityDecided({ votes: [no(1000094), no(1000095), no(3)], members: FOUR, authorId: '1000025' }), true);
|
|
235
|
+
// Still live: 1 yes, 3 members yet to vote.
|
|
236
|
+
assert.equal(majorityDecided({ votes: [yes(1000094)], members: FOUR, authorId: '1000025' }), false);
|
|
237
|
+
// An empty board decides nothing.
|
|
238
|
+
assert.equal(majorityDecided({ votes: [], members: [], authorId: null }), false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('a majority that is only the author is NOT treated as decided', () => {
|
|
242
|
+
// It would be arithmetically carried on a 1-member board, but it cannot pass —
|
|
243
|
+
// so closing early would deny the item the one real voice that could save it.
|
|
244
|
+
assert.equal(majorityDecided({ votes: [yes(1000025)], members: [member(1000025)], authorId: '1000025' }), false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('full turnout closes a majority sitting, and only when the constitution says so', () => {
|
|
248
|
+
const all = [yes(1000025), no(1000094), no(1000095), no(3)];
|
|
249
|
+
// Every member has voted; the outcome is already unreachable anyway.
|
|
250
|
+
assert.equal(shouldCloseAfterVote({ constitution: MAJORITY, votes: all, members: FOUR, authorId: '1000025' }), true);
|
|
251
|
+
// With close_early off, an undecided-but-full sitting waits for the clock.
|
|
252
|
+
const split = [yes(1000025), yes(1000094)]; // 2 of 2 members here
|
|
253
|
+
const two = [member(1000025), member(1000094)];
|
|
254
|
+
assert.equal(
|
|
255
|
+
shouldCloseAfterVote({ constitution: { ...MAJORITY, close_early_on_full_turnout: false }, votes: split, members: two, authorId: '1000025' }),
|
|
256
|
+
true, 'carried on the merits (2 of 2, one non-author), so it closes regardless',
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── the rule is a real, proposable value ───────────────────────────────────
|
|
261
|
+
|
|
262
|
+
test('majority is a LEGAL pass_rule everywhere a rule is read', () => {
|
|
263
|
+
assert.ok(config.PASS_RULES.includes('majority'));
|
|
264
|
+
// The sanitizer keeps it (it is not a typo to be corrected away)…
|
|
265
|
+
assert.equal(config.sanitizeBoard({ ...MAJORITY }).pass_rule, 'majority');
|
|
266
|
+
// …and the fail-closed fallback is unchanged for a value that IS a typo.
|
|
267
|
+
assert.equal(config.sanitizeBoard({ ...MAJORITY, pass_rule: 'majorty' }).pass_rule, 'first_ratifier');
|
|
268
|
+
assert.equal(board.validateProposedConstitution({ ...MAJORITY }).ok, true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('an unknown rule still closes NOTHING after a vote (fail-closed, unchanged)', () => {
|
|
272
|
+
assert.equal(
|
|
273
|
+
shouldCloseAfterVote({ constitution: { pass_rule: 'whatever' }, votes: [yes(1000094)], members: FOUR, authorId: '1000025' }),
|
|
274
|
+
false,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
@@ -132,15 +132,46 @@ test('the demotion between open and vote refuses the vote — live re-check, unc
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test('
|
|
135
|
+
test('an author MAY cast on their own item, earns no karma for it — number/string ids compare equal', async () => {
|
|
136
|
+
// Task 1003273 replaced the flat refusal: an author may vote, and may never
|
|
137
|
+
// decide alone (enforced by the close trigger and the tally, not by refusing
|
|
138
|
+
// the cast — see government_board_majority.mjs). The id-shape detail this test
|
|
139
|
+
// has always guarded still matters: Postgres hands back a NUMBER author id
|
|
140
|
+
// while the voter carries a STRING, and the self-cast comparison must see
|
|
141
|
+
// through that, or an author would silently be paid karma on their own item.
|
|
142
|
+
let karmaPaid = 0;
|
|
136
143
|
await withDb({
|
|
137
144
|
getBoardItemForVote: async () => openItem(),
|
|
138
145
|
builderHoldsRankActive: async () => true,
|
|
139
|
-
getBoardItemAuthor: async () => 42, //
|
|
140
|
-
insertBoardVote: async () => {
|
|
146
|
+
getBoardItemAuthor: async () => 42, // number, deliberately
|
|
147
|
+
insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
|
|
148
|
+
insertVoteKarma: async () => { karmaPaid += 1; },
|
|
141
149
|
}, async () => {
|
|
142
150
|
const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
|
|
143
|
-
assert.
|
|
151
|
+
assert.equal(out.ok, true);
|
|
152
|
+
assert.equal(out.self_cast, true, '42 and "42" are the same author');
|
|
153
|
+
assert.equal(out.karma_delta, 0);
|
|
154
|
+
assert.equal(karmaPaid, 0, 'a self-cast writes no karma row');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a NON-author on the same item is paid the vote karma as before', async () => {
|
|
159
|
+
// The contrast, so the no-karma rule above cannot silently become "no karma
|
|
160
|
+
// for anyone" — which would quietly delete the incentive to read other
|
|
161
|
+
// people's ideas.
|
|
162
|
+
let karmaPaid = 0;
|
|
163
|
+
await withDb({
|
|
164
|
+
getBoardItemForVote: async () => openItem(),
|
|
165
|
+
builderHoldsRankActive: async () => true,
|
|
166
|
+
getBoardItemAuthor: async () => 42,
|
|
167
|
+
insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
|
|
168
|
+
insertVoteKarma: async () => { karmaPaid += 1; },
|
|
169
|
+
}, async () => {
|
|
170
|
+
const out = await castVote({ itemId: 11, voterId: '77', direction: 'yes' });
|
|
171
|
+
assert.equal(out.ok, true);
|
|
172
|
+
assert.equal(out.self_cast, false);
|
|
173
|
+
assert.equal(out.karma_delta, VOTE_KARMA_DELTA);
|
|
174
|
+
assert.equal(karmaPaid, 1);
|
|
144
175
|
});
|
|
145
176
|
});
|
|
146
177
|
|
|
@@ -47,11 +47,26 @@ test('an objection needs the reason AND exactly one section; a yes sends neither
|
|
|
47
47
|
assert.match(cast, /item_closed/, 'a cast that CLOSED the sitting says so (first_ratifier / full turnout)');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test('the author of a full_idea
|
|
50
|
+
test('the author of a full_idea gets a REAL ballot plus the limit in words (task 1003273)', () => {
|
|
51
|
+
// This test used to assert the opposite — that an author saw a note INSTEAD of
|
|
52
|
+
// a form. That was the shape of the live bug: every open item was the reader's
|
|
53
|
+
// own, so the room offered nine sittings and no way to vote on any of them. The
|
|
54
|
+
// owner's rule is now "may vote, may not decide alone", so the form must be
|
|
55
|
+
// there and the limit must be stated rather than enforced by a missing button.
|
|
51
56
|
const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
|
|
52
57
|
assert.match(form, /subject_type === 'full_idea'[^]*author_id.*viewerId/,
|
|
53
|
-
'
|
|
54
|
-
|
|
58
|
+
'still SUBJECT-TYPED — an amendment proposer is not held to the author rule (§7)');
|
|
59
|
+
// The note is now additive: it rides ALONGSIDE the buttons, not in place of them.
|
|
60
|
+
assert.match(form, /ownNote/, 'the own-idea note is composed into the form');
|
|
61
|
+
assert.match(form, /can\u2019t be decided by your vote alone|decided by your vote alone/,
|
|
62
|
+
'the note states the real limit');
|
|
63
|
+
assert.match(form, /earns no karma/, 'and that a self-cast is unpaid');
|
|
64
|
+
// The single most important structural assertion: there is exactly ONE return
|
|
65
|
+
// of the ballot, so no branch can silently withhold it from the author again.
|
|
66
|
+
assert.equal((form.match(/data-vote="yes"/g) || []).length, 1,
|
|
67
|
+
'one ballot, reachable by author and peer alike');
|
|
68
|
+
assert.ok(!/return `<p class="gov-grid-note">This is your idea/.test(form),
|
|
69
|
+
'the old form-suppressing early return is gone, not merely bypassed');
|
|
55
70
|
assert.match(form, /if \(mine\) return ''/, 'one vote per member — a cast voter gets no second form');
|
|
56
71
|
assert.match(form, /if \(item\.closed_at\) return ''/, 'closed sittings take no votes');
|
|
57
72
|
});
|
|
@@ -265,7 +265,7 @@ test('a malformed constitution can never widen the franchise or lower the bar',
|
|
|
265
265
|
{ membership: '' },
|
|
266
266
|
{ membership: { rank: 'archon' } },
|
|
267
267
|
{ pass_rule: 'unanimous' },
|
|
268
|
-
{ pass_rule: '
|
|
268
|
+
{ pass_rule: 'unanimous' },
|
|
269
269
|
{ pass_rule: null },
|
|
270
270
|
{ membership: 'rank:xenos; DROP TABLE government_ranks' },
|
|
271
271
|
];
|
|
@@ -325,7 +325,14 @@ test('the resolved constitution is frozen, like the rest of the config contract'
|
|
|
325
325
|
});
|
|
326
326
|
|
|
327
327
|
test('PASS_RULES is the closed set the rest of the goal codes against', () => {
|
|
328
|
-
|
|
328
|
+
// `majority` joined the set in task 1003273 (owner decision 2026-08-25). The
|
|
329
|
+
// set stays CLOSED and asserted whole on purpose: board.js branches on these
|
|
330
|
+
// strings, so a rule added to config without a tally to match would be a
|
|
331
|
+
// constitution a project could adopt and then never resolve.
|
|
332
|
+
assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier', 'majority']);
|
|
329
333
|
assert.ok(Object.isFrozen(PASS_RULES));
|
|
330
334
|
assert.ok(PASS_RULES.includes(BOARD_DEFAULTS.pass_rule), 'the default must be a member of the set');
|
|
335
|
+
// The fail-closed default is UNCHANGED by the addition — adding a stricter rule
|
|
336
|
+
// must not move what a malformed constitution falls back to.
|
|
337
|
+
assert.equal(BOARD_DEFAULTS.pass_rule, 'first_ratifier');
|
|
331
338
|
});
|