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}"],
},
});