diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md index b3e184e..da35bdb 100644 --- a/openspec/changes/multi-day-routes/tasks.md +++ b/openspec/changes/multi-day-routes/tasks.md @@ -42,9 +42,9 @@ ## 8. Testing -- [ ] 8.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases +- [x] 8.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases - [ ] 8.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map -- [ ] 8.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved +- [x] 8.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved - [ ] 8.4 Unit tests for `dayBreaks` extraction in route update logic - [ ] 8.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats - [ ] 8.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata diff --git a/packages/gpx/src/compute-days.test.ts b/packages/gpx/src/compute-days.test.ts new file mode 100644 index 0000000..0702daf --- /dev/null +++ b/packages/gpx/src/compute-days.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { computeDays } from "./compute-days.ts"; +import type { Waypoint } from "@trails-cool/types"; +import type { TrackPoint } from "./types.ts"; + +function makeTrack(coords: [number, number, number][]): TrackPoint[] { + return coords.map(([lat, lon, ele]) => ({ lat, lon, ele })); +} + +describe("computeDays", () => { + it("returns empty array for no waypoints", () => { + expect(computeDays([], [[{ lat: 0, lon: 0 }]])).toEqual([]); + }); + + it("returns empty array for no tracks", () => { + expect(computeDays([{ lat: 0, lon: 0 }], [])).toEqual([]); + }); + + it("returns single day when no overnight waypoints", () => { + const waypoints: Waypoint[] = [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 51.34, lon: 12.375, name: "Leipzig" }, + ]; + const track = makeTrack([ + [52.52, 13.405, 34], + [52.0, 13.0, 50], + [51.34, 12.375, 113], + ]); + const days = computeDays(waypoints, [track]); + expect(days).toHaveLength(1); + expect(days[0]!.dayNumber).toBe(1); + expect(days[0]!.startName).toBe("Berlin"); + expect(days[0]!.endName).toBe("Leipzig"); + expect(days[0]!.distance).toBeGreaterThan(0); + }); + + it("splits into two days at overnight waypoint", () => { + const waypoints: Waypoint[] = [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true }, + { lat: 50.98, lon: 11.028, name: "Erfurt" }, + ]; + const track = makeTrack([ + [52.52, 13.405, 34], + [52.2, 12.8, 60], + [51.84, 12.243, 80], + [51.4, 11.6, 200], + [50.98, 11.028, 195], + ]); + const days = computeDays(waypoints, [track]); + expect(days).toHaveLength(2); + + expect(days[0]!.dayNumber).toBe(1); + expect(days[0]!.startName).toBe("Berlin"); + expect(days[0]!.endName).toBe("Dessau"); + + expect(days[1]!.dayNumber).toBe(2); + expect(days[1]!.startName).toBe("Dessau"); + expect(days[1]!.endName).toBe("Erfurt"); + + // Total distance should roughly equal sum of days + const totalDist = days[0]!.distance + days[1]!.distance; + expect(totalDist).toBeGreaterThan(0); + }); + + it("splits into three days with two overnight stops", () => { + const waypoints: Waypoint[] = [ + { lat: 0, lon: 0, name: "A" }, + { lat: 1, lon: 0, name: "B", isDayBreak: true }, + { lat: 2, lon: 0, name: "C", isDayBreak: true }, + { lat: 3, lon: 0, name: "D" }, + ]; + const track = makeTrack([ + [0, 0, 0], [0.5, 0, 10], [1, 0, 20], + [1.5, 0, 30], [2, 0, 40], + [2.5, 0, 50], [3, 0, 60], + ]); + const days = computeDays(waypoints, [track]); + expect(days).toHaveLength(3); + expect(days[0]!.startName).toBe("A"); + expect(days[0]!.endName).toBe("B"); + expect(days[1]!.startName).toBe("B"); + expect(days[1]!.endName).toBe("C"); + expect(days[2]!.startName).toBe("C"); + expect(days[2]!.endName).toBe("D"); + }); + + it("computes ascent and descent per day", () => { + const waypoints: Waypoint[] = [ + { lat: 0, lon: 0 }, + { lat: 1, lon: 0, isDayBreak: true }, + { lat: 2, lon: 0 }, + ]; + const track = makeTrack([ + [0, 0, 100], + [0.5, 0, 200], // +100 ascent + [1, 0, 150], // -50 descent + [1.5, 0, 50], // -100 descent + [2, 0, 250], // +200 ascent + ]); + const days = computeDays(waypoints, [track]); + expect(days).toHaveLength(2); + expect(days[0]!.ascent).toBe(100); + expect(days[0]!.descent).toBe(50); + expect(days[1]!.ascent).toBe(200); + expect(days[1]!.descent).toBe(100); + }); + + it("handles overnight on first waypoint gracefully", () => { + const waypoints: Waypoint[] = [ + { lat: 0, lon: 0, isDayBreak: true }, + { lat: 1, lon: 0 }, + ]; + const track = makeTrack([[0, 0, 0], [1, 0, 100]]); + const days = computeDays(waypoints, [track]); + // First waypoint is implicit start of Day 1, isDayBreak on it creates + // a zero-length day followed by the real day + expect(days.length).toBeGreaterThanOrEqual(1); + }); + + it("handles overnight on last waypoint gracefully", () => { + const waypoints: Waypoint[] = [ + { lat: 0, lon: 0 }, + { lat: 1, lon: 0, isDayBreak: true }, + ]; + const track = makeTrack([[0, 0, 0], [1, 0, 100]]); + const days = computeDays(waypoints, [track]); + // Last waypoint is implicit end, isDayBreak on it = single day + expect(days.length).toBeGreaterThanOrEqual(1); + }); + + it("handles single waypoint", () => { + const waypoints: Waypoint[] = [{ lat: 0, lon: 0 }]; + const track = makeTrack([[0, 0, 0]]); + const days = computeDays(waypoints, [track]); + // Single waypoint can't form a meaningful day with distance + expect(days).toHaveLength(0); + }); +}); diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts index 0eefc9f..f92fab1 100644 --- a/packages/gpx/src/generate.test.ts +++ b/packages/gpx/src/generate.test.ts @@ -48,4 +48,59 @@ describe("generateGpx", () => { expect(parsed.waypoints[0]!.name).toBe("Start"); expect(parsed.tracks[0]).toHaveLength(2); }); + + it("emits overnight for isDayBreak waypoints", () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true }, + { lat: 50.98, lon: 11.028, name: "Erfurt" }, + ], + }); + expect(gpx).toContain("overnight"); + // Only Dessau should have the type element + expect(gpx.match(/overnight<\/type>/g)).toHaveLength(1); + }); + + it("round-trips isDayBreak through generate and parse", async () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true }, + { lat: 50.98, lon: 11.028, name: "Erfurt" }, + ], + tracks: [[ + { lat: 52.52, lon: 13.405, ele: 34 }, + { lat: 51.84, lon: 12.243, ele: 80 }, + { lat: 50.98, lon: 11.028, ele: 195 }, + ]], + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.waypoints).toHaveLength(3); + expect(parsed.waypoints[0]!.isDayBreak).toBeUndefined(); + expect(parsed.waypoints[1]!.isDayBreak).toBe(true); + expect(parsed.waypoints[2]!.isDayBreak).toBeUndefined(); + }); + + it("splits tracks by day with splitByDay option", () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 0, lon: 0, name: "A" }, + { lat: 1, lon: 0, name: "B", isDayBreak: true }, + { lat: 2, lon: 0, name: "C" }, + ], + tracks: [[ + { lat: 0, lon: 0, ele: 0 }, + { lat: 0.5, lon: 0, ele: 50 }, + { lat: 1, lon: 0, ele: 100 }, + { lat: 1.5, lon: 0, ele: 150 }, + { lat: 2, lon: 0, ele: 200 }, + ]], + splitByDay: true, + }); + // Should have two elements + expect(gpx.match(//g)).toHaveLength(2); + expect(gpx).toContain("Day 1: A - B"); + expect(gpx).toContain("Day 2: B - C"); + }); });