Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,25 @@ columns). Do not silently rewrite either historical form. The SHACL
shapes graph (`docs/ontology/lineageweave-kg-shapes.ttl`) is the
closed-world data-validation boundary for DB-to-RDF projections and is
published beside the ontology.

## Verified maintenance practices

- Bind authorization-sensitive asynchronous UI results to the request lifecycle,
not token equality alone. Exercise deferred success and failure across A → B → A
and unmounts; retired requests must not update data, errors, loading state, or
trigger follow-up reads. Use behavioral tests rather than source-text guards.
- A test deadline failure is not a causal RED assertion. Record timeouts separately
and diagnose the driver or runtime before claiming a regression is reproduced.
- Validate local login from the documented `http://localhost:15173` origin. An IP
alias can fail the issuer redirect allowlist even when the canonical origin works;
do not broaden redirect permissions to accommodate a test-only alias.
- Release eligible changes through the existing protected owner workflow and its
GitHub Secrets. Verify the revision, checks, approval, published version, and
runtime outcome separately. Inspect secret metadata only; never expose values.
Preserve reusable lessons here as work proceeds, without transient logs or real
source records.

- Keyboard acceptance must send keyboard input to focused controls; a pointer-click
test with a keyboard title proves neither Enter nor Space activation. Keep both
native-button and custom SVG-control activation covered. Programmatic focus in
a unit test does not establish browser Tab-order acceptance.
279 changes: 279 additions & 0 deletions docs/product-technical-gap-baseline.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ operator-facing control you can click before changing product CSS.
| `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. `DeniedAfterLoaded` proves previously loaded evidence cannot be exported or opened after denial. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project/Work-evidence shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The populated scene includes one assertion-backed occupational construct without a person-trait promotion. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0255 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/components/OntologyExplorer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,24 @@ export const Denied: Story = {
},
};

export const DeniedAfterLoaded: Story = {
args: {
neighborhood: demoNeighborhood,
status: "denied",
},
play: ({ canvasElement }) => {
const buttons = Array.from(canvasElement.querySelectorAll("button"));
for (const label of ["Export CSV", "Export JSON-LD"]) {
if (!buttons.find((button) => button.textContent === label)?.disabled) {
throw new Error("A denied neighborhood must disable evidence exports");
}
}
if (canvasElement.querySelector(".ontology-exact-values, .ontology-drawer")) {
throw new Error("A denied neighborhood must omit prior evidence");
}
},
};

export const StaleCutoff: Story = {
args: {
knowledgeCutoff: "2026-01-15T12:00:00Z",
Expand Down
184 changes: 177 additions & 7 deletions frontend/src/components/OntologyExplorer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BackendError, fetchOntologyNeighborhood } from "../api";
Expand Down Expand Up @@ -161,8 +161,85 @@ describe("OntologyExplorer", () => {
expect(container.querySelector('polygon[points="0,-16 20,0 0,16 -20,0"]')).not.toBeNull();
});

it.each([
["credential", { accessToken: "token-b" }],
["cutoff", { knowledgeCutoff: "2026-01-01T00:00:00Z" }],
["focus", { focusNodeId: EVIDENCE_POST_ID }],
["focus type", { focusNodeType: "node_person" }],
] as const)("retires old pages before a changed %s loads", async (_name, change) => {
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
let releaseOld!: (value: OntologyNeighborhoodPayload) => void;
let releaseCurrent!: (value: OntologyNeighborhoodPayload) => void;
fetchNeighborhood
.mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
.mockImplementationOnce(() => new Promise((resolve) => { releaseOld = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { releaseCurrent = resolve; }));
const initial = {
accessToken: "token-a", focusNodeType: "node_post", focusNodeId: POST_ID,
knowledgeCutoff: undefined,
};
const { rerender } = render(<OntologyExplorer {...initial} />);
fireEvent.click(await screen.findByRole("button", { name: "Select node: Post Demo public post" }));
fireEvent.click(screen.getByRole("button", { name: "Load next relation page" }));
expect(fetchNeighborhood).toHaveBeenCalledTimes(2);

const current = { ...initial, ...change };
rerender(<OntologyExplorer {...current} />);
expect(screen.queryByRole("button", { name: "Select node: Post Demo public post" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Demo public post" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
expect(fetchNeighborhood).toHaveBeenCalledTimes(3);
expect(fetchNeighborhood).toHaveBeenNthCalledWith(3, current.accessToken, {
focusNodeType: current.focusNodeType, focusNodeId: current.focusNodeId,
knowledgeCutoff: current.knowledgeCutoff, cursor: undefined,
});

await act(async () => { releaseOld(neighborhood()); });
expect(screen.queryByRole("button", { name: "Select node: Post Demo public post" })).not.toBeInTheDocument();
await act(async () => { releaseCurrent(neighborhood({
focus_node_id: current.focusNodeId, focus_node_type_code: current.focusNodeType,
nodes: [{ ...neighborhood().nodes[0], node_id: current.focusNodeId,
node_type_code: current.focusNodeType, display_label: "Current scope record" }],
edges: [], exact_value_rows: [],
})); });
expect(screen.getByRole("button", { name: /Select node: .*Current scope record/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
});

it.each(["success", "denial"])("ignores retired %s after credential re-entry", async (outcome) => {
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
let resolveRetired!: (value: OntologyNeighborhoodPayload) => void;
let rejectRetired!: (error: BackendError) => void;
fetchNeighborhood
.mockImplementationOnce(() => new Promise((resolve, reject) => {
resolveRetired = resolve;
rejectRetired = reject;
}))
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValueOnce(neighborhood());
const props = { focusNodeType: "node_post", focusNodeId: POST_ID };
const { rerender } = render(<OntologyExplorer {...props} accessToken="token-a" />);
rerender(<OntologyExplorer {...props} accessToken="token-b" />);
rerender(<OntologyExplorer {...props} accessToken="token-a" />);
expect(await screen.findByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
expect(fetchNeighborhood).toHaveBeenCalledTimes(3);

await act(async () => {
if (outcome === "denial") {
rejectRetired(new BackendError("/api/ontology/neighborhood", 403));
} else {
resolveRetired(neighborhood({ nodes: [], edges: [], exact_value_rows: [] }));
}
});
expect(screen.getByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeEnabled();
expect(fetchNeighborhood).toHaveBeenCalledTimes(3);
});

it("keeps loaded pages visible when a continuation page fails", async () => {
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
let rejectContinuation!: (error: BackendError) => void;
fetchNeighborhood
.mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
Expand Down Expand Up @@ -246,7 +323,92 @@ describe("OntologyExplorer", () => {
).toBeInTheDocument();
});

it("lets keyboard users open node and edge evidence", async () => {
it.each([403, 404])("discards prior evidence and exports after continuation denial (%s)", async (status) => {
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
fetchNeighborhood.mockReset();
fetchNeighborhood
.mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
.mockRejectedValueOnce(new BackendError("/api/ontology/neighborhood", status));
render(
<OntologyExplorer accessToken="synthetic-access-token" focusNodeType="node_post" focusNodeId={POST_ID} />,
);
await userEvent.click(await screen.findByRole("button", { name: "Select node: Post Demo public post" }));
await userEvent.click(screen.getByRole("button", { name: "Load next relation page" }));
expect(await screen.findByText("Related information is unavailable for this record. Open a visible post next.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
expect(screen.queryByRole("region", { name: "Exact values" })).not.toBeInTheDocument();
expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load next relation page" })).not.toBeInTheDocument();
expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
});

it.each([403, 404])("restarts at the first page after continuation denial and credential refresh (%s)", async (status) => {
const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
fetchNeighborhood.mockReset();
fetchNeighborhood
.mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
.mockRejectedValueOnce(new BackendError("/api/ontology/neighborhood", status))
.mockResolvedValueOnce(neighborhood({ next_cursor: null }));

const { rerender } = render(
<OntologyExplorer accessToken="synthetic-token-a" focusNodeType="node_post" focusNodeId={POST_ID} />,
);

await userEvent.click(await screen.findByRole("button", { name: "Load next relation page" }));
expect(
await screen.findByText("Related information is unavailable for this record. Open a visible post next."),
).toBeInTheDocument();
await waitFor(() => expect(fetchNeighborhood).toHaveBeenCalledTimes(2));

rerender(
<OntologyExplorer accessToken="synthetic-token-b" focusNodeType="node_post" focusNodeId={POST_ID} />,
);
await waitFor(() => expect(fetchNeighborhood).toHaveBeenCalledTimes(3));
expect(fetchNeighborhood).toHaveBeenNthCalledWith(
3,
"synthetic-token-b",
expect.objectContaining({ cursor: undefined }),
);
expect(await screen.findByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
});

it.each(["ready", undefined] as const)("does not restore a denied supplied payload on status-only recovery (%s)", (recoveredStatus) => {
const payload = neighborhood();
const { rerender } = render(
<OntologyExplorer focusNodeType="node_post" focusNodeId={POST_ID} neighborhood={payload} />,
);
fireEvent.click(screen.getByRole("button", { name: "Select node: Post Demo public post" }));
fireEvent.change(screen.getByLabelText("Search within this neighborhood"), { target: { value: "Demo" } });
rerender(
<OntologyExplorer focusNodeType="node_post" focusNodeId={POST_ID} neighborhood={payload} status="denied" />,
);
expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
rerender(
<OntologyExplorer focusNodeType="node_post" focusNodeId={POST_ID} neighborhood={payload} status={recoveredStatus} />,
);
expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
rerender(
<OntologyExplorer focusNodeType="node_post" focusNodeId={POST_ID} neighborhood={neighborhood()} status="ready" />,
);
expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Search within this neighborhood")).toHaveValue("");
});

it("denies exports when initially supplied a denied payload", () => {
render(
<OntologyExplorer focusNodeType="node_post" focusNodeId={POST_ID} neighborhood={neighborhood()} status="denied" />,
);
expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
});

it.each(["{Enter}", " "])("opens node and edge evidence with %s", async (activationKey) => {
const user = userEvent.setup();
const onSelectPost = vi.fn();
const onOpenEvidence = vi.fn();
render(
Expand All @@ -264,14 +426,22 @@ describe("OntologyExplorer", () => {
expect(screen.getByRole("columnheader", { name: "Valid from" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Valid to" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Evidence" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Select node: Post Demo public post" }));
const nodeButton = screen.getByRole("button", { name: "Select node: Post Demo public post" });
nodeButton.focus();
expect(nodeButton).toHaveFocus();
await user.keyboard(activationKey);
expect(screen.getByRole("heading", { name: "Demo public post" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Open evidence post" }));
screen.getByRole("button", { name: "Open evidence post" }).focus();
await user.keyboard(activationKey);
expect(onSelectPost).toHaveBeenCalledWith(POST_ID);
expect(onOpenEvidence).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /Select edge: mentions from/ }));
const edgeButton = screen.getByRole("button", { name: /Select edge: mentions from/ });
edgeButton.focus();
expect(edgeButton).toHaveFocus();
await user.keyboard(activationKey);
expect(screen.getByText(/Property IRI/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: `Open evidence: ${POST_ID}` }));
screen.getByRole("button", { name: `Open evidence: ${POST_ID}` }).focus();
await user.keyboard(activationKey);
expect(onOpenEvidence).toHaveBeenCalledWith(POST_ID);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
Expand Down
Loading
Loading