import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { mergeDecisions, parseDecision } from '../src/model/decisions'; import { DecisionAttachment, mergeRegistries, parseRegistry, summarizeDiagram } from '../src/model/format'; import { ModelGraph, buildGraph } from '../src/model/graph'; import { parseQuery, runQuery, shortestPath } from '../src/model/graphQuery'; const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model'); const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8'); const REGISTRY_FILES = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry']; const DIAGRAM_FILES = ['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']; 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 graphOfModel(): ModelGraph { const registry = mergeRegistries(REGISTRY_FILES.map((file) => ({ file, ...parseRegistry(read(file), file) }))); const diagrams = DIAGRAM_FILES.map((file) => summarizeDiagram(read(file), file)); const decisions = mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) }))); const decisionUsage = new Map(); for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]); return buildGraph({ meta: { id: 'online-shop', name: 'Online Shop' }, registry, diagrams, decisions, decisionUsage }); } test('diagram summaries carry nodes and edges, shared and inline', () => { const api = summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify'); assert.deepEqual(api.nodes.map((n) => n.id), ['web', 'auth', 'orders', 'catalogue', 'db', 'queue']); assert.equal(api.nodes[0].ref, 'web'); assert.equal(api.nodes[1].ref, undefined); assert.equal(api.nodes[1].type, 'security'); assert.equal(api.edges.length, 6); assert.deepEqual(api.edges[0], { from: 'web', to: 'auth', label: 'sign in' }); const broken = summarizeDiagram('diagram_type: [', 'x.archify'); assert.deepEqual(broken.nodes, []); assert.deepEqual(broken.edges, []); }); test('the model graph unites every diagram: elements once, connections once, labels merged', () => { const graph = graphOfModel(); // 11 registry elements + 3 inline components of the API diagram. assert.equal(graph.nodes.length, 14); const db = graph.nodes.find((n) => n.id === 'db')!; assert.equal(db.shared, true); assert.equal(db.type, 'database'); assert.deepEqual(db.diagrams, ['0200-containers/online-shop.archify', '0300-components/api.archify']); assert.equal(db.file, 'registry/containers.archify-registry'); assert.ok(db.description); const orders = graph.nodes.find((n) => n.id === 'orders')!; assert.equal(orders.shared, false); assert.deepEqual(orders.diagrams, ['0300-components/api.archify']); // api → db is drawn once in the containers diagram; orders → db and catalogue → db are separate connections. assert.equal(graph.edges.length, 4 + 7 + 6); const apiDb = graph.edges.find((e) => e.id === 'api>db')!; assert.deepEqual(apiDb.labels, ['SQL']); assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']); assert.deepEqual(graph.diagrams.map((d) => d.path), DIAGRAM_FILES); }); test('decisions reach the elements they name and the nodes and edges they are attached to', () => { const graph = graphOfModel(); const queue = graph.nodes.find((n) => n.id === 'queue')!; assert.deepEqual(queue.decisions, ['queue-between-api-and-worker']); const api = graph.nodes.find((n) => n.id === 'api')!; assert.deepEqual([...api.decisions].sort(), ['opensearch-for-product-search', 'postgres-as-source-of-truth', 'queue-between-api-and-worker']); assert.deepEqual(graph.edges.find((e) => e.id === 'api>queue')!.decisions, ['queue-between-api-and-worker']); assert.equal(graph.decisions.length, 3); assert.equal(graph.decisions.find((d) => d.id === 'postgres-as-source-of-truth')!.status, 'accepted'); }); test('an unused registry element is in the graph with no diagrams', () => { const registry = mergeRegistries([{ file: 'r', ...parseRegistry('elements:\n api: { type: backend, label: API }\n ghost: { type: database, label: Ghost }\n', 'r') }]); const diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')]; const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() }); assert.deepEqual(graph.nodes.map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]); assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost']); }); test('queries parse into terms; unknown keys are plain text', () => { assert.deepEqual(parseQuery('database type:backend -shared near:api 2 foo:bar unused'), [ { key: 'text', value: 'database', negate: false }, { key: 'type', value: 'backend', negate: false }, { key: 'shared', value: '', negate: true }, { key: 'near', value: 'api,2', negate: false }, { key: 'text', value: 'foo:bar', negate: false }, { key: 'unused', value: '', negate: false }, ]); assert.deepEqual(parseQuery(' '), []); }); test('queries narrow the graph: text, type, diagram, decision, shared', () => { const graph = graphOfModel(); const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); assert.equal(runQuery(graph, '').nodes.size, 14); assert.deepEqual(ids('type:database'), ['cache', 'db']); assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']); assert.deepEqual(ids('worker'), ['worker']); assert.deepEqual(ids('diagram:0100-*'), ['customer', 'email', 'psp', 'shop', 'support']); assert.deepEqual(ids('diagram:0300-components/api'), ['auth', 'catalogue', 'db', 'orders', 'queue', 'web']); assert.deepEqual(ids('-shared'), ['auth', 'catalogue', 'orders']); assert.deepEqual(ids('shared type:backend'), ['api', 'worker']); assert.deepEqual(ids('decision:queue-between-api-and-worker'), ['api', 'queue', 'worker']); assert.deepEqual(ids('type:backend -worker'), ['api', 'catalogue', 'orders']); const unknown = runQuery(graph, 'decision:nope'); assert.equal(unknown.nodes.size, 0); assert.match(unknown.message ?? '', /No decision "nope"/); }); test('queries walk the graph: uses, near, from/to', () => { const graph = graphOfModel(); const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); assert.deepEqual(ids('uses:api'), ['api', 'cache', 'db', 'queue', 'web']); assert.deepEqual(ids('uses:API'), ['api', 'cache', 'db', 'queue', 'web']); assert.deepEqual(ids('near:worker,2'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']); assert.deepEqual(ids('near:worker 2 type:database'), ['db']); const result = runQuery(graph, 'from:customer to:db'); assert.deepEqual(result.path, ['customer', 'web', 'api', 'db']); assert.deepEqual([...result.edges].sort(), ['api>db', 'customer>web', 'web>api']); // Direction is tried first; against the arrows the search still finds a way. assert.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']); assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5); const none = runQuery(graph, 'from:customer to:nowhere'); assert.equal(none.nodes.size, 0); assert.match(none.message ?? '', /No element "nowhere"/); const missing = runQuery(graph, 'from:customer'); assert.match(missing.message ?? '', /both from: and to:/); }); test('connections are shown only when both ends match', () => { const graph = graphOfModel(); const result = runQuery(graph, 'type:backend'); assert.ok(result.nodes.has('api')); assert.ok(!result.edges.has('api>db')); assert.ok(result.edges.has('orders>queue') === false); assert.ok([...result.edges].every((id) => id.split('>').every((end) => result.nodes.has(end)))); });