Files
root-at-skic 094ca2925f Drop generated cards; registry descriptions become Elements tooltips
- resolveRefs no longer adds cards to a diagram; it returns node id →
  description, which the page sets as the tooltip of the matching row in
  Archify's Elements panel
- hand-written cards pass through unchanged; docs and changelog
2026-10-19 20:44:09 +03:00

144 lines
7.1 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import {
groupOf,
hasDiagramBody,
isMainFile,
isRegistryFile,
mergeRegistries,
parseRegistry,
readModelMeta,
resolveRefs,
sortedGroups,
summarizeDiagram,
} from '../src/model/format';
import { parse } from 'yaml';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
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) })));
}
test('file kinds are told apart by name', () => {
assert.ok(isMainFile('/m/online-shop/main.archify'));
assert.ok(!isMainFile('/m/online-shop/0100-context/main-flow.archify'));
assert.ok(isRegistryFile('registry/people.archify-registry'));
assert.ok(!isRegistryFile('people.archify'));
});
test('main.archify: model block and optional diagram body', () => {
const doc = parse(read('main.archify'));
assert.deepEqual(readModelMeta(doc), {
id: 'online-shop',
name: 'Online Shop',
description: 'The web shop, its API and workers, and the stores behind them. Context, containers and components, C4 style.\n',
});
assert.ok(hasDiagramBody(doc));
assert.equal(readModelMeta(parse(read('0100-context/online-shop.archify'))), undefined);
assert.ok(!hasDiagramBody({ schema_version: 1, model: { id: 'x' } }));
});
test('registry: elements with line numbers, duplicates reported once', () => {
const people = parseRegistry(read('registry/people.archify-registry'), 'people');
assert.deepEqual(people.problems, []);
assert.equal(people.elements.length, 2);
const customer = people.elements[0];
assert.equal(customer.id, 'customer');
assert.equal(customer.type, 'external');
assert.equal(customer.line, 2);
const merged = mergeRegistries([
{ file: 'a', ...parseRegistry('elements:\n api: { type: backend, label: API }\n', 'a') },
{ file: 'b', ...parseRegistry('elements:\n api: { type: backend, label: API again }\n db: { type: database, label: DB }\n', 'b') },
]);
assert.equal(merged.elements.size, 2);
assert.equal(merged.problems.length, 1);
assert.match(merged.problems[0].message, /"api" is defined twice/);
assert.equal(merged.problems[0].file, 'b');
});
test('registry: broken files are reported, not thrown', () => {
const bad = parseRegistry('elements:\n api: {\n', 'bad');
assert.equal(bad.elements.length, 0);
assert.equal(bad.problems[0].severity, 'error');
const noMap = parseRegistry('- just\n- a list\n', 'list');
assert.match(noMap.problems[0].message, /mapping/);
});
test('resolve: refs become full components, links and usage are collected', () => {
const registry = registryOfModel();
const doc = parse(read('0200-containers/online-shop.archify'));
const r = resolveRefs(doc, { registry, fileName: 'containers.archify' });
assert.deepEqual(r.problems, []);
const api = (r.diagram.components as Record<string, unknown>[]).find((c) => c.id === 'api')!;
assert.deepEqual(api, { id: 'api', type: 'backend', label: 'API', sublabel: 'Node.js', brand: 'node-js', pos: [460, 160], size: [130, 60] });
assert.equal(r.links.get('api'), '0300-components/api.archify');
assert.ok(!r.links.has('db'));
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
assert.equal(r.refs.get(2)?.id, 'api');
assert.ok(!('model' in r.diagram));
// Descriptions travel with the node ids, for the Elements panel; nothing is added to the diagram itself.
assert.ok(!('cards' in r.diagram));
assert.equal(r.descriptions.get('customer'), 'Buys products; signs in with email or a social account.');
assert.equal(r.descriptions.size, 7);
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }], 'hand-written cards pass through');
});
test('resolve: overrides, second id for the same element, inline elements and links', () => {
const registry = registryOfModel();
const doc = {
components: [
{ ref: 'db', label: 'Orders DB', pos: [0, 0] },
{ ref: 'db', id: 'db-replica', sublabel: 'replica', pos: [0, 0] },
{ id: 'cron', type: 'backend', label: 'Nightly job', diagram: 'jobs.archify', pos: [0, 0] },
{ ref: 'api', diagram: 'other.archify', pos: [0, 0] },
],
};
const r = resolveRefs(doc, { registry, fileName: 'x.archify' });
const components = r.diagram.components as Record<string, unknown>[];
assert.equal(components[0].label, 'Orders DB');
assert.equal(components[0].brand, 'postgresql');
assert.equal(components[1].id, 'db-replica');
assert.equal(components[1].sublabel, 'replica');
assert.deepEqual(components[2], { id: 'cron', type: 'backend', label: 'Nightly job', pos: [0, 0] });
assert.equal(r.links.get('cron'), 'jobs.archify');
assert.equal(r.links.get('api'), 'other.archify', 'the component link wins over the registry link');
assert.deepEqual(r.problems, []);
});
test('resolve: unknown refs, forbidden overrides, outside a model', () => {
const registry = registryOfModel();
const r = resolveRefs({ components: [{ ref: 'ghost', pos: [0, 0] }, { ref: 'api', type: 'frontend', brand: 'react', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
assert.equal(r.problems.length, 3);
assert.equal(r.problems[0].pointer, '/components/0/ref');
assert.match(r.problems[0].message, /Unknown element "ghost"/);
assert.deepEqual(r.problems.slice(1).map((p) => p.pointer), ['/components/1/type', '/components/1/brand']);
const ghost = (r.diagram.components as Record<string, unknown>[])[0];
assert.deepEqual(ghost, { type: 'external', label: 'ghost', pos: [0, 0], id: 'ghost' });
const outside = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }] }, { fileName: '/w/loose.archify' });
assert.match(outside.problems[0].message, /not inside a model/);
});
test('summaries and groups: root first, then folders', () => {
const summaries = [
summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify'),
summarizeDiagram(read('0100-context/online-shop.archify'), '0100-context/online-shop.archify'),
summarizeDiagram('schema_version: 1\ndiagram_type: workflow\nmeta: { title: Checkout }\n', 'checkout.archify'),
summarizeDiagram('oops: [', 'broken.archify'),
];
assert.equal(summaries[0].title, 'API components');
assert.equal(summaries[0].kind, 'architecture');
assert.deepEqual(summaries[0].used, ['web', 'db', 'queue']);
assert.ok(summaries[3].parseError);
assert.equal(groupOf('a/b/c.archify'), 'a/b');
assert.equal(groupOf('c.archify'), '');
const groups = sortedGroups(summaries);
assert.deepEqual(groups.map((g) => g.group), ['', '0100-context', '0300-components']);
assert.deepEqual(groups[0].diagrams.map((d) => d.relativePath), ['broken.archify', 'checkout.archify']);
});