mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: wire analyse submit to SSE stream
This commit is contained in:
@@ -41,7 +41,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh
|
|||||||
|
|
||||||
- [x] `pytest backend/tests/api/test_analysis_router.py` passes (all paths: happy, error, oversized, partial failure, timeout)
|
- [x] `pytest backend/tests/api/test_analysis_router.py` passes (all paths: happy, error, oversized, partial failure, timeout)
|
||||||
- [x] All vitest tests pass: `npx vitest run src/__tests__/ProgressIndicator.test.tsx src/__tests__/useAnalysis.test.ts`
|
- [x] All vitest tests pass: `npx vitest run src/__tests__/ProgressIndicator.test.tsx src/__tests__/useAnalysis.test.ts`
|
||||||
- [ ] Submitting headers triggers backend analysis with SSE streaming
|
- [x] Submitting headers triggers backend analysis with SSE streaming
|
||||||
- [ ] Progress bar updates in real-time showing current test name and percentage
|
- [ ] Progress bar updates in real-time showing current test name and percentage
|
||||||
- [ ] Countdown timer counts down from 30 seconds
|
- [ ] Countdown timer counts down from 30 seconds
|
||||||
- [ ] Partial failures show inline error indicators per FR-25
|
- [ ] Partial failures show inline error indicators per FR-25
|
||||||
|
|||||||
101
frontend/src/__tests__/HomePage.test.tsx
Normal file
101
frontend/src/__tests__/HomePage.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import Home from "../app/page";
|
||||||
|
|
||||||
|
const { submitSpy, cancelSpy } = vi.hoisted(() => ({
|
||||||
|
submitSpy: vi.fn().mockResolvedValue(undefined),
|
||||||
|
cancelSpy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useAnalysis", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => ({
|
||||||
|
status: "idle",
|
||||||
|
progress: null,
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
submit: submitSpy,
|
||||||
|
cancel: cancelSpy,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextarea = (container: HTMLElement): HTMLTextAreaElement => {
|
||||||
|
const textarea = container.querySelector("textarea");
|
||||||
|
if (!textarea) {
|
||||||
|
throw new Error("Expected header textarea to be rendered.");
|
||||||
|
}
|
||||||
|
return textarea as HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAnalyseButton = (container: HTMLElement): HTMLButtonElement => {
|
||||||
|
const buttons = Array.from(container.querySelectorAll("button"));
|
||||||
|
const button = buttons.find((candidate) =>
|
||||||
|
(candidate.textContent ?? "").toLowerCase().includes("analyse"),
|
||||||
|
);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error("Expected analyse button to be rendered.");
|
||||||
|
}
|
||||||
|
return button as HTMLButtonElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitSpy.mockClear();
|
||||||
|
cancelSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Home page", () => {
|
||||||
|
it("submits analysis when analyse is clicked", () => {
|
||||||
|
const { container } = render(<Home />);
|
||||||
|
const textarea = getTextarea(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
textarea.value = "Received: from mail.example.com";
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = getAnalyseButton(container);
|
||||||
|
act(() => {
|
||||||
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submitSpy).toHaveBeenCalledWith({
|
||||||
|
headers: "Received: from mail.example.com",
|
||||||
|
config: { testIds: [], resolve: false, decodeAll: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import AnalyseButton from "../components/AnalyseButton";
|
import AnalyseButton from "../components/AnalyseButton";
|
||||||
import FileDropZone from "../components/FileDropZone";
|
import FileDropZone from "../components/FileDropZone";
|
||||||
import HeaderInput from "../components/HeaderInput";
|
import HeaderInput from "../components/HeaderInput";
|
||||||
|
import useAnalysis from "../hooks/useAnalysis";
|
||||||
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
|
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
|
||||||
|
import type { AnalysisConfig } from "../types/analysis";
|
||||||
|
|
||||||
|
const defaultConfig: AnalysisConfig = {
|
||||||
|
testIds: [],
|
||||||
|
resolve: false,
|
||||||
|
decodeAll: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [headerInput, setHeaderInput] = useState("");
|
const [headerInput, setHeaderInput] = useState("");
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const { status, submit } = useAnalysis();
|
||||||
const hasHeaderInput = headerInput.trim().length > 0;
|
const hasHeaderInput = headerInput.trim().length > 0;
|
||||||
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
|
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
|
||||||
const canAnalyse = hasHeaderInput && !isOversized;
|
const canAnalyse = hasHeaderInput && !isOversized;
|
||||||
const analyseTimeoutRef = useRef<number | null>(null);
|
const isLoading = status === "submitting" || status === "analysing";
|
||||||
|
|
||||||
useEffect(() => {
|
const handleAnalyse = useCallback(() => {
|
||||||
return () => {
|
|
||||||
if (analyseTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(analyseTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAnalyse = () => {
|
|
||||||
if (!canAnalyse) {
|
if (!canAnalyse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsAnalyzing(true);
|
void submit({ headers: headerInput, config: defaultConfig });
|
||||||
if (analyseTimeoutRef.current !== null) {
|
}, [canAnalyse, headerInput, submit]);
|
||||||
window.clearTimeout(analyseTimeoutRef.current);
|
|
||||||
}
|
|
||||||
analyseTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setIsAnalyzing(false);
|
|
||||||
}, 800);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background text-text">
|
<main className="min-h-screen bg-background text-text">
|
||||||
@@ -70,7 +64,7 @@ export default function Home() {
|
|||||||
<AnalyseButton
|
<AnalyseButton
|
||||||
hasInput={canAnalyse}
|
hasInput={canAnalyse}
|
||||||
onAnalyse={handleAnalyse}
|
onAnalyse={handleAnalyse}
|
||||||
isLoading={isAnalyzing}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 text-xs text-text/60">
|
<div className="flex items-center gap-2 text-xs text-text/60">
|
||||||
<kbd className="rounded-md border border-info/30 bg-background/40 px-2 py-1 font-mono">
|
<kbd className="rounded-md border border-info/30 bg-background/40 px-2 py-1 font-mono">
|
||||||
|
|||||||
Reference in New Issue
Block a user