mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 13:33:30 +01:00
MAESTRO: add failing tests for test selection controls
This commit is contained in:
@@ -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] 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)
|
- [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)
|
- [ ] 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)
|
- [ ] 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)
|
||||||
|
|
||||||
|
|||||||
163
frontend/src/__tests__/AnalysisControls.test.tsx
Normal file
163
frontend/src/__tests__/AnalysisControls.test.tsx
Normal file
@@ -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<void> => {
|
||||||
|
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(
|
||||||
|
<AnalysisControls config={config} onChange={() => 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<AnalysisConfig>({
|
||||||
|
testIds: [],
|
||||||
|
resolve: false,
|
||||||
|
decodeAll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConfig = (next: AnalysisConfig) => {
|
||||||
|
setConfig(next);
|
||||||
|
handleChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AnalysisControls config={config} onChange={updateConfig} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<AnalysisControlsHarness />);
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
frontend/src/__tests__/TestSelector.test.tsx
Normal file
188
frontend/src/__tests__/TestSelector.test.tsx
Normal file
@@ -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<void> => {
|
||||||
|
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(
|
||||||
|
<TestSelector selectedTestIds={[]} onSelectionChange={() => 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<number[]>([]);
|
||||||
|
return <TestSelector selectedTestIds={selected} onSelectionChange={setSelected} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<TestSelectorHarness />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestSelector selectedTestIds={[]} onSelectionChange={() => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user