tasks, webui: field table notes and tests, scroll hint label, row 88 (#1582)

Row 61 follow-up. Filbert approved round 1 in comment 28214; Sage's
full gate passed 16:18:32-16:22:34Z. The public/bus.js change is the
one aria-label line Mos released under decision 123.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 11:22:55 -05:00
co-authored by Claude Opus 5.5
parent 97097efce0
commit 824143858f
5 changed files with 93 additions and 18 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { TASK_VERBS } from '../../bus/src/broker.mjs';
// Row 61 (#1550): the slice 1 field table as data, in ../fields.json. The prose in
// docs/plans/2026-10-04_slice-1.md stays the reviewed source; tests/fields.test.mjs checks both
// against what the verbs do. Two layers, as lead decision 113 rules:
// Row 61 (#1550): the slice 1 field table as data, in ../fields.json. The Field table in
// docs/plans/2026-10-04_slice-1.md is the reviewed prose it must match, and tests/fields.test.mjs
// checks that prose and this table against what the verbs do. Two layers, as lead decision 113 rules:
// - `verbs`: the writer verbs.mjs checks before anything else. `pm` is the pm definition
// (field-writer otherwise), `assigned` a role whose bot is an assignee (not-assigned
// otherwise), `any` no writer check.
+62 -12
View File
@@ -9,11 +9,11 @@ import { FakeVikunja } from '../src/fake.mjs';
import { loadFields, checkFields, FieldTableError, FIELDS_FILE } from '../src/fields.mjs';
import { TASK_VERBS } from '../../bus/src/broker.mjs';
import { loadRole } from '../../business/src/index.mjs';
// Row 61 (#1550): fields.json against what the verbs do. The writer layer is read from the table;
// whether a write needs a decision, and who resolves it, from roles/*.json as the business loader
// reads them (lead decision 113). A version 1 file, the researcher's, grants nothing.
// Neither expectation is written out here, so a change to the table, a role file or the verbs
// that leaves them disagreeing fails this file.
// Row 61 (#1550): fields.json, and the Field table in docs/plans/2026-10-04_slice-1.md, against what
// the verbs in src/verbs.mjs do, with the role files as given. The writer layer is read from the table; whether a write needs a decision, and who
// resolves it, from roles/*.json as the business loader reads them (lead decision 113). A version 1
// file, the researcher's, grants nothing. Neither expectation is written out here, so a change to
// the table, the prose, a role file or the verbs that leaves them disagreeing fails this file.
const table = loadFields();
const ROLES = fileURLToPath(new URL('../../../roles/', import.meta.url));
const authority = (r) => loadRole(ROLES, r).authority;
@@ -26,6 +26,14 @@ test('the committed table loads frozen; its verbs are the broker task verbs', ()
assert.deepEqual(checkFields(raw()), raw());
});
test('a write names a verb the table has as its own key, not one it inherits', () => {
for (const verb of ['constructor', 'toString']) {
const t = raw();
t.fields[0].writes[0].verb = verb;
assert.throws(() => checkFields(t), tableError('fields[0].writes[0].verb: not a task verb'), verb);
}
});
test('the schema refuses any other shape', () => {
const bad = {
'an unknown top key': (t) => (t.writers = {}),
@@ -80,6 +88,34 @@ test('the schema refuses any other shape', () => {
}
});
test('the Field table in the slice 1 plan names the verbs each field is written by', () => {
const doc = readFileSync(new URL('../../../docs/plans/2026-10-04_slice-1.md', import.meta.url), 'utf8');
const start = doc.indexOf('### Field table');
const section = doc.slice(start, doc.indexOf('### Rows and order', start));
// Header and separator aside, a row is Field, Writer, Action, Vikunja call.
const rows = section
.split('\n')
.filter((l) => l.startsWith('| '))
.slice(1)
.map((l) => l.split('|').slice(1, -1).map((c) => c.trim()));
// Names compare without case, backticks or a plural s: "Assignees" is "assignee" in the prose.
const norm = (s) => s.toLowerCase().replace(/`/g, '').replace(/s$/, '');
// Every name the prose gives must be a label in fields.json. A name with no label fails here and
// the message names it, so a renamed or invented field cannot pass by matching nothing.
const labels = new Set(table.fields.map((f) => norm(f.label)));
for (const [field] of rows) {
for (const name of field.split(', ')) {
assert.ok(labels.has(norm(name)), `the Field table names "${name}", which fields.json does not have`);
}
}
for (const f of table.fields) {
const hit = rows.filter(([field]) => field.split(', ').map(norm).includes(norm(f.label)));
assert.equal(hit.length, 1, `${f.field}: one row of the Field table`);
const named = [...hit[0][2].matchAll(/`(task\.[a-z.]+)`/g)].map((m) => m[1]);
assert.deepEqual([...new Set(named)].sort(), [...new Set(f.writes.map((w) => w.verb))].sort(), f.field);
}
});
// A Vikunja write, under /api/v2, as the table names it, and the fields it writes. A bucket move
// writes `done` when it moves into the done bucket and the bucket otherwise.
const CALLS = [
@@ -114,12 +150,23 @@ test('each verb refuses, asks for a decision and writes as the table and the rol
};
const dir = mkdtempSync(join(tmpdir(), 'fields-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
// The role files' authority, and a researcher seat.
// The role files' authority, a researcher seat, and the cto as a tracker seat: the cto is the
// technical arbiter, and its own writes are walked like the others'.
const w = await world(t, {
fake,
fetch,
business: (b, { owner, project }) => {
for (const r of ['pm', 'coder', 'reviewer', 'cto']) b.roles[r].authority = authority(r);
const ctoBot = fake.user('bot-demo-cto', { owner });
fake.share(project, ctoBot, 1);
const ctoFile = join(dir, 'cto.token');
writeFileSync(ctoFile, fake.token(ctoBot, SCOPES.worker) + '\n');
chmodSync(ctoFile, 0o600);
b.roles.cto = {
...b.roles.cto,
tracker: { bot: 'bot-demo-cto', botId: ctoBot },
credentials: { vikunja: { file: ctoFile, expires: '2099-01-01' } },
};
const bot = fake.user('bot-demo-researcher', { owner });
fake.share(project, bot, 1);
const file = join(dir, 'researcher.token');
@@ -136,9 +183,8 @@ test('each verb refuses, asks for a decision and writes as the table and the rol
assert.deepEqual(w.businesses.demo.roles.researcher.authority, { withinRole: [], crossRole: [] });
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready');
const ROLE_NAMES = ['pm', 'coder', 'reviewer', 'researcher'];
const ROLE_NAMES = ['pm', 'coder', 'reviewer', 'researcher', 'cto'];
const caps = Object.fromEntries(ROLE_NAMES.map((r) => [r, w.agent(r)]));
const cto = w.agent('cto');
const botId = (r) => w.businesses.demo.roles[r].tracker.botId;
const done = w.installed.buckets.done;
const seen = Object.fromEntries(TASK_VERBS.map((v) => [v, new Set()]));
@@ -175,9 +221,12 @@ test('each verb refuses, asks for a decision and writes as the table and the rol
},
});
// Cross-role goes to the technical arbiter; anything the role file doesn't list, to the human.
const route = a.crossRole.includes(verb) ? w.businesses.demo.arbiters.technical : 'human';
// The arbiter's own cross-role decision goes to the human: the broker never routes a role's
// decision back to that role (broker.mjs, decision.raise).
const arbiter = w.businesses.demo.arbiters.technical;
const route = a.crossRole.includes(verb) && arbiter !== role ? arbiter : 'human';
assert.equal(w.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, route, `${role} ${verb} route`);
w.broker.request(route === 'human' ? w.human : cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
w.broker.request(route === 'human' ? w.human : caps[route], { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
decision = d.id;
}
const r = await w.call(caps[role], verb, decision ? { ...args, decision } : args);
@@ -207,11 +256,12 @@ test('each verb refuses, asks for a decision and writes as the table and the rol
for (const [i, r] of ROLE_NAMES.entries()) await attempt(r, 'task.scope.change', { task_ref, title: `b${i}`, description: `what ${i}` });
for (const r of ['pm', 'coder', 'researcher']) await attempt(r, 'task.update.assigned', { task_ref, state: 'in-progress' });
await attempt('reviewer', 'task.update.assigned', { task_ref, percent_done: 0.5, state: 'in-progress', comment: 'started' });
// People add the coder and the researcher in Vikunja; each may then write as the assigned role.
// People add the coder, the researcher and the cto in Vikunja; each may then write as the assigned role.
const id = Number(task_ref.split('/')[1]);
for (const r of ['coder', 'researcher']) assert.equal((await w.ui('POST', `/tasks/${id}/assignees`, { user_id: botId(r) })).status, 201);
for (const r of ['coder', 'researcher', 'cto']) assert.equal((await w.ui('POST', `/tasks/${id}/assignees`, { user_id: botId(r) })).status, 201);
await attempt('coder', 'task.update.assigned', { task_ref, percent_done: 0.7 });
await attempt('researcher', 'task.update.assigned', { task_ref, state: 'in-review' });
await attempt('cto', 'task.update.assigned', { task_ref, percent_done: 0.8 });
await attempt('pm', 'task.close', { task_ref, verdict: 'row 61 r1' });
assert.equal(fake.task(id).done, true);
for (const v of TASK_VERBS) assert.deepEqual([...seen[v]].sort(), expected(v), v);