diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-04-Test-Selection.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-04-Test-Selection.md index ca5b53e..356caed 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-04-Test-Selection.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-04-Test-Selection.md @@ -22,7 +22,7 @@ This phase implements the test selection panel and analysis configuration contro - [x] T020 [US2] Write failing test (TDD Red) in `backend/tests/api/test_tests_router.py` — verify `GET /api/tests` returns 200 with all registered test IDs and names. Use httpx AsyncClient against the FastAPI test app - [x] T021 [US2] Create `backend/app/schemas/tests.py` (response schema) and `backend/app/routers/tests.py` — FastAPI router with `GET /api/tests` returning all available tests (id, name, category) from scanner registry. Register router in `backend/app/main.py`. Verify `test_tests_router.py` passes (TDD Green) -- [ ] T022 [US2] Write failing tests (TDD Red) in `frontend/src/__tests__/TestSelector.test.tsx` (render with mocked list, select/deselect all, search filtering) and `frontend/src/__tests__/AnalysisControls.test.tsx` (render, toggle states, keyboard accessibility) +- [x] T022 [US2] Write failing tests (TDD Red) in `frontend/src/__tests__/TestSelector.test.tsx` (render with mocked list, select/deselect all, search filtering) and `frontend/src/__tests__/AnalysisControls.test.tsx` (render, toggle states, keyboard accessibility) - [ ] T023 [P] [US2] Create `frontend/src/components/TestSelector.tsx` — dropdown with checkboxes per test, fetches list from `GET /api/tests`, "Select All"/"Deselect All" buttons (FR-04), text search/filter (FR-23), grouped by vendor/category (FR-24), FontAwesome icons. Verify `TestSelector.test.tsx` passes (TDD Green) - [ ] T024 [P] [US2] Create `frontend/src/components/AnalysisControls.tsx` — panel containing TestSelector, DNS resolution toggle (off by default, FR-18), decode-all toggle (FR-19), FontAwesome toggle icons, keyboard accessible (NFR-02). Verify `AnalysisControls.test.tsx` passes (TDD Green) diff --git a/frontend/src/__tests__/AnalysisControls.test.tsx b/frontend/src/__tests__/AnalysisControls.test.tsx new file mode 100644 index 0000000..8f81406 --- /dev/null +++ b/frontend/src/__tests__/AnalysisControls.test.tsx @@ -0,0 +1,163 @@ +import type { ReactElement } from "react"; +import { useState } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import AnalysisControls from "../components/AnalysisControls"; +import type { AnalysisConfig } from "../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +type TestInfo = { + id: number; + name: string; + category: string; +}; + +const cleanups: Array<() => void> = []; +let restoreFetch: (() => void) | null = null; + +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 setupFetchMock = (tests: TestInfo[]): void => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ tests, totalCount: tests.length }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + restoreFetch = () => { + globalThis.fetch = originalFetch; + }; +}; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const getToggle = (container: HTMLElement, testId: string): HTMLElement => { + const toggle = container.querySelector(`[data-testid="${testId}"]`); + if (!toggle) { + throw new Error(`Expected toggle ${testId} to be rendered.`); + } + return toggle as HTMLElement; +}; + +const getTestSelector = (container: HTMLElement): HTMLElement => { + const selector = container.querySelector('[data-testid="test-selector"]'); + if (!selector) { + throw new Error("Expected test selector to be rendered."); + } + return selector as HTMLElement; +}; + +const sampleTests: TestInfo[] = [ + { id: 101, name: "SpamAssassin Rule Hits", category: "SpamAssassin" }, + { id: 202, name: "Mimecast Fingerprint", category: "Mimecast" }, +]; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } + if (restoreFetch) { + restoreFetch(); + restoreFetch = null; + } +}); + +describe("AnalysisControls", () => { + it("renders toggles with default off state", async () => { + setupFetchMock(sampleTests); + const config: AnalysisConfig = { testIds: [], resolve: false, decodeAll: false }; + + const { container } = render( + undefined} />, + ); + + await act(async () => { + await flushPromises(); + }); + + getTestSelector(container); + expect(getToggle(container, "toggle-resolve").getAttribute("aria-checked")).toBe("false"); + expect(getToggle(container, "toggle-decode-all").getAttribute("aria-checked")).toBe( + "false", + ); + }); + + it("updates toggles on click and keyboard", async () => { + setupFetchMock(sampleTests); + const handleChange = vi.fn(); + + const AnalysisControlsHarness = () => { + const [config, setConfig] = useState({ + testIds: [], + resolve: false, + decodeAll: false, + }); + + const updateConfig = (next: AnalysisConfig) => { + setConfig(next); + handleChange(next); + }; + + return ; + }; + + const { container } = render(); + + await act(async () => { + await flushPromises(); + }); + + const resolveToggle = getToggle(container, "toggle-resolve"); + act(() => { + resolveToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(resolveToggle.getAttribute("aria-checked")).toBe("true"); + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ resolve: true, decodeAll: false }), + ); + + const decodeToggle = getToggle(container, "toggle-decode-all"); + act(() => { + decodeToggle.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + + expect(decodeToggle.getAttribute("aria-checked")).toBe("true"); + expect(handleChange).toHaveBeenLastCalledWith( + expect.objectContaining({ resolve: true, decodeAll: true }), + ); + }); +}); diff --git a/frontend/src/__tests__/TestSelector.test.tsx b/frontend/src/__tests__/TestSelector.test.tsx new file mode 100644 index 0000000..2c9785e --- /dev/null +++ b/frontend/src/__tests__/TestSelector.test.tsx @@ -0,0 +1,188 @@ +import type { ReactElement } from "react"; +import { useState } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import TestSelector from "../components/TestSelector"; + +type RenderResult = { + container: HTMLDivElement; +}; + +type TestInfo = { + id: number; + name: string; + category: string; +}; + +const cleanups: Array<() => void> = []; +let restoreFetch: (() => void) | null = null; + +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 setupFetchMock = (tests: TestInfo[]): void => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ tests, totalCount: tests.length }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + restoreFetch = () => { + globalThis.fetch = originalFetch; + }; +}; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const getSearchInput = (container: HTMLElement): HTMLInputElement => { + const input = container.querySelector('[data-testid="test-search-input"]'); + if (!input) { + throw new Error("Expected test search input to be rendered."); + } + return input as HTMLInputElement; +}; + +const getSelectAllButton = (container: HTMLElement): HTMLButtonElement => { + const button = container.querySelector('[data-testid="select-all-tests"]'); + if (!button) { + throw new Error("Expected select all button to be rendered."); + } + return button as HTMLButtonElement; +}; + +const getDeselectAllButton = (container: HTMLElement): HTMLButtonElement => { + const button = container.querySelector('[data-testid="deselect-all-tests"]'); + if (!button) { + throw new Error("Expected deselect all button to be rendered."); + } + return button as HTMLButtonElement; +}; + +const getCheckbox = (container: HTMLElement, id: number): HTMLInputElement => { + const checkbox = container.querySelector(`[data-testid="test-checkbox-${id}"]`); + if (!checkbox) { + throw new Error(`Expected checkbox for test ${id} to be rendered.`); + } + return checkbox as HTMLInputElement; +}; + +const queryCheckbox = (container: HTMLElement, id: number): HTMLInputElement | null => { + return container.querySelector(`[data-testid="test-checkbox-${id}"]`) as HTMLInputElement | null; +}; + +const sampleTests: TestInfo[] = [ + { id: 101, name: "SpamAssassin Rule Hits", category: "SpamAssassin" }, + { id: 202, name: "Mimecast Fingerprint", category: "Mimecast" }, + { id: 303, name: "Barracuda Reputation", category: "Barracuda" }, +]; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } + if (restoreFetch) { + restoreFetch(); + restoreFetch = null; + } +}); + +describe("TestSelector", () => { + it("renders fetched tests with checkboxes", async () => { + setupFetchMock(sampleTests); + const { container } = render( + undefined} />, + ); + + await act(async () => { + await flushPromises(); + }); + + sampleTests.forEach((test) => { + const checkbox = getCheckbox(container, test.id); + expect(checkbox).toBeTruthy(); + expect(container.textContent ?? "").toContain(test.name); + }); + }); + + it("selects and deselects all tests", async () => { + setupFetchMock(sampleTests); + + const TestSelectorHarness = () => { + const [selected, setSelected] = useState([]); + return ; + }; + + const { container } = render(); + + await act(async () => { + await flushPromises(); + }); + + const selectAllButton = getSelectAllButton(container); + act(() => { + selectAllButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + sampleTests.forEach((test) => { + expect(getCheckbox(container, test.id).checked).toBe(true); + }); + + const deselectAllButton = getDeselectAllButton(container); + act(() => { + deselectAllButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + sampleTests.forEach((test) => { + expect(getCheckbox(container, test.id).checked).toBe(false); + }); + }); + + it("filters tests by search text", async () => { + setupFetchMock(sampleTests); + + const { container } = render( + undefined} />, + ); + + await act(async () => { + await flushPromises(); + }); + + const searchInput = getSearchInput(container); + act(() => { + searchInput.value = "mime"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(getCheckbox(container, 202)).toBeTruthy(); + expect(queryCheckbox(container, 101)).toBeNull(); + expect(queryCheckbox(container, 303)).toBeNull(); + }); +});