351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
Concept,
|
|
ConceptOwnership,
|
|
ConceptRelation,
|
|
ConceptRepository
|
|
} from "../static/js/cmap/model/concept-repository.js";
|
|
import {
|
|
CmapModel,
|
|
ConceptMap,
|
|
ConceptMapConcept,
|
|
ConceptMapConnector,
|
|
ConceptMapPhrase
|
|
} from "../static/js/cmap/model/concept-map.js";
|
|
import { buildBundle } from "../static/js/cmap/model/interchange.js";
|
|
import { CmapReferenceIndex } from "../static/js/cmap/model/cmap-reference-index.js";
|
|
|
|
const firstId = "11111111-1111-4111-8111-111111111111";
|
|
const secondId = "22222222-2222-4222-8222-222222222222";
|
|
const thirdId = "33333333-3333-4333-8333-333333333333";
|
|
|
|
function sampleDocument() {
|
|
return {
|
|
schemaVersion: 2,
|
|
metadata: {
|
|
namespace: "",
|
|
tags: ["architecture"],
|
|
summary: "System overview",
|
|
explanationPageSlug: "cmap:architecture"
|
|
},
|
|
concepts: [{
|
|
id: firstId,
|
|
label: "Application",
|
|
synopsis: "Runs the application",
|
|
aspects: ["runtime"],
|
|
tags: [{ type: "person", value: "Hans" }],
|
|
descriptionPageSlug: "cmap:application",
|
|
pageSlug: "application",
|
|
cmapSlug: null,
|
|
externalUrl: "https://example.com/application",
|
|
imageSource: ""
|
|
}],
|
|
items: [{
|
|
id: 1,
|
|
conceptId: firstId,
|
|
kind: "concept",
|
|
parentCmapLink: false,
|
|
groupId: null,
|
|
childMap: null,
|
|
parentSubmapId: null,
|
|
submapDepth: 0,
|
|
expanded: false,
|
|
submapInitialized: false,
|
|
separateMap: false,
|
|
mapReference: null,
|
|
hiddenContexts: [],
|
|
layouts: {},
|
|
backgroundColor: "#ffffff",
|
|
borderColor: "#333333",
|
|
submapBackgroundColor: null,
|
|
submapBorderColor: null,
|
|
textColor: "#111111",
|
|
fontFamily: "Arial",
|
|
fontSize: "11pt",
|
|
fontWeight: "700",
|
|
fontStyle: "normal",
|
|
synopsisTextColor: "#444444",
|
|
synopsisFontFamily: "Arial",
|
|
synopsisFontSize: "9pt",
|
|
synopsisFontWeight: "400",
|
|
synopsisFontStyle: "normal",
|
|
width: 220,
|
|
height: 70,
|
|
autoWidth: false,
|
|
autoHeight: false,
|
|
x: 80,
|
|
y: 90
|
|
}, {
|
|
id: 2,
|
|
conceptId: null,
|
|
kind: "phrase",
|
|
label: "uses",
|
|
synopsis: "",
|
|
parentCmapLink: false,
|
|
groupId: null,
|
|
childMap: null,
|
|
parentSubmapId: null,
|
|
submapDepth: 0,
|
|
expanded: false,
|
|
submapInitialized: false,
|
|
separateMap: false,
|
|
mapReference: null,
|
|
hiddenContexts: [],
|
|
layouts: {},
|
|
backgroundColor: "#ffffff",
|
|
borderColor: "transparent",
|
|
submapBackgroundColor: null,
|
|
submapBorderColor: null,
|
|
textColor: "#111111",
|
|
fontFamily: "Arial",
|
|
fontSize: "11pt",
|
|
fontWeight: "700",
|
|
fontStyle: "normal",
|
|
synopsisTextColor: "#444444",
|
|
synopsisFontFamily: "Arial",
|
|
synopsisFontSize: "9pt",
|
|
synopsisFontWeight: "400",
|
|
synopsisFontStyle: "normal",
|
|
width: 145,
|
|
height: 36,
|
|
autoWidth: true,
|
|
autoHeight: true,
|
|
x: 330,
|
|
y: 100
|
|
}],
|
|
connectors: [{
|
|
id: 1,
|
|
sourceId: 1,
|
|
targetId: 2,
|
|
hasArrow: false,
|
|
lineColor: "#333",
|
|
lineWidth: 2
|
|
}],
|
|
conceptMaps: []
|
|
};
|
|
}
|
|
|
|
test("CmapModel round-trips repository content, placements, phrases and connectors", () => {
|
|
const document = sampleDocument();
|
|
const model = CmapModel.fromDocument(document);
|
|
|
|
assert.ok(model.repository.concept(firstId) instanceof Concept);
|
|
assert.ok(model.conceptMap.item(1) instanceof ConceptMapConcept);
|
|
assert.ok(model.conceptMap.item(2) instanceof ConceptMapPhrase);
|
|
assert.ok(model.conceptMap.connector(1) instanceof ConceptMapConnector);
|
|
assert.deepEqual(model.toDocument(), document);
|
|
});
|
|
|
|
test("two placements share one repository concept without sharing appearance", () => {
|
|
const model = CmapModel.fromDocument(sampleDocument());
|
|
model.conceptMap.addItem(new ConceptMapConcept(3, firstId, {
|
|
x: 600, y: 300, width: 180, height: 60, backgroundColor: "#ffeeaa"
|
|
}));
|
|
model.repository.concept(firstId).update({ label: "Web application" });
|
|
|
|
assert.equal(model.repository.concept(firstId).label, "Web application");
|
|
assert.equal(model.conceptMap.item(1).backgroundColor, "#ffffff");
|
|
assert.equal(model.conceptMap.item(3).backgroundColor, "#ffeeaa");
|
|
assert.equal(model.toDocument().concepts.length, 1);
|
|
});
|
|
|
|
test("semantic relations and ownership remain independent of map connectors", () => {
|
|
const repository = new ConceptRepository([
|
|
{ id: firstId, label: "Parent" },
|
|
{ id: secondId, label: "Child" }
|
|
]);
|
|
repository.addRelation(new ConceptRelation("contains", firstId, secondId, {
|
|
label: "contains"
|
|
}));
|
|
repository.addOwnership(new ConceptOwnership(firstId, secondId));
|
|
const conceptMap = new ConceptMap();
|
|
conceptMap.addItem(new ConceptMapConcept(1, firstId, { x: 0, y: 0 }));
|
|
conceptMap.addItem(new ConceptMapConcept(2, secondId, {
|
|
parentSubmapId: 1, x: 50, y: 80
|
|
}));
|
|
conceptMap.addConnector(new ConceptMapConnector(1, 1, 2, {
|
|
relationId: "contains"
|
|
}));
|
|
const document = new CmapModel(repository, conceptMap).toDocument();
|
|
|
|
assert.equal(document.conceptRelations[0].id, "contains");
|
|
assert.equal(document.conceptOwnerships[0].parentConceptId, firstId);
|
|
assert.equal(document.items[1].parentSubmapId, 1);
|
|
assert.equal(document.connectors[0].relationId, "contains");
|
|
});
|
|
|
|
test("concept ownership rejects cycles", () => {
|
|
const repository = new ConceptRepository([
|
|
{ id: firstId, label: "First" },
|
|
{ id: secondId, label: "Second" }
|
|
]);
|
|
repository.addOwnership({ parentConceptId: firstId, childConceptId: secondId });
|
|
assert.throws(() => repository.addOwnership({
|
|
parentConceptId: secondId,
|
|
childConceptId: firstId
|
|
}), /cycle/);
|
|
});
|
|
|
|
test("an embedded submap becomes an independent map linked from its owner", () => {
|
|
const document = {
|
|
schemaVersion: 2,
|
|
metadata: { tags: [], summary: "Parent", explanationPageSlug: "" },
|
|
concepts: [
|
|
{ id: firstId, label: "Owner", synopsis: "Nested subject", aspects: ["design"] },
|
|
{ id: secondId, label: "Nested concept" },
|
|
{ id: thirdId, label: "Outside concept" }
|
|
],
|
|
conceptOwnerships: [
|
|
{ parentConceptId: firstId, childConceptId: secondId },
|
|
{ parentConceptId: firstId, childConceptId: thirdId }
|
|
],
|
|
items: [
|
|
{ id: 1, conceptId: firstId, kind: "submap", x: 100, y: 100, submapDepth: 0 },
|
|
{ id: 2, conceptId: secondId, kind: "concept", x: 240, y: 260,
|
|
parentSubmapId: 1, submapDepth: 1 },
|
|
{ id: 3, kind: "phrase", label: "contains", x: 450, y: 270,
|
|
parentSubmapId: 1, submapDepth: 1 },
|
|
{ id: 4, conceptId: thirdId, kind: "concept", x: 20, y: 100, submapDepth: 0 }
|
|
],
|
|
connectors: [
|
|
{ id: 1, sourceId: 2, targetId: 3 },
|
|
{ id: 2, sourceId: 4, targetId: 2 },
|
|
{ id: 3, sourceId: 1, targetId: 2 }
|
|
],
|
|
conceptMaps: []
|
|
};
|
|
|
|
const extraction = CmapModel.fromDocument(document)
|
|
.extractSubmap(1, "nested-subject");
|
|
const parentDocument = extraction.parentModel.toDocument();
|
|
const childDocument = extraction.childModel.toDocument();
|
|
|
|
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(parentDocument.connectors.map((connector) => ({
|
|
sourceId: connector.sourceId,
|
|
targetId: connector.targetId
|
|
})), [{ sourceId: 4, targetId: 1 }]);
|
|
assert.deepEqual(parentDocument.conceptOwnerships, [
|
|
{ parentConceptId: firstId, childConceptId: thirdId }
|
|
]);
|
|
|
|
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.childModel.metadata(), {
|
|
namespace: "",
|
|
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
|
|
});
|
|
});
|
|
|
|
test("a model document is accepted by the complete JSON bundle export", async () => {
|
|
const model = CmapModel.fromDocument(sampleDocument());
|
|
const bundle = await buildBundle({
|
|
rootMap: {
|
|
slug: "architecture",
|
|
title: "Architecture",
|
|
document: model.toDocument()
|
|
},
|
|
maxDepth: 0,
|
|
exportedAt: "2026-09-01T12:00:00.000Z",
|
|
loadConceptMap: async () => { throw new Error("not requested"); },
|
|
loadWikiPage: async (reference) => ({
|
|
slug: reference,
|
|
title: reference,
|
|
markdown: "Description",
|
|
tags: []
|
|
}),
|
|
loadAttachment: async () => { throw new Error("not requested"); }
|
|
});
|
|
|
|
assert.equal(bundle.rootCmapSlug, "architecture");
|
|
assert.deepEqual(bundle.cmaps[0].document.items, model.toDocument().items);
|
|
assert.equal(bundle.concepts[0].label, "Application");
|
|
});
|
|
|
|
test("CMap reference index stores lightweight destinations per concept", () => {
|
|
const index = new CmapReferenceIndex().load([
|
|
{ conceptId: firstId, label: "Application", cmapSlug: "architecture", count: 2 },
|
|
{ conceptId: firstId, label: "Application", cmapSlug: "operations", count: 1 }
|
|
], [
|
|
{ slug: "architecture", title: "Architecture" },
|
|
{ slug: "operations", title: "Operations" }
|
|
]);
|
|
|
|
assert.deepEqual(index.mapsFor(firstId), [
|
|
{ slug: "architecture", title: "Architecture", count: 2 },
|
|
{ slug: "operations", title: "Operations", count: 1 }
|
|
]);
|
|
assert.equal(index.countFor(firstId), 3);
|
|
assert.equal(index.conceptIdForName("Application"), firstId);
|
|
});
|
|
|
|
test("model finds boundary references from active submap crossings", () => {
|
|
const model = CmapModel.fromDocument({
|
|
concepts: [
|
|
{ id: firstId, label: "Submap" },
|
|
{ id: secondId, label: "Inside" },
|
|
{ id: thirdId, label: "Remote", cmapSlug: "remote-map" }
|
|
],
|
|
items: [
|
|
{ id: 10, kind: "submap", conceptId: firstId, x: 0, y: 0, width: 800, height: 500 },
|
|
{ id: 1, conceptId: secondId, parentSubmapId: 10, x: 100, y: 120, width: 200, height: 60 },
|
|
{ id: 2, conceptId: thirdId, x: 420, y: 120, width: 200, height: 60 }
|
|
],
|
|
connectors: [{ id: 1, sourceId: 1, targetId: 2 }],
|
|
conceptMaps: [{ id: "remote-map", title: "Remote map" }]
|
|
});
|
|
|
|
assert.deepEqual(model.boundaryReferencesFor(10), [{
|
|
connector: {
|
|
id: 1, sourceId: 1, targetId: 2, hasArrow: true,
|
|
lineColor: "#333", lineWidth: 2
|
|
},
|
|
inside: { id: 1, x: 100, y: 120, width: 200, height: 60 },
|
|
outside: { id: 2, x: 420, y: 120, width: 200, height: 60 },
|
|
sourceInside: true,
|
|
conceptId: thirdId,
|
|
label: "Remote",
|
|
linkingPhraseLabel: null,
|
|
targetMaps: []
|
|
}]);
|
|
});
|
|
|
|
test("model anchors boundary references on the connected concept beyond a phrase", () => {
|
|
const model = CmapModel.fromDocument({
|
|
concepts: [
|
|
{ id: firstId, label: "Submap" },
|
|
{ id: secondId, label: "Inside" },
|
|
{ id: thirdId, label: "Remote", cmapSlug: "remote-map" }
|
|
],
|
|
items: [
|
|
{ id: 10, kind: "submap", conceptId: firstId, x: 0, y: 0, width: 800, height: 500 },
|
|
{ id: 1, conceptId: secondId, parentSubmapId: 10, x: 100, y: 120, width: 200, height: 60 },
|
|
{ id: 2, kind: "phrase", label: "relates", parentSubmapId: 10, x: 330, y: 120, width: 80, height: 30 },
|
|
{ id: 3, conceptId: thirdId, x: 480, y: 120, width: 200, height: 60 }
|
|
],
|
|
connectors: [
|
|
{ id: 1, sourceId: 1, targetId: 2 },
|
|
{ id: 2, sourceId: 2, targetId: 3 }
|
|
],
|
|
conceptMaps: [{ id: "remote-map", title: "Remote map" }]
|
|
});
|
|
|
|
assert.equal(model.boundaryReferencesFor(10)[0].inside.id, 1);
|
|
});
|