mirror of
https://github.com/nejimakibird/model-weave.git
synced 2026-07-22 06:51:24 +00:00
772 lines
34 KiB
JavaScript
772 lines
34 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { build } from "esbuild";
|
|
|
|
const outputFile = "dist/test-flow-diagram.mjs";
|
|
|
|
await build({
|
|
stdin: {
|
|
contents: [
|
|
'export { parseFlowDiagramFile } from "./src/parsers/dfd-diagram-parser";',
|
|
'export { buildVaultIndex } from "./src/core/vault-index";',
|
|
'export { detectFileType } from "./src/core/schema-detector";',
|
|
'export { isDiagramPreviewRouteFileType, isDfdLikeDiagramPreviewFileType } from "./src/core/preview-routing";',
|
|
'export { isModelWeavePreviewSupportedFileType } from "./src/core/supported-formats";',
|
|
'export { resolveDiagramRelations } from "./src/core/relation-resolver";',
|
|
'export { buildDfdMermaidSource, buildFlowDiagramHoverMetadata, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";',
|
|
'export { getAppliedColorSchemeRowsForTargets } from "./src/core/color-scheme";',
|
|
'export { buildCurrentDiagramDiagnostics } from "./src/core/current-file-diagnostics";',
|
|
'export { createModelWeaveTranslator } from "./src/i18n/messages";',
|
|
'export { getAppliedColorSchemeLowerPaneSlot, getDiagnosticActionCandidates, getDiagnosticDetailEntries } from "./src/views/modeling-preview-view";',
|
|
'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";'
|
|
].join("\n"),
|
|
resolveDir: ".",
|
|
sourcefile: "test-flow-diagram-entry.ts",
|
|
loader: "ts"
|
|
},
|
|
bundle: true,
|
|
format: "esm",
|
|
platform: "browser",
|
|
outfile: outputFile,
|
|
plugins: [
|
|
{
|
|
name: "stub-platform-modules",
|
|
setup(buildApi) {
|
|
buildApi.onResolve({ filter: /^obsidian$/ }, () => ({
|
|
path: "obsidian",
|
|
namespace: "stub"
|
|
}));
|
|
buildApi.onLoad({ filter: /^obsidian$/, namespace: "stub" }, () => ({
|
|
contents: [
|
|
"export class ItemView {}",
|
|
"export const MarkdownRenderer = { renderMarkdown: async () => {} };",
|
|
"export class Notice { constructor() {} }",
|
|
"export class TFile {}",
|
|
"export class WorkspaceLeaf {}",
|
|
"export const getLanguage = () => 'en';",
|
|
"export const loadMermaid = async () => ({ initialize: () => {}, render: async () => ({ svg: '' }) });"
|
|
].join("\n"),
|
|
loader: "js"
|
|
}));
|
|
buildApi.onResolve({ filter: /^electron$/ }, () => ({
|
|
path: "electron",
|
|
namespace: "stub"
|
|
}));
|
|
buildApi.onLoad({ filter: /^electron$/, namespace: "stub" }, () => ({
|
|
contents: "export const shell = { openPath: async () => '', openExternal: async () => {} };",
|
|
loader: "js"
|
|
}));
|
|
buildApi.onResolve({ filter: /^fs$/ }, () => ({
|
|
path: "fs",
|
|
namespace: "stub"
|
|
}));
|
|
buildApi.onLoad({ filter: /^fs$/, namespace: "stub" }, () => ({
|
|
contents: "export const existsSync = () => false; export const statSync = () => ({ isFile: () => false, isDirectory: () => false }); export const promises = {}; export default { existsSync, statSync, promises };",
|
|
loader: "js"
|
|
}));
|
|
buildApi.onResolve({ filter: /^path$/ }, () => ({
|
|
path: "path",
|
|
namespace: "stub"
|
|
}));
|
|
buildApi.onLoad({ filter: /^path$/, namespace: "stub" }, () => ({
|
|
contents: "export const basename = (value) => String(value).split('/').pop() ?? ''; export const extname = () => ''; export const join = (...parts) => parts.join('/'); export default { basename, extname, join };",
|
|
loader: "js"
|
|
}));
|
|
}
|
|
}
|
|
],
|
|
logLevel: "silent"
|
|
});
|
|
|
|
const {
|
|
buildCurrentDiagramDiagnostics,
|
|
buildDfdMermaidSource,
|
|
buildFlowDiagramHoverMetadata,
|
|
createModelWeaveTranslator,
|
|
getAppliedColorSchemeLowerPaneSlot,
|
|
getAppliedColorSchemeRowsForTargets,
|
|
getDfdMermaidColorSchemeTargets,
|
|
buildVaultIndex,
|
|
detectFileType,
|
|
getDiagnosticActionCandidates,
|
|
getDiagnosticDetailEntries,
|
|
getExpectedHeaderForDiagnostic,
|
|
isDfdLikeDiagramPreviewFileType,
|
|
isDiagramPreviewRouteFileType,
|
|
isModelWeavePreviewSupportedFileType,
|
|
parseFlowDiagramFile,
|
|
resolveDiagramRelations
|
|
} = await import(`../${outputFile}?t=${Date.now()}`);
|
|
|
|
globalThis.activeDocument = {
|
|
body: {
|
|
classList: {
|
|
contains: () => false
|
|
}
|
|
}
|
|
};
|
|
|
|
const flowMarkdown = `---
|
|
type: flow_diagram
|
|
id: FLOW-ORDER-SCREEN-COMMUNICATION
|
|
name: Order Screen Communication Flow
|
|
kind: screen_communication
|
|
---
|
|
|
|
# Order Screen Communication Flow
|
|
|
|
## Objects
|
|
|
|
| id | label | kind | ref | domain | notes |
|
|
|---|---|---|---|---|---|
|
|
| order_screen | Order Screen | screen | [[SCR-ORDER]] | order | Source screen |
|
|
| order_process | Order Process | app_process | [[PROC-ORDER]] | order | Application process |
|
|
| session_store | Session Store | session | | order | Session state |
|
|
| data_store | Data Store | datastore | | order | Persistent state |
|
|
| mystery | Mystery | something_new | | order | Unknown kind |
|
|
|
|
## Flows
|
|
|
|
| id | from | to | data | notes |
|
|
|---|---|---|---|---|
|
|
| FLOW-001 | order_screen | order_process | [[DATA-ORDER-REQUEST]] | Submit order |
|
|
| FLOW-002 | order_process | session_store | Order result | Store result |
|
|
| FLOW-003 | session_store | data_store | Snapshot | Persist snapshot |
|
|
| FLOW-004 | mystery | order_screen | Unknown handoff | Unknown source |
|
|
`;
|
|
|
|
function parseFlow(markdown = flowMarkdown) {
|
|
const result = parseFlowDiagramFile(markdown, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
assert.ok(result.file);
|
|
return result;
|
|
}
|
|
|
|
function resolveFlow(markdown = flowMarkdown, extraFiles = []) {
|
|
const index = buildVaultIndex([
|
|
{ path: "FLOW-ORDER-SCREEN-COMMUNICATION.md", content: markdown },
|
|
...extraFiles
|
|
], { parseMode: "full", validate: false });
|
|
const model = index.modelsByFilePath["FLOW-ORDER-SCREEN-COMMUNICATION.md"];
|
|
assert.equal(model.fileType, "flow-diagram");
|
|
return { index, model, resolved: resolveDiagramRelations(model, index) };
|
|
}
|
|
|
|
test("flow_diagram is detected and parsed as a distinct model type", () => {
|
|
assert.equal(detectFileType({ type: "flow_diagram" }), "flow-diagram");
|
|
const { file, warnings } = parseFlow();
|
|
|
|
assert.equal(file.fileType, "flow-diagram");
|
|
assert.equal(file.schema, "flow_diagram");
|
|
assert.equal(file.kind, "screen_communication");
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
|
|
test("flow_diagram follows the same preview support and diagram route checks", () => {
|
|
const fileType = detectFileType({ type: "flow_diagram" });
|
|
|
|
assert.equal(fileType, "flow-diagram");
|
|
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
|
|
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
|
|
assert.equal(isDiagramPreviewRouteFileType(fileType), true);
|
|
});
|
|
|
|
test("flow-diagram alias normalizes to the same internal preview route", () => {
|
|
const fileType = detectFileType({ type: "flow-diagram" });
|
|
|
|
assert.equal(fileType, "flow-diagram");
|
|
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
|
|
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
|
|
});
|
|
|
|
test("dfd_diagram preview route remains DFD-like diagram", () => {
|
|
const fileType = detectFileType({ type: "dfd_diagram" });
|
|
|
|
assert.equal(fileType, "dfd-diagram");
|
|
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
|
|
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
|
|
assert.equal(isDiagramPreviewRouteFileType(fileType), true);
|
|
});
|
|
|
|
test("unsupported type still does not use the diagram preview route", () => {
|
|
const fileType = detectFileType({ type: "not_a_model_weave_type" });
|
|
|
|
assert.equal(fileType, "markdown");
|
|
assert.equal(isModelWeavePreviewSupportedFileType(fileType), false);
|
|
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), false);
|
|
assert.equal(isDiagramPreviewRouteFileType(fileType), false);
|
|
});
|
|
|
|
test("flow_diagram Objects table parses MVP columns", () => {
|
|
const { file } = parseFlow();
|
|
const first = file.objectEntries[0];
|
|
|
|
assert.equal(first.id, "order_screen");
|
|
assert.equal(first.label, "Order Screen");
|
|
assert.equal(first.kind, "screen");
|
|
assert.equal(first.ref, "[[SCR-ORDER]]");
|
|
assert.equal(first.domain, "order");
|
|
assert.equal(first.notes, "Source screen");
|
|
});
|
|
|
|
test("flow_diagram Flows table parses MVP columns", () => {
|
|
const { file } = parseFlow();
|
|
const first = file.flows[0];
|
|
|
|
assert.equal(first.id, "FLOW-001");
|
|
assert.equal(first.from, "order_screen");
|
|
assert.equal(first.to, "order_process");
|
|
assert.equal(first.data, "[[DATA-ORDER-REQUEST]]");
|
|
assert.equal(first.notes, "Submit order");
|
|
});
|
|
|
|
test("flow_diagram Mermaid renders screen as curv-trap", () => {
|
|
const { resolved } = resolveFlow();
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /order_screen@\{ shape: curv-trap, label: "Order Screen" \}/);
|
|
assert.match(source, /class order_screen screen/);
|
|
});
|
|
|
|
test("flow_diagram Mermaid renders store kinds as lin-cyl", () => {
|
|
const { resolved } = resolveFlow();
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /session_store@\{ shape: lin-cyl, label: "Session Store" \}/);
|
|
assert.match(source, /data_store@\{ shape: lin-cyl, label: "Data Store" \}/);
|
|
assert.match(source, /class session_store store/);
|
|
assert.match(source, /class data_store store/);
|
|
});
|
|
|
|
test("flow_diagram unknown object kind falls back to rect", () => {
|
|
const { model, resolved } = resolveFlow();
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.equal(model.objectEntries.find((entry) => entry.id === "mystery")?.kind, "unknown");
|
|
assert.match(source, /mystery@\{ shape: rect, label: "Mystery" \}/);
|
|
});
|
|
|
|
test("flow_diagram Flows.data appears as edge label", () => {
|
|
const { resolved } = resolveFlow();
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /order_screen -->\|DATA-ORDER-REQUEST\| order_process/);
|
|
assert.match(source, /order_process -->\|Order result\| session_store/);
|
|
});
|
|
|
|
function getFlowDiagramDataWarnings(markdown, extraFiles = []) {
|
|
const { model, resolved } = resolveFlow(markdown, extraFiles);
|
|
return buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings)
|
|
.filter((warning) => warning.code === "unresolved-reference" && /flow data reference/.test(warning.message));
|
|
}
|
|
|
|
test("flow_diagram Flows.data plain text does not warn", () => {
|
|
const warnings = getFlowDiagramDataWarnings(flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", "DATA-ORDER-DRAFT"));
|
|
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
test("flow_diagram Flows.data empty value does not warn", () => {
|
|
const warnings = getFlowDiagramDataWarnings(flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", ""));
|
|
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
test("flow_diagram Flows.data resolved Wikilink does not warn", () => {
|
|
const warnings = getFlowDiagramDataWarnings(flowMarkdown, [
|
|
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
|
|
]);
|
|
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
test("flow_diagram Flows.data unresolved Wikilink emits unresolved-reference warning", () => {
|
|
const warnings = getFlowDiagramDataWarnings(flowMarkdown);
|
|
const diagnostic = warnings.find((warning) => warning.context.referenceValue === "[[DATA-ORDER-REQUEST]]");
|
|
|
|
assert.ok(diagnostic);
|
|
assert.equal(diagnostic.message, 'Flow Diagram flow data reference "[[DATA-ORDER-REQUEST]]" could not be resolved. Check the data/model id or file name.');
|
|
assert.equal(diagnostic.severity, "warning");
|
|
assert.equal(diagnostic.field, "data");
|
|
assert.equal(diagnostic.context.section, "Flows");
|
|
assert.equal(diagnostic.context.rowIndex, 1);
|
|
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
|
|
|
|
const t = createModelWeaveTranslator("en");
|
|
const details = getDiagnosticDetailEntries(diagnostic, t);
|
|
assert.equal(details.some((entry) => /local .*Objects.* table/.test(entry.value)), false);
|
|
});
|
|
|
|
test("flow_diagram Flows.data multiple Wikilinks warn only for unresolved links", () => {
|
|
const markdown = flowMarkdown.replace("[[DATA-ORDER-REQUEST]]", "[[DATA-A]], [[DATA-B]]");
|
|
const warnings = getFlowDiagramDataWarnings(markdown, [
|
|
{ path: "DATA-A.md", content: "---\ntype: data_object\nid: DATA-A\nname: Data A\n---\n" }
|
|
]);
|
|
|
|
assert.deepEqual(warnings.map((warning) => warning.context.referenceValue), ["[[DATA-B]]"]);
|
|
});
|
|
|
|
test("flow_diagram object hover metadata is generated for each Objects row", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
|
|
assert.equal(metadata.objects.length, resolved.nodes.length);
|
|
assert.deepEqual(metadata.objects.map((target) => target.mermaidId), [
|
|
"order_screen",
|
|
"order_process",
|
|
"session_store",
|
|
"data_store",
|
|
"mystery"
|
|
]);
|
|
});
|
|
|
|
test("flow_diagram object hover metadata includes row details and raw wikilink ref", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.hoverTitle, "Flow Object");
|
|
assert.deepEqual(
|
|
Object.fromEntries(target.hoverRows.map((row) => [row.label, row.value])),
|
|
{
|
|
id: "order_screen",
|
|
label: "Order Screen",
|
|
kind: "screen",
|
|
domain: "order",
|
|
ref: "[[SCR-ORDER]]",
|
|
notes: "Source screen"
|
|
}
|
|
);
|
|
assert.match(target.hoverText, /Flow Object/);
|
|
assert.match(target.hoverText, /ref: \[\[SCR-ORDER\]\]/);
|
|
});
|
|
|
|
test("flow_diagram object hover metadata exposes resolved file path for resolved wikilink ref", () => {
|
|
const { resolved } = resolveFlow(flowMarkdown, [
|
|
{ path: "SCR-ORDER.md", content: "---\ntype: screen\nid: SCR-ORDER\nname: Order Screen\n---\n" }
|
|
]);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, "SCR-ORDER.md");
|
|
assert.equal(target.filePath, "SCR-ORDER.md");
|
|
assert.notEqual(target.previewLinktext, "[[SCR-ORDER]]");
|
|
assert.match(target.nativeTooltip, /Flow Object: order_screen/);
|
|
assert.match(target.nativeTooltip, /ref: \[\[SCR-ORDER\]\]/);
|
|
assert.ok(target.hoverRows.length > 0);
|
|
});
|
|
|
|
test("flow_diagram plain or unresolved object ref falls back to custom card metadata", () => {
|
|
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "SCR-MISSING");
|
|
const { resolved } = resolveFlow(markdown);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.hoverTitle, "Flow Object");
|
|
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, "SCR-MISSING");
|
|
assert.match(target.nativeTooltip, /ref: SCR-MISSING/);
|
|
assert.notEqual(target.hoverText, undefined);
|
|
});
|
|
|
|
test("flow_diagram unresolved object ref wikilink falls back without raw Page Preview target", () => {
|
|
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "[[SCR-MISSING]]");
|
|
const { resolved } = resolveFlow(markdown);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.objects.find((entry) => entry.nodeId === "order_screen");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.filePath, undefined);
|
|
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, "[[SCR-MISSING]]");
|
|
assert.match(target.nativeTooltip, /ref: \[\[SCR-MISSING\]\]/);
|
|
});
|
|
|
|
test("flow_diagram empty object ref has fallback card metadata and safe tooltip", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.objects.find((entry) => entry.nodeId === "session_store");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.hoverRows.find((row) => row.label === "ref")?.value, undefined);
|
|
assert.match(target.nativeTooltip, /Flow Object: session_store/);
|
|
assert.match(target.nativeTooltip, /ref: -/);
|
|
});
|
|
|
|
test("flow_diagram flow hover metadata is generated for each Flows row", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
|
|
assert.equal(metadata.flows.length, resolved.edges.length);
|
|
assert.deepEqual(metadata.flows.map((target) => target.mermaidId), [
|
|
"FLOW_1_FLOW_001",
|
|
"FLOW_2_FLOW_002",
|
|
"FLOW_3_FLOW_003",
|
|
"FLOW_4_FLOW_004"
|
|
]);
|
|
});
|
|
|
|
test("flow_diagram flow hover metadata includes row details and raw data ref", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.hoverTitle, "Flow");
|
|
assert.deepEqual(
|
|
Object.fromEntries(target.hoverRows.map((row) => [row.label, row.value])),
|
|
{
|
|
id: "FLOW-001",
|
|
from: "order_screen",
|
|
to: "order_process",
|
|
data: "[[DATA-ORDER-REQUEST]]",
|
|
notes: "Submit order"
|
|
}
|
|
);
|
|
assert.match(target.hoverText, /data: \[\[DATA-ORDER-REQUEST\]\]/);
|
|
});
|
|
|
|
test("flow_diagram flow hover metadata exposes resolved file path for resolved wikilink data", () => {
|
|
const { resolved } = resolveFlow(flowMarkdown, [
|
|
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
|
|
]);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, "DATA-ORDER-REQUEST.md");
|
|
assert.equal(target.filePath, "DATA-ORDER-REQUEST.md");
|
|
assert.notEqual(target.previewLinktext, "[[DATA-ORDER-REQUEST]]");
|
|
assert.match(target.nativeTooltip, /Flow: FLOW-001/);
|
|
assert.match(target.nativeTooltip, /data: \[\[DATA-ORDER-REQUEST\]\]/);
|
|
assert.ok(target.hoverRows.length > 0);
|
|
});
|
|
|
|
test("flow_diagram plain flow data falls back to custom card metadata", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-002");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.hoverTitle, "Flow");
|
|
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, "Order result");
|
|
assert.match(target.nativeTooltip, /data: Order result/);
|
|
assert.notEqual(target.hoverText, undefined);
|
|
});
|
|
|
|
test("flow_diagram unresolved flow data wikilink falls back without raw Page Preview target", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-001");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.filePath, undefined);
|
|
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, "[[DATA-ORDER-REQUEST]]");
|
|
assert.match(target.nativeTooltip, /data: \[\[DATA-ORDER-REQUEST\]\]/);
|
|
});
|
|
|
|
test("flow_diagram empty flow data has fallback card metadata and safe tooltip", () => {
|
|
const markdown = flowMarkdown.replace("| FLOW-002 | order_process | session_store | Order result | Store result |", "| FLOW-002 | order_process | session_store | | Store result |");
|
|
const { resolved } = resolveFlow(markdown);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
const target = metadata.flows.find((entry) => entry.edgeId === "FLOW-002");
|
|
|
|
assert.ok(target);
|
|
assert.equal(target.previewLinktext, undefined);
|
|
assert.equal(target.hoverRows.find((row) => row.label === "data")?.value, undefined);
|
|
assert.match(target.nativeTooltip, /Flow: FLOW-002/);
|
|
assert.match(target.nativeTooltip, /data: -/);
|
|
});
|
|
|
|
test("flow_diagram hover metadata is not native-tooltip-only", () => {
|
|
const { resolved } = resolveFlow();
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
|
|
assert.equal(metadata.objects.every((target) => target.hoverRows.length > 0), true);
|
|
assert.equal(metadata.flows.every((target) => target.hoverRows.length > 0), true);
|
|
assert.equal(metadata.objects.some((target) => target.previewLinktext), false);
|
|
});
|
|
|
|
test("flow_diagram hover metadata uses stable node and flow mapping keys", () => {
|
|
const { resolved } = resolveFlow(flowDomainMarkdown);
|
|
const metadata = buildFlowDiagramHoverMetadata(resolved, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
|
|
|
|
assert.equal(metadata.objects.find((target) => target.nodeId === "ORDER_ENTRY")?.mermaidId, "ORDER_ENTRY");
|
|
assert.equal(metadata.objects.find((target) => target.nodeId === "SESSION_STORE")?.mermaidId, "SESSION_STORE");
|
|
assert.equal(metadata.flows.find((target) => target.edgeId === "F01")?.mermaidId, "FLOW_1_F01");
|
|
assert.equal(metadata.flows.find((target) => target.edgeId === "F05")?.mermaidId, "FLOW_5_F05");
|
|
});
|
|
|
|
test("flow_diagram refs can point to screen, app_process, and data_object without DFD compatibility warnings", () => {
|
|
const markdown = flowMarkdown.replace(
|
|
"| data_store | Data Store | datastore | | order | Persistent state |",
|
|
"| data_store | Data Store | datastore | [[DATA-ORDER-REQUEST]] | order | Persistent state |"
|
|
);
|
|
const { index, model, resolved } = resolveFlow(markdown, [
|
|
{ path: "SCR-ORDER.md", content: "---\ntype: screen\nid: SCR-ORDER\nname: Order Screen\n---\n" },
|
|
{ path: "PROC-ORDER.md", content: "---\ntype: app_process\nid: PROC-ORDER\nname: Order Process\n---\n" },
|
|
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
|
|
]);
|
|
const messages = [
|
|
...(index.warningsByFilePath[model.path] ?? []),
|
|
...resolved.warnings
|
|
].map((warning) => warning.message);
|
|
|
|
assert.equal(messages.some((message) => /DFD/.test(message)), false);
|
|
assert.equal(messages.some((message) => /unresolved Flow Diagram object ref/.test(message)), false);
|
|
});
|
|
|
|
test("flow_diagram malformed Objects header exposes expected header guidance", () => {
|
|
const markdown = flowMarkdown.replace("| id | label | kind | ref | domain | notes |", "| id | label | kind | notes |");
|
|
const parsed = parseFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: parsed.file }, parsed.warnings);
|
|
const tableDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "invalid-table-column" && /Objects/.test(diagnostic.message));
|
|
|
|
assert.ok(tableDiagnostic);
|
|
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostic), "id | label | kind | ref | domain | notes");
|
|
});
|
|
|
|
test("flow_diagram malformed Flows header exposes expected header and suppresses row cascade", () => {
|
|
const markdown = flowMarkdown.replace("| id | from | to | data | notes |", "| id | frow | to | data | notes |");
|
|
const parsed = parseFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: parsed.file }, parsed.warnings);
|
|
const tableDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "invalid-table-column" && /Flows/.test(diagnostic.message));
|
|
|
|
assert.ok(tableDiagnostic);
|
|
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostic), "id | from | to | data | notes");
|
|
assert.equal(diagnostics.some((diagnostic) => /Flow Diagram Flows row must have "from"/.test(diagnostic.message)), false);
|
|
assert.equal(diagnostics.some((diagnostic) => /unresolved Flow Diagram flow source/.test(diagnostic.message)), false);
|
|
});
|
|
|
|
test("flow_diagram unresolved Objects.ref keeps external reference guidance without expected header", () => {
|
|
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "[[SCR-MISSING]]");
|
|
const { model, resolved } = resolveFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, [
|
|
...(resolved.warnings ?? [])
|
|
]);
|
|
const diagnostic = diagnostics.find((entry) =>
|
|
entry.code === "unresolved-reference" && /object ref/.test(entry.message)
|
|
);
|
|
|
|
assert.ok(diagnostic);
|
|
assert.equal(diagnostic.severity, "warning");
|
|
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
|
|
|
|
const t = createModelWeaveTranslator("en");
|
|
const details = getDiagnosticDetailEntries(diagnostic, t);
|
|
const actions = getDiagnosticActionCandidates(diagnostic, diagnostic.message, t, undefined);
|
|
|
|
assert.equal(details.some((entry) => entry.label === "Expected header"), false);
|
|
assert.equal(actions.some((action) => action.id === "copy-expected-header"), false);
|
|
assert.equal(
|
|
details.some((entry) => /indexed model files/.test(entry.value)),
|
|
true
|
|
);
|
|
});
|
|
|
|
test("flow_diagram unresolved Flows.from reports local Objects.id guidance", () => {
|
|
const markdown = flowMarkdown.replace("| FLOW-001 | order_screen | order_process |", "| FLOW-001 | ORDER_ENTRY | order_process |");
|
|
const { model, resolved } = resolveFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings);
|
|
const diagnostic = diagnostics.find((entry) =>
|
|
entry.code === "unresolved-reference" && entry.field === "Flows.from"
|
|
);
|
|
|
|
assert.ok(diagnostic);
|
|
assert.equal(diagnostic.severity, "error");
|
|
assert.match(diagnostic.message, /local ## Objects table/);
|
|
assert.doesNotMatch(diagnostic.message, /indexed Model Weave files|indexed model files/);
|
|
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
|
|
|
|
const t = createModelWeaveTranslator("en");
|
|
const details = getDiagnosticDetailEntries(diagnostic, t);
|
|
assert.equal(details.some((entry) => entry.label === "Expected header"), false);
|
|
assert.equal(
|
|
details.some((entry) => /local .*Objects.* table/.test(entry.value) && /object ID/.test(entry.value)),
|
|
true
|
|
);
|
|
});
|
|
|
|
test("flow_diagram unresolved Flows.to reports local Objects.id guidance", () => {
|
|
const markdown = flowMarkdown.replace("| FLOW-001 | order_screen | order_process |", "| FLOW-001 | order_screen | ORDER_DONE |");
|
|
const { model, resolved } = resolveFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings);
|
|
const diagnostic = diagnostics.find((entry) =>
|
|
entry.code === "unresolved-reference" && entry.field === "Flows.to"
|
|
);
|
|
|
|
assert.ok(diagnostic);
|
|
assert.equal(diagnostic.severity, "error");
|
|
assert.match(diagnostic.message, /local ## Objects table/);
|
|
assert.doesNotMatch(diagnostic.message, /indexed Model Weave files|indexed model files/);
|
|
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
|
|
});
|
|
|
|
test("flow_diagram malformed Objects header suppresses flow endpoint cascade", () => {
|
|
const markdown = flowMarkdown.replace("| id | label | kind | ref | domain | notes |", "| id | label | kind | notes |");
|
|
const { index, model, resolved } = resolveFlow(markdown);
|
|
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, [
|
|
...(index.warningsByFilePath[model.path] ?? []),
|
|
...resolved.warnings
|
|
]);
|
|
const tableDiagnostics = diagnostics.filter((entry) =>
|
|
entry.code === "invalid-table-column" && /Objects/.test(entry.message)
|
|
);
|
|
|
|
assert.equal(tableDiagnostics.length, 1);
|
|
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostics[0]), "id | label | kind | ref | domain | notes");
|
|
assert.equal(
|
|
diagnostics.some((entry) => entry.code === "unresolved-reference" && (entry.field === "Flows.from" || entry.field === "Flows.to")),
|
|
false
|
|
);
|
|
});
|
|
|
|
const flowDomainMarkdown = `---
|
|
type: flow_diagram
|
|
id: FLOW-ORDER-SCREEN-COMMUNICATION
|
|
name: Order Screen Communication Flow
|
|
kind: screen_communication
|
|
---
|
|
|
|
## Objects
|
|
|
|
| id | label | kind | ref | domain | notes |
|
|
|---|---|---|---|---|---|
|
|
| ORDER_ENTRY | Order Entry Screen | screen | [[SCR-ORDER-ENTRY]] | sales | Parent screen |
|
|
| CUSTOMER_SEARCH | Customer Search Screen | screen | [[SCR-CUSTOMER-SEARCH]] | sales | Helper search screen |
|
|
| ORDER_CONTEXT | Order Wizard Context | context | | sales | Temporary working context |
|
|
| SESSION_STORE | Session Store | store | | platform | Temporary persistence |
|
|
| ORDER_SUBMIT | Order Submit Process | app_process | [[PROC-ORDER-SUBMIT]] | application | Submit process |
|
|
|
|
## Flows
|
|
|
|
| id | from | to | data | notes |
|
|
|---|---|---|---|---|
|
|
| F01 | ORDER_ENTRY | ORDER_CONTEXT | [[DATA-ORDER-DRAFT]] | Keep draft |
|
|
| F02 | ORDER_CONTEXT | CUSTOMER_SEARCH | [[DATA-CUSTOMER-SEARCH-CONDITION]] | Open search |
|
|
| F03 | CUSTOMER_SEARCH | ORDER_CONTEXT | [[DATA-CUSTOMER-SELECTION]] | Return selection |
|
|
| F04 | ORDER_CONTEXT | ORDER_ENTRY | [[DATA-CUSTOMER-SELECTION]] | Apply selection |
|
|
| F05 | ORDER_CONTEXT | SESSION_STORE | [[DATA-ORDER-DRAFT]] | Save draft |
|
|
| F06 | ORDER_ENTRY | ORDER_SUBMIT | [[DATA-ORDER-DRAFT]] | Submit |
|
|
`;
|
|
|
|
test("flow_diagram Objects.domain renders Mermaid domain subgraphs", () => {
|
|
const { resolved } = resolveFlow(flowDomainMarkdown);
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /subgraph DOMAIN_sales\["sales"\]/);
|
|
assert.match(source, /subgraph DOMAIN_platform\["platform"\]/);
|
|
assert.match(source, /subgraph DOMAIN_application\["application"\]/);
|
|
assert.match(source, /subgraph DOMAIN_sales[\s\S]*ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}[\s\S]*CUSTOMER_SEARCH@\{ shape: curv-trap, label: "Customer Search Screen" \}[\s\S]*ORDER_CONTEXT@\{ shape: rect, label: "Order Wizard Context" \}[\s\S]*end/);
|
|
});
|
|
|
|
test("flow_diagram domain grouping preserves kind shapes and flow data labels", () => {
|
|
const { resolved } = resolveFlow(flowDomainMarkdown);
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
|
|
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
|
|
assert.match(source, /ORDER_SUBMIT@\{ shape: rect, label: "Order Submit Process" \}/);
|
|
assert.match(source, /ORDER_ENTRY -->\|DATA-ORDER-DRAFT\| ORDER_CONTEXT/);
|
|
assert.match(source, /ORDER_CONTEXT -->\|DATA-ORDER-DRAFT\| SESSION_STORE/);
|
|
});
|
|
|
|
test("flow_diagram without Objects.domain renders without domain subgraphs", () => {
|
|
const markdown = flowDomainMarkdown.replaceAll("| sales |", "| |").replaceAll("| platform |", "| |").replaceAll("| application |", "| |");
|
|
const { resolved } = resolveFlow(markdown);
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.doesNotMatch(source, /subgraph DOMAIN_/);
|
|
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
|
|
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
|
|
});
|
|
|
|
test("flow_diagram domain IDs are sanitized for Mermaid subgraphs", () => {
|
|
const markdown = flowDomainMarkdown.replaceAll("| sales |", "| sales/platform ops |");
|
|
const { resolved } = resolveFlow(markdown);
|
|
const source = buildDfdMermaidSource(resolved);
|
|
|
|
assert.match(source, /subgraph DOMAIN_sales_platform_ops\["sales\/platform ops"\]/);
|
|
assert.match(source, /DOMAIN_sales_platform_ops/);
|
|
});
|
|
|
|
const flowDomainColorScheme = {
|
|
id: "COLOR-FLOW-DOMAINS",
|
|
name: "Flow Domain Colors",
|
|
entries: [
|
|
{ target: "domain", kind: "sales", fill: "#7ddea7", stroke: "#000000", text: "#ffffff", rowIndex: 0 },
|
|
{ target: "domain", kind: "application", fill: "#DDEBFF", stroke: "#4F81BD", text: "#111111", rowIndex: 1 }
|
|
],
|
|
defaultStyle: {
|
|
fill: "#f5f5f5",
|
|
stroke: "#9e9e9e",
|
|
text: "#111111"
|
|
}
|
|
};
|
|
|
|
test("flow_diagram domain Color Scheme rows emit Mermaid subgraph style lines", () => {
|
|
const { resolved } = resolveFlow(flowDomainMarkdown);
|
|
const source = buildDfdMermaidSource(resolved, flowDomainColorScheme);
|
|
|
|
assert.match(source, /style DOMAIN_sales fill:#7ddea7,stroke:#000000,color:#ffffff/);
|
|
assert.match(source, /style DOMAIN_application fill:#DDEBFF,stroke:#4F81BD,color:#111111/);
|
|
assert.match(source, /style DOMAIN_platform fill:#f5f5f5,stroke:#9e9e9e,color:#111111/);
|
|
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
|
|
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
|
|
assert.match(source, /ORDER_ENTRY -->\|DATA-ORDER-DRAFT\| ORDER_CONTEXT/);
|
|
});
|
|
|
|
test("flow_diagram Applied Color Scheme targets include used domain rows", () => {
|
|
const { resolved } = resolveFlow(flowDomainMarkdown);
|
|
const targets = getDfdMermaidColorSchemeTargets(resolved);
|
|
const rows = getAppliedColorSchemeRowsForTargets(flowDomainColorScheme, targets);
|
|
|
|
assert.deepEqual(targets, ["domain"]);
|
|
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "sales"), true);
|
|
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "application"), true);
|
|
assert.equal(rows.some((row) => row.entry.target === "app_process"), false);
|
|
assert.equal(rows.some((row) => row.entry.target === "weave_map"), false);
|
|
});
|
|
|
|
test("dfd_diagram Applied Color Scheme targets include dfd and domain when domains are used", () => {
|
|
const targets = getDfdMermaidColorSchemeTargets({
|
|
diagram: {
|
|
schema: "dfd_diagram",
|
|
kind: "dfd",
|
|
domains: [{ id: "sales", name: "Sales", kind: "sales", rowIndex: 0 }]
|
|
},
|
|
nodes: [{ id: "process", metadata: { domain: "sales" } }],
|
|
edges: []
|
|
});
|
|
const rows = getAppliedColorSchemeRowsForTargets({
|
|
...flowDomainColorScheme,
|
|
entries: [
|
|
...flowDomainColorScheme.entries,
|
|
{ target: "dfd", kind: "process", fill: "#9bbb59", stroke: "#6f8a3f", text: "#000000", rowIndex: 2 }
|
|
]
|
|
}, targets);
|
|
|
|
assert.deepEqual(targets, ["dfd", "domain"]);
|
|
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "sales"), true);
|
|
assert.equal(rows.some((row) => row.entry.target === "dfd" && row.entry.kind === "process"), true);
|
|
assert.equal(rows.some((row) => row.entry.target === "app_process"), false);
|
|
});
|
|
|
|
test("Applied Color Scheme placement policy uses Details for color-aware previews", () => {
|
|
assert.equal(getAppliedColorSchemeLowerPaneSlot("app_process"), "details");
|
|
assert.equal(getAppliedColorSchemeLowerPaneSlot("domains"), "details");
|
|
assert.equal(getAppliedColorSchemeLowerPaneSlot("domain_diagram"), "details");
|
|
assert.equal(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "details");
|
|
assert.equal(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "details");
|
|
});
|
|
|
|
test("DFD and Flow Applied Color Scheme placement is not Relationships", () => {
|
|
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "impact");
|
|
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "impact");
|
|
});
|