Squash of the PM's work/vikunja-19-265 branch, net diff 0c3826910..8ae5a1491
(patch sha256 47b34e6054a74eedd8a5b6577e40537a07b2fb8fe5846f309e100146238ef743).
The PRD moves to version 1.1. "Business" becomes "organization" in
REQ-ROLE-3, REQ-ROLE-4 and REQ-LAUNCH-1. REQ-DEC-3 and REQ-DEC-4 name an
authorized human instead of Jason. Non-goal D adds that v1 has no hosted
SaaS, and "Where it runs" says Mosaic Stack stays self-hosted open source.
A new Organizations section adds REQ-ORG-1, REQ-ORG-2, REQ-USER-1,
REQ-THREAD-1, REQ-TPL-1 and REQ-FED-1. packages/tasks/requirements.json
matches at version 1.1 with 30 ids, and the tasks and bus requirements
tests expect 30.
The branch's 010d30cbe (parseRequirementId, task 19/229) is reverted on
the branch and is not part of this squash.
Jason approved the PRD in decision 03130d08 and the merge in decision
f71c73c9 (resolved yes via the CLI, 2026-10-14T16:35:32Z). The stack
reviewer's verdict d3f97f9d approves 8ae5a1491. Sage's gate on 1c9a4910d
plus the patch passed: every package suite and every scripts/test-*.sh.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
82 lines
4.4 KiB
JavaScript
82 lines
4.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { loadRequirements, checkRequirements, RequirementsError, REQUIREMENTS_FILE } from '../src/requirements.mjs';
|
|
// Row 90 (#1584): requirements.json against the approved PRD it names. The PRD's **REQ-...**
|
|
// bullets are the source; the list must hold the same ids in the same order, and the PRD's
|
|
// status line must give the list's version.
|
|
const list = loadRequirements();
|
|
const raw = () => JSON.parse(readFileSync(REQUIREMENTS_FILE, 'utf8'));
|
|
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
|
const listError = (message) => (e) => e instanceof RequirementsError && (message === undefined || e.message === message);
|
|
|
|
test("the list holds the PRD's requirement ids, in its order, at its version", () => {
|
|
const prd = readFileSync(join(REPO, list.prd), 'utf8');
|
|
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
|
|
// Any other mention of a bold id would be a bullet this pattern missed.
|
|
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
|
|
assert.equal(ids.length, 30);
|
|
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
|
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
|
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.1']);
|
|
});
|
|
|
|
test('the committed list loads frozen', () => {
|
|
assert.ok(Object.isFrozen(list) && Object.isFrozen(list.requirements) && Object.isFrozen(list.requirements[0]));
|
|
assert.deepEqual(checkRequirements(raw()), raw());
|
|
assert.deepEqual(loadRequirements(), list);
|
|
});
|
|
|
|
test('the schema refuses any other shape', () => {
|
|
const bad = {
|
|
'an unknown top key': (l) => (l.source = 'x'),
|
|
'no prd': (l) => delete l.prd,
|
|
'a prd outside docs/prd': (l) => (l.prd = 'docs/plans/x.md'),
|
|
'a prd that is not markdown': (l) => (l.prd = 'docs/prd/mosaic-stack.txt'),
|
|
'a prd with a parent step': (l) => (l.prd = 'docs/prd/../x.md'),
|
|
'no version': (l) => delete l.version,
|
|
'a numeric version': (l) => (l.version = 1.5),
|
|
'a version with no minor': (l) => (l.version = '1'),
|
|
'a version 0': (l) => (l.version = '0.4'),
|
|
'no requirements': (l) => delete l.requirements,
|
|
'an empty list': (l) => (l.requirements = []),
|
|
'requirements not a list': (l) => (l.requirements = {}),
|
|
'an entry not an object': (l) => (l.requirements[0] = 'REQ-ROLE-1'),
|
|
'an unknown entry key': (l) => (l.requirements[0].note = 'x'),
|
|
'no id': (l) => delete l.requirements[0].id,
|
|
'no title': (l) => delete l.requirements[0].title,
|
|
'an id in lower case': (l) => (l.requirements[0].id = 'REQ-role-1'),
|
|
'an id numbered 0': (l) => (l.requirements[0].id = 'REQ-ROLE-0'),
|
|
'an id with a suffix': (l) => (l.requirements[0].id = 'REQ-ROLE-1a'),
|
|
'an id twice': (l) => (l.requirements[1].id = l.requirements[0].id),
|
|
'a blank title': (l) => (l.requirements[0].title = ' '),
|
|
'a title over 120 characters': (l) => (l.requirements[0].title = 'x'.repeat(121)),
|
|
'a title of two lines': (l) => (l.requirements[0].title = 'a\nb'),
|
|
'a title that is not a string': (l) => (l.requirements[0].title = 1),
|
|
};
|
|
for (const x of [[], null, 'list']) assert.throws(() => checkRequirements(x), listError('list: not an object'));
|
|
for (const [what, edit] of Object.entries(bad)) {
|
|
const l = raw();
|
|
edit(l);
|
|
assert.throws(() => checkRequirements(l), listError(), what);
|
|
}
|
|
const ok = raw();
|
|
ok.requirements[0].title = 'x'.repeat(120);
|
|
assert.equal(checkRequirements(ok), ok);
|
|
const dir = mkdtempSync(join(tmpdir(), 'requirements-'));
|
|
try {
|
|
writeFileSync(join(dir, 'cut.json'), '{"prd":');
|
|
assert.throws(() => loadRequirements(join(dir, 'cut.json')), listError('requirements.json: not JSON'));
|
|
assert.throws(() => loadRequirements(join(dir, 'absent.json')), listError('requirements.json: ENOENT'));
|
|
const twice = raw();
|
|
twice.requirements.push(structuredClone(twice.requirements[0]));
|
|
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
|
|
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[30].id: REQ-ROLE-1 twice'));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|