Merge remote-tracking branch 'origin/main' into fix/kg-validator-entity-id

This commit is contained in:
T1mn
2026-08-21 16:32:44 +08:00
15 changed files with 2393 additions and 269 deletions
+1489 -14
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
"dependencies": {
@@ -29,6 +29,8 @@
"react-arborist": "^3.4.3",
"react-dom": "^19.2.4",
"react-dropzone": "^15.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"vis-data": "^8.0.3",
"vis-timeline": "^8.5.0"
@@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react";
import { graph } from "../../store/graphStore";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type { GraphSelectedNodeKind } from "./types";
import { MarkdownContentViewer } from "./MarkdownContentViewer";
export type LinkPrediction = {
target: string;
@@ -364,6 +365,11 @@ export function GraphInspectorPanel({
([key]) =>
!["x","y","valid_from","valid_until","content","source","source_url","pmid","pmids","evidence","provenance","confidence"].includes(key),
);
const nodeContent = (typeof attributes?.content === "string" && attributes.content)
? attributes.content
: (typeof properties.content === "string" && properties.content)
? properties.content
: "";
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
@@ -408,6 +414,20 @@ export function GraphInspectorPanel({
</div>
) : null}
{/* Content Section — only rendered when the node carries actual content.
This matches the existing inspector convention: sections that have no
data for the current node are either hidden (temporal bounds) or closed
by default (Source Attribution, Properties). Always showing an open
empty panel would add noise for every relationship/predicate node. */}
{nodeContent && (
<details className="node-panel-collapse" open>
<summary className="node-panel-summary">Content</summary>
<div className="node-panel-body" style={{ marginTop: 8 }}>
<MarkdownContentViewer content={nodeContent} />
</div>
</details>
)}
{/* Actions */}
<section style={sectionStyle}>
<div style={sectionTitleStyle}>Actions</div>
@@ -0,0 +1,403 @@
import { useState, useRef, useEffect, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
import { GRAPH_THEME } from "./graphTheme";
export interface MarkdownContentViewerProps {
content?: string | null;
className?: string;
defaultMode?: "preview" | "source";
}
export function isSafeUrl(url?: string): boolean {
if (!url) return false;
const trimmed = url.trim();
// Reject whitespace-only strings — new URL("", base) would resolve to the base
// protocol and produce a false positive. This guards direct callers of the exported
// function; markdown parsers normalise whitespace-only destinations to "" which
// already fails the !url check above.
if (!trimmed) return false;
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("#")) return true;
if (trimmed.startsWith("/")) return true;
try {
const parsed = new URL(trimmed, "http://localhost");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
export function MarkdownContentViewer({
content,
className,
defaultMode = "preview",
}: MarkdownContentViewerProps) {
const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
const [copied, setCopied] = useState(false);
// Track the content value for which the copied indicator is valid.
// When content changes (i.e. the user selects a different node), reset the
// copied indicator inline during render rather than in a useEffect — this
// avoids a cascading-render lint error and is the React-recommended pattern
// for resetting derived visual state on prop changes.
const [copiedForContent, setCopiedForContent] = useState<string | null | undefined>(content);
if (copiedForContent !== content) {
setCopiedForContent(content);
if (copied) {
// Clear the stale indicator synchronously so the new node's copy button
// never shows "Copied" from the previous selection.
setCopied(false);
}
}
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up any outstanding timeout on unmount.
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const rawContent = typeof content === "string" ? content : "";
const hasContent = rawContent.trim().length > 0;
const handleCopy = async () => {
if (!hasContent) return;
try {
await navigator.clipboard.writeText(rawContent);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard write unavailable
}
};
return (
<div className={className} style={viewerContainerStyle}>
<div style={viewerHeaderStyle}>
<div style={{ display: "flex", gap: 4 }} role="tablist">
<button
type="button"
role="tab"
aria-selected={activeMode === "preview"}
onClick={() => setActiveMode("preview")}
style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
>
<Eye size={12} style={{ marginRight: 5 }} />
Preview
</button>
<button
type="button"
role="tab"
aria-selected={activeMode === "source"}
onClick={() => setActiveMode("source")}
style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
>
<Code2 size={12} style={{ marginRight: 5 }} />
Source
</button>
</div>
{hasContent && (
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
{copied ? (
<>
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} />
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
</>
) : (
<>
<Copy size={12} style={{ marginRight: 4 }} />
<span style={{ fontSize: 11 }}>Copy</span>
</>
)}
</button>
)}
</div>
<div style={viewerBodyStyle}>
{!hasContent ? (
<div style={emptyTextStyle}>No content available for this node.</div>
) : activeMode === "source" ? (
<pre style={sourcePreStyle}>
<code style={sourceCodeStyle}>{rawContent}</code>
</pre>
) : (
<div style={previewStyle}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// C-1: react-markdown passes a HAST `node` prop (the raw AST
// Element) to every custom component override via passNode:true.
// In React 19 any unknown prop spreads onto a native element are
// serialised as HTML attributes, producing node="[object Object]"
// on every rendered link. Fix: destructure `node` by name so it
// is explicitly discarded, then spread `...rest` to preserve all
// other legitimate HAST/remark-gfm attributes — e.g. the `id`,
// `aria-describedby`, `aria-label`, `data-footnote-ref`,
// `data-footnote-backref`, and `class` attrs that GFM footnotes
// require for correct in-page navigation and accessibility.
//
// C-2: fragment links (#anchor, GFM footnote backlinks) must
// navigate within the current document. External links continue
// to use target="_blank" with noopener noreferrer.
//
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ href, children, title, node: _node, ...rest }) => {
if (!isSafeUrl(href)) {
return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
}
// isSafeUrl returning true guarantees href is a non-empty string.
const safeHref = href ?? "";
// Fragment links (#section, footnote backlinks like
// #user-content-fnref-1) are in-document anchors. Opening them
// in a new tab would break GFM footnote back-navigation.
const isFragment = safeHref.startsWith("#");
if (isFragment) {
return (
<a href={safeHref} title={title} style={linkStyle} {...rest}>
{children}
</a>
);
}
return (
<a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
{children}
<ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
</a>
);
},
img: ({ src, alt }) => (
<span style={imageBadgeStyle} title={src || "Image"}>
<ImageIcon size={12} style={{ marginRight: 5 }} />
<span>Image: {alt || src || "unlabeled"}</span>
</span>
),
h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
table: ({ children }) => (
<div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
</div>
),
thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
// C-1: discard `node` here too — code elements are custom components
// and would otherwise receive node="[object Object]" in the DOM.
code: ({ className: codeClass, children }) => {
const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
return (
<code style={isInline ? inlineCodeStyle : blockCodeStyle}>
{children}
</code>
);
},
}}
>
{rawContent}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
}
/* ─── Styles ──────────────────────────────────────────────────────── */
const viewerContainerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
background: "rgba(255, 255, 255, 0.025)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 12,
overflow: "hidden",
};
const viewerHeaderStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
background: "rgba(0, 0, 0, 0.2)",
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const tabBtnStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
padding: "4px 9px",
borderRadius: 6,
border: "1px solid transparent",
background: "transparent",
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
transition: "all 150ms ease",
};
const activeTabBtnStyle: CSSProperties = {
background: GRAPH_THEME.ui.timeline.playheadSoft,
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
color: GRAPH_THEME.ui.timeline.playhead,
};
const copyBtnStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
padding: "3px 8px",
borderRadius: 6,
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
background: "rgba(255, 255, 255, 0.04)",
color: GRAPH_THEME.ui.text.subtle,
fontSize: 11,
cursor: "pointer",
};
const viewerBodyStyle: CSSProperties = {
padding: 12,
maxHeight: 380,
overflowY: "auto",
};
const emptyTextStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
lineHeight: 1.5,
fontStyle: "italic",
};
const sourcePreStyle: CSSProperties = {
margin: 0,
padding: 10,
borderRadius: 8,
background: "rgba(0, 0, 0, 0.3)",
border: "1px solid rgba(255, 255, 255, 0.05)",
overflowX: "auto",
};
const sourceCodeStyle: CSSProperties = {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 12,
lineHeight: 1.6,
color: GRAPH_THEME.ui.text.strong,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
userSelect: "text",
};
const previewStyle: CSSProperties = {
color: GRAPH_THEME.ui.text.body,
fontSize: 13,
lineHeight: 1.6,
wordBreak: "break-word",
};
const h1Style: CSSProperties = {
fontSize: 16,
fontWeight: 700,
color: GRAPH_THEME.ui.text.strong,
marginTop: 8,
marginBottom: 6,
paddingBottom: 3,
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const h2Style: CSSProperties = {
fontSize: 14,
fontWeight: 700,
color: GRAPH_THEME.ui.text.strong,
marginTop: 8,
marginBottom: 4,
};
const h3Style: CSSProperties = {
fontSize: 13,
fontWeight: 600,
color: GRAPH_THEME.ui.text.strong,
marginTop: 6,
marginBottom: 4,
};
const h4Style: CSSProperties = {
fontSize: 12,
fontWeight: 600,
color: GRAPH_THEME.ui.text.strong,
marginTop: 4,
marginBottom: 2,
};
const blockquoteStyle: CSSProperties = {
margin: "8px 0",
padding: "6px 12px",
borderLeft: `3px solid ${GRAPH_THEME.ui.timeline.playhead}`,
background: "rgba(98, 226, 205, 0.05)",
borderRadius: "0 6px 6px 0",
color: GRAPH_THEME.ui.text.body,
fontStyle: "italic",
};
const linkStyle: CSSProperties = {
color: "#79c0ff",
textDecoration: "underline",
textUnderlineOffset: "3px",
wordBreak: "break-all",
};
const imageBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
padding: "3px 7px",
background: "rgba(255, 255, 255, 0.04)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 6,
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
margin: "3px 0",
};
const inlineCodeStyle: CSSProperties = {
fontFamily: "'JetBrains Mono', monospace",
fontSize: 12,
padding: "2px 5px",
borderRadius: 4,
background: "rgba(255, 255, 255, 0.07)",
color: "#e6edf3",
border: "1px solid rgba(255, 255, 255, 0.08)",
};
const preBlockStyle: CSSProperties = {
margin: "8px 0",
padding: 10,
borderRadius: 8,
background: "rgba(0, 0, 0, 0.35)",
border: "1px solid rgba(255, 255, 255, 0.08)",
overflowX: "auto",
};
const blockCodeStyle: CSSProperties = {
fontFamily: "'JetBrains Mono', monospace",
fontSize: 12,
lineHeight: 1.5,
color: "#e6edf3",
};
@@ -0,0 +1,264 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToString } from "react-dom/server";
(globalThis as any).React = React;
import { isSafeUrl, MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
test("isSafeUrl permits safe http, https, and mailto URLs and relative paths", () => {
assert.equal(isSafeUrl("https://example.com"), true);
assert.equal(isSafeUrl("http://localhost:8000"), true);
assert.equal(isSafeUrl("mailto:user@example.com"), true);
assert.equal(isSafeUrl("#section-1"), true);
assert.equal(isSafeUrl("/relative/path"), true);
});
test("isSafeUrl rejects protocol-relative URLs and dangerous schemes", () => {
// Protocol-relative URLs (must be blocked)
assert.equal(isSafeUrl("//evil.com"), false);
assert.equal(isSafeUrl("//localhost:8000"), false);
assert.equal(isSafeUrl("//"), false);
// Dangerous schemes
assert.equal(isSafeUrl("javascript:alert('xss')"), false);
assert.equal(isSafeUrl("JAVASCRIPT:alert(1)"), false);
assert.equal(isSafeUrl("data:text/html;base64,PHNjcmlwdD4="), false);
assert.equal(isSafeUrl("vbscript:MsgBox(1)"), false);
assert.equal(isSafeUrl(""), false);
assert.equal(isSafeUrl(undefined), false);
});
// ─── C URL contract: whitespace-only strings ────────────────────────────────
// The CommonMark parser normalises whitespace-only link destinations to "" so
// these values are unreachable through normal markdown rendering. However, the
// function is exported and its direct-call contract must be correct.
test("isSafeUrl rejects whitespace-only strings (contract correctness)", () => {
assert.equal(isSafeUrl(" "), false, "single space must be rejected");
assert.equal(isSafeUrl("\t"), false, "tab must be rejected");
assert.equal(isSafeUrl("\n"), false, "newline must be rejected");
assert.equal(isSafeUrl(" "), false, "multiple spaces must be rejected");
assert.equal(isSafeUrl(" \t\n "), false, "mixed whitespace must be rejected");
});
test("renders Preview mode with formatted Markdown elements and tabs", () => {
const markdown = `# Main Title\n\n**Bold Statement**\n\n* Item A\n* Item B`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "preview" }));
// Tab buttons are present
assert.equal(html.includes("Preview"), true);
assert.equal(html.includes("Source"), true);
assert.equal(html.includes("Copy"), true);
// Formatted preview elements
assert.equal(html.includes("Main Title"), true);
assert.equal(html.includes("Bold Statement"), true);
assert.equal(html.includes("<strong>Bold Statement</strong>"), true);
assert.equal(html.includes("Item A"), true);
assert.equal(html.includes("Item B"), true);
});
test("renders Source mode with exact unmodified text inside pre/code", () => {
const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" }));
assert.equal(html.includes("<pre"), true);
assert.equal(html.includes("<code"), true);
assert.equal(html.includes("# Title 🚀"), true);
assert.equal(html.includes(" * Indented item"), true);
assert.equal(html.includes('return &quot;α + β&quot;'), true);
});
test("renders raw HTML safely as escaped text without executing elements", () => {
const dangerousHtml = `<script>alert("XSS")</script><iframe src="https://evil.com"></iframe>`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: dangerousHtml, defaultMode: "preview" }));
// Script and iframe tags must NOT be rendered as active DOM tags
assert.equal(html.includes("<script>"), false);
assert.equal(html.includes("<iframe"), false);
// Content is escaped as text
assert.equal(html.includes("&lt;script&gt;"), true);
});
// ─── C-1: HAST node prop must not reach the DOM ─────────────────────────────
// react-markdown passes a HAST `node` (Element) object to custom component
// overrides. Before this fix, ...props spread caused React 19 to serialise it
// as node="[object Object]" on every <a> and <code> element.
test("rendered links do not expose the HAST node object as a DOM attribute", () => {
const content = `[Example](https://example.com)\n\nInline \`code\` here.`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// The rendered HTML must not contain the serialised HAST object
assert.equal(html.includes("node="), false, "node= attribute must not appear in rendered HTML");
assert.equal(html.includes("[object Object]"), false, "serialised HAST object must not appear in rendered HTML");
// The link must still render correctly with the right href
assert.equal(html.includes('href="https://example.com"'), true, "href must be present");
});
// ─── C-2: Fragment links must not open in a new tab ─────────────────────────
// Links to in-document anchors such as #section or GFM footnote backlinks like
// #user-content-fn-1 must stay in the current document. Only external links
// use target="_blank".
test("fragment links render in the current document without target blank", () => {
const content = `[Jump to section](#introduction)\n\n[External](https://example.com)`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// Fragment link must have the href
assert.equal(html.includes('href="#introduction"'), true, "fragment href must be present");
// Confirm no target=_blank attribute appears anywhere near the fragment link.
// We check that the output contains a fragment href WITHOUT target="_blank"
// by verifying the two strings are not both present (the external link has
// target blank; the fragment link must not).
const fragmentLinkIdx = html.indexOf('href="#introduction"');
assert.notEqual(fragmentLinkIdx, -1, "fragment link must be rendered");
// Inspect the 80 chars around the fragment href — should not contain target
const fragmentContext = html.slice(Math.max(0, fragmentLinkIdx - 10), fragmentLinkIdx + 90);
assert.equal(fragmentContext.includes('target="_blank"'), false, "fragment link must not have target=_blank");
// External link must still have target blank
assert.equal(html.includes('href="https://example.com"'), true, "external href must be present");
assert.equal(html.includes('target="_blank"'), true, "external link must have target=_blank");
assert.equal(html.includes('rel="noopener noreferrer"'), true, "external link must have rel");
});
test("GFM footnote backlinks render without target blank", () => {
// GFM footnote syntax: footnote ref in text + definition below
const content = `See the note[^1] for more.\n\n[^1]: This is the footnote text.`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// The footnote reference link (#user-content-fn-1) and backlink
// (#user-content-fnref-1) are fragment links and must not open in a new tab.
// We verify no fragment href is paired with target=_blank.
// Extract all href="#..." occurrences and confirm none is adjacent to target=_blank.
const anchorMatches = [...html.matchAll(/href="#[^"]*"/g)];
assert.ok(anchorMatches.length > 0, "GFM footnotes must produce fragment links");
for (const match of anchorMatches) {
const start = match.index ?? 0;
const context = html.slice(Math.max(0, start - 10), start + 120);
assert.equal(
context.includes('target="_blank"'),
false,
`fragment link ${match[0]} must not have target=_blank`,
);
}
});
// ─── C-1-R: GFM footnote attributes must be preserved (regression test) ─────
// The C-1 fix (removing the HAST `node` prop) must NOT silently drop other
// legitimate HAST attributes. remark-gfm generates the following on footnote
// links that are required for correct in-page navigation and accessibility:
//
// Footnote reference anchor:
// id="user-content-fnref-1" ← backlink target
// data-footnote-ref="true"
// aria-describedby="footnote-label"
//
// Footnote back-link anchor:
// data-footnote-backref=""
// aria-label="Back to reference 1" ← screen-reader label
// class="data-footnote-backref"
//
// If these are absent, clicking the ↩ back-link cannot scroll back to the
// in-text reference, and screen readers cannot announce the backlink purpose.
test("GFM footnote links preserve generated id, aria, and class attributes", () => {
const content = `See the note[^1] for more.\n\n[^1]: This is the footnote text.`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// The HAST `node` object must not appear serialised as a DOM attribute.
assert.equal(html.includes("node="), false, "node= attribute must not appear in HTML");
assert.equal(html.includes("[object Object]"), false, "serialised HAST object must not appear in HTML");
// Footnote reference anchor must retain its id so the backlink can navigate to it.
assert.equal(
html.includes('id="user-content-fnref-1"'),
true,
"footnote reference anchor must retain id for back-navigation",
);
// Footnote backlink must retain its aria-label for screen-reader accessibility.
assert.equal(
html.includes('aria-label="Back to reference 1"'),
true,
"footnote backlink must retain aria-label for accessibility",
);
// Footnote backlink must retain its class attribute.
assert.equal(
html.includes('class="data-footnote-backref"'),
true,
"footnote backlink must retain class attribute",
);
});
test("renders safe links as <a> with target blank and unclickable span for unsafe links", () => {
const content = `[Safe Link](https://getsemantica.ai)\n\n[Unsafe Scheme](javascript:alert(1))\n\n[Protocol Relative](//evil.com)`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// Safe link renders as <a> with security attributes
assert.equal(html.includes('href="https://getsemantica.ai"'), true);
assert.equal(html.includes('target="_blank"'), true);
assert.equal(html.includes('rel="noopener noreferrer"'), true);
// Unsafe links do NOT render as <a> tags
assert.equal(html.includes('href="javascript:alert(1)"'), false);
assert.equal(html.includes('href="//evil.com"'), false);
assert.equal(html.includes("Unsafe Scheme"), true);
assert.equal(html.includes("Protocol Relative"), true);
});
test("renders remote images as safe placeholder badges instead of <img> tags", () => {
const content = `![System Diagram](https://example.com/diagram.png)`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// No <img> tag rendered
assert.equal(html.includes("<img"), false);
// Image placeholder badge rendered
assert.equal(html.includes("Image:"), true);
assert.equal(html.includes("System Diagram"), true);
});
test("renders clear empty-state message when content is empty or null", () => {
const emptyHtml = renderToString(React.createElement(MarkdownContentViewer, { content: "" }));
assert.equal(emptyHtml.includes("No content available for this node."), true);
const nullHtml = renderToString(React.createElement(MarkdownContentViewer, { content: null }));
assert.equal(nullHtml.includes("No content available for this node."), true);
});
test("renders plain text cleanly without requiring Markdown formatting", () => {
const plainText = "Plain entity summary text without markdown formatting.";
const html = renderToString(React.createElement(MarkdownContentViewer, { content: plainText, defaultMode: "preview" }));
assert.equal(html.includes(plainText), true);
});
test("handles very large Markdown content without failure", () => {
const largeContent = `# Large Knowledge Node\n\n` + "Structured observation paragraph. ".repeat(400);
assert.equal(largeContent.length > 10000, true);
const html = renderToString(React.createElement(MarkdownContentViewer, { content: largeContent, defaultMode: "preview" }));
assert.equal(html.includes("Large Knowledge Node"), true);
});
// ─── H-2: Stale copied state lifecycle (SSR-compatible portion) ─────────────
// Full state-transition testing (Node A → copy → Node B) requires an interactive
// framework. The lifecycle correctness is guaranteed by the render-phase
// previous-prop synchronisation pattern: a `copiedForContent` state value tracks
// the content for which the copied indicator was set; when `content` changes, the
// mismatch is detected during render and `copied` is reset to false in the same
// React batch, before the new node's UI is painted. What we CAN verify in SSR
// is that the initial render for any content value shows the Copy button (not the
// Copied indicator), which confirms the initial state is always clean.
test("copy button always starts in un-copied state on initial render", () => {
const html = renderToString(React.createElement(MarkdownContentViewer, {
content: "# Some Node\n\nDescription text.",
defaultMode: "preview",
}));
// Initial render must show 'Copy', never 'Copied'
assert.equal(html.includes("Copy"), true, "Copy button must be present on initial render");
assert.equal(html.includes("Copied"), false, "Copied indicator must NOT be present on initial render");
});
+1 -1
View File
@@ -103,7 +103,7 @@ Discord = "https://discord.gg/sV34vps5hH"
llm-openai = ["openai>=1.0.0"]
llm-groq = ["groq>=0.4.0"]
llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.18.0"]
llm-anthropic = ["anthropic>=0.122.0"]
llm-ollama = ["ollama>=0.1.0"]
llm-deepseek = ["openai>=1.0.0"]
llm-litellm = ["litellm>=1.83.9"]
+3 -3
View File
@@ -159,9 +159,9 @@ annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
anthropic==0.121.0 \
--hash=sha256:6048713fa441e59e1cba8363171cd2a86273b25bd213e9c7ac70a523af88b011 \
--hash=sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6
anthropic==0.122.0 \
--hash=sha256:45ec906452ffae6b5f7f0c53d01f50bfb7e4ce878d7ae8e4309d13171e557e67 \
--hash=sha256:ffec56ae96657c8d19fa575ec96f140f380c353a07ab7d61b92eb18ee6536601
# via semantica (pyproject.toml)
antlr4-python3-runtime==4.9.3 \
--hash=sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b
+23 -5
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
from semantica.ingest import FileIngestor, WebIngestor, DBIngestor, StreamIngestor, FeedIngestor
from semantica.kg import GraphBuilder, EntityResolver, ProvenanceTracker
from semantica.provenance import InMemoryStorage, ProvenanceManager
from semantica.conflicts import ConflictDetector
pytestmark = pytest.mark.integration
@@ -75,13 +76,30 @@ class TestNotebook06MultiSourceIntegration:
for entity in all_entities:
provenance_tracker.track_entity(entity.get("id"), entity.get("source"), entity)
# Endpoints stay on "source"/"target", which is what GraphBuilder's
# dict normalization expects; the originating document moves to
# "document". The literal previously set "source" twice, so the
# endpoint id was silently overwritten by the document name.
relationships = [
{"source": "e2", "target": "e1", "type": "CEO_of", "source": "file1"}
{"id": "r1", "source": "e2", "target": "e1",
"type": "CEO_of", "document": "file1"}
]
with patch.object(provenance_tracker, 'track_relationship'):
for rel in relationships:
provenance_tracker.track_relationship(rel.get("source"), rel.get("target"), rel.get("source"), rel)
# kg.ProvenanceTracker has no track_relationship and never did; that
# lives on ProvenanceManager, which is where ProvenanceTracker's own
# DeprecationWarning points callers. Called for real rather than
# patched, so this step actually exercises something.
#
# Storage is pinned to in-memory: with no argument, ProvenanceManager
# falls back to the mutable class-level _default_storage_path, so an
# earlier test setting it would make this write SQLite to disk and
# turn the result order-dependent.
provenance_manager = ProvenanceManager(storage=InMemoryStorage())
for rel in relationships:
entry = provenance_manager.track_relationship(
rel["id"], rel["document"], metadata=rel
)
assert entry is not None
# --- Step 5: Build Unified KG ---
builder = GraphBuilder()
View File
+29
View File
@@ -0,0 +1,29 @@
"""Shared helper for visualization tests.
The visualization modules treat Plotly as optional: they bind ``px``, ``go`` and
``make_subplots`` to ``None`` when the import fails, and raise ``ProcessingError``
from ``_check_dependencies()``. Tests that exercise a Plotly-backed path need
those names to be usable, otherwise ``patch("...go.Figure")`` fails on ``None``
and the visualizers refuse to run.
``plotly_doubles`` fills in a double for each alias that is ``None``, so the
tests describe their own requirements instead of depending on whether Plotly
happens to be installed. When Plotly is installed the aliases are left alone and
the patches keep asserting against the real attribute names.
"""
from contextlib import ExitStack, contextmanager
from unittest.mock import MagicMock, patch
PLOTLY_ALIASES = ("px", "go", "make_subplots")
@contextmanager
def plotly_doubles(*modules):
"""Stand in for the module-level Plotly aliases that are unavailable."""
with ExitStack() as stack:
for module in modules:
for alias in PLOTLY_ALIASES:
if getattr(module, alias, "unused") is None:
stack.enter_context(patch.object(module, alias, MagicMock()))
yield
@@ -9,27 +9,16 @@ and must raise a clear ProcessingError for anything else.
"""
import contextlib
import sys
import unittest
from dataclasses import dataclass, field
from typing import List
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Stub out heavy optional deps before importing the module under test
# ---------------------------------------------------------------------------
sys.modules.setdefault("matplotlib", MagicMock())
sys.modules.setdefault("matplotlib.pyplot", MagicMock())
sys.modules.setdefault("matplotlib.patches", MagicMock())
sys.modules.setdefault("plotly", MagicMock())
sys.modules.setdefault("plotly.express", MagicMock())
sys.modules.setdefault("plotly.graph_objects", MagicMock())
sys.modules.setdefault("plotly.subplots", MagicMock())
sys.modules.setdefault("graphviz", MagicMock())
sys.modules.setdefault("seaborn", MagicMock())
from semantica.utils.exceptions import ProcessingError # noqa: E402
from semantica.visualization import kg_visualizer # noqa: E402
from semantica.visualization.kg_visualizer import KGVisualizer # noqa: E402
from tests.visualization._plotly_doubles import plotly_doubles # noqa: E402
# ---------------------------------------------------------------------------
# Minimal fixtures
@@ -191,10 +180,6 @@ class TestVisualizeNetworkAcceptsKGObject(unittest.TestCase):
def _run_visualize_network(self, graph_arg):
"""Run visualize_network with all Plotly internals mocked."""
mock_fig = MagicMock()
mock_go = sys.modules["plotly.graph_objects"]
mock_go.Figure.return_value = mock_fig
mock_go.Scatter.return_value = MagicMock()
mock_go.Layout.return_value = MagicMock()
viz = _make_viz()
@@ -207,6 +192,10 @@ class TestVisualizeNetworkAcceptsKGObject(unittest.TestCase):
# ColorPalette helpers
with (
plotly_doubles(kg_visualizer),
patch("semantica.visualization.kg_visualizer.go.Figure", return_value=mock_fig),
patch("semantica.visualization.kg_visualizer.go.Scatter"),
patch("semantica.visualization.kg_visualizer.go.Layout"),
patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
return_value={"Person": "#ff0000"},
@@ -254,9 +243,12 @@ class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
with patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
return_value=["#ff0000", "#00ff00"],
with (
plotly_doubles(kg_visualizer),
patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
return_value=["#ff0000", "#00ff00"],
),
):
self.viz.visualize_communities(self.kg, communities=communities)
self.viz._normalize_graph.assert_called_once_with(self.kg)
@@ -264,22 +256,24 @@ class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
def test_visualize_centrality_accepts_kg_object(self):
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
self.viz.visualize_centrality(self.kg, centrality={"centrality": {}})
with plotly_doubles(kg_visualizer):
self.viz.visualize_centrality(self.kg, centrality={"centrality": {}})
self.viz._normalize_graph.assert_called_once_with(self.kg)
def test_visualize_entity_types_accepts_kg_object(self):
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
mock_px = sys.modules["plotly.express"]
mock_px.bar.return_value = MagicMock()
self.viz.visualize_entity_types(self.kg)
with plotly_doubles(kg_visualizer), patch("semantica.visualization.kg_visualizer.px.bar"):
self.viz.visualize_entity_types(self.kg)
self.viz._normalize_graph.assert_called_once_with(self.kg)
def test_visualize_relationship_matrix_accepts_kg_object(self):
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
mock_go = sys.modules["plotly.graph_objects"]
mock_go.Figure.return_value = MagicMock()
mock_go.Heatmap.return_value = MagicMock()
self.viz.visualize_relationship_matrix(self.kg)
with (
plotly_doubles(kg_visualizer),
patch("semantica.visualization.kg_visualizer.go.Figure"),
patch("semantica.visualization.kg_visualizer.go.Heatmap"),
):
self.viz.visualize_relationship_matrix(self.kg)
self.viz._normalize_graph.assert_called_once_with(self.kg)
@@ -361,10 +355,6 @@ class TestFormalKnowledgeGraphType(unittest.TestCase):
def _run_visualize_network(self, graph_arg):
mock_fig = MagicMock()
mock_go = sys.modules["plotly.graph_objects"]
mock_go.Figure.return_value = mock_fig
mock_go.Scatter.return_value = MagicMock()
mock_go.Layout.return_value = MagicMock()
viz = _make_viz()
fake_pos = {"e1": (0.0, 0.0), "e2": (1.0, 1.0)}
viz.force_layout = MagicMock()
@@ -372,6 +362,10 @@ class TestFormalKnowledgeGraphType(unittest.TestCase):
viz.hierarchical_layout = MagicMock()
viz.circular_layout = MagicMock()
with (
plotly_doubles(kg_visualizer),
patch("semantica.visualization.kg_visualizer.go.Figure", return_value=mock_fig),
patch("semantica.visualization.kg_visualizer.go.Scatter"),
patch("semantica.visualization.kg_visualizer.go.Layout"),
patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
return_value={"Person": "#ff0000"},
@@ -392,9 +386,12 @@ class TestFormalKnowledgeGraphType(unittest.TestCase):
viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
with patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
return_value=["#ff0000", "#00ff00"],
with (
plotly_doubles(kg_visualizer),
patch(
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
return_value=["#ff0000", "#00ff00"],
),
):
viz.visualize_communities(kg, communities=communities)
viz._normalize_graph.assert_called_once_with(kg)
@@ -404,24 +401,28 @@ class TestFormalKnowledgeGraphType(unittest.TestCase):
viz = _make_viz()
viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
viz.visualize_centrality(kg, centrality={"centrality": {}})
with plotly_doubles(kg_visualizer):
viz.visualize_centrality(kg, centrality={"centrality": {}})
viz._normalize_graph.assert_called_once_with(kg)
def test_visualize_entity_types_accepts_knowledge_graph(self):
kg = self._make_kg()
viz = _make_viz()
viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
sys.modules["plotly.express"].bar.return_value = MagicMock()
viz.visualize_entity_types(kg)
with plotly_doubles(kg_visualizer), patch("semantica.visualization.kg_visualizer.px.bar"):
viz.visualize_entity_types(kg)
viz._normalize_graph.assert_called_once_with(kg)
def test_visualize_relationship_matrix_accepts_knowledge_graph(self):
kg = self._make_kg()
viz = _make_viz()
viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
sys.modules["plotly.graph_objects"].Figure.return_value = MagicMock()
sys.modules["plotly.graph_objects"].Heatmap.return_value = MagicMock()
viz.visualize_relationship_matrix(kg)
with (
plotly_doubles(kg_visualizer),
patch("semantica.visualization.kg_visualizer.go.Figure"),
patch("semantica.visualization.kg_visualizer.go.Heatmap"),
):
viz.visualize_relationship_matrix(kg)
viz._normalize_graph.assert_called_once_with(kg)
def test_knowledge_graph_importable_from_kg_module(self):
+77 -111
View File
@@ -1,149 +1,115 @@
import unittest
from unittest.mock import MagicMock, patch
import importlib
import sys
import unittest
from contextlib import contextmanager
from unittest.mock import patch
import numpy as np
# Helper to mock modules
def mock_module(name):
m = MagicMock()
sys.modules[name] = m
return m
from tests.visualization._plotly_doubles import plotly_doubles
@contextmanager
def import_without(module_name, *dependencies):
"""Import a module with selected optional dependencies unavailable."""
package_name, attribute = module_name.rsplit(".", 1)
package = importlib.import_module(package_name)
missing = object()
original_module = sys.modules.pop(module_name, missing)
original_attribute = getattr(package, attribute, missing)
try:
with patch.dict(sys.modules, {name: None for name in dependencies}):
yield importlib.import_module(module_name)
finally:
sys.modules.pop(module_name, None)
if original_module is not missing:
sys.modules[module_name] = original_module
if original_attribute is missing:
package.__dict__.pop(attribute, None)
else:
setattr(package, attribute, original_attribute)
class TestOptionalDependencies(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Mock heavy/problematic dependencies globally to prevent environment crashes
# We use a dict to save original modules if they exist, but for this test file
# we generally want to run in a controlled "clean" environment.
cls.modules_to_patch = [
'sklearn', 'sklearn.decomposition', 'sklearn.manifold',
'scipy', 'scipy.optimize',
'matplotlib', 'matplotlib.pyplot', 'matplotlib.patches',
'plotly', 'plotly.express', 'plotly.graph_objects', 'plotly.subplots',
'networkx', 'seaborn'
]
cls.original_modules = {}
for mod in cls.modules_to_patch:
if mod in sys.modules:
cls.original_modules[mod] = sys.modules[mod]
sys.modules[mod] = MagicMock()
@classmethod
def tearDownClass(cls):
# Restore original modules
for mod in cls.modules_to_patch:
if mod in cls.original_modules:
sys.modules[mod] = cls.original_modules[mod]
else:
del sys.modules[mod]
def setUp(self):
# Clear cached visualization modules to ensure fresh imports
self.viz_modules = [
'semantica.visualization.embedding_visualizer',
'semantica.visualization.ontology_visualizer',
'semantica.visualization.kg_visualizer',
'semantica.visualization.utils.export_formats'
]
for mod in self.viz_modules:
if mod in sys.modules:
del sys.modules[mod]
def test_embedding_visualizer_without_umap(self):
"""Test EmbeddingVisualizer behavior when umap is missing."""
# Ensure umap is missing
with patch.dict(sys.modules, {'umap': None}):
from semantica.visualization.embedding_visualizer import EmbeddingVisualizer
# Setup PCA mock to verify fallback
mock_pca_class = sys.modules['sklearn.decomposition'].PCA
mock_pca_instance = mock_pca_class.return_value
# Configure fit_transform to return correct shape (n_samples, 2)
mock_pca_instance.fit_transform.return_value = np.zeros((4, 2))
viz = EmbeddingVisualizer()
# Use numpy array!
embeddings = np.array([[0, 1, 2], [1, 0, 3], [0, 0, 0], [1, 1, 1]])
# Should fallback to PCA when method="umap" is used but umap is None
# The code logs a warning and uses PCA
viz.visualize_2d_projection(embeddings, method="umap")
# Verify PCA was called
with import_without(
"semantica.visualization.embedding_visualizer", "umap"
) as module:
with plotly_doubles(module), patch.object(module, "PCA") as mock_pca_class:
mock_pca_class.return_value.fit_transform.return_value = np.zeros((4, 2))
viz = module.EmbeddingVisualizer()
embeddings = np.array([[0, 1, 2], [1, 0, 3], [0, 0, 0], [1, 1, 1]])
viz.visualize_2d_projection(embeddings, method="umap")
mock_pca_class.assert_called()
def test_ontology_visualizer_without_graphviz(self):
"""Test OntologyVisualizer behavior when graphviz is missing."""
# Ensure graphviz is missing
with patch.dict(sys.modules, {'graphviz': None}):
from semantica.visualization.ontology_visualizer import OntologyVisualizer, ProcessingError
viz = OntologyVisualizer()
with import_without(
"semantica.visualization.ontology_visualizer", "graphviz"
) as module:
viz = module.OntologyVisualizer()
ontology = {
"classes": [
{"name": "A", "label": "A"},
{"name": "B", "label": "B", "parent": "A"}
{"name": "B", "label": "B", "parent": "A"},
]
}
with self.assertRaises(ProcessingError) as cm:
with self.assertRaises(module.ProcessingError) as cm:
viz.visualize_hierarchy(ontology, output="dot", file_path="test.dot")
self.assertIn("Graphviz is required for DOT export", str(cm.exception))
def test_analytics_visualizer_without_plotly(self):
"""Test AnalyticsVisualizer behavior when plotly is missing."""
with patch.dict(sys.modules, {'plotly': None, 'plotly.express': None, 'plotly.graph_objects': None}):
from semantica.visualization.analytics_visualizer import AnalyticsVisualizer, ProcessingError
# Need to ensure numpy is available for init (it's imported at top level)
# But we are testing plotly missing.
viz = AnalyticsVisualizer()
with self.assertRaises(ProcessingError) as cm:
viz.visualize_centrality_rankings({"node1": 1.0})
self.assertIn("Plotly is required", str(cm.exception))
with import_without(
"semantica.visualization.analytics_visualizer",
"plotly",
"plotly.express",
"plotly.graph_objects",
) as module:
viz = module.AnalyticsVisualizer()
def test_analytics_visualizer_without_plotly(self):
"""Test AnalyticsVisualizer behavior when plotly is missing."""
with patch.dict(sys.modules, {'plotly': None, 'plotly.express': None, 'plotly.graph_objects': None}):
from semantica.visualization.analytics_visualizer import AnalyticsVisualizer, ProcessingError
viz = AnalyticsVisualizer()
with self.assertRaises(ProcessingError) as cm:
with self.assertRaises(module.ProcessingError) as cm:
viz.visualize_centrality_rankings({})
self.assertIn("Plotly is required", str(cm.exception))
def test_semantic_network_visualizer_without_plotly(self):
"""Test SemanticNetworkVisualizer behavior when plotly is missing."""
with patch.dict(sys.modules, {'plotly': None, 'plotly.express': None, 'plotly.graph_objects': None}):
from semantica.visualization.semantic_network_visualizer import SemanticNetworkVisualizer, ProcessingError
viz = SemanticNetworkVisualizer()
with self.assertRaises(ProcessingError) as cm:
with import_without(
"semantica.visualization.semantic_network_visualizer",
"plotly",
"plotly.express",
"plotly.graph_objects",
) as module:
viz = module.SemanticNetworkVisualizer()
with self.assertRaises(module.ProcessingError) as cm:
viz.visualize_network({})
self.assertIn("Plotly is required", str(cm.exception))
def test_temporal_visualizer_without_plotly(self):
"""Test TemporalVisualizer behavior when plotly is missing."""
with patch.dict(sys.modules, {'plotly': None, 'plotly.express': None, 'plotly.graph_objects': None}):
from semantica.visualization.temporal_visualizer import TemporalVisualizer, ProcessingError
viz = TemporalVisualizer()
with self.assertRaises(ProcessingError) as cm:
with import_without(
"semantica.visualization.temporal_visualizer",
"plotly",
"plotly.express",
"plotly.graph_objects",
) as module:
viz = module.TemporalVisualizer()
with self.assertRaises(module.ProcessingError) as cm:
viz.visualize_timeline({"events": []})
self.assertIn("Plotly is required", str(cm.exception))
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()
+1 -23
View File
@@ -1,27 +1,5 @@
import unittest
from unittest.mock import MagicMock, patch, ANY
import sys
import types
# Helper to create a mock package
def mock_package(name):
m = MagicMock()
m.__path__ = []
sys.modules[name] = m
return m
# Mock libraries before importing module under test
# We need to ensure matplotlib behaves like a package for seaborn
sys.modules['matplotlib'] = MagicMock()
sys.modules['matplotlib.colors'] = MagicMock()
sys.modules['matplotlib.pyplot'] = MagicMock()
sys.modules['matplotlib.patches'] = MagicMock()
sys.modules['plotly'] = MagicMock()
sys.modules['plotly.express'] = MagicMock()
sys.modules['plotly.graph_objects'] = MagicMock()
sys.modules['plotly.subplots'] = MagicMock()
sys.modules['graphviz'] = MagicMock()
sys.modules['seaborn'] = MagicMock()
from unittest.mock import MagicMock, patch
from semantica.visualization.kg_visualizer import KGVisualizer
from semantica.visualization.ontology_visualizer import OntologyVisualizer
@@ -1,34 +1,25 @@
import unittest
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import sys
import numpy as np
# Mock heavy libraries before importing visualization modules
sys.modules['matplotlib'] = MagicMock()
sys.modules['matplotlib.pyplot'] = MagicMock()
sys.modules['matplotlib.colors'] = MagicMock()
sys.modules['matplotlib.patches'] = MagicMock()
sys.modules['plotly'] = MagicMock()
sys.modules['plotly.express'] = MagicMock()
sys.modules['plotly.graph_objects'] = MagicMock()
sys.modules['plotly.subplots'] = MagicMock()
sys.modules['seaborn'] = MagicMock()
sys.modules['umap'] = MagicMock()
sys.modules['sklearn'] = MagicMock()
sys.modules['sklearn.decomposition'] = MagicMock()
sys.modules['sklearn.manifold'] = MagicMock()
from semantica.visualization import analytics_visualizer, embedding_visualizer
from semantica.visualization.analytics_visualizer import AnalyticsVisualizer
from semantica.visualization.embedding_visualizer import EmbeddingVisualizer
from semantica.visualization.utils.color_schemes import ColorScheme
from tests.visualization._plotly_doubles import plotly_doubles
class TestVisualizationAdvanced(unittest.TestCase):
def setUp(self):
self.mock_logger = MagicMock()
self.mock_tracker = MagicMock()
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(plotly_doubles(analytics_visualizer, embedding_visualizer))
self.patchers = [
patch('semantica.visualization.analytics_visualizer.get_logger', return_value=self.mock_logger),
patch('semantica.visualization.analytics_visualizer.get_progress_tracker', return_value=self.mock_tracker),
@@ -53,22 +44,17 @@ class TestVisualizationAdvanced(unittest.TestCase):
viz = AnalyticsVisualizer()
centrality = {"n1": 0.5, "n2": 0.3}
# Access the mock that was injected
import plotly.graph_objects as go
# Reset mock to ensure clean state
go.Bar.reset_mock()
viz.visualize_centrality_rankings(centrality, output="interactive")
go.Bar.assert_called()
with (
patch('semantica.visualization.analytics_visualizer.go.Bar') as mock_bar,
patch('semantica.visualization.analytics_visualizer.go.Figure'),
):
viz.visualize_centrality_rankings(centrality, output="interactive")
mock_bar.assert_called()
def test_visualize_community_structure(self):
viz = AnalyticsVisualizer()
if hasattr(viz, 'visualize_community_structure'):
import plotly.graph_objects as go
# Reset mocks
go.Figure.reset_mock()
graph = MagicMock()
communities = {"c1": ["n1", "n2"]}
@@ -89,8 +75,6 @@ class TestVisualizationAdvanced(unittest.TestCase):
viz = EmbeddingVisualizer()
embeddings = np.random.rand(10, 128)
import plotly.graph_objects as go
# Mock UMAP/TSNE/PCA
with patch('semantica.visualization.embedding_visualizer.umap') as mock_umap, \
patch('semantica.visualization.embedding_visualizer.TSNE') as mock_tsne, \
@@ -116,12 +100,13 @@ class TestVisualizationAdvanced(unittest.TestCase):
viz = EmbeddingVisualizer()
embeddings = np.random.rand(5, 5)
import plotly.graph_objects as go
go.Heatmap.reset_mock()
if hasattr(viz, 'visualize_similarity_heatmap'):
viz.visualize_similarity_heatmap(embeddings)
go.Heatmap.assert_called()
with (
patch('semantica.visualization.embedding_visualizer.go.Heatmap') as mock_heatmap,
patch('semantica.visualization.embedding_visualizer.go.Figure'),
):
viz.visualize_similarity_heatmap(embeddings)
mock_heatmap.assert_called()
if __name__ == '__main__':
unittest.main()
@@ -1,26 +1,9 @@
import unittest
from unittest.mock import MagicMock, patch
import sys
import numpy as np
from pathlib import Path
import pytest
# Mock heavy libraries before importing visualization modules
sys.modules['matplotlib'] = MagicMock()
sys.modules['matplotlib.pyplot'] = MagicMock()
sys.modules['matplotlib.colors'] = MagicMock()
sys.modules['matplotlib.patches'] = MagicMock()
sys.modules['plotly'] = MagicMock()
sys.modules['plotly.express'] = MagicMock()
sys.modules['plotly.graph_objects'] = MagicMock()
sys.modules['plotly.subplots'] = MagicMock()
sys.modules['seaborn'] = MagicMock()
sys.modules['umap'] = MagicMock()
sys.modules['sklearn'] = MagicMock()
sys.modules['sklearn.decomposition'] = MagicMock()
sys.modules['sklearn.manifold'] = MagicMock()
sys.modules['networkx'] = MagicMock()
sys.modules['graphviz'] = MagicMock()
# Import visualizers
from semantica.visualization.kg_visualizer import KGVisualizer
@@ -52,24 +35,24 @@ class TestVisualizationComprehensive(unittest.TestCase):
patch('semantica.visualization.analytics_visualizer.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.visualization.temporal_visualizer.get_logger', return_value=self.mock_logger),
patch('semantica.visualization.temporal_visualizer.get_progress_tracker', return_value=self.mock_tracker),
# Mock Layouts
patch('semantica.visualization.kg_visualizer.ForceDirectedLayout', MagicMock()),
patch('semantica.visualization.kg_visualizer.HierarchicalLayout', MagicMock()),
patch('semantica.visualization.kg_visualizer.CircularLayout', MagicMock()),
patch('semantica.visualization.ontology_visualizer.HierarchicalLayout', MagicMock()),
patch('semantica.visualization.semantic_network_visualizer.ForceDirectedLayout', MagicMock()),
patch('semantica.visualization.kg_visualizer.go', MagicMock()),
patch('semantica.visualization.kg_visualizer.px', MagicMock()),
patch('semantica.visualization.ontology_visualizer.go', MagicMock()),
patch('semantica.visualization.ontology_visualizer.make_subplots', MagicMock()),
patch('semantica.visualization.embedding_visualizer.go', MagicMock()),
patch('semantica.visualization.embedding_visualizer.px', MagicMock()),
patch('semantica.visualization.semantic_network_visualizer.go', MagicMock()),
patch('semantica.visualization.semantic_network_visualizer.px', MagicMock()),
patch('semantica.visualization.analytics_visualizer.go', MagicMock()),
patch('semantica.visualization.analytics_visualizer.px', MagicMock()),
patch('semantica.visualization.analytics_visualizer.make_subplots', MagicMock()),
patch('semantica.visualization.temporal_visualizer.go', MagicMock()),
patch('semantica.visualization.temporal_visualizer.px', MagicMock()),
]
for p in self.patchers:
p.start()
# Reset plotly mocks
import plotly.graph_objects as go
import plotly.express as px
go.Figure.reset_mock()
px.bar.reset_mock()
px.scatter.reset_mock()
def tearDown(self):
for p in self.patchers:
p.stop()
@@ -210,8 +193,8 @@ class TestVisualizationComprehensive(unittest.TestCase):
embeddings = np.random.rand(10, 10)
# Test visualize_2d_projection (mock UMAP/PCA)
with patch('semantica.visualization.embedding_visualizer.umap.UMAP') as MockUMAP:
MockUMAP.return_value.fit_transform.return_value = np.random.rand(10, 2)
with patch('semantica.visualization.embedding_visualizer.umap', MagicMock()) as mock_umap:
mock_umap.UMAP.return_value.fit_transform.return_value = np.random.rand(10, 2)
viz.visualize_2d_projection(embeddings)
# Test visualize_similarity_heatmap
@@ -219,8 +202,8 @@ class TestVisualizationComprehensive(unittest.TestCase):
# Test visualize_clustering
clusters = [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
with patch('semantica.visualization.embedding_visualizer.umap.UMAP') as MockUMAP:
MockUMAP.return_value.fit_transform.return_value = np.random.rand(10, 2)
with patch('semantica.visualization.embedding_visualizer.umap', MagicMock()) as mock_umap:
mock_umap.UMAP.return_value.fit_transform.return_value = np.random.rand(10, 2)
viz.visualize_clustering(embeddings, clusters)
if __name__ == '__main__':