refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CmapAppearance,
cmapFontSizeInPoints,
normalizedCmapFontSize
} from "../static/cmap/model/appearance.js";
const defaultValues = {
backgroundColor: "#fff4cf", textColor: "#222222",
fontFamily: "Arial", fontSize: 11, fontWeight: "700", fontStyle: "normal",
synopsisTextColor: "#4d4d4d", synopsisFontFamily: "Arial",
synopsisFontSize: 9, synopsisFontWeight: "400", synopsisFontStyle: "normal",
submapBackgroundColor: "#edf7e8", submapBorderColor: "#57834a"
};
function appearance() {
return new CmapAppearance({
styles: [{
id: "default", nameKey: "style-default", protected: true, values: defaultValues
}],
palette: ["#ffffff", "#222222"]
});
}
test("CMap appearance owns detached styles and palette values", () => {
const model = appearance();
const styles = model.styles;
const palette = model.palette;
styles[0].values.backgroundColor = "#000000";
palette[0] = "#000000";
assert.equal(model.defaultValues.backgroundColor, "#fff4cf");
assert.equal(model.palette[0], "#ffffff");
});
test("CMap appearance normalizes form values and recognizes named styles", () => {
const model = appearance();
const values = model.normalizeValues({
...defaultValues,
backgroundColor: "#FFF4CF",
fontSize: "11.2"
});
assert.equal(values.backgroundColor, "#fff4cf");
assert.equal(values.fontSize, 11);
assert.equal(model.matchingStyleId(values), "default");
});
test("custom styles and palette colours change only through model methods", () => {
const model = appearance();
const style = model.putStyle({ id: "review", name: "Review", values: defaultValues });
assert.equal(model.style("review").name, "Review");
assert.equal(model.matchingStyleId(style.values), "default");
assert.equal(model.deleteStyle("default"), false);
assert.equal(model.deleteStyle("review"), true);
assert.equal(model.setPaletteColor(1, "#ABCDEF"), true);
assert.equal(model.palette[1], "#abcdef");
assert.equal(model.setPaletteColor(1, "not-a-colour"), false);
});
test("CMap appearance rejects incomplete backend aggregates", () => {
assert.throws(
() => new CmapAppearance({ styles: [], palette: ["#ffffff"] }),
/default style and colour palette/);
});
test("CMap font sizes convert and normalize to backend units", () => {
assert.equal(cmapFontSizeInPoints("16px"), 12);
assert.equal(cmapFontSizeInPoints("1.5em", 10), 15);
assert.equal(normalizedCmapFontSize("55", 11), 54);
});
+46 -26
View File
@@ -2,10 +2,19 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { generateMarkdown, derivedDocument, relationLines } = require("../static/js/cmap-export.js");
let CmapMarkdownExporter;
test.before(async () => {
({ CmapMarkdownExporter } = await import(
"../static/cmap/model/markdown-exporter.js"));
});
function map(slug, title, document) {
return { slug, title, document };
return { slug, title, toDocument: () => document };
}
function repository(maps) {
return { load: async (slug) => maps[slug] };
}
const root = map("root", "Root map", {
@@ -52,13 +61,13 @@ const pages = new Map([
]);
test("exports metadata, person tags, relations and linked maps to the selected depth", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 1,
includeWikiPages: false,
language: "nl",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
language: "nl"
});
assert.match(markdown, /CMap: Root map \(niveau 0\)/);
@@ -73,25 +82,25 @@ test("exports metadata, person tags, relations and linked maps to the selected d
});
test("depth zero exports only the selected map", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 0,
includeWikiPages: false,
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
includeWikiPages: false
});
assert.match(markdown, /Root map/);
assert.doesNotMatch(markdown, /## CMap: Child map/);
});
test("optionally appends linked wiki pages and concept explanations", async () => {
const markdown = await generateMarkdown({
rootMap: root,
const exporter = new CmapMarkdownExporter(
repository({ root, child }),
async (slug) => pages.get(slug));
const markdown = await exporter.export(root, {
maxDepth: 0,
includeWikiPages: true,
language: "en",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
language: "en"
});
assert.match(markdown, /## Linked wiki pages/);
assert.match(markdown, /### Approach/);
@@ -101,7 +110,7 @@ test("optionally appends linked wiki pages and concept explanations", async () =
assert.match(markdown, /Because\./);
});
test("derived views contain their root subtree and inherit useful root metadata", () => {
test("derived views contain their root subtree and inherit useful root metadata", async () => {
const source = {
schemaVersion: 2,
concepts: [
@@ -119,17 +128,22 @@ test("derived views contain their root subtree and inherit useful root metadata"
{ id: 2, sourceId: 11, targetId: 12 }
]
};
const result = derivedDocument(source, {
const derived = map("team", "Team", {
derivedView: { sourceCmapSlug: "source", rootItemId: 10 }
});
assert.deepEqual(result.items.map((item) => item.id), [10, 11]);
assert.deepEqual(result.connectors.map((connector) => connector.id), [1]);
assert.deepEqual(result.metadata.tags, ["people"]);
assert.equal(result.metadata.summary, "Team scope");
assert.equal(result.metadata.explanationPageSlug, "cmap:team");
const sourceMap = map("source", "Source", source);
const exporter = new CmapMarkdownExporter(
repository({ source: sourceMap }),
async (slug) => ({ slug, title: slug, markdown: "Team explanation", tags: [] }));
const markdown = await exporter.export(derived, { maxDepth: 0 });
assert.match(markdown, /Team scope/);
assert.match(markdown, /`people`/);
assert.match(markdown, /#### Work/);
assert.doesNotMatch(markdown, /#### Outside/);
assert.match(markdown, /Team explanation/);
});
test("direct and phrase relations receive readable Markdown", () => {
test("direct and phrase relations receive readable Markdown", async () => {
const documentValue = {
items: [
{ id: 1, label: "A" }, { id: 2, kind: "phrase", label: "supports" },
@@ -141,5 +155,11 @@ test("direct and phrase relations receive readable Markdown", () => {
{ sourceId: 3, targetId: 4, hasArrow: true }
]
};
assert.deepEqual(relationLines(documentValue), ["A — **supports** → B", "B → C"]);
const relationMap = map("relations", "Relations", documentValue);
const exporter = new CmapMarkdownExporter(
repository({ relations: relationMap }),
async () => null);
const markdown = await exporter.export(relationMap);
assert.match(markdown, /A — \*\*supports\*\* → B/);
assert.match(markdown, /B → C/);
});
+1 -1
View File
@@ -15,7 +15,7 @@ test.before(async () => {
preparedMapDocument,
attachmentUrls,
replaceAttachmentUrls
} = await import("../static/js/wiki/cmap/interchange.js"));
} = await import("../static/cmap/model/interchange.js"));
});
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
+103
View File
@@ -0,0 +1,103 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CmapJsonExporter } from "../static/cmap/model/json-exporter.js";
import { CmapJsonImporter } from "../static/cmap/model/json-importer.js";
import {
CmapRepository,
StoredConceptMap
} from "../static/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/cmap/model/concept-map.js";
const conceptId = "11111111-1111-4111-8111-111111111111";
const rootMap = {
slug: "root",
title: "Root",
document: {
schemaVersion: 2,
metadata: { explanationPageSlug: "root-page" },
concepts: [{ id: conceptId, label: "Root concept" }],
items: [{ id: 1, conceptId, kind: "concept", x: 10, y: 20 }],
connectors: []
}
};
const page = {
slug: "root-page",
title: "Root page",
markdown: "![Image](/uploads/root-page/image.png)",
tags: []
};
const storedRootMap = new StoredConceptMap(rootMap, CmapModel.fromDocument(rootMap.document));
const cmapRepository = { load: async () => { throw new Error("no linked map expected"); } };
test("CmapJsonExporter builds a complete bundle including attachments", async () => {
const exporter = new CmapJsonExporter(
cmapRepository,
async () => page,
async () => ({
ok: true,
headers: { get: () => "image/png" },
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
}),
"test"
);
const bundle = await exporter.export(storedRootMap);
assert.equal(bundle.generator, "test");
assert.equal(bundle.pages[0].attachments[0].contentBase64, "AQID");
});
test("CmapJsonImporter reports conflicts and imports pages, attachments and maps", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (path.endsWith("/upload")) return { url: "/uploads/root-page/imported-image.png" };
if (path === "/api/pages" && options.method === "POST") return { currentVersion: 1 };
if (path === "/api/cmaps" && options.method === "POST") {
const body = JSON.parse(options.body);
return {
slug: body.slug,
title: body.title,
currentVersion: 1,
document: body.document
};
}
return {};
};
const importer = new CmapJsonImporter(
new CmapRepository(api), api, (_key, fallback) => fallback);
const exporter = new CmapJsonExporter(
cmapRepository,
async () => page,
async () => ({
ok: true,
headers: { get: () => "image/png" },
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
})
);
const bundle = await exporter.export(storedRootMap);
const file = { size: 100, text: async () => JSON.stringify(bundle) };
const parsed = await importer.read(file);
assert.equal(parsed.rootCmapSlug, "root");
assert.deepEqual(importer.conflicts(parsed, [{ slug: "root-page" }], []), {
pages: [parsed.pages[0]],
conceptMaps: []
});
const result = await importer.import(parsed, {
pages: [], conceptMaps: [], summary: "Imported"
});
assert.equal(result.pagesCreated, 1);
assert.equal(result.mapsCreated, 1);
assert.equal(result.attachmentsImported, 1);
assert.deepEqual(calls.map((call) => `${call.options.method} ${call.path}`), [
"POST /api/pages",
"POST /api/pages/root-page/upload",
"PUT /api/pages/root-page",
"POST /api/cmaps"
]);
const pageUpdate = JSON.parse(calls[2].options.body);
assert.match(pageUpdate.markdown, /imported-image\.png/);
});
+16 -14
View File
@@ -14,7 +14,7 @@ import {
ConceptMapConnector,
ConceptMapPhrase
} from "../static/cmap/model/concept-map.js";
import { buildBundle } from "../static/js/wiki/cmap/interchange.js";
import { buildBundle } from "../static/cmap/model/interchange.js";
const firstId = "11111111-1111-4111-8111-111111111111";
const secondId = "22222222-2222-4222-8222-222222222222";
@@ -217,33 +217,35 @@ test("an embedded submap becomes an independent map linked from its owner", () =
const extraction = CmapModel.fromDocument(document)
.extractSubmap(1, "nested-subject");
const parentDocument = extraction.parentModel.toDocument();
const childDocument = extraction.childModel.toDocument();
assert.deepEqual(extraction.parentDocument.items.map((item) => item.id), [1, 4]);
assert.equal(extraction.parentDocument.items[0].kind, "concept");
assert.equal(extraction.parentDocument.concepts
assert.deepEqual(parentDocument.items.map((item) => item.id), [1, 4]);
assert.equal(parentDocument.items[0].kind, "concept");
assert.equal(parentDocument.concepts
.find((concept) => concept.id === firstId).cmapSlug, "nested-subject");
assert.deepEqual(extraction.parentDocument.connectors.map((connector) => ({
assert.deepEqual(parentDocument.connectors.map((connector) => ({
sourceId: connector.sourceId,
targetId: connector.targetId
})), [{ sourceId: 4, targetId: 1 }]);
assert.deepEqual(extraction.parentDocument.conceptOwnerships, [
assert.deepEqual(parentDocument.conceptOwnerships, [
{ parentConceptId: firstId, childConceptId: thirdId }
]);
assert.deepEqual(extraction.childDocument.items.map((item) => item.id), [2, 3]);
assert.ok(extraction.childDocument.items.every((item) => item.parentSubmapId === null));
assert.deepEqual(extraction.childDocument.concepts.map((concept) => concept.id), [secondId]);
assert.deepEqual(extraction.childDocument.connectors.map((connector) => connector.id), [1]);
assert.equal(extraction.childDocument.conceptOwnerships, undefined);
assert.equal(extraction.childDocument.derivedView, undefined);
assert.equal(extraction.childDocument.metadata.summary, "Nested subject");
assert.deepEqual(childDocument.items.map((item) => item.id), [2, 3]);
assert.ok(childDocument.items.every((item) => item.parentSubmapId === null));
assert.deepEqual(childDocument.concepts.map((concept) => concept.id), [secondId]);
assert.deepEqual(childDocument.connectors.map((connector) => connector.id), [1]);
assert.equal(childDocument.conceptOwnerships, undefined);
assert.equal(childDocument.derivedView, undefined);
assert.equal(childDocument.metadata.summary, "Nested subject");
const convertedDerivedMap = CmapModel.fromDocument(document).extractSubmap(
1,
"nested-subject",
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
);
assert.deepEqual(convertedDerivedMap.childDocument.metadata, {
assert.deepEqual(convertedDerivedMap.childModel.metadata(), {
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
});
});
+32
View File
@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PeopleRepository } from "../static/cmap/model/people-repository.js";
test("PeopleRepository keeps people API routes behind its public methods", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (!options.method) return { people: [{ id: 1, name: "Ada", active: true }] };
return { id: 1, name: "Ada", active: true };
};
const repository = new PeopleRepository(api);
const people = await repository.all();
const created = await repository.create("Ada");
await repository.update(created, false);
assert.equal(people[0].name, "Ada");
assert.deepEqual(calls.map((call) => call.path), [
"/api/people",
"/api/people",
"/api/people/1"
]);
assert.deepEqual(JSON.parse(calls[1].options.body), { name: "Ada" });
assert.deepEqual(JSON.parse(calls[2].options.body), { name: "Ada", active: false });
});
test("PeopleRepository normalizes a missing people collection to an empty list", async () => {
const repository = new PeopleRepository(async () => ({}));
assert.deepEqual(await repository.all(), []);
});
+90
View File
@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
CmapRepository,
StoredConceptMap
} from "../static/cmap/model/cmap-repository.js";
import { CmapModel } from "../static/cmap/model/concept-map.js";
const documentValue = {
schemaVersion: 2,
concepts: [{ id: "concept-1", label: "Legacy concept" }],
items: [{ id: 1, conceptId: "concept-1", kind: "concept", x: 10, y: 20 }],
connectors: []
};
test("CmapRepository decodes backend documents and normalizes legacy concept ids", async () => {
const repository = new CmapRepository(async () => ({
slug: "roadmap",
title: "Roadmap",
currentVersion: 3,
document: JSON.stringify(JSON.stringify(documentValue))
}));
const storedMap = await repository.load("roadmap");
assert.ok(storedMap instanceof StoredConceptMap);
assert.ok(storedMap.model instanceof CmapModel);
assert.equal(storedMap.currentVersion, 3);
assert.equal(storedMap.toDocument().concepts[0].id, "legacy:roadmap:concept-1");
assert.equal(storedMap.toDocument().items[0].conceptId, "legacy:roadmap:concept-1");
});
test("CmapRepository serializes model saves with backend version information", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
return {
slug: "roadmap",
title: "Roadmap",
currentVersion: calls.length,
document: documentValue
};
};
const repository = new CmapRepository(api);
const storedMap = await repository.load("roadmap");
const savedMap = await repository.save(storedMap, storedMap.model, {
summary: "Manual save",
saveKind: "manual",
snapshot: true
});
assert.equal(savedMap.currentVersion, 2);
assert.equal(calls[1].path, "/api/cmaps/roadmap");
const body = JSON.parse(calls[1].options.body);
assert.equal(body.baseVersion, 1);
assert.equal(body.summary, "Manual save");
assert.equal(body.saveKind, "manual");
assert.equal(body.snapshot, true);
assert.deepEqual(body.document.concepts, storedMap.toDocument().concepts);
});
test("CmapRepository keeps CMap routes behind its public storage API", async () => {
const calls = [];
const api = async (path, options = {}) => {
calls.push({ path, options });
if (path === "/api/cmaps") {
if (!options.method) return { conceptMaps: [{ slug: "one", title: "One" }] };
return { slug: "one", title: "One", currentVersion: 1, document: documentValue };
}
if (path.endsWith("/history")) return { versions: [{ version: 1 }] };
if (path === "/api/cmaps/concept-usage") return { placements: [{ conceptId: "one" }] };
return { slug: "one", title: "One", currentVersion: 2, document: documentValue };
};
const repository = new CmapRepository(api);
const summaries = await repository.list();
const storedMap = await repository.create("One", CmapModel.fromDocument(documentValue), "one");
const versions = await repository.history(storedMap);
const placements = await repository.conceptUsage();
await repository.deleteVersion(storedMap, 1);
await repository.archive(storedMap, "One");
assert.equal(summaries[0].slug, "one");
assert.equal(versions[0].version, 1);
assert.equal(placements[0].conceptId, "one");
assert.ok(calls.some((call) => call.path === "/api/cmaps/one/versions/1" &&
call.options.method === "DELETE"));
assert.ok(calls.some((call) => call.path === "/api/cmaps/one" &&
call.options.method === "DELETE"));
});
+2 -2
View File
@@ -66,11 +66,11 @@ test("the last selected concept determines target size and alignment", async ()
"external links are serialized as shared concept content"
);
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
reloaded.loadDocument(savedDocument);
reloaded.loadModel(editor.currentModel());
assert.equal(
reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl,
"https://example.com/first",
"external links survive an editor save and reload round-trip"
"external links survive an editor model round-trip"
);
editor.selectItem(first);
editor.selectItem(third, { additive: true });
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CmapAppearance } from "../static/cmap/model/appearance.js";
import { CmapAppearanceRepository } from "../static/cmap/model/appearance-repository.js";
import { CmapSettingsRepository } from "../static/cmap/model/settings-repository.js";
test("appearance repository owns the complete backend appearance envelope", async () => {
const requests = [];
const appearance = {
styles: [{
id: "default",
nameKey: "style-default",
protected: true,
values: {
backgroundColor: "#fff4cf", textColor: "#222222",
fontFamily: "Arial", fontSize: 11, fontWeight: "700", fontStyle: "normal",
synopsisTextColor: "#4d4d4d", synopsisFontFamily: "Arial",
synopsisFontSize: 9, synopsisFontWeight: "400", synopsisFontStyle: "normal",
submapBackgroundColor: "#edf7e8", submapBorderColor: "#57834a"
}
}],
palette: ["#ffffff", "#f1f3f5"]
};
const repository = new CmapAppearanceRepository(async (route, options = {}) => {
requests.push({ route, options });
return appearance;
});
const loaded = await repository.load();
assert.ok(loaded instanceof CmapAppearance);
assert.deepEqual(loaded.toData(), appearance);
const stored = await repository.save(loaded);
assert.equal(stored, loaded);
assert.deepEqual(stored.toData(), appearance);
assert.equal(requests[0].route, "/api/cmap-appearance");
assert.equal(requests[1].options.method, "PUT");
assert.deepEqual(JSON.parse(requests[1].options.body), appearance);
});
test("settings repository keeps backend settings in session memory", async () => {
const requests = [];
const repository = new CmapSettingsRepository(async (route, options = {}) => {
requests.push({ route, options });
if (route === "/api/cmap-settings") {
return {
startCmapSlug: "architecture",
pageGuidesVisible: false,
zooms: [{ cmapSlug: "architecture", contextKey: "root", zoomPercent: 125 }]
};
}
if (route.endsWith("/start")) return { startCmapSlug: "review" };
if (route.endsWith("/page-guides")) return { pageGuidesVisible: true };
return { zoomPercent: 150 };
});
await repository.load();
assert.equal(repository.startCmapSlug, "architecture");
assert.equal(repository.pageGuidesVisible, false);
assert.equal(repository.zoom("architecture", "root"), 125);
assert.equal(repository.zoom("architecture", "unknown"), 100);
assert.equal(await repository.setStartCmap("review"), "review");
assert.equal(await repository.setPageGuidesVisible(true), true);
assert.equal(await repository.setZoom("architecture", "root", 150), 150);
assert.equal(repository.zoom("architecture", "root"), 150);
assert.equal(requests.length, 4);
});
+17
View File
@@ -49,6 +49,23 @@ test("WikiWords use page and CMap catalogues without expanding protected text",
"[Homeopathie Wiki](#/homeopathie) en [Architectuur Kaart](#cmap/architectuur), maar `NieuwePagina` niet.");
});
test("an exclamation mark renders a WikiWord literally without becoming visible", () => {
const pages = [
{ slug: "homeopathie", namespace: "", pageSlug: "homeopathie", title: "Homeopathie Wiki" }
];
const conceptMaps = [{ slug: "architectuur", title: "Architectuur Kaart" }];
const markdown = "!HomeopathieWiki en !NieuwePagina en !cmap:ArchitectuurKaart.";
const expanded = expandWikiMentions(markdown, pages, [], conceptMaps);
assert.equal(expanded,
"HomeopathieWiki en NieuwePagina en cmap:ArchitectuurKaart.");
assert.equal(expandWikiMentions(expanded, pages, [], conceptMaps), expanded);
});
test("the WikiWord escape remains literal in protected Markdown", () => {
const markdown = "`!NieuwePagina` en [!NieuwePagina](https://example.com)";
assert.equal(expandWikiMentions(markdown, [], [], []), markdown);
});
test("ambiguous CMap mentions are not resolved", () => {
const conceptMaps = [
{ slug: "first", title: "Zelfde Kaart" },