201 lines
8.2 KiB
JavaScript
201 lines
8.2 KiB
JavaScript
"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/);
|
|
});
|