@bongos/core 1.21.53 → 1.21.55
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 +33 -28
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +88 -79
- package/modules/government/docket.js +9 -4
- package/modules/hall-ui/public/street-card.css +18 -0
- package/modules/hall-ui/public/street-card.js +285 -5
- package/modules/hall-ui/public/street-desk.js +51 -10
- package/modules/hall-ui/public/street.css +2 -0
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +180 -18
- package/modules/hall-ui/public/street.states.json +41 -1
- package/modules/hall-ui/records/governor-street.md +28 -0
- package/modules/lifecycle/goal-category.js +15 -0
- package/modules/lifecycle/routes/project-settings.js +42 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/goal_category.mjs +16 -0
- package/tests/hall_street_desk.mjs +541 -9
- package/tests/privacy_matrix.mjs +689 -0
|
@@ -271,26 +271,33 @@ function fakeDom() {
|
|
|
271
271
|
}
|
|
272
272
|
return { document: { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_n, t) => mk(t) }, byId, focused };
|
|
273
273
|
}
|
|
274
|
-
|
|
274
|
+
// xhr: an XMLHttpRequest stand-in (the recorded sign-off's upload); answer(url,
|
|
275
|
+
// body): a response of its own for a write, else the default; extra: more feeds
|
|
276
|
+
async function bootPage(hash, ms, { refuse = false, over = {}, xhr = null, answer = null, extra = {} } = {}) {
|
|
275
277
|
const { document, byId, focused } = fakeDom();
|
|
276
278
|
const sent = [];
|
|
277
279
|
const store = new Map();
|
|
278
280
|
let matters = ms.slice();
|
|
279
|
-
const feeds = () => ({ '/api/bongos/government/docket': docket(matters, over), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
|
|
281
|
+
const feeds = () => ({ '/api/bongos/government/docket': docket(matters, over), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] }, ...extra });
|
|
282
|
+
const reads = [];
|
|
280
283
|
const location = { hash, href: '' };
|
|
281
284
|
const window = {
|
|
282
285
|
innerWidth: 1440, innerHeight: 900, location,
|
|
283
286
|
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
284
287
|
matchMedia: () => ({ matches: true, addEventListener() {} }),
|
|
285
288
|
addEventListener() {}, history: { pushState() {} },
|
|
289
|
+
XMLHttpRequest: xhr || undefined,
|
|
286
290
|
};
|
|
287
291
|
const fetch = (url, init) => {
|
|
288
292
|
if (init && init.method && init.method !== 'GET') {
|
|
289
293
|
sent.push({ method: init.method, url, body: JSON.parse(init.body || '{}') });
|
|
294
|
+
const own = answer ? answer(url, JSON.parse(init.body || '{}')) : null;
|
|
295
|
+
if (own) return Promise.resolve({ ok: own.status < 300, status: own.status, json: async () => own.body || {} });
|
|
290
296
|
if (refuse) return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: { code: 'not_pending' } }) });
|
|
291
297
|
matters = matters.filter((m) => !url.includes('/' + String(m.id).split(':')[1]));
|
|
292
298
|
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true }) });
|
|
293
299
|
}
|
|
300
|
+
reads.push(url);
|
|
294
301
|
const body = feeds()[url];
|
|
295
302
|
return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
|
|
296
303
|
};
|
|
@@ -301,7 +308,7 @@ async function bootPage(hash, ms, { refuse = false, over = {} } = {}) {
|
|
|
301
308
|
await new Promise((r) => setTimeout(r, 0));
|
|
302
309
|
const deskEl = () => (byId.get('insideOpenA').hidden ? byId.get('insideOpenB') : byId.get('insideOpenA'));
|
|
303
310
|
const part = (cls) => deskEl().children.find((c) => c.className.split(' ').includes(cls));
|
|
304
|
-
return { window, byId, focused, sent, store, deskEl, part };
|
|
311
|
+
return { window, byId, focused, sent, store, deskEl, part, reads };
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
test('the page: the desk acts in place — the row clears, the floor lights with a tick, the meter takes a pip, the toast speaks', async () => {
|
|
@@ -490,13 +497,16 @@ test('Automation: switch on and off, allow a goal from the open goals, first in
|
|
|
490
497
|
assert.ok(!none.some((r) => r.actions.some((a) => a.key === 'allow')));
|
|
491
498
|
});
|
|
492
499
|
|
|
493
|
-
test('Review: a backend-only promise signs off in one tap; a recorded one
|
|
500
|
+
test('Review: a backend-only promise signs off in one tap; a recorded one is recorded in place; force sign-off only with its atom', () => {
|
|
494
501
|
const rows = actedRows(actedDocket(), 'review', 1);
|
|
495
502
|
const backend = rows.find((r) => r.actions[0].key === 'signoff');
|
|
496
503
|
assert.deepEqual(j(C.request(backend.card, 'signoff', backend.choices.signoff.options[0].input)), { method: 'POST', url: '/api/bongos/done-when/8/uat', body: { kind: 'backend_signoff', live_attested: true } });
|
|
497
504
|
assert.deepEqual(j(C.request(backend.card, 'force', backend.choices.force.options[0].input)), { method: 'POST', url: '/api/bongos/done-when/8/satisfy', body: { override_reason: 'Signed off on the floor desk: tested by hand.' } });
|
|
498
505
|
const ui = rows.find((r) => r.actions[0].key === 'record');
|
|
499
|
-
assert.equal(ui.actions[0].go, '
|
|
506
|
+
assert.equal(ui.actions[0].go, null, 'no link to the goal page (task 1004579)');
|
|
507
|
+
assert.equal(ui.actions[0].label, 'record it');
|
|
508
|
+
assert.deepEqual(j(ui.choices.record), { for: 'record', ask: 'the recording', file: true, options: [] }, 'it asks for the recording in place');
|
|
509
|
+
assert.ok(ui.actions.every((a) => !a.go), 'nothing on the row leaves the desk');
|
|
500
510
|
const noForce = actedRows(actedDocket({ force: false }), 'review', 1);
|
|
501
511
|
assert.ok(noForce.every((r) => !r.actions.some((a) => a.key === 'force')));
|
|
502
512
|
});
|
|
@@ -515,6 +525,14 @@ const settingsRoute = SRV('modules/lifecycle/routes/project-settings.js');
|
|
|
515
525
|
const modulesRoute = SRV('src/bongos/routes/modules.js');
|
|
516
526
|
const backupRoute = SRV('src/bongos/routes/backup.js');
|
|
517
527
|
const accessRoute = SRV('modules/onboarding/routes/access-requests.js');
|
|
528
|
+
// the work categories (task 1004580): three offered, in their order, and one retired
|
|
529
|
+
const CATS = [
|
|
530
|
+
{ id: 1, slug: 'money', label: 'Money', sort_order: 10, active: true },
|
|
531
|
+
{ id: 2, slug: 'walls', label: 'The walls', sort_order: 20, active: true },
|
|
532
|
+
{ id: 3, slug: 'hall', label: 'The hall', sort_order: 21, active: true },
|
|
533
|
+
{ id: 4, slug: 'legal', label: 'Legal', sort_order: 15, active: false },
|
|
534
|
+
];
|
|
535
|
+
const CAT_GOALS = new Map([['1', 4], ['2', 1]]);
|
|
518
536
|
const PREVIEW = { instance_id: 41, served_version: '1.21.19', recommended_version: '1.21.20', behind: true, observed_at: '2026-10-03T10:00:00.000Z' };
|
|
519
537
|
|
|
520
538
|
function moreDesks({ preflight = null, longWords = false } = {}) {
|
|
@@ -524,7 +542,7 @@ function moreDesks({ preflight = null, longWords = false } = {}) {
|
|
|
524
542
|
dk.matters.push(DK.normalizeMatter({ id: '1.21.20', rule: 'decide', state: 'needs', title: 'Take core 1.21.20 (running 1.21.19)', short: 'core 1.21.20', href: '/settings#software-update', ref: { type: 'core_version', id: '1.21.20' } }, { source: 'core_update', building: 'release' }));
|
|
525
543
|
dk.desks.push(
|
|
526
544
|
desk('release.core', 'release', coreRoute.coreDeskRows({ ...PREVIEW, preflight })),
|
|
527
|
-
desk('rules.settings', 'rules', settingsRoute.settingsDeskRows({ days: 14, categories:
|
|
545
|
+
desk('rules.settings', 'rules', settingsRoute.settingsDeskRows({ days: 14, categories: CATS.map((c) => ({ ...c, label: t(c.label) })), goals: CAT_GOALS, mayAdd: true })),
|
|
528
546
|
desk('rules.modules', 'rules', modulesRoute.moduleDeskRows([{ key: 'discord', title: t('Discord'), enabled: true }, { key: 'economy', title: 'Economy', enabled: false }])),
|
|
529
547
|
desk('treasury.backup', 'treasury', backupRoute.backupDeskRows({ age_hours: 30, stale: false }, false)),
|
|
530
548
|
desk('people.invite', 'people', [accessRoute.INVITE_DESK_ROW]),
|
|
@@ -633,13 +651,13 @@ test('Release: the core update is checked first; take is offered only after a pa
|
|
|
633
651
|
assert.deepEqual(D.breaks(desk), []);
|
|
634
652
|
});
|
|
635
653
|
|
|
636
|
-
test('Rules: the rot timer as day chips, work categories
|
|
654
|
+
test('Rules: the rot timer as day chips, work categories in place (never a link), modules on in one tap and off with a yes', () => {
|
|
637
655
|
const rows = actedRows(moreDesks(), 'rules', 0);
|
|
638
656
|
const rot = rows.find((r) => r.name === 'Rot timer');
|
|
639
657
|
assert.deepEqual(rot.choices.rot.options.map((o) => o.key), ['7', '30', '60', '90'], 'the current value is not offered');
|
|
640
658
|
assert.deepEqual(j(C.request(rot.card, 'rot', rot.choices.rot.options[1].input)), { method: 'PATCH', url: '/api/bongos/project-settings/rot-days', body: { days: 30 } });
|
|
641
|
-
|
|
642
|
-
assert.
|
|
659
|
+
assert.equal(rows.find((r) => r.name === 'Work categories'), undefined, 'no link-out row (task 1004580)');
|
|
660
|
+
assert.ok(rows.every((r) => r.actions.every((a) => !a.go)), 'nothing on the Rules floor leaves the building');
|
|
643
661
|
const on = rows.find((r) => r.name === 'Economy');
|
|
644
662
|
assert.deepEqual(j(C.request(on.card, 'module_on')), { method: 'POST', url: '/api/bongos/modules/economy/enable', body: { enabled: true } });
|
|
645
663
|
const off = rows.find((r) => r.name === 'Discord');
|
|
@@ -935,3 +953,517 @@ test('walkthrough 1004531: a card act that records nothing takes no pip', async
|
|
|
935
953
|
await settle();
|
|
936
954
|
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided', 'a refused act is not a decision');
|
|
937
955
|
});
|
|
956
|
+
|
|
957
|
+
// ── the recorded sign-off from the card (task 1004579) ───────────────────────
|
|
958
|
+
// A promise that needs a recording is recorded in place: the file is picked or
|
|
959
|
+
// dropped on its one line, sent with its progress on that line, then signed off
|
|
960
|
+
// through the goal page's own two routes. The row clears, the meter takes a
|
|
961
|
+
// pip, the toast speaks; a refused or failed upload says why on the line and
|
|
962
|
+
// offers to try again, and counts nothing.
|
|
963
|
+
const REC_NAME = 'uat-9-0123456789abcdef.mp4';
|
|
964
|
+
const VIDEO = { name: 'walkthrough of the city.mp4', type: 'video/mp4', size: 12 * 1024 * 1024 };
|
|
965
|
+
const uatMatters = () => [DK.normalizeMatter(lifeDocket.uatMatter({ id: 9, criterion_md: 'The city draws', goal_id: 4, shipped_total: 2, backend_only: false }, { force: false }), { source: 'criteria_uat', building: 'review' })];
|
|
966
|
+
// an XMLHttpRequest stand-in: each send takes the next planned answer; `hold`
|
|
967
|
+
// keeps it open until the test lets it go, so progress can be seen mid-flight
|
|
968
|
+
function fakeXhr(plan = []) {
|
|
969
|
+
const calls = [];
|
|
970
|
+
const held = [];
|
|
971
|
+
class X {
|
|
972
|
+
constructor() { this.upload = {}; this.headers = {}; }
|
|
973
|
+
open(method, url) { this.method = method; this.url = url; }
|
|
974
|
+
setRequestHeader(k, v) { this.headers[k] = v; }
|
|
975
|
+
send(file) {
|
|
976
|
+
calls.push({ method: this.method, url: this.url, type: this.headers['Content-Type'], file, withCredentials: this.withCredentials, xhr: this });
|
|
977
|
+
const p = plan.shift() || { status: 201, body: { ok: true, recording: REC_NAME } };
|
|
978
|
+
const go = () => {
|
|
979
|
+
(p.progress || []).forEach((n) => this.upload.onprogress && this.upload.onprogress({ lengthComputable: true, loaded: n, total: 100 }));
|
|
980
|
+
if (p.status === 0) { this.onerror(); return; }
|
|
981
|
+
this.status = p.status;
|
|
982
|
+
this.responseText = JSON.stringify(p.body || {});
|
|
983
|
+
this.onload();
|
|
984
|
+
};
|
|
985
|
+
if (p.hold) held.push(go); else setTimeout(go, 0);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return { X, calls, held };
|
|
989
|
+
}
|
|
990
|
+
function walk(n, pred, out = []) {
|
|
991
|
+
if (!n) return out;
|
|
992
|
+
if (pred(n)) out.push(n);
|
|
993
|
+
(n.kids || []).forEach((k) => walk(k, pred, out));
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
const byAct = (root, act) => walk(root, (n) => n.attrs && n.attrs['data-act'] === act)[0];
|
|
997
|
+
const recLine = (root) => walk(root, (n) => /\brec__line\b/.test(n.className))[0];
|
|
998
|
+
const recBox = (root) => walk(root, (n) => /(^| )rec( |$)/.test(n.className))[0];
|
|
999
|
+
const fileInput = (root) => walk(root, (n) => n.attrs && n.attrs.type === 'file')[0];
|
|
1000
|
+
function pickFile(root, file) {
|
|
1001
|
+
const input = fileInput(root);
|
|
1002
|
+
input.files = [file];
|
|
1003
|
+
input.fire('change');
|
|
1004
|
+
}
|
|
1005
|
+
async function reviewDesk(opts = {}) {
|
|
1006
|
+
const p = await bootPage('#building=review', uatMatters(), opts);
|
|
1007
|
+
const row = () => p.part('desk-rows') && p.part('desk-rows').children[0];
|
|
1008
|
+
return { ...p, row };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
test('1004579 the line: what it says in each phase, in one line, with at most two chips', () => {
|
|
1012
|
+
assert.deepEqual(j(C.recordLine({ phase: 'empty' })), { text: 'mp4 or webm, up to 100 MB', pct: null, busy: false, error: false, chips: [{ key: 'pick', label: 'pick or drop it' }] });
|
|
1013
|
+
const picked = C.recordPick({}, VIDEO);
|
|
1014
|
+
assert.equal(picked.phase, 'picked');
|
|
1015
|
+
assert.deepEqual(j(C.recordLine(picked)).chips, [{ key: 'pick', label: 'pick another' }, { key: 'send', label: 'tested it live' }], 'the live attestation is the one-tap chip that sends it');
|
|
1016
|
+
assert.equal(C.recordLine(picked).text, 'walkthrough of the city.mp4 · 12 MB');
|
|
1017
|
+
assert.equal(C.recordLine({ file: VIDEO, phase: 'uploading', pct: 42 }).text, 'walkthrough of the city.mp4 · 42% sent');
|
|
1018
|
+
assert.equal(C.recordLine({ file: VIDEO, phase: 'uploading', pct: 42 }).pct, 42);
|
|
1019
|
+
assert.deepEqual(C.recordLine({ file: VIDEO, phase: 'uploading', pct: 42 }).chips, [], 'nothing to tap while it is sent');
|
|
1020
|
+
assert.equal(C.recordLine({ file: VIDEO, phase: 'signing' }).text, 'walkthrough of the city.mp4 · sent, signing off…');
|
|
1021
|
+
for (const st of [{ phase: 'empty' }, picked, { file: VIDEO, phase: 'uploading', pct: 99 }, { file: VIDEO, phase: 'failed', why: C.recordWhy(0, {}), retry: true }]) {
|
|
1022
|
+
const ln = C.recordLine(st);
|
|
1023
|
+
assert.ok(!/[\r\n]/.test(ln.text), 'one line');
|
|
1024
|
+
assert.ok(ln.chips.length <= 2, 'at most two chips');
|
|
1025
|
+
assert.ok(ln.chips.every((c) => c.label.length <= D.LIMITS.chip), 'each chip within the desk chip cap');
|
|
1026
|
+
}
|
|
1027
|
+
const long = C.recordPick({}, { name: 'a very long name for a screen recording of the whole walkthrough.webm', type: 'video/webm', size: 2048 });
|
|
1028
|
+
assert.ok(C.recordLine(long).text.length <= 40, 'a long file name is cut, never wrapped');
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test('1004579 a wrong file says why before anything is sent, and asks for another', () => {
|
|
1032
|
+
const st = C.recordPick({}, { name: 'notes.pdf', type: 'application/pdf', size: 100 });
|
|
1033
|
+
assert.equal(st.phase, 'failed');
|
|
1034
|
+
assert.equal(st.why, 'Only an mp4 or webm video can go.');
|
|
1035
|
+
assert.deepEqual(j(C.recordLine(st).chips), [{ key: 'pick', label: 'pick another' }], 'no "try again" for a file that cannot go');
|
|
1036
|
+
assert.equal(C.recordPick({}, { name: 'big.mp4', type: 'video/mp4', size: C.RECORD_MAX + 1 }).why, 'Over the 100 MB cap. Record a shorter pass.');
|
|
1037
|
+
assert.equal(C.recordPick({}, { name: 'empty.webm', type: 'video/webm', size: 0 }).why, 'That file is empty.');
|
|
1038
|
+
assert.deepEqual(C.RECORD_TYPES, ['video/mp4', 'video/webm']);
|
|
1039
|
+
const lifecycle = SRV('modules/lifecycle/criterion-uat.js');
|
|
1040
|
+
assert.deepEqual(C.RECORD_TYPES.slice().sort(), Object.keys(lifecycle.EXT_BY_TYPE).sort(), 'the same types the server stores');
|
|
1041
|
+
assert.equal(C.RECORD_MAX, lifecycle.MAX_RECORDING_BYTES, 'the same cap the server screens');
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test('1004579 the routes are the goal page\'s own two: the raw video, then the sign-off with its stored name', () => {
|
|
1045
|
+
const r = C.recordRoutes(9);
|
|
1046
|
+
assert.equal(r.upload, '/api/bongos/done-when/9/uat/recording');
|
|
1047
|
+
assert.deepEqual(j(r.signoff(REC_NAME)), { method: 'POST', url: '/api/bongos/done-when/9/uat', body: { kind: 'uat', recording: REC_NAME, live_attested: true } });
|
|
1048
|
+
const old = read('criterion-uat-ui.js');
|
|
1049
|
+
assert.match(old, /\/done-when\/\$\{encodeURIComponent\(id\)\}\/uat\/recording/, 'the goal page uploads to the same route');
|
|
1050
|
+
assert.match(old, /kind: backend \? 'backend_signoff' : 'uat'/, 'and signs off with the same kind');
|
|
1051
|
+
const route = read('../../lifecycle/routes/criterion-uat.js');
|
|
1052
|
+
assert.match(route, /router\.post\('\/done-when\/:criterionId\/uat\/recording'/);
|
|
1053
|
+
assert.match(route, /router\.post\('\/done-when\/:criterionId\/uat',/);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test('1004579 a refusal is one short sentence a person can act on', () => {
|
|
1057
|
+
const said = [
|
|
1058
|
+
[413, {}],
|
|
1059
|
+
[400, { error: { code: 'bad_type', message: 'A UAT recording must be an mp4 or webm video.' } }],
|
|
1060
|
+
[403, { error: { code: 'signer_shipped_this_work', message: 'You shipped work linked to this criterion, so someone else has to sign it off. Any other reviewer, or the project owner, can.' } }],
|
|
1061
|
+
[409, { error: { code: 'work_not_live', message: 'Some of this work is shipped but not running on the site yet.' } }],
|
|
1062
|
+
[0, {}],
|
|
1063
|
+
[500, { error: { code: 'uat_signoff_failed', message: 'uat_signoff_failed' } }],
|
|
1064
|
+
[409, { error: { code: 'something_new', message: 'A brand new refusal from the server. With a second sentence that goes on and on.' } }],
|
|
1065
|
+
].map(([s, b]) => C.recordWhy(s, b));
|
|
1066
|
+
assert.deepEqual(said, [
|
|
1067
|
+
'Over the 100 MB cap. Record a shorter pass.',
|
|
1068
|
+
'Only an mp4 or webm video can go.',
|
|
1069
|
+
'You built this, so someone else signs it off.',
|
|
1070
|
+
'It is not on the live site yet. Sign off once it is.',
|
|
1071
|
+
'The upload broke off. Try again.',
|
|
1072
|
+
'It did not go through. Try again.',
|
|
1073
|
+
'A brand new refusal from the server.',
|
|
1074
|
+
]);
|
|
1075
|
+
for (const w of said) { assert.ok(w.length <= 72, w); assert.ok(!/[\r\n]/.test(w)); }
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test('1004579 recordRun: sends the video with its progress, then signs off with the stored name', async () => {
|
|
1079
|
+
const st = C.recordPick({}, VIDEO);
|
|
1080
|
+
const phases = [];
|
|
1081
|
+
const pcts = [];
|
|
1082
|
+
const sent = [];
|
|
1083
|
+
const out = await C.recordRun('9', st, {
|
|
1084
|
+
upload: async (url, file, onPct) => { assert.equal(url, '/api/bongos/done-when/9/uat/recording'); assert.equal(file, VIDEO); onPct(30); onPct(80); return { ok: true, status: 201, body: { recording: REC_NAME } }; },
|
|
1085
|
+
send: async (req) => { sent.push(j(req)); return { ok: true, status: 201, body: { ok: true, criterion_closed: true } }; },
|
|
1086
|
+
change: (s) => phases.push(s.phase),
|
|
1087
|
+
progress: (s) => pcts.push(s.pct),
|
|
1088
|
+
});
|
|
1089
|
+
assert.equal(out.ok, true);
|
|
1090
|
+
assert.deepEqual(phases, ['uploading', 'signing']);
|
|
1091
|
+
assert.deepEqual(pcts, [30, 80]);
|
|
1092
|
+
assert.deepEqual(sent, [{ method: 'POST', url: '/api/bongos/done-when/9/uat', body: { kind: 'uat', recording: REC_NAME, live_attested: true } }]);
|
|
1093
|
+
assert.equal(st.phase, 'done');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test('1004579 recordRun: a failed upload says why and offers to try again; a refused sign-off keeps the upload, so trying again does not send the video twice', async () => {
|
|
1097
|
+
const st = C.recordPick({}, VIDEO);
|
|
1098
|
+
let uploads = 0;
|
|
1099
|
+
const broke = await C.recordRun('9', st, { upload: async () => { uploads += 1; return { ok: false, status: 0, body: {} }; }, send: async () => assert.fail('no sign-off after a failed upload') });
|
|
1100
|
+
assert.equal(broke.ok, false);
|
|
1101
|
+
assert.equal(st.phase, 'failed');
|
|
1102
|
+
assert.equal(C.recordLine(st).text, 'The upload broke off. Try again.');
|
|
1103
|
+
assert.deepEqual(C.recordLine(st).chips.map((c) => c.label), ['try again', 'pick another']);
|
|
1104
|
+
let signs = 0;
|
|
1105
|
+
const refused = await C.recordRun('9', st, {
|
|
1106
|
+
upload: async () => { uploads += 1; return { ok: true, status: 201, body: { recording: REC_NAME } }; },
|
|
1107
|
+
send: async () => { signs += 1; return signs === 1 ? { ok: false, status: 409, body: { error: { code: 'work_not_live', message: 'x' } } } : { ok: true, status: 201, body: {} }; },
|
|
1108
|
+
});
|
|
1109
|
+
assert.equal(refused.ok, false);
|
|
1110
|
+
assert.equal(C.recordLine(st).text, 'It is not on the live site yet. Sign off once it is.');
|
|
1111
|
+
assert.equal(st.recording, REC_NAME, 'the stored recording is kept');
|
|
1112
|
+
const again = await C.recordRun('9', st, { upload: async () => assert.fail('the video is not sent twice'), send: async () => { signs += 1; return { ok: true, status: 201, body: {} }; } });
|
|
1113
|
+
assert.equal(again.ok, true);
|
|
1114
|
+
assert.equal(uploads, 2);
|
|
1115
|
+
// a refusal no new try can fix offers neither a retry nor (for a sign-off that is not yours) another file
|
|
1116
|
+
const mine = C.recordPick({}, VIDEO);
|
|
1117
|
+
await C.recordRun('9', mine, { upload: async () => ({ ok: true, status: 201, body: { recording: REC_NAME } }), send: async () => ({ ok: false, status: 403, body: { error: { code: 'signer_shipped_this_work' } } }) });
|
|
1118
|
+
assert.deepEqual(C.recordLine(mine).chips, [], 'you built it: nothing to tap but close');
|
|
1119
|
+
assert.equal(C.recordLine(mine).text, 'You built this, so someone else signs it off.');
|
|
1120
|
+
// a file the server refused asks for another file, not the same one again
|
|
1121
|
+
const odd = C.recordPick({}, VIDEO);
|
|
1122
|
+
await C.recordRun('9', odd, { upload: async () => ({ ok: false, status: 400, body: { error: { code: 'sniff_mismatch' } } }), send: async () => assert.fail() });
|
|
1123
|
+
assert.deepEqual(C.recordLine(odd).chips.map((c) => c.key), ['pick']);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test('1004579 uploadXhr: the raw file with its type, its progress, and the answer', async () => {
|
|
1127
|
+
const x = fakeXhr([{ status: 201, body: { ok: true, recording: REC_NAME }, progress: [25, 50, 100] }]);
|
|
1128
|
+
const pcts = [];
|
|
1129
|
+
const out = await C.uploadXhr(x.X, '/api/bongos/done-when/9/uat/recording', VIDEO, (p) => pcts.push(p));
|
|
1130
|
+
assert.deepEqual(out, { ok: true, status: 201, body: { ok: true, recording: REC_NAME } });
|
|
1131
|
+
assert.deepEqual(x.calls.map((c) => [c.method, c.url, c.type, c.file === VIDEO, c.withCredentials]), [['POST', '/api/bongos/done-when/9/uat/recording', 'video/mp4', true, true]]);
|
|
1132
|
+
assert.deepEqual(pcts, [25, 50, 99], 'the bar stops short of full until the server has answered');
|
|
1133
|
+
const broke = await C.uploadXhr(fakeXhr([{ status: 0 }]).X, '/u', VIDEO, () => {});
|
|
1134
|
+
assert.deepEqual(broke, { ok: false, status: 0, body: {} });
|
|
1135
|
+
const none = await C.uploadXhr(undefined, '/u', VIDEO, () => {});
|
|
1136
|
+
assert.equal(none.ok, false, 'no XMLHttpRequest: a failure, never a throw');
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test('1004579 the page: record it opens its line in place, the upload shows its progress, and the sign-off clears the row', async () => {
|
|
1140
|
+
const x = fakeXhr([{ status: 201, body: { ok: true, recording: REC_NAME }, hold: true }]);
|
|
1141
|
+
const p = await reviewDesk({ xhr: x.X });
|
|
1142
|
+
assert.equal(p.byId.get('insideFloors').children[1].attrs['aria-pressed'], 'true', 'Review opens on the promises waiting for a tester');
|
|
1143
|
+
const acts = p.row().children[0].children[2].children;
|
|
1144
|
+
assert.deepEqual(acts.map((b) => b.textContent), ['record it']);
|
|
1145
|
+
assert.ok(walk(p.deskEl(), (n) => n.tagName === 'a').length === 0, 'no link on the desk');
|
|
1146
|
+
acts[0].fire('click');
|
|
1147
|
+
const strip = p.row().children[1];
|
|
1148
|
+
assert.equal(strip.className, 'desk-choices');
|
|
1149
|
+
assert.equal(strip.children[0].textContent, 'the recording');
|
|
1150
|
+
assert.equal(recLine(strip).textContent, 'mp4 or webm, up to 100 MB');
|
|
1151
|
+
assert.equal(fileInput(strip).attrs.accept, 'video/mp4,video/webm');
|
|
1152
|
+
pickFile(strip, VIDEO);
|
|
1153
|
+
assert.equal(recLine(p.row()).textContent, 'walkthrough of the city.mp4 · 12 MB');
|
|
1154
|
+
const go = byAct(p.row(), 'record-send');
|
|
1155
|
+
assert.equal(go.textContent, 'tested it live');
|
|
1156
|
+
assert.equal(p.focused.el, go, 'focus moves to the chip that sends it');
|
|
1157
|
+
go.fire('click');
|
|
1158
|
+
await settle();
|
|
1159
|
+
assert.equal(x.calls.length, 1, 'the video went up');
|
|
1160
|
+
assert.match(recLine(p.row()).textContent, /0% sent$/);
|
|
1161
|
+
const line = recLine(p.row());
|
|
1162
|
+
x.held.shift()();
|
|
1163
|
+
await settle();
|
|
1164
|
+
assert.equal(p.sent.length, 1, 'then the sign-off');
|
|
1165
|
+
assert.deepEqual(p.sent[0], { method: 'POST', url: '/api/bongos/done-when/9/uat', body: { kind: 'uat', recording: REC_NAME, live_attested: true } });
|
|
1166
|
+
await new Promise((r) => setTimeout(r, 360));
|
|
1167
|
+
await settle();
|
|
1168
|
+
assert.equal(p.part('desk-rows'), undefined, 'the row cleared away');
|
|
1169
|
+
assert.ok(p.part('desk-quiet').className.includes('desk-quiet--cleared'), 'the floor lights');
|
|
1170
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided', 'the meter takes a pip');
|
|
1171
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Signed off with the recording. The promise counts as kept.');
|
|
1172
|
+
assert.match(p.byId.get('streetToast').className, /street-toast--desk/);
|
|
1173
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'signed off The city dra… · now', 'the record gains the line (cut at the record cap)');
|
|
1174
|
+
assert.ok(line, 'the line was drawn while it was sent');
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test('1004579 the page: progress is painted on the line in place while the video is sent', async () => {
|
|
1178
|
+
const x = fakeXhr([{ status: 201, body: { ok: true, recording: REC_NAME }, hold: true }]);
|
|
1179
|
+
const p = await reviewDesk({ xhr: x.X });
|
|
1180
|
+
p.row().children[0].children[2].children[0].fire('click');
|
|
1181
|
+
pickFile(p.row(), VIDEO);
|
|
1182
|
+
byAct(p.row(), 'record-send').fire('click');
|
|
1183
|
+
await settle();
|
|
1184
|
+
const line = recLine(p.row());
|
|
1185
|
+
const fill = walk(p.row(), (n) => n.className === 'rec__fill')[0];
|
|
1186
|
+
assert.ok(walk(p.row(), (n) => /\brec--busy\b/.test(n.className))[0], 'the line reads as busy while it is sent');
|
|
1187
|
+
assert.equal(fileInput(p.row()), undefined, 'no file chip while it is sent');
|
|
1188
|
+
assert.equal(line.textContent, 'walkthrough of the city.mp4 · 0% sent');
|
|
1189
|
+
assert.equal(fill.style.width, '0%');
|
|
1190
|
+
// the open request reports progress through its own upload hook
|
|
1191
|
+
x.calls[0].xhr.upload.onprogress({ lengthComputable: true, loaded: 42, total: 100 });
|
|
1192
|
+
assert.equal(recLine(p.row()), line, 'the same line node: progress never redraws the desk');
|
|
1193
|
+
assert.equal(line.textContent, 'walkthrough of the city.mp4 · 42% sent');
|
|
1194
|
+
assert.equal(fill.style.width, '42%', 'the bar fills');
|
|
1195
|
+
x.calls[0].xhr.upload.onprogress({ lengthComputable: true, loaded: 100, total: 100 });
|
|
1196
|
+
assert.equal(line.textContent, 'walkthrough of the city.mp4 · 99% sent', 'never full before the server answers');
|
|
1197
|
+
x.held.shift()();
|
|
1198
|
+
await settle();
|
|
1199
|
+
assert.equal(p.sent.length, 1, 'the sign-off follows the upload');
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test('1004579 the page: a refused upload says why on the line, keeps the row, counts nothing, and lets them try again', async () => {
|
|
1203
|
+
const x = fakeXhr([{ status: 413, body: {} }, { status: 0 }, { status: 201, body: { ok: true, recording: REC_NAME } }]);
|
|
1204
|
+
const p = await reviewDesk({ xhr: x.X });
|
|
1205
|
+
p.row().children[0].children[2].children[0].fire('click');
|
|
1206
|
+
pickFile(p.row(), VIDEO);
|
|
1207
|
+
byAct(p.row(), 'record-send').fire('click');
|
|
1208
|
+
await settle();
|
|
1209
|
+
assert.equal(recLine(p.row()).textContent, 'Over the 100 MB cap. Record a shorter pass.');
|
|
1210
|
+
assert.equal(recLine(p.row()).attrs.role, 'alert', 'the why is said, not just shown');
|
|
1211
|
+
assert.equal(recBox(p.row()).attrs['data-record'], 'failed');
|
|
1212
|
+
assert.equal(byAct(p.row(), 'record-send'), undefined, 'a file over the cap is not offered again');
|
|
1213
|
+
assert.ok(fileInput(p.row()), 'pick another is offered');
|
|
1214
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
1215
|
+
assert.equal(p.sent.length, 0, 'no sign-off after a refused upload');
|
|
1216
|
+
assert.equal(p.part('desk-rows').children.length, 1, 'the row stays');
|
|
1217
|
+
// another file; the connection breaks; try again goes through
|
|
1218
|
+
pickFile(p.row(), VIDEO);
|
|
1219
|
+
byAct(p.row(), 'record-send').fire('click');
|
|
1220
|
+
await settle();
|
|
1221
|
+
assert.equal(recLine(p.row()).textContent, 'The upload broke off. Try again.');
|
|
1222
|
+
const retry = byAct(p.row(), 'record-send');
|
|
1223
|
+
assert.equal(retry.textContent, 'try again');
|
|
1224
|
+
retry.fire('click');
|
|
1225
|
+
await settle();
|
|
1226
|
+
await new Promise((r) => setTimeout(r, 360));
|
|
1227
|
+
await settle();
|
|
1228
|
+
assert.equal(x.calls.length, 3);
|
|
1229
|
+
assert.equal(p.sent.length, 1);
|
|
1230
|
+
assert.equal(p.part('desk-rows'), undefined, 'signed off on the retry: the row cleared');
|
|
1231
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
test('1004579 the page: a refused sign-off says why on the line; nothing is counted', async () => {
|
|
1235
|
+
const x = fakeXhr();
|
|
1236
|
+
const p = await reviewDesk({ xhr: x.X, answer: (url) => (/\/uat$/.test(url) ? { status: 403, body: { error: { code: 'signer_shipped_this_work', message: 'You shipped work linked to this criterion.' } } } : null) });
|
|
1237
|
+
p.row().children[0].children[2].children[0].fire('click');
|
|
1238
|
+
pickFile(p.row(), VIDEO);
|
|
1239
|
+
byAct(p.row(), 'record-send').fire('click');
|
|
1240
|
+
await settle();
|
|
1241
|
+
assert.equal(recLine(p.row()).textContent, 'You built this, so someone else signs it off.');
|
|
1242
|
+
assert.equal(p.byId.get('streetToast').hidden, true, 'the line says it; no toast claims a decision');
|
|
1243
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
1244
|
+
assert.equal(p.part('desk-rows').children.length, 1);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test('1004579 the page: a recording dropped on its row is taken and its line opens', async () => {
|
|
1248
|
+
const p = await reviewDesk({ xhr: fakeXhr().X });
|
|
1249
|
+
const row = p.row();
|
|
1250
|
+
row.listeners.drop[0]({ preventDefault() {}, dataTransfer: { files: [VIDEO] } });
|
|
1251
|
+
assert.equal(recLine(p.row()).textContent, 'walkthrough of the city.mp4 · 12 MB');
|
|
1252
|
+
assert.equal(byAct(p.row(), 'record-send').textContent, 'tested it live');
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test('1004579 the page: tapping its name opens the goal page\'s three checks in place', async () => {
|
|
1256
|
+
const view = { uat_state: 'awaiting_uat', checks: { code: true, live: { available: true, live: true }, uat: null, stale_uat: null } };
|
|
1257
|
+
const p = await reviewDesk({ xhr: fakeXhr().X, extra: { '/api/bongos/done-when/9/uat': view } });
|
|
1258
|
+
p.row().children[0].children[1].fire('click');
|
|
1259
|
+
await settle();
|
|
1260
|
+
assert.ok(p.reads.includes('/api/bongos/done-when/9/uat'), 'the same read the goal page opens');
|
|
1261
|
+
assert.deepEqual(p.row().children[1].children.map((x) => x.textContent), ['code done', 'running live', 'not signed off yet']);
|
|
1262
|
+
assert.deepEqual(D.breaks({ ...D.deskModel({ key: 'review', floor: 1, docket: docket(uatMatters()), now: NOW }) }), []);
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('1004579 the card: record it on the street card records in place too, and the card closes on the sign-off', async () => {
|
|
1266
|
+
const x = fakeXhr();
|
|
1267
|
+
const view = { uat_state: 'awaiting_uat', checks: { code: true, live: { available: false }, uat: null, stale_uat: { signed_off_by_login: 'mira', created_at: '2026-09-30T10:00:00.000Z' } } };
|
|
1268
|
+
const p = await bootPage('', uatMatters(), { xhr: x.X, extra: { '/api/bongos/done-when/9/uat': view } });
|
|
1269
|
+
p.window.GovStreetPage.openCard('criteria_uat:9');
|
|
1270
|
+
const body = p.byId.get('streetCardBody');
|
|
1271
|
+
const rec = byAct(body, 'record');
|
|
1272
|
+
assert.equal(rec.tagName, 'button', 'a button, not a link');
|
|
1273
|
+
assert.equal(walk(body, (n) => n.tagName === 'a').length, 0, 'no link on the card');
|
|
1274
|
+
// more: the checks the goal page showed
|
|
1275
|
+
byAct(body, 'more').fire('click');
|
|
1276
|
+
await settle();
|
|
1277
|
+
const more = walk(body, (n) => n.className === 'mcard-more')[0];
|
|
1278
|
+
const words = walk(more, (n) => n.className === 'mcard-more__text').map((n) => n.textContent);
|
|
1279
|
+
assert.ok(words.includes('code · every linked task is done'));
|
|
1280
|
+
assert.ok(words.includes('live · this site cannot tell, so the sign-off says you tried it live'));
|
|
1281
|
+
assert.ok(words.some((w) => /^sign-off · an earlier one by mira on .* no longer counts/.test(w)));
|
|
1282
|
+
byAct(body, 'more').fire('click');
|
|
1283
|
+
byAct(body, 'record').fire('click');
|
|
1284
|
+
assert.equal(walk(body, (n) => n.className === 'mcard-chips__ask')[0].textContent, 'the recording');
|
|
1285
|
+
pickFile(body, VIDEO);
|
|
1286
|
+
assert.equal(recLine(body).textContent, 'walkthrough of the city.mp4 · 12 MB');
|
|
1287
|
+
byAct(body, 'record-send').fire('click');
|
|
1288
|
+
await settle();
|
|
1289
|
+
await settle();
|
|
1290
|
+
assert.equal(x.calls.length, 1);
|
|
1291
|
+
assert.deepEqual(p.sent, [{ method: 'POST', url: '/api/bongos/done-when/9/uat', body: { kind: 'uat', recording: REC_NAME, live_attested: true } }]);
|
|
1292
|
+
assert.equal(p.byId.get('streetCard').hidden, true, 'the card closes: the matter is done');
|
|
1293
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
1294
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Signed off with the recording. The promise counts as kept.');
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test('1004579 the card: a failed upload says why on the card\'s line and keeps the card open', async () => {
|
|
1298
|
+
const x = fakeXhr([{ status: 400, body: { error: { code: 'bad_type', message: 'A UAT recording must be an mp4 or webm video.' } } }]);
|
|
1299
|
+
const p = await bootPage('', uatMatters(), { xhr: x.X });
|
|
1300
|
+
p.window.GovStreetPage.openCard('criteria_uat:9');
|
|
1301
|
+
const body = p.byId.get('streetCardBody');
|
|
1302
|
+
byAct(body, 'record').fire('click');
|
|
1303
|
+
pickFile(body, VIDEO);
|
|
1304
|
+
byAct(body, 'record-send').fire('click');
|
|
1305
|
+
await settle();
|
|
1306
|
+
assert.equal(recLine(body).textContent, 'Only an mp4 or webm video can go.');
|
|
1307
|
+
assert.equal(p.byId.get('streetCard').hidden, false);
|
|
1308
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
1309
|
+
assert.equal(walk(body, (n) => n.className === 'mcard-error').length, 0, 'said once, on its own line');
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// ── task 1004580: the work categories, edited in place on the Rules desk ─────
|
|
1313
|
+
// The Project settings page's category list (project-settings.js catsBodyHtml)
|
|
1314
|
+
// renames, retires and brings back, reorders and adds. Each of those is now an
|
|
1315
|
+
// act on the floor desk, sent to the SAME two routes the page uses, under the
|
|
1316
|
+
// same atoms; the desk's old link-out row is gone.
|
|
1317
|
+
const catDesk = (rows) => ({ key: 'rules.settings', building: 'rules', status: 'live', rows: rows.map((r) => DK.normalizeDeskRow(r, { desk: 'rules.settings', building: 'rules' })).filter(Boolean) });
|
|
1318
|
+
const catRows = (opts = {}) => settingsRoute.settingsDeskRows({ days: 14, categories: CATS, goals: CAT_GOALS, mayAdd: true, ...opts });
|
|
1319
|
+
const catDocket = (opts) => docket([], { desks: [catDesk(catRows(opts))] });
|
|
1320
|
+
const rulesRows = (opts) => actedRows(catDocket(opts), 'rules', 0);
|
|
1321
|
+
const byName = (rows, n) => rows.find((r) => r.name === n);
|
|
1322
|
+
|
|
1323
|
+
test('1004580 the server: one row per category, offered ones in order then retired, its goals as the fact; add only with goal.create', () => {
|
|
1324
|
+
const rows = catRows();
|
|
1325
|
+
assert.deepEqual(rows.map((r) => r.id), ['rot', 'cat-1', 'cat-2', 'cat-3', 'cat-4', 'cat-new']);
|
|
1326
|
+
assert.deepEqual(rows.slice(1, 5).map((r) => r.fact), ['4 goals', '1 goal', '0 goals', 'retired · 0 goals']);
|
|
1327
|
+
assert.deepEqual(rows[1].acts, [{ key: 'rename', field: 'name' }, { key: 'retire' }]);
|
|
1328
|
+
assert.deepEqual(rows[4].acts, [{ key: 'restore' }, { key: 'rename', field: 'name' }], 'a retired one is brought back, never retired twice');
|
|
1329
|
+
assert.deepEqual(rows.slice(1, 5).map((r) => r.seq), [10, 20, 21, undefined], 'only an offered one has a place to move');
|
|
1330
|
+
assert.ok(rows.slice(1).every((r) => r.stay && r.tool && !r.href), 'every category row stays, counts as a tool and links nowhere');
|
|
1331
|
+
assert.deepEqual(rows[5].acts, [{ key: 'add', field: 'name' }]);
|
|
1332
|
+
assert.equal(rows[5].fact, '3 in use');
|
|
1333
|
+
assert.equal(catRows({ mayAdd: false }).find((r) => r.id === 'cat-new'), undefined, 'no add row for a viewer POST /work-categories refuses');
|
|
1334
|
+
// the docket keeps the typed name, the place and stay; a place that is no whole number is dropped
|
|
1335
|
+
const n = DK.normalizeDeskRow(rows[1], { desk: 'rules.settings', building: 'rules' });
|
|
1336
|
+
assert.deepEqual([n.acts[0].field, n.seq, n.stay], ['name', 10, true]);
|
|
1337
|
+
assert.equal(DK.normalizeDeskRow({ ...rows[1], seq: 1.5 }, { desk: 'rules.settings', building: 'rules' }).seq, undefined);
|
|
1338
|
+
assert.equal(DK.normalizeDeskRow({ ...rows[1], acts: [{ key: 'rename', field: 'password' }] }, { desk: 'rules.settings', building: 'rules' }).acts[0].field, undefined, 'only a name may be typed');
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test('1004580 the server: the desk is read under the settings page’s own atom, and the add row under POST’s', () => {
|
|
1342
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/project-settings.js'), 'utf8');
|
|
1343
|
+
const decl = src.split('function contributeSettingsDesk')[1].split('\n}')[0];
|
|
1344
|
+
assert.match(decl, /desk: 'rules\.settings',\s*gate: \{ permission: 'project\.settings\.manage' \}/, 'the same atom as GET /project-settings and PATCH /work-categories/:id');
|
|
1345
|
+
assert.match(decl, /mayAdd: [^\n]*ctx\.has\('goal\.create'\)/, 'the same atom as POST /work-categories');
|
|
1346
|
+
const goals = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
|
|
1347
|
+
assert.match(goals, /router\.patch\('\/work-categories\/:id', auth\.requireBuilder,\s*auth\.requirePermission\('project\.settings\.manage'\)/);
|
|
1348
|
+
assert.match(goals, /router\.post\('\/work-categories', auth\.requireBuilder, auth\.requirePermission\('goal\.create'\)/);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
test('1004580 the desk: each act is sent to the settings page’s own route, and nothing typed sends nothing', () => {
|
|
1352
|
+
const rows = rulesRows();
|
|
1353
|
+
const money = byName(rows, 'Money');
|
|
1354
|
+
assert.deepEqual(money.actions.map((a) => [a.key, a.label, a.tone]), [['rename', 'rename', 'primary'], ['retire', 'retire', 'quiet']]);
|
|
1355
|
+
assert.deepEqual([money.choices.rename.ask, money.choices.rename.field, money.choices.rename.value], ['new name', 'name', 'Money'], 'a rename starts from the name it has');
|
|
1356
|
+
assert.deepEqual(j(C.request(money.card, 'rename', { option: ' Revenue ' })), { method: 'PATCH', url: '/api/bongos/work-categories/1', body: { label: 'Revenue' } });
|
|
1357
|
+
assert.equal(C.request(money.card, 'rename', { option: ' ' }), null);
|
|
1358
|
+
assert.deepEqual(j(C.request(money.card, 'retire')), { method: 'PATCH', url: '/api/bongos/work-categories/1', body: { active: false } });
|
|
1359
|
+
const legal = byName(rows, 'Legal');
|
|
1360
|
+
assert.deepEqual(legal.actions.map((a) => a.label), ['bring back', 'rename']);
|
|
1361
|
+
assert.deepEqual(j(C.request(legal.card, 'restore')), { method: 'PATCH', url: '/api/bongos/work-categories/4', body: { active: true } });
|
|
1362
|
+
assert.deepEqual(j(C.request(money.card, 'move', { sort_order: 25 })), { method: 'PATCH', url: '/api/bongos/work-categories/1', body: { sort_order: 25 } });
|
|
1363
|
+
assert.equal(C.request(money.card, 'move', { sort_order: 2.5 }), null);
|
|
1364
|
+
const add = byName(rows, 'New category');
|
|
1365
|
+
assert.deepEqual([add.actions[0].label, add.choices.add.ask, add.choices.add.field], ['add', 'its name', 'name']);
|
|
1366
|
+
assert.deepEqual(j(C.request(add.card, 'add', { option: 'Developer Relations' })), { method: 'POST', url: '/api/bongos/work-categories', body: { label: 'Developer Relations' } });
|
|
1367
|
+
assert.equal(C.request(add.card, 'add', { option: '' }), null);
|
|
1368
|
+
assert.deepEqual(j([byName(rows, 'Money').moves, byName(rows, 'The hall').moves, byName(rows, 'Legal').moves || null]), [{ up: false, down: true }, { up: true, down: false }, null]);
|
|
1369
|
+
assert.deepEqual(D.breaks(D.deskModel({ key: 'rules', floor: 0, docket: catDocket(), goals: GOALS, now: NOW })), [], 'the line rule holds');
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
test('1004580 a move: one write when a whole number sits between its new neighbours, else the order numbered again', () => {
|
|
1373
|
+
const rows = rulesRows();
|
|
1374
|
+
// Money (10) down, between The walls (20) and The hall (21): no whole number fits, so all three again in tens
|
|
1375
|
+
assert.deepEqual(D.placeMoves(rows, byName(rows, 'Money').key, 1), [{ id: '2', sort_order: 10 }, { id: '1', sort_order: 20 }, { id: '3', sort_order: 30 }]);
|
|
1376
|
+
// The hall (21) up, to after Money (10) and before The walls (20): one write, 15
|
|
1377
|
+
assert.deepEqual(D.placeMoves(rows, byName(rows, 'The hall').key, -1), [{ id: '3', sort_order: 15 }]);
|
|
1378
|
+
// The walls (20) up to the top: ten before Money
|
|
1379
|
+
assert.deepEqual(D.placeMoves(rows, byName(rows, 'The walls').key, -1), [{ id: '2', sort_order: 0 }]);
|
|
1380
|
+
assert.deepEqual(D.placeMoves(rows, byName(rows, 'Money').key, -1), [], 'the top does not go up');
|
|
1381
|
+
assert.deepEqual(D.placeMoves(rows, byName(rows, 'Legal').key, -1), [], 'a retired one has no place');
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
// the page, on the Rules desk
|
|
1385
|
+
async function rulesPage(opts = {}) {
|
|
1386
|
+
const p = await bootPage('#building=rules', [], { over: { desks: [catDesk(catRows())] }, ...opts });
|
|
1387
|
+
const rowOf = (n) => p.part('desk-rows').children.find((r) => r.children[0].children[1].children[0].textContent === n);
|
|
1388
|
+
const button = (row, label) => row.children[0].children.find((c) => /desk-acts/.test(c.className)).children.find((b) => b.textContent === label);
|
|
1389
|
+
return { ...p, rowOf, button };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
test('1004580 the page: rename in place; the field starts from the name, the write is the page’s PATCH, the row stays, pip, toast, record', async () => {
|
|
1393
|
+
const p = await rulesPage({ answer: (url, body) => (body.label ? { status: 200, body: { ok: true, category: { id: 1, label: body.label } } } : null) });
|
|
1394
|
+
assert.ok(walk(p.deskEl(), (n) => n.tagName === 'a').length === 0, 'no link on the desk');
|
|
1395
|
+
assert.equal(walk(p.deskEl(), (n) => n.textContent === 'Work categories').length, 0, 'the link-out row is gone');
|
|
1396
|
+
p.button(p.rowOf('Money'), 'rename').fire('click');
|
|
1397
|
+
const ask = p.rowOf('Money').children[1];
|
|
1398
|
+
assert.equal(ask.className, 'desk-choices');
|
|
1399
|
+
assert.equal(ask.children[0].textContent, 'new name');
|
|
1400
|
+
const input = ask.children[1];
|
|
1401
|
+
assert.equal(input.value, 'Money');
|
|
1402
|
+
input.value = 'Revenue';
|
|
1403
|
+
ask.children[2].fire('click');
|
|
1404
|
+
await settle();
|
|
1405
|
+
assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/work-categories/1', body: { label: 'Revenue' } }]);
|
|
1406
|
+
assert.ok(p.rowOf('Money'), 'the row stays on the desk');
|
|
1407
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Renamed. Goals read Revenue now.');
|
|
1408
|
+
assert.match(p.byId.get('streetToast').className, /street-toast--desk/);
|
|
1409
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
1410
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'renamed Money · now');
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
test('1004580 the page: retire and bring back in one tap each', async () => {
|
|
1414
|
+
const p = await rulesPage();
|
|
1415
|
+
p.button(p.rowOf('The walls'), 'retire').fire('click');
|
|
1416
|
+
await settle();
|
|
1417
|
+
p.button(p.rowOf('Legal'), 'bring back').fire('click');
|
|
1418
|
+
await settle();
|
|
1419
|
+
assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/work-categories/2', body: { active: false } }, { method: 'PATCH', url: '/api/bongos/work-categories/4', body: { active: true } }]);
|
|
1420
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Brought back. New goals can pick it again.');
|
|
1421
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '2 decided');
|
|
1422
|
+
assert.deepEqual(p.part('desk-record').children.slice(1, 3).map((x) => x.textContent), ['brought back Legal · now', 'retired The walls · now']);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test('1004580 the page: tapping a name opens its detail in place, and it moves from there', async () => {
|
|
1426
|
+
const p = await rulesPage();
|
|
1427
|
+
p.rowOf('The hall').children[0].children[1].fire('click');
|
|
1428
|
+
const detail = p.rowOf('The hall').children[1];
|
|
1429
|
+
assert.equal(detail.className, 'desk-facts');
|
|
1430
|
+
assert.deepEqual(detail.children.slice(0, 3).map((c) => c.textContent).concat(detail.children[3].children.map((c) => c.textContent)), ['key hall', '3 of 3 in order', 'offered to new goals', 'move up'], 'the page’s detail: its key, its place, whether it is offered; the bottom one only moves up');
|
|
1431
|
+
byAct(detail, 'move-up').fire('click');
|
|
1432
|
+
await settle();
|
|
1433
|
+
assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/work-categories/3', body: { sort_order: 15 } }]);
|
|
1434
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Moved. New goals are offered the categories in this order.');
|
|
1435
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
1436
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'moved The hall · now');
|
|
1437
|
+
assert.equal(p.rowOf('The hall').children[1].className, 'desk-facts', 'the detail stays open to move it again');
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
test('1004580 the page: a new category is added by its typed name, and the record names it', async () => {
|
|
1441
|
+
const p = await rulesPage({ answer: (url, body) => (url.endsWith('/work-categories') ? { status: 201, body: { ok: true, created: true, category: { id: 9, label: body.label } } } : null) });
|
|
1442
|
+
p.button(p.rowOf('New category'), 'add').fire('click');
|
|
1443
|
+
const ask = p.rowOf('New category').children[1];
|
|
1444
|
+
assert.equal(ask.children[0].textContent, 'its name');
|
|
1445
|
+
assert.equal(ask.children[1].value || '', '', 'an add starts empty');
|
|
1446
|
+
ask.children[1].value = 'Outreach';
|
|
1447
|
+
ask.children[2].fire('click');
|
|
1448
|
+
await settle();
|
|
1449
|
+
assert.deepEqual(p.sent, [{ method: 'POST', url: '/api/bongos/work-categories', body: { label: 'Outreach' } }]);
|
|
1450
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Outreach is a category now. New goals can pick it.');
|
|
1451
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'added Outreach · now');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
test('1004580 the page: a refused act says why, keeps the row, and counts nothing', async () => {
|
|
1455
|
+
const p = await rulesPage({ answer: () => ({ status: 403, body: { error: { code: 'forbidden' } } }) });
|
|
1456
|
+
p.button(p.rowOf('Money'), 'retire').fire('click');
|
|
1457
|
+
await settle();
|
|
1458
|
+
assert.equal(p.sent.length, 1);
|
|
1459
|
+
assert.equal(p.byId.get('streetToast').textContent, 'That is not yours to do.');
|
|
1460
|
+
assert.ok(p.rowOf('Money'));
|
|
1461
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
1462
|
+
assert.ok(!walk(p.part('desk-record'), (n) => /retired Money/.test(n.textContent)).length, 'nothing on the record');
|
|
1463
|
+
// a refused move stops at the first write and counts nothing
|
|
1464
|
+
p.rowOf('Money').children[0].children[1].fire('click');
|
|
1465
|
+
byAct(p.rowOf('Money').children[1], 'move-down').fire('click');
|
|
1466
|
+
await settle();
|
|
1467
|
+
assert.equal(p.sent.length, 2, 'the renumbering stopped at its first refusal');
|
|
1468
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
1469
|
+
});
|