@bongos/core 1.21.17 → 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 +43 -28
- package/docs/adr/0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md +5 -1
- package/docs/copy-inventory.md +384 -386
- package/docs/copy-registry.json +461 -479
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +271 -271
- 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/public-landing/public/projects.html +34 -37
- 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 +12 -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
- package/tests/wizard_draft_resume.mjs +59 -30
- package/tests/wizard_six_screens.mjs +1 -1
|
@@ -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
|
|
@@ -101,57 +101,86 @@ test('a draft already at step 1 is unchanged, and an out-of-range step is not re
|
|
|
101
101
|
}
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
// ── slice 2 ·
|
|
104
|
+
// ── slice 2 · no "Allow repo access" step: the token is fetched, not asked for (task 1004555) ──
|
|
105
|
+
// Owner, 2026-10-02: "I don't want to see the old button at all." Every hub sign-in asks
|
|
106
|
+
// for repo access (ADR 0364) and GitHub answers at once for anyone who approved it, so a
|
|
107
|
+
// missing token is fetched by ONE automatic round-trip. The slices below are syncHome (what
|
|
108
|
+
// shows) and ensureRepoAccess (the probe and the hop), run under a DOM + fetch stub.
|
|
105
109
|
const SYNC_RE = /function syncHome\(\) \{[\s\S]*?\n {4}\}/;
|
|
106
110
|
const syncSlice = (HUB.match(SYNC_RE) || [null])[0];
|
|
111
|
+
const HOP_RE = /var HOP_KEY = 'cb-repo-hop';[\s\S]*?function hopForRepoAccess\(\) \{[\s\S]*?\n {4}\}/;
|
|
112
|
+
const hopSlice = (HUB.match(HOP_RE) || [null])[0];
|
|
113
|
+
const PROBE_RE = /function ensureRepoAccess\(\) \{[\s\S]*?\n {4}\}/;
|
|
114
|
+
const probeSlice = (HUB.match(PROBE_RE) || [null])[0];
|
|
107
115
|
|
|
108
|
-
test('the syncHome
|
|
116
|
+
test('the syncHome, hop and probe slices are still findable', () => {
|
|
109
117
|
assert.ok(syncSlice, 'syncHome() was not found in projects.html');
|
|
110
|
-
assert.
|
|
118
|
+
assert.ok(hopSlice, 'hopForRepoAccess() was not found in projects.html');
|
|
119
|
+
assert.ok(probeSlice, 'ensureRepoAccess() was not found in projects.html');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the page carries no "Allow repo access" control at all', () => {
|
|
123
|
+
assert.doesNotMatch(HUB, /Allow repo access/);
|
|
124
|
+
assert.doesNotMatch(HUB, /id="ghConnect/);
|
|
125
|
+
assert.doesNotMatch(HUB, /need-connect/, 'no state is left that would ask for it');
|
|
111
126
|
});
|
|
112
127
|
|
|
113
128
|
function runSync(repoAccess) {
|
|
114
129
|
const els = {};
|
|
115
130
|
// Elements start HIDDEN so that "shown" is only ever the result of syncHome setting it.
|
|
116
|
-
|
|
117
|
-
// the assertion below would pass against the unfixed page.
|
|
118
|
-
const el = (id) => (els[id] ||= {
|
|
119
|
-
id, hidden: true, textContent: '', _on: false,
|
|
120
|
-
classList: { toggle(_c, v) { els[id]._on = !!v; } },
|
|
121
|
-
});
|
|
131
|
+
const el = (id) => (els[id] ||= { id, hidden: true, textContent: '' });
|
|
122
132
|
const sandbox = {
|
|
123
|
-
repoAccess,
|
|
124
|
-
|
|
125
|
-
$: el,
|
|
126
|
-
isManualHome: () => repoAccess === 'manual' || repoAccess === 'unavailable',
|
|
133
|
+
repoAccess, state: { mode: 'greenfield' }, $: el,
|
|
134
|
+
isManualHome: () => repoAccess === 'unavailable',
|
|
127
135
|
initAdoptPicker() {}, syncFresh() {},
|
|
128
136
|
};
|
|
129
137
|
sandbox.globalThis = sandbox;
|
|
130
138
|
vm.createContext(sandbox);
|
|
131
139
|
vm.runInContext(syncSlice + '\nsyncHome();', sandbox);
|
|
132
|
-
return {
|
|
140
|
+
return { noteShown: !el('ghNoAccess').hidden, manualShown: !el('manualHome').hidden, freshShown: !el('ghFresh').hidden };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test('without access the manual fields show with one plain line; with access they do not', () => {
|
|
144
|
+
assert.deepEqual(runSync('unavailable'), { noteShown: true, manualShown: true, freshShown: false });
|
|
145
|
+
assert.deepEqual(runSync(true), { noteShown: false, manualShown: false, freshShown: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function runProbe({ status, hopped = false }) {
|
|
149
|
+
const store = new Map(hopped ? [['cb-repo-hop', '1']] : []);
|
|
150
|
+
const assigned = [];
|
|
151
|
+
let saves = 0;
|
|
152
|
+
const sandbox = {
|
|
153
|
+
API: '/api/bongos', ghProbed: false, repoAccess: null, repoList: [],
|
|
154
|
+
sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, v), removeItem: (k) => store.delete(k) },
|
|
155
|
+
window: { location: { assign: (u) => assigned.push(u) } },
|
|
156
|
+
saveDraft() { saves += 1; }, syncHome() {},
|
|
157
|
+
fetch: () => Promise.resolve({ status, json: () => Promise.resolve({ repos: [] }) }),
|
|
158
|
+
};
|
|
159
|
+
sandbox.globalThis = sandbox;
|
|
160
|
+
vm.createContext(sandbox);
|
|
161
|
+
vm.runInContext(hopSlice + '\n' + probeSlice + '\nvar done = ensureRepoAccess();', sandbox);
|
|
162
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
163
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
164
|
+
return { assigned, saves, repoAccess: vm.runInContext('repoAccess', sandbox), mark: store.get('cb-repo-hop') || null };
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
test('
|
|
136
|
-
const
|
|
137
|
-
assert.
|
|
138
|
-
assert.equal(
|
|
139
|
-
assert.equal(
|
|
167
|
+
test('a missing token is fetched by one automatic GitHub round-trip, back to the wizard, draft saved first', async () => {
|
|
168
|
+
const r = await runProbe({ status: 403 });
|
|
169
|
+
assert.deepEqual(r.assigned, ['/api/bongos/auth/web/start?return=/projects%3Fview%3Dnew&repos=1']);
|
|
170
|
+
assert.equal(r.saves, 1, 'the draft is saved before the page leaves');
|
|
171
|
+
assert.equal(r.mark, '1', 'the hop is marked for this tab');
|
|
140
172
|
});
|
|
141
173
|
|
|
142
|
-
test('a
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const forced = runSync('unavailable');
|
|
147
|
-
assert.equal(forced.manualShown, true);
|
|
148
|
-
assert.equal(forced.backShown, false);
|
|
174
|
+
test('a second miss after the hop falls back to the manual fields instead of looping', async () => {
|
|
175
|
+
const r = await runProbe({ status: 403, hopped: true });
|
|
176
|
+
assert.deepEqual(r.assigned, [], 'no second round-trip');
|
|
177
|
+
assert.equal(r.repoAccess, 'unavailable');
|
|
149
178
|
});
|
|
150
179
|
|
|
151
|
-
test('
|
|
152
|
-
const
|
|
153
|
-
assert.equal(
|
|
154
|
-
assert.equal(
|
|
180
|
+
test('access found clears the mark, so a later expiry may hop again', async () => {
|
|
181
|
+
const r = await runProbe({ status: 200, hopped: true });
|
|
182
|
+
assert.equal(r.repoAccess, true);
|
|
183
|
+
assert.equal(r.mark, null);
|
|
155
184
|
});
|
|
156
185
|
|
|
157
186
|
// ── slice 3 · why this task's original team-shape claim was wrong ─────────────────
|
|
@@ -102,7 +102,7 @@ test('nothing the old steps could do is lost: their controls are moved whole int
|
|
|
102
102
|
const r = (p.match(new RegExp(`<div class="setRow" data-row="${row}">[\\s\\S]*?\\n {16}</div>\\n`)) || [''])[0];
|
|
103
103
|
for (const id of ids) assert.match(r, new RegExp(`id="${id}"`), `${row} row hosts #${id}`);
|
|
104
104
|
};
|
|
105
|
-
has('code', ['physExisting', 'c-green', 'c-adopt', 'adoptNote', '
|
|
105
|
+
has('code', ['physExisting', 'c-green', 'c-adopt', 'adoptNote', 'ghNoAccess', 'ghAdopt', 'ghFresh', 'manualHome', 'h-home']);
|
|
106
106
|
has('address', ['f-slug', 'addrConfirm', 'addrCustom', 'f-domain', 'f-noaddr']);
|
|
107
107
|
has('app', ['appOpts', 'renderLater', 'renderSetup', 'f-renderKey', 'renderWs', 'renderSvc', 'renderNew']);
|
|
108
108
|
has('extras', ['modQ', 'modCards', 'modWhy', 'modCoreNote']);
|