content-explorer/ui/components/EntityDetail.test.tsx
tegwick b5b6f6bc00 Add a bounded relation graph and knowledge journeys.
CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL.

Assistant: grok
Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf
2026-10-19 10:00:20 +02:00

111 lines
6.3 KiB
TypeScript

// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { findEntity, findGraph, findKind, type EntityView } from "@/lib/catalog";
import { partitionIncident } from "@/lib/graph";
import { EntityDetail } from "./EntityDetail";
import { KindIndex } from "./KindIndex";
const systemsRoot = join(process.cwd(), "../examples/dataspaces/systems-thinking");
describe("kind and detail views", () => {
it("renders the registry plural and a linked record", () => {
const kind = findKind("software-architecture", "concept");
expect(kind).not.toBeNull();
const html = renderToStaticMarkup(<KindIndex kind={kind!} />);
expect(html).toContain(">Concepts<");
expect(html).toContain("Coupling");
expect(html).toContain('href="/software-architecture/concept/coupling/"');
expect(html).toContain("dependency");
expect(html).toContain(">Tags<");
});
it("renders the entity title, an unknown claim, and a neighbour", () => {
const entity = findEntity("systems-thinking", "concept", "system-boundary");
expect(entity).not.toBeNull();
const html = renderToStaticMarkup(<EntityDetail entity={entity!} />);
expect(html).toContain(">System boundary<");
expect(html).toContain("formal-measure");
expect(html).toContain("unknown");
expect(html).toContain("Stock and flow model");
expect(html).not.toContain("glossary-tip");
expect(html).not.toContain("link-preview");
expect(html).toContain('href="/systems-thinking/method/stock-flow/"');
});
it("renders both relation blocks, journeys, and optional hints for feedback", () => {
const snapshot = JSON.parse(readFileSync(join(systemsRoot, "snapshot.json"), "utf8")) as {
entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[];
relations: { id: string; from: string; to: string }[];
};
const manifest = JSON.parse(readFileSync(join(systemsRoot, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const entity = findEntity("systems-thinking", "concept", "feedback");
expect(entity).not.toBeNull();
const html = renderToStaticMarkup(<EntityDetail entity={entity!} />);
const host = documentFrom(html);
const incident = snapshot.relations.filter((relation) => relation.from === entity!.id || relation.to === entity!.id);
const rendered = [...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation"));
expect(rendered.sort()).toEqual(incident.map((relation) => relation.id).sort());
expect(host.querySelector('[aria-labelledby="outgoing-relations"] .text-muted')?.textContent).toBe(
manifest.relationLabels["represented-by"].forward,
);
const incomingWordings = [...host.querySelectorAll('[aria-labelledby="incoming-relations"] .text-muted')].map(
(node) => node.textContent,
);
expect(incomingWordings).toContain(manifest.relationLabels.models.inverse);
expect(incomingWordings).not.toContain(manifest.relationLabels.models.forward);
const causal = host.querySelector('[data-relation="r1"]');
const causalEntity = snapshot.entities.find((item) => item.id.endsWith(":causal-loop"));
expect(causal?.querySelector(".glossary-tip")?.textContent).toBe(causalEntity?.properties?.glossary);
expect(causal?.querySelector(".preview-title")?.textContent).toBe(causalEntity?.properties?.preview?.title);
expect(causal?.querySelector(".preview-summary")?.textContent).toBe(causalEntity?.properties?.preview?.summary);
expect(causal?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/");
const modeled = host.querySelector('[data-relation="r6"]');
expect(modeled?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/stock-flow/");
expect(modeled?.querySelector(".glossary-tip")).toBeNull();
expect(modeled?.querySelector(".link-preview")).toBeNull();
const members = [...host.querySelectorAll("#collection-heading")];
expect(members.length).toBe(1);
const collection = members[0]?.parentElement;
expect(collection?.querySelector('a[href="/systems-thinking/method/causal-loop/"]')).toBeTruthy();
expect(collection?.querySelector('a[href="/systems-thinking/method/stock-flow/"]')).toBeTruthy();
expect(host.querySelector('[aria-labelledby="related-heading"] a')?.getAttribute("href")).toBe(
"/systems-thinking/concept/emergence/",
);
const crumbs = [...host.querySelectorAll('[aria-label="Reading path"] li')];
const declared = snapshot.entities.find((item) => item.id === entity!.id)?.properties?.readingPath ?? [];
expect(crumbs).toHaveLength(declared.length);
expect(crumbs[0]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/concept/system-boundary/");
expect(crumbs[1]?.querySelector("[aria-current='page']")?.textContent).toBe(entity!.title);
expect(crumbs[2]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/");
});
it("shows a remainder when the shipped partition leaves relations out", () => {
const entity = findEntity("systems-thinking", "concept", "feedback");
const graph = findGraph("systems-thinking");
expect(entity && graph).toBeTruthy();
const blocks = partitionIncident(entity!.id, graph!.edges, graph!.labels, 1);
const listed = new Set([...blocks.outgoing, ...blocks.incoming].map((relation) => relation.id));
const view: EntityView = {
...entity!,
outgoing: entity!.outgoing.filter((relation) => listed.has(relation.id)),
incoming: entity!.incoming.filter((relation) => listed.has(relation.id)),
relationRemainder: blocks.remainder,
};
const html = renderToStaticMarkup(<EntityDetail entity={view} />);
expect(blocks.remainder).toBeGreaterThan(0);
expect(html).toContain(`${blocks.remainder} more relations are not listed.`);
const host = documentFrom(html);
expect([...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation")).sort()).toEqual(
[...listed].sort(),
);
});
});
function documentFrom(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}