@bongos/core 1.21.141 → 1.21.142
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 +55 -45
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
- package/docs/copy-inventory.md +97 -96
- package/docs/copy-registry.json +118 -109
- package/docs/module-api-changelog.md +3 -1
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +392 -392
- package/modules/copy-desk/page-status.js +51 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
- package/modules/government/db.js +6 -0
- package/modules/government/decision-board.js +2 -1
- package/modules/hall-ui/public/city-draw.js +4 -1
- package/modules/hall-ui/public/city.css +1 -1
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +35 -10
- package/modules/hall-ui/public/my-agents.states.json +1 -1
- package/modules/hall-ui/public/settings-interaction.js +7 -2
- package/modules/hall-ui/public/settings-law-locks.js +53 -0
- package/modules/hall-ui/public/settings.html +3 -2
- package/modules/hall-ui/public/settings.js +9 -4
- package/modules/hall-ui/public/street-deck.css +4 -0
- package/modules/hall-ui/public/street-office.js +73 -1
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +4 -1
- package/modules/hall-ui/public/studio-lib.js +21 -1
- package/modules/hall-ui/public/studio.css +11 -0
- package/modules/hall-ui/public/studio.js +8 -1
- package/modules/hall-ui/records/living-city.md +3 -1
- package/modules/ideas/governor-docket.js +4 -1
- package/modules/lifecycle/criterion-uat-db.js +18 -1
- package/modules/lifecycle/criterion-uat.js +12 -3
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/src/bongos/project-settings.js +5 -4
- package/src/module-api.js +1 -1
- package/tests/criterion_uat.mjs +42 -0
- package/tests/government_tidy_up.mjs +223 -0
- package/tests/hall_studio_home.mjs +20 -0
|
@@ -151,6 +151,26 @@
|
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Pages in flight (task 1004662): every page with an open round, where it
|
|
155
|
+
// is and since when, from the server's own list (stuck first). The panel
|
|
156
|
+
// derives no state; it only words the age.
|
|
157
|
+
function inFlightRows(pages, now) {
|
|
158
|
+
const at = now instanceof Date ? now.getTime() : Number(now || Date.now());
|
|
159
|
+
return ((pages && pages.in_flight) || []).map((p) => {
|
|
160
|
+
const t = p.since ? Date.parse(p.since) : NaN;
|
|
161
|
+
const days = Number.isFinite(t) ? Math.max(0, Math.floor((at - t) / 86400000)) : null;
|
|
162
|
+
const age = days == null ? '' : days === 0 ? 'since today' : days === 1 ? 'for 1 day' : `for ${days} days`;
|
|
163
|
+
return {
|
|
164
|
+
page_id: p.page_id,
|
|
165
|
+
title: titleOf(p),
|
|
166
|
+
surface: surfaceWord(p.surface),
|
|
167
|
+
state: p.state_word || 'Needs a look',
|
|
168
|
+
age,
|
|
169
|
+
stuck: !!p.stuck,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
function askedLine(n) {
|
|
155
175
|
const v = num(n);
|
|
156
176
|
if (v <= 0) return '';
|
|
@@ -221,7 +241,7 @@
|
|
|
221
241
|
|
|
222
242
|
const api = {
|
|
223
243
|
STUDIO_PAGE, PAGE_ID_RE, SURFACE_WORDS, surfaceWord, titleOf, numberWords, tallyParts, resumeDetail,
|
|
224
|
-
subParts, flowDetail, statusDetail, apexHost, landingHost, surfaceRows, askedLine, pickLabel, matchLine, claimWords,
|
|
244
|
+
subParts, flowDetail, statusDetail, apexHost, landingHost, surfaceRows, inFlightRows, askedLine, pickLabel, matchLine, claimWords,
|
|
225
245
|
skyAt, dayPart, STOCK_GREETING, clockGreets,
|
|
226
246
|
};
|
|
227
247
|
if (typeof window !== 'undefined') window.OTBStudioLib = api;
|
|
@@ -631,6 +631,17 @@ html.st-open { overflow: hidden; }
|
|
|
631
631
|
.st-surface__fill { height: 4px; border-radius: 999px; background: var(--st-accent); }
|
|
632
632
|
.st-asked { margin: 0; font-size: 14px; line-height: 1.5; color: var(--st-ink-faint); }
|
|
633
633
|
|
|
634
|
+
/* Where your pages are (task 1004662): each page with an open round, its state
|
|
635
|
+
and how long; a stuck page leads and carries the accent. */
|
|
636
|
+
.st-flight__head { margin: 28px 0 10px; font-size: 13px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; color: var(--st-ink-dim); }
|
|
637
|
+
.st-flights { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
|
638
|
+
.st-flight { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 6px 0; }
|
|
639
|
+
.st-flight__page { min-height: 24px; display: inline-flex; align-items: center; font-size: 15px; color: var(--st-ink); text-decoration: none; overflow-wrap: anywhere; }
|
|
640
|
+
.st-flight__page:hover, .st-flight__page:focus-visible { text-decoration: underline; }
|
|
641
|
+
.st-flight__where { font-size: 13px; color: var(--st-ink-dim); }
|
|
642
|
+
.st-flight__state { margin-left: auto; font-size: 13.5px; color: var(--st-ink-soft); }
|
|
643
|
+
.st-flight--stuck .st-flight__state { color: var(--st-accent); }
|
|
644
|
+
|
|
634
645
|
/* From the live site (task 1004499): one row per review, the page then who and
|
|
635
646
|
when, then its opening words. Each row opens the doc in the palette's panel. */
|
|
636
647
|
.st-sites { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
|
@@ -720,7 +720,14 @@
|
|
|
720
720
|
<div class="st-surface__track"><div class="st-surface__fill" style="width: ${Number(s.pct)}%"></div></div>
|
|
721
721
|
</li>`).join('');
|
|
722
722
|
const asked = lib.askedLine(home.next && home.next.asked_pages);
|
|
723
|
-
|
|
723
|
+
const flight = lib.inFlightRows(home.pages, new Date()).map((f) => `
|
|
724
|
+
<li class="st-flight${f.stuck ? ' st-flight--stuck' : ''}">
|
|
725
|
+
<a class="st-flight__page" href="${escapeHtml(editorHref(f.page_id))}">${escapeHtml(f.title)}</a>
|
|
726
|
+
<span class="st-flight__where">${escapeHtml(f.surface)}</span>
|
|
727
|
+
<span class="st-flight__state">${escapeHtml(f.state)}${f.age ? ` · ${escapeHtml(f.age)}` : ''}${f.stuck ? ' · stuck' : ''}</span>
|
|
728
|
+
</li>`).join('');
|
|
729
|
+
const inFlight = flight ? `<h3 class="st-flight__head">Where your pages are</h3><ul class="st-flights">${flight}</ul>` : '';
|
|
730
|
+
return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}${inFlight}`;
|
|
724
731
|
}
|
|
725
732
|
|
|
726
733
|
function siteHtml() {
|
|
@@ -45,7 +45,9 @@ The city's plaque and door, the street's header and readings, the deck and the s
|
|
|
45
45
|
|
|
46
46
|
- **Nothing is invented.** A field comes from a read or is derived from reads by a rule in `gov-living.js`; a read that fails leaves its part out. No sample data, no prototype mark, no key.
|
|
47
47
|
- **Salmon is spent only on what needs you**, everywhere: a matter routed to another seat stands in ink on the street (pin and windows) as in the city. Ordinary activity is ink, and so is a choice: inside a building the chosen floor, its frame, sign and number, the lift and the desk's plate are ink, salmon only while that floor needs you (task 1004718).
|
|
48
|
-
- **What needs you is never hidden.** The civic quarter's back corner, centre and front corner stand in one screen column, so the Charter (the tallest) takes the back corner and nothing taller stands in front of Planning; a building's salmon windows take its top rows first (task 1004718).
|
|
48
|
+
- **What needs you is never hidden.** The civic quarter's back corner, centre and front corner stand in one screen column, so the Charter (the tallest) takes the back corner and nothing taller stands in front of Planning; a building's salmon windows take its top rows first (task 1004718). Task 1004724 holds the rule in every column: the taller of any two buildings in one screen column stands at the back (Release behind Rules, Automation behind People), tested over all nine.
|
|
49
|
+
- **One ask, one place.** Inside a building, an ask the floor desk already holds as a row (the treasury's dry pot) is not repeated as a card in the building's book above it (task 1004724).
|
|
50
|
+
- **Who should lead.** The Lead's office offers anyone who may open a board item a pick of the project's active people and one chip; it files the existing appoint_lead People matter (POST /government/board/matters). Only the vote passing seats a Lead; the server checks everything again (task 1004724).
|
|
49
51
|
- **The street band is cropped to what it draws** (`street.js` `BAND_TOP`/`BAND_H`, matched by `street.css`): no empty sky over the skylanes and no bare ground under the brackets; a stage taller than the street needs gives its spare height to the foot of the page, under the readings, never to the sky. Paper haze is a cool sea tone deep enough to read as distance (task 1004718).
|
|
50
52
|
- **Motion is CSS only**, says only "this is live", and carries no data (count, size, position and colour do). Ambient loops are 20 s or longer (the pin's halo breathes on 24 s); growth, build-out and the deck's card (the panel rises once) are class-triggered one-shots; every animation stops under `prefers-reduced-motion`, and the still frame is a complete picture.
|
|
51
53
|
- **Colours are the pack's tokens**, except the iso and front-on drawing palettes, which are drawing data. Dark rules are written twice. Light mode is paper: glows become ink washes.
|
|
@@ -22,7 +22,10 @@ const blockers = require('./blockers');
|
|
|
22
22
|
const ratify = require('./ratify');
|
|
23
23
|
|
|
24
24
|
const POINT = 'governor.docket';
|
|
25
|
-
|
|
25
|
+
// Every open blocker is a matter, so the docket's counts are complete (task
|
|
26
|
+
// 1004724: a cap of 50 undercounted past it). Open blockers are few and the read
|
|
27
|
+
// is one indexed query; this ceiling only guards a runaway table.
|
|
28
|
+
const DOCKET_BLOCKER_LIMIT = 2000;
|
|
26
29
|
|
|
27
30
|
// One open blocker for one viewer. The list is public (GET /blockers), so every
|
|
28
31
|
// governor sees it; only a viewer who may resolve it (`blocker.review`, the
|
|
@@ -135,6 +135,7 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
|
|
|
135
135
|
const readings = await readLive(before.shippedIds, deps);
|
|
136
136
|
const live = uat.foldLiveReadings(readings);
|
|
137
137
|
const owner = deps.ownerId !== undefined ? { id: deps.ownerId } : await api.getFoundingBuilder();
|
|
138
|
+
const signerIsOwner = await holdsOwnerSeat(signerId, deps);
|
|
138
139
|
|
|
139
140
|
return withTx(async (client) => {
|
|
140
141
|
const { rows: crit } = await client.query(
|
|
@@ -148,7 +149,7 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
|
|
|
148
149
|
const backendOnly = criterion ? await isBackendOnly(client, id) : false;
|
|
149
150
|
const refusal = uat.signoffRefusal({
|
|
150
151
|
criterion, counts: now.counts, remaining: now.remaining, backendOnly, kind,
|
|
151
|
-
signerId, ownerId: owner ? owner.id : null, shipperIds: now.shipperIds,
|
|
152
|
+
signerId, ownerId: owner ? owner.id : null, signerIsOwner, shipperIds: now.shipperIds,
|
|
152
153
|
recording, live, liveAttested,
|
|
153
154
|
});
|
|
154
155
|
if (refusal) return { ok: false, refusal };
|
|
@@ -164,6 +165,22 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
|
|
|
164
165
|
}, { pool });
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// holdsOwnerSeat — does the signer hold the government's owner seat (task
|
|
169
|
+
// 1004757)? Read per request through the government port, uncached (ADR 0016).
|
|
170
|
+
// No port, or a failed read, answers false: the founding-builder check still
|
|
171
|
+
// applies, so this can only ever ADD the owner, never widen past them.
|
|
172
|
+
async function holdsOwnerSeat(signerId, deps = {}) {
|
|
173
|
+
const testing = process.env.NODE_ENV === 'test';
|
|
174
|
+
const government = testing && deps.government !== undefined ? deps.government : api.resolveOptional('government');
|
|
175
|
+
if (!government || typeof government.positionsFor !== 'function') return false;
|
|
176
|
+
try {
|
|
177
|
+
const p = await government.positionsFor(signerId);
|
|
178
|
+
return !!(p && Array.isArray(p.held) && p.held.some((h) => h && h.kind === 'owner'));
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
// recordOverride — the /satisfy escape hatch, which now has to give a reason and
|
|
168
185
|
// leaves a row saying so (the state reads "Satisfied (override)", never UAT).
|
|
169
186
|
async function recordOverride(exec, { criterionId, signerId, reason }) {
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
// solo project can never deadlock on its own work (the owner's "any other
|
|
28
28
|
// builder or the owner").
|
|
29
29
|
//
|
|
30
|
+
// WHO IS THE OWNER (task 1004757). The government's owner seat — every active
|
|
31
|
+
// Archon (modules/government/leads.js ownerOf) — is who this project calls its
|
|
32
|
+
// owner everywhere else (the "Owner" position on /me, the seat an unseated
|
|
33
|
+
// building falls to). The founding builder (lowest-id human) is kept as a
|
|
34
|
+
// fallback for an instance without the government module. Using ONLY the
|
|
35
|
+
// founding builder refused the real owner on a hub whose lowest-id human is
|
|
36
|
+
// someone else, while the refusal itself told them the owner could sign.
|
|
37
|
+
//
|
|
30
38
|
// This file has NO database and NO express: the state derivation and the
|
|
31
39
|
// refusal ladder are pure so every answer is pinned by a DB-free test, and the
|
|
32
40
|
// store unit-tests against a temp dir. The SQL lives in criterion-uat-db.js.
|
|
@@ -125,12 +133,13 @@ function foldLiveReadings(readings) {
|
|
|
125
133
|
// backendOnly boolean
|
|
126
134
|
// kind 'uat' | 'backend_signoff'
|
|
127
135
|
// signerId the authenticated builder
|
|
128
|
-
// ownerId the
|
|
136
|
+
// ownerId the founding builder (getFoundingBuilder), or null
|
|
137
|
+
// signerIsOwner the signer holds the government's owner seat
|
|
129
138
|
// shipperIds builder ids that shipped a linked task
|
|
130
139
|
// recording a stored recording name, or null
|
|
131
140
|
// live foldLiveReadings(...) output
|
|
132
141
|
// liveAttested the signer ticked "this is on the live site"
|
|
133
|
-
function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
|
|
142
|
+
function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, signerIsOwner = false, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
|
|
134
143
|
if (!criterion) return { code: 'criterion_not_found', status: 404, message: 'No such criterion.' };
|
|
135
144
|
if (criterion.satisfied) {
|
|
136
145
|
return { code: 'criterion_already_satisfied', status: 409, message: 'This criterion is already closed.' };
|
|
@@ -153,7 +162,7 @@ function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind,
|
|
|
153
162
|
return { code: 'criterion_needs_recording', status: 409, message: 'This criterion is user-facing, so it needs a UAT recording. Only a criterion marked backend-only when it was written can be signed off without one.' };
|
|
154
163
|
}
|
|
155
164
|
const signer = String(signerId);
|
|
156
|
-
const isOwner = ownerId != null && String(ownerId) === signer;
|
|
165
|
+
const isOwner = signerIsOwner === true || (ownerId != null && String(ownerId) === signer);
|
|
157
166
|
if (!isOwner && shipperIds.map(String).includes(signer)) {
|
|
158
167
|
return { code: 'signer_shipped_this_work', status: 403, message: 'You shipped work linked to this criterion, so someone else has to sign it off. Any other reviewer, or the project owner, can.' };
|
|
159
168
|
}
|
|
@@ -412,9 +412,9 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
412
412
|
router.patch('/project-settings/spend-cap', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('treasury'), async (req, res) => {
|
|
413
413
|
if (api.validateOrRespond(req, res, { usd: {} })) return; // a number of dollars, or null to turn it off; parsed below
|
|
414
414
|
const raw = req.body ? req.body.usd : undefined;
|
|
415
|
-
if (raw === undefined) return res.fail('bad_spend_cap', { status: 400, message: '
|
|
415
|
+
if (raw === undefined) return res.fail('bad_spend_cap', { status: 400, message: 'Send a number of dollars a month, or nothing to turn the cap off.' });
|
|
416
416
|
const parsed = raw === null ? { usd: null } : api.projectSettings.parseSpendCap(raw);
|
|
417
|
-
if (parsed.reason) return res.fail('bad_spend_cap', { status: 400, message:
|
|
417
|
+
if (parsed.reason) return res.fail('bad_spend_cap', { status: 400, message: parsed.reason });
|
|
418
418
|
try {
|
|
419
419
|
await api.projectSettings.setSpendCap(parsed.usd, req.builder.id);
|
|
420
420
|
const resolved = await api.projectSettings.resolveSpendCap();
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.142",
|
|
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.142",
|
|
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.142",
|
|
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
|
@@ -9844,9 +9844,23 @@
|
|
|
9844
9844
|
}
|
|
9845
9845
|
],
|
|
9846
9846
|
"1.21.141": [
|
|
9847
|
+
{
|
|
9848
|
+
"id": "1004757",
|
|
9849
|
+
"text": "The project owner can now sign off a goal criterion even when they built some of its work, as the on-screen message always said. Everyone else who built the work still needs someone else to sign."
|
|
9850
|
+
},
|
|
9851
|
+
{
|
|
9852
|
+
"id": "1004662",
|
|
9853
|
+
"text": "The Studio now shows where every page you have submitted actually is: waiting to be applied, being applied, waiting for your review, or approved and going live, with how long it has sat there and a \"stuck\" mark when it has sat"
|
|
9854
|
+
},
|
|
9847
9855
|
{
|
|
9848
9856
|
"id": "1004755",
|
|
9849
9857
|
"text": "Your home page now lists only the goals you have joined that are still open, and under each one says how many tasks you can pick up. Finished goals no longer crowd them out."
|
|
9850
9858
|
}
|
|
9859
|
+
],
|
|
9860
|
+
"1.21.142": [
|
|
9861
|
+
{
|
|
9862
|
+
"id": "1004724",
|
|
9863
|
+
"text": "Twelve small government fixes. Spend-cap mistakes now read as plain sentences. Removing a Lead from a building with none says what to do next. A toast names a setting by its name, not its code. Inside the Treasury, the dry-pot"
|
|
9864
|
+
}
|
|
9851
9865
|
]
|
|
9852
9866
|
}
|
|
@@ -358,11 +358,12 @@ function describeTweakAutopickup(resolved) {
|
|
|
358
358
|
// it is said so and a new loan is recommended against. It never stops work.
|
|
359
359
|
const MAX_SPEND_CAP_USD = 1000000;
|
|
360
360
|
function parseSpendCap(raw) {
|
|
361
|
-
|
|
361
|
+
// The reasons are plain sentences a person reads as-is (task 1004724).
|
|
362
|
+
if (raw == null || raw === '') return { reason: 'Type a number of dollars a month, or clear the cap to turn it off.' };
|
|
362
363
|
const n = Number(raw);
|
|
363
|
-
if (!Number.isFinite(n)) return { reason: 'not a number' };
|
|
364
|
-
if (n <= 0) return { reason: 'must be more than $0
|
|
365
|
-
if (n > MAX_SPEND_CAP_USD) return { reason: `
|
|
364
|
+
if (!Number.isFinite(n)) return { reason: 'That is not a number. Type a number of dollars a month, like 200.' };
|
|
365
|
+
if (n <= 0) return { reason: 'The cap must be more than $0. To have no cap, clear it instead.' };
|
|
366
|
+
if (n > MAX_SPEND_CAP_USD) return { reason: `The cap can be at most $${MAX_SPEND_CAP_USD.toLocaleString('en-US')} a month.` };
|
|
366
367
|
return { usd: Math.round(n * 100) / 100 };
|
|
367
368
|
}
|
|
368
369
|
// resolveSpendCap() — { on, usd, source, invalid }. Off (usd null) unless a usable
|
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.142'; // 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');
|
package/tests/criterion_uat.mjs
CHANGED
|
@@ -238,6 +238,48 @@ await test('SIGNOFF: a shipper is refused and NOTHING is written', async () => {
|
|
|
238
238
|
assert.ok(!db.calls.some((c) => /WITH candidate AS/.test(c.sql)), 'a refusal never runs the close');
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
// task 1004757: the owner the refusal names is the government's owner seat. The
|
|
242
|
+
// hub's owner was refused because only the founding builder (lowest-id human)
|
|
243
|
+
// counted, and on the hub that is someone else.
|
|
244
|
+
const ownerSeat = (ids) => ({ positionsFor: async (id) => ({ held: ids.includes(String(id)) ? [{ kind: 'owner' }] : [{ kind: 'board' }] }) });
|
|
245
|
+
|
|
246
|
+
await test('LADDER: the owner seat may sign off work they shipped, even when not the founding builder', () => {
|
|
247
|
+
assert.equal(code({ signerId: 7, ownerId: 3, signerIsOwner: true }), null);
|
|
248
|
+
assert.equal(code({ signerId: 7, ownerId: 3, signerIsOwner: false }), 'signer_shipped_this_work');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('SIGNOFF: a shipper holding the owner seat is allowed and the row is written', async () => {
|
|
252
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
253
|
+
const out = await uatDb.signOff(
|
|
254
|
+
{ criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
|
|
255
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: ownerSeat(['7']) },
|
|
256
|
+
);
|
|
257
|
+
assert.equal(out.ok, true, JSON.stringify(out.refusal));
|
|
258
|
+
assert.equal(inserts(db).length, 1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await test('SIGNOFF: a shipper on the board but NOT the owner seat is still refused', async () => {
|
|
262
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
263
|
+
const out = await uatDb.signOff(
|
|
264
|
+
{ criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
|
|
265
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: ownerSeat(['9']) },
|
|
266
|
+
);
|
|
267
|
+
assert.equal(out.ok, false);
|
|
268
|
+
assert.equal(out.refusal.code, 'signer_shipped_this_work');
|
|
269
|
+
assert.equal(inserts(db).length, 0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await test('SIGNOFF: an unreadable owner seat falls back to the founding builder, never wider', async () => {
|
|
273
|
+
const broken = { positionsFor: async () => { throw new Error('down'); } };
|
|
274
|
+
const db = signoffDb({ linked: SHIPPED });
|
|
275
|
+
const out = await uatDb.signOff(
|
|
276
|
+
{ criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
|
|
277
|
+
{ pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: broken },
|
|
278
|
+
);
|
|
279
|
+
assert.equal(out.ok, false);
|
|
280
|
+
assert.equal(out.refusal.code, 'signer_shipped_this_work');
|
|
281
|
+
});
|
|
282
|
+
|
|
241
283
|
await test('SIGNOFF: the project owner may sign off their own shipped work', async () => {
|
|
242
284
|
const db = signoffDb({ linked: [{ id: 11, status: 'shipped', shipped_by: 3, shipped_at: new Date() }] });
|
|
243
285
|
const out = await uatDb.signOff(
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// tests/government_tidy_up.mjs — the government tidy-up after the goal 1000111
|
|
2
|
+
// chain (task 1004724). One case per item that changed behaviour; DB-free (every
|
|
3
|
+
// read is stubbed at the module seam it goes through).
|
|
4
|
+
//
|
|
5
|
+
// 1 the spend-cap refusals are plain sentences, sent as-is
|
|
6
|
+
// 2 "no Lead to remove" says what to do next
|
|
7
|
+
// 3 a setting a law already holds is named by its row name, not its key
|
|
8
|
+
// 5 /settings shows the law locks its reads report (settings-law-locks.js)
|
|
9
|
+
// 7 in every city column the taller building stands at the back
|
|
10
|
+
// 8 the city plaque's words are in the body face in both modes
|
|
11
|
+
// 9 the docket reads every open blocker, so its counts are complete
|
|
12
|
+
// 10 the office offers "who should lead", filed as the appoint_lead People matter
|
|
13
|
+
// 11 a habit already up for a vote reads "proposed" after a reload
|
|
14
|
+
// 12 two appointments racing to one unseated building: a clean refusal
|
|
15
|
+
// 4 the treasury's dry-pot ask shows once: the book's card steps aside for the desk row
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
27
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
28
|
+
|
|
29
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
30
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
31
|
+
const projectSettings = require(path.join(ROOT, 'src', 'bongos', 'project-settings.js'));
|
|
32
|
+
const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
|
|
33
|
+
const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
|
|
34
|
+
const decisionBoard = require(path.join(ROOT, 'modules', 'government', 'decision-board.js'));
|
|
35
|
+
const ideasBlockers = require(path.join(ROOT, 'modules', 'ideas', 'blockers.js'));
|
|
36
|
+
const ideasDocket = require(path.join(ROOT, 'modules', 'ideas', 'governor-docket.js'));
|
|
37
|
+
const City = require(path.join(HALL, 'city-draw.js'));
|
|
38
|
+
const MyAgents = require(path.join(HALL, 'my-agents.js'));
|
|
39
|
+
const Laws = require(path.join(HALL, 'settings-law-locks.js'));
|
|
40
|
+
const Office = require(path.join(HALL, 'street-office.js'));
|
|
41
|
+
|
|
42
|
+
async function withStubs(target, stubs, fn) {
|
|
43
|
+
const originals = {};
|
|
44
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = target[k]; target[k] = v; }
|
|
45
|
+
try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) target[k] = v; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('1: every spend-cap refusal is a plain sentence, and the route sends it as-is', () => {
|
|
49
|
+
for (const raw of ['', 'lots', 0, -5, projectSettings.MAX_SPEND_CAP_USD + 1]) {
|
|
50
|
+
const { reason } = projectSettings.parseSpendCap(raw);
|
|
51
|
+
assert.ok(reason, 'refused: ' + raw);
|
|
52
|
+
assert.match(reason, /^[A-Z].*\.$/, 'a sentence: ' + reason);
|
|
53
|
+
assert.doesNotMatch(reason, /usd|\.\.|^empty$|out of range/i, 'no code words: ' + reason);
|
|
54
|
+
}
|
|
55
|
+
assert.equal(projectSettings.parseSpendCap(200).usd, 200);
|
|
56
|
+
const route = read('modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
57
|
+
assert.match(route, /res\.fail\('bad_spend_cap', \{ status: 400, message: parsed\.reason \}\)/);
|
|
58
|
+
assert.doesNotMatch(route, /message: `usd: /);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('2: removing a Lead from a building with none says what to do next', async () => {
|
|
62
|
+
await withStubs(govDb, {
|
|
63
|
+
listDecisionRules: async () => [], listLeads: async () => [],
|
|
64
|
+
createMatter: async () => { throw new Error('nothing is filed'); },
|
|
65
|
+
openBoardItem: async () => { throw new Error('nothing is opened'); },
|
|
66
|
+
}, async () => {
|
|
67
|
+
const out = await board.openMatter({ area: 'people', title: 'No Lead', action: { type: 'remove_lead', building: 'review' }, proposedBy: '42' });
|
|
68
|
+
assert.equal(out.reason, 'no_lead');
|
|
69
|
+
assert.match(out.what_this_means, /has no Lead to remove/);
|
|
70
|
+
assert.match(out.what_this_means, /propose a Lead/, 'the next step is named');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('3: a setting a law already holds is named by its row name in the toast', () => {
|
|
75
|
+
const src = read('modules', 'hall-ui', 'public', 'my-agents.js');
|
|
76
|
+
assert.match(src, /nameFor\(str\(err\.details\.setting\)\)/);
|
|
77
|
+
assert.doesNotMatch(src, /already holds ' \+ str\(err\.details/, 'never the raw key');
|
|
78
|
+
assert.equal(MyAgents.SETTING.verbosity.name, 'Reply length');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('5: a law lock on /settings disables what it rules out and names the law', () => {
|
|
82
|
+
const data = { law_locks: [
|
|
83
|
+
{ setting: 'verbosity', value: 'concise', law_no: 2, law_text: 'Agents keep replies short', enforced: true, allowed: ['concise'] },
|
|
84
|
+
{ setting: 'wandering', max: 'focused', law_no: 3, law_text: 'Agents stay on task', enforced: true, allowed: ['locked', 'focused'] },
|
|
85
|
+
{ setting: 'machinery', value: 'nonsense', law_no: 4, enforced: false, reason: 'value_not_allowed' },
|
|
86
|
+
] };
|
|
87
|
+
const fixed = Laws.lockFor(data, 'verbosity');
|
|
88
|
+
assert.equal(Laws.allows(fixed, 'concise'), true);
|
|
89
|
+
assert.equal(Laws.allows(fixed, 'detailed'), false);
|
|
90
|
+
assert.equal(Laws.lawLine(fixed), 'Law 2 sets this for every agent in the project: "Agents keep replies short". Only a vote can change it.');
|
|
91
|
+
const cap = Laws.lockFor(data, 'wandering');
|
|
92
|
+
assert.deepEqual(['locked', 'focused', 'balanced', 'exploratory'].map((v) => Laws.allows(cap, v)), [true, true, false, false]);
|
|
93
|
+
assert.match(Laws.lawLine(cap), /^Law 3 caps this/);
|
|
94
|
+
assert.equal(Laws.tagFor(cap), 'set by law 3');
|
|
95
|
+
assert.equal(Laws.lockFor(data, 'machinery'), null, 'a lock that enforces nothing locks nothing');
|
|
96
|
+
assert.equal(Laws.lockFor({}, 'verbosity'), null);
|
|
97
|
+
assert.equal(Laws.allows(null, 'anything'), true);
|
|
98
|
+
// both panels read it, and the page loads it before either
|
|
99
|
+
assert.match(read('modules', 'hall-ui', 'public', 'settings-interaction.js'), /Laws\.lockFor\(data, f\.field\)/);
|
|
100
|
+
assert.match(read('modules', 'hall-ui', 'public', 'settings.js'), /Laws\.lockFor\(data, 'wandering'\)/);
|
|
101
|
+
const html = read('modules', 'hall-ui', 'public', 'settings.html');
|
|
102
|
+
const at = (s) => html.indexOf(s);
|
|
103
|
+
assert.ok(at('settings-law-locks.js') > 0 && at('settings-law-locks.js') < at('/builders/settings.js') && at('settings-law-locks.js') < at('settings-interaction.js'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('7: in every screen column of the civic quarter the taller building stands at the back', () => {
|
|
107
|
+
const at = City.CIVIC_SLOTS.map(([key, x, y]) => ({ key, col: x - y, depth: x + y, roof: City.ROOF[key] }));
|
|
108
|
+
for (const b of at) {
|
|
109
|
+
for (const o of at.filter((z) => z.col === b.col && z.depth > b.depth)) {
|
|
110
|
+
assert.ok(o.roof < b.roof, o.key + ' stands in front of the shorter ' + b.key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
assert.equal(new Set(City.CIVIC_SLOTS.map((s) => s[0])).size, 9, 'nine buildings');
|
|
114
|
+
assert.equal(new Set(City.CIVIC_SLOTS.map((s) => s[1] + ',' + s[2])).size, 9, 'nine lots');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('8: the plaque words are in the body face (the light hall maps labels to mono)', () => {
|
|
118
|
+
const css = read('modules', 'hall-ui', 'public', 'city.css');
|
|
119
|
+
assert.match(css, /\.city-plaque__words \{ font-family: var\(--font-body\);/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('9: the docket reads every open blocker, so its counts are complete past 50', async () => {
|
|
123
|
+
const many = Array.from({ length: 120 }, (_, i) => ({ id: String(i + 1), title: 'Blocker ' + (i + 1), body_md: '', status: 'open', created_at: '2026-10-01T00:00:00.000Z', blocking_task_ids: [] }));
|
|
124
|
+
let asked = null;
|
|
125
|
+
seams._reset();
|
|
126
|
+
try {
|
|
127
|
+
await withStubs(ideasBlockers, { listOpenBlockers: async (o) => { asked = o; return many.slice(0, o && o.limit != null ? o.limit : many.length); } }, async () => {
|
|
128
|
+
ideasDocket.contributeIdeasToDocket();
|
|
129
|
+
const c = seams.contributions('governor.docket').find((x) => x.source === 'blockers');
|
|
130
|
+
const rows = await c.read({ has: () => true });
|
|
131
|
+
assert.equal(rows.length, 120, 'all of them');
|
|
132
|
+
assert.ok(asked.limit == null || asked.limit >= 1000, 'a ceiling far past any real list');
|
|
133
|
+
});
|
|
134
|
+
} finally { seams._reset(); }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('10: the office offers who should lead to a builder who may open a board item, filed as appoint_lead', () => {
|
|
138
|
+
const living = { seats_real: true, owner: { name: 'Builder 1' }, buildings: [
|
|
139
|
+
{ key: 'treasury', name: 'Treasury', lead: { id: '7', name: 'Gus', since: '2026-10-01' }, structure: null, construction: [], answers: { ids: ['7'] } },
|
|
140
|
+
] };
|
|
141
|
+
const roster = { builders: [
|
|
142
|
+
{ id: '9', display_name: 'Zoe', status: 'active' }, { id: '7', display_name: 'Gus', status: 'active' },
|
|
143
|
+
{ id: '3', github_login: 'ann', status: 'active' }, { id: '4', display_name: 'Old', status: 'inactive' },
|
|
144
|
+
] };
|
|
145
|
+
const may = { permissions: { 'board.item.open': true } };
|
|
146
|
+
assert.equal(Office.appointPlan(living, 'treasury', roster, { permissions: {} }), null, 'not offered without the perm');
|
|
147
|
+
assert.equal(Office.appointPlan(living, 'treasury', roster, null), null);
|
|
148
|
+
const plan = Office.appointPlan(living, 'treasury', roster, may);
|
|
149
|
+
assert.deepEqual(plan.people.map((p) => p.name), ['ann', 'Zoe'], 'active people, A to Z, never the Lead in office');
|
|
150
|
+
assert.equal(plan.seated, true);
|
|
151
|
+
const body = Office.appointBody(plan, '9');
|
|
152
|
+
assert.deepEqual(body, { area: 'people', title: 'Appoint Zoe as Lead of Treasury', action: { type: 'appoint_lead', building: 'treasury', builder_id: 9 } });
|
|
153
|
+
assert.equal(decisionBoard.validateMatterAction('people', body.action).ok, true, 'the server\'s own shape');
|
|
154
|
+
assert.equal(Office.appointBody(plan, '7'), null, 'only someone on the list');
|
|
155
|
+
assert.equal(Office.appointPlan(living, 'treasury', roster, may, { leadFiled: { treasury: true } }).filed, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('11: a habit already up for a vote reads proposed after a reload', () => {
|
|
159
|
+
const room = { open: [
|
|
160
|
+
{ id: '55', subject_type: 'constitutional_amendment', subject: { kind: 'laws', proposed: { enact: [{ text: 'Agents reply in detail', locks: [{ setting: 'verbosity', value: 'detailed' }] }] } } },
|
|
161
|
+
{ id: '56', subject_type: 'constitutional_amendment', subject: { kind: 'board', proposed: { enact: [{ locks: [{ setting: 'machinery', value: 'quiet' }] }] } } },
|
|
162
|
+
] };
|
|
163
|
+
const pending = MyAgents.pendingLaws(room);
|
|
164
|
+
assert.deepEqual(pending, { 'verbosity=detailed': '55' }, 'only laws sittings');
|
|
165
|
+
assert.deepEqual(MyAgents.pendingLaws(null), {});
|
|
166
|
+
assert.deepEqual(MyAgents.pendingLaws(false), {}, 'a 403 reads as none');
|
|
167
|
+
const me = {
|
|
168
|
+
builder: { id: '1' }, permissions: { 'board.item.open': true },
|
|
169
|
+
interaction: { fields: [{ field: 'verbosity', default_value: 'concise' }, { field: 'machinery', default_value: 'show' }], stored: { verbosity: 'detailed', machinery: 'quiet' }, effective: {} },
|
|
170
|
+
};
|
|
171
|
+
const v = MyAgents.buildView({ me, pending, proposed: {} }, {});
|
|
172
|
+
const h = (k) => v.habits.find((x) => x.key === k);
|
|
173
|
+
assert.equal(h('verbosity').proposed, true);
|
|
174
|
+
assert.equal(h('verbosity').item, '55');
|
|
175
|
+
assert.equal(h('machinery').proposed, false);
|
|
176
|
+
// a vote for ANOTHER value of the same setting is not this habit
|
|
177
|
+
const other = MyAgents.buildView({ me, pending: { 'verbosity=concise': '57' }, proposed: {} }, {});
|
|
178
|
+
assert.equal(other.habits.find((x) => x.key === 'verbosity').proposed, false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('12: two appointments racing to an unseated building end in a clean refusal, not a DB error', async () => {
|
|
182
|
+
const fake = (insertError) => ({
|
|
183
|
+
query: async (sql) => {
|
|
184
|
+
if (/^\s*INSERT INTO government_leads/.test(sql)) throw insertError;
|
|
185
|
+
if (/^\s*SELECT/.test(sql)) return { rows: [] };
|
|
186
|
+
return { rows: [] };
|
|
187
|
+
},
|
|
188
|
+
release: () => {},
|
|
189
|
+
});
|
|
190
|
+
const unique = Object.assign(new Error('duplicate key value violates unique constraint "government_leads_in_office_uniq"'), { code: '23505', constraint: 'government_leads_in_office_uniq' });
|
|
191
|
+
await withStubs(api.pool, { connect: async () => fake(unique) }, async () => {
|
|
192
|
+
const out = await govDb.applyLeadAct({ itemId: '40', action: { type: 'appoint_lead', building: 'planning', builder_id: 45, charter: 'x' } });
|
|
193
|
+
assert.equal(out.done, false);
|
|
194
|
+
assert.equal(out.reason, 'lead_seated_meanwhile');
|
|
195
|
+
assert.doesNotMatch(out.what_this_means, /duplicate|constraint|23505/);
|
|
196
|
+
});
|
|
197
|
+
const other = Object.assign(new Error('boom'), { code: '23503' });
|
|
198
|
+
await withStubs(api.pool, { connect: async () => fake(other) }, async () => {
|
|
199
|
+
await assert.rejects(govDb.applyLeadAct({ itemId: '40', action: { type: 'appoint_lead', building: 'planning', builder_id: 45, charter: 'x' } }), /boom/, 'any other failure still throws');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('4: a book card steps aside when the floor desk below holds the same matter', () => {
|
|
204
|
+
const node = (attrs, kids = []) => {
|
|
205
|
+
const n = { attrs, kids, parentNode: null, getAttribute: (k) => (k in attrs ? attrs[k] : null) };
|
|
206
|
+
kids.forEach((k) => { k.parentNode = n; });
|
|
207
|
+
n.removeChild = (c) => { n.kids = n.kids.filter((x) => x !== c); c.parentNode = null; };
|
|
208
|
+
return n;
|
|
209
|
+
};
|
|
210
|
+
const all = (n) => n.kids.flatMap((k) => [k, ...all(k)]);
|
|
211
|
+
const card1 = node({ 'data-ask': 'treasury_spending:1' });
|
|
212
|
+
const card2 = node({ 'data-ask': 'blockers:9' });
|
|
213
|
+
const book = node({}, [card1, card2]);
|
|
214
|
+
const govt = node({}, [book]);
|
|
215
|
+
govt.querySelectorAll = (sel) => (sel === '[data-ask]' ? all(govt).filter((n) => n.attrs['data-ask']) : []);
|
|
216
|
+
const desk = node({}, [node({ 'data-row': 'treasury_spending:1' })]);
|
|
217
|
+
desk.querySelector = (sel) => { const m = /^\[data-row="(.*)"\]$/.exec(sel); return m ? all(desk).find((n) => n.attrs['data-row'] === m[1]) || null : null; };
|
|
218
|
+
assert.equal(Office.page.settle(govt, desk), 1);
|
|
219
|
+
assert.deepEqual(book.kids.map((k) => k.attrs['data-ask']), ['blockers:9'], 'the one the desk shows is gone; the other stays');
|
|
220
|
+
assert.equal(Office.page.settle(null, desk), 0);
|
|
221
|
+
// street.js settles the book after it fills the floor desk
|
|
222
|
+
assert.match(read('modules', 'hall-ui', 'public', 'street.js'), /fillDesk\(on, bv\); if \(OP && OP\.settle\) OP\.settle\(\$\('insideGovt'\), on\);/);
|
|
223
|
+
});
|
|
@@ -248,6 +248,23 @@ try {
|
|
|
248
248
|
assert.equal(lib.askedLine(0), '');
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
await test('Where your pages are (task 1004662): the server in-flight list, worded, stuck first, with how long', () => {
|
|
252
|
+
assert.deepEqual(lib.inFlightRows(PAGES, NOW).map((r) => [r.page_id, r.state, r.age, r.stuck]), [
|
|
253
|
+
['landing:index', 'Being written', '', false],
|
|
254
|
+
], 'the open round the ledger holds, no date on it so no age');
|
|
255
|
+
const rows = lib.inFlightRows({ in_flight: [
|
|
256
|
+
{ page_id: 'landing:contact', title: 'Contact', surface: 'landing', state_word: 'Approved, going live', since: day(17), stuck: true },
|
|
257
|
+
{ page_id: 'builders:work', title: 'Tasks', surface: 'builders', state_word: 'Submitted, waiting to be applied', since: day(1), stuck: false },
|
|
258
|
+
{ page_id: 'status:index', title: 'Status', surface: 'status', state_word: null, since: NOW.toISOString(), stuck: true },
|
|
259
|
+
] }, NOW);
|
|
260
|
+
assert.deepEqual(rows.map((r) => [r.title, r.state, r.age, r.stuck]), [
|
|
261
|
+
['Contact', 'Approved, going live', 'for 17 days', true],
|
|
262
|
+
['Tasks', 'Submitted, waiting to be applied', 'for 1 day', false],
|
|
263
|
+
['Status', 'Needs a look', 'since today', true],
|
|
264
|
+
]);
|
|
265
|
+
assert.deepEqual(lib.inFlightRows(null, NOW), []);
|
|
266
|
+
});
|
|
267
|
+
|
|
251
268
|
await test('a word on the page finds its own line: the most specific wins, holes alone never match, shared never', () => {
|
|
252
269
|
const sentence = 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.';
|
|
253
270
|
assert.equal(lib.matchLine(sentence, STUDIO_READING.lines).key, 'L0003', 'not the generic "{…} of {…}"');
|
|
@@ -335,6 +352,9 @@ try {
|
|
|
335
352
|
assert.match(body, /1 of 2 tweaked/);
|
|
336
353
|
assert.match(body, /style="width: 50%"/);
|
|
337
354
|
assert.match(body, /1 page has been asked for/);
|
|
355
|
+
assert.match(body, /Where your pages are/);
|
|
356
|
+
assert.match(body, /st-flight__state">Being written</, 'the open round shows where it is');
|
|
357
|
+
assert.match(body, /href="\/builders\/tweak-editor\?page=landing:index"/);
|
|
338
358
|
await s.clickIn('st-panel', '[data-close-panel]');
|
|
339
359
|
assert.equal(s.el('st-panel').hidden, true);
|
|
340
360
|
assert.ok(!s.html.classList.contains('st-open'));
|