@bongos/core 1.21.18 → 1.21.20

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 (34) hide show
  1. package/.bongos-core.json +60 -35
  2. package/docs/copy-inventory.md +5 -5
  3. package/docs/copy-registry.json +6 -6
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +1 -0
  6. package/docs/page-readings.json +196 -197
  7. package/modules/autonomy/governor-docket.js +118 -4
  8. package/modules/economy/cost.js +36 -0
  9. package/modules/economy/governor-docket.js +81 -0
  10. package/modules/economy/routes/cost.js +2 -0
  11. package/modules/government/docket-floors.js +222 -0
  12. package/modules/government/docket-vocab.js +20 -0
  13. package/modules/government/docket.js +186 -5
  14. package/modules/hall-ui/public/street-amend.js +240 -0
  15. package/modules/hall-ui/public/street-card.js +4 -1
  16. package/modules/hall-ui/public/street-inside.js +99 -43
  17. package/modules/hall-ui/public/street.css +8 -0
  18. package/modules/hall-ui/public/street.html +5 -3
  19. package/modules/hall-ui/public/street.js +164 -11
  20. package/modules/hall-ui/public/street.states.json +12 -5
  21. package/modules/ideas/governor-docket.js +42 -1
  22. package/modules/lifecycle/governor-docket.js +197 -2
  23. package/modules/security/routes/reports.js +16 -0
  24. package/modules/security/security.js +5 -2
  25. package/modules/ui-design/kit/fixtures/government-constitution-rules.json +184 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/governor_docket.mjs +186 -2
  31. package/tests/hall_street.mjs +1 -1
  32. package/tests/hall_street_amend.mjs +336 -0
  33. package/tests/hall_street_inside.mjs +101 -5
  34. package/tests/rank_tier_single_source.mjs +5 -0
@@ -82,7 +82,7 @@ const item = (over = {}) => ({
82
82
  expired: false, closed_at: null, outcome: null, constitution: CONSENT, votes: [],
83
83
  ...over,
84
84
  });
85
- const none = () => ({ sources: new Map(), readings: new Map() });
85
+ const none = () => ({ sources: new Map(), readings: new Map(), floors: new Map(), histories: new Map() });
86
86
 
87
87
  // ── 1. the mapper ────────────────────────────────────────────────────────────
88
88
 
@@ -285,7 +285,7 @@ test('the response shape: counts, line, street-ordered buildings, sorted matters
285
285
  c.readings.set('flow', { reading: 'flow', gate: { anyBuilder: true }, read: async () => ({ value: 3, figure: '3 stuck', line: 'longest wait 4 days, waiting for review', hot: true }) });
286
286
  const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'security.report.adjudicate'], contributions: c, now: NOW });
287
287
 
288
- assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'generated_at', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
288
+ assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
289
289
  assert.deepEqual(d.viewer, { id: '7' });
290
290
  // board 11 (needs) + board 12 (settled, closed this week) + blocker + report; board 13 is too old
291
291
  assert.deepEqual(d.counts, { needs: 2, moving: 1, settled: 1 });
@@ -512,6 +512,190 @@ test('each contributed source gates on EXACTLY the atom its own list route requi
512
512
  seams._reset();
513
513
  });
514
514
 
515
+ // ── 5b. floors and the record (task 1004527) ────────────────────────────────
516
+
517
+ const floorsDocket = require(path.join(ROOT, 'modules', 'government', 'docket-floors.js'));
518
+ const economyDocket = require(path.join(ROOT, 'modules', 'economy', 'governor-docket.js'));
519
+
520
+ const GOV = {
521
+ board: { membership: 'rank:metic+', pass_rule: 'majority' },
522
+ members: [{ builder_id: '7', github_login: 'ana', display_name: 'Ana' }, { builder_id: '9', github_login: 'theo', display_name: null }],
523
+ decisions: [
524
+ { area: 'people', name: 'People', who: 'whoever may manage ranks', rule: 'decide', rule_words: 'decides alone', as_today: true, decided_by: 'gate', gate: 'government.manage' },
525
+ { area: 'rules', name: 'Rules', who: 'whoever may change the project settings', rule: 'decide', rule_words: 'decides alone', as_today: true, decided_by: 'gate' },
526
+ { area: 'review', name: 'Review', who: 'testers', rule: 'signoff', rule_words: 'signed off by someone who did not build it', as_today: false, decided_by: 'rank:tester', set_by_item_id: '40' },
527
+ { area: 'release', name: 'Release', who: 'no one yet', rule: 'vacant', rule_words: 'no one holds this yet', as_today: false, decided_by: 'nobody' },
528
+ { area: 'treasury', name: 'Treasury', who: 'whoever may set spending limits', rule: 'decide', rule_words: 'decides alone', as_today: true, decided_by: 'gate' },
529
+ ],
530
+ holders: { review: [{ builder_id: '3', github_login: 'lu', display_name: 'Lu' }], release: [], charter: null },
531
+ genesis: { stages: [{ key: 'charter', label: 'the charter', state: 'passed', item_id: '1', closed_at: '2026-07-04T00:00:00Z' }, { key: 'pay', label: 'who can be paid', state: 'sitting', item_id: '2' }] },
532
+ ranks: [{ rank_key: 'metic', label: 'Metic', holders: 4 }, { rank_key: 'archon', label: 'Archon', holders: 1 }],
533
+ amendments: [{ item_id: '40', kind: 'decision_rules', rationale_md: '# Testers hold Review', closed_at: '2026-10-01T09:00:00Z', outcome: 'passed' }, { item_id: '41', kind: 'constitution', rationale_md: '', closed_at: '2026-09-02T09:00:00Z', outcome: 'returned' }],
534
+ closed: [{ id: '50', subject_type: 'full_idea', subject_id: '8', subject: { title: 'A template marketplace' }, closed_at: '2026-09-20T00:00:00Z', outcome: 'passed' }, { id: '40', subject_type: 'constitutional_amendment', closed_at: '2026-10-01T09:00:00Z', outcome: 'passed' }],
535
+ assignments: [{ builder_id: '3', github_login: 'lu', display_name: 'Lu', rank_key: 'tester', rank_label: 'Tester', is_system: false, assigned_at: '2026-09-30T00:00:00Z' }],
536
+ };
537
+
538
+ test('floors: every floor the design names is reported, and nothing is invented for one nobody answers', async () => {
539
+ const c = none();
540
+ let ran = false;
541
+ c.floors.set('release.live_month', { floor: 'release.live_month', gate: { anyBuilder: true }, read: async () => ({ count: 3, line: '3 pieces of work went live', lines: [['Invite flow', 'sep 26'], 'not a line', [''], ['x'.repeat(400), 'm']], hot: 'yes' }) });
542
+ c.floors.set('automation.running', { floor: 'automation.running', gate: { permission: 'autonomy.fence.manage' }, read: async () => { ran = true; return { count: '2' }; } });
543
+ c.floors.set('treasury.hosting', { floor: 'treasury.hosting', gate: { anyBuilder: true }, read: async () => { throw new Error('db down'); } });
544
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: c, now: NOW });
545
+ assert.deepEqual(d.floors.map((f) => f.key), vocab.FLOORS.map((f) => f.key), 'every declared floor, in order');
546
+ const by = Object.fromEntries(d.floors.map((f) => [f.key, f]));
547
+ assert.deepEqual([by['release.live_month'].status, by['release.live_month'].count, by['release.live_month'].hot], ['live', '3', false]);
548
+ assert.deepEqual(by['release.live_month'].lines.map((l) => l[0].length), [11, 200], 'only real [text, meta] lines, bounded');
549
+ assert.deepEqual([by['automation.running'].status, by['automation.running'].count, ran], ['not_yours', '', false], 'a gated floor never runs');
550
+ assert.deepEqual([by['treasury.hosting'].status, by['treasury.hosting'].count], ['unavailable', '']);
551
+ assert.deepEqual([by['charter.purpose'].status, by['charter.purpose'].count], ['coming', '']);
552
+ assert.match(by['charter.purpose'].reason, /no written purpose/);
553
+ assert.deepEqual([by['treasury.grading'].status, by['treasury.grading'].count], ['off', ''], 'no contributor: off, no figure');
554
+ assert.equal(by['charter.council'].status, 'unavailable', 'no government read: unavailable, never an empty council');
555
+ assert.equal(by['planning.ideas'].status, 'live', 'ideas follow the board, which is live here');
556
+ });
557
+
558
+ test('floors: the government\'s own — council, sections, founding, ranks, testers — from the rows it read', async () => {
559
+ boardView = { open: [item({ id: '60', subject_type: 'full_idea', subject_id: '9', subject: { title: 'Weekly digest', author_id: '3' }, constitution: { membership: 'rank:metic+', pass_rule: 'majority' } })], closed: [] };
560
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: none(), now: NOW, decisions: GOV.decisions, government: GOV });
561
+ const by = Object.fromEntries(d.floors.map((f) => [f.key, f]));
562
+ assert.deepEqual([by['charter.council'].count, by['charter.council'].line], ['2', '2 seats · passes on more than half saying yes']);
563
+ assert.deepEqual(by['charter.council'].lines[0], ['Ana and theo.', 'seated now']);
564
+ assert.deepEqual(by['people.council'].lines[2], ['Seats have no term yet. Elections and recalls are coming.', 'coming']);
565
+ assert.deepEqual([by['charter.founding'].line, by['charter.founding'].hot], ['1 of 2 founding steps passed', true]);
566
+ assert.deepEqual(by['charter.review'].lines, [['Held by testers: signed off by someone who did not build it.', 'amended']]);
567
+ assert.deepEqual([by['charter.release'].line, by['charter.release'].lines[0][1]], ['held by no one yet', 'amended']);
568
+ assert.equal(by['charter.money'].line, 'whoever may set spending limits · decides alone');
569
+ assert.match(by['charter.membership'].line, /metic and above/);
570
+ assert.deepEqual([by['people.ranks'].count, by['people.ranks'].lines[0]], ['2', ['Metic', '4 people']]);
571
+ assert.match(by['people.ranks'].lines.at(-1)[0], /Promotion by a vote is coming/);
572
+ assert.deepEqual([by['review.testers'].count, by['review.testers'].line], ['1', 'Lu']);
573
+ assert.deepEqual([by['planning.ideas'].count, by['planning.ideas'].lines[0][0]], ['1', 'Weekly digest']);
574
+ // who holds each building: the people its row seats now
575
+ const b = Object.fromEntries(d.buildings.map((x) => [x.key, x]));
576
+ assert.deepEqual([b.review.holders, b.review.holder_count], [[{ id: '3', login: 'lu', name: 'Lu' }], 1]);
577
+ assert.deepEqual([b.release.holders, b.release.holder_count], [[], 0], 'vacant: no one, said as no one');
578
+ assert.deepEqual([b.charter.holders, b.planning.holders], [null, null], 'unresolved is null, never an invented seat');
579
+ // without the docket's own atom none of it shows
580
+ const out = await docket.buildDocket({ viewer: VIEWER, permissions: [], contributions: none(), now: NOW, decisions: GOV.decisions, government: GOV });
581
+ assert.ok(out.floors.filter((f) => floorsDocket.BUILDERS[f.key]).every((f) => f.status === 'not_yours' && f.count === ''));
582
+ assert.ok(out.buildings.every((x) => x.holders === null), 'holders are not shown without it');
583
+ assert.deepEqual(out.history, []);
584
+ });
585
+
586
+ test('the record: what closed, from every keeper, newest first; gated keepers never run; undated rows dropped', async () => {
587
+ boardView = { open: [], closed: [] };
588
+ const c = none();
589
+ let ran = false;
590
+ c.histories.set('releases', { history: 'releases', building: 'release', gate: { anyBuilder: true }, read: async () => [{ id: 9, title: 'Invite flow', at: '2026-09-26T00:00:00Z', outcome: 'live' }, { id: 10, title: 'No date', at: null }] });
591
+ c.histories.set('rulings', { history: 'rulings', building: 'disputes', gate: { permission: 'security.report.adjudicate' }, read: async () => { ran = true; return []; } });
592
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: c, now: NOW, decisions: GOV.decisions, government: GOV });
593
+ assert.equal(ran, false);
594
+ assert.deepEqual(d.history.map((h) => [h.id, h.building, h.outcome]), [
595
+ ['government:amendment:40', 'charter', 'passed'],
596
+ ['government:rank:3:tester', 'people', 'granted'],
597
+ ['releases:9', 'release', 'live'],
598
+ ['government:board:50', 'planning', 'ratified'],
599
+ ['government:amendment:41', 'charter', 'returned'],
600
+ ['government:founding:1', 'charter', 'passed'],
601
+ ]);
602
+ assert.equal(d.history[4].title, 'A change to the charter', 'an amendment with no rationale reads as one');
603
+ assert.deepEqual(d.history_sources.map((s) => [s.source, s.status]), [['government', 'live'], ['releases', 'live'], ['rulings', 'not_yours']]);
604
+ });
605
+
606
+ test('lifecycle floors: goals kept of total, priorities, versions, signed off, live this month, held back, newcomers', () => {
607
+ const goals = [{ id: 1, title: 'Hall', status: 'open', version_id: 'V2' }, { id: 2, title: 'Payments', status: 'open', version_id: 'V2' }, { id: 3, title: 'Old', status: 'achieved', version_id: 'V1', updated_at: '2026-09-01T00:00:00Z' }];
608
+ const crit = [{ id: 'c1', goal_id: 1, satisfied: true, criterion_md: 'Search works', checked_at: '2026-10-01T00:00:00Z' }, { id: 'c2', goal_id: 1, satisfied: false }, { id: 'c3', goal_id: 9, satisfied: true, criterion_md: 'Old one', checked_at: '2026-09-03T00:00:00Z' }];
609
+ assert.deepEqual(lifecycleDocket.goalsFloor(goals, crit).lines, [['Hall', '1 of 2 kept'], ['Payments', 'no promises yet']]);
610
+ assert.equal(lifecycleDocket.goalsFloor(goals, crit).count, '2');
611
+ assert.deepEqual(lifecycleDocket.prioritiesFloor(goals, 'V2').lines[0], ['1 Hall · 2 Payments', 'version V2']);
612
+ assert.equal(lifecycleDocket.versionsFloor([{ id: 'V2', name: 'V2', status: 'building' }, { id: 'V1', name: 'V1', status: 'shipped', shipped_at: '2026-09-28T00:00:00Z' }], goals).line, 'V2 building · 2 goals in it');
613
+ const so = lifecycleDocket.signedOffFloor(crit, NOW);
614
+ assert.deepEqual([so.count, so.line, so.lines[0]], ['1', '1 signed off this month', ['Search works', 'oct 1']]);
615
+ const shipped = [{ id: 1, title: 'A', shipped_at: '2026-10-01T03:00:00Z' }, { id: 2, title: 'B', shipped_at: '2026-09-30T23:00:00Z' }];
616
+ assert.deepEqual([lifecycleDocket.liveMonthFloor(shipped, NOW).count, lifecycleDocket.liveMonthFloor(shipped, NOW).lines], ['1', [['A', 'oct 1']]], 'only this month');
617
+ assert.deepEqual(lifecycleDocket.heldBackFloor([{ title: 'X', needs_rebase: true }, { title: 'Y', needs_rebase: false }]).count, '1');
618
+ assert.equal(lifecycleDocket.newcomersFloor([{ title: 'N', newcomer_friendly: true }, { title: 'M' }], [{ newcomer_friendly: true }, { newcomer_friendly: true }]).lines.at(-1)[0], '2 newcomer tasks are ready to claim.');
619
+ assert.deepEqual(lifecycleDocket.signoffHistory(crit).map((h) => h.id), ['c1', 'c3']);
620
+ assert.deepEqual(lifecycleDocket.goalHistory(goals).map((h) => h.title), ['Old achieved']);
621
+ assert.deepEqual(lifecycleDocket.releaseHistory(shipped).map((h) => h.at), ['2026-10-01T03:00:00Z', '2026-09-30T23:00:00Z']);
622
+ });
623
+
624
+ test('automation, treasury and disputes floors read their own ledgers, and say so when there is nothing', () => {
625
+ const ag = autonomyDocket.allowedGoalsFloor({ priority_goal_id: 7, goals: [{ goal_id: 7, added_at: '2026-09-20T00:00:00Z' }, { goal_id: 8, added_at: '2026-09-02T00:00:00Z' }] });
626
+ assert.deepEqual([ag.count, ag.lines], ['2', [['goal 7', 'first in line'], ['goal 8', 'since sep 2']]]);
627
+ assert.equal(autonomyDocket.allowedGoalsFloor({ goals: [] }).line, 'no goal allowed');
628
+ const describe = (r) => ({ ok: r.alive, state: r.alive ? 'alive' : 'dead' });
629
+ const run = autonomyDocket.runningFloor([{ display_name: 'Ana', runners: [{ alive: true, working_task_id: '12' }, { alive: false, working_task_id: '13' }, { alive: true, working_task_id: null }] }], NOW, describe);
630
+ assert.deepEqual([run.count, run.lines], ['1', [['task 12', 'Ana']]], 'only a live runner on a task is building');
631
+ assert.deepEqual([autonomyDocket.spendingLimitFloor({ ceilingUsd: 40, spentUsd: 12 }).line, autonomyDocket.spendingLimitFloor({ ceilingUsd: 40, spentUsd: 35 }).hot], ['$40 a month · $12 used', true]);
632
+ const rep = autonomyDocket.reportsFloor([{ routine: 'builder', decision: 'go', created_at: '2026-09-30T00:00:00Z' }, { routine: 'idea-triage', decision: 'no-go', reason: 'nothing open', created_at: '2026-09-01T00:00:00Z' }], NOW);
633
+ assert.deepEqual([rep.count, rep.lines.map((l) => l[0])], ['1', ['The builder went ahead.', 'Idea sorting held back: nothing open.']], 'said in words, not the gate\'s keys');
634
+ const months = [{ month: '2026-10', rewards_credits: 1180, rewarded_builders: 6, grading_usd: 310.4, hosting_usd: 120 }, { month: '2026-09', rewards_credits: 4920, rewarded_builders: 8, grading_usd: 0, hosting_usd: 160 }];
635
+ assert.deepEqual([economyDocket.rewardsFloor(months, NOW).count, economyDocket.rewardsFloor(months, NOW).line], ['$1,180', '$1,180 this month · 6 builders']);
636
+ assert.deepEqual(economyDocket.hostingFloor(months, NOW).lines[1], ['$160 in sep.', 'last month']);
637
+ assert.equal(economyDocket.gradingFloor([], NOW).count, '$0', 'a month with no rows is a real zero, read from the ledger');
638
+ assert.deepEqual(economyDocket.treasuryHistory(months).map((h) => h.title), ['Paid $1,180 to 6 builders', 'AI grading cost $310', 'Hosting cost $120', 'Paid $4,920 to 8 builders', 'Hosting cost $160']);
639
+ assert.deepEqual(reportsRoute.rulingHistory([{ id: 1, title: 'sign-in limits', status: 'fixed', fixed_at: '2026-08-02T00:00:00Z' }, { id: 2, title: 'open one', status: 'open' }, { id: 3, title: 'undated', status: 'wontfix' }]).map((h) => [h.title, h.outcome]), [['Security report: sign-in limits', 'fixed']]);
640
+ });
641
+
642
+ test('rules in force: the landing rule and what it settled; the record by month', () => {
643
+ assert.deepEqual(ideasDocket.rulesFloor(18), { count: '1', line: '1 rule · settled 18 this month', lines: [['Every filing lands in its goal without a vote.', 'settled 18'], ['A new rule put to the council is coming.', 'coming']], hot: false });
644
+ assert.deepEqual(ideasDocket.rulesHistory([{ month: '2026-10', n: 3 }, { month: '2026-09', n: 0 }]).map((h) => [h.title, h.at]), [['The landing rule settled 3 filings', '2026-10-01T00:00:00.000Z']]);
645
+ });
646
+
647
+ test('one ledger read per docket request: the treasury floors and record share it, and so do money and the spending limit', async () => {
648
+ const costMod = require(path.join(ROOT, 'modules', 'economy', 'cost.js'));
649
+ const autoDb = require(path.join(ROOT, 'modules', 'autonomy', 'db.js'));
650
+ const realFence = autoDb.readFence;
651
+ let fences = 0;
652
+ autoDb.readFence = async () => { fences += 1; return { enabled: true, goals: [], updated_at: '2026-09-02T00:00:00Z' }; };
653
+ const realMonths = costMod.treasuryMonths;
654
+ let reads = 0;
655
+ costMod.treasuryMonths = async () => { reads += 1; return [{ month: '2026-10', rewards_credits: 10, rewarded_builders: 1, grading_usd: 2, hosting_usd: 3 }]; };
656
+ let spends = 0;
657
+ seams._reset();
658
+ seams.registerProvider('reward', { autonomousApiSpendMtd: async () => { spends += 1; return 5; } });
659
+ try {
660
+ for (const f of ['modules/economy/routes/cost.js', 'modules/autonomy/routes/autonomy.js']) require(path.join(ROOT, f))();
661
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'autonomy.precheck'], now: NOW + 1 });
662
+ const by = Object.fromEntries(d.floors.map((f) => [f.key, f]));
663
+ assert.deepEqual([by['treasury.rewards'].count, by['treasury.hosting'].count, by['automation.spending_limit'].status], ['$10', '$3', 'live']);
664
+ assert.equal(reads, 1, 'one treasury read for three floors and the record');
665
+ assert.equal(spends, 1, 'one spend read for the money reading and the spending limit');
666
+ assert.equal(fences, 1, 'one fence read for the switch, allowed goals and the switch\'s record');
667
+ await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], now: NOW + 2 });
668
+ assert.equal(reads, 2, 'a new request reads afresh');
669
+ // two requests in flight at once, even at the same instant, each read once
670
+ reads = 0; spends = 0;
671
+ await Promise.all([1, 2, 3].map(() => docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'autonomy.precheck'], now: NOW + 3 })));
672
+ assert.deepEqual([reads, spends], [3, 3], 'one read per request, never shared across requests');
673
+ } finally {
674
+ costMod.treasuryMonths = realMonths;
675
+ autoDb.readFence = realFence;
676
+ seams._reset();
677
+ }
678
+ });
679
+
680
+ test('each floor and record gates on the atom of the route that serves its rows', async () => {
681
+ seams._reset();
682
+ for (const f of ['modules/ideas/routes/blockers.js', 'modules/lifecycle/routes/lifecycle.js', 'modules/security/routes/reports.js', 'modules/autonomy/routes/autonomy.js', 'modules/economy/routes/cost.js']) require(path.join(ROOT, f))();
683
+ const reg = docket.registeredContributions();
684
+ const routePerm = (file, key) => rrc.classifyText(fs.readFileSync(path.join(ROOT, file), 'utf8')).find((x) => x.key === key).perms;
685
+ assert.deepEqual([reg.floors.get('automation.running').gate.permission], routePerm('modules/autonomy/routes/autonomy.js', 'GET /autonomy/runners/all'));
686
+ for (const k of ['automation.spending_limit', 'automation.reports']) assert.deepEqual([reg.floors.get(k).gate.permission], routePerm('modules/autonomy/routes/autonomy.js', 'GET /autonomy/runs'));
687
+ assert.deepEqual([reg.histories.get('rulings').gate.permission], routePerm('modules/security/routes/reports.js', 'GET /security/reports'));
688
+ const anyBuilder = [...reg.floors].filter(([, c]) => c.gate.anyBuilder === true).map(([k]) => k).sort();
689
+ assert.deepEqual(anyBuilder, ['automation.allowed_goals', 'people.newcomers', 'planning.goals', 'planning.priorities', 'planning.versions', 'release.held_back', 'release.live_month', 'review.signed_off', 'rules.in_force', 'treasury.grading', 'treasury.hosting', 'treasury.rewards']);
690
+ // every declared floor has exactly one answer: a contributor, the government, or a coming reason
691
+ const own = new Set([...Object.keys(floorsDocket.BUILDERS), 'planning.ideas']);
692
+ for (const f of vocab.FLOORS) {
693
+ const answers = [reg.floors.has(f.key), own.has(f.key), docket.COMING_FLOORS.some((c) => c.floor === f.key)].filter(Boolean).length;
694
+ assert.equal(answers, 1, `${f.key} has one answer`);
695
+ }
696
+ seams._reset();
697
+ });
698
+
515
699
  // ── 6. the route ─────────────────────────────────────────────────────────────
516
700
 
517
701
  const realRequireBuilder = api.requireBuilder;
@@ -377,7 +377,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
377
377
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
378
378
  assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome, as the city');
379
379
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
380
- assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street'], 'the shared kit loads first, then the card (task 1004528) and the interiors (task 1004526)');
380
+ assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the shared kit loads first, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
381
381
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
382
382
  });
383
383
 
@@ -0,0 +1,336 @@
1
+ // tests/hall_street_amend.mjs — CHANGE HOW THIS WORKS DRAFTS AN AMENDMENT
2
+ // (task 1004530, goal 1000125).
3
+ //
4
+ // WHAT IT GUARDS. From any building, "change how this works" opens a drafted
5
+ // amendment to that building's row of the who-decides-what table, prefilled
6
+ // with the rule in force and a proposed one as struck and inserted text; the
7
+ // governor picks who and which rule from the vocabulary only, says why, and
8
+ // files it; the street answers. What can go wrong:
9
+ // * the draft offering a seat or a rule the server does not know (free text),
10
+ // or a rule that does not fit who decides (as today with a rule, empty with
11
+ // anything but vacant) — the server would refuse what the governor drafted;
12
+ // * the struck and inserted words drifting from what the matter card shows the
13
+ // council (pinned: they ARE the card's change() over the stored amendment);
14
+ // * a request body the server's own validator refuses, the Charter's draft
15
+ // going to the rules route (it is the board: /board/items);
16
+ // * a refusal swallowed, or said as a code instead of words;
17
+ // * the page filing with no reason, filing twice, or answering nothing.
18
+ //
19
+ // The fixtures are the SERVER'S OWN resolved rows (decision-rules.js), so the
20
+ // draft is tested against the shape the constitution route really serves.
21
+ //
22
+ // Run: node --test tests/hall_street_amend.mjs
23
+
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import vm from 'node:vm';
29
+ import { createRequire } from 'node:module';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
34
+ const require = createRequire(import.meta.url);
35
+ const C = require(path.join(PUB, 'street-card.js'));
36
+ const A = require(path.join(PUB, 'street-amend.js'));
37
+ const S = require(path.join(PUB, 'street-draw.js'));
38
+ const DR = require(path.join(ROOT, 'modules/government/decision-rules.js'));
39
+ const { RANK_ORDER } = require(path.join(ROOT, 'modules/government/membership-predicate.js'));
40
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
41
+ const j = (v) => JSON.parse(JSON.stringify(v));
42
+
43
+ const BOARD = { membership: 'rank:archon', pass_rule: 'majority', window_minutes: 4320, close_early_on_full_turnout: true };
44
+ function constitution(rows = [], board = BOARD) {
45
+ return {
46
+ board,
47
+ decision_rules: {
48
+ areas: j(DR.resolveAll(rows, { board })),
49
+ rules: DR.DECISION_RULES.map((r) => ({ key: r.key, words: r.words })),
50
+ changed_by: 'a passed amendment only',
51
+ },
52
+ };
53
+ }
54
+ const SAT = { area: 'review', decided_by: 'rank:metic+', rule: 'signoff', who: null, set_by_item_id: 12 };
55
+
56
+ // ── the draft ───────────────────────────────────────────────────────────────
57
+
58
+ test('the vocabulary is the server\'s: the rank ladder, the seven rules, the nine buildings', () => {
59
+ assert.deepEqual(A.RANKS, [...RANK_ORDER]);
60
+ const c = constitution();
61
+ for (const area of DR.AREA_KEYS) {
62
+ const d = A.draftFor(area, c);
63
+ assert.equal(d.ok, true, area);
64
+ for (const o of d.whoOptions) assert.ok(o.value === 'gate' || o.value === 'nobody' || /^rank:/.test(o.value), `${area}: ${o.value} is a seat, never free text`);
65
+ for (const o of d.ruleOptions) assert.ok(DR.SITTING_RULE_KEYS.includes(o.value), `${area}: ${o.value}`);
66
+ }
67
+ });
68
+
69
+ test('a building decided as today drafts: the board\'s members, by consent, struck and inserted', () => {
70
+ const d = A.draftFor('review', constitution());
71
+ assert.equal(d.name, 'Review');
72
+ assert.deepEqual(d.current, { decided_by: 'gate', rule: null });
73
+ assert.equal(d.decided_by, 'rank:archon');
74
+ assert.equal(d.rule, 'consent');
75
+ assert.deepEqual(d.whoOptions.map((o) => o.value), ['gate', 'rank:archon', 'rank:xenos+', 'rank:thetes+', 'rank:metic+', 'nobody']);
76
+ assert.equal(d.whoOptions[0].words, 'as today: whoever may mark work as meeting its checks');
77
+ assert.deepEqual(d.ruleOptions.map((o) => o.value), ['decide', 'first', 'consent', 'majority', 'unanimous', 'signoff']);
78
+ assert.deepEqual(j(A.change(d)), [{ label: 'Review', struck: 'whoever may mark work as meeting its checks, decides alone', inserted: 'the archon rank, passes unless someone objects' }]);
79
+ });
80
+
81
+ test('a building already changed drafts its way back to as today', () => {
82
+ const d = A.draftFor('review', constitution([SAT]));
83
+ assert.deepEqual(d.current, { decided_by: 'rank:metic+', rule: 'signoff' });
84
+ assert.equal(d.decided_by, 'gate');
85
+ assert.equal(d.rule, null);
86
+ assert.deepEqual(d.ruleOptions, [], 'as today decides alone: there is no rule to pick');
87
+ assert.deepEqual(j(A.change(d)), [{ label: 'Review', struck: 'metic and above (metic, archon), signed off by someone who did not build it', inserted: 'as it is decided today' }]);
88
+ });
89
+
90
+ test('the rule follows who: no one is vacant, a sitting keeps a rule that fits, a value outside the vocabulary changes nothing', () => {
91
+ let d = A.draftFor('people', constitution());
92
+ d = A.pick(d, 'who', 'nobody');
93
+ assert.equal(d.rule, 'vacant');
94
+ assert.deepEqual(j(A.change(d))[0].inserted, 'no one yet');
95
+ d = A.pick(d, 'who', 'rank:metic+');
96
+ assert.ok(DR.SITTING_RULE_KEYS.includes(d.rule), 'a sitting gets a sitting rule back');
97
+ d = A.pick(d, 'rule', 'unanimous');
98
+ assert.equal(d.rule, 'unanimous');
99
+ assert.equal(A.pick(d, 'who', 'rank:everyone'), d, 'not a seat on offer');
100
+ assert.equal(A.pick(d, 'rule', 'vacant'), d, 'vacant is not a sitting rule');
101
+ assert.equal(A.pick(d, 'rule', 'whatever'), d);
102
+ d = A.pick(d, 'who', 'gate');
103
+ assert.equal(A.changed(d), false, 'back to as today is no change');
104
+ assert.deepEqual(A.change(d), []);
105
+ assert.match(A.check(d), /how it works today/);
106
+ });
107
+
108
+ test('the request is the one the server\'s validator accepts, and the card reads it back word for word', () => {
109
+ for (const [area, who, rule] of [['review', 'rank:archon', 'consent'], ['treasury', 'rank:metic+', 'majority'], ['people', 'nobody', null], ['rules', 'rank:xenos+', 'signoff']]) {
110
+ let d = A.draftFor(area, constitution());
111
+ d = A.pick(d, 'who', who);
112
+ if (rule) d = A.pick(d, 'rule', rule);
113
+ assert.equal(A.request(d), null, 'no reason, no request');
114
+ assert.match(A.check(d), /Say why/);
115
+ d = A.pick(d, 'reason', ' Three pieces of work are waiting. ');
116
+ const req = A.request(d);
117
+ assert.equal(req.method, 'POST');
118
+ assert.equal(req.url, '/api/bongos/government/board/decision-rules');
119
+ assert.equal(req.body.rationale_md, 'Three pieces of work are waiting.');
120
+ const v = DR.validateDecisionRulesProposal(req.body.rules);
121
+ assert.equal(v.ok, true, `${area}: ${v.reason}`);
122
+ assert.equal(DR.changesAnything(v.rules, []), true, 'a change the server will not refuse as no_change');
123
+ // the card the council votes on, over the amendment as the server stores it
124
+ const stored = { subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules', proposed: { decision_rules: j(v.rules) } } };
125
+ const docket = { buildings: [{ key: area, decision: constitution().decision_rules.areas.find((r) => r.area === area) }] };
126
+ assert.deepEqual(j(C.change(stored, docket, { board: BOARD }).rows), j(A.change(d)), `${area}: drafted words = voted words`);
127
+ }
128
+ });
129
+
130
+ test('the Charter is the board: its draft is a constitution amendment of members and pass rule, through /board/items', () => {
131
+ let d = A.draftFor('charter', constitution());
132
+ assert.equal(d.charter, true);
133
+ assert.ok(!d.whoOptions.some((o) => o.value === 'gate' || o.value === 'nobody'), 'the charter is never as-today or empty');
134
+ assert.deepEqual(d.ruleOptions.map((o) => o.value), ['first', 'consent', 'majority', 'unanimous'], 'the board\'s four pass rules');
135
+ assert.equal(d.rule, 'consent');
136
+ assert.deepEqual(j(A.change(d)), j(C.change({ subject: { kind: 'constitution', proposed: { pass_rule: 'consent' } } }, null, { board: { pass_rule: 'majority' } }).rows));
137
+ assert.equal(j(A.change(d))[0].label, 'how it passes');
138
+ d = A.pick(A.pick(d, 'who', 'rank:metic+'), 'reason', 'More of us should sit.');
139
+ const req = A.request(d);
140
+ assert.equal(req.url, '/api/bongos/government/board/items');
141
+ assert.equal(req.body.subject_type, 'constitutional_amendment');
142
+ assert.deepEqual(Object.keys(req.body.proposal).sort(), ['close_early_on_full_turnout', 'membership', 'pass_rule', 'window_minutes'], 'a constitution is filed whole, with no field the validator refuses');
143
+ assert.deepEqual(req.body.proposal, { membership: 'rank:metic+', pass_rule: 'consent', window_minutes: 4320, close_early_on_full_turnout: true });
144
+ assert.equal(j(A.change(d)).length, 2, 'who sits, and how it passes');
145
+ });
146
+
147
+ test('a constitution that could not be read drafts nothing, and says so', () => {
148
+ for (const c of [null, {}, { board: BOARD }, { board: BOARD, decision_rules: null }, { board: BOARD, decision_rules: { areas: [], rules: [] } }]) {
149
+ const d = A.draftFor('review', c);
150
+ assert.equal(d.ok, false);
151
+ assert.equal(A.request(d), null);
152
+ assert.match(A.check(d), /could not be read/);
153
+ }
154
+ });
155
+
156
+ test('the server\'s refusal is said plainly, never as a code', () => {
157
+ const env = (code, details) => ({ error: { code, message: code, details } });
158
+ assert.match(A.refusal(403, env('forbidden')), /not yours to do/);
159
+ assert.match(A.refusal(401, env('unauthorized')), /Sign in/);
160
+ assert.match(A.refusal(409, env('no_change', { reason: 'every building named is already decided this way' })), /already is how it works/);
161
+ assert.match(A.refusal(400, env('invalid_decision_rules', { reason: 'unknown_rank', rank_key: 'council' })), /no such rank/);
162
+ assert.equal(A.refusal(400, env('invalid_decision_rules', { reason: 'too_many_rows' })), 'The server refused it: too many rows.');
163
+ assert.equal(A.refusal(409, env('proposer_would_lose_their_seat', { what_this_means: 'You would no longer sit on the board.' })), 'You would no longer sit on the board.');
164
+ assert.equal(A.refusal(0, {}), 'It could not be done just now.');
165
+ for (const s of [A.refusal(400, env('invalid_decision_rules', { reason: 'bad_rule' })), A.refusal(403, env('forbidden'))]) assert.doesNotMatch(s, /_/);
166
+ });
167
+
168
+ test('the model is pure', () => {
169
+ const src = read('street-amend.js').replace(/^\s*\/\/.*$/gm, '');
170
+ assert.doesNotMatch(src, /document\.|fetch\(|Date\.now|Math\.random|innerHTML/);
171
+ const d = A.draftFor('review', constitution());
172
+ assert.deepEqual(j(A.draftFor('review', constitution())), j(d));
173
+ A.pick(d, 'who', 'gate');
174
+ assert.equal(d.decided_by, 'rank:archon', 'a pick returns a new draft');
175
+ });
176
+
177
+ // ── the page ────────────────────────────────────────────────────────────────
178
+
179
+ test('the markup: the draft lives under the button, hidden, and the model loads before the page', () => {
180
+ const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
181
+ assert.match(html, /id="insideAmend" aria-expanded="false" aria-controls="insideDraft"/);
182
+ assert.match(html, /<div class="inside-draft" id="insideDraft" hidden><\/div>/);
183
+ const order = ['street-card.js', 'street-amend.js', 'street.js'].map((f) => html.indexOf(`/builders/${f}?`));
184
+ assert.ok(order[0] > 0 && order[0] < order[1] && order[1] < order[2], 'card, then the draft model, then the page');
185
+ const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
186
+ assert.ok(css.includes('.inside-draft[hidden] { display: none; }'));
187
+ assert.match(css, /@media \(max-width: 899px\) \{\s*\.inside-draft__picks \{ grid-template-columns: 1fr; \}/, 'narrow: the picks stack');
188
+ assert.doesNotMatch(css.slice(css.indexOf('.inside-draft')), /#[0-9a-f]{3,8}\b/i, 'tokens only');
189
+ assert.doesNotMatch(read('street.js'), /not built yet/);
190
+ });
191
+
192
+ function fakeDom() {
193
+ const byId = new Map();
194
+ const focused = { el: null };
195
+ const mk = (tag) => ({
196
+ tagName: tag, attrs: {}, kids: [], className: '', listeners: {}, _text: '', hidden: false, value: undefined,
197
+ style: { setProperty(k, v) { this[k] = v; } },
198
+ clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
199
+ get children() { const a = this.kids.slice(); a[Symbol.isConcatSpreadable] = false; return a; },
200
+ get firstChild() { return this.kids[0] || null; },
201
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
202
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
203
+ appendChild(c) { this.kids.push(c); c.parentNode = this; return c; },
204
+ removeChild(c) { this.kids = this.kids.filter((x) => x !== c); return c; },
205
+ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
206
+ fire(t, e = {}) { return (this.listeners[t] || []).map((fn) => fn({ type: t, preventDefault() {}, ...e })); },
207
+ focus() { focused.el = this; },
208
+ set textContent(v) { this._text = String(v); this.kids = []; },
209
+ get textContent() { return this._text; },
210
+ get innerHTML() { throw new Error('no innerHTML'); },
211
+ set innerHTML(v) { throw new Error('no innerHTML'); },
212
+ });
213
+ const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
214
+ for (const id of [...html.matchAll(/id="([A-Za-z]+)"/g)].map((m) => m[1])) {
215
+ const el = mk('x');
216
+ el.setAttribute('id', id);
217
+ const tag = new RegExp(`<[^>]*\\bid="${id}"[^>]*>`).exec(html);
218
+ if (tag && /\shidden(?=[\s>])/.test(tag[0])) el.hidden = true;
219
+ }
220
+ const document = { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
221
+ return { document, byId, focused };
222
+ }
223
+ const all = (el) => [el, ...el.kids.flatMap(all)];
224
+ const textOf = (el) => all(el).map((n) => n._text).filter(Boolean).join(' | ');
225
+ const tick = () => new Promise((r) => setTimeout(r, 0));
226
+
227
+ async function bootPage({ hash = '#building=review', cons = constitution(), answer = () => ({ status: 201, body: { ok: true, item: { id: 77 }, amendment: { id: 5 } } }) } = {}) {
228
+ const { document, byId, focused } = fakeDom();
229
+ const gets = [], posts = [], history = [];
230
+ const location = { hash };
231
+ const window = {
232
+ innerWidth: 1440, innerHeight: 900, location,
233
+ localStorage: { getItem: () => null, setItem() {} },
234
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
235
+ addEventListener() {},
236
+ history: { pushState(_s, _t, url) { history.push(url); location.hash = ''; } },
237
+ };
238
+ const feeds = {
239
+ '/api/bongos/government/docket': { viewer: { id: '95' }, counts: { needs: 0, moving: 0, settled: 0 }, line: '', matters: [], buildings: S.CIVIC_KEYS.map((key) => ({ key, name: key, matter_ids: [] })), sources: [], readings: [] },
240
+ '/api/bongos/goals': { goals: [] },
241
+ '/api/bongos/goals/rollups': { rollups: [] },
242
+ '/api/bongos/government/constitution': cons,
243
+ };
244
+ const fetch = (url, opts = {}) => {
245
+ if (opts.method === 'POST') {
246
+ posts.push({ url, body: JSON.parse(opts.body) });
247
+ const a = answer(url);
248
+ return Promise.resolve({ ok: a.status < 300, status: a.status, json: async () => a.body });
249
+ }
250
+ gets.push(url);
251
+ const body = feeds[url];
252
+ return Promise.resolve(body == null ? { ok: false, status: 500, json: async () => ({}) } : { ok: true, status: 200, json: async () => body });
253
+ };
254
+ const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, setTimeout, clearTimeout });
255
+ ctx.globalThis = ctx;
256
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
257
+ await window.GovStreetPage.ready;
258
+ await tick();
259
+ return { window, byId, focused, gets, posts, history };
260
+ }
261
+
262
+ test('change how this works opens the drafted amendment: struck, inserted, who, how, why', async () => {
263
+ const p = await bootPage();
264
+ const box = p.byId.get('insideDraft');
265
+ assert.equal(box.hidden, true);
266
+ await p.byId.get('insideAmend').fire('click')[0];
267
+ assert.ok(p.gets.includes('/api/bongos/government/constitution'), 'the rule in force is read fresh');
268
+ assert.equal(box.hidden, false);
269
+ assert.equal(p.byId.get('insideAmend').attrs['aria-expanded'], 'true');
270
+ const del = all(box).find((n) => n.tagName === 'del'), ins = all(box).find((n) => n.tagName === 'ins');
271
+ assert.equal(del.textContent, 'whoever may mark work as meeting its checks, decides alone');
272
+ assert.equal(ins.textContent, 'the archon rank, passes unless someone objects');
273
+ assert.equal(p.focused.el, p.byId.get('insideDraftWho'), 'focus lands on who decides');
274
+ assert.equal(p.byId.get('insideDraftWho').kids.length, 6);
275
+ assert.equal(p.byId.get('insideDraftRule').value, 'consent');
276
+ // a pick redraws the words and keeps focus where it was
277
+ const who = p.byId.get('insideDraftWho');
278
+ who.value = 'nobody';
279
+ who.fire('change');
280
+ assert.equal(all(box).find((n) => n.tagName === 'ins').textContent, 'no one yet');
281
+ assert.ok(!all(box).some((n) => n.attrs.id === 'insideDraftRule'), 'an empty building has no rule to pick');
282
+ assert.match(textOf(box), /nothing is decided until someone holds it/);
283
+ assert.equal(p.focused.el.attrs.id, 'insideDraftWho');
284
+ // a feed landing mid-sentence leaves the draft alone
285
+ const before = p.byId.get('insideDraftWhy');
286
+ before.value = 'half a thought';
287
+ before.fire('input');
288
+ await p.window.GovStreetPage.load();
289
+ assert.equal(p.byId.get('insideDraftWhy'), before, 'the same textarea, not a fresh one');
290
+ // not now closes it and gives focus back to the button
291
+ all(box).find((n) => n.tagName === 'button' && n._text === 'not now').fire('click');
292
+ assert.equal(box.hidden, true);
293
+ assert.equal(p.byId.get('insideAmend').attrs['aria-expanded'], 'false');
294
+ assert.equal(p.focused.el, p.byId.get('insideAmend'));
295
+ });
296
+
297
+ test('filing: no reason is refused in place; a filed amendment goes to the Charter, once, and the street answers', async () => {
298
+ const p = await bootPage();
299
+ await p.byId.get('insideAmend').fire('click')[0];
300
+ await p.byId.get('insideDraftFile').fire('click')[0];
301
+ assert.equal(p.posts.length, 0, 'nothing is sent without a reason');
302
+ assert.match(p.byId.get('insideDraftError').textContent, /Say why/);
303
+ assert.equal(p.byId.get('insideDraftError').attrs.role, 'alert');
304
+ assert.equal(p.focused.el, p.byId.get('insideDraftWhy'));
305
+ const why = p.byId.get('insideDraftWhy');
306
+ why.value = 'Three pieces of finished work are waiting.';
307
+ const first = p.byId.get('insideDraftFile').fire('click')[0];
308
+ await p.byId.get('insideDraftFile').fire('click')[0];
309
+ await first;
310
+ assert.equal(p.posts.length, 1, 'one click files once');
311
+ assert.deepEqual(p.posts[0], { url: '/api/bongos/government/board/decision-rules', body: { rules: [{ area: 'review', decided_by: 'rank:archon', rule: 'consent' }], rationale_md: 'Three pieces of finished work are waiting.' } });
312
+ assert.equal(p.byId.get('streetToast').hidden, false);
313
+ assert.equal(p.byId.get('streetToast').textContent, 'Filed. Your amendment to Review is on the Charter for the council to vote on.');
314
+ assert.deepEqual(p.history, ['/builders/street'], 'back to the street, where the Charter pin appears');
315
+ assert.equal(p.byId.get('streetInside').hidden, true);
316
+ });
317
+
318
+ test('a refusal from the server is said in the draft, and the draft stays to be changed', async () => {
319
+ const p = await bootPage({ answer: () => ({ status: 403, body: { error: { code: 'forbidden', message: 'missing permission board.item.open' } } }) });
320
+ await p.byId.get('insideAmend').fire('click')[0];
321
+ p.byId.get('insideDraftWhy').value = 'Because.';
322
+ await p.byId.get('insideDraftFile').fire('click')[0];
323
+ assert.equal(p.posts.length, 1);
324
+ assert.match(p.byId.get('insideDraftError').textContent, /not yours to do/);
325
+ assert.equal(p.byId.get('insideDraft').hidden, false);
326
+ assert.equal(p.history.length, 0, 'still in the building');
327
+ assert.equal(p.byId.get('streetToast').hidden, true);
328
+ });
329
+
330
+ test('a constitution that cannot be read says so where the draft would be', async () => {
331
+ const p = await bootPage({ cons: null });
332
+ await p.byId.get('insideAmend').fire('click')[0];
333
+ assert.equal(p.byId.get('insideDraft').hidden, true);
334
+ assert.equal(p.byId.get('insideAmendNote').hidden, false);
335
+ assert.match(p.byId.get('insideAmendNote').textContent, /could not be read/);
336
+ });