@bongos/core 1.19.660 → 1.19.662
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 +18 -18
- package/docs/copy-inventory.md +55 -54
- package/docs/copy-registry.json +81 -72
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/catalog.js +126 -16
- package/modules/hall-ui/public/work.css +6 -0
- package/modules/hall-ui/public/work.html +18 -6
- package/modules/hall-ui/public/work.js +161 -66
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_sort_staleness.mjs +12 -1
- package/tests/government_ownership_scope.mjs +192 -11
- package/tests/hall_board_world.mjs +103 -0
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.662'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -202,7 +202,18 @@ await test('a server-side filter change RE-FETCHES; every other filter stays ins
|
|
|
202
202
|
// direct question anyway, and means a control absent from that query can never
|
|
203
203
|
// cost a round-trip.
|
|
204
204
|
assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
|
|
205
|
-
|
|
205
|
+
// task 1003828 split this into ONE DECISION PER FEED. It used to be a single
|
|
206
|
+
// comparison that re-fetched both and returned early — which was fine while the
|
|
207
|
+
// two asked the server the same question, and wrong once the backlog started
|
|
208
|
+
// asking a narrower one (version/kind/discipline/goal_id, which GET /tasks
|
|
209
|
+
// answers and GET /tasks/claimable does not). The early return also meant a
|
|
210
|
+
// change to a CLIENT-side lens skipped the other tab's re-render until the next
|
|
211
|
+
// poll repainted it. Each side now re-fetches or re-renders on its own; the
|
|
212
|
+
// property under test is unchanged — a server-facing change costs a round-trip
|
|
213
|
+
// and nothing else does.
|
|
214
|
+
assert.match(WORK_SRC, /let lastBacklogQuery = '';/);
|
|
215
|
+
assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
|
|
216
|
+
assert.match(WORK_SRC, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
|
|
206
217
|
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
207
218
|
assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
|
|
208
219
|
'the contract this decision depends on — if it ever hands the changed id instead, revisit');
|
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
PERMISSIONS, RANK_ORDER, RANK_SEED, RESOURCE_FAMILIES, permissionKeys, byKey,
|
|
41
41
|
scopeOf, resourceOf, isOwnershipScoped, isCrossOwner, ownershipScopedKeys, crossOwnerKeys,
|
|
42
42
|
crossOwnerKeysFor, isPrincipalKey, seedForRank,
|
|
43
|
+
OWNERSHIP_VERBS, VERB_DOMINANCE, verbDominates, verbOf,
|
|
43
44
|
} = catalog;
|
|
44
45
|
const {
|
|
45
46
|
sameOwner, authorizeOwned, authorizeOwnedOrAny, builderHasPermissions, effectivePermissions,
|
|
@@ -106,12 +107,24 @@ test('A3: the modelled families are the ones the ownership sweep names', () => {
|
|
|
106
107
|
// The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`.
|
|
107
108
|
assert.deepEqual([...crossOwnerKeys()].sort(),
|
|
108
109
|
['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
|
|
109
|
-
|
|
110
|
+
// The escalation path is DECLARED per key, never derived from the resource
|
|
111
|
+
// family (task 1003195). box is the one REAL pair in the catalog today:
|
|
112
|
+
// `box.fleet.manage` is the same `manage` verb over every owner's box, so it
|
|
113
|
+
// genuinely subsumes managing your own.
|
|
110
114
|
assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
|
|
115
|
+
// claim and memory each HAVE an `any` key in their family and still declare no
|
|
116
|
+
// counterpart — the distinction the family-derived version could not make.
|
|
117
|
+
// `claim.release.any` carries one third of `act` (release, not cost or notes);
|
|
118
|
+
// `memory.read.any` is a read, and reading every builder's memory is not the
|
|
119
|
+
// authority to manage one. A family-derived answer returned both.
|
|
120
|
+
assert.deepEqual(crossOwnerKeysFor('task.act.own'), []);
|
|
121
|
+
assert.deepEqual(crossOwnerKeysFor('memory.manage.own'), []);
|
|
111
122
|
// Some resources have NO cross-owner form by design — nobody manages another
|
|
112
123
|
// builder's credential, Archon included.
|
|
113
124
|
assert.deepEqual(crossOwnerKeysFor('credential.manage.own'), []);
|
|
114
125
|
assert.deepEqual(crossOwnerKeysFor('nope.not.a.key'), []);
|
|
126
|
+
// A cross-owner key is not itself own-scoped, so it has no path of its own.
|
|
127
|
+
assert.deepEqual(crossOwnerKeysFor('claim.release.any'), []);
|
|
115
128
|
});
|
|
116
129
|
|
|
117
130
|
test('A4: RESOURCE_FAMILIES is frozen and agrees with each row it lists', () => {
|
|
@@ -246,20 +259,112 @@ test('C5: builderHasPermissions REFUSES an ownership-scoped key rather than answ
|
|
|
246
259
|
|
|
247
260
|
// ═══ D — `.any` DOES reach another builder's resource ═════════════════════════
|
|
248
261
|
|
|
249
|
-
test('D1:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
test('D1: a DECLARED cross-owner counterpart reaches a row the caller does not own', async () => {
|
|
263
|
+
// box is the catalog's one real pair, and it must keep working: `box.fleet.manage`
|
|
264
|
+
// is the whole point of a cross-owner key. Asserted BOTH with and without the own
|
|
265
|
+
// key, because the counterpart is a standalone admit — it does not merely widen a
|
|
266
|
+
// grant the builder already has.
|
|
267
|
+
await holding(['box.manage.own', 'box.fleet.manage'], async () => {
|
|
268
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true,
|
|
269
|
+
'box.fleet.manage is exactly the authority to manage someone else\'s box');
|
|
270
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: ME }), true);
|
|
254
271
|
});
|
|
272
|
+
await holding(['box.fleet.manage'], async () => {
|
|
273
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ═══ D1b — a cross-owner key with a WEAKER VERB is not a counterpart ═══════════
|
|
278
|
+
//
|
|
279
|
+
// task 1003195, from the task-1002671 adversarial verification (finding 12). The
|
|
280
|
+
// admit branch used to hand back the resource family's whole `any` list VERB-BLIND,
|
|
281
|
+
// so any cross-owner key over the resource satisfied EVERY own key over it. The
|
|
282
|
+
// escalation is now a per-key declaration checked for verb dominance at load.
|
|
283
|
+
//
|
|
284
|
+
// ORACLE DISCIPLINE, per this file's header: these two cases are driven by holding
|
|
285
|
+
// the weak `any` key ALONE. The old D1 always held the own key TOO, so the admit it
|
|
286
|
+
// recorded could have come from either branch — which is exactly why the defect sat
|
|
287
|
+
// unpinned. Holding only the weak key leaves the counterpart branch as the sole
|
|
288
|
+
// possible source of a `true`.
|
|
289
|
+
test('D1b: a weaker-verb cross-owner key does not satisfy an own key', async () => {
|
|
290
|
+
// A READ grant over every builder's memory is not the authority to MANAGE one.
|
|
291
|
+
await holding(['memory.read.any'], async () => {
|
|
292
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
|
|
293
|
+
'memory.read.any reads every memory; it does not manage another builder\'s');
|
|
294
|
+
});
|
|
295
|
+
// `act` is a BUNDLE (release + cost + notes); `claim.release.any` carries the
|
|
296
|
+
// release third only, so it must not admit the other two against someone else.
|
|
297
|
+
await holding(['claim.release.any'], async () => {
|
|
298
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
|
|
299
|
+
'claim.release.any releases a claim; it does not post costs or notes on one');
|
|
300
|
+
});
|
|
301
|
+
// Holding the own key as well changes nothing about the OTHER owner's row — the
|
|
302
|
+
// shape the old D1 asserted as an admit.
|
|
255
303
|
await holding(['memory.manage.own', 'memory.read.any'], async () => {
|
|
256
|
-
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }),
|
|
304
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false);
|
|
305
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: ME }), true,
|
|
306
|
+
'the own branch is untouched — your own row is still yours');
|
|
257
307
|
});
|
|
258
|
-
await holding(['
|
|
259
|
-
assert.equal(await authorizeOwnedOrAny(ME, '
|
|
308
|
+
await holding(['task.act.own', 'claim.release.any'], async () => {
|
|
309
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false);
|
|
310
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: ME }), true);
|
|
311
|
+
});
|
|
312
|
+
// And the strongest form: EVERY permission in the catalog still does not reach
|
|
313
|
+
// another owner's memory or claim, because nothing in it dominates those verbs.
|
|
314
|
+
await holding(EVERYTHING, async () => {
|
|
315
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
|
|
316
|
+
'no key in the catalog dominates memory.manage.own');
|
|
317
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
|
|
318
|
+
'no key in the catalog dominates task.act.own');
|
|
260
319
|
});
|
|
261
320
|
});
|
|
262
321
|
|
|
322
|
+
// The declaration itself, held against the verb rule that admits it. This is the
|
|
323
|
+
// catalog-level twin of D1b: it fails if a future edit declares a counterpart whose
|
|
324
|
+
// verb does not dominate, even before any resolver call exercises it.
|
|
325
|
+
test('D1c: every declared counterpart is cross-owner, same-resource, and verb-dominant', () => {
|
|
326
|
+
let declared = 0;
|
|
327
|
+
for (const p of PERMISSIONS) {
|
|
328
|
+
if (p.scope !== 'own') continue;
|
|
329
|
+
assert.ok(Array.isArray(p.counterpart),
|
|
330
|
+
`${p.key} is own-scoped and must declare counterpart explicitly`);
|
|
331
|
+
assert.ok(OWNERSHIP_VERBS.includes(p.verb), `${p.key} must declare a known verb`);
|
|
332
|
+
assert.equal(verbOf(p.key), p.verb, 'verbOf must agree with the row it reads');
|
|
333
|
+
for (const key of p.counterpart) {
|
|
334
|
+
declared++;
|
|
335
|
+
const c = byKey(key);
|
|
336
|
+
assert.ok(c, `${p.key} names counterpart ${key}, which must exist`);
|
|
337
|
+
assert.equal(c.scope, 'any', `${p.key}'s counterpart ${key} must be cross-owner`);
|
|
338
|
+
assert.equal(c.resource, p.resource, `${p.key}'s counterpart ${key} must share its resource`);
|
|
339
|
+
assert.ok(verbDominates(c.verb, p.verb),
|
|
340
|
+
`${p.key}'s counterpart ${key} (verb ${c.verb}) must dominate verb ${p.verb}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
assert.ok(declared >= 1, 'expected at least the box pair to be declared');
|
|
344
|
+
// A verb never dominates a wider one, and an unknown verb fails closed — the
|
|
345
|
+
// relation is asserted directly so a future edit to the table is caught here.
|
|
346
|
+
assert.equal(verbDominates('manage', 'read'), true);
|
|
347
|
+
assert.equal(verbDominates('manage', 'act'), true);
|
|
348
|
+
assert.equal(verbDominates('read', 'read'), true);
|
|
349
|
+
assert.equal(verbDominates('read', 'manage'), false);
|
|
350
|
+
assert.equal(verbDominates('release', 'act'), false);
|
|
351
|
+
assert.equal(verbDominates('act', 'manage'), false);
|
|
352
|
+
assert.equal(verbDominates('manage', 'nonsense'), false);
|
|
353
|
+
assert.equal(verbDominates(null, 'manage'), false);
|
|
354
|
+
// The table is the whole relation: only `manage` subsumes anything, and it is the
|
|
355
|
+
// one verb nothing else dominates. Pinned so a widening needs a deliberate edit.
|
|
356
|
+
assert.deepEqual(Object.keys(VERB_DOMINANCE), ['manage']);
|
|
357
|
+
for (const v of OWNERSHIP_VERBS) {
|
|
358
|
+
assert.equal(verbDominates('manage', v), true, `manage must dominate ${v}`);
|
|
359
|
+
if (v !== 'manage') {
|
|
360
|
+
assert.equal(verbDominates(v, 'manage'), false, `${v} must not dominate manage`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// An unscoped key has no verb at all — the axis only means something with a scope.
|
|
364
|
+
assert.equal(verbOf('cost.log'), null);
|
|
365
|
+
assert.equal(verbOf('nope.not.a.key'), null);
|
|
366
|
+
});
|
|
367
|
+
|
|
263
368
|
test('D2: holding only the OWN key never reaches someone else, in either entry point', async () => {
|
|
264
369
|
for (const [, fam] of PAIRED) {
|
|
265
370
|
for (const ownKey of fam.own) {
|
|
@@ -354,7 +459,7 @@ test('F3: a cross-owner key cheaper than its own-scoped counterpart throws at mo
|
|
|
354
459
|
assert.throws(
|
|
355
460
|
() => loadMutatedCatalog('inverted floors', (s) => s.replace(
|
|
356
461
|
` { key: 'goal.manage.own',`,
|
|
357
|
-
` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
462
|
+
` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
358
463
|
/must never be cheaper than own-only/,
|
|
359
464
|
);
|
|
360
465
|
});
|
|
@@ -363,11 +468,87 @@ test('F4: a cross-owner key with no ownership-scoped counterpart throws at modul
|
|
|
363
468
|
assert.throws(
|
|
364
469
|
() => loadMutatedCatalog('lone any', (s) => s.replace(
|
|
365
470
|
` { key: 'goal.manage.own',`,
|
|
366
|
-
` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
471
|
+
` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
367
472
|
/with no ownership-scoped counterpart/,
|
|
368
473
|
);
|
|
369
474
|
});
|
|
370
475
|
|
|
476
|
+
// F5–F8 are task 1003195's half: the counterpart declaration and its verb rule.
|
|
477
|
+
// Same harness, so what is proven is the SHIPPED assertion block. Each fixture
|
|
478
|
+
// reintroduces the exact defect the task was filed for, or a near neighbour of it.
|
|
479
|
+
|
|
480
|
+
test('F5: a counterpart whose verb does not dominate throws at module load', () => {
|
|
481
|
+
// The literal finding-12 defect: memory.read.any declared as the escalation path
|
|
482
|
+
// for memory.manage.own — a read grant admitting a write on another owner's row.
|
|
483
|
+
assert.throws(
|
|
484
|
+
() => loadMutatedCatalog('weak verb counterpart', (s) => s.replace(
|
|
485
|
+
`resource: 'memory', verb: 'manage', counterpart: []`,
|
|
486
|
+
`resource: 'memory', verb: 'manage', counterpart: ['memory.read.any']`)),
|
|
487
|
+
/does not dominate/,
|
|
488
|
+
);
|
|
489
|
+
// And its sibling: release is one third of act.
|
|
490
|
+
assert.throws(
|
|
491
|
+
() => loadMutatedCatalog('partial verb counterpart', (s) => s.replace(
|
|
492
|
+
`resource: 'claim', verb: 'act', counterpart: []`,
|
|
493
|
+
`resource: 'claim', verb: 'act', counterpart: ['claim.release.any']`)),
|
|
494
|
+
/does not dominate/,
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('F6: an own-scoped key that declares no counterpart at all throws at module load', () => {
|
|
499
|
+
// The fail-loud that replaces the silent family derivation: a new own key cannot
|
|
500
|
+
// inherit an escalation path merely by belonging to a resource.
|
|
501
|
+
assert.throws(
|
|
502
|
+
() => loadMutatedCatalog('missing counterpart', (s) => s.replace(
|
|
503
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
504
|
+
`resource: 'credential', verb: 'manage'`)),
|
|
505
|
+
/must declare counterpart/,
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('F7: a counterpart that is dangling, cross-resource, or not cross-owner throws', () => {
|
|
510
|
+
assert.throws(
|
|
511
|
+
() => loadMutatedCatalog('dangling counterpart', (s) => s.replace(
|
|
512
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
513
|
+
`resource: 'credential', verb: 'manage', counterpart: ['credential.manage.any']`)),
|
|
514
|
+
/not a permission in this catalog/,
|
|
515
|
+
);
|
|
516
|
+
assert.throws(
|
|
517
|
+
() => loadMutatedCatalog('cross-resource counterpart', (s) => s.replace(
|
|
518
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
519
|
+
`resource: 'credential', verb: 'manage', counterpart: ['box.fleet.manage']`)),
|
|
520
|
+
/never crosses resource families/,
|
|
521
|
+
);
|
|
522
|
+
assert.throws(
|
|
523
|
+
() => loadMutatedCatalog('own key as counterpart', (s) => s.replace(
|
|
524
|
+
`resource: 'box', verb: 'manage', counterpart: ['box.fleet.manage']`,
|
|
525
|
+
`resource: 'box', verb: 'manage', counterpart: ['memory.manage.own']`)),
|
|
526
|
+
/is not cross-owner/,
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('F8: the verb field itself is partitioned with scope and checked against the vocabulary', () => {
|
|
531
|
+
assert.throws(
|
|
532
|
+
() => loadMutatedCatalog('scope without verb', (s) => s.replace(
|
|
533
|
+
`resource: 'builder_prefs', verb: 'set', counterpart: []`,
|
|
534
|
+
`resource: 'builder_prefs', counterpart: []`)),
|
|
535
|
+
/scope and verb must come together/,
|
|
536
|
+
);
|
|
537
|
+
assert.throws(
|
|
538
|
+
() => loadMutatedCatalog('unknown verb', (s) => s.replace(
|
|
539
|
+
`resource: 'builder_prefs', verb: 'set', counterpart: []`,
|
|
540
|
+
`resource: 'builder_prefs', verb: 'obliterate', counterpart: []`)),
|
|
541
|
+
/unknown verb/,
|
|
542
|
+
);
|
|
543
|
+
// A counterpart on a key that has no owner axis is a mis-declaration, not a no-op.
|
|
544
|
+
assert.throws(
|
|
545
|
+
() => loadMutatedCatalog('counterpart on an any key', (s) => s.replace(
|
|
546
|
+
`resource: 'memory', verb: 'read', guards:`,
|
|
547
|
+
`resource: 'memory', verb: 'read', counterpart: [], guards:`)),
|
|
548
|
+
/is not own-scoped/,
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
371
552
|
// ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
|
|
372
553
|
|
|
373
554
|
test('G1: the seed is still floor-derived and cumulative — the ownership axis adds no grants', () => {
|
|
@@ -471,3 +471,106 @@ test('the tab strip is left alone — it scrolls in its own row and never moved
|
|
|
471
471
|
assert.ok(strip, 'the tab strip rule is present');
|
|
472
472
|
assert.match(strip[1], /overflow-x:\s*auto/, 'it scrolls itself, which is why it is not the overflow cause');
|
|
473
473
|
});
|
|
474
|
+
|
|
475
|
+
// ---- the board's filters drive BOTH queue tabs (task 1003828) ---------------
|
|
476
|
+
//
|
|
477
|
+
// The owner reported an auto-filed artist review as "going into uncategorized
|
|
478
|
+
// BONGOS-V2 backlog tasks" and asked for tasks to be "filterable by role type".
|
|
479
|
+
// The review was never uncategorised — cascade.js inherits its parent's goal —
|
|
480
|
+
// but the Backlog tab it lands on could not say so and could not be narrowed:
|
|
481
|
+
//
|
|
482
|
+
// * renderBacklog() applied the SEARCH BOX ONLY. Version, Goal, Kind,
|
|
483
|
+
// Discipline, Newcomer and Run mode were read inside renderClaimable and
|
|
484
|
+
// nowhere else, so six controls were silently inert over the tab holding the
|
|
485
|
+
// most rows. Nothing errored; the list simply did not change.
|
|
486
|
+
// * The filter bar was INSIDE #claim-scroll, which makeTabs hides. So on the
|
|
487
|
+
// Backlog tab those controls were not merely inert — they were off-screen.
|
|
488
|
+
// * A backlog row rendered id + title + version + rank + kind. No goal, no
|
|
489
|
+
// discipline. A correctly-goaled task genuinely read as uncategorised.
|
|
490
|
+
//
|
|
491
|
+
// Each is invisible in a browser the way this file's other entries are: the page
|
|
492
|
+
// renders, it just renders a control that does nothing.
|
|
493
|
+
|
|
494
|
+
const workJs = read('work.js');
|
|
495
|
+
|
|
496
|
+
test('the filter bar is above the panels, not inside the Ready one that gets hidden', () => {
|
|
497
|
+
const filters = workHtml.indexOf('id="work-filters"');
|
|
498
|
+
const tabs = workHtml.indexOf('id="work-tabs"');
|
|
499
|
+
const claim = workHtml.indexOf('id="claim-scroll"');
|
|
500
|
+
assert.ok(filters > 0 && tabs > 0 && claim > 0, 'all three mounts present');
|
|
501
|
+
assert.ok(filters > tabs, 'the bar follows the tab strip it belongs to');
|
|
502
|
+
assert.ok(filters < claim,
|
|
503
|
+
'and precedes the Ready panel — inside it, makeTabs hides the filters with the tab');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('the bar shows on the two QUEUE tabs and hides on the two that it cannot narrow', () => {
|
|
507
|
+
assert.match(workJs, /const FILTERED_TABS = new Set\(\['ready', 'backlog'\]\)/,
|
|
508
|
+
'Ready and Backlog are task feeds; In Progress and Completed render other objects');
|
|
509
|
+
assert.match(workJs, /function syncFilterBarVisibility/);
|
|
510
|
+
// Wired BOTH ways: on a tab change, and once at boot — makeTabs restores a
|
|
511
|
+
// deep-linked tab (/work#completed) before the bar is built, and fires
|
|
512
|
+
// onChange only on a CHANGE, so opening state has to be set explicitly.
|
|
513
|
+
assert.match(workJs, /onChange: \(id\) => \{[\s\S]{0,160}syncFilterBarVisibility\(id\)/,
|
|
514
|
+
'a tab change re-syncs the bar');
|
|
515
|
+
assert.match(workJs, /syncFilterBarVisibility\(workTabs && workTabs\.active \? workTabs\.active\(\) : 'ready'\)/,
|
|
516
|
+
'and the opening tab decides the opening state');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('both queue tabs narrow through ONE predicate, so a filter cannot reach only one', () => {
|
|
520
|
+
assert.match(workJs, /function matchesBoardFilters\(t, f\)/,
|
|
521
|
+
'the predicate is shared, not copied per tab');
|
|
522
|
+
// Both renderers must consult it. This is the whole defect: before, only the
|
|
523
|
+
// claimable one did.
|
|
524
|
+
const claimable = workJs.slice(workJs.indexOf('function renderClaimable'), workJs.indexOf('async function populateCreatorFilter'));
|
|
525
|
+
const backlog = workJs.slice(workJs.indexOf('function renderBacklog'), workJs.indexOf('async function refreshLive'));
|
|
526
|
+
for (const [name, body] of [['renderClaimable', claimable], ['renderBacklog', backlog]]) {
|
|
527
|
+
assert.ok(body.length, `${name} body located`);
|
|
528
|
+
assert.match(body, /matchesBoardFilters\(t, filters\)/, `${name} narrows through the shared predicate`);
|
|
529
|
+
assert.match(body, /applyRunMode\(list, \$\('#f-fit'\)/, `${name} applies the set-relative run-mode lens too`);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('a backlog row names its craft and its goal', () => {
|
|
534
|
+
const backlog = workJs.slice(workJs.indexOf('function renderBacklog'));
|
|
535
|
+
// The same vocabulary the Ready card has carried since 1755 — a second spelling
|
|
536
|
+
// would let the two tabs describe one task differently.
|
|
537
|
+
assert.match(backlog, /const discipline = t\.discipline \|\| 'unclassified'/);
|
|
538
|
+
assert.match(backlog, /disciplineTag/, 'the row renders the discipline tag');
|
|
539
|
+
assert.match(backlog, /taskState\.goalTitles \|\| \{\}/, 'the goal is named by TITLE, not by bare id');
|
|
540
|
+
assert.match(backlog, /'No goal'/, 'and a goal-less row says so rather than rendering a gap');
|
|
541
|
+
assert.ok(rule(workCss, '.history__goal'), '.history__goal has a rule — an unstyled sub-line inherits the title size');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the backlog asks the SERVER the narrow question, because its read is capped', () => {
|
|
545
|
+
// A client-side-only narrow of a capped read LIES: filtering the 500 fetched
|
|
546
|
+
// rows to the artist ones hides the artist tasks that sat at row 501.
|
|
547
|
+
assert.match(workJs, /function backlogFilterQuery/);
|
|
548
|
+
const q = workJs.slice(workJs.indexOf('function backlogFilterQuery'), workJs.indexOf('BACKLOG_LIMIT ='));
|
|
549
|
+
for (const param of ['version=', 'kind=', 'discipline=', 'goal_id=']) {
|
|
550
|
+
assert.ok(q.includes(param), `the backlog read sends ${param} — GET /tasks answers it`);
|
|
551
|
+
}
|
|
552
|
+
// '__none__' (ungoaled) has no server predicate, so it must not be sent.
|
|
553
|
+
assert.match(q, /f\.goal && f\.goal !== '__none__'/,
|
|
554
|
+
"the client-only 'No goal' lens is never sent to the route");
|
|
555
|
+
// It reads through boardFilterValues rather than re-querying the DOM, so the
|
|
556
|
+
// query and the client predicate can never disagree about a control's value.
|
|
557
|
+
assert.match(q, /const f = boardFilterValues\(\);/);
|
|
558
|
+
// And it stays SEPARATE from the claimable query: GET /tasks/claimable answers
|
|
559
|
+
// only version + discipline, so kind/goal_id there would be a silent no-op.
|
|
560
|
+
const claimQ = workJs.slice(workJs.indexOf('function serverFilterQuery'), workJs.indexOf('task 1003828 — the BACKLOG'));
|
|
561
|
+
assert.ok(!claimQ.includes('goal_id='), 'the claimable feed is not sent goal_id');
|
|
562
|
+
assert.ok(!claimQ.includes('kind='), 'nor kind');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('each feed decides its own re-fetch, so a server change cannot strand the other tab', () => {
|
|
566
|
+
// The old shape compared ONE query and returned early, which left the other
|
|
567
|
+
// tab's client-side lenses unapplied until the next poll happened to repaint.
|
|
568
|
+
assert.match(workJs, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
|
|
569
|
+
assert.match(workJs, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('the Goal filter offers goals that only have BACKLOG work', () => {
|
|
573
|
+
const populate = workJs.slice(workJs.indexOf('function populateGoalFilter'), workJs.indexOf('async function copyPromptForTask'));
|
|
574
|
+
assert.match(populate, /\[\.\.\.taskState\.all, \.\.\.\(taskState\.backlog \|\| \[\]\)\]/,
|
|
575
|
+
'built from the claimable feed alone, a backlog-only goal was unofferable');
|
|
576
|
+
});
|