import { describe, it, expect } from "vitest";
import { generateGpx } from "@trails-cool/gpx";
import { processGpx, validateGpx, GpxValidationError } from "./gpx-save.server.ts";
const VALID_GPX = `
500
520
510
`;
const ONE_POINT_GPX = `
500
`;
const EMPTY_GPX = `
`;
const OUT_OF_RANGE_LAT_GPX = `
`;
const OUT_OF_RANGE_LON_GPX = `
`;
describe("validateGpx", () => {
it("returns parsed GpxData for valid GPX", async () => {
const result = await validateGpx(VALID_GPX);
expect(result.tracks.flat().length).toBe(3);
expect(result.tracks[0]![0]!.lat).toBeCloseTo(47.0);
});
it("throws GpxValidationError for GPX with fewer than 2 track points", async () => {
await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow("at least 2 track points");
});
it("throws GpxValidationError for GPX with zero track points", async () => {
await expect(validateGpx(EMPTY_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(EMPTY_GPX)).rejects.toThrow("at least 2 track points");
});
it("throws GpxValidationError for out-of-range latitude", async () => {
await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow("out-of-range coordinates");
});
it("throws GpxValidationError for out-of-range longitude", async () => {
await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow(GpxValidationError);
await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow("out-of-range coordinates");
});
it("throws GpxValidationError for unparseable XML", async () => {
await expect(validateGpx("not xml at all")).rejects.toThrow(GpxValidationError);
await expect(validateGpx(" {
const err = await validateGpx(EMPTY_GPX).catch((e) => e);
expect(err).toBeInstanceOf(GpxValidationError);
expect(err.name).toBe("GpxValidationError");
});
});
const TIMED_GPX = `
An alpine loop
500
520
`;
describe("processGpx", () => {
it("extracts coords in [lon, lat] PostGIS axis order", async () => {
const { coords } = await processGpx(VALID_GPX);
expect(coords).toEqual([
[8.0, 47.0],
[8.1, 47.1],
[8.2, 47.2],
]);
});
it("derives distance, elevation, description, and start time", async () => {
const { stats } = await processGpx(TIMED_GPX);
expect(stats.distance).toBeGreaterThan(0);
expect(stats.elevationGain).not.toBeNull();
expect(stats.elevationLoss).not.toBeNull();
expect(stats.description).toBe("An alpine loop");
expect(stats.startTime).toEqual(new Date("2026-06-01T08:00:00Z"));
});
it("returns null startTime and empty dayBreaks when absent", async () => {
const { stats } = await processGpx(VALID_GPX);
expect(stats.startTime).toBeNull();
expect(stats.dayBreaks).toEqual([]);
});
it("derives dayBreaks from day-break waypoints (round-trip via generateGpx)", async () => {
const gpx = generateGpx({
name: "Multi-day",
waypoints: [
{ lat: 47.0, lon: 8.0, name: "Start" },
{ lat: 47.1, lon: 8.1, name: "Hut", isDayBreak: true },
{ lat: 47.2, lon: 8.2, name: "End" },
],
tracks: [[
{ lat: 47.0, lon: 8.0 },
{ lat: 47.1, lon: 8.1 },
{ lat: 47.2, lon: 8.2 },
]],
});
const { stats } = await processGpx(gpx);
expect(stats.dayBreaks).toEqual([1]);
});
it("propagates GpxValidationError from validation", async () => {
await expect(processGpx(ONE_POINT_GPX)).rejects.toThrow(GpxValidationError);
});
it("returns the parsed GpxData so callers never re-parse", async () => {
const { parsed } = await processGpx(VALID_GPX);
expect(parsed.tracks.flat()).toHaveLength(3);
});
});