From c23c3259650e41479cd9c0c64513cebda718952a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 01:19:27 +0200 Subject: [PATCH] Add i18n keys and unit tests for overlays - i18n: POI category names, panel UI strings (en + de) - overpass.test.ts: Query building, response parsing, deduplication - poi-cache.test.ts: Tile quantization, cache hit/miss, bbox filtering - poi-categories.test.ts: Profile-to-overlay mapping, category validation Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/overpass.test.ts | 104 ++++++++++++++++++++ apps/planner/app/lib/poi-cache.test.ts | 61 ++++++++++++ apps/planner/app/lib/poi-categories.test.ts | 46 +++++++++ openspec/changes/osm-overlays/tasks.md | 8 +- packages/i18n/src/locales/de.ts | 17 ++++ packages/i18n/src/locales/en.ts | 17 ++++ 6 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 apps/planner/app/lib/overpass.test.ts create mode 100644 apps/planner/app/lib/poi-cache.test.ts create mode 100644 apps/planner/app/lib/poi-categories.test.ts diff --git a/apps/planner/app/lib/overpass.test.ts b/apps/planner/app/lib/overpass.test.ts new file mode 100644 index 0000000..919d9ca --- /dev/null +++ b/apps/planner/app/lib/overpass.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts"; +import { poiCategories } from "./poi-categories.ts"; + +describe("buildQuery", () => { + it("builds Overpass QL with bbox and category union", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 }; + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const query = buildQuery(bbox, categories); + + expect(query).toContain("[out:json]"); + expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]"); + expect(query).toContain('amenity"="drinking_water"'); + expect(query).toContain("out center 200"); + }); + + it("combines multiple categories into a single union", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 }; + const categories = poiCategories.filter((c) => ["drinking_water", "camping"].includes(c.id)); + const query = buildQuery(bbox, categories); + + expect(query).toContain("drinking_water"); + expect(query).toContain("camp_site"); + }); +}); + +describe("parseResponse", () => { + it("parses nodes into Poi objects", () => { + const data = { + elements: [ + { + type: "node", + id: 123, + lat: 52.52, + lon: 13.405, + tags: { amenity: "drinking_water", name: "Brunnen" }, + }, + ], + }; + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const pois = parseResponse(data, categories); + + expect(pois).toHaveLength(1); + expect(pois[0]!.id).toBe(123); + expect(pois[0]!.name).toBe("Brunnen"); + expect(pois[0]!.category).toBe("drinking_water"); + expect(pois[0]!.lat).toBe(52.52); + }); + + it("uses center for way/relation elements", () => { + const data = { + elements: [ + { + type: "way", + id: 456, + center: { lat: 52.51, lon: 13.39 }, + tags: { tourism: "camp_site", name: "Zeltplatz" }, + }, + ], + }; + const categories = poiCategories.filter((c) => c.id === "camping"); + const pois = parseResponse(data, categories); + + expect(pois).toHaveLength(1); + expect(pois[0]!.lat).toBe(52.51); + expect(pois[0]!.lon).toBe(13.39); + }); + + it("skips elements without coordinates", () => { + const data = { + elements: [ + { type: "node", id: 789, tags: { amenity: "drinking_water" } }, + ], + }; + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const pois = parseResponse(data, categories); + expect(pois).toHaveLength(0); + }); + + it("skips elements that don't match any category", () => { + const data = { + elements: [ + { type: "node", id: 100, lat: 52.52, lon: 13.4, tags: { amenity: "bank" } }, + ], + }; + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const pois = parseResponse(data, categories); + expect(pois).toHaveLength(0); + }); +}); + +describe("deduplicateById", () => { + it("removes duplicate POIs by id", () => { + const pois: Poi[] = [ + { id: 1, lat: 52.5, lon: 13.4, category: "drinking_water", tags: {} }, + { id: 1, lat: 52.5, lon: 13.4, category: "camping", tags: {} }, + { id: 2, lat: 52.6, lon: 13.5, category: "shelter", tags: {} }, + ]; + const result = deduplicateById(pois); + expect(result).toHaveLength(2); + expect(result[0]!.id).toBe(1); + expect(result[1]!.id).toBe(2); + }); +}); diff --git a/apps/planner/app/lib/poi-cache.test.ts b/apps/planner/app/lib/poi-cache.test.ts new file mode 100644 index 0000000..1d20c98 --- /dev/null +++ b/apps/planner/app/lib/poi-cache.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { quantize, getCellKeys, getCached, setCached, clearCache } from "./poi-cache.ts"; +import type { Poi } from "./overpass.ts"; + +beforeEach(() => { + clearCache(); +}); + +describe("quantize", () => { + it("rounds down to 0.1 degree grid", () => { + expect(quantize(52.537)).toBe(52.5); + expect(quantize(13.405)).toBe(13.4); + expect(quantize(0.0)).toBe(0); + expect(quantize(-0.15)).toBe(-0.2); + }); +}); + +describe("getCellKeys", () => { + it("returns cell keys covering the bbox", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.4 }; + const keys = getCellKeys(bbox); + expect(keys.length).toBeGreaterThan(0); + expect(keys).toContain("52.5,13.3"); + expect(keys).toContain("52.5,13.4"); + expect(keys).toContain("52.6,13.3"); + }); +}); + +describe("getCached / setCached", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.4 }; + const categoriesKey = "drinking_water"; + const pois: Poi[] = [ + { id: 1, lat: 52.52, lon: 13.35, category: "drinking_water", tags: {} }, + { id: 2, lat: 52.55, lon: 13.38, category: "drinking_water", tags: {} }, + ]; + + it("returns null on cache miss", () => { + expect(getCached(bbox, categoriesKey)).toBeNull(); + }); + + it("returns cached POIs on cache hit", () => { + setCached(bbox, categoriesKey, pois); + const result = getCached(bbox, categoriesKey); + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + }); + + it("filters POIs to requested bbox", () => { + setCached(bbox, categoriesKey, [ + ...pois, + { id: 3, lat: 52.9, lon: 13.35, category: "drinking_water", tags: {} }, // outside bbox + ]); + const result = getCached(bbox, categoriesKey); + expect(result).toHaveLength(2); // only the 2 within bbox + }); + + it("returns null for different categories key", () => { + setCached(bbox, categoriesKey, pois); + expect(getCached(bbox, "camping")).toBeNull(); + }); +}); diff --git a/apps/planner/app/lib/poi-categories.test.ts b/apps/planner/app/lib/poi-categories.test.ts new file mode 100644 index 0000000..8b0c832 --- /dev/null +++ b/apps/planner/app/lib/poi-categories.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { getCategoriesForProfile, profileOverlayDefaults, poiCategories } from "./poi-categories.ts"; + +describe("getCategoriesForProfile", () => { + it("returns bike infra for cycling profiles", () => { + expect(getCategoriesForProfile("fastbike")).toContain("bike_infra"); + expect(getCategoriesForProfile("safety")).toContain("bike_infra"); + }); + + it("returns shelter and viewpoints for hiking", () => { + const cats = getCategoriesForProfile("trekking"); + expect(cats).toContain("shelter"); + expect(cats).toContain("viewpoints"); + }); + + it("returns empty for unknown profiles", () => { + expect(getCategoriesForProfile("car")).toEqual([]); + }); +}); + +describe("profileOverlayDefaults", () => { + it("maps cycling profiles to waymarked-cycling", () => { + expect(profileOverlayDefaults.fastbike).toContain("waymarked-cycling"); + expect(profileOverlayDefaults.safety).toContain("waymarked-cycling"); + }); + + it("maps hiking to waymarked-hiking", () => { + expect(profileOverlayDefaults.trekking).toContain("waymarked-hiking"); + }); +}); + +describe("poiCategories", () => { + it("has 9 categories", () => { + expect(poiCategories).toHaveLength(9); + }); + + it("all categories have required fields", () => { + for (const cat of poiCategories) { + expect(cat.id).toBeTruthy(); + expect(cat.name).toBeTruthy(); + expect(cat.icon).toBeTruthy(); + expect(cat.color).toMatch(/^#/); + expect(cat.query).toContain("nwr"); + } + }); +}); diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md index bdea449..f87736f 100644 --- a/openspec/changes/osm-overlays/tasks.md +++ b/openspec/changes/osm-overlays/tasks.md @@ -53,12 +53,12 @@ ## 9. i18n -- [ ] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de) +- [x] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de) ## 10. Testing -- [ ] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication -- [ ] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss -- [ ] 10.3 Unit tests for profile-to-overlay mapping +- [x] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication +- [x] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss +- [x] 10.3 Unit tests for profile-to-overlay mapping - [ ] 10.4 E2E test: enable hillshading overlay, verify tile requests - [ ] 10.5 E2E test: enable POI category, verify markers appear (mock Overpass response) diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 1a0527e..6b23c6d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -69,6 +69,23 @@ export default { ascent: "Anstieg", descent: "Abstieg", }, + poi: { + toggle: "Sehenswürdigkeiten", + title: "Sehenswürdigkeiten", + loading: "Laden...", + zoomIn: "Näher heranzoomen für POIs", + rateLimited: "POI-Daten vorübergehend nicht verfügbar", + error: "POIs konnten nicht geladen werden", + drinkingWater: "Trinkwasser", + shelter: "Unterstand", + camping: "Camping", + food: "Essen & Trinken", + groceries: "Lebensmittel", + bikeInfra: "Fahrrad-Infrastruktur", + accommodation: "Unterkunft", + viewpoints: "Aussichtspunkte", + toilets: "Toiletten", + }, noGoAreas: { draw: "Sperrgebiet zeichnen", cancel: "Sperrgebiet abbrechen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index da27412..caca9b4 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -69,6 +69,23 @@ export default { ascent: "Ascent", descent: "Descent", }, + poi: { + toggle: "Points of Interest", + title: "Points of Interest", + loading: "Loading...", + zoomIn: "Zoom in to see POIs", + rateLimited: "POI data temporarily unavailable", + error: "Failed to load POIs", + drinkingWater: "Drinking water", + shelter: "Shelter", + camping: "Camping", + food: "Food & drink", + groceries: "Groceries", + bikeInfra: "Bike infrastructure", + accommodation: "Accommodation", + viewpoints: "Viewpoints", + toilets: "Toilets", + }, noGoAreas: { draw: "Draw no-go area", cancel: "Cancel no-go area",