@bongos/core 1.21.89 → 1.21.91
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 +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +37 -1
- package/docs/api-reference.md +8 -2
- package/docs/copy-inventory.md +7 -7
- package/docs/copy-registry.json +7 -7
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +266 -274
- package/modules/government/routes/government.js +4 -0
- package/modules/hall-ui/public/craft-mode.js +60 -0
- package/modules/hall-ui/public/style.css +32 -0
- package/modules/hall-ui/records/craft-mode.md +1 -1
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/moving.js +55 -0
- package/modules/lifecycle/routes/moving.js +30 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/craft_mode_tallies.mjs +149 -0
- package/tests/government_board_routes.mjs +361 -0
|
@@ -750,6 +750,10 @@ module.exports = function buildGovernmentRouter() {
|
|
|
750
750
|
franchise: { type: 'string', maxLength: 32 },
|
|
751
751
|
council_rank: { type: 'string', maxLength: RANK_KEY_MAX },
|
|
752
752
|
pass_rule: { type: 'string', maxLength: 32 },
|
|
753
|
+
// Declared with no type rule: null (no clock) or a whole number, which the
|
|
754
|
+
// check just below enforces. Left out, the strict schema refused every
|
|
755
|
+
// request carrying a clock as an unknown field (found by task 1004655).
|
|
756
|
+
window_minutes: {},
|
|
753
757
|
rationale_md: { type: 'string', maxLength: 5000 },
|
|
754
758
|
acknowledge_self_removal: { type: 'boolean' },
|
|
755
759
|
})) return;
|
|
@@ -196,6 +196,64 @@
|
|
|
196
196
|
if (mark) mark.textContent = look;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// ---------- Governing's header tallies (task 1004563) ----------
|
|
200
|
+
// "needs you N · moving M" beside the eyebrow, as on the four-mode prototype.
|
|
201
|
+
// Needs you is the open-blocker total GET /blockers already carries; moving is
|
|
202
|
+
// tasks being built or waiting to land (active + confirmed, owner 2026-10-08),
|
|
203
|
+
// counted by the server at GET /moving. Read once per page. A figure that cannot
|
|
204
|
+
// be read is LEFT OUT, never shown as 0: zero would say nothing waits on you.
|
|
205
|
+
var TALLY_API = '/api/bongos';
|
|
206
|
+
var tallyAsk = null;
|
|
207
|
+
function readCount(path, pick) {
|
|
208
|
+
if (typeof fetch !== 'function') return Promise.resolve(null);
|
|
209
|
+
return fetch(TALLY_API + path, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
|
210
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
211
|
+
.then(function (j) {
|
|
212
|
+
var n = j ? pick(j) : null;
|
|
213
|
+
return (typeof n === 'number' && isFinite(n) && n >= 0) ? n : null;
|
|
214
|
+
}, function () { return null; });
|
|
215
|
+
}
|
|
216
|
+
function askTallies() {
|
|
217
|
+
if (!tallyAsk) {
|
|
218
|
+
tallyAsk = Promise.all([
|
|
219
|
+
readCount('/blockers?limit=1', function (j) { return j.page && j.page.total; }),
|
|
220
|
+
readCount('/moving', function (j) { return j.moving; }),
|
|
221
|
+
]).then(function (v) { return { needs: v[0], moving: v[1] }; });
|
|
222
|
+
}
|
|
223
|
+
return tallyAsk;
|
|
224
|
+
}
|
|
225
|
+
function tallyHtml(t) {
|
|
226
|
+
var parts = [];
|
|
227
|
+
function one(label, n, due) {
|
|
228
|
+
return '<span class="craft-tally' + (due ? ' craft-tally--due' : '') + '">'
|
|
229
|
+
+ '<span class="craft-tally__label">' + label + '</span> '
|
|
230
|
+
+ '<span class="craft-tally__n">' + n + '</span></span>';
|
|
231
|
+
}
|
|
232
|
+
if (t && t.needs != null) parts.push(one('needs you', t.needs, t.needs > 0));
|
|
233
|
+
if (t && t.moving != null) parts.push(one('moving', t.moving, false));
|
|
234
|
+
return parts.join(' <span class="craft-tallies__sep" aria-hidden="true">·</span> ');
|
|
235
|
+
}
|
|
236
|
+
function applyTallies(mode) {
|
|
237
|
+
var box = document.getElementById('craft-tallies');
|
|
238
|
+
if (mode !== 'governing') { remove(box); return; }
|
|
239
|
+
var mark = document.getElementById('craft-eyebrow');
|
|
240
|
+
if (!box && mark && mark.parentNode) {
|
|
241
|
+
box = document.createElement('span');
|
|
242
|
+
box.id = 'craft-tallies';
|
|
243
|
+
box.className = 'craft-tallies';
|
|
244
|
+
box.setAttribute('hidden', '');
|
|
245
|
+
mark.parentNode.insertBefore(box, mark.nextSibling);
|
|
246
|
+
}
|
|
247
|
+
if (!box) return;
|
|
248
|
+
askTallies().then(function (t) {
|
|
249
|
+
var live = document.getElementById('craft-tallies');
|
|
250
|
+
if (!live || document.documentElement.dataset.craft !== 'governing') return;
|
|
251
|
+
var html = tallyHtml(t);
|
|
252
|
+
live.innerHTML = html;
|
|
253
|
+
if (html) live.removeAttribute('hidden'); else live.setAttribute('hidden', '');
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
199
257
|
// ---------- each mode's tools (task 1004496 onward) ----------
|
|
200
258
|
// Artistry has two: the palette (a feedback doc, page-notes.js) and the
|
|
201
259
|
// paintbrush (the page's words as a wording change, paintbrush.js). Ideating
|
|
@@ -311,6 +369,7 @@
|
|
|
311
369
|
if (sw) sw.classList.toggle('craft-switch--unset', !mode);
|
|
312
370
|
applyNav();
|
|
313
371
|
applyLook(mode);
|
|
372
|
+
applyTallies(mode);
|
|
314
373
|
applyTools(mode);
|
|
315
374
|
try { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: mode } })); } catch (_) { /* old engine */ }
|
|
316
375
|
}
|
|
@@ -521,6 +580,7 @@
|
|
|
521
580
|
applyNav: applyNav,
|
|
522
581
|
LOOKS: LOOKS,
|
|
523
582
|
artHtml: artHtml,
|
|
583
|
+
tallyHtml: tallyHtml,
|
|
524
584
|
TOOLS: TOOLS,
|
|
525
585
|
switchHtml: switchHtml,
|
|
526
586
|
mount: mount,
|
|
@@ -1472,6 +1472,38 @@ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing
|
|
|
1472
1472
|
flex-shrink: 0;
|
|
1473
1473
|
}
|
|
1474
1474
|
@media (max-width: 640px) { .craft-eyebrow { display: none; } }
|
|
1475
|
+
/* Governing's header tallies (craft-mode.js applyTallies, task 1004563):
|
|
1476
|
+
"needs you N · moving M" after the eyebrow. The warm dot marks needs-you only
|
|
1477
|
+
while something waits. Hidden at <=960px, earlier than the eyebrow: below that
|
|
1478
|
+
they squeeze the jump button under its own label (measured at 800px). */
|
|
1479
|
+
.craft-tallies {
|
|
1480
|
+
display: inline-flex;
|
|
1481
|
+
align-items: baseline;
|
|
1482
|
+
gap: 10px;
|
|
1483
|
+
white-space: nowrap;
|
|
1484
|
+
flex-shrink: 0;
|
|
1485
|
+
font-size: 12px;
|
|
1486
|
+
}
|
|
1487
|
+
.craft-tallies[hidden] { display: none; }
|
|
1488
|
+
.craft-tally { display: inline-flex; align-items: baseline; gap: 6px; }
|
|
1489
|
+
.craft-tally__label {
|
|
1490
|
+
font-family: var(--label-face);
|
|
1491
|
+
font-size: 11px;
|
|
1492
|
+
letter-spacing: .12em;
|
|
1493
|
+
text-transform: uppercase;
|
|
1494
|
+
color: var(--ink-soft);
|
|
1495
|
+
}
|
|
1496
|
+
.craft-tally__n { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; }
|
|
1497
|
+
.craft-tally--due::before {
|
|
1498
|
+
content: '';
|
|
1499
|
+
align-self: center;
|
|
1500
|
+
width: 7px;
|
|
1501
|
+
height: 7px;
|
|
1502
|
+
border-radius: 999px;
|
|
1503
|
+
background: var(--accent);
|
|
1504
|
+
}
|
|
1505
|
+
.craft-tallies__sep { color: var(--ink-faint); }
|
|
1506
|
+
@media (max-width: 960px) { .craft-tallies { display: none; } }
|
|
1475
1507
|
/* The signature: the mode's door scene (craft-mode.js artHtml, the same art the
|
|
1476
1508
|
picker draws) framed under the brand, 40px in the closed rail and 64px open.
|
|
1477
1509
|
The 110px-tall scene is scaled to the plate's height and widened to fill it,
|
|
@@ -34,7 +34,7 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
|
|
|
34
34
|
- **The accent stays the action colour.** `--craft` paints only the switch's current pill, the current tab (tint, icon, label, marker) and the top bar's hairline.
|
|
35
35
|
- **Two pieces are drawn by `craft-mode.js` `applyLook()`, on every mode change:** the eyebrow beside `#topbar-title` (`LOOKS`: "the studio · night", "the sky", "the city"; hidden at <=640px, where it overflowed phones) and the scene plate under `.app-sidebar__brand` (40px closed rail, 64px open). The plate's markup IS `artHtml(mode)`, the picker's door art, exported rather than copied; light mode recolours it to each scene's day (the city is the dark one through `invert(1) hue-rotate(180deg)`). It does not animate.
|
|
36
36
|
- **Always-present tabs widened** (owner, 2026-10-03): `ALWAYS_TABS` is Builders, Collab and Profile, so every mode keeps the You and Community groups.
|
|
37
|
-
- **Governing's header tallies** ("needs you · moving")
|
|
37
|
+
- **Governing's header tallies** (task 1004563): `applyTallies()` draws "needs you N · moving M" right after the eyebrow, Governing only, hidden at <=960px (below that they squeezed the jump button under its own label, measured at 800px). Needs you is `GET /blockers?limit=1`'s `page.total`; moving is `GET /moving` (lifecycle, tasks `active` + `confirmed`, owner 2026-10-08), a count in SQL so the page never pulls task rows. Read once per page; a figure that cannot be read is LEFT OUT, never drawn as 0. The warm dot marks needs-you only above zero. It toggles the `hidden` ATTRIBUTE, never `.hidden =` (the nav gate owns that property; `tests/craft_mode_nav.mjs`). Pinned by `tests/craft_mode_tallies.mjs`.
|
|
38
38
|
|
|
39
39
|
## The paintbrush (task 1004497)
|
|
40
40
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/moving.js — how much of the project is MOVING right now, as one
|
|
4
|
+
// count (task 1004563, goal 1000095 WA6).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Governing mode's top bar reads "needs you N · moving M" (the four-mode
|
|
7
|
+
// prototype the owner named the standard on 2026-10-03). "Needs you" already had
|
|
8
|
+
// a source — the public open-blocker list carries its own total — but "moving" had
|
|
9
|
+
// none, and the only way to get one from the browser was GET /tasks?status=…, which
|
|
10
|
+
// returns every row with its whole description to produce a single number. That is
|
|
11
|
+
// the read the hall's bounded-reads rule forbids (modules/hall-ui/CLAUDE.md: a Home
|
|
12
|
+
// card asks the server for what it draws). So the number is counted here, in SQL.
|
|
13
|
+
//
|
|
14
|
+
// WHAT COUNTS (owner decision, 2026-10-08): a task being built right now plus a task
|
|
15
|
+
// waiting to land — status `active` or `confirmed`. Not `completed` (a ship waiting on
|
|
16
|
+
// its grade is neither being built nor queued to land) and nothing terminal. The two
|
|
17
|
+
// halves come back separately as well as summed, so a caller can say which is which.
|
|
18
|
+
//
|
|
19
|
+
// MODULE BOUNDARY: reaches core only through the published doorway.
|
|
20
|
+
|
|
21
|
+
const api = require('../../src/module-api');
|
|
22
|
+
|
|
23
|
+
const MOVING_STATUSES = Object.freeze(['active', 'confirmed']);
|
|
24
|
+
|
|
25
|
+
// → { moving, active, confirmed }. `deps.pool` is the #538 test seam, honoured only
|
|
26
|
+
// under NODE_ENV==='test' like its siblings in this module.
|
|
27
|
+
async function countMoving(deps = {}) {
|
|
28
|
+
const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
|
|
29
|
+
const { rows } = await pool.query(
|
|
30
|
+
`SELECT status, count(*)::int AS n
|
|
31
|
+
FROM tasks
|
|
32
|
+
WHERE status = ANY($1::text[])
|
|
33
|
+
GROUP BY status`,
|
|
34
|
+
[MOVING_STATUSES],
|
|
35
|
+
);
|
|
36
|
+
const by = Object.fromEntries(rows.map((r) => [r.status, Number(r.n) || 0]));
|
|
37
|
+
const active = by.active || 0;
|
|
38
|
+
const confirmed = by.confirmed || 0;
|
|
39
|
+
return { moving: active + confirmed, active, confirmed };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The GET /moving handler, injectable so a test drives it with no express.
|
|
43
|
+
function movingHandler({ log, count = countMoving } = {}) {
|
|
44
|
+
return async function movingRoute(_req, res) {
|
|
45
|
+
try {
|
|
46
|
+
res.set('Cache-Control', 'private, max-age=30');
|
|
47
|
+
res.json(await count());
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (log) log.error('GET /moving', err);
|
|
50
|
+
res.fail('moving_failed', { status: 500, message: 'internal error' });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { MOVING_STATUSES, countMoving, movingHandler };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/moving.js — GET /moving, how many tasks are being built or
|
|
4
|
+
// waiting to land (task 1004563, goal 1000095 WA6).
|
|
5
|
+
//
|
|
6
|
+
// WHY ITS OWN ROUTE FILE. routes/tasks.js sits at the 1,500-line ratchet, and the path
|
|
7
|
+
// is top-level `/moving`, never `/tasks/moving`: the manifest mounts `tasks` first and
|
|
8
|
+
// its `/tasks/:id` would swallow a later literal segment (the `/inbox/awaiting-nod`
|
|
9
|
+
// trap). The count and the handler live in ../moving.js so a test drives them with no
|
|
10
|
+
// express and no database.
|
|
11
|
+
//
|
|
12
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
13
|
+
// `../../../src/module-api`; `../moving` is this module's own read.
|
|
14
|
+
|
|
15
|
+
const express = require('express');
|
|
16
|
+
const api = require('../../../src/module-api');
|
|
17
|
+
const moving = require('../moving');
|
|
18
|
+
|
|
19
|
+
const log = api.logger('lifecycle');
|
|
20
|
+
|
|
21
|
+
module.exports = function buildMovingRouter() {
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
|
|
24
|
+
// rank: any-builder — GET /moving, the count behind Governing mode's "moving" tally.
|
|
25
|
+
// A count of rows every signed-in builder can already list on the Tasks board, so no
|
|
26
|
+
// floor above sign-in; nothing here is writable.
|
|
27
|
+
router.get('/moving', api.requireBuilder, moving.movingHandler({ log }));
|
|
28
|
+
|
|
29
|
+
return router;
|
|
30
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.91",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.91",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.91",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9364,5 +9364,17 @@
|
|
|
9364
9364
|
"id": "1004656",
|
|
9365
9365
|
"text": "A new project now really runs under the government its founder picks. Before, the choice made while creating a project was stored and then ignored, so every project stayed under one-person rule. Now picking a government files"
|
|
9366
9366
|
}
|
|
9367
|
+
],
|
|
9368
|
+
"1.21.90": [
|
|
9369
|
+
{
|
|
9370
|
+
"id": "1004655",
|
|
9371
|
+
"text": "Three government actions now have tests that check them the way the site really answers: choosing a form of government, taking back your own proposal before anyone votes, and seeing who each founding stage is still waiting on."
|
|
9372
|
+
}
|
|
9373
|
+
],
|
|
9374
|
+
"1.21.91": [
|
|
9375
|
+
{
|
|
9376
|
+
"id": "1004563",
|
|
9377
|
+
"text": "In Governing mode the top bar now shows two live numbers: how many things are waiting on the owner (\"needs you\") and how many tasks are being built or waiting to go live (\"moving\"). If a number can't be read it is simply left"
|
|
9378
|
+
}
|
|
9367
9379
|
]
|
|
9368
9380
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.91'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// tests/craft_mode_tallies.mjs — Governing mode's header tallies (task 1004563, goal
|
|
2
|
+
// 1000095 WA6): "needs you N · moving M" beside the eyebrow.
|
|
3
|
+
//
|
|
4
|
+
// Runs the REAL craft-mode.js over a small DOM with a stubbed fetch, and the REAL
|
|
5
|
+
// server count (modules/lifecycle/moving.js) over a fake pool, so the two halves are
|
|
6
|
+
// pinned where they actually decide: which statuses "moving" counts (owner decision
|
|
7
|
+
// 2026-10-08: active + confirmed), and that a figure the page could not read is left
|
|
8
|
+
// out rather than drawn as a zero that says nothing waits on you.
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import vm from 'node:vm';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const CRAFT = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
process.env.NODE_ENV = 'test';
|
|
21
|
+
const { countMoving, movingHandler, MOVING_STATUSES } = require('../modules/lifecycle/moving.js');
|
|
22
|
+
|
|
23
|
+
function node(tag, id, cls) {
|
|
24
|
+
const attrs = {};
|
|
25
|
+
return {
|
|
26
|
+
tagName: tag, id: id || '', className: cls || '', dataset: {}, children: [], parentNode: null,
|
|
27
|
+
textContent: '', innerHTML: '',
|
|
28
|
+
setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
29
|
+
removeAttribute(k) { delete attrs[k]; },
|
|
30
|
+
get nextSibling() { const sib = this.parentNode && this.parentNode.children; return sib ? sib[sib.indexOf(this) + 1] || null : null; },
|
|
31
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
|
|
32
|
+
insertBefore(c, ref) {
|
|
33
|
+
c.parentNode = this;
|
|
34
|
+
const at = ref ? this.children.indexOf(ref) : -1;
|
|
35
|
+
if (at === -1) this.children.push(c); else this.children.splice(at, 0, c);
|
|
36
|
+
return c;
|
|
37
|
+
},
|
|
38
|
+
removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
|
|
39
|
+
querySelector(sel) { return all(this).find((n) => sel === '.' + n.className) || null; },
|
|
40
|
+
querySelectorAll() { return []; },
|
|
41
|
+
addEventListener() {}, classList: { toggle() {} },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
|
|
45
|
+
|
|
46
|
+
// answers: path → JSON body, or a number (an HTTP failure status), or 'throw'.
|
|
47
|
+
function boot(mode, answers) {
|
|
48
|
+
const sidebar = node('NAV', 'app-sidebar');
|
|
49
|
+
sidebar.appendChild(node('DIV', '', 'app-sidebar__brand'));
|
|
50
|
+
const topbar = node('DIV', 'app-topbar');
|
|
51
|
+
topbar.appendChild(node('H2', 'topbar-title'));
|
|
52
|
+
const root = node('HTML');
|
|
53
|
+
const asked = [];
|
|
54
|
+
const document = {
|
|
55
|
+
documentElement: root, body: node('BODY'), activeElement: null,
|
|
56
|
+
getElementById: (id) => [sidebar, topbar, ...all(sidebar), ...all(topbar)].find((n) => n.id === id) || null,
|
|
57
|
+
querySelectorAll: () => [],
|
|
58
|
+
createElement: (t) => node(t.toUpperCase()),
|
|
59
|
+
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
|
|
60
|
+
};
|
|
61
|
+
const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
|
|
62
|
+
const window = { localStorage: mk({}), sessionStorage: mk(mode ? { 'hall-craft': mode } : {}), matchMedia: () => ({ matches: false }) };
|
|
63
|
+
const fetch = (url) => {
|
|
64
|
+
asked.push(url);
|
|
65
|
+
const key = Object.keys(answers || {}).find((k) => url.endsWith(k));
|
|
66
|
+
const a = key ? answers[key] : 404;
|
|
67
|
+
if (a === 'throw') return Promise.reject(new Error('offline'));
|
|
68
|
+
if (typeof a === 'number') return Promise.resolve({ ok: false, status: a, json: () => Promise.resolve({}) });
|
|
69
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(a) });
|
|
70
|
+
};
|
|
71
|
+
const sandbox = { window, document, fetch, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
|
|
72
|
+
vm.createContext(sandbox);
|
|
73
|
+
vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
|
|
74
|
+
const craft = window.OTBCraftMode;
|
|
75
|
+
craft.mount();
|
|
76
|
+
const box = () => all(topbar).find((n) => n.id === 'craft-tallies') || null;
|
|
77
|
+
return { craft, topbar, asked, box };
|
|
78
|
+
}
|
|
79
|
+
const settle = () => new Promise((r) => setImmediate(r));
|
|
80
|
+
const text = (html) => html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
81
|
+
|
|
82
|
+
const BOTH = { '/blockers?limit=1': { blockers: [{ id: 1 }], page: { limit: 1, offset: 0, total: 3 } }, '/moving': { moving: 12, active: 9, confirmed: 3 } };
|
|
83
|
+
|
|
84
|
+
test('Governing reads both counts and draws "needs you 3 · moving 12" right after the eyebrow', async () => {
|
|
85
|
+
const { topbar, box, asked } = boot('governing', BOTH);
|
|
86
|
+
await settle();
|
|
87
|
+
const b = box();
|
|
88
|
+
assert.ok(b, 'the tallies are drawn');
|
|
89
|
+
const eyebrow = all(topbar).find((n) => n.id === 'craft-eyebrow');
|
|
90
|
+
assert.equal(eyebrow.nextSibling, b, 'they sit directly after the mode words');
|
|
91
|
+
assert.equal(b.getAttribute('hidden'), null);
|
|
92
|
+
assert.equal(text(b.innerHTML), 'needs you 3 · moving 12');
|
|
93
|
+
assert.match(b.innerHTML, /craft-tally craft-tally--due/, 'something waits, so needs-you carries the warm dot');
|
|
94
|
+
assert.deepEqual(asked.map((u) => u.replace('/api/bongos', '')).sort(), ['/blockers?limit=1', '/moving'], 'one page of one blocker for the total, one count — never a task list');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('nothing waiting draws "needs you 0" without the dot', async () => {
|
|
98
|
+
const { box } = boot('governing', { ...BOTH, '/blockers?limit=1': { blockers: [], page: { limit: 1, offset: 0, total: 0 } } });
|
|
99
|
+
await settle();
|
|
100
|
+
assert.equal(text(box().innerHTML), 'needs you 0 · moving 12');
|
|
101
|
+
assert.doesNotMatch(box().innerHTML, /craft-tally--due/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a count that cannot be read is LEFT OUT, never drawn as zero', async () => {
|
|
105
|
+
const noMoving = boot('governing', { ...BOTH, '/moving': 401 });
|
|
106
|
+
await settle();
|
|
107
|
+
assert.equal(text(noMoving.box().innerHTML), 'needs you 3', 'a signed-out or failed /moving drops only its own figure');
|
|
108
|
+
|
|
109
|
+
const offline = boot('governing', { '/blockers?limit=1': 'throw', '/moving': { moving: 'lots' } });
|
|
110
|
+
await settle();
|
|
111
|
+
assert.equal(offline.box().getAttribute('hidden'), '', 'nothing readable: the slot stays hidden, no zeros');
|
|
112
|
+
assert.equal(offline.box().innerHTML, '');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('only Governing wears the tallies, and leaving it removes them', async () => {
|
|
116
|
+
for (const mode of ['building', 'artistry', 'ideating', null]) {
|
|
117
|
+
const { box, asked } = boot(mode, BOTH);
|
|
118
|
+
await settle();
|
|
119
|
+
assert.equal(box(), null, `${mode || 'no mode'} draws no tallies`);
|
|
120
|
+
assert.deepEqual(asked, [], `${mode || 'no mode'} asks the server for nothing`);
|
|
121
|
+
}
|
|
122
|
+
const { craft, box } = boot('governing', BOTH);
|
|
123
|
+
await settle();
|
|
124
|
+
assert.ok(box());
|
|
125
|
+
craft.choose('building');
|
|
126
|
+
assert.equal(box(), null, 'switching away takes them down');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the server counts active + confirmed only, and sums them', async () => {
|
|
130
|
+
assert.deepEqual([...MOVING_STATUSES], ['active', 'confirmed']);
|
|
131
|
+
let seen;
|
|
132
|
+
const pool = { query: async (sql, params) => { seen = { sql, params }; return { rows: [{ status: 'active', n: 9 }, { status: 'confirmed', n: '3' }] }; } };
|
|
133
|
+
assert.deepEqual(await countMoving({ pool }), { moving: 12, active: 9, confirmed: 3 });
|
|
134
|
+
assert.deepEqual(seen.params, [['active', 'confirmed']]);
|
|
135
|
+
assert.match(seen.sql, /count\(\*\)/, 'a count in SQL, never rows to count in JS');
|
|
136
|
+
const empty = { query: async () => ({ rows: [] }) };
|
|
137
|
+
assert.deepEqual(await countMoving({ pool: empty }), { moving: 0, active: 0, confirmed: 0 }, 'a quiet board is a real zero');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('GET /moving answers the count, and a failed read is a 500, not a zero', async () => {
|
|
141
|
+
const res = () => { const r = { headers: {} }; r.set = (k, v) => { r.headers[k] = v; }; r.json = (b) => { r.body = b; }; r.fail = (code, o) => { r.failed = { code, status: o.status }; }; return r; };
|
|
142
|
+
const ok = res();
|
|
143
|
+
await movingHandler({ count: async () => ({ moving: 4, active: 1, confirmed: 3 }) })({}, ok);
|
|
144
|
+
assert.deepEqual(ok.body, { moving: 4, active: 1, confirmed: 3 });
|
|
145
|
+
const bad = res();
|
|
146
|
+
await movingHandler({ count: async () => { throw new Error('db down'); }, log: { error() {} } })({}, bad);
|
|
147
|
+
assert.deepEqual(bad.failed, { code: 'moving_failed', status: 500 });
|
|
148
|
+
assert.equal(bad.body, undefined);
|
|
149
|
+
});
|