@bongos/core 1.21.18 → 1.21.19
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 +39 -24
- package/docs/copy-inventory.md +5 -5
- package/docs/copy-registry.json +6 -6
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +38 -38
- package/modules/hall-ui/public/street-amend.js +240 -0
- package/modules/hall-ui/public/street-card.js +4 -1
- package/modules/hall-ui/public/street.css +8 -0
- package/modules/hall-ui/public/street.html +5 -3
- package/modules/hall-ui/public/street.js +164 -11
- package/modules/hall-ui/public/street.states.json +12 -5
- package/modules/ui-design/kit/fixtures/government-constitution-rules.json +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_street_amend.mjs +336 -0
- package/tests/hall_street_inside.mjs +4 -2
- package/tests/rank_tier_single_source.mjs +5 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "GET /government/constitution as the street reads it for a drafted amendment (task 1004530): the board block and the who-decides-what table, resolved by modules/government/decision-rules.js resolveAll (Release already amended to a sitting; every other building as today). Regenerate from the resolver, never by hand.",
|
|
3
|
+
"board": {
|
|
4
|
+
"membership": "rank:metic+",
|
|
5
|
+
"pass_rule": "majority",
|
|
6
|
+
"window_minutes": 4320,
|
|
7
|
+
"close_early_on_full_turnout": true
|
|
8
|
+
},
|
|
9
|
+
"history": [],
|
|
10
|
+
"headcount": 6,
|
|
11
|
+
"decision_rules": {
|
|
12
|
+
"areas": [
|
|
13
|
+
{
|
|
14
|
+
"area": "charter",
|
|
15
|
+
"name": "Charter",
|
|
16
|
+
"decided_by": "board",
|
|
17
|
+
"rule": "majority",
|
|
18
|
+
"rule_words": "majority vote",
|
|
19
|
+
"who": "the board (metic and above (metic, archon))",
|
|
20
|
+
"as_today": true,
|
|
21
|
+
"sitting": false,
|
|
22
|
+
"gate": null,
|
|
23
|
+
"today": "the board, under the constitution in force",
|
|
24
|
+
"acts": [],
|
|
25
|
+
"set_by_item_id": null,
|
|
26
|
+
"updated_at": null
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"area": "rules",
|
|
30
|
+
"name": "Rules",
|
|
31
|
+
"decided_by": "gate",
|
|
32
|
+
"rule": "decide",
|
|
33
|
+
"rule_words": "decides alone",
|
|
34
|
+
"who": "whoever may change the project settings",
|
|
35
|
+
"as_today": true,
|
|
36
|
+
"sitting": false,
|
|
37
|
+
"gate": "project.settings.manage",
|
|
38
|
+
"today": "whoever may change the project settings",
|
|
39
|
+
"acts": [],
|
|
40
|
+
"set_by_item_id": null,
|
|
41
|
+
"updated_at": null
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"area": "disputes",
|
|
45
|
+
"name": "Disputes",
|
|
46
|
+
"decided_by": "gate",
|
|
47
|
+
"rule": "decide",
|
|
48
|
+
"rule_words": "decides alone",
|
|
49
|
+
"who": "whoever may judge reports",
|
|
50
|
+
"as_today": true,
|
|
51
|
+
"sitting": false,
|
|
52
|
+
"gate": "security.report.adjudicate",
|
|
53
|
+
"today": "whoever may judge reports",
|
|
54
|
+
"acts": [],
|
|
55
|
+
"set_by_item_id": null,
|
|
56
|
+
"updated_at": null
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"area": "people",
|
|
60
|
+
"name": "People",
|
|
61
|
+
"decided_by": "gate",
|
|
62
|
+
"rule": "decide",
|
|
63
|
+
"rule_words": "decides alone",
|
|
64
|
+
"who": "whoever may manage ranks",
|
|
65
|
+
"as_today": true,
|
|
66
|
+
"sitting": false,
|
|
67
|
+
"gate": "government.manage",
|
|
68
|
+
"today": "whoever may manage ranks",
|
|
69
|
+
"acts": [
|
|
70
|
+
"POST /government/assignments",
|
|
71
|
+
"DELETE /government/assignments/:builderId/:rankKey"
|
|
72
|
+
],
|
|
73
|
+
"set_by_item_id": null,
|
|
74
|
+
"updated_at": null
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"area": "planning",
|
|
78
|
+
"name": "Planning",
|
|
79
|
+
"decided_by": "gate",
|
|
80
|
+
"rule": "decide",
|
|
81
|
+
"rule_words": "decides alone",
|
|
82
|
+
"who": "whoever may create goals",
|
|
83
|
+
"as_today": true,
|
|
84
|
+
"sitting": false,
|
|
85
|
+
"gate": "goal.create",
|
|
86
|
+
"today": "whoever may create goals",
|
|
87
|
+
"acts": [],
|
|
88
|
+
"set_by_item_id": null,
|
|
89
|
+
"updated_at": null
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"area": "automation",
|
|
93
|
+
"name": "Automation",
|
|
94
|
+
"decided_by": "gate",
|
|
95
|
+
"rule": "decide",
|
|
96
|
+
"rule_words": "decides alone",
|
|
97
|
+
"who": "whoever may switch unattended building on and off",
|
|
98
|
+
"as_today": true,
|
|
99
|
+
"sitting": false,
|
|
100
|
+
"gate": "autonomy.fence.manage",
|
|
101
|
+
"today": "whoever may switch unattended building on and off",
|
|
102
|
+
"acts": [],
|
|
103
|
+
"set_by_item_id": null,
|
|
104
|
+
"updated_at": null
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"area": "review",
|
|
108
|
+
"name": "Review",
|
|
109
|
+
"decided_by": "gate",
|
|
110
|
+
"rule": "decide",
|
|
111
|
+
"rule_words": "decides alone",
|
|
112
|
+
"who": "whoever may mark work as meeting its checks",
|
|
113
|
+
"as_today": true,
|
|
114
|
+
"sitting": false,
|
|
115
|
+
"gate": "criterion.satisfy",
|
|
116
|
+
"today": "whoever may mark work as meeting its checks",
|
|
117
|
+
"acts": [],
|
|
118
|
+
"set_by_item_id": null,
|
|
119
|
+
"updated_at": null
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"area": "release",
|
|
123
|
+
"name": "Release",
|
|
124
|
+
"decided_by": "rank:archon",
|
|
125
|
+
"rule": "unanimous",
|
|
126
|
+
"rule_words": "everyone agrees",
|
|
127
|
+
"who": "exactly archon",
|
|
128
|
+
"as_today": false,
|
|
129
|
+
"sitting": true,
|
|
130
|
+
"gate": "core.pin.move",
|
|
131
|
+
"today": "whoever may put a release live",
|
|
132
|
+
"acts": [],
|
|
133
|
+
"set_by_item_id": "3",
|
|
134
|
+
"updated_at": null
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"area": "treasury",
|
|
138
|
+
"name": "Treasury",
|
|
139
|
+
"decided_by": "gate",
|
|
140
|
+
"rule": "decide",
|
|
141
|
+
"rule_words": "decides alone",
|
|
142
|
+
"who": "whoever may set spending limits",
|
|
143
|
+
"as_today": true,
|
|
144
|
+
"sitting": false,
|
|
145
|
+
"gate": "builder.budget.set",
|
|
146
|
+
"today": "whoever may set spending limits",
|
|
147
|
+
"acts": [],
|
|
148
|
+
"set_by_item_id": null,
|
|
149
|
+
"updated_at": null
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"rules": [
|
|
153
|
+
{
|
|
154
|
+
"key": "decide",
|
|
155
|
+
"words": "decides alone"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"key": "first",
|
|
159
|
+
"words": "first yes wins"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"key": "consent",
|
|
163
|
+
"words": "passes unless someone objects"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"key": "majority",
|
|
167
|
+
"words": "majority vote"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"key": "unanimous",
|
|
171
|
+
"words": "everyone agrees"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"key": "signoff",
|
|
175
|
+
"words": "signed off by someone who did not build it"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"key": "vacant",
|
|
179
|
+
"words": "no one holds this yet"
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
"changed_by": "a passed amendment only — POST /government/board/decision-rules files one"
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.19",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.19",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.19",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8864,5 +8864,11 @@
|
|
|
8864
8864
|
"id": "1004555",
|
|
8865
8865
|
"text": "Project setup no longer shows an \"Allow repo access\" button — if the site needs GitHub access it fetches it in a quick reload and brings you straight back to where you were."
|
|
8866
8866
|
}
|
|
8867
|
+
],
|
|
8868
|
+
"1.21.19": [
|
|
8869
|
+
{
|
|
8870
|
+
"id": "1004530",
|
|
8871
|
+
"text": "Inside any building on the street, \"change how this works\" now drafts a real amendment. It shows how the building decides today crossed out, and the proposed change written in. You pick who decides and how from the project's o"
|
|
8872
|
+
}
|
|
8867
8873
|
]
|
|
8868
8874
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/hall_street.mjs
CHANGED
|
@@ -377,7 +377,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
|
|
|
377
377
|
for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
378
378
|
assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome, as the city');
|
|
379
379
|
const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
380
|
-
assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street'], 'the shared kit loads first, then the card (task 1004528)
|
|
380
|
+
assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the shared kit loads first, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
|
|
381
381
|
assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
|
|
382
382
|
});
|
|
383
383
|
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// tests/hall_street_amend.mjs — CHANGE HOW THIS WORKS DRAFTS AN AMENDMENT
|
|
2
|
+
// (task 1004530, goal 1000125).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS. From any building, "change how this works" opens a drafted
|
|
5
|
+
// amendment to that building's row of the who-decides-what table, prefilled
|
|
6
|
+
// with the rule in force and a proposed one as struck and inserted text; the
|
|
7
|
+
// governor picks who and which rule from the vocabulary only, says why, and
|
|
8
|
+
// files it; the street answers. What can go wrong:
|
|
9
|
+
// * the draft offering a seat or a rule the server does not know (free text),
|
|
10
|
+
// or a rule that does not fit who decides (as today with a rule, empty with
|
|
11
|
+
// anything but vacant) — the server would refuse what the governor drafted;
|
|
12
|
+
// * the struck and inserted words drifting from what the matter card shows the
|
|
13
|
+
// council (pinned: they ARE the card's change() over the stored amendment);
|
|
14
|
+
// * a request body the server's own validator refuses, the Charter's draft
|
|
15
|
+
// going to the rules route (it is the board: /board/items);
|
|
16
|
+
// * a refusal swallowed, or said as a code instead of words;
|
|
17
|
+
// * the page filing with no reason, filing twice, or answering nothing.
|
|
18
|
+
//
|
|
19
|
+
// The fixtures are the SERVER'S OWN resolved rows (decision-rules.js), so the
|
|
20
|
+
// draft is tested against the shape the constitution route really serves.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/hall_street_amend.mjs
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const C = require(path.join(PUB, 'street-card.js'));
|
|
36
|
+
const A = require(path.join(PUB, 'street-amend.js'));
|
|
37
|
+
const S = require(path.join(PUB, 'street-draw.js'));
|
|
38
|
+
const DR = require(path.join(ROOT, 'modules/government/decision-rules.js'));
|
|
39
|
+
const { RANK_ORDER } = require(path.join(ROOT, 'modules/government/membership-predicate.js'));
|
|
40
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
41
|
+
const j = (v) => JSON.parse(JSON.stringify(v));
|
|
42
|
+
|
|
43
|
+
const BOARD = { membership: 'rank:archon', pass_rule: 'majority', window_minutes: 4320, close_early_on_full_turnout: true };
|
|
44
|
+
function constitution(rows = [], board = BOARD) {
|
|
45
|
+
return {
|
|
46
|
+
board,
|
|
47
|
+
decision_rules: {
|
|
48
|
+
areas: j(DR.resolveAll(rows, { board })),
|
|
49
|
+
rules: DR.DECISION_RULES.map((r) => ({ key: r.key, words: r.words })),
|
|
50
|
+
changed_by: 'a passed amendment only',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const SAT = { area: 'review', decided_by: 'rank:metic+', rule: 'signoff', who: null, set_by_item_id: 12 };
|
|
55
|
+
|
|
56
|
+
// ── the draft ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('the vocabulary is the server\'s: the rank ladder, the seven rules, the nine buildings', () => {
|
|
59
|
+
assert.deepEqual(A.RANKS, [...RANK_ORDER]);
|
|
60
|
+
const c = constitution();
|
|
61
|
+
for (const area of DR.AREA_KEYS) {
|
|
62
|
+
const d = A.draftFor(area, c);
|
|
63
|
+
assert.equal(d.ok, true, area);
|
|
64
|
+
for (const o of d.whoOptions) assert.ok(o.value === 'gate' || o.value === 'nobody' || /^rank:/.test(o.value), `${area}: ${o.value} is a seat, never free text`);
|
|
65
|
+
for (const o of d.ruleOptions) assert.ok(DR.SITTING_RULE_KEYS.includes(o.value), `${area}: ${o.value}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a building decided as today drafts: the board\'s members, by consent, struck and inserted', () => {
|
|
70
|
+
const d = A.draftFor('review', constitution());
|
|
71
|
+
assert.equal(d.name, 'Review');
|
|
72
|
+
assert.deepEqual(d.current, { decided_by: 'gate', rule: null });
|
|
73
|
+
assert.equal(d.decided_by, 'rank:archon');
|
|
74
|
+
assert.equal(d.rule, 'consent');
|
|
75
|
+
assert.deepEqual(d.whoOptions.map((o) => o.value), ['gate', 'rank:archon', 'rank:xenos+', 'rank:thetes+', 'rank:metic+', 'nobody']);
|
|
76
|
+
assert.equal(d.whoOptions[0].words, 'as today: whoever may mark work as meeting its checks');
|
|
77
|
+
assert.deepEqual(d.ruleOptions.map((o) => o.value), ['decide', 'first', 'consent', 'majority', 'unanimous', 'signoff']);
|
|
78
|
+
assert.deepEqual(j(A.change(d)), [{ label: 'Review', struck: 'whoever may mark work as meeting its checks, decides alone', inserted: 'the archon rank, passes unless someone objects' }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a building already changed drafts its way back to as today', () => {
|
|
82
|
+
const d = A.draftFor('review', constitution([SAT]));
|
|
83
|
+
assert.deepEqual(d.current, { decided_by: 'rank:metic+', rule: 'signoff' });
|
|
84
|
+
assert.equal(d.decided_by, 'gate');
|
|
85
|
+
assert.equal(d.rule, null);
|
|
86
|
+
assert.deepEqual(d.ruleOptions, [], 'as today decides alone: there is no rule to pick');
|
|
87
|
+
assert.deepEqual(j(A.change(d)), [{ label: 'Review', struck: 'metic and above (metic, archon), signed off by someone who did not build it', inserted: 'as it is decided today' }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the rule follows who: no one is vacant, a sitting keeps a rule that fits, a value outside the vocabulary changes nothing', () => {
|
|
91
|
+
let d = A.draftFor('people', constitution());
|
|
92
|
+
d = A.pick(d, 'who', 'nobody');
|
|
93
|
+
assert.equal(d.rule, 'vacant');
|
|
94
|
+
assert.deepEqual(j(A.change(d))[0].inserted, 'no one yet');
|
|
95
|
+
d = A.pick(d, 'who', 'rank:metic+');
|
|
96
|
+
assert.ok(DR.SITTING_RULE_KEYS.includes(d.rule), 'a sitting gets a sitting rule back');
|
|
97
|
+
d = A.pick(d, 'rule', 'unanimous');
|
|
98
|
+
assert.equal(d.rule, 'unanimous');
|
|
99
|
+
assert.equal(A.pick(d, 'who', 'rank:everyone'), d, 'not a seat on offer');
|
|
100
|
+
assert.equal(A.pick(d, 'rule', 'vacant'), d, 'vacant is not a sitting rule');
|
|
101
|
+
assert.equal(A.pick(d, 'rule', 'whatever'), d);
|
|
102
|
+
d = A.pick(d, 'who', 'gate');
|
|
103
|
+
assert.equal(A.changed(d), false, 'back to as today is no change');
|
|
104
|
+
assert.deepEqual(A.change(d), []);
|
|
105
|
+
assert.match(A.check(d), /how it works today/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the request is the one the server\'s validator accepts, and the card reads it back word for word', () => {
|
|
109
|
+
for (const [area, who, rule] of [['review', 'rank:archon', 'consent'], ['treasury', 'rank:metic+', 'majority'], ['people', 'nobody', null], ['rules', 'rank:xenos+', 'signoff']]) {
|
|
110
|
+
let d = A.draftFor(area, constitution());
|
|
111
|
+
d = A.pick(d, 'who', who);
|
|
112
|
+
if (rule) d = A.pick(d, 'rule', rule);
|
|
113
|
+
assert.equal(A.request(d), null, 'no reason, no request');
|
|
114
|
+
assert.match(A.check(d), /Say why/);
|
|
115
|
+
d = A.pick(d, 'reason', ' Three pieces of work are waiting. ');
|
|
116
|
+
const req = A.request(d);
|
|
117
|
+
assert.equal(req.method, 'POST');
|
|
118
|
+
assert.equal(req.url, '/api/bongos/government/board/decision-rules');
|
|
119
|
+
assert.equal(req.body.rationale_md, 'Three pieces of work are waiting.');
|
|
120
|
+
const v = DR.validateDecisionRulesProposal(req.body.rules);
|
|
121
|
+
assert.equal(v.ok, true, `${area}: ${v.reason}`);
|
|
122
|
+
assert.equal(DR.changesAnything(v.rules, []), true, 'a change the server will not refuse as no_change');
|
|
123
|
+
// the card the council votes on, over the amendment as the server stores it
|
|
124
|
+
const stored = { subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules', proposed: { decision_rules: j(v.rules) } } };
|
|
125
|
+
const docket = { buildings: [{ key: area, decision: constitution().decision_rules.areas.find((r) => r.area === area) }] };
|
|
126
|
+
assert.deepEqual(j(C.change(stored, docket, { board: BOARD }).rows), j(A.change(d)), `${area}: drafted words = voted words`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the Charter is the board: its draft is a constitution amendment of members and pass rule, through /board/items', () => {
|
|
131
|
+
let d = A.draftFor('charter', constitution());
|
|
132
|
+
assert.equal(d.charter, true);
|
|
133
|
+
assert.ok(!d.whoOptions.some((o) => o.value === 'gate' || o.value === 'nobody'), 'the charter is never as-today or empty');
|
|
134
|
+
assert.deepEqual(d.ruleOptions.map((o) => o.value), ['first', 'consent', 'majority', 'unanimous'], 'the board\'s four pass rules');
|
|
135
|
+
assert.equal(d.rule, 'consent');
|
|
136
|
+
assert.deepEqual(j(A.change(d)), j(C.change({ subject: { kind: 'constitution', proposed: { pass_rule: 'consent' } } }, null, { board: { pass_rule: 'majority' } }).rows));
|
|
137
|
+
assert.equal(j(A.change(d))[0].label, 'how it passes');
|
|
138
|
+
d = A.pick(A.pick(d, 'who', 'rank:metic+'), 'reason', 'More of us should sit.');
|
|
139
|
+
const req = A.request(d);
|
|
140
|
+
assert.equal(req.url, '/api/bongos/government/board/items');
|
|
141
|
+
assert.equal(req.body.subject_type, 'constitutional_amendment');
|
|
142
|
+
assert.deepEqual(Object.keys(req.body.proposal).sort(), ['close_early_on_full_turnout', 'membership', 'pass_rule', 'window_minutes'], 'a constitution is filed whole, with no field the validator refuses');
|
|
143
|
+
assert.deepEqual(req.body.proposal, { membership: 'rank:metic+', pass_rule: 'consent', window_minutes: 4320, close_early_on_full_turnout: true });
|
|
144
|
+
assert.equal(j(A.change(d)).length, 2, 'who sits, and how it passes');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a constitution that could not be read drafts nothing, and says so', () => {
|
|
148
|
+
for (const c of [null, {}, { board: BOARD }, { board: BOARD, decision_rules: null }, { board: BOARD, decision_rules: { areas: [], rules: [] } }]) {
|
|
149
|
+
const d = A.draftFor('review', c);
|
|
150
|
+
assert.equal(d.ok, false);
|
|
151
|
+
assert.equal(A.request(d), null);
|
|
152
|
+
assert.match(A.check(d), /could not be read/);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the server\'s refusal is said plainly, never as a code', () => {
|
|
157
|
+
const env = (code, details) => ({ error: { code, message: code, details } });
|
|
158
|
+
assert.match(A.refusal(403, env('forbidden')), /not yours to do/);
|
|
159
|
+
assert.match(A.refusal(401, env('unauthorized')), /Sign in/);
|
|
160
|
+
assert.match(A.refusal(409, env('no_change', { reason: 'every building named is already decided this way' })), /already is how it works/);
|
|
161
|
+
assert.match(A.refusal(400, env('invalid_decision_rules', { reason: 'unknown_rank', rank_key: 'council' })), /no such rank/);
|
|
162
|
+
assert.equal(A.refusal(400, env('invalid_decision_rules', { reason: 'too_many_rows' })), 'The server refused it: too many rows.');
|
|
163
|
+
assert.equal(A.refusal(409, env('proposer_would_lose_their_seat', { what_this_means: 'You would no longer sit on the board.' })), 'You would no longer sit on the board.');
|
|
164
|
+
assert.equal(A.refusal(0, {}), 'It could not be done just now.');
|
|
165
|
+
for (const s of [A.refusal(400, env('invalid_decision_rules', { reason: 'bad_rule' })), A.refusal(403, env('forbidden'))]) assert.doesNotMatch(s, /_/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the model is pure', () => {
|
|
169
|
+
const src = read('street-amend.js').replace(/^\s*\/\/.*$/gm, '');
|
|
170
|
+
assert.doesNotMatch(src, /document\.|fetch\(|Date\.now|Math\.random|innerHTML/);
|
|
171
|
+
const d = A.draftFor('review', constitution());
|
|
172
|
+
assert.deepEqual(j(A.draftFor('review', constitution())), j(d));
|
|
173
|
+
A.pick(d, 'who', 'gate');
|
|
174
|
+
assert.equal(d.decided_by, 'rank:archon', 'a pick returns a new draft');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── the page ────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
test('the markup: the draft lives under the button, hidden, and the model loads before the page', () => {
|
|
180
|
+
const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
|
|
181
|
+
assert.match(html, /id="insideAmend" aria-expanded="false" aria-controls="insideDraft"/);
|
|
182
|
+
assert.match(html, /<div class="inside-draft" id="insideDraft" hidden><\/div>/);
|
|
183
|
+
const order = ['street-card.js', 'street-amend.js', 'street.js'].map((f) => html.indexOf(`/builders/${f}?`));
|
|
184
|
+
assert.ok(order[0] > 0 && order[0] < order[1] && order[1] < order[2], 'card, then the draft model, then the page');
|
|
185
|
+
const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
186
|
+
assert.ok(css.includes('.inside-draft[hidden] { display: none; }'));
|
|
187
|
+
assert.match(css, /@media \(max-width: 899px\) \{\s*\.inside-draft__picks \{ grid-template-columns: 1fr; \}/, 'narrow: the picks stack');
|
|
188
|
+
assert.doesNotMatch(css.slice(css.indexOf('.inside-draft')), /#[0-9a-f]{3,8}\b/i, 'tokens only');
|
|
189
|
+
assert.doesNotMatch(read('street.js'), /not built yet/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
function fakeDom() {
|
|
193
|
+
const byId = new Map();
|
|
194
|
+
const focused = { el: null };
|
|
195
|
+
const mk = (tag) => ({
|
|
196
|
+
tagName: tag, attrs: {}, kids: [], className: '', listeners: {}, _text: '', hidden: false, value: undefined,
|
|
197
|
+
style: { setProperty(k, v) { this[k] = v; } },
|
|
198
|
+
clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
|
|
199
|
+
get children() { const a = this.kids.slice(); a[Symbol.isConcatSpreadable] = false; return a; },
|
|
200
|
+
get firstChild() { return this.kids[0] || null; },
|
|
201
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
202
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
203
|
+
appendChild(c) { this.kids.push(c); c.parentNode = this; return c; },
|
|
204
|
+
removeChild(c) { this.kids = this.kids.filter((x) => x !== c); return c; },
|
|
205
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
206
|
+
fire(t, e = {}) { return (this.listeners[t] || []).map((fn) => fn({ type: t, preventDefault() {}, ...e })); },
|
|
207
|
+
focus() { focused.el = this; },
|
|
208
|
+
set textContent(v) { this._text = String(v); this.kids = []; },
|
|
209
|
+
get textContent() { return this._text; },
|
|
210
|
+
get innerHTML() { throw new Error('no innerHTML'); },
|
|
211
|
+
set innerHTML(v) { throw new Error('no innerHTML'); },
|
|
212
|
+
});
|
|
213
|
+
const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
|
|
214
|
+
for (const id of [...html.matchAll(/id="([A-Za-z]+)"/g)].map((m) => m[1])) {
|
|
215
|
+
const el = mk('x');
|
|
216
|
+
el.setAttribute('id', id);
|
|
217
|
+
const tag = new RegExp(`<[^>]*\\bid="${id}"[^>]*>`).exec(html);
|
|
218
|
+
if (tag && /\shidden(?=[\s>])/.test(tag[0])) el.hidden = true;
|
|
219
|
+
}
|
|
220
|
+
const document = { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
|
|
221
|
+
return { document, byId, focused };
|
|
222
|
+
}
|
|
223
|
+
const all = (el) => [el, ...el.kids.flatMap(all)];
|
|
224
|
+
const textOf = (el) => all(el).map((n) => n._text).filter(Boolean).join(' | ');
|
|
225
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
226
|
+
|
|
227
|
+
async function bootPage({ hash = '#building=review', cons = constitution(), answer = () => ({ status: 201, body: { ok: true, item: { id: 77 }, amendment: { id: 5 } } }) } = {}) {
|
|
228
|
+
const { document, byId, focused } = fakeDom();
|
|
229
|
+
const gets = [], posts = [], history = [];
|
|
230
|
+
const location = { hash };
|
|
231
|
+
const window = {
|
|
232
|
+
innerWidth: 1440, innerHeight: 900, location,
|
|
233
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
234
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
235
|
+
addEventListener() {},
|
|
236
|
+
history: { pushState(_s, _t, url) { history.push(url); location.hash = ''; } },
|
|
237
|
+
};
|
|
238
|
+
const feeds = {
|
|
239
|
+
'/api/bongos/government/docket': { viewer: { id: '95' }, counts: { needs: 0, moving: 0, settled: 0 }, line: '', matters: [], buildings: S.CIVIC_KEYS.map((key) => ({ key, name: key, matter_ids: [] })), sources: [], readings: [] },
|
|
240
|
+
'/api/bongos/goals': { goals: [] },
|
|
241
|
+
'/api/bongos/goals/rollups': { rollups: [] },
|
|
242
|
+
'/api/bongos/government/constitution': cons,
|
|
243
|
+
};
|
|
244
|
+
const fetch = (url, opts = {}) => {
|
|
245
|
+
if (opts.method === 'POST') {
|
|
246
|
+
posts.push({ url, body: JSON.parse(opts.body) });
|
|
247
|
+
const a = answer(url);
|
|
248
|
+
return Promise.resolve({ ok: a.status < 300, status: a.status, json: async () => a.body });
|
|
249
|
+
}
|
|
250
|
+
gets.push(url);
|
|
251
|
+
const body = feeds[url];
|
|
252
|
+
return Promise.resolve(body == null ? { ok: false, status: 500, json: async () => ({}) } : { ok: true, status: 200, json: async () => body });
|
|
253
|
+
};
|
|
254
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, setTimeout, clearTimeout });
|
|
255
|
+
ctx.globalThis = ctx;
|
|
256
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
257
|
+
await window.GovStreetPage.ready;
|
|
258
|
+
await tick();
|
|
259
|
+
return { window, byId, focused, gets, posts, history };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
test('change how this works opens the drafted amendment: struck, inserted, who, how, why', async () => {
|
|
263
|
+
const p = await bootPage();
|
|
264
|
+
const box = p.byId.get('insideDraft');
|
|
265
|
+
assert.equal(box.hidden, true);
|
|
266
|
+
await p.byId.get('insideAmend').fire('click')[0];
|
|
267
|
+
assert.ok(p.gets.includes('/api/bongos/government/constitution'), 'the rule in force is read fresh');
|
|
268
|
+
assert.equal(box.hidden, false);
|
|
269
|
+
assert.equal(p.byId.get('insideAmend').attrs['aria-expanded'], 'true');
|
|
270
|
+
const del = all(box).find((n) => n.tagName === 'del'), ins = all(box).find((n) => n.tagName === 'ins');
|
|
271
|
+
assert.equal(del.textContent, 'whoever may mark work as meeting its checks, decides alone');
|
|
272
|
+
assert.equal(ins.textContent, 'the archon rank, passes unless someone objects');
|
|
273
|
+
assert.equal(p.focused.el, p.byId.get('insideDraftWho'), 'focus lands on who decides');
|
|
274
|
+
assert.equal(p.byId.get('insideDraftWho').kids.length, 6);
|
|
275
|
+
assert.equal(p.byId.get('insideDraftRule').value, 'consent');
|
|
276
|
+
// a pick redraws the words and keeps focus where it was
|
|
277
|
+
const who = p.byId.get('insideDraftWho');
|
|
278
|
+
who.value = 'nobody';
|
|
279
|
+
who.fire('change');
|
|
280
|
+
assert.equal(all(box).find((n) => n.tagName === 'ins').textContent, 'no one yet');
|
|
281
|
+
assert.ok(!all(box).some((n) => n.attrs.id === 'insideDraftRule'), 'an empty building has no rule to pick');
|
|
282
|
+
assert.match(textOf(box), /nothing is decided until someone holds it/);
|
|
283
|
+
assert.equal(p.focused.el.attrs.id, 'insideDraftWho');
|
|
284
|
+
// a feed landing mid-sentence leaves the draft alone
|
|
285
|
+
const before = p.byId.get('insideDraftWhy');
|
|
286
|
+
before.value = 'half a thought';
|
|
287
|
+
before.fire('input');
|
|
288
|
+
await p.window.GovStreetPage.load();
|
|
289
|
+
assert.equal(p.byId.get('insideDraftWhy'), before, 'the same textarea, not a fresh one');
|
|
290
|
+
// not now closes it and gives focus back to the button
|
|
291
|
+
all(box).find((n) => n.tagName === 'button' && n._text === 'not now').fire('click');
|
|
292
|
+
assert.equal(box.hidden, true);
|
|
293
|
+
assert.equal(p.byId.get('insideAmend').attrs['aria-expanded'], 'false');
|
|
294
|
+
assert.equal(p.focused.el, p.byId.get('insideAmend'));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('filing: no reason is refused in place; a filed amendment goes to the Charter, once, and the street answers', async () => {
|
|
298
|
+
const p = await bootPage();
|
|
299
|
+
await p.byId.get('insideAmend').fire('click')[0];
|
|
300
|
+
await p.byId.get('insideDraftFile').fire('click')[0];
|
|
301
|
+
assert.equal(p.posts.length, 0, 'nothing is sent without a reason');
|
|
302
|
+
assert.match(p.byId.get('insideDraftError').textContent, /Say why/);
|
|
303
|
+
assert.equal(p.byId.get('insideDraftError').attrs.role, 'alert');
|
|
304
|
+
assert.equal(p.focused.el, p.byId.get('insideDraftWhy'));
|
|
305
|
+
const why = p.byId.get('insideDraftWhy');
|
|
306
|
+
why.value = 'Three pieces of finished work are waiting.';
|
|
307
|
+
const first = p.byId.get('insideDraftFile').fire('click')[0];
|
|
308
|
+
await p.byId.get('insideDraftFile').fire('click')[0];
|
|
309
|
+
await first;
|
|
310
|
+
assert.equal(p.posts.length, 1, 'one click files once');
|
|
311
|
+
assert.deepEqual(p.posts[0], { url: '/api/bongos/government/board/decision-rules', body: { rules: [{ area: 'review', decided_by: 'rank:archon', rule: 'consent' }], rationale_md: 'Three pieces of finished work are waiting.' } });
|
|
312
|
+
assert.equal(p.byId.get('streetToast').hidden, false);
|
|
313
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Filed. Your amendment to Review is on the Charter for the council to vote on.');
|
|
314
|
+
assert.deepEqual(p.history, ['/builders/street'], 'back to the street, where the Charter pin appears');
|
|
315
|
+
assert.equal(p.byId.get('streetInside').hidden, true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a refusal from the server is said in the draft, and the draft stays to be changed', async () => {
|
|
319
|
+
const p = await bootPage({ answer: () => ({ status: 403, body: { error: { code: 'forbidden', message: 'missing permission board.item.open' } } }) });
|
|
320
|
+
await p.byId.get('insideAmend').fire('click')[0];
|
|
321
|
+
p.byId.get('insideDraftWhy').value = 'Because.';
|
|
322
|
+
await p.byId.get('insideDraftFile').fire('click')[0];
|
|
323
|
+
assert.equal(p.posts.length, 1);
|
|
324
|
+
assert.match(p.byId.get('insideDraftError').textContent, /not yours to do/);
|
|
325
|
+
assert.equal(p.byId.get('insideDraft').hidden, false);
|
|
326
|
+
assert.equal(p.history.length, 0, 'still in the building');
|
|
327
|
+
assert.equal(p.byId.get('streetToast').hidden, true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a constitution that cannot be read says so where the draft would be', async () => {
|
|
331
|
+
const p = await bootPage({ cons: null });
|
|
332
|
+
await p.byId.get('insideAmend').fire('click')[0];
|
|
333
|
+
assert.equal(p.byId.get('insideDraft').hidden, true);
|
|
334
|
+
assert.equal(p.byId.get('insideAmendNote').hidden, false);
|
|
335
|
+
assert.match(p.byId.get('insideAmendNote').textContent, /could not be read/);
|
|
336
|
+
});
|
|
@@ -475,8 +475,10 @@ test('picking a floor moves the SAME lift node and re-enters the chosen block; d
|
|
|
475
475
|
assert.equal(p.byId.get('insideDrawers').children[1].attrs['aria-pressed'], 'true');
|
|
476
476
|
assert.equal(p.byId.get('insideAmendNote').hidden, true);
|
|
477
477
|
p.byId.get('insideAmend').fire('click');
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
479
|
+
// the draft itself is tests/hall_street_amend.mjs (task 1004530); with no constitution to read it says so, never nothing
|
|
480
|
+
assert.equal(p.byId.get('insideAmendNote').hidden, false, 'change how this works answers, rather than nothing');
|
|
481
|
+
assert.match(p.byId.get('insideAmendNote').textContent, /could not be read/);
|
|
480
482
|
});
|
|
481
483
|
|
|
482
484
|
test('a table row opens the short card; Escape gives focus back to the row; back to the street returns to the door', async () => {
|
|
@@ -124,6 +124,11 @@ const STATIC_LADDER_FILES = [
|
|
|
124
124
|
// match the canonical order or the example teaches a ladder that does not
|
|
125
125
|
// exist.
|
|
126
126
|
'modules/hall-ui/public/government.js',
|
|
127
|
+
// task 1004530: the street's drafted amendment offers the rank ladder as the
|
|
128
|
+
// seats a building can be handed to (rank:<key>+). Browser code, so a mirror;
|
|
129
|
+
// tests/hall_street_amend.mjs pins GovAmend.RANKS equal to the server's
|
|
130
|
+
// membership-predicate RANK_ORDER.
|
|
131
|
+
'modules/hall-ui/public/street-amend.js',
|
|
127
132
|
// task 1002487: the pure agent-definition validator applies the ADR 0043
|
|
128
133
|
// sub-Metic floor to an agent's declared scope, so it needs the ladder to
|
|
129
134
|
// compare against. It is one of the "dependency-free" cases this array exists
|