added mermaid and a lot of cmap changes

This commit is contained in:
2026-08-27 13:13:39 +02:00
parent 20c1584016
commit 2215d1d04a
35 changed files with 6442 additions and 350 deletions
+175
View File
@@ -0,0 +1,175 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
function fakeElement() {
return {
className: "rw-cmap-item",
style: {},
dataset: {},
querySelector: () => null,
querySelectorAll: () => [],
remove() {},
addEventListener() {},
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {},
removeAttribute() {}
};
}
function fakeNode(attributes) {
const state = { ...attributes };
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
onRendered() {},
onMove() {},
onMoveEnd() {},
visible() {},
redraw() {},
toFront() {},
remove() {},
element: () => null
};
}
function fakeLink(attributes) {
const state = { ...attributes };
let source = null;
let target = null;
let connectionChangeHandler = null;
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
sourceNode(value) {
if (arguments.length === 0) return source;
source = value;
return this;
},
targetNode(value) {
if (arguments.length === 0) return target;
target = value;
return this;
},
onConnectionChange(handler) { connectionChangeHandler = handler; },
triggerConnectionChange(type, node) {
if (type === "source") source = node;
if (type === "target") target = node;
connectionChangeHandler(this, type, node);
},
onRendered() {},
visible() {},
straighten() {},
draggable() {},
redraw() {},
remove() {},
element: () => null
};
}
function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
styleSheets: [],
body: { append() {} },
createElement: () => fakeElement()
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("a dragged connector endpoint survives collapsing and expanding its submap", () => {
const factory = loadEditorFactory();
const links = [];
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes),
link(attributes) {
const link = fakeLink(attributes);
links.push(link);
return link;
}
};
const canvas = {
addEventListener() {},
querySelector: () => null,
clientWidth: 1200,
clientHeight: 800,
scrollLeft: 0,
scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const submap = editor.addItem({
id: 1,
kind: "submap",
label: "Problemen en Symptomen LTS Model",
x: 300,
y: 200,
width: 320,
height: 70
});
submap.expanded = true;
submap.submapInitialized = true;
const problems = editor.addItem({
id: 2,
kind: "phrase",
label: "problemen",
parentSubmap: submap,
submapDepth: 1,
x: 400,
y: 300
});
const symptoms = editor.addItem({
id: 3,
kind: "phrase",
label: "Symptomen",
parentSubmap: submap,
submapDepth: 1,
x: 400,
y: 500
});
const concept = editor.addItem({
id: 4,
kind: "concept",
label: "Rob maakt veel fouten",
parentSubmap: submap,
submapDepth: 1,
x: 800,
y: 400
});
const connector = editor.addConnector(problems, concept, true, { id: 1 });
links[0].triggerConnectionChange("source", symptoms.node);
assert.equal(connector.source, symptoms,
"the editor model follows the endpoint dragged by the drawing library");
editor.toggleSubmap(submap, false);
editor.toggleSubmap(submap, true);
assert.equal(connector.source, symptoms);
assert.equal(connector.link.sourceNode(), symptoms.node);
assert.deepEqual(editor.toDocument().connectors, [{
id: 1,
sourceId: symptoms.id,
targetId: concept.id,
hasArrow: true,
lineColor: "#333",
lineWidth: 2
}]);
});
+131
View File
@@ -0,0 +1,131 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
function fakeElement({ width = 320, height = 120 } = {}) {
return {
className: "rw-cmap-item",
style: {},
dataset: {},
firstElementChild: { style: {} },
innerHTML: "",
querySelector: () => null,
querySelectorAll: () => [],
getBoundingClientRect: () => ({ width, height }),
remove() {},
addEventListener() {},
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {},
removeAttribute() {}
};
}
function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
styleSheets: [],
body: { append() {} },
createElement: () => fakeElement()
};
global.window = { Cmap: null };
require("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("automatic render sizing advances only an unchanged saved baseline", () => {
const factory = loadEditorFactory();
const canvas = {
addEventListener() {},
querySelector: () => null,
clientWidth: 1200,
clientHeight: 800,
scrollLeft: 0,
scrollTop: 0
};
const map = { onSelection() {}, onActivation() {} };
let savedBaseline = null;
let notification = null;
const editor = factory.createEditor(canvas, {
Cmap: () => map,
onAutomaticLayoutChange: (change) => {
notification = change;
if (savedBaseline === change.beforeSnapshot) {
savedBaseline = change.afterSnapshot;
}
}
});
const attributes = { x: 10, y: 20, width: 220, height: 70 };
const node = {
attr(value) {
if (typeof value === "string") return attributes[value];
Object.assign(attributes, value);
return this;
},
redraw() {},
element: () => null
};
const record = {
id: 1,
conceptId: "global-concept",
kind: "concept",
label: "Concept",
synopsis: "Long content",
aspects: [],
tags: [],
descriptionPageSlug: null,
pageSlug: null,
cmapSlug: null,
imageSource: "",
parentCmapLink: false,
groupId: null,
childMap: null,
parentSubmap: null,
submapDepth: 0,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
hiddenContexts: new Set(),
layouts: {},
backgroundColor: "#fff",
borderColor: "#000",
submapBackgroundColor: "#fff",
submapBorderColor: "#000",
textColor: "#222",
fontFamily: "Arial",
fontSize: "11pt",
fontWeight: "normal",
fontStyle: "normal",
synopsisTextColor: "#222",
synopsisFontFamily: "Arial",
synopsisFontSize: "11pt",
synopsisFontWeight: "normal",
synopsisFontStyle: "normal",
width: 220,
height: 70,
autoWidth: false,
autoHeight: false,
fitContentPending: false,
node
};
editor.items.push(record);
savedBaseline = editor.historySnapshot();
editor.fitItemToContent(record, fakeElement());
assert.ok(notification, "automatic sizing reports its exact before and after snapshots");
assert.notEqual(notification.beforeSnapshot, notification.afterSnapshot);
assert.equal(savedBaseline, notification.afterSnapshot,
"an untouched baseline follows renderer-only normalization");
const editedBaseline = "user-edited-state";
savedBaseline = editedBaseline;
record.height = 70;
attributes.height = 70;
editor.fitItemToContent(record, fakeElement({ height: 140 }));
assert.equal(savedBaseline, editedBaseline,
"automatic sizing never hides a different user-edited baseline");
});
+145
View File
@@ -0,0 +1,145 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { generateMarkdown, derivedDocument, relationLines } = require("../static/js/cmap-export.js");
function map(slug, title, document) {
return { slug, title, document };
}
const root = map("root", "Root map", {
schemaVersion: 2,
metadata: {
tags: ["architecture", "report"],
summary: "Summary of the root map.",
explanationPageSlug: "cmap:root-explanation"
},
concepts: [
{
id: "concept-a", label: "Decision", synopsis: "Choose the approach.",
aspects: ["governance"], pageSlug: "decisions:approach",
descriptionPageSlug: "cmap:decision",
cmapSlug: "child",
externalUrl: "https://example.com/decision",
tags: [{ type: "person", value: "Alex Morgan" }, { type: "person", value: "Sam de Vries" }]
},
{ id: "concept-b", label: "Delivery" }
],
items: [
{ id: 1, conceptId: "concept-a", kind: "concept" },
{ id: 2, kind: "phrase", label: "enables" },
{ id: 3, conceptId: "concept-b", kind: "concept" }
],
connectors: [
{ id: 1, sourceId: 1, targetId: 2, hasArrow: false },
{ id: 2, sourceId: 2, targetId: 3, hasArrow: true }
]
});
const child = map("child", "Child map", {
schemaVersion: 2,
metadata: { tags: ["detail"], summary: "Child summary." },
concepts: [{ id: "concept-c", label: "Child concept", cmapSlug: "root" }],
items: [{ id: 1, conceptId: "concept-c", kind: "concept" }],
connectors: []
});
const pages = new Map([
["cmap:root-explanation", { slug: "cmap:root-explanation", title: "Root explanation", tags: ["explanation"], markdown: "# Why\n\nBecause." }],
["decisions:approach", { slug: "decisions:approach", title: "Approach", tags: ["decision"], markdown: "# Decision\n\nUse the simple option." }],
["cmap:decision", { slug: "cmap:decision", title: "Decision explanation", tags: [], markdown: "Details." }]
]);
test("exports metadata, person tags, relations and linked maps to the selected depth", async () => {
const markdown = await generateMarkdown({
rootMap: root,
maxDepth: 1,
includeWikiPages: false,
language: "nl",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
});
assert.match(markdown, /CMap: Root map \(niveau 0\)/);
assert.match(markdown, /CMap: Child map \(niveau 1\)/);
assert.match(markdown, /`architecture`, `report`/);
assert.match(markdown, /Personen \(verantwoordelijkheid\/actie\):\*\* Alex Morgan, Sam de Vries/);
assert.match(markdown, /Decision — \*\*enables\*\* → Delivery/);
assert.match(markdown, /### CMap-uitleg/);
assert.match(markdown, /Webpagina:\*\* https:\/\/example\.com\/decision/);
assert.doesNotMatch(markdown, /## Gekoppelde wikipagina's/);
assert.equal((markdown.match(/## CMap: Root map/g) || []).length, 1, "cycles are exported once");
});
test("depth zero exports only the selected map", async () => {
const markdown = await generateMarkdown({
rootMap: root,
maxDepth: 0,
includeWikiPages: false,
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
});
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,
maxDepth: 0,
includeWikiPages: true,
language: "en",
loadConceptMap: async (slug) => ({ root, child }[slug]),
loadWikiPage: async (slug) => pages.get(slug)
});
assert.match(markdown, /## Linked wiki pages/);
assert.match(markdown, /### Approach/);
assert.match(markdown, /### Decision explanation/);
assert.equal((markdown.match(/Root explanation/g) || []).length, 0,
"the explanation page is embedded under the CMap rather than duplicated as an appendix heading");
assert.match(markdown, /Because\./);
});
test("derived views contain their root subtree and inherit useful root metadata", () => {
const source = {
schemaVersion: 2,
concepts: [
{ id: "root-concept", label: "Team", synopsis: "Team scope", aspects: ["people"], descriptionPageSlug: "cmap:team" },
{ id: "child-concept", label: "Work" },
{ id: "outside-concept", label: "Outside" }
],
items: [
{ id: 10, conceptId: "root-concept", kind: "submap" },
{ id: 11, conceptId: "child-concept", kind: "concept", parentSubmapId: 10 },
{ id: 12, conceptId: "outside-concept", kind: "concept" }
],
connectors: [
{ id: 1, sourceId: 10, targetId: 11 },
{ id: 2, sourceId: 11, targetId: 12 }
]
};
const result = derivedDocument(source, {
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");
});
test("direct and phrase relations receive readable Markdown", () => {
const documentValue = {
items: [
{ id: 1, label: "A" }, { id: 2, kind: "phrase", label: "supports" },
{ id: 3, label: "B" }, { id: 4, label: "C" }
],
connectors: [
{ sourceId: 1, targetId: 2, hasArrow: false },
{ sourceId: 2, targetId: 3, hasArrow: true },
{ sourceId: 3, targetId: 4, hasArrow: true }
]
};
assert.deepEqual(relationLines(documentValue), ["A — **supports** → B", "B → C"]);
});
+200
View File
@@ -0,0 +1,200 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildBundle, validateBundle, preparedMapDocument, attachmentUrls, replaceAttachmentUrls
} = require("../static/js/cmap-interchange.js");
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
const childId = "53ef1cf3-c936-48f7-889a-ad644fab9321";
const styledItem = {
id: 10, conceptId, kind: "concept", x: 127.5, y: 88, width: 312,
height: 97, backgroundColor: "#ffe5a8", borderColor: "#a97c00",
textColor: "#172033", fontFamily: "Georgia", fontSize: 13.5,
fontWeight: "700", synopsisFontSize: 9.5
};
const root = {
slug: "architecture",
title: "Architecture",
document: {
schemaVersion: 2,
metadata: { explanationPageSlug: "cmap:architecture" },
concepts: [{
id: conceptId, label: "Process", synopsis: "Shared content",
aspects: ["TODO"], pageSlug: "process:details", cmapSlug: "child",
externalUrl: "https://example.com/process"
}],
items: [styledItem, { id: 11, kind: "phrase", label: "contains", x: 500, y: 90 }],
connectors: [{ id: 1, sourceId: 10, targetId: 11, hasArrow: true }]
}
};
const child = {
slug: "child", title: "Child", document: {
schemaVersion: 2,
concepts: [{ id: childId, label: "Child concept" }],
items: [{ id: 1, conceptId: childId, kind: "concept", x: 20, y: 30 }],
connectors: []
}
};
const pages = new Map([
["cmap:architecture", { slug: "cmap:architecture", title: "Explanation", markdown: "# Explanation", tags: ["cmap"] }],
["process:details", {
slug: "process:details", title: "Details",
markdown: "Full page\n\n![Diagram](/uploads/process:details/1700000-diagram.png)", tags: []
}]
]);
const exportedAttachment = {
mimeType: "image/png",
contentBase64: "aW1hZ2U="
};
const loadAttachment = async (url) => {
assert.equal(url, "/uploads/process:details/1700000-diagram.png");
return exportedAttachment;
};
test("bundle separates shared content while preserving complete diagram layout", async () => {
const bundle = await buildBundle({
rootMap: root,
maxDepth: 1,
exportedAt: "2026-08-25T12:00:00.000Z",
generator: "test",
loadConceptMap: async (slug) => ({ child }[slug]),
loadWikiPage: async (reference) => pages.get(reference),
loadAttachment
});
assert.equal(bundle.format, "racket-wiki-cmap-bundle");
assert.deepEqual(bundle.cmaps.map((cmap) => cmap.slug), ["architecture", "child"]);
assert.deepEqual(bundle.pages.map((page) => page.reference), ["cmap:architecture", "process:details"]);
assert.equal(bundle.concepts.find((concept) => concept.id === conceptId).synopsis, "Shared content");
assert.equal(bundle.concepts.find((concept) => concept.id === conceptId).externalUrl,
"https://example.com/process");
const exportedItem = bundle.cmaps[0].document.items[0];
assert.equal(exportedItem.x, 127.5);
assert.equal(exportedItem.fontSize, 13.5);
assert.equal(exportedItem.backgroundColor, "#ffe5a8");
assert.equal(exportedItem.synopsis, undefined, "shared content is not duplicated on a placement");
assert.deepEqual(bundle.cmaps[0].document.connectors, root.document.connectors);
assert.deepEqual(bundle.pages.find((page) => page.reference === "process:details").attachments, [{
url: "/uploads/process:details/1700000-diagram.png",
name: "1700000-diagram.png",
mimeType: "image/png",
contentBase64: "aW1hZ2U="
}]);
});
test("prepared import document rejoins concepts and placements without changing layout", async () => {
const bundle = await buildBundle({
rootMap: root, maxDepth: 0,
loadConceptMap: async () => { throw new Error("not loaded"); },
loadWikiPage: async (reference) => pages.get(reference),
loadAttachment
});
const documentValue = preparedMapDocument(bundle, bundle.cmaps[0]);
assert.equal(documentValue.concepts[0].synopsis, "Shared content");
assert.equal(documentValue.concepts[0].externalUrl, "https://example.com/process");
assert.equal(documentValue.items[0].width, 312);
assert.equal(documentValue.items[0].fontFamily, "Georgia");
assert.deepEqual(documentValue.connectors, root.document.connectors);
});
test("a derived CMap always carries its source map as a layout dependency", async () => {
const derived = {
slug: "derived", title: "Derived", document: {
schemaVersion: 2,
derivedView: { sourceCmapSlug: "architecture", rootItemId: 10 },
concepts: [], items: [], connectors: []
}
};
const bundle = await buildBundle({
rootMap: derived, maxDepth: 0,
loadConceptMap: async (slug) => {
assert.equal(slug, "architecture");
return root;
},
loadWikiPage: async (reference) => pages.get(reference),
loadAttachment
});
assert.deepEqual(bundle.cmaps.map((cmap) => cmap.slug), ["derived", "architecture"]);
assert.equal(bundle.rootCmapSlug, "derived");
assert.equal(bundle.cmaps[1].document.items[0].x, 127.5);
});
test("temporary ids are accepted only when their concepts are placed", () => {
const bundle = {
format: "racket-wiki-cmap-bundle", formatVersion: 1,
rootCmapSlug: "generated", pages: [], missing: { cmaps: [], pages: [] },
concepts: [{ id: "new:generated-concept", label: "Generated concept" }],
cmaps: [{ slug: "generated", title: "Generated", document: {
concepts: [{ id: "new:generated-concept" }],
items: [{ id: 1, conceptId: "new:generated-concept", kind: "concept", x: 10, y: 20 }],
connectors: []
}}]
};
assert.equal(validateBundle(bundle), bundle);
const orphan = structuredClone(bundle);
orphan.cmaps[0].document.items = [];
assert.throws(() => validateBundle(orphan), /must occur as a diagram placement/);
});
test("invalid connector endpoints and duplicate slugs are rejected", () => {
const bundle = {
format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map",
concepts: [{ id: conceptId, label: "Concept" }], pages: [],
cmaps: [
{ slug: "map", title: "Map", document: { concepts: [{ id: conceptId }], items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: [{ sourceId: 1, targetId: 99 }] } },
{ slug: "map", title: "Duplicate", document: { concepts: [], items: [], connectors: [] } }
]
};
assert.throws(() => validateBundle(bundle), /unknown item[\s\S]*duplicated/);
});
test("external concept links only accept complete http or https URLs", () => {
const bundle = {
format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map",
concepts: [{ id: conceptId, label: "Concept", externalUrl: "javascript:alert(1)" }],
pages: [],
cmaps: [{ slug: "map", title: "Map", document: {
concepts: [{ id: conceptId }],
items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: []
}}]
};
assert.throws(() => validateBundle(bundle), /externalUrl: must be a complete http or https URL/);
bundle.concepts[0].externalUrl = "https://example.com/path";
assert.equal(validateBundle(bundle), bundle);
});
test("attachment URLs are detected once and can be rewritten after upload", () => {
const oldUrl = "/uploads/process:details/1700000-diagram.png";
const markdown = `![Diagram](${oldUrl})\n<img src="${oldUrl}">`;
assert.deepEqual(attachmentUrls(markdown), [oldUrl]);
assert.equal(
replaceAttachmentUrls(markdown, new Map([[oldUrl, "/uploads/process:details/new-diagram.png"]])),
"![Diagram](/uploads/process:details/new-diagram.png)\n" +
"<img src=\"/uploads/process:details/new-diagram.png\">"
);
assert.deepEqual(
attachmentUrls("![Diagram](/uploads/page/1700000-system overview.png)"),
["/uploads/page/1700000-system overview.png"]
);
});
test("invalid embedded attachment content is rejected", () => {
const bundle = {
format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map",
concepts: [{ id: conceptId, label: "Concept" }],
cmaps: [{ slug: "map", title: "Map", document: {
concepts: [{ id: conceptId }],
items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: []
}}],
pages: [{
reference: "page", title: "Page", markdown: "", tags: [],
attachments: [{
url: "/uploads/page/file.png", name: "file.png",
mimeType: "image/png", contentBase64: "not base64!"
}]
}]
};
assert.throws(() => validateBundle(bundle), /contentBase64: must be valid base64/);
});
+120
View File
@@ -0,0 +1,120 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
class FakeEventTarget {
constructor() {
this.listeners = new Map();
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
this.listeners.set(type, (this.listeners.get(type) || [])
.filter((candidate) => candidate !== listener));
}
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) || []) listener(event);
}
}
class FakeElement extends FakeEventTarget {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.style = {};
this.attributes = {};
this.childNodes = [];
this.parentNode = null;
this.scrollLeft = 0;
this.scrollTop = 0;
this.scrollWidth = 1000;
this.scrollHeight = 800;
this.clientWidth = 1000;
this.clientHeight = 800;
this.textContent = "";
}
appendChild(child) {
if (child.parentNode) child.parentNode.removeChild(child);
child.parentNode = this;
this.childNodes.push(child);
return child;
}
removeChild(child) {
const index = this.childNodes.indexOf(child);
if (index >= 0) this.childNodes.splice(index, 1);
child.parentNode = null;
return child;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getBoundingClientRect() {
return { left: 0, top: 0, right: this.clientWidth, bottom: this.clientHeight };
}
set innerHTML(markup) {
this.childNodes = [];
if (!String(markup).includes("<svg>")) return;
const svg = new FakeElement("svg");
svg.appendChild(new FakeElement("path"));
svg.appendChild(new FakeElement("path"));
this.appendChild(svg);
this.appendChild(new FakeElement("div"));
}
}
function settleRendering() {
return new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
}
test("relations render and hit-test behind concept nodes", async () => {
const root = new FakeElement("div");
const fakeDocument = new FakeEventTarget();
fakeDocument.body = root;
fakeDocument.createElement = (tagName) => new FakeElement(tagName);
global.document = fakeDocument;
global.window = {
requestAnimationFrame: (callback) => setImmediate(callback)
};
const Cmap = require("../static/cmap/cmap.js");
const map = Cmap(root);
const concept = map.node({ x: 40, y: 0, width: 120, height: 50 });
const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 });
const relation = map.link({
sourceX: 0, sourceY: 25,
cx: 150, cy: 25,
targetX: 360, targetY: 25
});
await settleRendering();
relation.toFront();
await settleRendering();
assert.ok(Number(relation.element().style.zIndex) < Number(concept.element().style.zIndex));
assert.ok(Number(relation.element().style.zIndex) < Number(otherConcept.element().style.zIndex));
let selected = null;
map.onSelection((component) => { selected = component; });
const surface = root.childNodes[0];
surface.dispatchEvent({
type: "mousedown",
pageX: 100,
pageY: 25,
clientX: 100,
clientY: 25,
preventDefault() {}
});
fakeDocument.dispatchEvent({ type: "mouseup", pageX: 100, pageY: 25 });
assert.equal(selected, concept, "the concept wins when a relation crosses behind it");
});
+148
View File
@@ -0,0 +1,148 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
function fakeNode(attributes) {
const state = { ...attributes };
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {},
toFront() {}, remove() {}, element: () => null
};
}
function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
styleSheets: [],
body: { append() {} },
createElement: () => ({ style: {}, remove() {} })
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("the last selected concept determines target size and alignment", async () => {
const factory = loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes)
};
const canvas = {
addEventListener() {}, querySelector: () => null,
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const first = editor.addItem({
id: 1, label: "First", externalUrl: "https://example.com/first",
x: 10, y: 10, width: 100, height: 40
});
const second = editor.addItem({ id: 2, label: "Second", x: 80, y: 100, width: 120, height: 45 });
const third = editor.addItem({ id: 3, label: "Third", x: 250, y: 250, width: 200, height: 60 });
const savedDocument = editor.toDocument();
assert.equal(
savedDocument.concepts.find((concept) => concept.id === first.conceptId).externalUrl,
"https://example.com/first",
"external links are serialized as shared concept content"
);
const reloaded = factory.createEditor(canvas, { Cmap: () => map });
reloaded.loadDocument(savedDocument);
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"
);
editor.selectItem(first);
editor.selectItem(third, { additive: true });
editor.selectItem(second, { additive: true });
editor.resetHistory();
assert.equal(editor.selected(), second, "the last selected concept is the target object");
assert.equal(editor.canLayoutSelection("distribute-horizontal"), true);
assert.equal(editor.canLayoutSelection("distribute-vertical"), true);
editor.applySelectionLayout("align-right");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))),
[100, 80, 0]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("align-center");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))),
[90, 80, 40]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("align-left");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))),
[80, 80, 80]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("align-bottom");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))),
[105, 100, 85]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("align-middle");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))),
[102.5, 100, 92.5]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("align-top");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))),
[100, 100, 100]);
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("distribute-horizontal");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))),
[10, 120, 250], "outer items stay fixed and horizontal gaps become equal");
first.node.attr({ x: 10, y: 10 });
second.node.attr({ x: 80, y: 100 });
third.node.attr({ x: 250, y: 250 });
editor.applySelectionLayout("distribute-vertical");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))),
[10, 127.5, 250], "outer items stay fixed and the middle item receives an equal gap");
editor.applySelectionLayout("same-width");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("width"))),
[120, 120, 120]);
editor.applySelectionLayout("same-height");
assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("height"))),
[45, 45, 45]);
first.node.attr({ width: 90, height: 35 });
third.node.attr({ width: 180, height: 70 });
editor.applySelectionLayout("same-size");
assert.deepEqual([first, second, third].map((item) =>
[Number(item.node.attr("width")), Number(item.node.attr("height"))]),
[[120, 45], [120, 45], [120, 45]]);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(editor.canUndo(), true, "layout commands participate in undo and autosave history");
assert.deepEqual(editor.toDocument().items.map((item) => [item.width, item.height]),
[[120, 45], [120, 45], [120, 45]]);
});
+74
View File
@@ -0,0 +1,74 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
function fakeNode(attributes) {
const state = { ...attributes };
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {},
toFront() {}, remove() {}, element: () => null
};
}
function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
styleSheets: [],
body: { append() {} },
createElement: () => ({ style: {}, remove() {} })
};
global.window = {
Cmap: null,
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("back from a second nested submap returns exactly one map level", () => {
const factory = loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
node: (attributes) => fakeNode(attributes)
};
const canvas = {
addEventListener() {}, querySelector: () => null,
clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0,
isConnected: true
};
const editor = factory.createEditor(canvas, { Cmap: () => map });
const first = editor.addItem({
id: 1, kind: "submap", label: "First level", separateMap: true,
mapReference: { id: "first", title: "First level" },
x: 100, y: 100, width: 220, height: 70
});
first.submapInitialized = true;
const second = editor.addItem({
id: 2, kind: "submap", label: "Second level", separateMap: true,
mapReference: { id: "second", title: "Second level" },
parentSubmap: first, submapDepth: 1,
x: 400, y: 200, width: 220, height: 70
});
second.submapInitialized = true;
editor.openSubmapMap(first);
editor.openSubmapMap(second);
assert.equal(editor.activeMapRoot, second);
assert.equal(editor.canStepBackWithinMap(), true,
"the host must use local map history before navigating to a source CMap route");
editor.openParentMap();
assert.equal(editor.activeMapRoot, first);
assert.equal(editor.canStepBackWithinMap(), false);
});
+68
View File
@@ -0,0 +1,68 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const integration = require("../static/js/mermaid-racket-wiki.js");
function fixture(sourceText) {
let replacement = null;
const document = {
createElement() {
return {
attributes: {},
className: "",
innerHTML: "",
setAttribute(name, value) { this.attributes[name] = value; }
};
}
};
const source = {
classList: { values: [], add(value) { this.values.push(value); } },
dataset: {},
ownerDocument: document,
replaceWith(value) { replacement = value; },
title: ""
};
const code = { parentElement: source, textContent: sourceText };
const container = { querySelectorAll: () => [code] };
return { container, replacement: () => replacement, source };
}
test("initializes Mermaid with automatic rendering disabled and strict security", () => {
let config = null;
integration.initialize({ initialize(value) { config = value; } });
assert.deepEqual(config, {
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true
});
});
test("replaces a Mermaid code block with the rendered SVG", async () => {
const value = fixture("flowchart LR\n A --> B");
const mermaid = {
initialize() {},
async render(id, source) {
assert.match(id, /^racket-wiki-mermaid-/);
assert.equal(source, "flowchart LR\n A --> B");
return { svg: "<svg>diagram</svg>" };
}
};
await integration.hydrate(value.container, mermaid);
assert.equal(value.replacement().className, "rw-mermaid");
assert.equal(value.replacement().innerHTML, "<svg>diagram</svg>");
assert.equal(value.replacement().attributes.role, "img");
});
test("keeps invalid Mermaid source visible and marks it as an error", async () => {
const value = fixture("not a diagram");
const mermaid = {
initialize() {},
async render() { throw new Error("Parse error"); }
};
await integration.hydrate(value.container, mermaid);
assert.equal(value.replacement(), null);
assert.equal(value.source.dataset.mermaidState, "error");
assert.deepEqual(value.source.classList.values, ["rw-mermaid-error"]);
assert.equal(value.source.title, "Parse error");
});