@bongos/core 1.19.622 → 1.19.624
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 +70 -35
- package/docs/copy-inventory.md +166 -158
- package/docs/copy-registry.json +286 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +41 -4
- package/modules/economy/credits.js +38 -0
- package/modules/economy/reward.js +5 -0
- package/modules/hall-ui/public/board-room.css +83 -0
- package/modules/hall-ui/public/board-room.html +70 -0
- package/modules/hall-ui/public/board-room.js +517 -0
- package/modules/hall-ui/public/board-room.states.json +104 -0
- package/modules/hall-ui/public/government.css +10 -57
- package/modules/hall-ui/public/government.html +10 -12
- package/modules/hall-ui/public/government.js +119 -367
- package/modules/hall-ui/public/hall-render.js +26 -7
- package/modules/hall-ui/public/palette.js +5 -2
- package/modules/hall-ui/public/shell.js +16 -0
- package/modules/sessions/db.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-earnings.js +123 -0
- package/scripts/gds/fitness-checks-vocabulary.js +7 -0
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth.js +27 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +6 -5
- package/tests/builder_needs.mjs +122 -0
- package/tests/government_board_hash_redirect.mjs +129 -0
- package/tests/government_board_room_copy.mjs +41 -15
- package/tests/government_board_vote_ui.mjs +5 -1
- package/tests/hall_nav.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +11 -0
- package/tests/hall_palette.mjs +9 -3
- package/tests/rank_tier_single_source.mjs +8 -0
- package/tests/session_earnings_mirror_db.mjs +250 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// tests/government_board_hash_redirect.mjs — the `#board-room` compatibility
|
|
2
|
+
// redirect (ADR 0266, task 1003735).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. ADR 0266 carved the Board Room out of the /government
|
|
5
|
+
// tab strip onto its own page, and named this redirect load-bearing for a reason
|
|
6
|
+
// that does not expire: Discord's window-open announcement deep-links
|
|
7
|
+
// `?item=<id>#board-room` (ADR 0175 §8 makes that mention the "vote within X"
|
|
8
|
+
// notice), and every one of those messages already sitting in channel history
|
|
9
|
+
// carries the old form forever. If the redirect breaks, a member clicking the
|
|
10
|
+
// notification lands on the Permissions page with no way to the sitting.
|
|
11
|
+
//
|
|
12
|
+
// AND WHY IT CANNOT BE A SERVER TEST. A URL fragment is never sent to the
|
|
13
|
+
// server, so `#board-room` cannot be caught by a route in serve-internal.js —
|
|
14
|
+
// adding a regex there looks like the obvious fix and silently never fires. That
|
|
15
|
+
// makes this browser function the ONLY thing standing between an old link and a
|
|
16
|
+
// dead end, which is exactly the shape that deserves a pin.
|
|
17
|
+
//
|
|
18
|
+
// It is extracted and RUN, not grepped: the interesting behaviour is the
|
|
19
|
+
// branch (which hashes trigger) and the query-preservation (encodeURIComponent
|
|
20
|
+
// on a value that came off the URL), and a source match would pass on a
|
|
21
|
+
// function that returns the wrong string.
|
|
22
|
+
//
|
|
23
|
+
// Pure file-read + vm (no DB/network) — runs in the DB-free unit gate.
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
33
|
+
const GOV_SRC = fs.readFileSync(path.join(HALL, 'government.js'), 'utf8');
|
|
34
|
+
|
|
35
|
+
// Lift the function out of the page's IIFE and run it against a fake location.
|
|
36
|
+
// `sliceFn` counts braces rather than matching a closing line, so a nested block
|
|
37
|
+
// inside the body cannot end the slice early.
|
|
38
|
+
function sliceFn(src, name) {
|
|
39
|
+
const start = src.indexOf(`function ${name}`);
|
|
40
|
+
assert.notEqual(start, -1, `${name} not found in government.js`);
|
|
41
|
+
const open = src.indexOf('{', start);
|
|
42
|
+
let depth = 0;
|
|
43
|
+
for (let i = open; i < src.length; i += 1) {
|
|
44
|
+
if (src[i] === '{') depth += 1;
|
|
45
|
+
else if (src[i] === '}') {
|
|
46
|
+
depth -= 1;
|
|
47
|
+
if (depth === 0) return src.slice(start, i + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`${name} is unbalanced`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Returns { result, replaced } — `replaced` is the URL location.replace was
|
|
54
|
+
// called with, or null if it was never called.
|
|
55
|
+
function runRedirect({ hash, search = '' }) {
|
|
56
|
+
let replaced = null;
|
|
57
|
+
const sandbox = {
|
|
58
|
+
URLSearchParams,
|
|
59
|
+
window: {
|
|
60
|
+
location: {
|
|
61
|
+
hash,
|
|
62
|
+
search,
|
|
63
|
+
replace(url) { replaced = url; },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
vm.createContext(sandbox);
|
|
68
|
+
vm.runInContext(`${sliceFn(GOV_SRC, 'redirectLegacyBoardHash')}
|
|
69
|
+
globalThis.__out = redirectLegacyBoardHash();`, sandbox, { filename: 'government.js' });
|
|
70
|
+
return { result: sandbox.__out, replaced };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('the old hash lands on the room, and says it navigated', () => {
|
|
74
|
+
const { result, replaced } = runRedirect({ hash: '#board-room' });
|
|
75
|
+
assert.equal(replaced, '/board-room');
|
|
76
|
+
assert.equal(result, true,
|
|
77
|
+
'init() returns early on true — a falsy answer would let the government page paint over the navigation');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('?item=N is carried across — that is the whole point of the Discord link', () => {
|
|
81
|
+
const { replaced } = runRedirect({ hash: '#board-room', search: '?item=12' });
|
|
82
|
+
assert.equal(replaced, '/board-room?item=12');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the item is re-encoded, never pasted through raw', () => {
|
|
86
|
+
// The value arrives off a URL a stranger can author, and goes straight back
|
|
87
|
+
// into one. Board item ids are numeric, so this is defence rather than a live
|
|
88
|
+
// case — which is why it is pinned instead of assumed.
|
|
89
|
+
const { replaced } = runRedirect({ hash: '#board-room', search: '?item=1%202%263' });
|
|
90
|
+
assert.equal(replaced, '/board-room?item=1%202%263',
|
|
91
|
+
'a space and an ampersand survive as escapes rather than splitting the query');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an item that is absent or empty produces a bare URL, not a dangling ?item=', () => {
|
|
95
|
+
assert.equal(runRedirect({ hash: '#board-room', search: '?other=1' }).replaced, '/board-room');
|
|
96
|
+
assert.equal(runRedirect({ hash: '#board-room', search: '?item=' }).replaced, '/board-room',
|
|
97
|
+
'an empty value is falsy, so no query is appended');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('every OTHER hash is left alone — the two surviving tabs still work', () => {
|
|
101
|
+
// The government page kept #constitution and #ranks. A redirect that fired on
|
|
102
|
+
// those would make the page unreachable, which is a worse failure than the one
|
|
103
|
+
// this function exists to prevent.
|
|
104
|
+
for (const hash of ['#constitution', '#ranks', '', '#board-room-extra', '#Board-Room']) {
|
|
105
|
+
const { result, replaced } = runRedirect({ hash, search: '?item=12' });
|
|
106
|
+
assert.equal(replaced, null, `${hash || '(no hash)'} must not navigate`);
|
|
107
|
+
assert.equal(result, false, `${hash || '(no hash)'} must let init() continue`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('init() consults it FIRST and returns on true', () => {
|
|
112
|
+
// The order is the behaviour: called after the /me fetch, the reader would see
|
|
113
|
+
// the government page paint and then jump. Asserted on the source because it is
|
|
114
|
+
// a statement about sequence, not a value.
|
|
115
|
+
const init = sliceFn(GOV_SRC, 'init');
|
|
116
|
+
const guard = init.indexOf('redirectLegacyBoardHash()');
|
|
117
|
+
assert.notEqual(guard, -1, 'init() must consult the redirect');
|
|
118
|
+
assert.match(init.slice(guard - 30, guard + 60), /if \(redirectLegacyBoardHash\(\)\) return;/,
|
|
119
|
+
'it must be an early return, not a fire-and-continue');
|
|
120
|
+
const meFetch = init.indexOf("fetchJson('/me')");
|
|
121
|
+
assert.ok(guard < meFetch, 'the redirect must run BEFORE the /me read and any paint');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the government page no longer declares a board-room tab to redirect INTO', () => {
|
|
125
|
+
// If #board-room came back as a tab id, this redirect would fight makeTabs for
|
|
126
|
+
// the same hash and the room would exist in two places.
|
|
127
|
+
const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
128
|
+
assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
|
|
129
|
+
});
|
|
@@ -26,20 +26,28 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
|
|
27
27
|
const require = createRequire(import.meta.url);
|
|
28
28
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
// TWO SOURCES SINCE ADR 0266. The Board Room was the `#board-room` tab of the
|
|
30
|
+
// government page and is now its own page, so the sitting card, its copy and the
|
|
31
|
+
// vote form live in board-room.js / board-room.html. `CLIENT` and `HTML` stay
|
|
32
|
+
// pointed at the government page for the two assertions that are genuinely about
|
|
33
|
+
// what STAYED there — the Constitution's own membership sentence, and the short
|
|
34
|
+
// pass-rule name its change-history line reads.
|
|
29
35
|
const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
30
36
|
const HTML = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.html'), 'utf8');
|
|
37
|
+
const BOARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
|
|
38
|
+
const BOARD_HTML = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.html'), 'utf8');
|
|
31
39
|
const { PASS_RULES } = require(path.join(ROOT, 'modules', 'government', 'config.js'));
|
|
32
40
|
|
|
33
|
-
const card = () =>
|
|
41
|
+
const card = () => BOARD.slice(BOARD.indexOf('function itemCard'), BOARD.indexOf('async function loadBoard'));
|
|
34
42
|
|
|
35
43
|
// Deleted copy is asserted against the RENDERING source only. The comments in
|
|
36
|
-
//
|
|
44
|
+
// board-room.js deliberately quote what was removed and why — that history is
|
|
37
45
|
// the point of them, and a test that forbade it would push the record out of
|
|
38
46
|
// the file it explains.
|
|
39
|
-
const CODE =
|
|
47
|
+
const CODE = BOARD.split(/\r?\n/).filter((l) => !/^\s*\/\//.test(l)).join('\n');
|
|
40
48
|
|
|
41
49
|
test('every pass rule the server can hold has a plain-language sentence', () => {
|
|
42
|
-
const block =
|
|
50
|
+
const block = BOARD.slice(BOARD.indexOf('const PASS_RULE_PLAIN'), BOARD.indexOf('function decidedByHtml'));
|
|
43
51
|
assert.ok(PASS_RULES.length >= 3, `sanity: found the real rule set (${PASS_RULES})`);
|
|
44
52
|
for (const rule of PASS_RULES) {
|
|
45
53
|
assert.ok(block.includes(`${rule}:`), `PASS_RULE_PLAIN must explain '${rule}' — a rule with no copy renders its enum key`);
|
|
@@ -52,14 +60,23 @@ test('every pass rule the server can hold has a plain-language sentence', () =>
|
|
|
52
60
|
});
|
|
53
61
|
|
|
54
62
|
test('the short name map covers the same rule set (the history line)', () => {
|
|
55
|
-
|
|
63
|
+
// Its reader (historyRow, the amendment change-history) stayed on the
|
|
64
|
+
// government page when ADR 0266 carved the room out, so this map stayed with
|
|
65
|
+
// it. The slice therefore ends at a landmark that is still IN government.js —
|
|
66
|
+
// it used to end at `const PASS_RULE_PLAIN`, which has moved to board-room.js,
|
|
67
|
+
// and an indexOf that misses returns -1: `slice(start, -1)` would have handed
|
|
68
|
+
// this assertion most of the file and passed for the wrong reason.
|
|
69
|
+
const start = CLIENT.indexOf('const PASS_RULE_NAME');
|
|
70
|
+
const end = CLIENT.indexOf('function dialRow');
|
|
71
|
+
assert.ok(start !== -1 && end > start, 'PASS_RULE_NAME sits above dialRow in government.js');
|
|
72
|
+
const block = CLIENT.slice(start, end);
|
|
56
73
|
for (const rule of PASS_RULES) {
|
|
57
74
|
assert.ok(block.includes(`${rule}:`), `PASS_RULE_NAME must name '${rule}'`);
|
|
58
75
|
}
|
|
59
76
|
});
|
|
60
77
|
|
|
61
78
|
test('a rank reaches the reader as a LABEL, never as the enum key', () => {
|
|
62
|
-
assert.match(
|
|
79
|
+
assert.match(BOARD, /function rankNames\(keys\)[\s\S]*?window\.OTB\.rankLabel\(k\)/,
|
|
63
80
|
'the board card resolves rank keys through the branding pack');
|
|
64
81
|
const membership = CLIENT.slice(CLIENT.indexOf('function membershipSentence'), CLIENT.indexOf('function passRuleSentence'));
|
|
65
82
|
assert.match(membership, /window\.OTB\.rankLabel\(k\)/,
|
|
@@ -84,7 +101,7 @@ test('the card order is title → action → the idea → the apparatus', () =>
|
|
|
84
101
|
});
|
|
85
102
|
|
|
86
103
|
test('the idea renders OPEN — never behind a summary', () => {
|
|
87
|
-
const sections =
|
|
104
|
+
const sections = BOARD.slice(BOARD.indexOf('function ideaSectionsHtml'), BOARD.indexOf('function rankNames'));
|
|
88
105
|
assert.ok(!/<details/.test(sections),
|
|
89
106
|
'the substance of the vote is not a disclosure — that was the reported bug');
|
|
90
107
|
assert.match(sections, /gov-idea__body/, 'and it is set as prose, not as a config row');
|
|
@@ -99,12 +116,12 @@ test('the tally stays visible without a click (ADR 0175 §9)', () => {
|
|
|
99
116
|
test('the self-explaining copy is DELETED, not reworded', () => {
|
|
100
117
|
assert.ok(!/Casting arrives with the vote form/.test(CODE),
|
|
101
118
|
'the sentence that explained the UI to itself — and the form it promised has shipped');
|
|
102
|
-
assert.ok(!/tally: \$\{yes\} yes/.test(
|
|
119
|
+
assert.ok(!/tally: \$\{yes\} yes/.test(BOARD), 'the raw-config tally line is gone');
|
|
103
120
|
assert.ok(!/The idea, in full/.test(CODE), 'so is the summary that hid the idea');
|
|
104
121
|
});
|
|
105
122
|
|
|
106
123
|
test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
|
|
107
|
-
const ballot =
|
|
124
|
+
const ballot = BOARD.slice(BOARD.indexOf('function ballotHtml'), BOARD.indexOf('// ---- the vote form'));
|
|
108
125
|
assert.match(ballot, /SECTION_LABELS\[v\.section_key\]/,
|
|
109
126
|
'`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
|
|
110
127
|
});
|
|
@@ -112,18 +129,27 @@ test('the section a voter faults is shown by NAME, in the ballot and the picker'
|
|
|
112
129
|
test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
|
|
113
130
|
// The requirement simplifying may not weaken: it is why voting lives in the
|
|
114
131
|
// hall instead of on a chat reaction.
|
|
115
|
-
const form =
|
|
132
|
+
const form = BOARD.slice(BOARD.indexOf('function voteFormHtml'), BOARD.indexOf('async function castVoteFromRoom'));
|
|
116
133
|
assert.match(form, /data-vote-reason/, 'the reason box survives');
|
|
117
134
|
assert.match(form, /gov-vote__sections/, 'so does the five-way section picker');
|
|
118
135
|
assert.match(form, /Object\.keys\(SECTION_LABELS\)\.map/, 'with one radio per declared section');
|
|
119
136
|
});
|
|
120
137
|
|
|
121
|
-
test('
|
|
138
|
+
test('each room frames itself in one line, not a paragraph', () => {
|
|
122
139
|
// The two intros used to run ~60 and ~50 words before anything actionable.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
//
|
|
141
|
+
// They now live on two pages (ADR 0266), and the Board Room's heading is its
|
|
142
|
+
// page's own <h1> rather than a panel <h2> — a second "Board Room" heading
|
|
143
|
+
// under the page head would be the copy-that-explains-the-UI-to-itself this
|
|
144
|
+
// file exists to keep out. So the landmark differs per room; the RULE does not.
|
|
145
|
+
const rooms = [
|
|
146
|
+
{ heading: 'Constitution', src: HTML, at: '<h2 class="gov-h">Constitution</h2>' },
|
|
147
|
+
{ heading: 'Board Room', src: BOARD_HTML, at: '<h1 class="page-head__h">Board Room</h1>' },
|
|
148
|
+
];
|
|
149
|
+
for (const { heading, src, at } of rooms) {
|
|
150
|
+
const i = src.indexOf(at);
|
|
151
|
+
assert.notEqual(i, -1, `${heading} heading missing (${at})`);
|
|
152
|
+
const note = /<p class="gov-grid-note">([\s\S]*?)<\/p>/.exec(src.slice(i));
|
|
127
153
|
assert.ok(note, `${heading} has no framing line`);
|
|
128
154
|
const words = note[1].replace(/<[^>]+>/g, ' ').trim().split(/\s+/).length;
|
|
129
155
|
assert.ok(words <= 30, `${heading}'s intro is ${words} words — a reader meets it before anything actionable`);
|
|
@@ -16,7 +16,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
-
|
|
19
|
+
// board-room.js since ADR 0266, not government.js: every assertion in this file
|
|
20
|
+
// is about the sitting card, the ballot and the vote form, and all three moved
|
|
21
|
+
// with the room onto its own page. The name stays `CLIENT` because that is what
|
|
22
|
+
// each assertion below reads — this file has exactly one source.
|
|
23
|
+
const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
|
|
20
24
|
const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
21
25
|
const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
|
|
22
26
|
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -177,7 +177,11 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
177
177
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
178
178
|
// 'project-settings' joins the Government group at the metic floor (task 1003280,
|
|
179
179
|
// goal 1000072) — it sits before 'government', which is the one archon-only item.
|
|
180
|
-
|
|
180
|
+
// 'board-room' leads the group since ADR 0266: it is the one page here you ACT
|
|
181
|
+
// on (a sitting waits on a decision; the rest is monitoring), the same ordering
|
|
182
|
+
// rule the Community group's first item follows. Before that carve it had no
|
|
183
|
+
// item at all — it was a tab behind the one labelled "Permissions".
|
|
184
|
+
assert.deepEqual(nav[4].items.map((i) => i.id), ['board-room', 'watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
|
|
181
185
|
});
|
|
182
186
|
|
|
183
187
|
test('Economy lives in Docs and points at the reward-schedule page', () => {
|
|
@@ -38,6 +38,7 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
|
|
|
38
38
|
// requireGovernmentPage page.view.government metic+ (ADR 0157)
|
|
39
39
|
// requireProjectCuratePage project.curate metic+
|
|
40
40
|
// requireGovernmentManagePage government.manage archon
|
|
41
|
+
// requireBoardVotePage board.vote.cast xenos+ (ADR 0266)
|
|
41
42
|
const ORACLE = {
|
|
42
43
|
// Any signed-in builder — no rank floor.
|
|
43
44
|
'/builders/work': 'requireBuilderPage',
|
|
@@ -93,6 +94,16 @@ const ORACLE = {
|
|
|
93
94
|
// tab seals itself for non-archons.
|
|
94
95
|
'/builders/government': 'requireGovernmentPage',
|
|
95
96
|
|
|
97
|
+
// ADR 0266: the Board Room, carved out of the tab strip above onto its own
|
|
98
|
+
// page. It is the ONE hall page whose gate is deliberately WIDER than the
|
|
99
|
+
// group it sits in — `board.vote.cast` floors at xenos, on purpose, so that a
|
|
100
|
+
// constitution widening its board below Metic gets members who can reach the
|
|
101
|
+
// room rather than members who can only vote by curl. The floor is the coarse
|
|
102
|
+
// gate; membership is checked in-handler per item against that item's own
|
|
103
|
+
// snapshotted constitution, so a holder who sits on no board sees the room and
|
|
104
|
+
// no ballot (ADR 0175 §9 made the ballot open on purpose).
|
|
105
|
+
'/builders/board-room': 'requireBoardVotePage',
|
|
106
|
+
|
|
96
107
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
97
108
|
// schedule and a builder's public portfolio all render for a stranger.
|
|
98
109
|
//
|
package/tests/hall_palette.mjs
CHANGED
|
@@ -113,11 +113,17 @@ test('Tasks tab anchors exist in work.js makeTabs', () => {
|
|
|
113
113
|
}
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
// TWO rooms since ADR 0266 (was three): the Board Room became its own page at
|
|
117
|
+
// /board-room, so `#board-room` is no longer a tab id here and the palette must
|
|
118
|
+
// not declare it as a sub-destination — it is read live from the sidebar like
|
|
119
|
+
// every other page. The counts stay exact rather than becoming `>= 2`, because
|
|
120
|
+
// the whole point of this test is that a tab and its jump cannot drift apart.
|
|
121
|
+
test('Government section anchors exist in government.js makeTabs', () => {
|
|
117
122
|
const ids = [...read('government.js').matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
118
|
-
assert.ok(ids.length ===
|
|
123
|
+
assert.ok(ids.length === 2, `sanity: found the two government tabs (${ids})`);
|
|
124
|
+
assert.ok(!ids.includes('board-room'), 'the Board Room is a page, not a tab (ADR 0266)');
|
|
119
125
|
const declared = declaredHrefs((x) => x.startsWith('/government#'));
|
|
120
|
-
assert.ok(declared.length ===
|
|
126
|
+
assert.ok(declared.length === 2, 'palette declares the two Government sections');
|
|
121
127
|
for (const h of declared) {
|
|
122
128
|
assert.ok(ids.includes(h.split('#')[1]), `${h} must be a real government.js tab id`);
|
|
123
129
|
}
|
|
@@ -116,6 +116,14 @@ const STATIC_LADDER_FILES = [
|
|
|
116
116
|
// which holds it against catalog.RANK_ORDER and holds the whole browser parser
|
|
117
117
|
// equal to the server one.
|
|
118
118
|
'modules/hall-ui/public/government.js',
|
|
119
|
+
// ADR 0266: the Board Room moved off the government page onto its own, and the
|
|
120
|
+
// sitting card's `decidedByHtml` expands the same `rank:<key>+` membership form
|
|
121
|
+
// to say who may vote on THAT sitting — so the carve took a copy of the ladder
|
|
122
|
+
// with it. Same reason as the row above (browser code cannot require the server
|
|
123
|
+
// module), and the same pinning: `tests/government_board_vote_ui.mjs` reads
|
|
124
|
+
// board-room.js and holds its predicate parser equal to the server's, and the
|
|
125
|
+
// pin below in this file holds the array itself against LIVE_RANK_LADDER.
|
|
126
|
+
'modules/hall-ui/public/board-room.js',
|
|
119
127
|
// task 1002487: the pure agent-definition validator applies the ADR 0043
|
|
120
128
|
// sub-Metic floor to an agent's declared scope, so it needs the ladder to
|
|
121
129
|
// compare against. It is one of the "dependency-free" cases this array exists
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// tests/session_earnings_mirror_db.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003634 — the session earnings mirror, against a REAL Postgres.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS BEING PROVEN, and why a fake cannot prove it. The defect was never in
|
|
6
|
+
// JavaScript arithmetic; it was in which credit_log rows a WHERE clause can
|
|
7
|
+
// reach. credit_log pays a builder by two different keys — the per-task streams
|
|
8
|
+
// key on `task_id` and leave session_id NULL, the cost-plus session reward
|
|
9
|
+
// (ADR 0054) keys on `session_id` and leaves task_id NULL — and the mirror
|
|
10
|
+
// summed only the first. A stubbed client would answer whatever the stub was
|
|
11
|
+
// written to answer, which is exactly the assumption under test. So the SQL has
|
|
12
|
+
// to meet a real planner over real columns.
|
|
13
|
+
//
|
|
14
|
+
// NON-DESTRUCTIVE BY CONSTRUCTION: every fixture and credit row is written inside
|
|
15
|
+
// ONE transaction that is ALWAYS rolled back. It never TRUNCATEs, never commits,
|
|
16
|
+
// and is safe to point at a dev database holding real rows. (The precedent and
|
|
17
|
+
// the schema-driven insertRow helper are tests/idea_credit_streams_db.mjs's.)
|
|
18
|
+
//
|
|
19
|
+
// Real-DB (self-skips without Postgres; belongs in the INTEGRATION set).
|
|
20
|
+
//
|
|
21
|
+
// Run: DATABASE_URL=postgres://... node tests/session_earnings_mirror_db.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { Pool } = require('pg');
|
|
28
|
+
const credits = require('../modules/economy/credits.js');
|
|
29
|
+
|
|
30
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
// Each test runs inside its own SAVEPOINT: a failure rolls back to it, so one
|
|
35
|
+
// broken assertion cannot abort the transaction and cascade into every test
|
|
36
|
+
// after it.
|
|
37
|
+
let client;
|
|
38
|
+
async function test(name, fn) {
|
|
39
|
+
await client.query('SAVEPOINT t');
|
|
40
|
+
try {
|
|
41
|
+
await fn();
|
|
42
|
+
passed++; console.log(` ok ${name}`);
|
|
43
|
+
await client.query('RELEASE SAVEPOINT t');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
failed++; console.error(` FAIL ${name}\n ${err.message}`);
|
|
46
|
+
await client.query('ROLLBACK TO SAVEPOINT t');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const schemaCache = new Map();
|
|
51
|
+
async function columnsOf(client, table) {
|
|
52
|
+
if (schemaCache.has(table)) return schemaCache.get(table);
|
|
53
|
+
const { rows } = await client.query(
|
|
54
|
+
`SELECT column_name, data_type, is_nullable, column_default, is_identity
|
|
55
|
+
FROM information_schema.columns
|
|
56
|
+
WHERE table_schema = 'public' AND table_name = $1`,
|
|
57
|
+
[table]
|
|
58
|
+
);
|
|
59
|
+
schemaCache.set(table, rows);
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function placeholderFor(dataType, columnName) {
|
|
64
|
+
switch (dataType) {
|
|
65
|
+
case 'integer': case 'bigint': case 'smallint':
|
|
66
|
+
case 'numeric': case 'real': case 'double precision':
|
|
67
|
+
return 0;
|
|
68
|
+
case 'boolean': return false;
|
|
69
|
+
case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
|
|
70
|
+
return new Date();
|
|
71
|
+
case 'json': case 'jsonb': return {};
|
|
72
|
+
case 'ARRAY': return [];
|
|
73
|
+
default: return `sem-${columnName}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Identifiers are interpolated because SQL cannot parameterise them. Both are
|
|
78
|
+
// trusted by construction: `table` is a literal from this file, and the column
|
|
79
|
+
// names come from information_schema for that table — never from input.
|
|
80
|
+
async function insertRow(client, table, explicit = {}) {
|
|
81
|
+
const cols = await columnsOf(client, table);
|
|
82
|
+
if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
|
|
83
|
+
const values = { ...explicit };
|
|
84
|
+
for (const c of cols) {
|
|
85
|
+
if (c.column_name in values) continue;
|
|
86
|
+
if (c.is_nullable === 'YES') continue;
|
|
87
|
+
if (c.column_default !== null) continue;
|
|
88
|
+
if (c.is_identity === 'YES') continue;
|
|
89
|
+
values[c.column_name] = placeholderFor(c.data_type, c.column_name);
|
|
90
|
+
}
|
|
91
|
+
const names = Object.keys(values);
|
|
92
|
+
const params = names.map((_, i) => `$${i + 1}`);
|
|
93
|
+
const { rows } = await client.query(
|
|
94
|
+
`INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
|
|
95
|
+
VALUES (${params.join(', ')}) RETURNING *`,
|
|
96
|
+
names.map((n) => values[n])
|
|
97
|
+
);
|
|
98
|
+
return rows[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The query the mirror used BEFORE this fix — sumCreditsForTasks's predicate,
|
|
102
|
+
// inlined so the bug itself is asserted rather than described. It cannot use
|
|
103
|
+
// credits.sumCreditsForTasks() directly: that reads the module's own pool and so
|
|
104
|
+
// cannot see this transaction's uncommitted rows.
|
|
105
|
+
async function taskKeyedSumOnly(client, builderId, taskIds) {
|
|
106
|
+
const { rows } = await client.query(
|
|
107
|
+
`SELECT COALESCE(SUM(delta), 0)::int AS total
|
|
108
|
+
FROM credit_log WHERE builder_id = $1 AND task_id = ANY($2::bigint[])`,
|
|
109
|
+
[builderId, taskIds]
|
|
110
|
+
);
|
|
111
|
+
return rows[0].total;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// One builder, one shipped task, one session_record linking them.
|
|
115
|
+
async function fixture(client) {
|
|
116
|
+
const tag = `sem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
const builder = await insertRow(client, 'builders', {
|
|
118
|
+
github_id: `${tag}-gh`,
|
|
119
|
+
github_login: tag,
|
|
120
|
+
display_name: tag,
|
|
121
|
+
total_credits: 0,
|
|
122
|
+
});
|
|
123
|
+
const version = await insertRow(client, 'versions', { id: `${tag}-v`, status: 'building' });
|
|
124
|
+
const task = await insertRow(client, 'tasks', {
|
|
125
|
+
title: `${tag} task`,
|
|
126
|
+
status: 'shipped',
|
|
127
|
+
version_id: version.id,
|
|
128
|
+
});
|
|
129
|
+
const sessionId = `${tag}-session`;
|
|
130
|
+
await insertRow(client, 'session_records', {
|
|
131
|
+
session_id: sessionId,
|
|
132
|
+
builder_id: builder.id,
|
|
133
|
+
task_ids: [task.id],
|
|
134
|
+
drachmae_earned: 0,
|
|
135
|
+
total_tokens: 7823777,
|
|
136
|
+
});
|
|
137
|
+
return { builder, task, sessionId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Book a cost-plus session reward the way credits.js does: keyed on session_id,
|
|
141
|
+
// with task_id deliberately NULL.
|
|
142
|
+
function sessionReward(client, builderId, sessionId, delta, basis) {
|
|
143
|
+
return client.query(
|
|
144
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, description, session_id, reward_basis)
|
|
145
|
+
VALUES ($1, NULL, $2, 'session.token_reward', $3, $4, $5)`,
|
|
146
|
+
[builderId, delta, `$${delta} test`, sessionId, basis]
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main() {
|
|
151
|
+
try {
|
|
152
|
+
client = await pool.connect();
|
|
153
|
+
} catch {
|
|
154
|
+
console.log('session_earnings_mirror_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await client.query('BEGIN');
|
|
159
|
+
try {
|
|
160
|
+
// 1. THE BUG, executable. A session paid purely on cost-plus reads as zero
|
|
161
|
+
// through the old predicate — the exact state a builder reported as
|
|
162
|
+
// missing pay while the ledger showed them paid.
|
|
163
|
+
await test('the task-keyed sum alone cannot see a cost-plus session reward (the bug)', async () => {
|
|
164
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
165
|
+
await sessionReward(client, builder.id, sessionId, 7, 581);
|
|
166
|
+
|
|
167
|
+
assert.equal(await taskKeyedSumOnly(client, builder.id, [task.id]), 0,
|
|
168
|
+
'the old predicate should report 0 — this is the defect being fixed');
|
|
169
|
+
assert.equal(
|
|
170
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 7,
|
|
171
|
+
'the two-key sum must find the 7 drachmae actually paid');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 2. Neither key may be lost when both streams pay the same session.
|
|
175
|
+
await test('both attribution keys are summed together', async () => {
|
|
176
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
177
|
+
await sessionReward(client, builder.id, sessionId, 7, 581);
|
|
178
|
+
await client.query(
|
|
179
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
|
|
180
|
+
VALUES ($1, $2, 60, 'task.ship', NULL)`,
|
|
181
|
+
[builder.id, task.id]
|
|
182
|
+
);
|
|
183
|
+
assert.equal(
|
|
184
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 67,
|
|
185
|
+
'a session paid on both streams must total both');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 3. The OR is one predicate over one scan. A row carrying BOTH keys must be
|
|
189
|
+
// summed once — if it were summed per-key, every fix would over-report.
|
|
190
|
+
await test('a row carrying both keys is counted once, not twice', async () => {
|
|
191
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
192
|
+
await client.query(
|
|
193
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
|
|
194
|
+
VALUES ($1, $2, 5, 'idea.credit.task_ship:1:2', $3)`,
|
|
195
|
+
[builder.id, task.id, sessionId]
|
|
196
|
+
);
|
|
197
|
+
assert.equal(
|
|
198
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 5,
|
|
199
|
+
'the doubly-keyed row must contribute 5, not 10');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 4. The figure is SET from ledger state, so re-deriving is a no-op. This is
|
|
203
|
+
// what lets every pay-on-land trigger call the sync and lets a re-attempt
|
|
204
|
+
// repair a stale row instead of inflating a correct one.
|
|
205
|
+
await test('re-deriving is idempotent across a top-up', async () => {
|
|
206
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
207
|
+
await sessionReward(client, builder.id, sessionId, 18, 1496);
|
|
208
|
+
const first = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
|
|
209
|
+
const again = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
|
|
210
|
+
assert.equal(first, 18);
|
|
211
|
+
assert.equal(again, 18, 'the same ledger state must yield the same figure');
|
|
212
|
+
|
|
213
|
+
// A second ship in the same session tops the reward up at a higher basis.
|
|
214
|
+
await sessionReward(client, builder.id, sessionId, 35, 4403);
|
|
215
|
+
assert.equal(
|
|
216
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 53,
|
|
217
|
+
'a top-up must be reflected in full, not added to a stale total');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 5. Degenerate inputs must not become an unfiltered scan of the ledger.
|
|
221
|
+
await test('no keys means zero, never an unscoped sum', async () => {
|
|
222
|
+
const { builder, sessionId } = await fixture(client);
|
|
223
|
+
await sessionReward(client, builder.id, sessionId, 9, 750);
|
|
224
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, [], null), 0);
|
|
225
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, null, null), 0);
|
|
226
|
+
// A session key alone is enough — the cost-plus-only case.
|
|
227
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, [], sessionId), 9);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 6. Another builder's rows are never reachable, whichever key matches.
|
|
231
|
+
await test('the sum stays scoped to one builder', async () => {
|
|
232
|
+
const a = await fixture(client);
|
|
233
|
+
const b = await fixture(client);
|
|
234
|
+
await sessionReward(client, a.builder.id, a.sessionId, 7, 581);
|
|
235
|
+
await sessionReward(client, b.builder.id, b.sessionId, 99, 8250);
|
|
236
|
+
assert.equal(
|
|
237
|
+
await credits.sumSessionEarnings(client, a.builder.id, [a.task.id, b.task.id], a.sessionId), 7,
|
|
238
|
+
"another builder's credits must not leak in via a shared task id");
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
await client.query('ROLLBACK');
|
|
242
|
+
client.release();
|
|
243
|
+
await pool.end();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`\nsession_earnings_mirror_db: ${passed} passed, ${failed} failed`);
|
|
247
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|