import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { parse } from 'yaml'; import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions'; import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format'; const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model'); const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8'); const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision']; function registryOfModel() { const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry']; return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) }))); } function decisionsOfModel() { return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) }))); } test('decision files parse with lines for every linked id', () => { const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2'); assert.deepEqual(problems, []); assert.ok(decision); assert.equal(decision.id, 'queue-between-api-and-worker'); assert.equal(decision.title, 'A queue between the API and the worker'); assert.equal(decision.status, 'accepted'); assert.deepEqual(decision.elements, ['api', 'queue', 'worker']); assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']); assert.equal(decision.lines.elements.length, 3); assert.ok(decision.context && decision.decision && decision.consequences); }); test('decision files: broken YAML, missing block, unknown status', () => { assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error'); assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/); const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x'); assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']); assert.equal(bad.problems[0].line, 3); }); test('decisions merge, validate links, and the superseded rule applies', () => { const set = decisionsOfModel(); assert.deepEqual(set.problems, []); assert.equal(set.decisions.size, 3); const registry = registryOfModel(); const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']); assert.deepEqual(validateDecisions(set, registry, diagrams), []); // Duplicate id, unknown element, missing diagram, dangling supersedes. const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup'); const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]); assert.match(merged.problems[0].message, /defined twice/); const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4'); const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]); const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams); assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']); assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' }); assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' }); const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id); assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last'); assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search'); // Numeric ids are a YAML trap and are refused on both sides. assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/); assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/); }); test('diagrams: decisions keys are stripped, collected and checked', () => { const registry = registryOfModel(); const known = new Set(decisionsOfModel().decisions.keys()); const doc = parse(read('0200-containers/online-shop.archify')); const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' }); assert.deepEqual(r.problems, []); assert.ok(!('decisions' in r.diagram)); const components = r.diagram.components as Record[]; const connections = r.diagram.connections as Record[]; assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c))); assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [ ['postgres-as-source-of-truth', 'diagram', '/decisions/0'], ['queue-between-api-and-worker', 'node', '/components/5/decisions/0'], ['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'], ]); const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' }); assert.match(numeric.problems[0].message, /is a number to YAML/); const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' }); assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0'); assert.match(unknown.problems[0].message, /Unknown decision "nope"/); const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' }); assert.match(outside.problems[0].message, /not inside a model/); // Other kinds: nodes and edges carry the key too. const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' }); assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']); assert.ok(!('decisions' in (workflow.diagram.nodes as Record[])[0])); const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify'); assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']); });