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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 01:19:27 +02:00
parent 6aa2229b39
commit c23c325965
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 249 additions and 4 deletions

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

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

View file

@ -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)

View file

@ -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",

View file

@ -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",