added mermaid and a lot of cmap changes
This commit is contained in:
@@ -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
|
||||
}]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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", 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 = `\n<img src="${oldUrl}">`;
|
||||
assert.deepEqual(attachmentUrls(markdown), [oldUrl]);
|
||||
assert.equal(
|
||||
replaceAttachmentUrls(markdown, new Map([[oldUrl, "/uploads/process:details/new-diagram.png"]])),
|
||||
"\n" +
|
||||
"<img src=\"/uploads/process:details/new-diagram.png\">"
|
||||
);
|
||||
assert.deepEqual(
|
||||
attachmentUrls(""),
|
||||
["/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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user