@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.
- package/.bongos-core.json +153 -73
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +306 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +21 -20
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +445 -445
- package/modules/autonomy/governor-docket.js +10 -5
- package/modules/builder-settings/builder-needs.js +29 -3
- package/modules/copy-desk/guarded-lines.js +89 -0
- package/modules/copy-desk/guarded-lines.json +14 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/page-status.js +77 -7
- package/modules/copy-desk/pages.js +4 -0
- package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +8 -0
- package/modules/copy-desk/stuck-tweaks.js +79 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/government/asks-db.js +38 -2
- package/modules/government/catalog.js +1 -1
- package/modules/government/docket.js +20 -2
- package/modules/government/migrations/government_032_ask_flags.sql +35 -0
- package/modules/government/routes/government.js +43 -1
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/brand-apply.js +3 -0
- package/modules/hall-ui/public/city-decide.js +642 -0
- package/modules/hall-ui/public/city-draw.js +14 -3
- package/modules/hall-ui/public/city-switchboard.js +141 -0
- package/modules/hall-ui/public/city.css +176 -10
- package/modules/hall-ui/public/city.html +8 -3
- package/modules/hall-ui/public/city.js +185 -38
- package/modules/hall-ui/public/sky-data.js +4 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
- package/modules/hall-ui/public/tweak-editor.css +2 -0
- package/modules/hall-ui/public/tweak-editor.js +3 -1
- package/modules/lifecycle/db-claims.js +20 -5
- package/modules/lifecycle/rebase-gate.js +78 -0
- package/modules/lifecycle/routes/claims.js +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/ask.js +68 -1
- package/scripts/gds/claim.js +6 -1
- package/scripts/gds/copy-apply.js +2 -0
- package/src/bongos/module-promotion.js +219 -0
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/me.js +11 -0
- package/src/bongos/routes/modules.js +46 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -0
- package/tests/claim_gate_ci_unblock.mjs +4 -0
- package/tests/claim_gate_rebase.mjs +56 -3
- package/tests/copy_desk_page_editor.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +42 -0
- package/tests/copy_guarded_lines.mjs +54 -0
- package/tests/copy_stuck_page_tweaks.mjs +118 -0
- package/tests/government_ask_flags.mjs +80 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/government_treasury.mjs +1 -0
- package/tests/governor_docket.mjs +1 -1
- package/tests/hall_approval_queue.mjs +15 -0
- package/tests/hall_city.mjs +5 -4
- package/tests/hall_city_living.mjs +118 -12
- package/tests/hall_city_rewording.mjs +51 -0
- package/tests/hall_city_switchboard.mjs +63 -0
- package/tests/hall_gov_real_site.mjs +2 -2
- package/tests/hall_sky.mjs +18 -0
- package/tests/hall_street_deck.mjs +6 -4
- package/tests/hall_street_depth.mjs +1 -1
- package/tests/hall_street_desk.mjs +5 -3
- package/tests/hall_tweak_editor.mjs +26 -0
- package/tests/module_promotion.mjs +290 -0
- package/tests/rebase_gate_set_aside.mjs +73 -0
- 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, '
|
|
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();
|
package/tests/hall_city.mjs
CHANGED
|
@@ -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="
|
|
271
|
-
assert.equal((html.match(/<a\b/g) || []).length,
|
|
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> & <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, '
|
|
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, '
|
|
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', '
|
|
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, '
|
|
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."
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
+
});
|