Files
racket-wiki/test/cmap-submap-navigation.test.js
T

257 lines
8.1 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
let editorFactory;
function fakeNode(attributes) {
const state = { ...attributes, visible: true };
return {
attr(value) {
if (typeof value === "string") return state[value];
Object.assign(state, value);
return this;
},
onRendered() {}, onMove() {}, onMoveEnd() {},
visible(value) {
if (value === undefined) return state.visible;
state.visible = Boolean(value);
return state.visible;
},
redraw() {},
toFront() {}, remove() {}, element: () => null
};
}
async function loadEditorFactory() {
if (editorFactory) return editorFactory;
global.Element = class Element {
constructor() {
this.tagName = "DIV";
this.id = "";
this.classList = [];
this.dataset = {};
}
closest() { return null; }
};
global.document = {
currentScript: null,
styleSheets: [],
body: { append() {} },
createElement: () => ({ style: {}, remove() {} })
};
global.window = {
setTimeout,
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
await import("../static/js/cmap/cmap-racket-wiki.js");
editorFactory = global.window.RacketWikiCmap;
return editorFactory;
}
test("back from a second nested submap returns exactly one map level", async () => {
const factory = await 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, { createDiagramEngine: () => 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);
});
test("single-click selects and double-click edits every kind of concept", async () => {
const factory = await 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 edited = [];
const openedPages = [];
const openedMaps = [];
const editor = factory.createEditor(canvas, {
createDiagramEngine: () => map,
onEditItem: (record) => edited.push(record),
onOpenPage: (record) => openedPages.push(record),
onOpenCmap: (record) => openedMaps.push(record)
});
const ordinary = editor.addItem({ id: 1, label: "Ordinary" });
const page = editor.addItem({ id: 2, label: "Page", pageSlug: "page" });
const linkedMap = editor.addItem({ id: 3, label: "Linked map", cmapSlug: "other" });
const submap = editor.addItem({ id: 4, kind: "submap", label: "Group" });
editor.handleMapSelection(linkedMap.node, {
target: new global.Element(),
ctrlKey: false,
metaKey: false,
shiftKey: false
});
assert.equal(editor.selected(), linkedMap);
assert.deepEqual(openedMaps, []);
editor.handleMapActivation(ordinary.node, { preventDefault() {} });
editor.handleMapActivation(page.node, { preventDefault() {} });
editor.handleMapActivation(linkedMap.node, { preventDefault() {} });
editor.handleMapActivation(submap.node, { preventDefault() {} });
assert.deepEqual(edited, [ordinary, page, linkedMap, submap]);
assert.deepEqual(openedPages, []);
assert.deepEqual(openedMaps, []);
});
test("a linked CMap opens only through its arrow decorator", async () => {
await loadEditorFactory();
const { CmapItemDecorator } = await import(
"../static/js/cmap/view/cmap-item-decorator.js");
const listeners = new Map();
const linkedButton = {
dataset: {},
addEventListener(type, listener) { listeners.set(type, listener); }
};
const elementListeners = new Map();
const element = {
dataset: {},
style: {},
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {},
addEventListener(type, listener) { elementListeners.set(type, listener); },
querySelector(selector) {
return selector === ".rw-cmap-open-linked" ? linkedButton : null;
}
};
const openedMaps = [];
const editor = {
labels: { relation: "Relation" },
selectedItems: new Set(),
selectedItem: null,
activeMapRoot: null,
onOpenCmap: (record) => openedMaps.push(record),
ensureCanvasExtent() {}
};
const record = {
id: 1,
kind: "concept",
label: "Linked map",
cmapSlug: "other",
node: {
attr(name) {
return { x: 10, y: 20, width: 120, height: 50 }[name];
}
}
};
const decorator = new CmapItemDecorator(editor);
decorator.fitItemToContent = () => {};
decorator.decorateItem(record, element);
assert.equal(elementListeners.has("click"), false,
"the complete concept must not become a navigation target");
listeners.get("click")({ preventDefault() {}, stopPropagation() {} });
assert.deepEqual(openedMaps, [record]);
});
test("collapsed submap descendants stay hidden after the first render frame", async () => {
const factory = await loadEditorFactory();
const renderFrames = [];
global.window.requestAnimationFrame = (callback) => renderFrames.push(callback);
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, { createDiagramEngine: () => map });
const submap = editor.addItem({ id: 1, kind: "submap", label: "Collapsed" });
submap.expanded = false;
const child = editor.addItem({ id: 2, label: "Hidden child", parentSubmap: submap });
editor.applyCurrentContextLayout();
assert.equal(child.node.visible(), false);
child.node.visible(true);
for (const renderFrame of renderFrames.splice(0)) renderFrame();
assert.equal(child.node.visible(), false,
"the post-render visibility pass hides a descendant exposed by initial rendering");
});
test("loading an unresolved connector retains it without a runtime error", async () => {
const factory = await loadEditorFactory();
global.window.requestAnimationFrame = (callback) => callback();
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, { createDiagramEngine: () => map });
const warnings = [];
const originalWarn = console.warn;
console.warn = (...values) => warnings.push(values);
try {
editor.loadDocument({
schemaVersion: 2,
concepts: [{ id: "concept-one", label: "One" }],
items: [{
id: 1, conceptId: "concept-one", kind: "concept",
x: 20, y: 20, width: 120, height: 50
}],
connectors: [{
id: 1, sourceId: 1, targetId: 999,
hasArrow: true, lineColor: "#333", lineWidth: 2
}]
});
} finally {
console.warn = originalWarn;
}
assert.equal(editor.unresolvedConnectors.length, 1);
assert.match(warnings[0][0], /relation retained without rendering/);
});