Skip to content

Commit fb681ea

Browse files
authored
Merge pull request #48 from saagpatel/codex/refactor/wave5-1-response-panel
refactor(components): decompose ResponsePanel into helpers + hook + sections
2 parents 4cd4b9b + e159410 commit fb681ea

6 files changed

Lines changed: 646 additions & 257 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// @vitest-environment jsdom
2+
import {
3+
act,
4+
cleanup,
5+
fireEvent,
6+
render,
7+
screen,
8+
waitFor,
9+
} from "@testing-library/react";
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { ResponsePanel } from "./ResponsePanel";
12+
import type { ContextSource } from "../../types/knowledge";
13+
14+
const invokeMock = vi.fn();
15+
const showSuccessMock = vi.fn();
16+
const showErrorMock = vi.fn();
17+
const writeTextMock = vi.fn(async () => undefined);
18+
19+
vi.mock("@tauri-apps/api/core", () => ({
20+
invoke: (...args: unknown[]) => invokeMock(...args),
21+
}));
22+
23+
vi.mock("../../contexts/ToastContext", () => ({
24+
useToastContext: () => ({ success: showSuccessMock, error: showErrorMock }),
25+
}));
26+
27+
vi.mock("./RatingPanel", () => ({
28+
RatingPanel: () => <div data-testid="rating-panel" />,
29+
}));
30+
31+
vi.mock("./JiraPostPanel", () => ({
32+
JiraPostPanel: () => <div data-testid="jira-post-panel" />,
33+
}));
34+
35+
function makeSource(overrides: Partial<ContextSource> = {}): ContextSource {
36+
return {
37+
chunk_id: "chunk-1",
38+
document_id: "doc-1",
39+
file_path: "/kb/reset-password.md",
40+
title: "Reset Password",
41+
heading_path: "Guides > Reset",
42+
score: 0.82,
43+
search_method: "Hybrid",
44+
source_type: "file",
45+
...overrides,
46+
};
47+
}
48+
49+
beforeEach(() => {
50+
invokeMock.mockReset();
51+
showSuccessMock.mockReset();
52+
showErrorMock.mockReset();
53+
writeTextMock.mockClear();
54+
Object.defineProperty(navigator, "clipboard", {
55+
configurable: true,
56+
value: { writeText: writeTextMock },
57+
});
58+
});
59+
60+
afterEach(() => {
61+
cleanup();
62+
});
63+
64+
describe("ResponsePanel", () => {
65+
it("renders a canned response with word count and KB sources", () => {
66+
render(
67+
<ResponsePanel
68+
response="Please reset your password using the self-service portal."
69+
streamingText=""
70+
isStreaming={false}
71+
sources={[makeSource()]}
72+
generating={false}
73+
metrics={null}
74+
confidence={{
75+
mode: "answer",
76+
score: 0.82,
77+
rationale: "Strong KB match.",
78+
}}
79+
/>,
80+
);
81+
82+
expect(
83+
screen.getByRole("heading", { level: 3, name: "Response" }),
84+
).toBeTruthy();
85+
expect(screen.getByText(/8 words/)).toBeTruthy();
86+
expect(screen.getByText("Ready to answer")).toBeTruthy();
87+
// Auto-show-sources effect opens the panel on first non-streaming render
88+
// with sources available, so the toggle renders as "Hide Sources".
89+
expect(screen.getByRole("button", { name: "Hide Sources" })).toBeTruthy();
90+
expect(screen.getByText("Knowledge Base Sources")).toBeTruthy();
91+
});
92+
93+
it("gates copy when mode is clarify and records an audit override", async () => {
94+
invokeMock.mockResolvedValue(undefined);
95+
96+
render(
97+
<ResponsePanel
98+
response="Please gather more logs before acting."
99+
streamingText=""
100+
isStreaming={false}
101+
sources={[]}
102+
generating={false}
103+
metrics={null}
104+
confidence={{
105+
mode: "clarify",
106+
score: 0.4,
107+
rationale: "Missing info.",
108+
}}
109+
/>,
110+
);
111+
112+
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
113+
114+
const reasonInput = await screen.findByPlaceholderText(
115+
/Explain why copying without citations/,
116+
);
117+
fireEvent.change(reasonInput, {
118+
target: { value: "User confirmed out-of-band, tolerable risk." },
119+
});
120+
121+
await act(async () => {
122+
fireEvent.click(
123+
screen.getByRole("button", { name: /Copy with override/ }),
124+
);
125+
});
126+
127+
await waitFor(() => {
128+
expect(invokeMock).toHaveBeenCalledWith(
129+
"audit_response_copy_override",
130+
expect.objectContaining({
131+
reason: "User confirmed out-of-band, tolerable risk.",
132+
confidenceMode: "clarify",
133+
sourcesCount: 0,
134+
}),
135+
);
136+
});
137+
expect(writeTextMock).toHaveBeenCalledWith(
138+
"Please gather more logs before acting.",
139+
);
140+
expect(showSuccessMock).toHaveBeenCalledWith(
141+
"Response copied (override logged)",
142+
);
143+
});
144+
145+
it("expands a source row and fetches its preview content", async () => {
146+
invokeMock.mockResolvedValue([
147+
{
148+
id: "chunk-1",
149+
chunk_index: 0,
150+
content: "Full KB chunk body for preview",
151+
},
152+
]);
153+
154+
render(
155+
<ResponsePanel
156+
response="Resolved via KB article."
157+
streamingText=""
158+
isStreaming={false}
159+
sources={[makeSource()]}
160+
generating={false}
161+
metrics={null}
162+
confidence={{ mode: "answer", score: 0.9, rationale: "ok" }}
163+
/>,
164+
);
165+
166+
// Sources panel auto-opens on non-streaming render with sources.
167+
const expandToggle = screen
168+
.getByText("Reset Password")
169+
.closest("button") as HTMLButtonElement;
170+
expect(expandToggle).toBeTruthy();
171+
await act(async () => {
172+
fireEvent.click(expandToggle);
173+
});
174+
175+
await waitFor(() => {
176+
expect(invokeMock).toHaveBeenCalledWith("get_document_chunks", {
177+
documentId: "doc-1",
178+
});
179+
});
180+
expect(
181+
await screen.findByText("Full KB chunk body for preview"),
182+
).toBeTruthy();
183+
});
184+
});

0 commit comments

Comments
 (0)