Model graph: the whole model as one graph with queries; 0.9.0
- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram) - union graph of every element and connection, registry and inline, built from the model index - query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared - side panel per element and connection with diagrams, registry line and decisions one click away - legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js - command Archify | Open Model Graph; integration check; unit tests for graph and queries
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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.
|
||||
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))));
|
||||
});
|
||||
@@ -317,6 +317,22 @@ export async function run(): Promise<void> {
|
||||
assert.ok(tab);
|
||||
});
|
||||
|
||||
await check('Archify | Open Model Graph switches the main.archify editor to the Graph page and back', async () => {
|
||||
const main = workspaceFile('model/main.archify');
|
||||
const diagram = workspaceFile('model/0100-context/online-shop.archify');
|
||||
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
|
||||
assert.equal(commands.find((item) => item.command === 'archify.openModelGraph')?.title, 'Archify | Open Model Graph');
|
||||
await vscode.commands.executeCommand('archify.openModelGraph', diagram);
|
||||
const tab = await waitFor('model editor active', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
// The choice is remembered per file in the workspace state until the page is switched back.
|
||||
const ext = vscode.extensions.getExtension(EXTENSION_ID);
|
||||
assert.ok(ext?.isActive);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => {
|
||||
const registry = workspaceFile('model/registry/people.archify-registry');
|
||||
await vscode.commands.executeCommand('vscode.open', registry);
|
||||
|
||||
Reference in New Issue
Block a user