trails/packages/gpx/src/timestamp-repair.test.ts
Ullrich Schäfer 4536470eb9
feat(gpx): post-parse timestamp repair
Task group 3 of gpx-parser-robustness. Timestamps were passed through raw;
a partially-broken time channel from a flaky recorder silently shrank
moving time and skewed start-time derivation.

New pure `timestamp-repair.ts` applied per segment after parsing:
- validity = Date.parse yields a finite epoch;
- a segment with no valid timestamps is left untouched (untimed track);
- >50% invalid → drop all the segment's timestamps (noise, not signal);
- otherwise → linearly interpolate invalid runs between valid neighbours
  (by point index), leading/trailing runs clamp to the nearest valid
  timestamp; only repaired points are rewritten as ISO 8601, valid points
  keep their original string.

Monotonicity intentionally not enforced (movingTime already skips
non-positive intervals; reordering would be fabrication). No GpxData shape
change. Wired into parseTracks output in parse.ts.

Verified: gpx typecheck + lint clean, 81/81 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:10:57 +02:00

56 lines
2.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { repairSegmentTimestamps } from "./timestamp-repair.ts";
import { movingTime } from "./moving-time.ts";
import type { TrackPoint } from "./types.ts";
const pt = (time?: string, lat = 52.5, lon = 13.4): TrackPoint => ({ lat, lon, time });
describe("repairSegmentTimestamps", () => {
it("interpolates a sparse invalid timestamp between valid neighbours", () => {
const out = repairSegmentTimestamps([
pt("2026-01-01T10:00:00Z"),
pt("garbage"),
pt("2026-01-01T10:02:00Z"),
pt("2026-01-01T10:03:00Z"),
]);
// index 1 is midway (by index) between valid index 0 and index 2.
expect(out[1]!.time).toBe("2026-01-01T10:01:00.000Z");
// Valid points keep their original strings untouched.
expect(out[0]!.time).toBe("2026-01-01T10:00:00Z");
expect(out[3]!.time).toBe("2026-01-01T10:03:00Z");
});
it("drops all timestamps when more than half the segment is invalid", () => {
const out = repairSegmentTimestamps([
pt("2026-01-01T10:00:00Z"),
pt("garbage"),
pt(undefined),
pt("nope"),
]);
expect(out.every((p) => p.time === undefined)).toBe(true);
// A dropped time channel means the segment reads as untimed.
expect(movingTime([out])).toBeNull();
});
it("clamps leading and trailing invalid runs to the nearest valid timestamp", () => {
const out = repairSegmentTimestamps([
pt("garbage"),
pt("2026-01-01T10:01:00Z"),
pt("2026-01-01T10:02:00Z"),
pt(undefined),
]);
expect(out[0]!.time).toBe("2026-01-01T10:01:00.000Z"); // leading clamp
expect(out[3]!.time).toBe("2026-01-01T10:02:00.000Z"); // trailing clamp
});
it("leaves an all-valid segment untouched", () => {
const input = [pt("2026-01-01T10:00:00Z"), pt("2026-01-01T10:01:00Z")];
const out = repairSegmentTimestamps(input);
expect(out.map((p) => p.time)).toEqual(["2026-01-01T10:00:00Z", "2026-01-01T10:01:00Z"]);
});
it("leaves an untimed segment (no timestamps) untouched", () => {
const out = repairSegmentTimestamps([pt(undefined), pt(undefined), pt(undefined)]);
expect(out.every((p) => p.time === undefined)).toBe(true);
});
});