From 9240edd8b4115cd49acde48196a6b56b7890fa72 Mon Sep 17 00:00:00 2001 From: nejimakibird <23066051+nejimakibird@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:50:47 +0900 Subject: [PATCH] feat: add rich hover and data ref diagnostics for flow diagrams --- main.js | 351 ++++++++++++++++++++++-- src/core/current-file-diagnostics.ts | 2 + src/core/relation-resolver.ts | 56 ++-- src/renderers/dfd-mermaid.ts | 262 +++++++++++++++++- src/views/mermaid-node-interactions.ts | 150 +++++++++- styles.css | 40 +++ test/domains-model.test.mjs | 77 +++++- test/flow-diagram.test.mjs | 254 ++++++++++++++++- test/mermaid-node-interactions.test.mjs | 37 ++- 9 files changed, 1169 insertions(+), 60 deletions(-) diff --git a/main.js b/main.js index 03a4d18..f256d7b 100644 --- a/main.js +++ b/main.js @@ -2974,6 +2974,8 @@ function localizeDiagnosticMessage(message, language) { [/^unresolved DFD flow source "([^"]+)"$/, (_match, source) => `DFD flow \u306E source "${source}" \u304C\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002`], [/^unresolved DFD flow target "([^"]+)"$/, (_match, target) => `DFD flow \u306E target "${target}" \u304C\u89E3\u6C7A\u3067\u304D\u307E\u305B\u3093\u3002`], [/^unresolved DFD object ref "([^"]+)"$/, (_match, ref) => `DFD\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u306E\u53C2\u7167 "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002ID\u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`], + [/^DFD flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `DFD flow data reference "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002data/model \u306E id \u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`], + [/^Flow Diagram flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `Flow Diagram flow data reference "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002data/model \u306E id \u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`], [/^DFD object ref "([^"]+)" could not be resolved\. Check the ID or file name\.$/, (_match, ref) => `DFD\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u306E\u53C2\u7167 "${ref}" \u306E\u53C2\u7167\u5148\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002ID\u307E\u305F\u306F\u30D5\u30A1\u30A4\u30EB\u540D\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002`], [/^frontmatter parse error: unexpected list item "([^"]+)"$/, (_match, value) => `frontmatter \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u4E88\u671F\u3057\u306A\u3044\u30EA\u30B9\u30C8\u9805\u76EE\u3067\u3059: "${value}"`], [/^frontmatter parse error: malformed line "([^"]+)"$/, (_match, value) => `frontmatter \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u884C\u306E\u5F62\u5F0F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044: "${value}"`], @@ -4764,17 +4766,13 @@ function resolveDfdDiagramRelations(diagram, index) { warnings.push(createDfdFlowShapeWarning(diagram.path, context, "datastore -> datastore")); } } - const flowData = resolveDfdFlowDataDisplay(flow.data, index, { suppressUnresolvedWarning: isFlowDiagram }); - if (flowData.warning) { - warnings.push({ - code: "unresolved-reference", - message: flowData.warning, - severity: "warning", - path: diagram.path, - field: "Flows", - context - }); - } + const flowData = resolveDfdFlowDataDisplay(flow.data, index); + warnings.push(...resolveDfdFlowDataReferenceWarnings( + diagram, + flow.data, + index, + context + )); edges.push({ id: flow.id, source: sourceEntry.node.id, @@ -5006,6 +5004,9 @@ function resolveDfdDiagramObjects(diagram, index, domainContext) { domain, rowIndex: entry.rowIndex, local: !ref, + refReference: resolvedIdentity?.parsed, + refModelPath: resolvedIdentity?.resolvedModel?.path, + refModelType: resolvedIdentity?.resolvedModel?.fileType, compatibilityMode: entry.compatibilityMode }, object: resolvedObject @@ -5053,7 +5054,25 @@ function resolveDfdFlowEndpoint(value, registry, index) { } return null; } -function resolveDfdFlowDataDisplay(rawValue, index, options = {}) { +function resolveDfdFlowDataReferenceWarnings(diagram, rawValue, index, context) { + const wikilinks = rawValue ? extractWikilinkReferences(rawValue) : []; + if (wikilinks.length === 0) { + return []; + } + const diagramLabel = diagram.schema === "flow_diagram" ? "Flow Diagram" : "DFD"; + return wikilinks.filter((reference) => !resolveReferenceIdentity(reference, index).resolvedModel).map((reference) => ({ + code: "unresolved-reference", + message: `${diagramLabel} flow data reference "${reference}" could not be resolved. Check the data/model id or file name.`, + severity: "warning", + path: diagram.path, + field: "data", + context: { + ...context, + referenceValue: reference + } + })); +} +function resolveDfdFlowDataDisplay(rawValue, index) { const trimmed = rawValue?.trim(); if (!trimmed) { return {}; @@ -5089,8 +5108,7 @@ function resolveDfdFlowDataDisplay(rawValue, index, options = {}) { if (reference.target) { return { label: getReferenceDisplayName(trimmed), - reference, - warning: options.suppressUnresolvedWarning ? void 0 : `unresolved flow data reference "${trimmed}"` + reference }; } return { label: getReferenceDisplayName(trimmed), reference }; @@ -16184,7 +16202,7 @@ function attachMermaidNodeInteractions(options) { } function attachGraphElementHoverPreview(options) { const fallback = options.rootEl ?? getElementHoverFallback(options.targetEl); - if (!fallback || !options.target.linktext || !options.target.sourcePath) { + if (!fallback || !canShowGraphInteractionHover(options.target)) { return () => void 0; } const controller = new AbortController(); @@ -16210,7 +16228,7 @@ function attachGraphElementHoverPreview(options) { debugName: options.debugName, isDebugEnabled: options.isDebugEnabled }, fallback, event, false); - const hoverLinkTarget = triggerGraphInteractionHoverPreview( + const hoverLinkTarget = triggerGraphInteractionHover( options.app, source, resolveGraphHoverParent(options.targetEl, fallback, options.hoverParent), @@ -16269,7 +16287,11 @@ function clearGraphHoverState(state, action, debug, rootEl, event, suppressSynth state.activeHoverSourcePath = ""; state.activeHoverTargetEl = null; if (activeHoverTargetEl && !suppressSyntheticLeave) { - dispatchGraphHoverTargetLeave(activeHoverTargetEl); + if (isGraphFallbackHoverCard(activeHoverTargetEl)) { + activeHoverTargetEl.remove(); + } else { + dispatchGraphHoverTargetLeave(activeHoverTargetEl); + } } else if (hadActiveNode && !suppressSyntheticLeave) { dispatchGraphHoverTargetLeave(rootEl); } @@ -16331,24 +16353,29 @@ function getMermaidNodeInteractionFromEvent(event, interactions) { function findMermaidNodeInteractionByTarget(interactions, target) { return interactions.find((interaction) => interaction.target === target) ?? null; } +function getGraphInteractionNativeTooltipText(target, fallbackTitle) { + return target.nativeTooltip ?? fallbackTitle ?? target.label ?? target.linktext; +} function setMermaidNodeTitle(nodeEl, target, formatTitle) { - const titleText = formatTitle?.(target) ?? target.label ?? target.linktext; + const existingTitle = nodeEl.querySelector("title"); + const titleText = getGraphInteractionNativeTooltipText(target, formatTitle?.(target)); if (!titleText) { + existingTitle?.remove(); return; } const doc = nodeEl.ownerDocument; - const title = nodeEl.querySelector("title") ?? doc.createElementNS("http://www.w3.org/2000/svg", "title"); + const title = existingTitle ?? doc.createElementNS("http://www.w3.org/2000/svg", "title"); title.textContent = titleText; if (!title.parentElement) { nodeEl.prepend(title); } } function triggerMermaidNodeHoverPreview(options, source, targetEl, target, event, stateDebug) { - if (!target.linktext || !target.sourcePath) { + if (!canShowGraphInteractionHover(target)) { return null; } const hoverParent = resolveGraphHoverParent(targetEl, options.rootEl, options.hoverParent); - return triggerGraphInteractionHoverPreview( + return triggerGraphInteractionHover( options.app, source, hoverParent, @@ -16362,6 +16389,83 @@ function triggerMermaidNodeHoverPreview(options, source, targetEl, target, event stateDebug ); } +function canShowGraphInteractionHover(target) { + return Boolean( + target.previewLinktext && target.sourcePath || target.linktext && target.sourcePath && !target.hoverRows?.length || target.hoverRows?.length + ); +} +function triggerGraphInteractionHover(app, source, hoverParent, targetEl, target, event, debug, stateDebug) { + const previewLinktext = getGraphInteractionPreviewLinktext(target); + if (previewLinktext) { + const previewTarget = { ...target, linktext: previewLinktext }; + return triggerGraphInteractionHoverPreview( + app, + source, + hoverParent, + targetEl, + previewTarget, + event, + debug, + stateDebug + ) ?? triggerGraphInteractionFallbackHover(hoverParent, target, event); + } + return triggerGraphInteractionFallbackHover(hoverParent, target, event); +} +function getGraphInteractionPreviewLinktext(target) { + const explicit = target.previewLinktext?.trim(); + if (explicit) { + return explicit; + } + if (target.hoverRows?.length) { + return null; + } + const linktext = target.linktext?.trim(); + return linktext && target.sourcePath ? linktext : null; +} +function triggerGraphInteractionFallbackHover(hoverParent, target, event) { + if (!target.hoverRows?.length) { + return null; + } + const card = createGraphFallbackHoverCard(hoverParent, target, event); + return { targetEl: card, reusableAnchorTargetUsed: false }; +} +function createGraphFallbackHoverCard(hoverParent, target, event) { + const doc = hoverParent.ownerDocument; + const existing = hoverParent.querySelectorAll(".model-weave-graph-hover-card"); + existing.forEach((element) => element.remove()); + const card = doc.createElement("div"); + card.className = "model-weave-graph-hover-card"; + card.setAttribute("role", "tooltip"); + const title = doc.createElement("div"); + title.className = "model-weave-graph-hover-card-title"; + title.textContent = target.hoverTitle ?? target.label ?? "Model Weave"; + card.appendChild(title); + const rows = doc.createElement("dl"); + rows.className = "model-weave-graph-hover-card-rows"; + for (const row of target.hoverRows ?? []) { + const term = doc.createElement("dt"); + term.textContent = row.label; + const description = doc.createElement("dd"); + description.textContent = row.value?.trim() || "-"; + rows.appendChild(term); + rows.appendChild(description); + } + card.appendChild(rows); + hoverParent.appendChild(card); + positionGraphFallbackHoverCard(card, hoverParent, event); + return card; +} +function positionGraphFallbackHoverCard(card, hoverParent, event) { + const view = hoverParent.ownerDocument.defaultView; + const viewportWidth = view?.innerWidth ?? event.clientX + 360; + const x = Math.min(Math.max(12, event.clientX + 14), Math.max(12, viewportWidth - 380)); + const y = Math.max(12, event.clientY + 14); + card.style.left = `${x}px`; + card.style.top = `${y}px`; +} +function isGraphFallbackHoverCard(element) { + return element instanceof HTMLElement && element.classList.contains("model-weave-graph-hover-card"); +} function triggerGraphInteractionHoverPreview(app, source, hoverParent, targetEl, target, event, debug, stateDebug) { if (!target.linktext || !target.sourcePath) { return null; @@ -18536,9 +18640,11 @@ function renderDfdMermaidDiagram(diagram, options) { shell3.root.appendChild(createObjectDetails(diagram, options?.dfdDetailLabels)); shell3.root.appendChild(createFlowDetails(diagram.edges, options?.dfdDetailLabels)); } - const interactionTargets = buildDfdMermaidInteractionTargets( + const sourcePath = options?.interactionSourcePath ?? diagram.diagram.path; + const flowHoverMetadata = isFlowDiagramModel(diagram.diagram) ? buildFlowDiagramHoverMetadata(diagram, sourcePath) : null; + const interactionTargets = flowHoverMetadata?.objects ?? buildDfdMermaidInteractionTargets( diagram, - options?.interactionSourcePath ?? diagram.diagram.path + sourcePath ); const ready = renderMermaidSourceIntoShell(shell3, { source: buildDfdMermaidSource(diagram, options?.colorScheme), @@ -18553,6 +18659,14 @@ function renderDfdMermaidDiagram(diagram, options) { sourcePanelCopyLabel: options?.sourcePanelCopyLabel, showRenderDebug: !options?.forExport && options?.showMermaidRenderDebug === true }).then(() => { + if (!options?.forExport && options?.app && flowHoverMetadata) { + attachFlowDiagramFlowHoverPreviews( + shell3.surface, + flowHoverMetadata.flows, + options.app, + options?.showMermaidRenderDebug === true + ); + } if (!options?.forExport && options?.app && interactionTargets.length > 0) { attachMermaidNodeInteractions({ app: options.app, @@ -18562,8 +18676,19 @@ function renderDfdMermaidDiagram(diagram, options) { nodeClassName: "model-weave-mermaid-interactive-node", dragThreshold: 6, isDebugEnabled: () => options?.showMermaidRenderDebug === true, - debugName: "DFD Mermaid", - formatTitle: (target) => target.label ? `${target.label} (${target.targetType ?? "model"})` : target.linktext + debugName: isFlowDiagramModel(diagram.diagram) ? "Flow Diagram Mermaid" : "DFD Mermaid", + formatTitle: (target) => target.label ? `${target.label} (${target.targetType ?? "model"})` : target.linktext, + openLinkText: isFlowDiagramModel(diagram.diagram) ? (target, event) => { + const linktext = getFlowDiagramOpenLinkText(target); + if (!linktext) { + return; + } + return options.app?.workspace.openLinkText( + linktext, + target.sourcePath, + event.ctrlKey || event.metaKey + ); + } : void 0 }); } }).catch(() => { @@ -18579,6 +18704,182 @@ function renderDfdMermaidDiagram(diagram, options) { setMermaidRenderReadyPromise(shell3.root, ready); return shell3.root; } +function buildFlowDiagramHoverMetadata(diagram, sourcePath) { + if (!isFlowDiagramModel(diagram.diagram)) { + return { objects: [], flows: [] }; + } + const objects = diagram.nodes.map((node) => { + const rows = buildFlowDiagramObjectHoverRows(node); + const hoverTitle = "Flow Object"; + const target = { + mermaidId: toMermaidNodeId(node.id), + linktext: getFlowDiagramFallbackLinktext(sourcePath), + sourcePath, + label: node.label ?? node.id, + kind: "flow-diagram-object", + targetType: "flow_diagram_object", + filePath: getStringMetadata(node.metadata, "refModelPath"), + modelId: node.id, + modelType: "flow-diagram", + nodeId: node.id, + hoverTitle, + hoverRows: rows, + hoverText: formatHoverText(hoverTitle, rows), + previewLinktext: getResolvedPreviewLinktext(node.ref, getStringMetadata(node.metadata, "refModelPath")), + nativeTooltip: formatFlowDiagramObjectTooltip(node) + }; + return target; + }); + const flows = diagram.edges.map((edge, index) => { + const data = getStringMetadata(edge.metadata, "dataRaw") ?? edge.label; + const rows = buildFlowDiagramFlowHoverRows(edge, data); + const hoverTitle = "Flow"; + return { + mermaidId: toFlowDiagramFlowMermaidId(edge, index), + linktext: getFlowDiagramFallbackLinktext(sourcePath), + sourcePath, + edgeId: edge.id, + source: edge.source, + target: edge.target, + label: edge.label, + data, + kind: "flow-diagram-flow", + targetType: "flow_diagram_flow", + modelId: edge.id, + modelType: "flow-diagram", + filePath: getStringMetadata(edge.metadata, "dataModelPath"), + hoverTitle, + hoverRows: rows, + hoverText: formatHoverText(hoverTitle, rows), + previewLinktext: getResolvedPreviewLinktext(data, getStringMetadata(edge.metadata, "dataModelPath")), + nativeTooltip: formatFlowDiagramFlowTooltip(edge, data) + }; + }); + return { objects, flows }; +} +function formatFlowDiagramObjectTooltip(node) { + const lines = [ + `Flow Object: ${node.id}`, + node.label, + `kind: ${typeof node.kind === "string" ? node.kind : "-"}`, + `domain: ${getStringMetadata(node.metadata, "domain") ?? "-"}`, + `ref: ${node.ref?.trim() || "-"}` + ]; + const notes = formatDiagramEdgeNotes(node.metadata?.notes); + if (notes) { + lines.push(notes); + } + return lines.filter((line) => Boolean(line && line.trim())).join("\n"); +} +function formatFlowDiagramFlowTooltip(edge, data) { + const lines = [ + `Flow: ${edge.id ?? "-"}`, + `${edge.source} -> ${edge.target}`, + `data: ${data?.trim() || "-"}` + ]; + const notes = formatDiagramEdgeNotes(edge.metadata?.notes); + if (notes) { + lines.push(notes); + } + return lines.join("\n"); +} +function buildFlowDiagramObjectHoverRows(node) { + return [ + { label: "id", value: node.id }, + { label: "label", value: node.label }, + { label: "kind", value: typeof node.kind === "string" ? node.kind : void 0 }, + { label: "domain", value: getStringMetadata(node.metadata, "domain") }, + { label: "ref", value: node.ref }, + { label: "notes", value: formatDiagramEdgeNotes(node.metadata?.notes) } + ]; +} +function buildFlowDiagramFlowHoverRows(edge, data) { + return [ + { label: "id", value: edge.id }, + { label: "from", value: edge.source }, + { label: "to", value: edge.target }, + { label: "data", value: data }, + { label: "notes", value: formatDiagramEdgeNotes(edge.metadata?.notes) } + ]; +} +function formatHoverText(title, rows) { + return [ + title, + ...rows.map((row) => `${row.label}: ${formatHoverValue(row.value)}`) + ].join("\n"); +} +function formatHoverValue(value) { + const trimmed = value?.trim(); + return trimmed || "-"; +} +function getStringMetadata(metadata, key) { + const value = metadata?.[key]; + return typeof value === "string" ? value : void 0; +} +function toFlowDiagramFlowMermaidId(edge, index) { + const id = edge.id?.trim() || `${edge.source}_${edge.target}_${index + 1}`; + return `FLOW_${index + 1}_${toMermaidNodeId(id)}`; +} +function getResolvedPreviewLinktext(rawReference, resolvedPath) { + if (!resolvedPath) { + return void 0; + } + const trimmed = rawReference?.trim(); + if (!trimmed) { + return void 0; + } + const parsed = parseReferenceValue(trimmed); + return parsed?.kind === "wikilink" || parsed?.kind === "markdown_link" ? resolvedPath : void 0; +} +function getFlowDiagramFallbackLinktext(sourcePath) { + return sourcePath; +} +function getFlowDiagramOpenLinkText(target) { + const linktext = target.previewLinktext?.trim() || target.filePath?.trim(); + return linktext ? linktext : null; +} +function attachFlowDiagramFlowHoverPreviews(rootEl, flowTargets, app, showMermaidRenderDebug) { + if (flowTargets.length === 0) { + return; + } + const svg = rootEl.querySelector("svg"); + if (!svg) { + return; + } + const edgeLabels = Array.from(svg.querySelectorAll("g.edgeLabel")); + flowTargets.forEach((target, index) => { + const labelEl = edgeLabels[index]; + if (!labelEl) { + return; + } + setSvgNativeTooltip(labelEl, target.nativeTooltip); + labelEl.addClass("model-weave-mermaid-interactive-flow"); + labelEl.setAttribute("data-model-weave-flow-id", target.edgeId ?? target.mermaidId); + attachGraphElementHoverPreview({ + app, + targetEl: labelEl, + target, + rootEl, + source: "model-weave", + isDebugEnabled: () => showMermaidRenderDebug, + debugName: "Flow Diagram Mermaid Flow" + }); + }); +} +function setSvgNativeTooltip(element, text) { + const existingTitle = element.querySelector("title"); + const trimmed = text?.trim(); + if (!trimmed) { + existingTitle?.remove(); + element.removeAttribute("title"); + return; + } + const title = existingTitle ?? element.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "title"); + title.textContent = trimmed; + if (!title.parentElement) { + element.prepend(title); + } +} function buildDfdMermaidInteractionTargets(diagram, sourcePath) { return diagram.nodes.map((node) => { const object = getDfdObject(node); diff --git a/src/core/current-file-diagnostics.ts b/src/core/current-file-diagnostics.ts index 491ac08..447930f 100644 --- a/src/core/current-file-diagnostics.ts +++ b/src/core/current-file-diagnostics.ts @@ -1445,6 +1445,8 @@ export function localizeDiagnosticMessage(message: string, language?: string): s [/^unresolved DFD flow source "([^"]+)"$/, (_match, source) => `DFD flow の source "${source}" が解決できません。`], [/^unresolved DFD flow target "([^"]+)"$/, (_match, target) => `DFD flow の target "${target}" が解決できません。`], [/^unresolved DFD object ref "([^"]+)"$/, (_match, ref) => `DFDオブジェクトの参照 "${ref}" の参照先が見つかりません。IDまたはファイル名を確認してください。`], + [/^DFD flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `DFD flow data reference "${ref}" の参照先が見つかりません。data/model の id またはファイル名を確認してください。`], + [/^Flow Diagram flow data reference "([^"]+)" could not be resolved\. Check the data\/model id or file name\.$/, (_match, ref) => `Flow Diagram flow data reference "${ref}" の参照先が見つかりません。data/model の id またはファイル名を確認してください。`], [/^DFD object ref "([^"]+)" could not be resolved\. Check the ID or file name\.$/, (_match, ref) => `DFDオブジェクトの参照 "${ref}" の参照先が見つかりません。IDまたはファイル名を確認してください。`], [/^frontmatter parse error: unexpected list item "([^"]+)"$/, (_match, value) => `frontmatter の解析に失敗しました。予期しないリスト項目です: "${value}"`], [/^frontmatter parse error: malformed line "([^"]+)"$/, (_match, value) => `frontmatter の解析に失敗しました。行の形式を確認してください: "${value}"`], diff --git a/src/core/relation-resolver.ts b/src/core/relation-resolver.ts index f013e0e..55945c9 100644 --- a/src/core/relation-resolver.ts +++ b/src/core/relation-resolver.ts @@ -31,6 +31,7 @@ import { mergeDomainDiagramSources } from "./domain-diagram-resolver"; import { validateDomainEntries } from "../parsers/domains-parser"; import { buildReferenceIdentityKeys, + extractWikilinkReferences, findModelByReference, getReferenceDisplayName, parseReferenceValue, @@ -234,17 +235,13 @@ function resolveDfdDiagramRelations( } } - const flowData = resolveDfdFlowDataDisplay(flow.data, index, { suppressUnresolvedWarning: isFlowDiagram }); - if (flowData.warning) { - warnings.push({ - code: "unresolved-reference", - message: flowData.warning, - severity: "warning", - path: diagram.path, - field: "Flows", - context - }); - } + const flowData = resolveDfdFlowDataDisplay(flow.data, index); + warnings.push(...resolveDfdFlowDataReferenceWarnings( + diagram, + flow.data, + index, + context + )); edges.push({ id: flow.id, @@ -536,6 +533,9 @@ function resolveDfdDiagramObjects( domain, rowIndex: entry.rowIndex, local: !ref, + refReference: resolvedIdentity?.parsed, + refModelPath: resolvedIdentity?.resolvedModel?.path, + refModelType: resolvedIdentity?.resolvedModel?.fileType, compatibilityMode: entry.compatibilityMode }, object: resolvedObject @@ -599,15 +599,40 @@ function resolveDfdFlowEndpoint( return null; } -function resolveDfdFlowDataDisplay( +function resolveDfdFlowDataReferenceWarnings( + diagram: DfdDiagramModel | FlowDiagramModel, rawValue: string | undefined, index: ModelingVaultIndex, - options: { suppressUnresolvedWarning?: boolean } = {} + context: { section: string; rowIndex: number; relatedId?: string } +): ValidationWarning[] { + const wikilinks = rawValue ? extractWikilinkReferences(rawValue) : []; + if (wikilinks.length === 0) { + return []; + } + + const diagramLabel = diagram.schema === "flow_diagram" ? "Flow Diagram" : "DFD"; + return wikilinks + .filter((reference) => !resolveReferenceIdentity(reference, index).resolvedModel) + .map((reference) => ({ + code: "unresolved-reference", + message: `${diagramLabel} flow data reference "${reference}" could not be resolved. Check the data/model id or file name.`, + severity: "warning", + path: diagram.path, + field: "data", + context: { + ...context, + referenceValue: reference + } + })); +} + +function resolveDfdFlowDataDisplay( + rawValue: string | undefined, + index: ModelingVaultIndex ): { label?: string; reference?: ReturnType; model?: ParsedFileModel | null; - warning?: string; } { const trimmed = rawValue?.trim(); if (!trimmed) { @@ -653,8 +678,7 @@ function resolveDfdFlowDataDisplay( if (reference.target) { return { label: getReferenceDisplayName(trimmed), - reference, - warning: options.suppressUnresolvedWarning ? undefined : `unresolved flow data reference "${trimmed}"` + reference }; } diff --git a/src/renderers/dfd-mermaid.ts b/src/renderers/dfd-mermaid.ts index d591523..28b2ab2 100644 --- a/src/renderers/dfd-mermaid.ts +++ b/src/renderers/dfd-mermaid.ts @@ -24,8 +24,14 @@ import { setMermaidRenderReadyPromise } from "./mermaid-shared"; import { sanitizeMermaidId } from "./mermaid-helpers"; +import { parseReferenceValue } from "../core/reference-resolver"; import { modelWeaveText } from "../i18n/language"; -import { attachMermaidNodeInteractions, type GraphInteractionTarget } from "../views/mermaid-node-interactions"; +import { + attachGraphElementHoverPreview, + attachMermaidNodeInteractions, + type GraphInteractionHoverRow, + type GraphInteractionTarget +} from "../views/mermaid-node-interactions"; export interface DfdDetailLabels { displayedObjects: string; @@ -37,6 +43,18 @@ export interface DfdDetailLabels { unresolved: string; } +export interface FlowDiagramHoverMetadata { + objects: GraphInteractionTarget[]; + flows: FlowDiagramFlowHoverTarget[]; +} + +export interface FlowDiagramFlowHoverTarget extends GraphInteractionTarget { + edgeId?: string; + source: string; + target: string; + data?: string; +} + export function renderDfdMermaidDiagram( diagram: ResolvedDiagram, options?: { @@ -94,9 +112,13 @@ export function renderDfdMermaidDiagram( shell.root.appendChild(createFlowDetails(diagram.edges, options?.dfdDetailLabels)); } - const interactionTargets = buildDfdMermaidInteractionTargets( + const sourcePath = options?.interactionSourcePath ?? diagram.diagram.path; + const flowHoverMetadata = isFlowDiagramModel(diagram.diagram) + ? buildFlowDiagramHoverMetadata(diagram, sourcePath) + : null; + const interactionTargets = flowHoverMetadata?.objects ?? buildDfdMermaidInteractionTargets( diagram, - options?.interactionSourcePath ?? diagram.diagram.path + sourcePath ); const ready = renderMermaidSourceIntoShell(shell, { @@ -113,6 +135,14 @@ export function renderDfdMermaidDiagram( showRenderDebug: !options?.forExport && options?.showMermaidRenderDebug === true }).then(() => { + if (!options?.forExport && options?.app && flowHoverMetadata) { + attachFlowDiagramFlowHoverPreviews( + shell.surface, + flowHoverMetadata.flows, + options.app, + options?.showMermaidRenderDebug === true + ); + } if (!options?.forExport && options?.app && interactionTargets.length > 0) { attachMermaidNodeInteractions({ app: options.app, @@ -122,10 +152,23 @@ export function renderDfdMermaidDiagram( nodeClassName: "model-weave-mermaid-interactive-node", dragThreshold: 6, isDebugEnabled: () => options?.showMermaidRenderDebug === true, - debugName: "DFD Mermaid", + debugName: isFlowDiagramModel(diagram.diagram) ? "Flow Diagram Mermaid" : "DFD Mermaid", formatTitle: (target) => target.label ? `${target.label} (${target.targetType ?? "model"})` - : target.linktext + : target.linktext, + openLinkText: isFlowDiagramModel(diagram.diagram) + ? (target, event) => { + const linktext = getFlowDiagramOpenLinkText(target); + if (!linktext) { + return; + } + return options.app?.workspace.openLinkText( + linktext, + target.sourcePath, + event.ctrlKey || event.metaKey + ); + } + : undefined }); } }).catch(() => { @@ -144,6 +187,215 @@ export function renderDfdMermaidDiagram( } +export function buildFlowDiagramHoverMetadata( + diagram: ResolvedDiagram, + sourcePath: string +): FlowDiagramHoverMetadata { + if (!isFlowDiagramModel(diagram.diagram)) { + return { objects: [], flows: [] }; + } + + const objects = diagram.nodes.map((node) => { + const rows = buildFlowDiagramObjectHoverRows(node); + const hoverTitle = "Flow Object"; + const target: GraphInteractionTarget = { + mermaidId: toMermaidNodeId(node.id), + linktext: getFlowDiagramFallbackLinktext(sourcePath), + sourcePath, + label: node.label ?? node.id, + kind: "flow-diagram-object", + targetType: "flow_diagram_object", + filePath: getStringMetadata(node.metadata, "refModelPath"), + modelId: node.id, + modelType: "flow-diagram", + nodeId: node.id, + hoverTitle, + hoverRows: rows, + hoverText: formatHoverText(hoverTitle, rows), + previewLinktext: getResolvedPreviewLinktext(node.ref, getStringMetadata(node.metadata, "refModelPath")), + nativeTooltip: formatFlowDiagramObjectTooltip(node) + }; + return target; + }); + + const flows = diagram.edges.map((edge, index) => { + const data = getStringMetadata(edge.metadata, "dataRaw") ?? edge.label; + const rows = buildFlowDiagramFlowHoverRows(edge, data); + const hoverTitle = "Flow"; + return { + mermaidId: toFlowDiagramFlowMermaidId(edge, index), + linktext: getFlowDiagramFallbackLinktext(sourcePath), + sourcePath, + edgeId: edge.id, + source: edge.source, + target: edge.target, + label: edge.label, + data, + kind: "flow-diagram-flow", + targetType: "flow_diagram_flow", + modelId: edge.id, + modelType: "flow-diagram", + filePath: getStringMetadata(edge.metadata, "dataModelPath"), + hoverTitle, + hoverRows: rows, + hoverText: formatHoverText(hoverTitle, rows), + previewLinktext: getResolvedPreviewLinktext(data, getStringMetadata(edge.metadata, "dataModelPath")), + nativeTooltip: formatFlowDiagramFlowTooltip(edge, data) + }; + }); + + return { objects, flows }; +} + +function formatFlowDiagramObjectTooltip(node: DiagramNode): string { + const lines = [ + `Flow Object: ${node.id}`, + node.label, + `kind: ${typeof node.kind === "string" ? node.kind : "-"}`, + `domain: ${getStringMetadata(node.metadata, "domain") ?? "-"}`, + `ref: ${node.ref?.trim() || "-"}` + ]; + const notes = formatDiagramEdgeNotes(node.metadata?.notes); + if (notes) { + lines.push(notes); + } + return lines.filter((line): line is string => Boolean(line && line.trim())).join("\n"); +} + +function formatFlowDiagramFlowTooltip(edge: DiagramEdge, data: string | undefined): string { + const lines = [ + `Flow: ${edge.id ?? "-"}`, + `${edge.source} -> ${edge.target}`, + `data: ${data?.trim() || "-"}` + ]; + const notes = formatDiagramEdgeNotes(edge.metadata?.notes); + if (notes) { + lines.push(notes); + } + return lines.join("\n"); +} + +function buildFlowDiagramObjectHoverRows(node: DiagramNode): GraphInteractionHoverRow[] { + return [ + { label: "id", value: node.id }, + { label: "label", value: node.label }, + { label: "kind", value: typeof node.kind === "string" ? node.kind : undefined }, + { label: "domain", value: getStringMetadata(node.metadata, "domain") }, + { label: "ref", value: node.ref }, + { label: "notes", value: formatDiagramEdgeNotes(node.metadata?.notes) } + ]; +} + +function buildFlowDiagramFlowHoverRows(edge: DiagramEdge, data: string | undefined): GraphInteractionHoverRow[] { + return [ + { label: "id", value: edge.id }, + { label: "from", value: edge.source }, + { label: "to", value: edge.target }, + { label: "data", value: data }, + { label: "notes", value: formatDiagramEdgeNotes(edge.metadata?.notes) } + ]; +} + +function formatHoverText(title: string, rows: GraphInteractionHoverRow[]): string { + return [ + title, + ...rows.map((row) => `${row.label}: ${formatHoverValue(row.value)}`) + ].join("\n"); +} + +function formatHoverValue(value: string | undefined): string { + const trimmed = value?.trim(); + return trimmed || "-"; +} + +function getStringMetadata(metadata: Record | undefined, key: string): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" ? value : undefined; +} + +function toFlowDiagramFlowMermaidId(edge: DiagramEdge, index: number): string { + const id = edge.id?.trim() || `${edge.source}_${edge.target}_${index + 1}`; + return `FLOW_${index + 1}_${toMermaidNodeId(id)}`; +} + +function getResolvedPreviewLinktext(rawReference: string | undefined, resolvedPath: string | undefined): string | undefined { + if (!resolvedPath) { + return undefined; + } + + const trimmed = rawReference?.trim(); + if (!trimmed) { + return undefined; + } + + const parsed = parseReferenceValue(trimmed); + return parsed?.kind === "wikilink" || parsed?.kind === "markdown_link" + ? resolvedPath + : undefined; +} + +function getFlowDiagramFallbackLinktext(sourcePath: string): string { + return sourcePath; +} + +function getFlowDiagramOpenLinkText(target: GraphInteractionTarget): string | null { + const linktext = target.previewLinktext?.trim() || target.filePath?.trim(); + return linktext ? linktext : null; +} + +function attachFlowDiagramFlowHoverPreviews( + rootEl: HTMLElement, + flowTargets: FlowDiagramFlowHoverTarget[], + app: App, + showMermaidRenderDebug: boolean +): void { + if (flowTargets.length === 0) { + return; + } + + const svg = rootEl.querySelector("svg"); + if (!svg) { + return; + } + + const edgeLabels = Array.from(svg.querySelectorAll("g.edgeLabel")); + flowTargets.forEach((target, index) => { + const labelEl = edgeLabels[index]; + if (!labelEl) { + return; + } + setSvgNativeTooltip(labelEl, target.nativeTooltip); + labelEl.addClass("model-weave-mermaid-interactive-flow"); + labelEl.setAttribute("data-model-weave-flow-id", target.edgeId ?? target.mermaidId); + attachGraphElementHoverPreview({ + app, + targetEl: labelEl, + target, + rootEl, + source: "model-weave", + isDebugEnabled: () => showMermaidRenderDebug, + debugName: "Flow Diagram Mermaid Flow" + }); + }); +} + +function setSvgNativeTooltip(element: SVGElement, text: string | undefined): void { + const existingTitle = element.querySelector("title"); + const trimmed = text?.trim(); + if (!trimmed) { + existingTitle?.remove(); + element.removeAttribute("title"); + return; + } + + const title = existingTitle + ?? element.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "title"); + title.textContent = trimmed; + if (!title.parentElement) { + element.prepend(title); + } +} + function buildDfdMermaidInteractionTargets( diagram: ResolvedDiagram, sourcePath: string diff --git a/src/views/mermaid-node-interactions.ts b/src/views/mermaid-node-interactions.ts index 49d5935..4d3808d 100644 --- a/src/views/mermaid-node-interactions.ts +++ b/src/views/mermaid-node-interactions.ts @@ -12,6 +12,16 @@ export interface GraphInteractionTarget { modelType?: string; nodeId?: string; status?: string; + hoverTitle?: string; + hoverRows?: GraphInteractionHoverRow[]; + hoverText?: string; + previewLinktext?: string; + nativeTooltip?: string; +} + +export interface GraphInteractionHoverRow { + label: string; + value?: string; } export interface AttachMermaidNodeInteractionsOptions { @@ -311,7 +321,7 @@ export function attachGraphElementHoverPreview( options: AttachGraphElementHoverPreviewOptions ): () => void { const fallback = options.rootEl ?? getElementHoverFallback(options.targetEl); - if (!fallback || !options.target.linktext || !options.target.sourcePath) { + if (!fallback || !canShowGraphInteractionHover(options.target)) { return () => undefined; } @@ -342,7 +352,7 @@ export function attachGraphElementHoverPreview( debugName: options.debugName, isDebugEnabled: options.isDebugEnabled }, fallback, event, false); - const hoverLinkTarget = triggerGraphInteractionHoverPreview( + const hoverLinkTarget = triggerGraphInteractionHover( options.app, source, resolveGraphHoverParent(options.targetEl, fallback, options.hoverParent), @@ -420,7 +430,11 @@ function clearGraphHoverState( state.activeHoverTargetEl = null; if (activeHoverTargetEl && !suppressSyntheticLeave) { - dispatchGraphHoverTargetLeave(activeHoverTargetEl); + if (isGraphFallbackHoverCard(activeHoverTargetEl)) { + activeHoverTargetEl.remove(); + } else { + dispatchGraphHoverTargetLeave(activeHoverTargetEl); + } } else if (hadActiveNode && !suppressSyntheticLeave) { dispatchGraphHoverTargetLeave(rootEl); } @@ -518,18 +532,27 @@ function findMermaidNodeInteractionByTarget( return interactions.find((interaction) => interaction.target === target) ?? null; } +export function getGraphInteractionNativeTooltipText( + target: GraphInteractionTarget, + fallbackTitle?: string +): string | undefined { + return target.nativeTooltip ?? fallbackTitle ?? target.label ?? target.linktext; +} + function setMermaidNodeTitle( nodeEl: SVGElement, target: GraphInteractionTarget, formatTitle: AttachMermaidNodeInteractionsOptions["formatTitle"] ): void { - const titleText = formatTitle?.(target) ?? target.label ?? target.linktext; + const existingTitle = nodeEl.querySelector("title"); + const titleText = getGraphInteractionNativeTooltipText(target, formatTitle?.(target)); if (!titleText) { + existingTitle?.remove(); return; } const doc = nodeEl.ownerDocument; - const title = nodeEl.querySelector("title") ?? doc.createElementNS("http://www.w3.org/2000/svg", "title"); + const title = existingTitle ?? doc.createElementNS("http://www.w3.org/2000/svg", "title"); title.textContent = titleText; if (!title.parentElement) { nodeEl.prepend(title); @@ -544,13 +567,13 @@ function triggerMermaidNodeHoverPreview( event: MouseEvent, stateDebug?: HoverStateDebugFields ): HoverLinkTargetResolution | null { - if (!target.linktext || !target.sourcePath) { + if (!canShowGraphInteractionHover(target)) { return null; } const hoverParent = resolveGraphHoverParent(targetEl, options.rootEl, options.hoverParent); - return triggerGraphInteractionHoverPreview( + return triggerGraphInteractionHover( options.app, source, hoverParent, @@ -565,6 +588,119 @@ function triggerMermaidNodeHoverPreview( ); } +function canShowGraphInteractionHover(target: GraphInteractionTarget): boolean { + return Boolean( + (target.previewLinktext && target.sourcePath) || + (target.linktext && target.sourcePath && !target.hoverRows?.length) || + target.hoverRows?.length + ); +} + +function triggerGraphInteractionHover( + app: App, + source: string, + hoverParent: HTMLElement, + targetEl: HTMLElement | SVGElement, + target: GraphInteractionTarget, + event: MouseEvent, + debug?: HoverPreviewDebugOptions, + stateDebug?: HoverStateDebugFields +): HoverLinkTargetResolution | null { + const previewLinktext = getGraphInteractionPreviewLinktext(target); + if (previewLinktext) { + const previewTarget = { ...target, linktext: previewLinktext }; + return triggerGraphInteractionHoverPreview( + app, + source, + hoverParent, + targetEl, + previewTarget, + event, + debug, + stateDebug + ) ?? triggerGraphInteractionFallbackHover(hoverParent, target, event); + } + + return triggerGraphInteractionFallbackHover(hoverParent, target, event); +} + +function getGraphInteractionPreviewLinktext(target: GraphInteractionTarget): string | null { + const explicit = target.previewLinktext?.trim(); + if (explicit) { + return explicit; + } + if (target.hoverRows?.length) { + return null; + } + const linktext = target.linktext?.trim(); + return linktext && target.sourcePath ? linktext : null; +} + +function triggerGraphInteractionFallbackHover( + hoverParent: HTMLElement, + target: GraphInteractionTarget, + event: MouseEvent +): HoverLinkTargetResolution | null { + if (!target.hoverRows?.length) { + return null; + } + + const card = createGraphFallbackHoverCard(hoverParent, target, event); + return { targetEl: card, reusableAnchorTargetUsed: false }; +} + +function createGraphFallbackHoverCard( + hoverParent: HTMLElement, + target: GraphInteractionTarget, + event: MouseEvent +): HTMLElement { + const doc = hoverParent.ownerDocument; + const existing = hoverParent.querySelectorAll(".model-weave-graph-hover-card"); + existing.forEach((element) => element.remove()); + + const card = doc.createElement("div"); + card.className = "model-weave-graph-hover-card"; + card.setAttribute("role", "tooltip"); + + const title = doc.createElement("div"); + title.className = "model-weave-graph-hover-card-title"; + title.textContent = target.hoverTitle ?? target.label ?? "Model Weave"; + card.appendChild(title); + + const rows = doc.createElement("dl"); + rows.className = "model-weave-graph-hover-card-rows"; + for (const row of target.hoverRows ?? []) { + const term = doc.createElement("dt"); + term.textContent = row.label; + const description = doc.createElement("dd"); + description.textContent = row.value?.trim() || "-"; + rows.appendChild(term); + rows.appendChild(description); + } + card.appendChild(rows); + + hoverParent.appendChild(card); + positionGraphFallbackHoverCard(card, hoverParent, event); + return card; +} + +function positionGraphFallbackHoverCard( + card: HTMLElement, + hoverParent: HTMLElement, + event: MouseEvent +): void { + const view = hoverParent.ownerDocument.defaultView; + const viewportWidth = view?.innerWidth ?? event.clientX + 360; + const x = Math.min(Math.max(12, event.clientX + 14), Math.max(12, viewportWidth - 380)); + const y = Math.max(12, event.clientY + 14); + card.style.left = `${x}px`; + card.style.top = `${y}px`; +} + +function isGraphFallbackHoverCard(element: HTMLElement | SVGElement): element is HTMLElement { + return element instanceof HTMLElement && element.classList.contains("model-weave-graph-hover-card"); +} + function triggerGraphInteractionHoverPreview( app: App, source: string, diff --git a/styles.css b/styles.css index 4f6e234..a19a000 100644 --- a/styles.css +++ b/styles.css @@ -1992,3 +1992,43 @@ body.model-weave-focus-mode-active .popover.hover-popover { text-overflow: ellipsis; white-space: nowrap; } + + +.model-weave-graph-hover-card { + position: fixed; + z-index: 1000; + max-width: min(360px, calc(100% - 24px)); + padding: 10px 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-primary); + box-shadow: var(--shadow-s); + color: var(--text-normal); + font-size: var(--font-ui-small); + line-height: 1.35; + pointer-events: none; +} + +.model-weave-graph-hover-card-title { + margin-bottom: 6px; + font-weight: 700; +} + +.model-weave-graph-hover-card-rows { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 8px; + margin: 0; +} + +.model-weave-graph-hover-card-rows dt { + margin: 0; + color: var(--text-muted); + font-weight: 600; +} + +.model-weave-graph-hover-card-rows dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} diff --git a/test/domains-model.test.mjs b/test/domains-model.test.mjs index d5e0089..7a7c080 100644 --- a/test/domains-model.test.mjs +++ b/test/domains-model.test.mjs @@ -32,7 +32,8 @@ await build({ 'export { renderClassDiagram } from "./src/renderers/class-renderer";', 'export { buildVaultIndex, ensureVaultValidation, replaceVaultIndexFile } from "./src/core/vault-index";', 'export { resolveObjectContext } from "./src/core/object-context-resolver";', - 'export { buildCurrentObjectDiagnostics, localizeDiagnosticMessage } from "./src/core/current-file-diagnostics";' + 'export { buildCurrentObjectDiagnostics, localizeDiagnosticMessage } from "./src/core/current-file-diagnostics";', + 'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";' ].join("\n"), resolveDir: ".", sourcefile: "test-domains-model-entry.ts", @@ -125,6 +126,7 @@ const { buildCurrentObjectDiagnostics, ensureVaultValidation, localizeDiagnosticMessage, + getExpectedHeaderForDiagnostic, resolveDiagramRelations, replaceVaultIndexFile } = await import( @@ -1849,6 +1851,79 @@ test("DFD files without local Domains remain compatible", () => { assert.equal(warnings.length, 0); }); +function dfdDataWarningMarkdown(dataValue) { + return `${dfdFrontmatter} +## Objects + +| id | label | kind | ref | notes | +|---|---|---|---|---| +| user | User | external | | User | +| pick | Pick items | process | | Pick | + +## Flows + +| id | from | to | data | notes | +|---|---|---|---|---| +| request | user | pick | ${dataValue} | User request | +`; +} + +function getDfdFlowDataWarnings(dataValue, extraFiles = []) { + const { resolved } = resolveDfdWithFiles([ + { path: "DFD-SHIPPING.md", content: dfdDataWarningMarkdown(dataValue) }, + ...extraFiles + ]); + return resolved.warnings.filter((warning) => + warning.code === "unresolved-reference" && /flow data reference/.test(warning.message) + ); +} + +test("dfd_diagram Flows.data plain text does not warn", () => { + assert.equal(getDfdFlowDataWarnings("DATA-ORDER-DRAFT").length, 0); + assert.equal(getDfdFlowDataWarnings("Search condition").length, 0); +}); + +test("dfd_diagram Flows.data empty value does not warn", () => { + assert.equal(getDfdFlowDataWarnings("").length, 0); +}); + +test("dfd_diagram Flows.data resolved Wikilink does not warn", () => { + const warnings = getDfdFlowDataWarnings("[[DATA-ORDER-DRAFT]]", [ + { path: "DATA-ORDER-DRAFT.md", content: "---\ntype: data_object\nid: DATA-ORDER-DRAFT\nname: Order Draft\n---\n" } + ]); + + assert.equal(warnings.length, 0); +}); + +test("dfd_diagram Flows.data unresolved Wikilink emits unresolved-reference warning", () => { + const warnings = getDfdFlowDataWarnings("[[DATA-NOT-EXISTS]]"); + + assert.equal(warnings.length, 1); + assert.equal(warnings[0].message, 'DFD flow data reference "[[DATA-NOT-EXISTS]]" could not be resolved. Check the data/model id or file name.'); + assert.equal(warnings[0].severity, "warning"); + assert.equal(warnings[0].field, "data"); + assert.equal(warnings[0].context.section, "Flows"); + assert.equal(warnings[0].context.rowIndex, 1); + assert.equal(warnings[0].context.referenceValue, "[[DATA-NOT-EXISTS]]"); + assert.equal(getExpectedHeaderForDiagnostic(warnings[0]), null); + assert.notEqual(warnings[0].context.referenceKind, "local-object-id"); +}); + +test("dfd_diagram Flows.data multiple Wikilinks warn only for unresolved links", () => { + const warnings = getDfdFlowDataWarnings("[[DATA-A]], [[DATA-B]]", [ + { 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("localizes DFD flow data reference diagnostics", () => { + assert.equal( + localizeDiagnosticMessage('DFD flow data reference "[[DATA-NOT-EXISTS]]" could not be resolved. Check the data/model id or file name.', "ja"), + 'DFD flow data reference "[[DATA-NOT-EXISTS]]" の参照先が見つかりません。data/model の id またはファイル名を確認してください。' + ); +}); + test("DFD Objects refs accept non-DFD Model Weave assets without unresolved warnings", () => { const dfd = `--- type: dfd_diagram diff --git a/test/flow-diagram.test.mjs b/test/flow-diagram.test.mjs index 80e0bce..034ab15 100644 --- a/test/flow-diagram.test.mjs +++ b/test/flow-diagram.test.mjs @@ -13,7 +13,7 @@ await build({ 'export { isDiagramPreviewRouteFileType, isDfdLikeDiagramPreviewFileType } from "./src/core/preview-routing";', 'export { isModelWeavePreviewSupportedFileType } from "./src/core/supported-formats";', 'export { resolveDiagramRelations } from "./src/core/relation-resolver";', - 'export { buildDfdMermaidSource, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";', + '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";', @@ -81,6 +81,7 @@ await build({ const { buildCurrentDiagramDiagnostics, buildDfdMermaidSource, + buildFlowDiagramHoverMetadata, createModelWeaveTranslator, getAppliedColorSchemeLowerPaneSlot, getAppliedColorSchemeRowsForTargets, @@ -253,6 +254,255 @@ test("flow_diagram Flows.data appears as edge label", () => { 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 |", @@ -519,4 +769,4 @@ test("Applied Color Scheme placement policy uses Details for color-aware preview test("DFD and Flow Applied Color Scheme placement is not Relationships", () => { assert.notEqual(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "impact"); assert.notEqual(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "impact"); -}); +}); diff --git a/test/mermaid-node-interactions.test.mjs b/test/mermaid-node-interactions.test.mjs index cdeb1af..04e6973 100644 --- a/test/mermaid-node-interactions.test.mjs +++ b/test/mermaid-node-interactions.test.mjs @@ -6,7 +6,7 @@ const outputFile = "dist/test-mermaid-node-interactions.mjs"; await build({ stdin: { - contents: 'export { resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } from "./src/views/mermaid-node-interactions";', + contents: 'export { getGraphInteractionNativeTooltipText, resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } from "./src/views/mermaid-node-interactions";', resolveDir: ".", sourcefile: "test-mermaid-node-interactions-entry.ts", loader: "ts" @@ -33,9 +33,11 @@ await build({ logLevel: "silent" }); -const { resolveGraphHoverParent, resolveGraphHoverLinkTargetElement } = await import( - `../${outputFile}?t=${Date.now()}` -); +const { + getGraphInteractionNativeTooltipText, + resolveGraphHoverParent, + resolveGraphHoverLinkTargetElement +} = await import(`../${outputFile}?t=${Date.now()}`); class TestElement { constructor(className = "", parent = null) { @@ -125,3 +127,30 @@ test("resolveGraphHoverLinkTargetElement falls back through viewport and viewer assert.equal(resolveGraphHoverLinkTargetElement(svgNode, viewerRoot), viewport); assert.equal(resolveGraphHoverLinkTargetElement(detached, viewerRoot), viewerRoot); }); + + +test("getGraphInteractionNativeTooltipText keeps normal tooltip fallback without rich metadata", () => { + assert.equal( + getGraphInteractionNativeTooltipText({ + mermaidId: "node", + linktext: "MODEL.md", + sourcePath: "SOURCE.md", + label: "Model Node" + }), + "Model Node" + ); +}); + +test("getGraphInteractionNativeTooltipText prefers explicit safe tooltip for rich hover metadata", () => { + assert.equal( + getGraphInteractionNativeTooltipText({ + mermaidId: "node", + linktext: "SOURCE.md", + sourcePath: "SOURCE.md", + label: "[[MISSING]]", + hoverRows: [{ label: "ref", value: "[[MISSING]]" }], + nativeTooltip: "Flow Object: NODE\nref: [[MISSING]]" + }), + "Flow Object: NODE\nref: [[MISSING]]" + ); +});