@bongos/core 1.19.544 → 1.19.545
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 +66 -21
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +115 -0
- package/docs/copy-inventory.md +37 -13
- package/docs/copy-registry.json +264 -18
- package/docs/module-api-changelog.md +2 -0
- package/docs/research/task-network-map/real-a-every-task.png +0 -0
- package/docs/research/task-network-map/real-b-dependency-forest.png +0 -0
- package/docs/research/task-network-map/real-c-goals.png +0 -0
- package/docs/research/task-network-map/real-d-joined-by-goal.png +0 -0
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/hall-ui/public/studio.css +268 -0
- package/modules/hall-ui/public/studio.html +74 -0
- package/modules/hall-ui/public/studio.js +213 -0
- package/modules/hall-ui/public/studio.states.json +54 -0
- package/modules/hall-ui/public/style.css +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spike-task-map.js +178 -9
- package/src/bongos/serve-internal.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +6 -1
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hall_studio_world.mjs +143 -0
- package/tests/spike_task_map.mjs +0 -0
|
@@ -139,24 +139,160 @@ function buildSynthetic({ nodes, goals, density, seed = 1003282 }) {
|
|
|
139
139
|
return tasks;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// depIdsOf — the dependency ids on one row of a real dump, whatever the API called
|
|
143
|
+
// them. This is load-bearing and was WRONG until task 1003477: `GET /tasks?include=deps`
|
|
144
|
+
// answers with `blocked_by` (an array of {id,title,status,goal_id,satisfied} — see
|
|
145
|
+
// db.attachTaskDepSummaries), and this loader only ever looked for `dependencies`/`deps`.
|
|
146
|
+
// A real dump therefore parsed as a corpus with ZERO edges, and every --from number
|
|
147
|
+
// would have described an empty graph while looking perfectly plausible — the exact
|
|
148
|
+
// failure a spike must not have, because nothing about the output says "no edges here
|
|
149
|
+
// because the field was misnamed". All three spellings are accepted now, and each may
|
|
150
|
+
// hold bare ids or objects carrying one.
|
|
151
|
+
function depIdsOf(row) {
|
|
152
|
+
// First NON-EMPTY array wins, not the first field present: a dump carrying both an
|
|
153
|
+
// empty `blocked_by` and a populated `dependencies` would otherwise read as edgeless,
|
|
154
|
+
// which is the same silent zero this function was written to stop.
|
|
155
|
+
const raw = [row.blocked_by, row.dependencies, row.deps]
|
|
156
|
+
.find((v) => Array.isArray(v) && v.length > 0) || [];
|
|
157
|
+
return raw
|
|
158
|
+
.map((d) => Number(d && typeof d === 'object' ? d.id : d))
|
|
159
|
+
.filter(Number.isFinite);
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
// loadReal — the same shape from a real `GET /tasks?include=deps` dump, so the next
|
|
143
163
|
// session with DB access can replace every synthetic number in the verdict with a
|
|
144
164
|
// measured one by re-running with --from.
|
|
165
|
+
//
|
|
166
|
+
// GOALS: a task with no goal does NOT share a bucket with the other ungoaled tasks —
|
|
167
|
+
// "no goal" is not a goal they have in common, and pooling them would silently turn
|
|
168
|
+
// every dependency between two ungoaled tasks into a WITHIN-goal edge, understating
|
|
169
|
+
// the one number this loader exists to produce. Each gets its own bucket instead;
|
|
170
|
+
// `goalId: null` marks them so census() can report them apart from real goals.
|
|
145
171
|
function loadReal(file) {
|
|
146
172
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
147
173
|
const rows = Array.isArray(raw) ? raw : (raw.tasks || raw.rows || []);
|
|
148
174
|
const goalIndex = new Map();
|
|
175
|
+
let nextBucket = 0;
|
|
149
176
|
return rows.map((r) => {
|
|
150
|
-
const
|
|
151
|
-
|
|
177
|
+
const gid = r.goal_id == null || r.goal_id === '' ? null : String(r.goal_id);
|
|
178
|
+
let bucket;
|
|
179
|
+
if (gid === null) {
|
|
180
|
+
bucket = nextBucket++; // ungoaled: a bucket of one
|
|
181
|
+
} else {
|
|
182
|
+
if (!goalIndex.has(gid)) goalIndex.set(gid, nextBucket++);
|
|
183
|
+
bucket = goalIndex.get(gid);
|
|
184
|
+
}
|
|
152
185
|
return {
|
|
153
186
|
id: Number(r.id),
|
|
154
|
-
goal:
|
|
155
|
-
|
|
187
|
+
goal: bucket,
|
|
188
|
+
goalId: gid,
|
|
189
|
+
deps: depIdsOf(r),
|
|
156
190
|
};
|
|
157
191
|
});
|
|
158
192
|
}
|
|
159
193
|
|
|
194
|
+
// census — THE TWO NUMBERS task 1003477 exists to measure, plus the three that decide
|
|
195
|
+
// what they mean. ADR 0227 deferred items 3 (the goal graph) and 4 (the dependency
|
|
196
|
+
// forest) on a SYNTHETIC density sweep and said the live corpus must settle them.
|
|
197
|
+
//
|
|
198
|
+
// with_dependency_pct — the share of tasks carrying at least one dependency row.
|
|
199
|
+
// Item 4's question: is there a forest, or is the corpus
|
|
200
|
+
// confetti? Note this is OUT-degree (what a task waits on),
|
|
201
|
+
// not the degree-0 count the table's `isol%` reports — a task
|
|
202
|
+
// that only BLOCKS others declares no row of its own but is
|
|
203
|
+
// still drawn, so the two numbers differ and mean different
|
|
204
|
+
// things.
|
|
205
|
+
// cross_goal_pct — the share of dependency edges whose two ends sit in
|
|
206
|
+
// different goals. Item 3's question: does the goal graph
|
|
207
|
+
// have EDGES, or is it forty disconnected dots?
|
|
208
|
+
//
|
|
209
|
+
// And what turns those into a verdict: how many goals are left with no cross-goal edge
|
|
210
|
+
// at all (isolated dots a node-link layout has nothing to say about), how many separate
|
|
211
|
+
// components the forest breaks into, and how big the typical one is — a forest of 128
|
|
212
|
+
// four-node trees is a drill-down, not a map, however many edges it contains.
|
|
213
|
+
function census(tasks) {
|
|
214
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
215
|
+
const edges = [];
|
|
216
|
+
let withDep = 0;
|
|
217
|
+
for (const t of tasks) {
|
|
218
|
+
const live = t.deps.filter((d) => byId.has(d));
|
|
219
|
+
if (t.deps.length > 0) withDep += 1;
|
|
220
|
+
for (const d of live) edges.push([t.id, d]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let crossGoal = 0;
|
|
224
|
+
let touchingUngoaled = 0;
|
|
225
|
+
const goalPairs = new Set();
|
|
226
|
+
const goalsWithEdge = new Set();
|
|
227
|
+
for (const [a, b] of edges) {
|
|
228
|
+
const ga = byId.get(a).goalId;
|
|
229
|
+
const gb = byId.get(b).goalId;
|
|
230
|
+
if (ga === null || gb === null) { touchingUngoaled += 1; continue; }
|
|
231
|
+
if (ga === gb) continue;
|
|
232
|
+
crossGoal += 1;
|
|
233
|
+
goalPairs.add(`${ga}>${gb}`);
|
|
234
|
+
goalsWithEdge.add(ga);
|
|
235
|
+
goalsWithEdge.add(gb);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const realGoals = new Set(tasks.map((t) => t.goalId).filter((g) => g !== null));
|
|
239
|
+
const ungoaled = tasks.filter((t) => t.goalId === null).length;
|
|
240
|
+
|
|
241
|
+
// The forest: only the tasks with an edge in either direction, and how it breaks up.
|
|
242
|
+
const degree = new Map(tasks.map((t) => [t.id, 0]));
|
|
243
|
+
for (const [a, b] of edges) {
|
|
244
|
+
degree.set(a, degree.get(a) + 1);
|
|
245
|
+
degree.set(b, degree.get(b) + 1);
|
|
246
|
+
}
|
|
247
|
+
const connected = tasks.filter((t) => degree.get(t.id) > 0).map((t) => t.id);
|
|
248
|
+
const comp = components(connected, edges);
|
|
249
|
+
const sizes = componentSizes(connected, edges);
|
|
250
|
+
const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 0;
|
|
251
|
+
|
|
252
|
+
// THE DRILL-DOWN, which is the shape item 4 is actually scoped as ("queried when a
|
|
253
|
+
// goal is opened") — not the whole-corpus forest. Per goal: the tasks with an edge
|
|
254
|
+
// whose OTHER end sits in the same goal. Its WORST case is the number that decides
|
|
255
|
+
// the item, because a drill-down has to survive its biggest goal, not its median one.
|
|
256
|
+
const perGoal = new Map();
|
|
257
|
+
for (const [a, b] of edges) {
|
|
258
|
+
const ga = byId.get(a).goalId;
|
|
259
|
+
if (ga === null || ga !== byId.get(b).goalId) continue;
|
|
260
|
+
if (!perGoal.has(ga)) perGoal.set(ga, new Set());
|
|
261
|
+
perGoal.get(ga).add(a);
|
|
262
|
+
perGoal.get(ga).add(b);
|
|
263
|
+
}
|
|
264
|
+
const perGoalSizes = [...perGoal.values()].map((v) => v.size).sort((x, y) => y - x);
|
|
265
|
+
const perGoalMax = perGoalSizes[0] || 0;
|
|
266
|
+
const perGoalMedian = perGoalSizes.length ? perGoalSizes[Math.floor(perGoalSizes.length / 2)] : 0;
|
|
267
|
+
|
|
268
|
+
const pct = (n, d) => (d ? Number(((n / d) * 100).toFixed(2)) : 0);
|
|
269
|
+
return {
|
|
270
|
+
tasks: tasks.length,
|
|
271
|
+
edges: edges.length,
|
|
272
|
+
with_dependency: withDep,
|
|
273
|
+
with_dependency_pct: pct(withDep, tasks.length),
|
|
274
|
+
connected: connected.length,
|
|
275
|
+
connected_pct: pct(connected.length, tasks.length),
|
|
276
|
+
cross_goal: crossGoal,
|
|
277
|
+
cross_goal_pct: pct(crossGoal, edges.length),
|
|
278
|
+
edges_touching_ungoaled: touchingUngoaled,
|
|
279
|
+
goals: realGoals.size,
|
|
280
|
+
ungoaled_tasks: ungoaled,
|
|
281
|
+
goal_pairs: goalPairs.size,
|
|
282
|
+
goals_with_cross_goal_edge: goalsWithEdge.size,
|
|
283
|
+
goals_isolated: realGoals.size - goalsWithEdge.size,
|
|
284
|
+
goals_isolated_pct: pct(realGoals.size - goalsWithEdge.size, realGoals.size),
|
|
285
|
+
forest_components: comp.count,
|
|
286
|
+
forest_largest: comp.largest,
|
|
287
|
+
forest_median_component: median,
|
|
288
|
+
per_goal_forests: perGoalSizes.length,
|
|
289
|
+
per_goal_largest: perGoalMax,
|
|
290
|
+
per_goal_median: perGoalMedian,
|
|
291
|
+
// The drill-down clears the bar only if its BIGGEST goal still fits labels.
|
|
292
|
+
per_goal_largest_labels: inkBudget(perGoalMax || 1).fits_with_label,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
160
296
|
// ---------------------------------------------------------------------------
|
|
161
297
|
// graph views — the literal ask, and the two shapes it could become
|
|
162
298
|
// ---------------------------------------------------------------------------
|
|
@@ -223,7 +359,11 @@ function viewsOf(tasks) {
|
|
|
223
359
|
|
|
224
360
|
// weakly-connected components, ignoring edge direction — a reader does not perceive
|
|
225
361
|
// arrow direction when deciding "are these two blobs the same thing".
|
|
226
|
-
|
|
362
|
+
// componentSizes — the same union-find, returning the raw sizes largest-first. Split
|
|
363
|
+
// out because "how many components" and "how big is the typical one" are different
|
|
364
|
+
// questions: 128 components says a view is fragmented, but only the MEDIAN size says
|
|
365
|
+
// whether each fragment is a readable little tree or a hairball of its own (census()).
|
|
366
|
+
function componentSizes(nodes, edges) {
|
|
227
367
|
const idx = new Map(nodes.map((n, i) => [n, i]));
|
|
228
368
|
const parent = nodes.map((_, i) => i);
|
|
229
369
|
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
|
@@ -238,7 +378,11 @@ function components(nodes, edges) {
|
|
|
238
378
|
const r = find(i);
|
|
239
379
|
sizes.set(r, (sizes.get(r) || 0) + 1);
|
|
240
380
|
}
|
|
241
|
-
|
|
381
|
+
return [...sizes.values()].sort((a, b) => b - a);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function components(nodes, edges) {
|
|
385
|
+
const all = componentSizes(nodes, edges);
|
|
242
386
|
return { count: all.length, largest: all[0] || 0, singletons: all.filter((s) => s === 1).length };
|
|
243
387
|
}
|
|
244
388
|
|
|
@@ -658,11 +802,34 @@ function measure(label, view, { render: doRender, file, radius = 3 } = {}) {
|
|
|
658
802
|
};
|
|
659
803
|
}
|
|
660
804
|
|
|
805
|
+
// printCensus — the two gating numbers first and in words, because a reader who
|
|
806
|
+
// stops after the first screen must still leave with the answer ADR 0227 asked for.
|
|
807
|
+
function printCensus(c) {
|
|
808
|
+
const cg = c.goals_with_cross_goal_edge;
|
|
809
|
+
console.log('live corpus census — the numbers ADR 0227 items 3 and 4 wait on');
|
|
810
|
+
console.log(` corpus ${c.tasks} tasks, ${c.goals} goals`
|
|
811
|
+
+ (c.ungoaled_tasks ? `, ${c.ungoaled_tasks} task(s) with no goal` : ''));
|
|
812
|
+
console.log(` Q1 tasks with a dependency ${c.with_dependency} (${c.with_dependency_pct}%) → item 4, the dependency forest`);
|
|
813
|
+
console.log(` Q2 cross-goal edges ${c.cross_goal} of ${c.edges} (${c.cross_goal_pct}%) → item 3, the goal graph`);
|
|
814
|
+
if (c.edges_touching_ungoaled) {
|
|
815
|
+
console.log(` (${c.edges_touching_ungoaled} edge(s) touch a task with no goal and are counted as neither)`);
|
|
816
|
+
}
|
|
817
|
+
console.log(` goal graph shape ${c.goal_pairs} distinct goal pairs; ${cg} goal(s) carry an edge, `
|
|
818
|
+
+ `${c.goals_isolated} isolated (${c.goals_isolated_pct}%)`);
|
|
819
|
+
console.log(` forest shape ${c.connected} tasks with any edge (${c.connected_pct}%) in `
|
|
820
|
+
+ `${c.forest_components} components; largest ${c.forest_largest}, median ${c.forest_median_component}`);
|
|
821
|
+
console.log(` per-goal drill-down ${c.per_goal_forests} goal(s) have one; largest ${c.per_goal_largest}, `
|
|
822
|
+
+ `median ${c.per_goal_median} — worst case ${c.per_goal_largest_labels ? 'still carries labels' : 'CANNOT carry labels'}`);
|
|
823
|
+
console.log('');
|
|
824
|
+
}
|
|
825
|
+
|
|
661
826
|
function main() {
|
|
662
827
|
const results = [];
|
|
828
|
+
let realCensus = null;
|
|
663
829
|
|
|
664
830
|
if (FROM) {
|
|
665
831
|
const tasks = loadReal(path.resolve(REPO_ROOT, FROM));
|
|
832
|
+
realCensus = census(tasks);
|
|
666
833
|
const v = viewsOf(tasks);
|
|
667
834
|
results.push(measure('real', v.A, { render: RENDER, file: path.join(OUT_DIR, 'real-a-every-task.png') }));
|
|
668
835
|
results.push(measure('real', v.B, { render: RENDER, file: path.join(OUT_DIR, 'real-b-dependency-forest.png') }));
|
|
@@ -688,13 +855,14 @@ function main() {
|
|
|
688
855
|
}
|
|
689
856
|
|
|
690
857
|
if (AS_JSON) {
|
|
691
|
-
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, results }, null, 2)}\n`);
|
|
858
|
+
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, census: realCensus, results }, null, 2)}\n`);
|
|
692
859
|
return;
|
|
693
860
|
}
|
|
694
861
|
|
|
695
862
|
const pad = (s, n) => String(s).padEnd(n);
|
|
696
863
|
const rpad = (s, n) => String(s).padStart(n);
|
|
697
|
-
|
|
864
|
+
if (realCensus) printCensus(realCensus);
|
|
865
|
+
console.log(`task-network-map spike — ${realCensus ? realCensus.tasks : N_TASKS} tasks over ${realCensus ? realCensus.goals : N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
|
|
698
866
|
console.log(`ink floor: a legible dot needs ${MIN_DOT_PITCH}px of pitch; a label needs ${MIN_LABEL_W}×${MIN_LABEL_H}px `
|
|
699
867
|
+ `(${inkBudget(1).labels_that_fit} labels fit on this canvas, total)`);
|
|
700
868
|
console.log('');
|
|
@@ -725,6 +893,7 @@ if (require.main === module) main();
|
|
|
725
893
|
// alpha-blend bug would quietly corrupt the evidence rather than fail loudly.
|
|
726
894
|
// Exported = coverable; see tests/spike_task_map.mjs.
|
|
727
895
|
module.exports = {
|
|
728
|
-
buildSynthetic,
|
|
896
|
+
buildSynthetic, loadReal, depIdsOf, census, viewsOf, components, componentSizes,
|
|
897
|
+
layout, crossingCount, inkBudget, mulberry32,
|
|
729
898
|
crc32, chunk, writePng, canvas, goalColor, render,
|
|
730
899
|
};
|
|
@@ -1033,7 +1033,7 @@ function mountInternalSurfaces(app) {
|
|
|
1033
1033
|
// any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
|
|
1034
1034
|
// derives crafts from the viewer's own profile), so signed-in is the right floor.
|
|
1035
1035
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1036
|
-
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1036
|
+
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1037
1037
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1038
1038
|
// page that embeds them.
|
|
1039
1039
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.545'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -149,7 +149,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
149
149
|
// pages followed by the two CRAFT surfaces side by side: the ideator's queue and
|
|
150
150
|
// the artist's. Same reasoning as copy-desk for why it is here rather than Docs
|
|
151
151
|
// (a place you act, not reference material) or You (its subject is the project).
|
|
152
|
-
|
|
152
|
+
// task 1003117 (R06 of goal 1000074): 'studio' — the Studio, artist mode —
|
|
153
|
+
// joins the same group AFTER copy-desk, so the two artist surfaces (the calm
|
|
154
|
+
// room and the flag table) sit side by side, both with the project's own work
|
|
155
|
+
// rather than in Docs (reference) or You (a person). Pinning the order keeps a
|
|
156
|
+
// new tab in the hall's most valuable nav real estate a reviewed decision.
|
|
157
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk', 'studio'],
|
|
153
158
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
154
159
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
155
160
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -52,6 +52,11 @@ const ORACLE = {
|
|
|
52
52
|
// every rank by design, and collab's reads (task 1003172) are scoped to the
|
|
53
53
|
// caller by the server — so for all three, sign-in IS the whole gate.
|
|
54
54
|
'/builders/copy-desk': 'requireBuilderPage',
|
|
55
|
+
// task 1003117 (R06 of goal 1000074): the Studio (artist mode) — open to every
|
|
56
|
+
// rank like copy-desk (an artist is a craft, not a rank), so sign-in is the
|
|
57
|
+
// whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
|
|
58
|
+
// themselves server-side.
|
|
59
|
+
'/builders/studio': 'requireBuilderPage',
|
|
55
60
|
'/builders/ideas': 'requireBuilderPage',
|
|
56
61
|
'/builders/collab': 'requireBuilderPage',
|
|
57
62
|
// task 1002710: the orientation reading rooms moved DOWN to this tier from
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// tests/hall_studio_world.mjs — the Studio (artist mode), R06 of goal 1000074
|
|
2
|
+
// (task 1003117). The surface's load-bearing invariants, pinned so a later edit
|
|
3
|
+
// cannot quietly turn the calm room back into a form or a write path.
|
|
4
|
+
//
|
|
5
|
+
// hall_audit.mjs already holds this page to the universal rules (style.css +
|
|
6
|
+
// shell.js present, no colour literal, no page-declared pack var, no bespoke
|
|
7
|
+
// pager/chart, no sideways-scroll style). hall_nav.mjs pins the nav entry and
|
|
8
|
+
// hall_page_gate_map.mjs the sign-in gate. What is left, and lives here, is what
|
|
9
|
+
// only the Studio must be true: it writes nothing, it gates nothing, it is a
|
|
10
|
+
// room and not a table, and it IS the artist mode it claims to be.
|
|
11
|
+
//
|
|
12
|
+
// Pure file-read (no DB/network) — runs in the DB-free unit gate.
|
|
13
|
+
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
23
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
24
|
+
|
|
25
|
+
const HTML = read('studio.html');
|
|
26
|
+
// Comments legitimately mention markup (e.g. "the page's one <h1>"), so strip
|
|
27
|
+
// them before counting tags — the invariant is about the rendered DOM, not prose.
|
|
28
|
+
const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
|
|
29
|
+
const JS = read('studio.js');
|
|
30
|
+
const CSS = read('studio.css');
|
|
31
|
+
|
|
32
|
+
test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
|
|
33
|
+
// Every craft act (coach the grader, remake copy, propose wording) happens on
|
|
34
|
+
// the surface that owns it (the task record, the copy desk) as a claimed,
|
|
35
|
+
// graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
|
|
36
|
+
// here would be the live-CMS write path the goal forbids (criterion
|
|
37
|
+
// artist-edits-land-as-tasks) — so it must not exist.
|
|
38
|
+
const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
|
|
39
|
+
assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the reviews read is bounded and correct', () => {
|
|
43
|
+
// Review requests (cascade.js) are filed at backlog, discipline artist. They sit
|
|
44
|
+
// there until dismissed and cascade files one per qualifying ship, so the read
|
|
45
|
+
// MUST carry a limit cap — the unbounded-read shape modules/hall-ui/CLAUDE.md
|
|
46
|
+
// flags as a repeat bug. Assert the actual bound, not just the filter, so the
|
|
47
|
+
// test name does not overclaim.
|
|
48
|
+
assert.match(JS, /\/tasks\?discipline=artist&status=backlog&limit=\$\{REVIEW_LIMIT\}/, 'the backlog artist read carries a limit cap');
|
|
49
|
+
assert.match(JS, /const REVIEW_LIMIT = \d+;/, 'the cap is a named, finite constant');
|
|
50
|
+
assert.match(JS, /source === 'cascade'/, 'and rows are filtered to the cascade (review) source');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the copy-desk read is best-effort — the room degrades, never errors', () => {
|
|
54
|
+
// An instance without the copy-desk module 404s /copy-desk/queue; the Studio
|
|
55
|
+
// must fall back to reviews alone, so the flags loader returns [] on !ok rather
|
|
56
|
+
// than throwing or blanking the room.
|
|
57
|
+
assert.match(JS, /copy-desk\/queue\?status=open/, 'flags come from the open copy-desk queue');
|
|
58
|
+
assert.match(JS, /if \(!res\.ok\) return \[\];/, 'a failed queue read yields no flags, not an error');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('it is artist mode — the room declares the mode marker', () => {
|
|
62
|
+
// The social-mode pattern (shell.js applySocial): the mode rides an attribute
|
|
63
|
+
// on the document root, set here statically so entering the room IS entering
|
|
64
|
+
// the mode with no paint flash. Losing it makes this just another page.
|
|
65
|
+
assert.match(HTML, /<html[^>]*\bdata-artist="on"/, 'the page declares data-artist="on" on <html>');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('one <main>, one <h1> — the greeting is it', () => {
|
|
69
|
+
// one-header-main-h1, checked here in the static markup so every state (incl.
|
|
70
|
+
// the no-JS fallback) keeps the single heading.
|
|
71
|
+
const h1s = HTML_NC.match(/<h1\b/g) || [];
|
|
72
|
+
assert.equal(h1s.length, 1, 'exactly one <h1>');
|
|
73
|
+
assert.match(HTML_NC, /<h1[^>]*id="studio-greet"/, 'the greeting is the page h1');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a room, not a table — no tabular markup', () => {
|
|
77
|
+
// The criterion is explicit: "rather than another table of rows". The copy
|
|
78
|
+
// desk is the table; the Studio must not become a second one.
|
|
79
|
+
assert.ok(!/<table\b/.test(HTML_NC) && !/<table\b/.test(JS), 'the Studio renders no <table>');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the accent is spent only where the rule allows', () => {
|
|
83
|
+
// DESIGN.md's One Warm Thing Rule: the accent lights the pressable (the primary
|
|
84
|
+
// action, .btn-accent, which the shell owns) or the LIVE. The one warm thing
|
|
85
|
+
// this sheet paints is the ambient lamp (an accent wash), documented as the
|
|
86
|
+
// room's live light; nothing else in studio.css may fill from the accent.
|
|
87
|
+
const accentFills = CSS.match(/background:\s*[^;]*var\(--accent\)[^;]*;/g) || [];
|
|
88
|
+
assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
|
|
92
|
+
// #nav-studio records classList toggles, so applyCraftEmphasis is tested for
|
|
93
|
+
// what it DOES, not just that it exists.
|
|
94
|
+
function bootShell() {
|
|
95
|
+
const src = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
|
|
96
|
+
const makeEl = () => {
|
|
97
|
+
const classes = new Set();
|
|
98
|
+
return {
|
|
99
|
+
dataset: {}, style: {}, title: '', textContent: '', hidden: false, _classes: classes,
|
|
100
|
+
set innerHTML(_v) {}, get innerHTML() { return ''; },
|
|
101
|
+
classList: {
|
|
102
|
+
add: (c) => classes.add(c), remove: (c) => classes.delete(c),
|
|
103
|
+
toggle: (c, on) => { const want = on === undefined ? !classes.has(c) : !!on; if (want) classes.add(c); else classes.delete(c); return want; },
|
|
104
|
+
contains: (c) => classes.has(c),
|
|
105
|
+
},
|
|
106
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
107
|
+
addEventListener() {}, querySelectorAll() { return []; }, appendChild() {}, focus() {},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const els = new Map();
|
|
111
|
+
const getEl = (id) => { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); };
|
|
112
|
+
const document = {
|
|
113
|
+
body: { dataset: { page: 'studio' } },
|
|
114
|
+
documentElement: getEl('__html__'),
|
|
115
|
+
getElementById: getEl,
|
|
116
|
+
querySelectorAll() { return []; },
|
|
117
|
+
createElement() { return makeEl(); },
|
|
118
|
+
addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
|
|
119
|
+
};
|
|
120
|
+
const location = { hostname: 'builders.cloudbongos.com', origin: 'https://builders.cloudbongos.com', pathname: '/studio', hash: '', assign() {}, replace() {} };
|
|
121
|
+
const window = {
|
|
122
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
123
|
+
BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
|
|
124
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
125
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
126
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
127
|
+
};
|
|
128
|
+
const sandbox = { window, document, location, console, setTimeout, clearTimeout, CustomEvent: class {} };
|
|
129
|
+
sandbox.globalThis = sandbox;
|
|
130
|
+
vm.createContext(sandbox);
|
|
131
|
+
vm.runInContext(src, sandbox, { filename: 'shell.js' });
|
|
132
|
+
return { applyAccess: window.OTBShell.applyAccess, navStudio: () => getEl('nav-studio') };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('the Studio nav entry is warmed only for an artist-craft builder', () => {
|
|
136
|
+
const { applyAccess, navStudio } = bootShell();
|
|
137
|
+
|
|
138
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist', 'ideator'] } });
|
|
139
|
+
assert.ok(navStudio()._classes.has('nav-item--craft'), 'an artist-craft builder gets the warmed entry');
|
|
140
|
+
|
|
141
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['engineer'] } });
|
|
142
|
+
assert.ok(!navStudio()._classes.has('nav-item--craft'), 'and a non-artist builder does not (the class is toggled off)');
|
|
143
|
+
});
|
package/tests/spike_task_map.mjs
CHANGED
|
Binary file
|