@bongos/core 1.21.35 → 1.21.37
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 +100 -80
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/api/openapi.json +25 -35
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +8 -8
- package/docs/copy-registry.json +8 -8
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +569 -523
- package/modules/autonomy/matter-acts.js +41 -0
- package/modules/autonomy/routes/autonomy.js +2 -0
- package/modules/government/board.js +4 -0
- package/modules/government/db.js +14 -0
- package/modules/government/decision-board.js +90 -11
- package/modules/government/docket-vocab.js +5 -0
- package/modules/government/docket.js +11 -0
- package/modules/government/routes/government.js +2 -1
- package/modules/hall-ui/public/atlas.html +3 -3
- package/modules/hall-ui/public/blockers.html +3 -3
- package/modules/hall-ui/public/board-room.html +3 -3
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/collab.html +3 -3
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/craft-mode.js +44 -6
- package/modules/hall-ui/public/deploy.html +3 -3
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/drachmae.html +3 -3
- package/modules/hall-ui/public/fleet.html +3 -3
- package/modules/hall-ui/public/gate.html +3 -3
- package/modules/hall-ui/public/goal-map.html +3 -3
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/government.html +3 -3
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/modules.html +3 -3
- package/modules/hall-ui/public/primer.html +3 -3
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/project-settings.html +3 -3
- package/modules/hall-ui/public/ranks.html +3 -3
- package/modules/hall-ui/public/roadmap.html +3 -3
- package/modules/hall-ui/public/roster.html +3 -3
- package/modules/hall-ui/public/sessions.html +3 -3
- package/modules/hall-ui/public/settings.html +3 -3
- package/modules/hall-ui/public/street-card.js +62 -2
- package/modules/hall-ui/public/street-desk.js +101 -8
- package/modules/hall-ui/public/street-inside.js +6 -3
- package/modules/hall-ui/public/street.css +15 -0
- package/modules/hall-ui/public/street.html +3 -3
- package/modules/hall-ui/public/street.js +73 -4
- package/modules/hall-ui/public/street.states.json +124 -0
- package/modules/hall-ui/public/studio.html +3 -3
- package/modules/hall-ui/public/style.css +121 -2
- package/modules/hall-ui/public/task.html +3 -3
- package/modules/hall-ui/public/thinking.html +3 -3
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/watch.html +3 -3
- package/modules/hall-ui/public/work.html +3 -3
- package/modules/hall-ui/records/craft-mode.md +8 -0
- package/modules/ideas/governor-docket.js +91 -0
- package/modules/lifecycle/governor-docket.js +69 -0
- package/modules/lifecycle/matter-acts.js +63 -0
- package/modules/lifecycle/routes/done-when.js +26 -12
- package/modules/lifecycle/routes/gate-approvals.js +74 -59
- package/modules/lifecycle/routes/lifecycle.js +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/route-rank-check.js +2 -0
- package/src/bongos/routes/builders.js +56 -1
- package/src/module-api.js +1 -1
- package/tests/craft_mode_looks.mjs +170 -0
- package/tests/craft_mode_nav.mjs +7 -5
- package/tests/governor_matter_acts.mjs +241 -0
- package/tests/hall_street_desk.mjs +244 -4
- package/tests/hall_street_inside.mjs +3 -3
- package/tests/hall_tweak_editor.mjs +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// tests/craft_mode_looks.mjs — each craft mode's look (task 1004495, goal 1000095
|
|
2
|
+
// WA6; direction docs/design/craft-mode-direction.md).
|
|
3
|
+
//
|
|
4
|
+
// Artistry, Ideating and Governing each wear a look: --craft / --craft-on tokens
|
|
5
|
+
// (light + dark twins) in style.css, the header eyebrow beside the page title and
|
|
6
|
+
// the door's scene framed under the brand. Building, Legacy and no mode wear
|
|
7
|
+
// NOTHING — the hall as it always was. These cases run the REAL craft-mode.js over
|
|
8
|
+
// a small DOM and read the REAL style.css, so a look that leaks into Building, a
|
|
9
|
+
// plate that is a second copy of the art, or a colour that loses its contrast reds.
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import vm from 'node:vm';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
19
|
+
const CRAFT = fs.readFileSync(path.join(HALL, 'craft-mode.js'), 'utf8');
|
|
20
|
+
const CSS = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
|
|
21
|
+
|
|
22
|
+
const LOOKED = ['artistry', 'ideating', 'governing'];
|
|
23
|
+
const WORDS = { artistry: 'the studio · night', ideating: 'the sky', governing: 'the city' };
|
|
24
|
+
|
|
25
|
+
// A small DOM: enough tree for insertBefore / removeChild / nextSibling to be real.
|
|
26
|
+
function node(tag, id, cls) {
|
|
27
|
+
const attrs = {};
|
|
28
|
+
const el = {
|
|
29
|
+
tagName: tag, id: id || '', className: cls || '', dataset: {}, children: [], parentNode: null,
|
|
30
|
+
textContent: '', innerHTML: '',
|
|
31
|
+
setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
32
|
+
hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
|
|
33
|
+
get nextSibling() { const sib = this.parentNode && this.parentNode.children; return sib ? sib[sib.indexOf(this) + 1] || null : null; },
|
|
34
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
|
|
35
|
+
insertBefore(c, ref) {
|
|
36
|
+
c.parentNode = this;
|
|
37
|
+
const at = ref ? this.children.indexOf(ref) : -1;
|
|
38
|
+
if (at === -1) this.children.push(c); else this.children.splice(at, 0, c);
|
|
39
|
+
return c;
|
|
40
|
+
},
|
|
41
|
+
removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
|
|
42
|
+
querySelector(sel) { return all(this).find((n) => sel === '.' + n.className) || null; },
|
|
43
|
+
querySelectorAll() { return []; },
|
|
44
|
+
addEventListener() {}, classList: { toggle() {} },
|
|
45
|
+
};
|
|
46
|
+
return el;
|
|
47
|
+
}
|
|
48
|
+
function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
|
|
49
|
+
|
|
50
|
+
function boot(session = {}) {
|
|
51
|
+
const sidebar = node('NAV', 'app-sidebar');
|
|
52
|
+
const brand = sidebar.appendChild(node('DIV', '', 'app-sidebar__brand'));
|
|
53
|
+
sidebar.appendChild(node('NAV', '', 'app-sidebar__nav'));
|
|
54
|
+
const topbar = node('DIV', 'app-topbar');
|
|
55
|
+
topbar.appendChild(node('BUTTON', 'topbar-menu'));
|
|
56
|
+
const title = topbar.appendChild(node('H2', 'topbar-title'));
|
|
57
|
+
topbar.appendChild(node('DIV', '', 'topbar-side'));
|
|
58
|
+
const root = node('HTML');
|
|
59
|
+
const body = node('BODY');
|
|
60
|
+
const document = {
|
|
61
|
+
documentElement: root, body, activeElement: null,
|
|
62
|
+
getElementById: (id) => [sidebar, topbar, ...all(sidebar), ...all(topbar)].find((n) => n.id === id) || null,
|
|
63
|
+
querySelectorAll: () => [],
|
|
64
|
+
createElement: (t) => node(t.toUpperCase()),
|
|
65
|
+
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
|
|
66
|
+
};
|
|
67
|
+
const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
|
|
68
|
+
const window = { localStorage: mk({}), sessionStorage: mk(session), matchMedia: () => ({ matches: false }) };
|
|
69
|
+
const sandbox = { window, document, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
|
|
70
|
+
vm.createContext(sandbox);
|
|
71
|
+
vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
|
|
72
|
+
const craft = window.OTBCraftMode;
|
|
73
|
+
craft.mount();
|
|
74
|
+
return {
|
|
75
|
+
craft, root, sidebar, brand, topbar, title,
|
|
76
|
+
plates: () => all(sidebar).filter((n) => n.id === 'craft-plate'),
|
|
77
|
+
marks: () => all(topbar).filter((n) => n.id === 'craft-eyebrow'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('each looked mode frames its own door scene under the brand, and names itself beside the title', () => {
|
|
82
|
+
const p = boot();
|
|
83
|
+
for (const m of LOOKED) {
|
|
84
|
+
p.craft.choose(m);
|
|
85
|
+
assert.equal(p.plates().length, 1, `${m}: exactly one plate`);
|
|
86
|
+
const plate = p.plates()[0];
|
|
87
|
+
assert.equal(p.brand.nextSibling, plate, `${m}: the plate sits right under the brand`);
|
|
88
|
+
assert.equal(plate.getAttribute('data-plate'), m);
|
|
89
|
+
assert.equal(plate.getAttribute('aria-hidden'), 'true', 'the plate is decoration');
|
|
90
|
+
assert.equal(plate.innerHTML, p.craft.artHtml(m), `${m}: the plate IS the picker's door art, not a second copy`);
|
|
91
|
+
assert.equal(p.marks().length, 1, `${m}: exactly one eyebrow`);
|
|
92
|
+
assert.equal(p.title.nextSibling, p.marks()[0], `${m}: the eyebrow sits right after the page title`);
|
|
93
|
+
assert.equal(p.marks()[0].textContent, WORDS[m], `${m}: in the prototype's own words`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('BUILDING, LEGACY AND NO MODE WEAR NO LOOK: no plate, no eyebrow, and switching to them takes the look away', () => {
|
|
98
|
+
const none = boot();
|
|
99
|
+
assert.equal(none.root.dataset.craft, undefined);
|
|
100
|
+
assert.equal(none.plates().length + none.marks().length, 0, 'no mode draws nothing');
|
|
101
|
+
for (const m of ['building', 'legacy']) {
|
|
102
|
+
const p = boot({ 'hall-craft': m });
|
|
103
|
+
assert.equal(p.plates().length + p.marks().length, 0, `${m} at boot draws nothing`);
|
|
104
|
+
p.craft.choose('governing');
|
|
105
|
+
assert.equal(p.plates().length, 1);
|
|
106
|
+
p.craft.choose(m);
|
|
107
|
+
assert.equal(p.plates().length + p.marks().length, 0, `switching to ${m} removes the plate and the eyebrow`);
|
|
108
|
+
}
|
|
109
|
+
assert.deepEqual(Object.keys(none.craft.LOOKS).sort(), [...LOOKED].sort(), 'only the three crafts have a look');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the art is drawn once: craft-mode.js has one artHtml and exports it', () => {
|
|
113
|
+
assert.equal(CRAFT.match(/function artHtml\(/g).length, 1);
|
|
114
|
+
assert.match(CRAFT, /artHtml: artHtml,/);
|
|
115
|
+
assert.match(CRAFT, /plate\.innerHTML = artHtml\(mode\);/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Every selector in style.css that names a craft mode.
|
|
119
|
+
const CRAFT_SELECTORS = [...CSS.matchAll(/data-craft="([a-z]+)"/g)].map((m) => m[1]);
|
|
120
|
+
|
|
121
|
+
test('style.css names only the three looked modes — Building and Legacy have no rule at all', () => {
|
|
122
|
+
assert.ok(CRAFT_SELECTORS.length >= 12, `found ${CRAFT_SELECTORS.length} craft selectors`);
|
|
123
|
+
for (const m of CRAFT_SELECTORS) assert.ok(LOOKED.includes(m), `a rule names data-craft="${m}"`);
|
|
124
|
+
assert.doesNotMatch(CSS, /data-craft="(building|legacy)"/);
|
|
125
|
+
assert.doesNotMatch(CSS, /data-craft\]/, 'no rule matches ANY mode, which would dress Building too');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The token values, light and dark, read from the stylesheet itself.
|
|
129
|
+
function tokens(selector) {
|
|
130
|
+
const at = CSS.indexOf(selector + ' {');
|
|
131
|
+
assert.ok(at !== -1, `style.css has a ${selector} block`);
|
|
132
|
+
const block = CSS.slice(at, CSS.indexOf('}', at));
|
|
133
|
+
const get = (name) => (block.match(new RegExp(name + ':\\s*(#[0-9a-f]{6})')) || [])[1];
|
|
134
|
+
return { craft: get('--craft'), on: get('--craft-on'), bg: get('--bg') };
|
|
135
|
+
}
|
|
136
|
+
function lum(hex) {
|
|
137
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
138
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
139
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
140
|
+
}
|
|
141
|
+
function contrast(a, b) { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }
|
|
142
|
+
|
|
143
|
+
test('every mode colour has a light and a dark twin that keep their contrast', () => {
|
|
144
|
+
const LIGHT_BG = '#edecee', DARK_BG = '#000000';
|
|
145
|
+
for (const m of LOOKED) {
|
|
146
|
+
const light = tokens(`html[data-craft="${m}"]`);
|
|
147
|
+
const dark = tokens(`html[data-theme="dark"][data-craft="${m}"]`);
|
|
148
|
+
for (const [k, t] of [['light', light], ['dark', dark]]) {
|
|
149
|
+
assert.ok(t.craft && t.on, `${m} ${k} sets --craft and --craft-on`);
|
|
150
|
+
}
|
|
151
|
+
assert.ok(contrast(light.craft, LIGHT_BG) >= 5.4, `${m} light --craft on paper: ${contrast(light.craft, LIGHT_BG).toFixed(2)}`);
|
|
152
|
+
assert.ok(contrast(dark.craft, dark.bg || DARK_BG) >= 5.4, `${m} dark --craft on its ground`);
|
|
153
|
+
assert.ok(contrast(light.on, light.craft) >= 5.8, `${m} light pill text: ${contrast(light.on, light.craft).toFixed(2)}`);
|
|
154
|
+
assert.ok(contrast(dark.on, dark.craft) >= 5.8, `${m} dark pill text`);
|
|
155
|
+
assert.equal(light.bg, undefined, `${m}: light keeps the hall's paper ground`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the shared treatment, the eyebrow and the plate are written as the direction says', () => {
|
|
160
|
+
const looked = 'html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing"])';
|
|
161
|
+
for (const target of ['.craft-switch__opt[aria-pressed="true"]', '.nav-item--active', '.nav-item--active::before', '.app-topbar']) {
|
|
162
|
+
assert.ok(CSS.includes(`${looked} ${target}`), `the looked modes restyle ${target}`);
|
|
163
|
+
}
|
|
164
|
+
assert.match(CSS, /@media \(max-width: 640px\) \{ \.craft-eyebrow \{ display: none; \} \}/, 'the eyebrow is hidden on a phone');
|
|
165
|
+
assert.match(CSS, /\.craft-plate \{[^}]*height: 40px;/, '40px in the closed rail');
|
|
166
|
+
assert.match(CSS, /\.app-sidebar:focus-within \.craft-plate \{ height: 64px;/, '64px when the rail opens');
|
|
167
|
+
assert.match(CSS, /html\[data-theme="light"\] \.craft-plate \.craft-art__city \{ filter: invert\(1\) hue-rotate\(180deg\); \}/,
|
|
168
|
+
'the light city is the dark one inverted, not a second drawing');
|
|
169
|
+
assert.equal(CSS.match(/prefers-reduced-motion/g).length, 1, 'no second reduced-motion block');
|
|
170
|
+
});
|
package/tests/craft_mode_nav.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Each craft mode shows only the tabs its craft needs; Legacy mode and no mode
|
|
5
5
|
// show every tab, exactly as before. The owner's rule (2026-10-01): some tabs
|
|
6
6
|
// stay in EVERY mode, Builders first among them, written down as ONE shared list
|
|
7
|
-
// that each mode's list adds to. These cases boot the REAL shell-trail.js +
|
|
7
|
+
// that each mode's list adds to; on 2026-10-03 Collab and Profile joined it. These cases boot the REAL shell-trail.js +
|
|
8
8
|
// craft-mode.js + shell.js and read the sidebar the shell actually rendered, so a
|
|
9
9
|
// tab id that drifts from NAV, or a gate pass that runs after the mode and
|
|
10
10
|
// re-shows a tab, reds here.
|
|
@@ -126,13 +126,15 @@ function boot({ session = {}, perms = { 'board.vote.cast': true } } = {}) {
|
|
|
126
126
|
|
|
127
127
|
const CRAFTS = ['building', 'artistry', 'ideating', 'governing'];
|
|
128
128
|
|
|
129
|
-
test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, inside every mode', () => {
|
|
129
|
+
test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, then Collab and Profile, inside every mode', () => {
|
|
130
130
|
const { craft } = boot();
|
|
131
|
-
assert.
|
|
131
|
+
assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile'],
|
|
132
|
+
'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03)');
|
|
132
133
|
for (const m of CRAFTS) {
|
|
133
134
|
const tabs = craft.tabsFor(m);
|
|
134
135
|
assert.ok(Array.isArray(tabs), `${m} has a named tab list`);
|
|
135
136
|
for (const id of craft.ALWAYS_TABS) assert.ok(tabs.includes(id), `${m} keeps ${id}`);
|
|
137
|
+
assert.equal(new Set(tabs).size, tabs.length, `${m} names no tab twice`);
|
|
136
138
|
}
|
|
137
139
|
assert.match(CRAFT, /return own \? ALWAYS_TABS\.concat\(own\) : null;/, 'each list is ALWAYS_TABS plus its own, not a copy of it');
|
|
138
140
|
});
|
|
@@ -173,7 +175,7 @@ test('each mode shows its own tabs, and switching updates the nav live', () => {
|
|
|
173
175
|
p.craft.choose(m);
|
|
174
176
|
assert.equal(p.root.dataset.craft, m);
|
|
175
177
|
assert.deepEqual(new Set(p.showing()), new Set(p.craft.tabsFor(m)), `${m} shows exactly its list`);
|
|
176
|
-
assert.ok(p.showing().includes(
|
|
178
|
+
for (const id of ['roster', 'collab', 'profile']) assert.ok(p.showing().includes(id), `${id} stays in ${m}`);
|
|
177
179
|
assert.equal(p.headShowing('government'), m === 'governing', `the Government head shows only where its tabs do (${m})`);
|
|
178
180
|
}
|
|
179
181
|
p.craft.choose('legacy');
|
|
@@ -185,7 +187,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
|
|
|
185
187
|
p.signIn('thetes');
|
|
186
188
|
assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
|
|
187
189
|
assert.ok(!p.headShowing('government'), 'nor its group head');
|
|
188
|
-
assert.deepEqual(p.showing(), ['blockers', 'board-room', 'roster'], 'what a thetes board member in Governing sees');
|
|
190
|
+
assert.deepEqual(p.showing(), ['blockers', 'board-room', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
|
|
189
191
|
p.signIn('archon');
|
|
190
192
|
assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
|
|
191
193
|
assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// tests/governor_matter_acts.mjs — a PASSED matter at Automation, Release,
|
|
2
|
+
// Review or Treasury carries out its act (task 1004561, goal 1000125), the way
|
|
3
|
+
// People already did: after the close commits, through the owning module's act,
|
|
4
|
+
// which calls the same function as that building's direct route. Also the two
|
|
5
|
+
// new desks' server halves (the Treasury budget rows, the Review undo rows) and
|
|
6
|
+
// the budget route's permission pin. DB-free: every write is a stub.
|
|
7
|
+
import { strict as assert } from 'node:assert';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
16
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
17
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
18
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
19
|
+
const decisionBoard = require(path.join(GOV, 'decision-board.js'));
|
|
20
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
21
|
+
const autoActs = require(path.join(ROOT, 'modules', 'autonomy', 'matter-acts.js'));
|
|
22
|
+
const autoDb = require(path.join(ROOT, 'modules', 'autonomy', 'db.js'));
|
|
23
|
+
const lifeActs = require(path.join(ROOT, 'modules', 'lifecycle', 'matter-acts.js'));
|
|
24
|
+
const lifeDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
|
|
25
|
+
const doneWhen = require(path.join(ROOT, 'modules', 'lifecycle', 'done-when.js'));
|
|
26
|
+
const doneWhenRoutes = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'done-when.js'));
|
|
27
|
+
const gateRoutes = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'gate-approvals.js'));
|
|
28
|
+
const lifeDocket = require(path.join(ROOT, 'modules', 'lifecycle', 'governor-docket.js'));
|
|
29
|
+
const buildersRoute = require(path.join(ROOT, 'src', 'bongos', 'routes', 'builders.js'));
|
|
30
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
31
|
+
|
|
32
|
+
async function stub(obj, stubs, fn) {
|
|
33
|
+
const originals = {};
|
|
34
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = obj[k]; obj[k] = v; }
|
|
35
|
+
try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) obj[k] = v; }
|
|
36
|
+
}
|
|
37
|
+
const sittingRow = (area, decided_by, rule) => ({ area, decided_by, rule, who: null, set_by_item_id: '30' });
|
|
38
|
+
|
|
39
|
+
// Register every owning module's acts once, as their routers do at boot.
|
|
40
|
+
autoActs.contributeAutomationMatterActs();
|
|
41
|
+
lifeActs.contributeLifecycleMatterActs();
|
|
42
|
+
buildersRoute();
|
|
43
|
+
|
|
44
|
+
// ── the shape of each act ───────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('each building\'s act is checked strictly when the matter is opened: shape, fields, nothing guessed', () => {
|
|
47
|
+
const v = decisionBoard.validateMatterAction;
|
|
48
|
+
assert.deepEqual(v('automation', { type: 'switch_on' }), { ok: true, action: { type: 'switch_on' } });
|
|
49
|
+
assert.deepEqual(v('automation', { type: 'allow_goal', goal_id: '12' }).action, { type: 'allow_goal', goal_id: 12 });
|
|
50
|
+
assert.deepEqual(v('release', { type: 'approve_gate', pr: 1500 }).action, { type: 'approve_gate', pr: 1500 });
|
|
51
|
+
assert.deepEqual(v('release', { type: 'approve_override', request_id: 4 }).action, { type: 'approve_override', request_id: 4 });
|
|
52
|
+
assert.deepEqual(v('review', { type: 'satisfy_criterion', criterion_id: 9 }).action, { type: 'satisfy_criterion', criterion_id: 9 });
|
|
53
|
+
assert.deepEqual(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: 50 }).action, { type: 'set_budget', builder_id: 7, monthly_usd: 50 });
|
|
54
|
+
assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: null }).action.monthly_usd, null, 'null clears the limit');
|
|
55
|
+
// refusals, each named
|
|
56
|
+
assert.equal(v('rules', { type: 'switch_on' }).reason, 'action_not_supported_here');
|
|
57
|
+
assert.equal(v('planning', { type: 'switch_on' }).reason, 'action_not_supported_here');
|
|
58
|
+
assert.equal(v('automation', { type: 'satisfy_criterion', criterion_id: 9 }).reason, 'bad_action', 'an act from another building');
|
|
59
|
+
assert.equal(v('automation', { type: 'switch_on', goal_id: 3 }).reason, 'unknown_field');
|
|
60
|
+
assert.equal(v('automation', { type: 'allow_goal' }).field, 'action.goal_id');
|
|
61
|
+
assert.equal(v('automation', { type: 'allow_goal', goal_id: 0 }).reason, 'bad_action');
|
|
62
|
+
assert.equal(v('automation', { type: 'allow_goal', goal_id: true }).reason, 'bad_action');
|
|
63
|
+
assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: -1 }).reason, 'bad_action');
|
|
64
|
+
assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: '50' }).reason, 'bad_action');
|
|
65
|
+
assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: 1e9 }).reason, 'bad_action');
|
|
66
|
+
assert.equal(v('review', []).reason, 'bad_action');
|
|
67
|
+
// People is unchanged
|
|
68
|
+
assert.deepEqual(v('people', { type: 'assign_rank', builder_id: '7', rank_key: 'tester' }).action, { type: 'assign_rank', builder_id: 7, rank_key: 'tester' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('every act in the table is carried out by its owning module, and only those', () => {
|
|
72
|
+
for (const [area, acts] of Object.entries(decisionBoard.MATTER_ACTIONS)) {
|
|
73
|
+
if (area === 'people') continue; // People acts inside the government module itself
|
|
74
|
+
for (const type of Object.keys(acts)) assert.ok(decisionBoard.matterActFor(area, type), `${area}.${type} has an act`);
|
|
75
|
+
}
|
|
76
|
+
assert.equal(decisionBoard.matterActFor('automation', 'satisfy_criterion'), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('opening a matter whose act no module carries out is refused before anything is filed', async () => {
|
|
80
|
+
const never = async () => { throw new Error('must not be reached'); };
|
|
81
|
+
await stub(db, { listDecisionRules: async () => [sittingRow('automation', 'rank:council', 'majority')], createMatter: never, openBoardItem: never }, async () => {
|
|
82
|
+
await stub(api, { contributions: () => [] }, async () => {
|
|
83
|
+
const out = await board.openMatter({ area: 'automation', title: 'Switch it on', action: { type: 'switch_on' }, proposedBy: '42' });
|
|
84
|
+
assert.equal(out.reason, 'action_not_wired');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
let made = null;
|
|
88
|
+
await stub(db, {
|
|
89
|
+
listDecisionRules: async () => [sittingRow('treasury', 'rank:council', 'majority')],
|
|
90
|
+
createMatter: async (a) => { made = a; return { id: '8', ...a }; },
|
|
91
|
+
openBoardItem: async (a) => ({ id: '40', subject_type: a.subjectType, subject_id: a.subjectId }),
|
|
92
|
+
}, async () => {
|
|
93
|
+
const out = await board.openMatter({ area: 'treasury', title: 'Ana: $50 a month', action: { type: 'set_budget', builder_id: 7, monthly_usd: 50 }, proposedBy: '42' });
|
|
94
|
+
assert.equal(out.ok, true, JSON.stringify(out));
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(made.action, { type: 'set_budget', builder_id: 7, monthly_usd: 50 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── a passed matter acts; a returned one never does ─────────────────────────
|
|
100
|
+
|
|
101
|
+
async function closeMatter({ area, action, outcome = 'passed', extraDb = {} }) {
|
|
102
|
+
const subjectType = `${area}_matter`;
|
|
103
|
+
const votes = outcome === 'passed' ? [{ voter_id: '9', direction: 'yes' }] : [{ voter_id: '9', direction: 'no', reason_md: 'no' }];
|
|
104
|
+
const events = [];
|
|
105
|
+
return stub(api, { emitAsync: async (name, payload) => { events.push([name, payload]); } }, () => stub(db, {
|
|
106
|
+
getVotesForItem: async () => votes,
|
|
107
|
+
closeBoardItem: async ({ outcome: o, afterClose }) => {
|
|
108
|
+
assert.equal(afterClose, null, 'a matter books nothing inside the close');
|
|
109
|
+
return { id: '40', subject_type: subjectType, subject_id: '8', opened_by: '42', closed_at: 'T', outcome: o };
|
|
110
|
+
},
|
|
111
|
+
getMatter: async () => ({ id: '8', area, title: 'A matter', action, proposed_by: '42' }),
|
|
112
|
+
insertAuthorRatifyKarma: async () => { throw new Error('a matter pays nobody'); },
|
|
113
|
+
getBoardItemAuthor: async () => '42',
|
|
114
|
+
...extraDb,
|
|
115
|
+
}, async () => {
|
|
116
|
+
const item = { id: '40', subject_type: subjectType, subject_id: '8', opened_by: '42', closed_at: null, constitution: { membership: 'rank:council', pass_rule: 'first_ratifier', rule: 'first' } };
|
|
117
|
+
return { out: await board.closeItem(item), events };
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('a passed Automation matter switches the fence and allows a goal, as the proposer, through the route\'s own writes', async () => {
|
|
122
|
+
const writes = [];
|
|
123
|
+
await stub(autoDb, {
|
|
124
|
+
setFenceEnabled: async (a) => { writes.push(['fence', a]); return { enabled: a.enabled }; },
|
|
125
|
+
allowGoal: async (a) => { writes.push(['allow', a]); return { goal_id: a.goalId }; },
|
|
126
|
+
}, async () => {
|
|
127
|
+
const on = await closeMatter({ area: 'automation', action: { type: 'switch_on' } });
|
|
128
|
+
assert.equal(on.out.outcome, 'passed');
|
|
129
|
+
assert.deepEqual(on.out.acted, { area: 'automation', matter_id: '8', action: 'switch_on', done: true });
|
|
130
|
+
const passed = on.events.find(([n]) => n === 'board.matter.passed');
|
|
131
|
+
assert.equal(passed[1].acted, true);
|
|
132
|
+
await closeMatter({ area: 'automation', action: { type: 'switch_off' } });
|
|
133
|
+
await closeMatter({ area: 'automation', action: { type: 'allow_goal', goal_id: 12 } });
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(writes.map(([k, a]) => [k, a.enabled ?? a.goalId, a.builderId]), [['fence', true, '42'], ['fence', false, '42'], ['allow', 12, '42']]);
|
|
136
|
+
assert.match(writes[0][1].reason, /passed Automation matter \(matter 8\)/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a RETURNED matter carries out nothing', async () => {
|
|
140
|
+
let wrote = false;
|
|
141
|
+
await stub(autoDb, { setFenceEnabled: async () => { wrote = true; return {}; } }, async () => {
|
|
142
|
+
const back = await closeMatter({ area: 'automation', action: { type: 'switch_on' }, outcome: 'returned' });
|
|
143
|
+
assert.equal(back.out.outcome, 'returned');
|
|
144
|
+
assert.equal(back.out.acted, undefined);
|
|
145
|
+
});
|
|
146
|
+
assert.equal(wrote, false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a passed Review matter signs the criterion off by override, once', async () => {
|
|
150
|
+
const signed = [];
|
|
151
|
+
await stub(doneWhenRoutes, { satisfyByOverride: async (a) => { signed.push(a); return { updated: {}, goalResult: {} }; } }, () =>
|
|
152
|
+
stub(doneWhen, { getCriterion: async (id) => ({ id, satisfied: id === 10 }) }, async () => {
|
|
153
|
+
const out = (await closeMatter({ area: 'review', action: { type: 'satisfy_criterion', criterion_id: 9 } })).out;
|
|
154
|
+
assert.equal(out.acted.done, true);
|
|
155
|
+
const again = (await closeMatter({ area: 'review', action: { type: 'satisfy_criterion', criterion_id: 10 } })).out;
|
|
156
|
+
assert.deepEqual([again.outcome, again.acted.done, again.acted.reason], ['passed', false, 'already_satisfied'], 'a refusal leaves the decision standing');
|
|
157
|
+
}));
|
|
158
|
+
assert.equal(signed.length, 1);
|
|
159
|
+
assert.equal(signed[0].criterionId, 9);
|
|
160
|
+
assert.equal(signed[0].signerId, '42');
|
|
161
|
+
assert.match(signed[0].reason, /passed Review matter/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a passed Release matter approves an override or clears a gate; a request already decided is said, not thrown', async () => {
|
|
165
|
+
const calls = [];
|
|
166
|
+
await stub(lifeDb, {
|
|
167
|
+
decideOverrideRequest: async (a) => {
|
|
168
|
+
calls.push(['override', a]);
|
|
169
|
+
if (a.requestId === 5) throw Object.assign(new Error('closed'), { code: 'REQUEST_NOT_OPEN' });
|
|
170
|
+
return {};
|
|
171
|
+
},
|
|
172
|
+
}, () => stub(gateRoutes, { approveGatePr: async (a) => { calls.push(['gate', a]); return a.number === 2 ? { ok: false, code: 'pr_not_open' } : { ok: true, body: {} }; } }, async () => {
|
|
173
|
+
assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_override', request_id: 4 } })).out.acted.done, true);
|
|
174
|
+
assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_override', request_id: 5 } })).out.acted.reason, 'request_not_open');
|
|
175
|
+
assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_gate', pr: 1500 } })).out.acted.done, true);
|
|
176
|
+
assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_gate', pr: 2 } })).out.acted.reason, 'pr_not_open');
|
|
177
|
+
}));
|
|
178
|
+
assert.deepEqual(calls[0], ['override', { requestId: 4, archonId: '42', decision: 'approve', note: 'A passed Release matter (matter 8) decided it.' }]);
|
|
179
|
+
assert.equal(calls[2][1].number, 1500);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('a passed Treasury matter sets the limit through the same write as PATCH /builders/:id/budget', async () => {
|
|
183
|
+
const set = [];
|
|
184
|
+
const reward = seams.resolveOptional('reward');
|
|
185
|
+
const fake = { setBuilderMonthlyBudget: async (id, usd) => { set.push([id, usd]); return id === 404 ? null : { id, monthly_budget_usd: usd }; } };
|
|
186
|
+
await stub(seams, { resolveOptional: (name, fb) => (name === 'reward' ? fake : (reward && fb)) }, async () => {
|
|
187
|
+
assert.equal((await closeMatter({ area: 'treasury', action: { type: 'set_budget', builder_id: 7, monthly_usd: 50 } })).out.acted.done, true);
|
|
188
|
+
assert.equal((await closeMatter({ area: 'treasury', action: { type: 'set_budget', builder_id: 404, monthly_usd: null } })).out.acted.reason, 'builder_not_found');
|
|
189
|
+
});
|
|
190
|
+
assert.deepEqual(set, [[7, 50], [404, null]]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('an act that throws leaves the decision passed and says it did not run', async () => {
|
|
194
|
+
await stub(autoDb, { setFenceEnabled: async () => { throw new Error('db down'); } }, async () => {
|
|
195
|
+
const { out, events } = await closeMatter({ area: 'automation', action: { type: 'switch_on' } });
|
|
196
|
+
assert.equal(out.outcome, 'passed');
|
|
197
|
+
assert.deepEqual([out.acted.done, out.acted.reason], [false, 'act_failed']);
|
|
198
|
+
assert.equal(events.find(([n]) => n === 'board.matter.passed')[1].acted, false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a stored act that no longer validates is not carried out', async () => {
|
|
203
|
+
let wrote = false;
|
|
204
|
+
await stub(autoDb, { allowGoal: async () => { wrote = true; } }, async () => {
|
|
205
|
+
const { out } = await closeMatter({ area: 'automation', action: { type: 'allow_goal', goal_id: -3 } });
|
|
206
|
+
assert.deepEqual([out.acted.done, out.acted.reason], [false, 'bad_action']);
|
|
207
|
+
});
|
|
208
|
+
assert.equal(wrote, false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── the two desks' server halves ────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
test('Treasury desk: a row per builder, their limit as the fact, the other limits one tap each', () => {
|
|
214
|
+
const rows = buildersRoute.budgetDeskRows([{ id: 7, display_name: 'Ana', github_login: 'ana', monthly_budget_usd: '100.00' }, { id: 8, github_login: 'kai', monthly_budget_usd: null }]);
|
|
215
|
+
assert.equal(rows[0].fact, '$100 a month');
|
|
216
|
+
assert.deepEqual(rows[0].acts[0].options.map((o) => o.key), ['25', '50', '250', 'none']);
|
|
217
|
+
assert.ok(rows[0].acts[0].options.every((o) => o.label.length <= 16), 'every chip fits the line rule');
|
|
218
|
+
assert.equal(rows[1].fact, 'no limit');
|
|
219
|
+
assert.deepEqual(rows[1].acts[0].options.map((o) => o.key), ['25', '50', '100', '250']);
|
|
220
|
+
assert.deepEqual(rows[0].ref, { type: 'builder_budget', id: 7 });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('Review desk: only signed-off promises, newest first, each undone with a one-tap reason', () => {
|
|
224
|
+
const rows = lifeDocket.signedDeskRows([
|
|
225
|
+
{ id: 1, criterion_md: 'Old', satisfied: true, checked_at: '2026-09-01T00:00:00Z' },
|
|
226
|
+
{ id: 2, criterion_md: 'New', satisfied: true, checked_at: '2026-10-02T00:00:00Z' },
|
|
227
|
+
{ id: 3, criterion_md: 'Open', satisfied: false, checked_at: null },
|
|
228
|
+
]);
|
|
229
|
+
assert.deepEqual(rows.map((r) => r.name), ['New', 'Old']);
|
|
230
|
+
assert.equal(rows[0].acts[0].key, 'unsign');
|
|
231
|
+
assert.ok(rows[0].acts[0].options.every((o) => o.label.length <= 16));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('the desks are read under their routes\' own atoms, and the budget route is pinned', () => {
|
|
235
|
+
const desks = api.contributions('governor.docket').filter((c) => c && (c.desk === 'treasury.budgets' || c.desk === 'review.signed'));
|
|
236
|
+
const gates = Object.fromEntries(desks.map((c) => [c.desk, c.gate && c.gate.permission]));
|
|
237
|
+
assert.equal(gates['treasury.budgets'], 'builder.budget.set');
|
|
238
|
+
assert.equal(rrc.EXPECTED_RANKS['PATCH /builders/:id/budget'], 'perm:builder.budget.set');
|
|
239
|
+
const life = rrc.MODULE_EXPECTED_RANKS.lifecycle || Object.values(rrc.MODULE_EXPECTED_RANKS).find((m) => m['POST /done-when/:criterionId/unsatisfy']);
|
|
240
|
+
assert.equal(life['POST /done-when/:criterionId/unsatisfy'], 'perm:criterion.unsatisfy');
|
|
241
|
+
});
|