From 9420201932b5054fd57860bc4f044cf2991755ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 00:48:10 +0200 Subject: [PATCH] Add tests to all packages, remove passWithNoTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every package now has tests — no more empty test suites: - map-core: 33 tests — maxspeedColor, routeGradeColor, elevationColor, POI categories, getCategoriesForProfile, tile layer configs - db: 5 tests — withDb error handling (503 on DB error, re-throws Response/DataWithResponseInit, logs errors) - ui: 14 tests — Button variants/sizes/disabled, Input label/id/error, Card className forwarding - map: 2 tests — baseLayers/overlayLayers re-export verification Remove passWithNoTests from vitest.shared.ts so missing tests are caught immediately when adding new packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/src/index.test.ts | 59 +++++++++++++ .../map-core/src/colors/elevation.test.ts | 54 ++++++++++++ packages/map-core/src/colors/maxspeed.test.ts | 38 ++++++++ packages/map-core/src/poi.test.ts | 56 ++++++++++++ packages/map-core/src/tiles.test.ts | 40 +++++++++ packages/map/src/index.test.ts | 19 ++++ packages/ui/src/components.test.tsx | 87 +++++++++++++++++++ packages/ui/vitest.config.ts | 9 +- vitest.shared.ts | 1 - 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 packages/db/src/index.test.ts create mode 100644 packages/map-core/src/colors/elevation.test.ts create mode 100644 packages/map-core/src/colors/maxspeed.test.ts create mode 100644 packages/map-core/src/poi.test.ts create mode 100644 packages/map-core/src/tiles.test.ts create mode 100644 packages/map/src/index.test.ts create mode 100644 packages/ui/src/components.test.tsx diff --git a/packages/db/src/index.test.ts b/packages/db/src/index.test.ts new file mode 100644 index 0000000..44aefc8 --- /dev/null +++ b/packages/db/src/index.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from "vitest"; +import { withDb } from "./index.ts"; + +describe("withDb", () => { + it("returns the handler result on success", async () => { + const result = await withDb(async () => "ok"); + expect(result).toBe("ok"); + }); + + it("throws 503 Response on database errors", async () => { + try { + await withDb(async () => { + throw new Error("connection refused"); + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Response); + expect((err as Response).status).toBe(503); + } + }); + + it("re-throws Response instances (redirects)", async () => { + const redirect = new Response(null, { status: 302 }); + try { + await withDb(async () => { + throw redirect; + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBe(redirect); + expect((err as Response).status).toBe(302); + } + }); + + it("re-throws DataWithResponseInit errors", async () => { + const dataError = { type: "DataWithResponseInit", data: "test", init: {} }; + try { + await withDb(async () => { + throw dataError; + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBe(dataError); + } + }); + + it("logs database errors", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await withDb(async () => { + throw new Error("timeout"); + }); + } catch { + // expected + } + expect(spy).toHaveBeenCalledWith("[withDb] Database error:", "timeout"); + spy.mockRestore(); + }); +}); diff --git a/packages/map-core/src/colors/elevation.test.ts b/packages/map-core/src/colors/elevation.test.ts new file mode 100644 index 0000000..da58151 --- /dev/null +++ b/packages/map-core/src/colors/elevation.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { routeGradeColor, elevationColor } from "./elevation.ts"; + +describe("routeGradeColor", () => { + it("returns green for flat terrain", () => { + expect(routeGradeColor(0)).toBe("#22c55e"); + expect(routeGradeColor(2)).toBe("#22c55e"); + }); + + it("returns yellow for moderate grades", () => { + expect(routeGradeColor(4)).toBe("#eab308"); + }); + + it("returns orange for steep grades", () => { + expect(routeGradeColor(8)).toBe("#f97316"); + }); + + it("returns red for very steep grades", () => { + expect(routeGradeColor(12)).toBe("#ef4444"); + }); + + it("returns dark red for extreme grades", () => { + expect(routeGradeColor(20)).toBe("#991b1b"); + }); + + it("uses absolute value for negative grades (descents)", () => { + expect(routeGradeColor(-4)).toBe("#eab308"); + expect(routeGradeColor(-12)).toBe("#ef4444"); + }); +}); + +describe("elevationColor", () => { + it("returns green at t=0", () => { + expect(elevationColor(0)).toBe("rgb(0, 200, 50)"); + }); + + it("returns yellow at t=0.5", () => { + expect(elevationColor(0.5)).toBe("rgb(255, 200, 50)"); + }); + + it("returns red at t=1", () => { + expect(elevationColor(1)).toBe("rgb(255, 0, 50)"); + }); + + it("interpolates between green and yellow in lower half", () => { + const color = elevationColor(0.25); + expect(color).toMatch(/^rgb\(\d+, 200, 50\)$/); + }); + + it("interpolates between yellow and red in upper half", () => { + const color = elevationColor(0.75); + expect(color).toMatch(/^rgb\(255, \d+, 50\)$/); + }); +}); diff --git a/packages/map-core/src/colors/maxspeed.test.ts b/packages/map-core/src/colors/maxspeed.test.ts new file mode 100644 index 0000000..433064f --- /dev/null +++ b/packages/map-core/src/colors/maxspeed.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { maxspeedColor } from "./maxspeed.ts"; + +describe("maxspeedColor", () => { + it("returns green for walking speed", () => { + expect(maxspeedColor("walk")).toBe("#22c55e"); + }); + + it("returns dark red for no speed limit", () => { + expect(maxspeedColor("none")).toBe("#991b1b"); + }); + + it("returns green for low speeds", () => { + expect(maxspeedColor("20")).toBe("#22c55e"); + expect(maxspeedColor("30")).toBe("#22c55e"); + }); + + it("returns yellow for moderate speeds", () => { + expect(maxspeedColor("50")).toBe("#eab308"); + }); + + it("returns orange for higher speeds", () => { + expect(maxspeedColor("70")).toBe("#f97316"); + }); + + it("returns red for fast speeds", () => { + expect(maxspeedColor("100")).toBe("#ef4444"); + }); + + it("returns dark red for very fast speeds", () => { + expect(maxspeedColor("130")).toBe("#991b1b"); + }); + + it("returns gray for unknown values", () => { + expect(maxspeedColor("unknown")).toBe("#9ca3af"); + expect(maxspeedColor("")).toBe("#9ca3af"); + }); +}); diff --git a/packages/map-core/src/poi.test.ts b/packages/map-core/src/poi.test.ts new file mode 100644 index 0000000..5da41ea --- /dev/null +++ b/packages/map-core/src/poi.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { poiCategories, getCategoriesForProfile, profileOverlayDefaults } from "./poi.ts"; + +describe("poiCategories", () => { + it("has expected categories", () => { + const ids = poiCategories.map((c) => c.id); + expect(ids).toContain("drinking_water"); + expect(ids).toContain("camping"); + expect(ids).toContain("food"); + expect(ids).toContain("bike_infra"); + }); + + it("every category has required fields", () => { + for (const cat of poiCategories) { + expect(cat.id).toBeTruthy(); + expect(cat.name).toBeTruthy(); + expect(cat.icon).toBeTruthy(); + expect(cat.color).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(cat.query).toBeTruthy(); + } + }); +}); + +describe("getCategoriesForProfile", () => { + it("returns trekking-specific categories", () => { + const ids = getCategoriesForProfile("trekking"); + expect(ids).toContain("shelter"); + expect(ids).toContain("viewpoints"); + expect(ids).not.toContain("bike_infra"); + }); + + it("returns cycling-specific categories", () => { + const ids = getCategoriesForProfile("fastbike"); + expect(ids).toContain("bike_infra"); + expect(ids).not.toContain("shelter"); + }); + + it("returns empty array for unknown profile", () => { + expect(getCategoriesForProfile("unknown")).toEqual([]); + }); + + it("returns empty array for profiles with no dedicated categories", () => { + // Categories without profiles are available to everyone, not returned here + expect(getCategoriesForProfile("")).toEqual([]); + }); +}); + +describe("profileOverlayDefaults", () => { + it("maps fastbike to cycling routes", () => { + expect(profileOverlayDefaults["fastbike"]).toContain("waymarked-cycling"); + }); + + it("maps trekking to hiking routes", () => { + expect(profileOverlayDefaults["trekking"]).toContain("waymarked-hiking"); + }); +}); diff --git a/packages/map-core/src/tiles.test.ts b/packages/map-core/src/tiles.test.ts new file mode 100644 index 0000000..f7d51ac --- /dev/null +++ b/packages/map-core/src/tiles.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { baseLayers, overlayLayers } from "./tiles.ts"; + +describe("baseLayers", () => { + it("has at least one base layer", () => { + expect(baseLayers.length).toBeGreaterThan(0); + }); + + it("every layer has a valid URL template", () => { + for (const layer of baseLayers) { + expect(layer.url).toContain("{z}"); + expect(layer.url).toContain("{x}"); + expect(layer.url).toContain("{y}"); + } + }); + + it("every layer has a name and attribution", () => { + for (const layer of baseLayers) { + expect(layer.name).toBeTruthy(); + expect(layer.attribution).toBeTruthy(); + } + }); + + it("includes OpenStreetMap", () => { + expect(baseLayers.some((l) => l.name === "OpenStreetMap")).toBe(true); + }); +}); + +describe("overlayLayers", () => { + it("every overlay has a unique id", () => { + const ids = overlayLayers.map((l) => l.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every overlay has a valid URL template", () => { + for (const layer of overlayLayers) { + expect(layer.url).toContain("{z}"); + } + }); +}); diff --git a/packages/map/src/index.test.ts b/packages/map/src/index.test.ts new file mode 100644 index 0000000..3014fe4 --- /dev/null +++ b/packages/map/src/index.test.ts @@ -0,0 +1,19 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { baseLayers, overlayLayers } from "./index.ts"; + +describe("map package exports", () => { + it("re-exports baseLayers from map-core", () => { + expect(baseLayers).toBeDefined(); + expect(baseLayers.length).toBeGreaterThan(0); + expect(baseLayers[0]!.name).toBe("OpenStreetMap"); + }); + + it("re-exports overlayLayers from map-core", () => { + expect(overlayLayers).toBeDefined(); + expect(overlayLayers.length).toBeGreaterThan(0); + expect(overlayLayers.every((l) => l.id)).toBe(true); + }); +}); diff --git a/packages/ui/src/components.test.tsx b/packages/ui/src/components.test.tsx new file mode 100644 index 0000000..cf23458 --- /dev/null +++ b/packages/ui/src/components.test.tsx @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, afterEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { Button } from "./Button.tsx"; +import { Input } from "./Input.tsx"; +import { Card } from "./Card.tsx"; + +afterEach(cleanup); + +describe("Button", () => { + it("renders with text", () => { + render(); + expect(screen.getByRole("button", { name: "Click me" })).toBeDefined(); + }); + + it("applies primary variant by default", () => { + render(); + expect(screen.getByRole("button").className).toContain("bg-blue-600"); + }); + + it("applies secondary variant", () => { + render(); + expect(screen.getByRole("button").className).toContain("bg-gray-100"); + }); + + it("applies ghost variant", () => { + render(); + expect(screen.getByRole("button").className).toContain("text-gray-700"); + }); + + it("applies size classes", () => { + render(); + expect(screen.getByRole("button").className).toContain("px-6"); + }); + + it("forwards disabled prop", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); + +describe("Input", () => { + it("renders with label", () => { + render(); + expect(screen.getByLabelText("Email")).toBeDefined(); + }); + + it("generates id from label", () => { + render(); + expect(screen.getByLabelText("First Name").id).toBe("first-name"); + }); + + it("uses provided id over generated one", () => { + render(); + expect(screen.getByLabelText("Email").id).toBe("custom-id"); + }); + + it("shows error message", () => { + render(); + expect(screen.getByText("Required")).toBeDefined(); + }); + + it("applies error border class", () => { + render(); + expect(screen.getByLabelText("Email").className).toContain("border-red-500"); + }); + + it("renders without label", () => { + render(); + expect(screen.getByPlaceholderText("Type here")).toBeDefined(); + }); +}); + +describe("Card", () => { + it("renders children", () => { + render(Card content); + expect(screen.getByText("Card content")).toBeDefined(); + }); + + it("applies additional className", () => { + const { container } = render(Content); + expect(container.firstElementChild?.className).toContain("mt-4"); + }); +}); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 8a07b14..d70f8aa 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -1 +1,8 @@ -export { default } from "../../vitest.shared.ts"; +import { defineConfig, mergeConfig } from "vitest/config"; +import shared from "../../vitest.shared.ts"; + +export default mergeConfig(shared, defineConfig({ + test: { + setupFiles: ["../../vitest.setup.ts"], + }, +})); diff --git a/vitest.shared.ts b/vitest.shared.ts index cc5d8f5..f4b34b1 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - passWithNoTests: true, include: ["src/**/*.test.{ts,tsx}", "app/**/*.test.{ts,tsx}"], }, });