mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 13:33:30 +01:00
MAESTRO: add red tests for header input flow
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# Phase 03: US1 — Header Input & Submission
|
||||||
|
|
||||||
|
This phase implements the user-facing input layer: a multi-line textarea for pasting SMTP headers, a drag-and-drop zone for EML/TXT files, and an analyse button with keyboard shortcut. The dark hacker theme becomes visible. By the end of this phase, users can paste text, drop files to auto-populate, and trigger analysis (though the full analysis pipeline connects in Phase 5). TDD Red-Green: write failing component tests first, then implement components.
|
||||||
|
|
||||||
|
## Spec Kit Context
|
||||||
|
|
||||||
|
- **Feature:** 1-web-header-analyzer
|
||||||
|
- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (FR-01, FR-02, FR-05, FR-14, FR-15)
|
||||||
|
- **Plan:** .specify/specs/1-web-header-analyzer/plan.md
|
||||||
|
- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
|
||||||
|
- **User Story:** US1 — Header Input & Submission (Scenarios 1, 2)
|
||||||
|
- **Constitution:** .specify/memory/constitution.md (TDD: P6, UX: P7)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Requires Phase 01** completed (frontend project initialised, design tokens, types)
|
||||||
|
- **Does NOT require Phase 02** (engine) — components are UI-only at this stage
|
||||||
|
|
||||||
|
## Design Reference
|
||||||
|
|
||||||
|
- Background: `#1e1e2e`, Surface: `#282a36`, Text: `#f8f8f2`
|
||||||
|
- Monospace font for header text areas
|
||||||
|
- FontAwesome icons for actions (paste, upload, clear, analyse)
|
||||||
|
- Responsive from 320px to 2560px (NFR-04)
|
||||||
|
- All controls keyboard accessible (NFR-02)
|
||||||
|
- Input validation: empty input blocked, oversized >1MB rejected with user-friendly error (NFR-10)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] T015 [US1] Write failing tests (TDD Red) in `frontend/src/__tests__/HeaderInput.test.tsx` (render, paste simulation, empty/oversized validation), `frontend/src/__tests__/FileDropZone.test.tsx` (render, drop event, file type filtering), and `frontend/src/__tests__/AnalyseButton.test.tsx` (render, disabled state, Ctrl+Enter shortcut)
|
||||||
|
- [ ] T016 [US1] Create main page layout in `frontend/src/app/page.tsx` with dark hacker theme (#1e1e2e background, monospace code areas, project title). Responsive from 320px to 2560px (NFR-04)
|
||||||
|
- [ ] T017 [P] [US1] Create `frontend/src/components/HeaderInput.tsx` — multi-line textarea for SMTP headers with placeholder, character count, clear button (FontAwesome icon), monospace styling, keyboard accessible (NFR-02), validation for empty and oversized >1MB input (NFR-10). Verify `HeaderInput.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T018 [P] [US1] Create `frontend/src/components/FileDropZone.tsx` — drag-and-drop zone accepting `.eml` and `.txt` files, reads client-side via File API (FR-02), populates HeaderInput on drop, shows drag-over highlight and rejection feedback, FontAwesome upload icon. Verify `FileDropZone.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T019 [US1] Create `frontend/src/components/AnalyseButton.tsx` — primary action button with FontAwesome analyse icon, Ctrl+Enter shortcut (FR-05), disabled when input empty, loading state during analysis (NFR-05), hacker accent colour. Verify `AnalyseButton.test.tsx` passes (TDD Green)
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
- [ ] All vitest tests pass: `npx vitest run src/__tests__/HeaderInput.test.tsx src/__tests__/FileDropZone.test.tsx src/__tests__/AnalyseButton.test.tsx`
|
||||||
|
- [ ] User can paste text into the header input area
|
||||||
|
- [ ] User can drop an EML/TXT file and see it auto-populate the input
|
||||||
|
- [ ] Analyse button is disabled when input is empty
|
||||||
|
- [ ] Ctrl+Enter keyboard shortcut triggers the analyse action
|
||||||
|
- [ ] Dark hacker theme is visible with correct colour palette
|
||||||
|
- [ ] Validation shows user-friendly errors for empty and oversized input
|
||||||
|
- [ ] `npx eslint src/` and `npx prettier --check src/` pass with zero errors
|
||||||
|
- [ ] Run `/speckit.analyze` to verify consistency
|
||||||
73
frontend/src/__tests__/AnalyseButton.test.tsx
Normal file
73
frontend/src/__tests__/AnalyseButton.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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 AnalyseButton from "../components/AnalyseButton";
|
||||||
|
|
||||||
|
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 getButton = (container: HTMLElement): HTMLButtonElement => {
|
||||||
|
const button = container.querySelector("button");
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AnalyseButton", () => {
|
||||||
|
it("renders an analyse button", () => {
|
||||||
|
const { container } = render(<AnalyseButton hasInput onAnalyse={() => undefined} />);
|
||||||
|
const button = getButton(container);
|
||||||
|
expect(button.textContent ?? "").toMatch(/analyse/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables itself when input is empty", () => {
|
||||||
|
const { container } = render(<AnalyseButton hasInput={false} onAnalyse={() => undefined} />);
|
||||||
|
const button = getButton(container);
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers analyse on Ctrl+Enter", () => {
|
||||||
|
const handleAnalyse = vi.fn();
|
||||||
|
render(<AnalyseButton hasInput onAnalyse={handleAnalyse} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleAnalyse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
frontend/src/__tests__/FileDropZone.test.tsx
Normal file
131
frontend/src/__tests__/FileDropZone.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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 FileDropZone from "../components/FileDropZone";
|
||||||
|
|
||||||
|
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 getDropZone = (container: HTMLElement): HTMLElement => {
|
||||||
|
const zone = container.querySelector('[data-testid="file-drop-zone"]');
|
||||||
|
if (!zone) {
|
||||||
|
throw new Error("Expected file drop zone to be rendered.");
|
||||||
|
}
|
||||||
|
return zone as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDropEvent = (files: File[]): Event => {
|
||||||
|
const event = new Event("drop", { bubbles: true });
|
||||||
|
const items = files.map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
type: file.type,
|
||||||
|
getAsFile: () => file,
|
||||||
|
}));
|
||||||
|
|
||||||
|
Object.defineProperty(event, "dataTransfer", {
|
||||||
|
value: {
|
||||||
|
files,
|
||||||
|
items,
|
||||||
|
types: ["Files"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFileReader = (result: string): (() => void) => {
|
||||||
|
const original = globalThis.FileReader;
|
||||||
|
|
||||||
|
class MockFileReader {
|
||||||
|
result: string | ArrayBuffer | null = null;
|
||||||
|
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => void) | null = null;
|
||||||
|
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => void) | null = null;
|
||||||
|
|
||||||
|
readAsText() {
|
||||||
|
this.result = result;
|
||||||
|
if (this.onload) {
|
||||||
|
this.onload(new ProgressEvent("load"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.FileReader = MockFileReader as unknown as typeof FileReader;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalThis.FileReader = original;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FileDropZone", () => {
|
||||||
|
it("renders a drop zone for EML/TXT files", () => {
|
||||||
|
const { container } = render(<FileDropZone onFileContent={() => undefined} />);
|
||||||
|
expect(getDropZone(container)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads dropped EML/TXT file content", () => {
|
||||||
|
const handleContent = vi.fn();
|
||||||
|
const restore = mockFileReader("Header from file");
|
||||||
|
const { container } = render(<FileDropZone onFileContent={handleContent} />);
|
||||||
|
const dropZone = getDropZone(container);
|
||||||
|
const file = new File(["Header from file"], "sample.eml", { type: "message/rfc822" });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dropZone.dispatchEvent(createDropEvent([file]));
|
||||||
|
});
|
||||||
|
|
||||||
|
restore();
|
||||||
|
|
||||||
|
expect(handleContent).toHaveBeenCalledWith("Header from file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported file types with feedback", () => {
|
||||||
|
const handleContent = vi.fn();
|
||||||
|
const { container } = render(<FileDropZone onFileContent={handleContent} />);
|
||||||
|
const dropZone = getDropZone(container);
|
||||||
|
const file = new File(["irrelevant"], "payload.pdf", { type: "application/pdf" });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dropZone.dispatchEvent(createDropEvent([file]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleContent).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
expect(alert?.textContent ?? "").toMatch(/\.eml/i);
|
||||||
|
expect(alert?.textContent ?? "").toMatch(/\.txt/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
frontend/src/__tests__/HeaderInput.test.tsx
Normal file
108
frontend/src/__tests__/HeaderInput.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 HeaderInput from "../components/HeaderInput";
|
||||||
|
|
||||||
|
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 textarea to be rendered.");
|
||||||
|
}
|
||||||
|
return textarea as HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlert = (container: HTMLElement): HTMLElement | null =>
|
||||||
|
container.querySelector("[role=\"alert\"]");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeaderInput", () => {
|
||||||
|
it("renders a textarea for SMTP headers", () => {
|
||||||
|
const { container } = render(<HeaderInput value="" onChange={() => undefined} />);
|
||||||
|
expect(getTextarea(container)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts pasted header text", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { container } = render(<HeaderInput value="" onChange={handleChange} />);
|
||||||
|
const textarea = getTextarea(container);
|
||||||
|
const pasted = "Received: from mail.example.com by mx.example.net";
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const pasteEvent = new Event("paste", { bubbles: true });
|
||||||
|
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||||
|
value: {
|
||||||
|
getData: () => pasted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
textarea.value = pasted;
|
||||||
|
textarea.dispatchEvent(pasteEvent);
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(pasted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a validation error when left empty", () => {
|
||||||
|
const { container } = render(<HeaderInput value="" onChange={() => undefined} />);
|
||||||
|
const textarea = getTextarea(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
textarea.dispatchEvent(new Event("blur", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const alert = getAlert(container);
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
expect(alert?.textContent ?? "").toMatch(/empty|required/i);
|
||||||
|
expect(textarea.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a validation error when input exceeds 1 MB", () => {
|
||||||
|
const oversized = "a".repeat(1024 * 1024 + 1);
|
||||||
|
const { container } = render(<HeaderInput value={oversized} onChange={() => undefined} />);
|
||||||
|
const textarea = getTextarea(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
textarea.dispatchEvent(new Event("blur", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const alert = getAlert(container);
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
expect(alert?.textContent ?? "").toMatch(/1\s?mb/i);
|
||||||
|
expect(textarea.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user