test(frontend): Vitest-Unit-Tests + CI-Integration, CLAUDE.md ignoriert
CI / backend-tests (push) Failing after 2m18s
CI / frontend-build (push) Failing after 17s

16 Tests ohne Browser-Abhängigkeit (jsdom): useIsDesktop (matchMedia-Reaktion),
client.ts (ApiError, Popup-Blocker-Fix bei ladeDokumentAnsehen), CommandPalette
(Debounce/Escape/Material-Treffer-Hinweis), ObjektListPage (Standort-
Gruppierung, Kacheln-Umschalter, localStorage-Persistenz, Suche). Vitest
separat von vite.config.ts (eigene vitest.config.ts), e2e/*.spec.ts (Playwright)
explizit ausgeschlossen. CI führt `npm run test` jetzt vor dem Build aus.

Nebenbei: CLAUDE.md ins .gitignore aufgenommen (bleibt lokal, nicht Teil des
Repos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 09:46:35 +02:00
co-authored by Claude Sonnet 5
parent 7ce644ac51
commit 28d8b2957d
10 changed files with 1558 additions and 2 deletions
+3
View File
@@ -71,5 +71,8 @@ jobs:
- name: Dependencies installieren - name: Dependencies installieren
run: npm install run: npm install
- name: Unit-Tests (Vitest)
run: npm run test
- name: Typecheck + Build - name: Typecheck + Build
run: npm run build run: npm run build
+1
View File
@@ -1,3 +1,4 @@
*~ *~
screenshots/ screenshots/
subagents-sammlung/ subagents-sammlung/
CLAUDE.md
+1228 -1
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -7,6 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
@@ -26,12 +28,17 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.47.2", "@playwright/test": "^1.47.2",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"jsdom": "^25.0.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.6", "vite": "^5.4.6",
"vite-plugin-pwa": "^0.20.5" "vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.9"
} }
} }
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiRequest, ApiError, ladeDokumentAnsehen } from "./client";
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("apiRequest", () => {
it("gibt JSON bei Erfolg zurück", async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 })
);
const result = await apiRequest<{ ok: boolean }>("/irgendwas");
expect(result).toEqual({ ok: true });
});
it("wirft ApiError mit Status und Detail bei Nicht-2xx", async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response(JSON.stringify({ detail: "Duplikat" }), { status: 409 })
);
await expect(apiRequest("/dokumente")).rejects.toMatchObject({
status: 409,
detail: { detail: "Duplikat" },
});
});
it("liefert undefined bei 204 ohne Body-Parse-Versuch", async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 204 }));
const result = await apiRequest("/irgendwas", { method: "DELETE" });
expect(result).toBeUndefined();
});
it("ApiError bleibt auch ohne JSON-Body (z.B. leere 401-Antwort) funktionsfähig", async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 401 }));
await expect(apiRequest("/geschuetzt")).rejects.toMatchObject({ status: 401, detail: null });
});
});
describe("ladeDokumentAnsehen (Popup-Blocker-Fix)", () => {
it("öffnet das Tab-Fenster SOFORT synchron, bevor fetch abgeschlossen ist", async () => {
const tabStub = { location: { href: "" }, close: vi.fn() };
const openSpy = vi.spyOn(window, "open").mockReturnValue(tabStub as unknown as Window);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response(new Blob(["pdf-inhalt"]), { status: 200 })
);
vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:fake-url") });
const promise = ladeDokumentAnsehen("abc-123");
// window.open muss VOR dem await fetch() aufgerufen worden sein - genau
// das war der ursprüngliche Bug (Popup-Blocker griff, weil open() erst
// nach dem await passierte und damit nicht mehr als Nutzer-Klick zählte).
expect(openSpy).toHaveBeenCalledWith("", "_blank");
await promise;
expect(tabStub.location.href).toBe("blob:fake-url");
});
it("fällt bei blockiertem Popup auf denselben Tab zurück statt zu crashen", async () => {
vi.spyOn(window, "open").mockReturnValue(null);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response(new Blob(["pdf-inhalt"]), { status: 200 })
);
vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:fake-url") });
const ortsStub = { href: "" };
vi.stubGlobal("location", ortsStub);
await ladeDokumentAnsehen("abc-123");
expect(ortsStub.href).toBe("blob:fake-url");
});
it("schließt das leere Tab-Fenster wieder, wenn der Download fehlschlägt", async () => {
const tabStub = { location: { href: "" }, close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(tabStub as unknown as Window);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 404 }));
await expect(ladeDokumentAnsehen("fehlt")).rejects.toBeInstanceOf(ApiError);
expect(tabStub.close).toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommandPalette } from "./CommandPalette";
import * as client from "../api/client";
import { AuthProvider } from "../auth/AuthContext";
function renderPalette() {
return render(
<MemoryRouter>
<AuthProvider>
<CommandPalette />
</AuthProvider>
</MemoryRouter>
);
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("CommandPalette", () => {
it("öffnet sich per Strg+K und schließt per Escape", () => {
renderPalette();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("sucht erst ab 2 Zeichen und debounced Backend-Aufrufe (250ms)", async () => {
const suchSpy = vi.spyOn(client, "apiRequest").mockResolvedValue([]);
renderPalette();
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
const eingabe = screen.getByPlaceholderText("Objekt suchen oder Aktion wählen…");
fireEvent.change(eingabe, { target: { value: "K" } });
await vi.advanceTimersByTimeAsync(300);
expect(suchSpy).not.toHaveBeenCalled();
fireEvent.change(eingabe, { target: { value: "Kompresse" } });
await vi.advanceTimersByTimeAsync(300);
expect(suchSpy).toHaveBeenCalledWith("/suche?q=Kompresse");
});
it("zeigt Material-Treffer mit ueber_material-Hinweis im Code-Feld", async () => {
vi.spyOn(client, "apiRequest").mockResolvedValue([
{ id: 7, code: "RTW-1", name: "RTW 1", ueber_material: "Kompresse" },
]);
renderPalette();
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
fireEvent.change(screen.getByPlaceholderText("Objekt suchen oder Aktion wählen…"), {
target: { value: "Kompresse" },
});
await vi.advanceTimersByTimeAsync(300);
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
expect(screen.getByText("RTW-1 · führt „Kompresse“")).toBeInTheDocument();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useIsDesktop } from "./useIsDesktop";
function mockMatchMedia(anfangsWert: boolean) {
const listeners = new Set<(e: MediaQueryListEvent) => void>();
const mql = {
matches: anfangsWert,
addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
};
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue(mql));
return {
aendern(neuerWert: boolean) {
mql.matches = neuerWert;
listeners.forEach((fn) => fn({ matches: neuerWert } as MediaQueryListEvent));
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("useIsDesktop", () => {
it("liefert den initialen matchMedia-Wert", () => {
mockMatchMedia(true);
const { result } = renderHook(() => useIsDesktop());
expect(result.current).toBe(true);
});
it("reagiert live auf einen Breakpoint-Wechsel (Resize)", () => {
const { aendern } = mockMatchMedia(false);
const { result } = renderHook(() => useIsDesktop());
expect(result.current).toBe(false);
act(() => aendern(true));
expect(result.current).toBe(true);
act(() => aendern(false));
expect(result.current).toBe(false);
});
});
@@ -0,0 +1,79 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as client from "../api/client";
import { ObjektListPage } from "./ObjektListPage";
const OBJEKTE = [
{ id: 1, code: "RTW-1", name: "RTW 1", standort_id: 10, status: "aktiv", letzte_kontrolle_am: null },
{ id: 2, code: "KTW-1", name: "KTW 1", standort_id: 20, status: "aktiv", letzte_kontrolle_am: null },
];
const STANDORTE = [
{ id: 10, name: "Wache Nord" },
{ id: 20, name: "Wache Süd" },
];
function renderSeite() {
return render(
<MemoryRouter>
<ObjektListPage />
</MemoryRouter>
);
}
beforeEach(() => {
localStorage.clear();
vi.spyOn(client, "apiRequest").mockImplementation((pfad: string) => {
if (pfad === "/objekte") return Promise.resolve(OBJEKTE);
if (pfad === "/standorte") return Promise.resolve(STANDORTE);
return Promise.reject(new Error(`unerwarteter Pfad: ${pfad}`));
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("ObjektListPage", () => {
it("gruppiert Objekte standardmäßig nach Standort", async () => {
renderSeite();
await waitFor(() => expect(screen.getByText("Wache Nord", { exact: false })).toBeInTheDocument());
expect(screen.getByText("Wache Süd", { exact: false })).toBeInTheDocument();
expect(screen.getByText("RTW 1")).toBeInTheDocument();
expect(screen.getByText("KTW 1")).toBeInTheDocument();
});
it("Umschalter wechselt auf flache Kachel-Ansicht ohne Standort-Gruppen", async () => {
const nutzer = userEvent.setup();
renderSeite();
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: "Kacheln" }));
expect(screen.queryByText("Wache Nord", { exact: false })).not.toBeInTheDocument();
expect(screen.getByText("RTW 1")).toBeInTheDocument();
expect(screen.getByText("KTW 1")).toBeInTheDocument();
});
it("merkt die gewählte Ansicht über localStorage", async () => {
const nutzer = userEvent.setup();
renderSeite();
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: "Kacheln" }));
expect(localStorage.getItem("mabea-objekte-ansicht")).toBe("kacheln");
});
it("Suchfeld filtert nach Name und Code", async () => {
const nutzer = userEvent.setup();
renderSeite();
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
await nutzer.type(screen.getByPlaceholderText("Suche oder Code…"), "KTW");
expect(screen.queryByText("RTW 1")).not.toBeInTheDocument();
expect(screen.getByText("KTW 1")).toBeInTheDocument();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import "@testing-library/jest-dom/vitest";
afterEach(() => {
cleanup();
});
// jsdom kennt window.matchMedia nicht - Default-Stub (immer "matches: false",
// also "mobil"), Tests die eine echte Breakpoint-Reaktion prüfen (useIsDesktop)
// überschreiben das gezielt selbst.
if (!window.matchMedia) {
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}
+17
View File
@@ -0,0 +1,17 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// Separat von vite.config.ts, damit das eigentliche Build-Setup (PWA-Plugin,
// Tailwind) unangetastet bleibt - Tests brauchen weder Service-Worker-
// Generierung noch das Tailwind-Vite-Plugin.
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
// e2e/*.spec.ts gehören Playwright (eigener Testrunner, andere API) -
// sonst versucht Vitest sie mitzusammeln und crasht.
exclude: ["e2e/**", "node_modules/**"],
},
});