Skip to content
Merged
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
90 changes: 90 additions & 0 deletions frontend/src/components/FiveW1H.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FiveW1H } from "./FiveW1H";

const meta = {
title: "Ask Agent/FiveW1H",
component: FiveW1H,
} satisfies Meta<typeof FiveW1H>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Loading: Story = {
args: { slots: null },
};

export const AllSlotsEmpty: Story = {
args: {
slots: (["who", "what", "when", "where", "why", "how"] as const).map((slot_code) => ({
slot_code,
values: [],
empty_next_action_code: "none",
})),
},
};

export const GroundedAnswer: Story = {
args: {
slots: [
{
slot_code: "who",
empty_next_action_code: "none",
values: [
{
text: "Ada West",
source: "post_summary_role",
evidence_text: "“Ada West signed off on the renewal”",
ontology_codes: [],
ontology_annotations: {},
},
{
text: "Northwind Logistics",
source: "post_summary_role.affiliated_organization_name",
ontology_codes: [],
ontology_annotations: {},
},
],
},
{
slot_code: "what",
empty_next_action_code: "none",
values: [
{
text: "Renewed the vendor contract",
source: "post_summary_event",
evidence_text: "“we renewed the contract through Q4”",
ontology_codes: ["evt-42"],
ontology_annotations: { ontology_label: "Contract renewal" },
},
],
},
{
slot_code: "when",
empty_next_action_code: "none",
values: [],
},
],
},
};

// Edge case: an evidence source with no human-label mapping yet must still
// render something readable instead of disappearing.
export const UnmappedEvidenceSource: Story = {
args: {
slots: [
{
slot_code: "how",
empty_next_action_code: "none",
values: [
{
text: "Filed via the vendor portal",
source: "some_future_source",
ontology_codes: [],
ontology_annotations: {},
},
],
},
],
},
};
156 changes: 156 additions & 0 deletions frontend/src/components/FiveW1H.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { FiveW1H } from "./FiveW1H";
import type { FiveW1HSlot } from "../api";

function slot(overrides: Partial<FiveW1HSlot>): FiveW1HSlot {
return {
slot_code: "who",
values: [],
empty_next_action_code: "none",
...overrides,
};
}

describe("FiveW1H", () => {
it("shows a loading state while slots have not arrived yet", () => {
render(<FiveW1H slots={null} />);
expect(screen.getByText("Loading 5W1H...")).toBeInTheDocument();
});

it("shows a no-evidence message for a slot with no values", () => {
render(<FiveW1H slots={[slot({ slot_code: "why", values: [] })]} />);
expect(screen.getByText("No grounded evidence for this dimension.")).toBeInTheDocument();
});

it("renders a human label for a raw evidence source instead of the dotted path", () => {
render(
<FiveW1H
slots={[
slot({
slot_code: "who",
values: [
{
text: "Ada West",
source: "post_summary_role.affiliated_organization_name",
ontology_codes: [],
ontology_annotations: {},
},
],
}),
]}
/>,
);
expect(screen.getByText("Ada West")).toBeInTheDocument();
expect(screen.getByText("Extracted affiliation")).toBeInTheDocument();
expect(screen.queryByText("post_summary_role.affiliated_organization_name")).not.toBeInTheDocument();
});

it("falls back to the raw source string for an unmapped evidence source", () => {
render(
<FiveW1H
slots={[
slot({
slot_code: "how",
values: [
{
text: "Filed via portal",
source: "some_future_source",
ontology_codes: [],
ontology_annotations: {},
},
],
}),
]}
/>,
);
expect(screen.getByText("some_future_source")).toBeInTheDocument();
});

it("shows optional evidence text and ontology class badges only when present", async () => {
render(
<FiveW1H
slots={[
slot({
slot_code: "what",
values: [
{
text: "Renewed the vendor contract",
source: "post_summary_event",
evidence_text: "“we renewed the contract”",
ontology_codes: ["evt-42"],
ontology_annotations: { ontology_label: "Contract renewal" },
},
],
}),
]}
/>,
);

await userEvent.click(screen.getByText("Evidence provenance"));
expect(screen.getByText("“we renewed the contract”")).toBeInTheDocument();
expect(screen.getByText("Ontology class: Contract renewal")).toBeInTheDocument();
});

it("falls back to the ontology code when no ontology label is annotated", async () => {
render(
<FiveW1H
slots={[
slot({
slot_code: "what",
values: [
{
text: "Renewed the vendor contract",
source: "post_summary_event",
ontology_codes: ["evt-42"],
ontology_annotations: {},
},
],
}),
]}
/>,
);

await userEvent.click(screen.getByText("Evidence provenance"));
expect(screen.getByText("Ontology class: evt-42")).toBeInTheDocument();
});

it("renders one definition entry per slot with its human label", () => {
render(
<FiveW1H
slots={[
slot({ slot_code: "who", values: [] }),
slot({ slot_code: "when", values: [] }),
slot({ slot_code: "where", values: [] }),
]}
/>,
);
expect(screen.getByText("Who")).toBeInTheDocument();
expect(screen.getByText("When")).toBeInTheDocument();
expect(screen.getByText("Where")).toBeInTheDocument();
});

it("renders multiple values for the same slot as separate list items", () => {
render(
<FiveW1H
slots={[
slot({
slot_code: "who",
values: [
{ text: "Ada West", source: "post_summary_role", ontology_codes: [], ontology_annotations: {} },
{ text: "Priya Nair", source: "post_summary_role", ontology_codes: [], ontology_annotations: {} },
],
}),
]}
/>,
);
expect(screen.getByText("Ada West")).toBeInTheDocument();
expect(screen.getByText("Priya Nair")).toBeInTheDocument();
});

it("exposes the section under an accessible landmark name", () => {
render(<FiveW1H slots={null} />);
expect(screen.getByRole("region", { name: "5W1H" })).toBeInTheDocument();
});
});