Files
root-at-skic 27d4794ff8 0.9.2: Source view, schema checks without the YAML extension
Every Archify page can show its file's YAML in place. Diagrams get
Diagram | Graph | Source (Graph only inside a model), main.archify
Model | Graph | Source, registry and decision pages View | Source, and the
Graph page a Source segment. 2D | 3D moves left of that switch and hides
while Source shows.

The YAML opens in a CodeMirror editor inside the page (dist/source.js,
loaded on first use), themed with the page's colours. Edits go to the VS
Code document as WorkspaceEdits; outside changes come back to the page.
Undo and redo are the editor's own, since VS Code sends ⌘Z in a focused
webview to the webview; save and find stay VS Code's. Problems come from
the host only (what the Problems panel holds for the file) and are
counted on the Source segment; completion and hover come from the schema,
folded into one draft-07 document per diagram type.

The host now checks .archify files against the extension's schema with
ajv on the YAML as written, before archify validate, and checks
.archify-registry files against the registry schema for the first time.
Registry and decision pages become text editor providers. While Source
shows, new renders wait and appear when the picture comes back.

Show Source switches the page to Source; Open in Text Editor (the editor
title button) opens the text beside it, as Show Source did. The decision
schema is no longer given to the YAML extension. Fixed: YAML errors in
registry and decision files were reported on line 1; "Webview is
disposed" when a diagram closed mid-render; the model heading under the
toolbar in narrow editors.
2026-10-27 23:46:05 +02:00

151 lines
7.5 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']);
});
test('a registry YAML error is reported at its line, not the first', () => {
const { problems } = parseRegistry('schema_version: 1\nelements:\n api:\n type: backend\n sub\n label: API\n', 'r.archify-registry');
const yaml = problems.find((p) => /not valid YAML/.test(p.message));
assert.ok(yaml, 'a YAML problem');
assert.ok((yaml.line ?? 0) >= 3, `reported on line ${(yaml.line ?? 0) + 1}`);
});