- one node per decision (tag coloured by status) with decides links to its elements and supersedes links - one node per registry file with defines links to its elements - own legend chips, side panels (Open decision, Open registry), type:decision and type:registry - walks and paths follow the diagrams' connections only; attached nodes follow their elements into a result
163 lines
10 KiB
TypeScript
163 lines
10 KiB
TypeScript
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<string, { path: string; attachedTo: DecisionAttachment }[]>();
|
|
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, plus 3 decision and 3 registry nodes.
|
|
assert.equal(graph.nodes.filter((n) => n.kind === 'element').length, 14);
|
|
assert.equal(graph.nodes.length, 20);
|
|
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.filter((e) => e.kind === 'connection').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('decisions and registries are nodes of their own, linked to the elements they are about or define', () => {
|
|
const graph = graphOfModel();
|
|
const decision = graph.nodes.find((n) => n.id === 'decision:queue-between-api-and-worker')!;
|
|
assert.equal(decision.kind, 'decision');
|
|
assert.equal(decision.type, 'decision');
|
|
assert.equal(decision.status, 'accepted');
|
|
assert.equal(decision.sublabel, 'queue-between-api-and-worker');
|
|
assert.deepEqual(graph.edges.filter((e) => e.kind === 'decides' && e.from === decision.id).map((e) => e.to).sort(), ['api', 'queue', 'worker']);
|
|
const registry = graph.nodes.find((n) => n.id === 'registry:registry/containers.archify-registry')!;
|
|
assert.equal(registry.kind, 'registry');
|
|
assert.equal(registry.label, 'registry/containers');
|
|
assert.deepEqual(registry.elements, ['web', 'api', 'worker', 'queue', 'db', 'cache']);
|
|
assert.equal(graph.edges.filter((e) => e.kind === 'defines' && e.from === registry.id).length, 6);
|
|
// Walks and paths do not go through them: a registry is not a shortcut between its elements.
|
|
assert.deepEqual(shortestPath(graph, 'customer', 'cache')?.nodes, ['customer', 'web', 'api', 'cache']);
|
|
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
|
|
assert.ok(!ids('uses:api').some((id) => id.startsWith('registry:') && id !== 'registry:registry/containers.archify-registry'));
|
|
// They follow their elements into a result, and a negative term removes them again.
|
|
assert.deepEqual(ids('type:database').filter((id) => id.includes(':')), ['decision:opensearch-for-product-search', 'decision:postgres-as-source-of-truth', 'registry:registry/containers.archify-registry']);
|
|
assert.deepEqual(ids('type:database -type:decision -type:registry'), ['cache', 'db']);
|
|
assert.deepEqual(ids('type:registry'), ['registry:registry/containers.archify-registry', 'registry:registry/people.archify-registry', 'registry:registry/systems.archify-registry']);
|
|
assert.ok(ids('decision:queue-between-api-and-worker').includes('decision:queue-between-api-and-worker'));
|
|
});
|
|
|
|
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.filter((n) => n.kind === 'element').map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]);
|
|
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost', 'registry:r']);
|
|
});
|
|
|
|
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(' '), []);
|
|
});
|
|
|
|
const elementIds = (graph: ModelGraph, q: string) => [...runQuery(graph, q).nodes].filter((id) => !id.includes(':')).sort();
|
|
|
|
test('queries narrow the graph: text, type, diagram, decision, shared', () => {
|
|
const graph = graphOfModel();
|
|
const ids = (q: string) => elementIds(graph, q);
|
|
assert.equal(runQuery(graph, '').nodes.size, 20);
|
|
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) => elementIds(graph, q);
|
|
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].filter((id) => !id.includes(':')).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) => { const at = id.indexOf('>', id.startsWith('decision:') || id.startsWith('registry:') ? id.indexOf(':') : 0); return result.nodes.has(id.slice(0, at)) && result.nodes.has(id.slice(at + 1)); }));
|
|
});
|