@bongos/core 1.19.642 → 1.19.643

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.
@@ -81,6 +81,31 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
81
81
  return out;
82
82
  }
83
83
 
84
+ // task 1003737: the badge nodes applyCounts() settles. They are THE SAME
85
+ // element objects gatedNodes() hands back — keyed by the same id — because
86
+ // applyCounts reads `el.hidden`, which the gate loop is what sets. A separate
87
+ // stub object here would read a hidden that nothing ever wrote, and the
88
+ // "a badge never out-runs its gate" assertion would pass vacuously.
89
+ function countNodes() {
90
+ const html = els.has('app-sidebar') ? String(els.get('app-sidebar').innerHTML) : '';
91
+ const out = [];
92
+ for (const tag of html.split('<')) {
93
+ const count = attr(tag, 'data-count');
94
+ if (count === undefined) continue;
95
+ const key = attr(tag, 'id');
96
+ if (!gatedEls.has(key)) gatedEls.set(key, { key, dataset: {}, hidden: true });
97
+ const el = gatedEls.get(key);
98
+ el.dataset.count = count;
99
+ if (!el._badge) {
100
+ el._badge = { hidden: true, textContent: '' };
101
+ el._label = { textContent: '' };
102
+ el.querySelector = (sel) => (sel === '[data-badge]' ? el._badge : sel === '[data-badge-label]' ? el._label : null);
103
+ }
104
+ out.push(el);
105
+ }
106
+ return out;
107
+ }
108
+
84
109
  const documentObj = {
85
110
  body: { dataset: { page } },
86
111
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
@@ -88,7 +113,11 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
88
113
  if (!els.has(id)) els.set(id, makeEl());
89
114
  return els.get(id);
90
115
  },
91
- querySelectorAll(sel) { return sel === '#app-sidebar [data-gate]' ? gatedNodes() : []; },
116
+ querySelectorAll(sel) {
117
+ if (sel === '#app-sidebar [data-gate]') return gatedNodes();
118
+ if (sel === '#app-sidebar .nav-item[data-count]') return countNodes();
119
+ return [];
120
+ },
92
121
  createElement() { return makeEl(); },
93
122
  addEventListener() {},
94
123
  dispatchEvent() {},
@@ -130,6 +159,14 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
130
159
  assert.ok(el, `nav item ${id} rendered`);
131
160
  return el.hidden;
132
161
  },
162
+ // task 1003737: what the badge on that item says right now — null when it is
163
+ // not showing, which is the state ZERO IS SILENT is about.
164
+ navBadge: (id) => {
165
+ const el = gatedEls.get('nav-' + id);
166
+ assert.ok(el, `nav item ${id} rendered`);
167
+ assert.ok(el._badge, `nav item ${id} declares a count`);
168
+ return el._badge.hidden ? null : { text: el._badge.textContent, label: el._label.textContent };
169
+ },
133
170
  };
134
171
  }
135
172
 
@@ -446,3 +483,115 @@ test('collapsible groups carry a stable storage key (collapse persistence)', ()
446
483
  assert.ok(!/nav-group--collapsible[^>]*>(?!<button)/.test(html.split('<div class="nav-group')[1] || ''),
447
484
  'the unlabeled project group stays always-expanded');
448
485
  });
486
+
487
+ // ── a nav item can carry a COUNT badge (task 1003737) ───────────────────────
488
+ //
489
+ // The owner's ask: show that sittings wait on your vote WITHOUT going inside.
490
+ // The count already rode /me (boardVotesNeed, pre-filtered to ballots this viewer
491
+ // could actually cast); what the hall had was no badge/bell/dot mechanism of ANY
492
+ // kind. These pin the MECHANISM, not the one consumer — the rules below are what
493
+ // a second adopter (help requests, the idea inbox) inherits for free.
494
+
495
+ const boardNeeds = (count, extra = {}) => ({
496
+ items: [{
497
+ id: 'board_votes', state: 'action_needed', count,
498
+ title: `${count} ideas are waiting on your vote`, ...extra,
499
+ }],
500
+ });
501
+ const seeingBoard = { authed: true, rank: 'xenos', permissions: { 'board.vote.cast': true } };
502
+
503
+ test('THE ASK: a waiting sitting puts a count on the Board Room item', () => {
504
+ const b = boot();
505
+ b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(3) });
506
+ assert.deepEqual(b.navBadge('board-room'), { text: '3', label: '3 ideas are waiting on your vote' });
507
+ });
508
+
509
+ test('ZERO IS SILENT — and so is every other kind of nothing', () => {
510
+ // Five different nothings, one answer. A reader must not be able to tell an
511
+ // empty board from a /me that has not landed: both mean "nothing to act on".
512
+ const cases = {
513
+ 'no needs key at all': undefined,
514
+ 'needs is null (not yet told)': null,
515
+ 'a roster with no board need': { items: [{ id: 'art_key', state: 'action_needed' }] },
516
+ 'the need exists but counts zero': boardNeeds(0),
517
+ 'a need carrying no count at all': { items: [{ id: 'board_votes', state: 'action_needed' }] },
518
+ };
519
+ for (const [name, needs] of Object.entries(cases)) {
520
+ const b = boot();
521
+ b.shell.applyAccess({ ...seeingBoard, needs });
522
+ assert.equal(b.navBadge('board-room'), null, `${name} → no badge`);
523
+ }
524
+ });
525
+
526
+ test('the founding grace silences the badge, because a badge is a demand', () => {
527
+ // computeNeeds rewrites every action_needed row to 'covered' during a new
528
+ // instance's first days, precisely so it does not greet its owner with demands.
529
+ // The count SURVIVES that rewrite (the grace spreads the row), so a badge keyed
530
+ // on the number alone would shout straight through the grace.
531
+ const b = boot();
532
+ b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(4, { state: 'covered' }) });
533
+ assert.equal(b.navBadge('board-room'), null, 'covered is not action_needed');
534
+ });
535
+
536
+ test('A BADGE NEVER OUT-RUNS ITS GATE — no count on a room you cannot see', () => {
537
+ // The disclosure rule. The count is real and the viewer holds no atom, so the
538
+ // item is hidden; the badge must not be written anyway. If it were, the DOM —
539
+ // and a screen reader walking it — would carry board activity to someone with
540
+ // no access to the room.
541
+ const b = boot();
542
+ b.shell.applyAccess({
543
+ authed: true, rank: 'archon', permissions: { 'board.vote.cast': false }, needs: boardNeeds(7),
544
+ });
545
+ assert.equal(b.navHidden('board-room'), true, 'precondition: the gate hides it');
546
+ assert.equal(b.navBadge('board-room'), null, 'a hidden item carries no count');
547
+ });
548
+
549
+ test('a badge already shown is CLEARED when access goes away', () => {
550
+ // The stale-count case: skipping hidden items rather than clearing them would
551
+ // leave the previous state's number sitting in the DOM after a sign-out.
552
+ const b = boot();
553
+ b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(2) });
554
+ assert.ok(b.navBadge('board-room'), 'precondition: it showed');
555
+ b.shell.applyAccess({ authed: false });
556
+ assert.equal(b.navBadge('board-room'), null, 'signing out takes the count with it');
557
+ });
558
+
559
+ test('a big count is capped rather than allowed to stretch the rail', () => {
560
+ const b = boot();
561
+ b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(140) });
562
+ assert.equal(b.navBadge('board-room').text, '99+');
563
+ });
564
+
565
+ test('the count reaches a screen reader as WORDS, not a floating numeral', () => {
566
+ // The numeral is aria-hidden decoration; the need's own sentence rides beside
567
+ // it in an sr-only span. "3" alone inside a link named Board Room says nothing.
568
+ const html = boot().sidebarHtml;
569
+ const item = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-board-room"'));
570
+ assert.ok(item, 'the Board Room item renders');
571
+ assert.match(item, /class="nav-item__badge" data-badge aria-hidden="true" hidden/, 'the numeral is decoration');
572
+ assert.match(item, /class="sr-only" data-badge-label/, 'the words are what a reader gets');
573
+ // An item that declared no count grows neither node — the badge is opt-in.
574
+ const home = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-home"'));
575
+ assert.ok(!/data-badge/.test(home), 'no count declared, no badge markup');
576
+ });
577
+
578
+ test('the mechanism is GENERIC — it keys on a declared need id, not on the board', () => {
579
+ // What makes a second consumer cheap: shell.js names a need id in `count:` and
580
+ // reads that row's number. Nothing in the badge path knows what a board IS, so
581
+ // adopting it is one field here plus one field on the need.
582
+ const fn = SRC.slice(SRC.indexOf('function applyCounts'), SRC.indexOf('// applyAccess({'));
583
+ assert.ok(fn.length > 0, 'applyCounts exists');
584
+ assert.ok(!/board/i.test(fn), 'the badge resolver names no specific need');
585
+ assert.match(fn, /dataset\.count/, 'it keys on the item’s own declared need id');
586
+ });
587
+
588
+ test('the badge is styled for BOTH shapes of the rail', () => {
589
+ // The rail is a 64px icon strip until hover/focus opens it, and the drawer is
590
+ // it permanently open with no hover to trigger anything. A badge positioned for
591
+ // one of the three reads wrong in the others, and no unit test would catch it.
592
+ const css = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
593
+ assert.match(css, /\.nav-item__badge \{/, 'the collapsed (icon-only) position');
594
+ assert.match(css, /:focus-within \.nav-item__badge/, 'the open-rail position');
595
+ const drawer = css.slice(css.indexOf('html.drawer-open .app-sidebar'));
596
+ assert.match(drawer, /\.nav-item__badge \{ top: 50%/, 'the mobile drawer position');
597
+ });
@@ -309,13 +309,89 @@ test('an unattributed thing is visible ONLY to canSeeAll — it has no creator t
309
309
  'a NULL creator must not be OR-ed into every builder\'s inbox');
310
310
  });
311
311
 
312
- test('each row carries the verbs for ITS tier, so the client never hardcodes the mapping', async () => {
313
- expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9' })] });
312
+ test('each row carries the verbs for ITS tier AND ITS status, so the client never hardcodes the mapping', async () => {
313
+ expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9', status: 'open' })] });
314
314
  const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
315
- assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'prune', 'kill', 'water']);
315
+ // rotRow() is a BACKLOG task: promote accepts it, demote does not.
316
+ assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'kill', 'water']);
316
317
  assert.deepEqual(got.rows[1].verbs.map((v) => v.verb), ['kill', 'water']);
317
318
  });
318
319
 
320
+ // ---- 4b. the offer must be the row's, not the tier's (task 1003751) ---------
321
+ //
322
+ // The regression this replaces: verbs were attached as `tier === 'task' ? TASK_VERBS
323
+ // : GOAL_VERBS`, by tier only. Since TASK_ROTTABLE_STATUSES is exactly
324
+ // ['backlog','ready'] and promote/demote accept disjoint sets, EVERY task line in
325
+ // the feed shipped one button whose route answers 409 — a backlog row offered
326
+ // Prune, a ready row offered Prioritise. The card's own header promises the
327
+ // opposite ("the client renders what the server will actually accept").
328
+
329
+ test('a backlog row is not offered Prune, and a ready row is not offered Prioritise', async () => {
330
+ expect({ rows: [rotRow({ status: 'backlog' }), rotRow({ id: '6', status: 'ready' })] });
331
+ const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
332
+ const backlog = got.rows[0].verbs.map((v) => v.verb);
333
+ const ready = got.rows[1].verbs.map((v) => v.verb);
334
+ assert.ok(!backlog.includes('prune'), "a backlog task is already at the bottom — /demote answers 409 cannot_demote");
335
+ assert.ok(backlog.includes('prioritise'), 'a backlog task is exactly what /promote is for');
336
+ assert.ok(!ready.includes('prioritise'), "a ready task is already claimable — /promote answers 409 cannot_promote");
337
+ assert.ok(ready.includes('prune'), 'ready is the only status /demote accepts');
338
+ // kill and water are unconditional across the rottable set, so no row loses them.
339
+ for (const offered of [backlog, ready]) {
340
+ assert.ok(offered.includes('kill') && offered.includes('water'),
341
+ 'kill and water apply to every rottable row — narrowing must not drop them');
342
+ }
343
+ });
344
+
345
+ test('no verb is offered for a status its route would refuse, across the whole rottable set', () => {
346
+ // The end-to-end claim of this feature, checked against the route guards rather
347
+ // than against a second hardcoded list. Every status that can reach the feed,
348
+ // every verb attached to it, must name a route that accepts that status.
349
+ for (const status of rot.TASK_ROTTABLE_STATUSES) {
350
+ const offered = rot.verbsFor('task', status).map((v) => v.verb);
351
+ assert.ok(offered.length > 0, `status '${status}' was offered no verbs at all`);
352
+ for (const verb of offered) {
353
+ const accepts = rot.TASK_VERB_ACCEPTS[verb];
354
+ assert.ok(!accepts || accepts.includes(status),
355
+ `verb '${verb}' is offered on a '${status}' row but its route accepts only [${accepts}] — that button 409s`);
356
+ }
357
+ }
358
+ });
359
+
360
+ test('the accepted-status map is the one the ROUTES actually enforce', () => {
361
+ // The unavoidable-copy pairing guard, in the same spirit as the rot-clock check
362
+ // against migration core_231: rot.js must mirror routes/tasks.js, so read the
363
+ // guards back out of the route source and fail if they have drifted apart.
364
+ const TASKS_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
365
+
366
+ const promoteGuard = /if \(!\[([^\]]*)\]\.includes\(task\.status\)\)\s*\{\s*return res\.fail\('cannot_promote'/.exec(TASKS_SRC);
367
+ assert.ok(promoteGuard, "could not find /promote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prioritise from the new shape");
368
+ const promoteAccepts = promoteGuard[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
369
+ assert.deepEqual(
370
+ [...rot.TASK_VERB_ACCEPTS.prioritise].sort(), promoteAccepts.sort(),
371
+ 'TASK_VERB_ACCEPTS.prioritise no longer matches what POST /tasks/:id/promote accepts — the rot card would offer (or withhold) a Prioritise button wrongly'
372
+ );
373
+
374
+ const demoteGuard = /if \(task\.status !== '([^']+)'\)\s*\{\s*return res\.fail\('cannot_demote'/.exec(TASKS_SRC);
375
+ assert.ok(demoteGuard, "could not find /demote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prune from the new shape");
376
+ assert.deepEqual(
377
+ [...rot.TASK_VERB_ACCEPTS.prune], [demoteGuard[1]],
378
+ 'TASK_VERB_ACCEPTS.prune no longer matches what POST /tasks/:id/demote accepts'
379
+ );
380
+ });
381
+
382
+ test('every key in the accepted-status map is a real task verb', () => {
383
+ // A typo'd key would silently make its verb UNCONDITIONAL (absent = no filter),
384
+ // which is the exact bug this map exists to fix, reintroduced quietly.
385
+ const known = new Set(rot.TASK_VERBS.map((v) => v.verb));
386
+ for (const verb of Object.keys(rot.TASK_VERB_ACCEPTS)) {
387
+ assert.ok(known.has(verb), `'${verb}' is not a declared task verb — a stale key filters nothing`);
388
+ }
389
+ });
390
+
391
+ test('a goal row keeps both its verbs — neither goal route is status-conditional here', () => {
392
+ assert.deepEqual(rot.verbsFor('goal', 'open').map((v) => v.verb), ['kill', 'water']);
393
+ });
394
+
319
395
  // ---- 5. the timer resolver: precedence + the reporting fail posture ---------
320
396
 
321
397
  test('no row anywhere = the documented default, and it says so', async () => {