@bongos/core 1.21.166 → 1.21.168

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.
Files changed (83) hide show
  1. package/.bongos-core.json +153 -73
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +306 -3
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +21 -20
  10. package/docs/copy-registry.json +34 -25
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/modules-contract.md +1 -0
  13. package/docs/page-inventory.json +3 -0
  14. package/docs/page-readings.json +445 -445
  15. package/modules/autonomy/governor-docket.js +10 -5
  16. package/modules/builder-settings/builder-needs.js +29 -3
  17. package/modules/copy-desk/guarded-lines.js +89 -0
  18. package/modules/copy-desk/guarded-lines.json +14 -0
  19. package/modules/copy-desk/module.json +2 -2
  20. package/modules/copy-desk/page-status.js +77 -7
  21. package/modules/copy-desk/pages.js +4 -0
  22. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  23. package/modules/copy-desk/routes/copy-desk.js +8 -0
  24. package/modules/copy-desk/stuck-tweaks.js +79 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  26. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  27. package/modules/government/asks-db.js +38 -2
  28. package/modules/government/catalog.js +1 -1
  29. package/modules/government/docket.js +20 -2
  30. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  31. package/modules/government/routes/government.js +43 -1
  32. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  33. package/modules/hall-ui/public/approval-queue.js +1 -0
  34. package/modules/hall-ui/public/brand-apply.js +3 -0
  35. package/modules/hall-ui/public/city-decide.js +642 -0
  36. package/modules/hall-ui/public/city-draw.js +14 -3
  37. package/modules/hall-ui/public/city-switchboard.js +141 -0
  38. package/modules/hall-ui/public/city.css +176 -10
  39. package/modules/hall-ui/public/city.html +8 -3
  40. package/modules/hall-ui/public/city.js +185 -38
  41. package/modules/hall-ui/public/sky-data.js +4 -1
  42. package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
  43. package/modules/hall-ui/public/tweak-editor.css +2 -0
  44. package/modules/hall-ui/public/tweak-editor.js +3 -1
  45. package/modules/lifecycle/db-claims.js +20 -5
  46. package/modules/lifecycle/rebase-gate.js +78 -0
  47. package/modules/lifecycle/routes/claims.js +10 -2
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/release-notes.json +28 -0
  51. package/scripts/gds/ask.js +68 -1
  52. package/scripts/gds/claim.js +6 -1
  53. package/scripts/gds/copy-apply.js +2 -0
  54. package/src/bongos/module-promotion.js +219 -0
  55. package/src/bongos/route-rank-check.js +7 -0
  56. package/src/bongos/routes/me.js +11 -0
  57. package/src/bongos/routes/modules.js +46 -0
  58. package/src/module-api.js +1 -1
  59. package/src/platform-server.js +5 -0
  60. package/tests/claim_gate_ci_unblock.mjs +4 -0
  61. package/tests/claim_gate_rebase.mjs +56 -3
  62. package/tests/copy_desk_page_editor.mjs +1 -1
  63. package/tests/copy_desk_page_submit.mjs +42 -0
  64. package/tests/copy_guarded_lines.mjs +54 -0
  65. package/tests/copy_stuck_page_tweaks.mjs +118 -0
  66. package/tests/government_ask_flags.mjs +80 -0
  67. package/tests/government_seed.mjs +1 -0
  68. package/tests/government_treasury.mjs +1 -0
  69. package/tests/governor_docket.mjs +1 -1
  70. package/tests/hall_approval_queue.mjs +15 -0
  71. package/tests/hall_city.mjs +5 -4
  72. package/tests/hall_city_living.mjs +118 -12
  73. package/tests/hall_city_rewording.mjs +51 -0
  74. package/tests/hall_city_switchboard.mjs +63 -0
  75. package/tests/hall_gov_real_site.mjs +2 -2
  76. package/tests/hall_sky.mjs +18 -0
  77. package/tests/hall_street_deck.mjs +6 -4
  78. package/tests/hall_street_depth.mjs +1 -1
  79. package/tests/hall_street_desk.mjs +5 -3
  80. package/tests/hall_tweak_editor.mjs +26 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/rebase_gate_set_aside.mjs +73 -0
  83. package/tests/reward_mode_surfacing.mjs +12 -0
@@ -318,6 +318,48 @@ try {
318
318
  assert.match(eng.description, /take it out/);
319
319
  assert.deepEqual([eng.status, eng.discipline], ['backlog', 'engineer']);
320
320
  });
321
+ // Task 1004776: a line that must keep its meaning. The real editor read marks
322
+ // it and says what it must still say; the real submit WARNS (never refuses)
323
+ // when the new text drops it, and the warning rides in the frozen batch to the
324
+ // Approval queue.
325
+ await test('a guarded line: the editor read marks it, the submit warns and still submits, the batch carries it', async () => {
326
+ const guarded = require('../modules/copy-desk/guarded-lines.js');
327
+ guarded._setList({ lines: [{
328
+ page_id: 'status:explore', file: 'modules/status-ui/public/explore.html',
329
+ must_contain: ['hidden from everyone'], must_still_say: 'A private project is hidden from everyone.', guarded_by: 'tests/x.mjs',
330
+ }] });
331
+ try {
332
+ const EXPLORE = '/copy-desk/pages/status:explore';
333
+ const LINES = [
334
+ L('L0001', 'list', 'Explore projects', 'placed', { file: 'modules/status-ui/public/explore.html', line: 3, string_id: 'ex0001' }),
335
+ L('L0002', 'list', 'A private project is hidden from everyone.', 'placed', { file: 'modules/status-ui/public/explore.html', line: 4, string_id: 'ex0002' }),
336
+ ];
337
+ const r0 = readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h2', lines: INDEX_LINES });
338
+ r0.pages.push({ id: 'status:explore', reading_hash: 'ex-h1', lines: LINES });
339
+ writeReadings(r0);
340
+ assert.equal((await call('POST', `${EXPLORE}/claim`, ALICE)).status, 201);
341
+
342
+ const read = await call('GET', `${EXPLORE}/draft`, ALICE);
343
+ assert.equal(read.status, 200, JSON.stringify(read.body));
344
+ const [plain, held] = read.body.reading.lines;
345
+ assert.equal(plain.note, null);
346
+ assert.equal(held.note, 'guarded', 'a third note kind beside shared and engineer');
347
+ assert.equal(held.must_still_say, 'A private project is hidden from everyone.');
348
+ assert.equal(read.body.notes.guarded, 'must still say');
349
+
350
+ const saved = await call('PUT', `${EXPLORE}/draft`, ALICE, { reading_hash: 'ex-h1', lines: [{ key: 'L0001', after: 'Explore' }, { key: 'L0002', after: 'you are a ninja.' }] });
351
+ assert.equal(saved.status, 200, JSON.stringify(saved.body));
352
+ const r = await call('POST', `${EXPLORE}/submit`, ALICE);
353
+ assert.equal(r.status, 200, 'warned, not refused');
354
+ assert.equal(r.body.outcome, 'submitted');
355
+ assert.deepEqual(r.body.warnings, [{ key: 'L0002', must_still_say: 'A private project is hidden from everyone.', missing: ['hidden from everyone'], guarded_by: 'tests/x.mjs' }]);
356
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/status:explore/r1');
357
+ const batch = pages.parseBatchBlock(round.description).batch;
358
+ assert.deepEqual(batch.warnings.map((w) => w.key), ['L0002'], 'the warning rides in the frozen batch');
359
+ } finally {
360
+ guarded._setList(null);
361
+ }
362
+ });
321
363
  } finally {
322
364
  server.close();
323
365
  pageData._setRoot(null);
@@ -0,0 +1,54 @@
1
+ // tests/copy_guarded_lines.mjs — the guarded-lines list stays honest (task 1004776).
2
+ //
3
+ // modules/copy-desk/guarded-lines.json names page lines that must keep their
4
+ // meaning. Every entry must still find exactly ONE line on its page in the
5
+ // committed docs/page-readings.json; a rewrite that drops the line (or a phrase
6
+ // that now matches two lines) reds here, so the list cannot rot into guarding
7
+ // nothing. Plus the pure warning rule.
8
+ //
9
+ // Run: node tests/copy_guarded_lines.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { makeRunner } from './helpers.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const guarded = require('../modules/copy-desk/guarded-lines.js');
21
+ const { test, summary } = makeRunner();
22
+ const readings = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/page-readings.json'), 'utf8'));
23
+
24
+ await test('every guarded line still exists, exactly once, in the page readings', () => {
25
+ const list = guarded.loadList();
26
+ assert.ok(list.lines.length >= 1, 'seeded with the Account page Private line');
27
+ assert.deepEqual(guarded.checkList(readings), []);
28
+ });
29
+
30
+ await test('each entry names the test that guards its meaning, and that test exists', () => {
31
+ for (const e of guarded.loadList().lines) {
32
+ assert.ok(e.guarded_by && fs.existsSync(path.join(ROOT, e.guarded_by)), `${e.page_id}: guarded_by ${e.guarded_by}`);
33
+ }
34
+ });
35
+
36
+ await test('the honesty check reds a line that is gone, and one that matches twice', () => {
37
+ const page = { id: 'landing:x', lines: [{ key: 'L1', text: 'Hidden from everyone.', file: 'a.html' }, { key: 'L2', text: 'Also hidden from everyone!', file: 'a.html' }] };
38
+ const gone = { lines: [{ page_id: 'landing:x', file: 'a.html', must_contain: ['never shown'], must_still_say: 's' }] };
39
+ const twice = { lines: [{ page_id: 'landing:x', file: 'a.html', must_contain: ['hidden from everyone'], must_still_say: 's' }] };
40
+ assert.match(guarded.checkList({ pages: [page] }, gone)[0].problem, /matches 0/);
41
+ assert.match(guarded.checkList({ pages: [page] }, twice)[0].problem, /matches 2/);
42
+ assert.match(guarded.checkList({ pages: [] }, gone)[0].problem, /no such page/);
43
+ });
44
+
45
+ await test('a submit is warned when a guarded line drops a phrase or is removed, not when it keeps them', () => {
46
+ const list = { lines: [{ page_id: 'landing:account', file: 'a.html', must_contain: ['hides the page from everyone', 'including you'], must_still_say: 'Private hides it from everyone, you included.' }] };
47
+ const readingPage = { lines: [{ key: 'L0049', text: 'Private hides the page from everyone — including you.', file: 'a.html' }, { key: 'L0050', text: 'Other', file: 'a.html' }] };
48
+ const w = guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', after: 'you are a ninja.' }, { key: 'L0050', after: 'x' }] }, list);
49
+ assert.deepEqual(w.map((x) => [x.key, x.missing]), [['L0049', ['hides the page from everyone', 'including you']]]);
50
+ assert.deepEqual(guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', after: 'Ninja mode: Private HIDES THE PAGE FROM EVERYONE, including you.' }] }, list), []);
51
+ assert.equal(guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', remove: true }] }, list).length, 1);
52
+ });
53
+
54
+ summary();
@@ -0,0 +1,118 @@
1
+ // tests/copy_stuck_page_tweaks.mjs — a page tweak that sits too long tells
2
+ // someone (task 1004777): a Needs-attention line on Home for the person it waits
3
+ // on, and a once-a-day Discord digest. Driven through the REAL composer
4
+ // (page-status.js stuckPageTweaks, the Studio's in-flight list and stuck rule),
5
+ // the REAL needs roster (builder-needs.computeNeeds) and the REAL digest
6
+ // (stuck-tweaks.runDigest). A page that is not stuck produces nothing.
7
+ //
8
+ // Run: node tests/copy_stuck_page_tweaks.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import { makeRunner } from './helpers.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const pages = require('../modules/copy-desk/pages.js');
16
+ const status = require('../modules/copy-desk/page-status.js');
17
+ const stuckTweaks = require('../modules/copy-desk/stuck-tweaks.js');
18
+ const needs = require('../modules/builder-settings/builder-needs.js');
19
+ const { test, summary } = makeRunner();
20
+
21
+ const NOW = new Date('2026-10-23T12:00:00.000Z');
22
+ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
23
+ const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
24
+ const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
25
+ const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
26
+ const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
27
+ const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
28
+ const ARTIST = '7';
29
+ const TASKS = [
30
+ // submitted 26 days ago and never applied: stuck, waits on the appliers
31
+ round(1, 'landing:index', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:index'), 1)}\n\n${batch('landing:index')}`, updated_at: ago(26) }),
32
+ // held for its artist 5 days: stuck, waits on the artist
33
+ round(2, 'landing:account', { status: 'completed', grade_passed: true, updated_at: ago(5) }),
34
+ // submitted yesterday: NOT stuck
35
+ round(3, 'builders:studio', { status: 'ready', description: `${pages.roundTaskBody(pg('builders:studio'), 1)}\n\n${batch('builders:studio')}`, updated_at: ago(1) }),
36
+ // being written for a month: a draft is never stuck
37
+ round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(30) }),
38
+ ];
39
+ const stuck = () => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now: NOW });
40
+
41
+ await test('the composer: only stuck pages, each with where it sits, how long, and who it waits on', () => {
42
+ assert.deepEqual(stuck().map((s) => [s.page_id, s.state, s.days, s.waits_on]), [
43
+ ['landing:index', 'submitted', 26, 'appliers'],
44
+ ['landing:account', 'waiting_for_artist', 5, 'artist'],
45
+ ]);
46
+ });
47
+
48
+ await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
49
+ const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
50
+ assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
51
+ const forMetic = status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: stuck() });
52
+ assert.deepEqual(forMetic.map((s) => s.page_id), ['landing:index']);
53
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'thetes', stuck: stuck() }), [], 'nobody else is told');
54
+ });
55
+
56
+ await test('the real needs roster: one Needs-attention line per page, where and how long, linking to the Studio', () => {
57
+ const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
58
+ const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
59
+ assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
60
+ ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Submitted, waiting to be applied for 26 days.', 'action_needed', '/studio'],
61
+ ['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
62
+ ]);
63
+ });
64
+
65
+ await test('a page that is not stuck produces nothing: no line, no digest', async () => {
66
+ const fresh = TASKS.filter((t) => t.id === '3' || t.id === '4');
67
+ const none = status.stuckPageTweaks({ inventory: INVENTORY, tasks: fresh, now: NOW });
68
+ assert.deepEqual(none, []);
69
+ assert.equal(needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: none }).items.filter((n) => n.id.startsWith('page_tweak_stuck:')).length, 0);
70
+ const posts = [];
71
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state: { lastDay: null }, post: async (m) => { posts.push(m); return { posted: true }; }, readStuckImpl: async () => none });
72
+ assert.deepEqual([r.posted, r.reason, posts.length], [false, 'nothing_stuck', 0]);
73
+ });
74
+
75
+ await test('the real digest: page names only, never copy, to the ship channel, at most once a day', async () => {
76
+ const posts = [];
77
+ const post = async (m) => { posts.push(m); return { posted: true }; };
78
+ const readStuckImpl = async ({ now }) => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now });
79
+ const state = { lastDay: null };
80
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state, post, readStuckImpl });
81
+ assert.equal(r.posted, true);
82
+ assert.equal(posts[0].channel, 'ship_feed');
83
+ assert.equal(posts[0].text, [
84
+ 'Page tweaks stuck (2). Open the Studio to move them:',
85
+ '- landing:index: submitted, waiting to be applied, 26 days',
86
+ '- Account: waiting for your review, 5 days',
87
+ ].join('\n'));
88
+ const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
89
+ assert.deepEqual([again.posted, again.reason, posts.length], [false, 'not_due', 1], 'once a day');
90
+ const early = await stuckTweaks.runDigest({ nowMs: Date.parse('2026-10-24T03:00:00Z'), state, post, readStuckImpl });
91
+ assert.equal(early.reason, 'not_due', 'not before the digest hour');
92
+ const next = await stuckTweaks.runDigest({ nowMs: Date.parse('2026-10-24T10:00:00Z'), state, post, readStuckImpl });
93
+ assert.equal(next.posted, true, 'the next day posts again');
94
+ });
95
+
96
+ await test('the Home read is shared: one ledger read per minute across every builder and request', async () => {
97
+ stuckTweaks._resetCache();
98
+ let reads = 0;
99
+ const read = async ({ now }) => { reads++; return status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now }); };
100
+ const t0 = NOW.getTime();
101
+ const [a, b] = await Promise.all([
102
+ stuckTweaks.readStuckCached({ nowMs: t0, read }),
103
+ stuckTweaks.readStuckCached({ nowMs: t0 + 10, read }),
104
+ ]);
105
+ await stuckTweaks.readStuckCached({ nowMs: t0 + stuckTweaks.CACHE_MS - 1, read });
106
+ assert.equal(reads, 1, 'concurrent and repeat callers share one read');
107
+ assert.equal(a, b);
108
+ await stuckTweaks.readStuckCached({ nowMs: t0 + stuckTweaks.CACHE_MS, read });
109
+ assert.equal(reads, 2, 'and it refreshes after the window');
110
+ stuckTweaks._resetCache();
111
+ });
112
+
113
+ await test('no Discord on the instance: no post, no error', async () => {
114
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state: { lastDay: null }, post: null, readStuckImpl: async () => stuck() });
115
+ assert.equal(r.posted, false);
116
+ });
117
+
118
+ summary();
@@ -0,0 +1,80 @@
1
+ // tests/government_ask_flags.mjs — the reword list (owner, 2026-10-23): the "?"
2
+ // on a city card flags a matter as unclear, an AI session reads the open flags
3
+ // and writes the matter a standard ask, and that write closes the flag.
4
+ import { strict as assert } from 'node:assert';
5
+ import { test } from 'node:test';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const require = createRequire(import.meta.url);
13
+ const docket = require(path.join(ROOT, 'modules', 'government', 'docket.js'));
14
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
15
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
16
+
17
+ const VIEWER = { id: 42, rank: 'metic' };
18
+ const NOW = Date.parse('2026-10-23T12:00:00Z');
19
+ function contributions(rows) {
20
+ return {
21
+ sources: new Map([
22
+ ['blockers', { source: 'blockers', building: 'planning', gate: { anyBuilder: true }, read: async () => rows.blockers || [] }],
23
+ ['override_requests', { source: 'override_requests', building: 'release', gate: { permission: 'override_request.decide' }, read: async () => rows.overrides || [] }],
24
+ ]),
25
+ readings: new Map(), floors: new Map(), histories: new Map(), desks: new Map(),
26
+ };
27
+ }
28
+ const ROWS = {
29
+ blockers: [{ id: 7, title: 'Pick a host', state: 'needs', rule: 'decide' }, { id: 8, title: 'Clear already', state: 'needs', rule: 'decide' }],
30
+ overrides: [{ id: 5, title: 'Merge without review', state: 'needs', rule: 'decide' }],
31
+ };
32
+ const FLAG = { flagged_at: '2026-10-23T11:00:00.000Z', note: null, times: 1 };
33
+
34
+ test('the docket marks a flagged matter unclear, only among the matters the viewer sees', async () => {
35
+ const asked = [];
36
+ const readFlags = async (ids) => { asked.push(...ids); return new Map([['blockers:7', FLAG], ['override_requests:5', FLAG]]); };
37
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: contributions(ROWS), now: NOW, readFlags });
38
+ const by = new Map(d.matters.map((m) => [m.id, m]));
39
+ assert.deepEqual(by.get('blockers:7').unclear, FLAG);
40
+ assert.equal(by.get('blockers:8').unclear, undefined, 'an unflagged matter carries nothing');
41
+ assert.ok(!asked.includes('override_requests:5'), 'a gated-out matter is never asked about');
42
+ const broken = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: contributions(ROWS), now: NOW, readFlags: async () => { throw new Error('db down'); } });
43
+ assert.ok(broken.matters.length > 0 && broken.matters.every((m) => m.unclear === undefined), 'an unreadable list marks nothing and the docket still stands');
44
+ });
45
+
46
+ test('both routes ride the docket\'s atom, and the flag route checks the matter is visible', () => {
47
+ const src = read('modules/government/routes/government.js');
48
+ for (const r of ["router.post('/government/asks/:matterId/unclear'", "router.get('/government/asks/unclear'"]) {
49
+ const line = src.split('\n').find((l) => l.includes(r));
50
+ assert.ok(line, r);
51
+ assert.match(line, /api\.requireBuilder, api\.requirePermission\('board\.vote\.cast'\)/, r + ': gated');
52
+ }
53
+ const body = src.split("router.post('/government/asks/:matterId/unclear'")[1].split('router.')[0];
54
+ assert.match(body, /docket\.findMatterFor\(req\.builder, matterId\)/, 'a flag is never put on a matter its flagger cannot see');
55
+ assert.match(body, /matter_not_found/);
56
+ const pins = rrc.GOVERNMENT_PINS || {};
57
+ const all = JSON.stringify(pins) + read('src/bongos/route-rank-check.js');
58
+ for (const k of ['POST /government/asks/:matterId/unclear', 'GET /government/asks/unclear']) assert.ok(all.includes(`'${k}': 'perm:board.vote.cast'`), k + ' pinned');
59
+ });
60
+
61
+ test('a rewrite closes the flag; passing the ask to another seat keeps it open', () => {
62
+ const db = read('modules/government/asks-db.js');
63
+ const save = db.split('async function saveAsk')[1].split('\n}\n')[0];
64
+ assert.match(save, /resolveFlag = true/);
65
+ assert.match(save, /if \(resolveFlag\) await pool\.query\('UPDATE government_ask_flags SET resolved_at = now\(\) WHERE matter_id = \$1 AND resolved_at IS NULL'/);
66
+ const routes = read('modules/government/routes/government.js');
67
+ const pass = routes.split("router.post('/government/asks/:matterId/pass'")[1].split('router.')[0];
68
+ assert.match(pass, /resolveFlag: false/, 'a pass keeps the words, so it keeps the flag');
69
+ const flag = db.split('async function flagUnclear')[1].split('\n}\n')[0];
70
+ assert.match(flag, /resolved_at = NULL/, 'flagging again reopens a closed flag');
71
+ assert.match(flag, /times = government_ask_flags\.times \+ 1/);
72
+ });
73
+
74
+ test('the table: one row per matter, the docket id grammar, a bounded note, nothing seeded', () => {
75
+ const sql = read('modules/government/migrations/government_032_ask_flags.sql');
76
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS government_ask_flags/);
77
+ assert.match(sql, /matter_id\s+text\s+PRIMARY KEY/);
78
+ assert.match(sql, /char_length\(note\) <= 500/);
79
+ assert.doesNotMatch(sql, /\bINSERT INTO\b/);
80
+ });
@@ -51,6 +51,7 @@ const NON_GRANT_MIGRATIONS = new Set([
51
51
  'government_029_leads.sql', // the Lead of each building, seated by a passed People matter (task 1004709)
52
52
  'government_030_buildouts.sql', // a Lead's build-outs, on the record at once or by a passed vote (task 1004711)
53
53
  'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
54
+ 'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
54
55
  ]);
55
56
 
56
57
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -85,6 +85,7 @@ function project({ pots = [], borrows = [], cap = null, spend = {}, lastMonth =
85
85
  st.asks.set(params[0], row);
86
86
  return { rows: [row] };
87
87
  }
88
+ if (/^UPDATE government_ask_flags SET resolved_at/.test(s)) return { rows: [], rowCount: 0 };
88
89
  if (/FROM government_asks WHERE matter_id = ANY/.test(s)) return { rows: params[0].map((id) => st.asks.get(id)).filter(Boolean) };
89
90
  if (/FROM government_pot_borrows b JOIN goals g ON g\.id = b\.goal_id WHERE b\.state = 'open'/.test(s)) {
90
91
  return { rows: st.borrows.filter((b) => b.state === 'open').map((b) => ({ ...b, title: st.goals.find((g) => String(g.id) === String(b.goal_id)).title })) };
@@ -428,7 +428,7 @@ test('the fence: a matter only when it needs a governor, and only a fence manage
428
428
  assert.equal(off[0].where, 'automation · the switch', 'the kicker is building · floor only (task 1004568)');
429
429
  assert.equal(off[0].detail, 'payments touched', 'the reason rides in detail: the fact, and the more');
430
430
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: false, goals: [] }, true)[0].detail, undefined, 'no reason, no detail');
431
- assert.equal(off[0].state, 'needs');
431
+ assert.equal(off[0].state, 'settled', 'the switch is a standing state on the switchboard, never a to-do');
432
432
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: false, goals: [] }, false)[0].state, 'settled');
433
433
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: true, goals: [] }, true)[0].title, 'Unattended building is on, but no goal is allowed');
434
434
  });
@@ -396,4 +396,19 @@ await test('a line whose old words a test still checks says so, through the real
396
396
  assert.equal(lib.lineRow(line, 'after').pinned, 'A test still checks the old words: tests/landing_page.mjs. It needs updating when this ships.');
397
397
  });
398
398
 
399
+ // task 1004776: a submit warning on a guarded line is shown on that line.
400
+ await test('a guarded line the rewrite dropped says so in the queue, through the real composer', () => {
401
+ const description = [
402
+ pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: [
403
+ { key: 'L0003', section: 'hero', before: 'Private hides the page from everyone — including you.', after: 'you are a ninja.', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' },
404
+ ], warnings: [{ key: 'L0003', must_still_say: 'Private hides the page from everyone, including its owner.', missing: ['including you'] }] }),
405
+ pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: '2026-09-21T00:00:00Z', reading_hash_after: 'h2', lines_after: ['you are a ninja.'], refused: [] }),
406
+ ].join('\n\n');
407
+ const tasks = [{ id: 52, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef('landing:index', 1), holder_id: null, artist_id: ME, artist_login: 'me' }];
408
+ const q = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'] });
409
+ const line = q.queue[0].lines[0];
410
+ assert.equal(line.must_still_say, 'Private hides the page from everyone, including its owner.');
411
+ assert.equal(lib.lineRow(line, 'after').guarded, 'This line no longer says what it must: Private hides the page from everyone, including its owner.');
412
+ });
413
+
399
414
  summary();
@@ -267,8 +267,9 @@ test('the page holds only the wordmark, the switch, the city, one button and one
267
267
  assert.equal((html.match(/<header\b/g) || []).length, 1);
268
268
  assert.equal((html.match(/<main\b/g) || []).length, 1);
269
269
  assert.equal((html.match(/<h1\b/g) || []).length, 1);
270
- assert.match(html, /<a class="city-start" id="cityStart" href="\/builders\/street"/);
271
- assert.equal((html.match(/<a\b/g) || []).length, 1, 'one door');
270
+ assert.match(html, /<a class="city-start" id="cityStart" href="#decide"/);
271
+ assert.equal((html.match(/<a\b/g) || []).length, 3, 'one door, and the two faint archived links to the street');
272
+ assert.ok(html.includes('<p class="city-archived">Archived: <a href="/builders/street">Street</a> &amp; <a href="/builders/street#building=charter">Building Level Views</a>.</p>'), 'the street is archived behind two faint links');
272
273
  assert.equal((html.match(/<button\b/g) || []).length, 1, 'one switch');
273
274
  assert.match(html, /class="city-mark">\{\{productName\}\}</);
274
275
  assert.equal(City.STREET_HREF, '/builders/street');
@@ -287,7 +288,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
287
288
  // task 1004685 (the living city): gov-living.js, the model under the drawing, and
288
289
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
289
290
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
290
- assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city'], 'the shell first, as on every hall page');
291
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, as on every hall page');
291
292
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
292
293
  assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
293
294
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
@@ -398,7 +399,7 @@ test('the page reads the three feeds and draws the docket\'s pins and line', asy
398
399
  assert.ok(!moving.children[0].children.some((c) => c.className === 'city-pin__ring'), 'a moving pin only bobs');
399
400
  assert.equal(pins.find((s) => s.className.includes('settled')).children[0].children.length, 0, 'a settled pin is a plain dot');
400
401
  assert.equal(p.byId.get('cityLine').textContent, '2 matters wait on you · 1 moving');
401
- assert.equal(p.byId.get('cityStart').attrs.href, '/builders/street');
402
+ assert.equal(p.byId.get('cityStart').attrs.href, '#decide', 'the door opens the decide view in place; the street is archived');
402
403
  assert.ok(p.byId.get('cityBlds').children.length > 9, 'the civic quarter and the district are drawn');
403
404
  assert.ok(p.byId.get('cityParcels').children.some((s) => /offset/.test(Object.keys(s.style).join(' '))), 'parcels carry an offset-path');
404
405
  });
@@ -181,7 +181,7 @@ test('salmon only for what waits on the viewer: on the floor whose seat it waits
181
181
  // a stranger: nothing waits on them, so nothing is salmon and the door is plain
182
182
  const s = city({ me: STRANGER });
183
183
  assert.equal(allPolys(s).filter((x) => /city-win--you/.test(x.p.cls || '')).length, 0);
184
- assert.ok(s.pins.every((p) => p.state === 'moving' || p.state === 'settled'), 'no salmon pin');
184
+ assert.ok(s.pins.every((p) => p.state === 'moving' || p.state === 'settled' || p.state === 'switch'), 'no salmon pin (the switchboard marker is ink)');
185
185
  assert.deepEqual(s.cta, City.ctaFor(0));
186
186
  assert.equal(s.cta.label, 'start governing');
187
187
  });
@@ -249,7 +249,7 @@ test('quiet buildings sit darker; busy ones keep the prototype\'s recipe exactly
249
249
  if (b.quiet) assert.notDeepEqual(recipe, ref.map((p) => p.f), `${s[0]} is quiet: its faces are mixed toward the void`);
250
250
  else assert.deepEqual(recipe, ref.map((p) => p.f), `${s[0]} is busy: the recipe is untouched`);
251
251
  }
252
- assert.deepEqual(m.civic.filter((c) => c.quiet).map((c) => c.key).sort(), ['people', 'rules'], 'no agent and no ask: Rules and People');
252
+ assert.deepEqual(m.civic.filter((c) => c.quiet).map((c) => c.key).sort(), ['automation', 'people', 'rules'], 'no agent and no ask: Automation (its switch is a standing state, not an ask), Rules and People');
253
253
  assert.equal(City.mixToward('#808080', '#000000', 0.5), '#404040');
254
254
  assert.equal(City.mixToward('rgba(255,255,255,0.4)', '#000000', 0.5), 'rgba(255,255,255,0.2)', 'a line thins out');
255
255
  });
@@ -323,7 +323,7 @@ test('the card: who holds it, one fact, its count, two doors (its first ask is t
323
323
  assert.equal(p.enter_href, '/builders/street#building=planning');
324
324
  for (const c of m.civic) assert.deepEqual(Object.keys(c.card).sort(), ['agents', 'deck_href', 'enter_href', 'fact', 'key', 'lead', 'name', 'on_others', 'on_you', 'open', 'plate'], c.key + ': every answer is the deck\'s');
325
325
  assert.equal(civicOf(m, 'charter').card.plate, 'owner', 'an unseated building is the owner\'s');
326
- assert.equal(civicOf(m, 'automation').card.fact.text, 'unattended off', 'the real fence');
326
+ assert.equal(civicOf(m, 'automation').card.fact.text, 'AI building: off', 'the real fence');
327
327
  assert.equal(civicOf(m, 'release').card.fact.text, '2 agents working');
328
328
  });
329
329
 
@@ -341,7 +341,7 @@ test('the door is "decide N in a row" to the deck; the line counts you, others a
341
341
  const m = city();
342
342
  const c = m.counts;
343
343
  assert.ok(c.on_you > 0 && c.on_others > 0);
344
- assert.deepEqual([m.cta.label, m.cta.href], ['decide ' + c.on_you + ' in a row', '/builders/street#deck']);
344
+ assert.deepEqual([m.cta.label, m.cta.href], ['decide ' + c.on_you + ' in a row', '#decide']);
345
345
  assert.equal(m.line, c.on_you + ' to do · ' + c.on_others + ' wait on others · 5 agents working');
346
346
  assert.equal(City.livingLine(1, 0, 1), '1 to do · 1 agent working');
347
347
  assert.equal(City.livingLine(0, 3, 0), 'nothing to do · 3 wait on others');
@@ -390,7 +390,7 @@ test('motion is CSS, long, inside the budget, with a still frame on every moving
390
390
 
391
391
  test('salmon is spent only on what waits on you (and the door, the focus ring)', () => {
392
392
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
393
- const ALLOWED = /city-win--you|city-pin--(urgent|needs)|city-pin__ring|city-pin__age|city-start|city-card__go--you|city-ask--you|city-plaque__tile\[data-key="you"\]|city-card__count--you|focus-visible|body\[data-page="city"\]\s*$/;
393
+ const ALLOWED = /city-win--you|city-pin--(urgent|needs)|city-pin__ring|city-pin__age|city-start|city-card__go--you|city-ask--you|city-plaque__tile\[data-key="you"\]|city-card__count--you|city-card--you|ask-crab|decide__yours|decide-ask--you|decide__tr--you|focus-visible|body\[data-page="city"\]\s*$/;
394
394
  let checked = 0;
395
395
  for (const r of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
396
396
  if (!/var\(--accent(-deep|-lift)?\)/.test(r[2])) continue;
@@ -507,7 +507,7 @@ async function bootLiving({ asks = false, answer = null } = {}) {
507
507
  vm.runInContext(read('city-draw.js'), ctx);
508
508
  assert.ok(window.GovLiving && window.GovCity);
509
509
  // the deck's card and sender, as city.html loads them (task 1004702)
510
- for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-deck.js']) vm.runInContext(read(f), ctx);
510
+ for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-deck.js', 'city-decide.js', 'city-switchboard.js']) vm.runInContext(read(f), ctx);
511
511
  assert.ok(window.GovDeck && window.GovDesk, 'the deck\'s scripts are on the page');
512
512
  vm.runInContext(read('city.js'), ctx);
513
513
  await window.GovCityPage.ready;
@@ -529,7 +529,7 @@ test('the page reads the living feeds and draws the sky, the plates, the plaque
529
529
  const m = city();
530
530
  const start = p.byId.get('cityStart');
531
531
  assert.equal(start.textContent, m.cta.label);
532
- assert.equal(start.attrs.href, '/builders/street#deck');
532
+ assert.equal(start.attrs.href, '#decide');
533
533
  assert.equal(p.byId.get('cityLine').textContent, m.line);
534
534
  for (const id of ['cityBlds', 'citySky', 'cityPins', 'cityPlaque']) assert.equal(findAll(p.byId.get(id), (n) => 'data-proto' in n.attrs).length, 0, id + ': nothing marked prototype');
535
535
  assert.doesNotMatch(read('city.html'), /cityKey|cityToast|data-proto/, 'the page has no prototype key and no in-place answer toast');
@@ -543,17 +543,38 @@ test('a building opens its card in place: who holds it, one fact, what waits, it
543
543
  assert.equal(card.attrs.hidden, undefined);
544
544
  assert.equal(card.attrs['data-building'], 'planning');
545
545
  assert.match(card.textContent, /Planning/);
546
- // the viewer (builder 2012) leads Planning: "Your seat." (task 1004702: the card speaks to you)
547
- assert.match(card.textContent, /Your seat\./);
546
+ // the viewer (builder 2012) leads Planning: "Your seat." rides the name as a hint, not a line
547
+ const nameEl = findAll(card, (x) => /\bcity-card__name\b/.test(String(x.className)))[0];
548
+ assert.equal(nameEl.attrs.title, 'Your seat.');
549
+ assert.doesNotMatch(card.textContent, /Your seat\./, 'no subtitle line');
548
550
  const n = civicOf(city(), 'planning').on_you;
549
- assert.match(card.textContent, new RegExp(n + ' wait on you\\.'));
551
+ const total = civicOf(city(), 'planning').open;
552
+ assert.match(card.textContent, new RegExp('Total ' + total + ' / Yours ' + n));
553
+ assert.ok(/\bcity-card--you\b/.test(card.className), 'a building on you wears the project colour');
554
+ assert.doesNotMatch(card.textContent, /Only you can answer/, 'the glow says it, not a line');
550
555
  assert.ok(/\bgov-glass\b/.test(card.className), 'the card is glass');
551
- const doors = findAll(card, (x) => x.tagName === 'a' && /city-card__(go|enter)/.test(String(x.className)));
552
- assert.deepEqual(doors.map((a) => [a.textContent, a.attrs.href]), [['decide all ' + n + ' in a row', '/builders/street#deck=planning'], ['go inside', '/builders/street#building=planning']], 'two doors');
556
+ assert.equal(findAll(card, (x) => x.tagName === 'a' && /city-card__enter/.test(String(x.className))).length, 0, 'no door to the street');
557
+ const go = findAll(card, (x) => /\bcity-card__go\b/.test(String(x.className)));
558
+ assert.deepEqual(go.map((b) => b.textContent), ['decide all ' + n], 'one decide button, in place');
559
+ assert.equal(findAll(card, (x) => /\bcity-card__arrow\b/.test(String(x.className))).length, 2, 'arrows to flip between buildings');
560
+ card.fire('keydown', { key: 'ArrowRight', preventDefault() {} });
561
+ assert.notEqual(p.byId.get('cityCard').attrs['data-building'], 'planning', 'the right arrow flips to the next building');
562
+ card.fire('keydown', { key: 'ArrowLeft', preventDefault() {} });
563
+ assert.equal(p.byId.get('cityCard').attrs['data-building'], 'planning', 'and the left arrow flips back');
553
564
  // the building's first ask stands in place, as the deck draws it
554
565
  const ask = findAll(card, (x) => /\bcity-ask\b/.test(String(x.className)));
555
566
  assert.equal(ask.length, 1, 'its first ask, in place');
556
567
  assert.ok(/\bask--here\b/.test(ask[0].className));
568
+ // answered in the card: the desk row's own acts are its answers, never "answer it on its desk"
569
+ assert.ok(findAll(card, (x) => /\bask__opt\b/.test(String(x.className))).length > 0, 'the card carries its answers');
570
+ assert.doesNotMatch(card.textContent, /Answer it on its desk/);
571
+ // Tab steps to the building's next item, Shift+Tab back
572
+ const stepN = () => findAll(p.byId.get('cityCard'), (x) => /\bcity-card__stepn\b/.test(String(x.className)))[0].textContent;
573
+ assert.equal(stepN(), '1 of ' + n);
574
+ p.byId.get('cityCard').fire('keydown', { key: 'Tab', preventDefault() {} });
575
+ assert.equal(stepN(), '2 of ' + n);
576
+ p.byId.get('cityCard').fire('keydown', { key: 'Tab', shiftKey: true, preventDefault() {} });
577
+ assert.equal(stepN(), '1 of ' + n);
557
578
  card.fire('keydown', { key: 'Escape', preventDefault() {} });
558
579
  assert.equal(card.attrs.hidden, '', 'Escape closes it');
559
580
  const again = p.byId.get('cityHits').children.find((b) => b.attrs['data-building'] === 'planning');
@@ -574,6 +595,10 @@ test('the city card answers its building\'s first ask in place, through the same
574
595
  const Kd = p.window.GovDeck;
575
596
  const op = opts[1];
576
597
  const key = op.attrs['data-key'];
598
+ // the first press picks that answer and sends nothing; the same number again confirms it
599
+ card.fire('keydown', { key: op.attrs['data-n'], preventDefault() {} });
600
+ await new Promise((r) => setTimeout(r, 20));
601
+ assert.equal(p.sent.length, 0, 'one press only picks');
577
602
  card.fire('keydown', { key: op.attrs['data-n'], preventDefault() {} });
578
603
  await new Promise((r) => setTimeout(r, 20));
579
604
  assert.equal(p.sent.length, 1, 'one request');
@@ -615,3 +640,84 @@ test('Planning stands behind nothing taller, so its lit floor shows', () => {
615
640
  for (const o of front) assert.ok(o.roof < planning.roof, 'planning stands behind the taller ' + o.key);
616
641
  assert.deepEqual(City.CIVIC_SLOTS.find((s) => s[0] === 'charter').slice(1), [5, 5], 'the Charter rises from the back corner');
617
642
  });
643
+
644
+ // ── the decide view and the switchboard, through the real page (task 1004784) ──
645
+
646
+ test('the decide view: one by one with Tab inside a building and arrows across, number keys pick then confirm', async () => {
647
+ const p = await bootLiving({ asks: true });
648
+ const d = p.window.GovCityPage.openDecide(null);
649
+ const view = p.byId.get('cityDecide');
650
+ assert.ok(view && view.attrs.hidden === undefined, 'the view opens over the city');
651
+ const pos = () => findAll(view, (x) => /\bdecide__pos-n\b/.test(String(x.className)))[0].textContent;
652
+ const first = pos();
653
+ const building = first.replace(/ \d+ of \d+$/, '');
654
+ view.fire('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} });
655
+ assert.notEqual(pos().replace(/ \d+ of \d+$/, ''), building, 'right goes to the next building');
656
+ view.fire('keydown', { key: 'ArrowLeft', preventDefault() {}, stopPropagation() {} });
657
+ assert.equal(pos(), first, 'left comes back to the first item of the first building');
658
+ const n = Number(/of (\d+)$/.exec(first)[1]);
659
+ if (n > 1) {
660
+ view.fire('keydown', { key: 'Tab', preventDefault() {}, stopPropagation() {} });
661
+ assert.equal(pos(), building + ' 2 of ' + n, 'Tab is the next item in the same building');
662
+ view.fire('keydown', { key: 'Tab', shiftKey: true, preventDefault() {}, stopPropagation() {} });
663
+ }
664
+ // walk to a card with answers, then pick and confirm
665
+ const live = () => { const o = findAll(view, (x) => /\bask__opt\b/.test(String(x.className))); return o.length && !/ask__opt--off/.test(o[0].className); };
666
+ const k = (key) => view.fire('keydown', { key, preventDefault() {}, stopPropagation() {} });
667
+ for (let b = 0; b < 12 && !live(); b++) { for (let i = 0; i < 30 && !live(); i++) k('Tab'); if (!live()) k('ArrowRight'); }
668
+ assert.ok(live(), 'some card answers in place');
669
+ const askId = () => (findAll(view, (x) => x.attrs && x.attrs['data-ask'])[0] || { attrs: {} }).attrs['data-ask'];
670
+ const was = askId();
671
+ const before = p.sent.length;
672
+ view.fire('keydown', { key: '1', preventDefault() {}, stopPropagation() {} });
673
+ await new Promise((r) => setTimeout(r, 10));
674
+ assert.equal(p.sent.length, before, 'the first press only picks');
675
+ assert.equal(askId(), was, 'and stays on the same card');
676
+ assert.deepEqual(JSON.parse(JSON.stringify(view.__armed)), { id: was, n: 1 }, 'the pick is held, waiting for its confirm');
677
+ view.fire('keydown', { key: '1', preventDefault() {}, stopPropagation() {} });
678
+ await new Promise((r) => setTimeout(r, 30));
679
+ assert.notEqual(askId(), was, 'the second press of the same number answers it, and the next card comes up');
680
+ assert.ok(d && d.isOpen());
681
+ });
682
+
683
+ test('the decide table: one row per open ask, and the bulk bar answers every ticked row through its own route', async () => {
684
+ const p = await bootLiving({ asks: true });
685
+ p.window.GovCityPage.openDecide(null, 'table');
686
+ const view = p.byId.get('cityDecide');
687
+ const rows = () => findAll(view, (x) => x.tagName === 'tr' && x.parentNode && x.parentNode.tagName === 'tbody');
688
+ const all = rows();
689
+ assert.ok(all.length > 1, 'rows are drawn');
690
+ const answerable = all.filter((r) => findAll(r, (x) => /\bdecide__opt\b/.test(String(x.className)) && !('disabled' in x.attrs)).length);
691
+ assert.ok(answerable.length >= 1, 'a row answers in place');
692
+ const box = findAll(answerable[0], (x) => /\bdecide__row-box\b/.test(String(x.className)))[0];
693
+ box.checked = true;
694
+ box.fire('change');
695
+ const bulk = findAll(view, (x) => /\bdecide__bulk-btn\b/.test(String(x.className)));
696
+ assert.ok(bulk.length >= 1, 'a ticked row offers its answers in the bulk bar');
697
+ const before = p.sent.length;
698
+ bulk[0].fire('click');
699
+ await new Promise((r) => setTimeout(r, 30));
700
+ assert.equal(p.sent.length, before + 1, 'one request per ticked row');
701
+ assert.match(p.sent[p.sent.length - 1].url, /^\/api\/bongos\//);
702
+ });
703
+
704
+ test('Automation opens as a switchboard through the page, and a flip sends the switch\'s own request', async () => {
705
+ const p = await bootLiving();
706
+ p.window.GovCityPage.openCard('automation');
707
+ const card = p.byId.get('cityCard');
708
+ assert.ok(/\bcity-card--board\b/.test(card.className), 'the card is a switchboard');
709
+ assert.equal(findAll(card, (x) => String(x.className).split(' ').includes('ask')).length, 0, 'no ask is drawn on it');
710
+ const toggles = findAll(card, (x) => /\bswb__toggle\b/.test(String(x.className)));
711
+ const none = findAll(card, (x) => /\bswb__none\b/.test(String(x.className)));
712
+ assert.ok(toggles.length || none.length, 'it draws the viewer\'s switches, or says they hold none');
713
+ if (toggles.length) {
714
+ const plain = toggles.find((t) => !findAll(t.parentNode.parentNode, (x) => /\bswb__why\b/.test(String(x.className))).length) || toggles[0];
715
+ const before = p.sent.length;
716
+ plain.fire('click');
717
+ await new Promise((r) => setTimeout(r, 20));
718
+ const sentNow = p.sent.length - before;
719
+ const opened = findAll(card, (x) => /\bswb__why\b/.test(String(x.className)) && !('hidden' in x.attrs)).length;
720
+ assert.ok(sentNow === 1 || opened === 1, 'a flip sends one request, or asks why first');
721
+ if (sentNow) assert.match(p.sent[p.sent.length - 1].url, /\/api\/bongos\/autonomy\//);
722
+ }
723
+ });