@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.
@@ -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
- assert.equal(p.byId.get('insideAmendNote').hidden, false, 'change how this works says it is not built yet, rather than nothing');
479
- assert.match(p.byId.get('insideAmendNote').textContent, /not built yet/);
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 · syncHome's control over the way back ────────────────────────────────
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 slice is still findable', () => {
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.match(syncSlice, /ghManualBack/, 'syncHome governs the way-back control');
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
- // With a `hidden: false` default, a control syncHome never mentions reads as visible and
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
- state: { mode: 'greenfield' },
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 { connectShown: el('ghConnect')._on, manualShown: !el('manualHome').hidden, backShown: !el('ghManualBack').hidden };
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('choosing manual leaves a way back to the connect offer', () => {
136
- const manual = runSync('manual');
137
- assert.equal(manual.manualShown, true, 'the manual fields are up');
138
- assert.equal(manual.connectShown, false, 'the connect box is down — that is what made it a trap');
139
- assert.equal(manual.backShown, true, 'and the way back is offered');
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 FORCED fallback offers no way back, because connecting cannot help', () => {
143
- // 'unavailable' means the server could not store a token. Offering "use my GitHub
144
- // repositories instead" there would send the visitor through a round-trip that lands
145
- // them right back here — an offer that cannot be honoured is worse than none.
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('while the connect offer is up, the way back is not', () => {
152
- const need = runSync('need-connect');
153
- assert.equal(need.connectShown, true);
154
- assert.equal(need.backShown, false, 'no second, redundant control beside the button itself');
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', 'ghConnect', 'ghAdopt', 'ghFresh', 'manualHome', 'h-home']);
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']);