test(frontend): Vitest-Unit-Tests + CI-Integration, CLAUDE.md ignoriert
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:
@@ -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,3 +1,4 @@
|
|||||||
*~
|
*~
|
||||||
screenshots/
|
screenshots/
|
||||||
subagents-sammlung/
|
subagents-sammlung/
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
Generated
+1228
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user