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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user