mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c1d55c463c
The Atlas country check timed out. It asked Dawarich for a year of visited cities in one request, and Dawarich computes that over every point in the window with nothing cached, so a dense recorder never got under the 15 s the server allows. The year is now asked for a month at a time, one request after another, and folded back together. The windows sit a second apart because Dawarich reads the bounds as inclusive. The recorded route only appeared after a reload. Every Dawarich call from the browser carried the shared 8 s timeout, so the browser gave up while the server carried on and cached the answer for the next page load. Calls that make the server talk to Dawarich now wait two minutes, the Atlas four. On the GL renderers the draw also gave up whenever isStyleLoaded() was false, which it is while any tile is still loading, and only retried on style.load. It now waits for idle. The hazard overlay had the same guard and gets the same wait. The pill shows a ring while the route loads, so a slow first fetch no longer looks like "done, nothing there". A collapsed day now takes its recorded route off the map, following the same folds the planner already applies to places, the road trip sidebar's included.
1014 lines
45 KiB
TypeScript
1014 lines
45 KiB
TypeScript
/**
|
|
* The pure half of the Dawarich integration — DAWARICH-TIME-001 through
|
|
* DAWARICH-CITIES-006. Everything under test is a free function over plain
|
|
* fixtures: no DB, no container, and no client. `dawarich.client` appears here
|
|
* only as a type-only import (erased at compile time), so nothing in this file
|
|
* can reach the network even by accident — there is no DawarichClient instance
|
|
* to mock because the helpers never construct one.
|
|
*
|
|
* Every timestamp is written with an explicit offset (`Z` or `+HH:MM`) so the
|
|
* cases mean the same thing on a CI runner in UTC and on a laptop in Berlin.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
import {
|
|
bucketPointsByDay,
|
|
bucketTracksByDay,
|
|
capPoints,
|
|
countPoints,
|
|
distanceMeters,
|
|
geoJsonToLatLng,
|
|
localDateOf,
|
|
mergeVisitedCountries,
|
|
minutesBetween,
|
|
normalizeVisit,
|
|
offsetMinutesOf,
|
|
simplify,
|
|
splitWindow,
|
|
syncWindow,
|
|
toNumber,
|
|
visitHash,
|
|
} from '../../../src/nest/integrations/dawarich.helpers';
|
|
import type {
|
|
DawarichSlimPoint,
|
|
DawarichTrackFeature,
|
|
DawarichVisitRaw,
|
|
} from '../../../src/nest/integrations/dawarich.client';
|
|
import type { DawarichTrackDay } from '@trek/shared';
|
|
|
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
const BASE_VISIT: DawarichVisitRaw = {
|
|
id: 42,
|
|
area_id: null,
|
|
started_at: '2024-06-15T18:30:00+02:00',
|
|
ended_at: '2024-06-15T20:00:00+02:00',
|
|
duration: 5400,
|
|
name: ' Cafe Kranzler ',
|
|
status: 'confirmed',
|
|
confidence: '0.87',
|
|
confidence_band: 'high',
|
|
place: { latitude: '52.5', longitude: '13.4', id: 7, country_code: 'de' },
|
|
};
|
|
|
|
const visit = (over: Partial<DawarichVisitRaw> = {}): DawarichVisitRaw => ({ ...BASE_VISIT, ...over });
|
|
|
|
/** A fixture whose shape is deliberately wrong — the payload is a stranger's JSON. */
|
|
const malformedVisit = (over: Record<string, unknown>): DawarichVisitRaw =>
|
|
({ ...BASE_VISIT, ...over }) as unknown as DawarichVisitRaw;
|
|
|
|
const point = (iso: string, lat: number | string, lng: number | string): DawarichSlimPoint => ({
|
|
latitude: lat,
|
|
longitude: lng,
|
|
timestamp: Date.parse(iso) / 1000,
|
|
});
|
|
|
|
const track = (
|
|
startAt: string | undefined,
|
|
endAt: string | undefined,
|
|
coordinates: unknown,
|
|
props: { distance?: number | null; dominant_mode?: string | null } = {},
|
|
): DawarichTrackFeature => ({
|
|
geometry: { coordinates },
|
|
properties: { start_at: startAt, end_at: endAt, distance: null, dominant_mode: null, ...props },
|
|
});
|
|
|
|
// ── localDateOf / offsetMinutesOf ────────────────────────────────────────────
|
|
|
|
describe('localDateOf / offsetMinutesOf', () => {
|
|
it('DAWARICH-TIME-001: reads the local date off the string rather than converting it', () => {
|
|
// 23:30 in Tokyo is still the 15th there and the 15th in Dawarich, even
|
|
// though the same instant is the 15th 14:30 UTC.
|
|
expect(localDateOf('2024-06-15T23:30:00+09:00')).toBe('2024-06-15');
|
|
});
|
|
|
|
it('DAWARICH-TIME-002: takes a UTC instant as given — there is no local meaning to recover', () => {
|
|
expect(localDateOf('2024-06-15T23:30:00Z')).toBe('2024-06-15');
|
|
});
|
|
|
|
it('DAWARICH-TIME-003: reads a positive offset as minutes', () => {
|
|
expect(offsetMinutesOf('2024-06-15T10:00:00+02:00')).toBe(120);
|
|
});
|
|
|
|
it('DAWARICH-TIME-004: reads a negative half-hour offset as minutes', () => {
|
|
expect(offsetMinutesOf('2024-06-15T10:00:00-05:30')).toBe(-330);
|
|
});
|
|
|
|
it('DAWARICH-TIME-005: accepts the colon-less form', () => {
|
|
expect(offsetMinutesOf('2024-06-15T10:00:00+0200')).toBe(120);
|
|
});
|
|
|
|
it('DAWARICH-TIME-006: Z, a bare date and junk are all zero', () => {
|
|
expect(offsetMinutesOf('2024-06-15T10:00:00Z')).toBe(0);
|
|
// A bare date ends in `-15`, which must not be mistaken for an offset.
|
|
expect(offsetMinutesOf('2024-06-15')).toBe(0);
|
|
expect(offsetMinutesOf('not a timestamp')).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── minutesBetween ───────────────────────────────────────────────────────────
|
|
|
|
describe('minutesBetween', () => {
|
|
it('DAWARICH-TIME-007: whole minutes across a span', () => {
|
|
expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T11:30:00Z')).toBe(90);
|
|
});
|
|
|
|
it('DAWARICH-TIME-008: rounds to the nearest minute in both directions', () => {
|
|
expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:29Z')).toBe(0);
|
|
expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:31Z')).toBe(1);
|
|
});
|
|
|
|
it('DAWARICH-TIME-009: compares instants, not wall clocks', () => {
|
|
// 10:00+02:00 is 08:00Z; 11:00+01:00 is 10:00Z — two hours apart.
|
|
expect(minutesBetween('2024-06-15T10:00:00+02:00', '2024-06-15T11:00:00+01:00')).toBe(120);
|
|
});
|
|
|
|
it('DAWARICH-TIME-010: a backwards or zero-length span is zero, never negative', () => {
|
|
expect(minutesBetween('2024-06-15T11:00:00Z', '2024-06-15T10:00:00Z')).toBe(0);
|
|
expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:00Z')).toBe(0);
|
|
});
|
|
|
|
it('DAWARICH-TIME-011: an unparsable timestamp is zero rather than NaN', () => {
|
|
expect(minutesBetween('2024-06-15T10:00:00Z', 'nope')).toBe(0);
|
|
expect(minutesBetween('nope', '2024-06-15T10:00:00Z')).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── syncWindow ───────────────────────────────────────────────────────────────
|
|
|
|
describe('syncWindow', () => {
|
|
it('DAWARICH-WINDOW-001: widens backwards by the lookback and forwards by the lookahead', () => {
|
|
const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 2, 3);
|
|
expect(window?.from.toISOString()).toBe('2024-06-13T00:00:00.000Z');
|
|
expect(window?.to.toISOString()).toBe('2024-06-20T23:59:59.000Z');
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-002: a zero lookback/lookahead still spans the trip itself', () => {
|
|
const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 0, 0);
|
|
expect(window?.from.toISOString()).toBe('2024-06-15T00:00:00.000Z');
|
|
expect(window?.to.toISOString()).toBe('2024-06-17T23:59:59.000Z');
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-003: never asks past now — the lookahead is clamped', () => {
|
|
const now = new Date('2024-06-16T08:00:00Z');
|
|
const window = syncWindow('2024-06-15', '2024-06-17', now, 1, 5);
|
|
expect(window?.to.toISOString()).toBe(now.toISOString());
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-004: an open-ended trip gets the lookback and stops at now', () => {
|
|
const now = new Date('2024-06-20T12:00:00Z');
|
|
const window = syncWindow('2024-06-15', null, now, 1, 7);
|
|
expect(window?.from.toISOString()).toBe('2024-06-14T00:00:00.000Z');
|
|
expect(window?.to.toISOString()).toBe(now.toISOString());
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-005: an unparsable end date is treated as open-ended, not as a failure', () => {
|
|
const now = new Date('2024-06-20T12:00:00Z');
|
|
const window = syncWindow('2024-06-15', 'whenever', now, 1, 7);
|
|
expect(window?.to.toISOString()).toBe(now.toISOString());
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-006: a missing start date yields no window at all', () => {
|
|
expect(syncWindow(null, '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-007: an unparsable or empty start date yields no window at all', () => {
|
|
expect(syncWindow('gibberish', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
|
|
expect(syncWindow('', null, new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-WINDOW-008: a trip that has not started yet collapses to nothing', () => {
|
|
// `now` is before the (already widened) start, so there is no recorded
|
|
// stretch to ask about: to <= from.
|
|
expect(syncWindow('2024-06-15', null, new Date('2024-06-10T00:00:00Z'), 1, 1)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── visitHash ────────────────────────────────────────────────────────────────
|
|
|
|
describe('visitHash', () => {
|
|
it('DAWARICH-HASH-001: is a sha256 hex digest', () => {
|
|
expect(visitHash(BASE_VISIT)).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('DAWARICH-HASH-002: is stable for the same input — a re-run that changed nothing stays quiet', () => {
|
|
expect(visitHash(visit())).toBe(visitHash(visit()));
|
|
});
|
|
|
|
it('DAWARICH-HASH-003: changes when the visit is renamed', () => {
|
|
expect(visitHash(visit({ name: 'Cafe Einstein' }))).not.toBe(visitHash(BASE_VISIT));
|
|
});
|
|
|
|
it('DAWARICH-HASH-004: changes when either timestamp moves', () => {
|
|
expect(visitHash(visit({ started_at: '2024-06-15T18:45:00+02:00' }))).not.toBe(visitHash(BASE_VISIT));
|
|
expect(visitHash(visit({ ended_at: '2024-06-15T21:00:00+02:00' }))).not.toBe(visitHash(BASE_VISIT));
|
|
});
|
|
|
|
it('DAWARICH-HASH-005: changes when the place moves or is re-identified', () => {
|
|
const moved = visit({ place: { ...BASE_VISIT.place!, latitude: '52.6' } });
|
|
const shifted = visit({ place: { ...BASE_VISIT.place!, longitude: '13.5' } });
|
|
const reidentified = visit({ place: { ...BASE_VISIT.place!, id: 8 } });
|
|
expect(visitHash(moved)).not.toBe(visitHash(BASE_VISIT));
|
|
expect(visitHash(shifted)).not.toBe(visitHash(BASE_VISIT));
|
|
expect(visitHash(reidentified)).not.toBe(visitHash(BASE_VISIT));
|
|
});
|
|
|
|
it('DAWARICH-HASH-006: changes when the status changes', () => {
|
|
expect(visitHash(visit({ status: 'suggested' }))).not.toBe(visitHash(BASE_VISIT));
|
|
});
|
|
|
|
it('DAWARICH-HASH-007: ignores fields TREK does not show — a re-scored confidence is not a change', () => {
|
|
expect(visitHash(visit({ confidence: '0.11', confidence_band: 'low' }))).toBe(visitHash(BASE_VISIT));
|
|
expect(visitHash(visit({ area_id: 99, user_id: 3 }))).toBe(visitHash(BASE_VISIT));
|
|
});
|
|
|
|
it('DAWARICH-HASH-008: a missing name and an empty name are the same visit', () => {
|
|
expect(visitHash(visit({ name: null }))).toBe(visitHash(visit({ name: '' })));
|
|
});
|
|
|
|
it('DAWARICH-HASH-009: a missing place does not throw', () => {
|
|
expect(visitHash(visit({ place: null }))).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('DAWARICH-HASH-010: a missing status and an empty status are the same visit', () => {
|
|
// `status` is nullable in the payload, so it is folded to the same blank a
|
|
// visit with an empty one gets. Without that, a null would hash as the
|
|
// literal "null" and a provider filling the field in with "" later would
|
|
// look like every unscored visit changing at once.
|
|
expect(visitHash(visit({ status: null }))).toBe(visitHash(visit({ status: '' })));
|
|
expect(visitHash(visit({ status: null }))).not.toBe(visitHash(BASE_VISIT));
|
|
});
|
|
});
|
|
|
|
// ── toNumber ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('toNumber', () => {
|
|
it('DAWARICH-NUM-001: passes finite numbers through and rejects the rest', () => {
|
|
expect(toNumber(52.5)).toBe(52.5);
|
|
expect(toNumber(0)).toBe(0);
|
|
expect(toNumber(Number.NaN)).toBeNull();
|
|
expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-NUM-002: parses numeric strings, rejecting blank and non-numeric ones', () => {
|
|
expect(toNumber('13.4')).toBe(13.4);
|
|
expect(toNumber(' ')).toBeNull();
|
|
expect(toNumber('north')).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-NUM-003: null, undefined and objects are not numbers', () => {
|
|
expect(toNumber(null)).toBeNull();
|
|
expect(toNumber(undefined)).toBeNull();
|
|
expect(toNumber({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── normalizeVisit ───────────────────────────────────────────────────────────
|
|
|
|
describe('normalizeVisit', () => {
|
|
it('DAWARICH-VISIT-001: reduces a full visit to what TREK stores', () => {
|
|
expect(normalizeVisit(BASE_VISIT)).toEqual({
|
|
sourceVisitId: '42',
|
|
name: 'Cafe Kranzler',
|
|
lat: 52.5,
|
|
lng: 13.4,
|
|
startedAt: '2024-06-15T18:30:00+02:00',
|
|
endedAt: '2024-06-15T20:00:00+02:00',
|
|
durationMinutes: 90,
|
|
localDate: '2024-06-15',
|
|
status: 'confirmed',
|
|
confidence: 0.87,
|
|
confidenceBand: 'high',
|
|
countryCodeFromSource: 'DE',
|
|
});
|
|
});
|
|
|
|
it('DAWARICH-VISIT-002: an empty visit keeps every optional field null instead of guessing', () => {
|
|
const sparse = visit({
|
|
id: '9',
|
|
started_at: '2024-06-15T18:30:00Z',
|
|
ended_at: '2024-06-15T19:15:00Z',
|
|
duration: null,
|
|
name: null,
|
|
status: null,
|
|
confidence: null,
|
|
confidence_band: null,
|
|
place: null,
|
|
});
|
|
expect(normalizeVisit(sparse)).toEqual({
|
|
sourceVisitId: '9',
|
|
name: 'Unnamed stay',
|
|
lat: null,
|
|
lng: null,
|
|
startedAt: '2024-06-15T18:30:00Z',
|
|
endedAt: '2024-06-15T19:15:00Z',
|
|
durationMinutes: 45,
|
|
localDate: '2024-06-15',
|
|
status: 'suggested',
|
|
confidence: null,
|
|
confidenceBand: null,
|
|
countryCodeFromSource: null,
|
|
});
|
|
});
|
|
|
|
it('DAWARICH-VISIT-003: a whitespace-only name falls back to the placeholder', () => {
|
|
expect(normalizeVisit(visit({ name: ' ' }))?.name).toBe('Unnamed stay');
|
|
});
|
|
|
|
it('DAWARICH-VISIT-004: a place without coordinates yields null lat/lng, not zeros', () => {
|
|
const result = normalizeVisit(visit({ place: { latitude: null, longitude: null, id: null } }));
|
|
expect(result?.lat).toBeNull();
|
|
expect(result?.lng).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-VISIT-005: "confirmed" is the only status taken at face value', () => {
|
|
expect(normalizeVisit(visit({ status: 'confirmed' }))?.status).toBe('confirmed');
|
|
expect(normalizeVisit(visit({ status: 'suggested' }))?.status).toBe('suggested');
|
|
});
|
|
|
|
it('DAWARICH-VISIT-006: an unknown status falls to "suggested" — the side that asks, not the side that acts', () => {
|
|
// "declined" stopped existing in Dawarich 1.12.0; a future value must not
|
|
// silently become a confirmation.
|
|
expect(normalizeVisit(visit({ status: 'declined' }))?.status).toBe('suggested');
|
|
expect(normalizeVisit(visit({ status: 'something_new' }))?.status).toBe('suggested');
|
|
expect(normalizeVisit(visit({ status: null }))?.status).toBe('suggested');
|
|
});
|
|
|
|
it('DAWARICH-VISIT-007: the span between the timestamps is the length, whatever duration says', () => {
|
|
// 18:30 to 20:00. The reported figure is ignored while there are two usable
|
|
// timestamps, because that is the pair shown beside it in the panel. It used
|
|
// to win and to be read as seconds, which divided Dawarich's minutes by sixty
|
|
// a second time and turned an eighteen-minute stop into "0 min".
|
|
expect(normalizeVisit(visit({ duration: 5400 }))?.durationMinutes).toBe(90);
|
|
expect(normalizeVisit(visit({ duration: '120' }))?.durationMinutes).toBe(90);
|
|
expect(normalizeVisit(visit({ duration: 18 }))?.durationMinutes).toBe(90);
|
|
});
|
|
|
|
it('DAWARICH-VISIT-008: a missing or zero duration is recomputed from the two timestamps', () => {
|
|
// 18:30 → 20:00 is 90 minutes whichever way it is derived.
|
|
expect(normalizeVisit(visit({ duration: null }))?.durationMinutes).toBe(90);
|
|
expect(normalizeVisit(visit({ duration: 0 }))?.durationMinutes).toBe(90);
|
|
expect(normalizeVisit(visit({ duration: -60 }))?.durationMinutes).toBe(90);
|
|
expect(normalizeVisit(visit({ duration: 'unknown' }))?.durationMinutes).toBe(90);
|
|
});
|
|
|
|
it('DAWARICH-VISIT-009: a two-letter country code from the source is upper-cased', () => {
|
|
expect(normalizeVisit(visit())?.countryCodeFromSource).toBe('DE');
|
|
const upper = visit({ place: { ...BASE_VISIT.place!, country_code: 'FR' } });
|
|
expect(normalizeVisit(upper)?.countryCodeFromSource).toBe('FR');
|
|
});
|
|
|
|
it('DAWARICH-VISIT-010: anything that is not a two-letter code is dropped', () => {
|
|
const three = visit({ place: { ...BASE_VISIT.place!, country_code: 'deu' } });
|
|
const empty = visit({ place: { ...BASE_VISIT.place!, country_code: '' } });
|
|
const absent = visit({ place: { latitude: '52.5', longitude: '13.4', id: 7 } });
|
|
expect(normalizeVisit(three)?.countryCodeFromSource).toBeNull();
|
|
expect(normalizeVisit(empty)?.countryCodeFromSource).toBeNull();
|
|
expect(normalizeVisit(absent)?.countryCodeFromSource).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-VISIT-011: the local date comes from the source offset, not from the host clock', () => {
|
|
const tokyoLateNight = visit({
|
|
started_at: '2024-06-15T23:30:00+09:00',
|
|
ended_at: '2024-06-16T00:30:00+09:00',
|
|
});
|
|
expect(normalizeVisit(tokyoLateNight)?.localDate).toBe('2024-06-15');
|
|
});
|
|
|
|
it('DAWARICH-VISIT-012: a visit without an id is unusable', () => {
|
|
expect(normalizeVisit(malformedVisit({ id: null }))).toBeNull();
|
|
expect(normalizeVisit(malformedVisit({ id: undefined }))).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-VISIT-013: non-string timestamps are unusable', () => {
|
|
expect(normalizeVisit(malformedVisit({ started_at: 1_718_476_200 }))).toBeNull();
|
|
expect(normalizeVisit(malformedVisit({ ended_at: null }))).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-VISIT-014: an unparsable start timestamp is unusable', () => {
|
|
expect(normalizeVisit(visit({ started_at: 'last Tuesday' }))).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-VISIT-015: an unreadable end collapses onto the arrival rather than staying unreadable', () => {
|
|
// The guard checks `started_at` alone, so an unreadable end does not drop
|
|
// the visit. It is repaired instead of passed through: kept as-is it would
|
|
// render as "Invalid Date" beside a perfectly good arrival time.
|
|
const result = normalizeVisit(visit({ duration: null, ended_at: 'sometime' }));
|
|
expect(result?.endedAt).toBe(new Date(Date.parse(result!.startedAt)).toISOString());
|
|
expect(result?.durationMinutes).toBe(0);
|
|
});
|
|
|
|
it('DAWARICH-VISIT-016: an unreadable end falls back to the reported duration', () => {
|
|
// With a duration to go on, the end is the arrival plus that span — which is
|
|
// what the stay actually was, and what the two timestamps then agree on.
|
|
// Minutes: Dawarich writes `duration_minutes = ((ended_at - started_at) / 60)`
|
|
// into this column (app/services/visits/create.rb).
|
|
const result = normalizeVisit(visit({ duration: 30, ended_at: 'sometime' }));
|
|
expect(Date.parse(result!.endedAt) - Date.parse(result!.startedAt)).toBe(1_800_000);
|
|
expect(result?.durationMinutes).toBe(30);
|
|
});
|
|
|
|
it('DAWARICH-VISIT-017: an unreadable end plus a duration of zero or less collapses onto the arrival', () => {
|
|
// A reported duration is only usable as a repair while it is positive: zero
|
|
// is "the detector had nothing", and a negative one is the same accident
|
|
// that produced the unreadable end. Either way the end moves to the
|
|
// arrival, which is the one timestamp that was actually readable. It never
|
|
// moves backwards from it.
|
|
for (const duration of [0, -45, '0'] as const) {
|
|
const result = normalizeVisit(visit({ duration, ended_at: 'sometime' }));
|
|
expect(result?.endedAt).toBe(new Date(Date.parse(result!.startedAt)).toISOString());
|
|
expect(result?.durationMinutes).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── distanceMeters ───────────────────────────────────────────────────────────
|
|
|
|
describe('distanceMeters', () => {
|
|
it('DAWARICH-GEO-001: a point is zero metres from itself', () => {
|
|
expect(distanceMeters(52.52, 13.405, 52.52, 13.405)).toBe(0);
|
|
});
|
|
|
|
it('DAWARICH-GEO-002: a small north-south step measures in metres', () => {
|
|
// 0.0009° of latitude is almost exactly 100 m anywhere on earth.
|
|
expect(distanceMeters(52.52, 13.405, 52.5209, 13.405)).toBeCloseTo(100, 0);
|
|
});
|
|
|
|
it('DAWARICH-GEO-003: a degree of longitude is ~111 km at the equator', () => {
|
|
expect(distanceMeters(0, 0, 0, 1)).toBeCloseTo(111_195, -2);
|
|
});
|
|
|
|
it('DAWARICH-GEO-004: a degree of longitude shrinks with the cosine of the latitude', () => {
|
|
// At 60° north a degree of longitude is half what it is at the equator.
|
|
expect(distanceMeters(60, 0, 60, 1)).toBeCloseTo(111_195 / 2, -2);
|
|
});
|
|
|
|
it('DAWARICH-GEO-005: distance is symmetric', () => {
|
|
const there = distanceMeters(48.85, 2.35, 52.52, 13.405);
|
|
const back = distanceMeters(52.52, 13.405, 48.85, 2.35);
|
|
expect(there).toBeCloseTo(back, 6);
|
|
});
|
|
});
|
|
|
|
// ── simplify ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('simplify', () => {
|
|
it('DAWARICH-GEO-006: a straight line collapses to its two ends', () => {
|
|
expect(simplify([[0, 0], [0, 0.5], [0, 1]], 0.00005)).toEqual([[0, 0], [0, 1]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-007: a long straight run collapses no matter how many points it has', () => {
|
|
const straight: Array<[number, number]> = Array.from({ length: 500 }, (_, i) => [0, i / 500]);
|
|
expect(simplify(straight, 0.00005)).toEqual([[0, 0], [0, 499 / 500]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-008: a corner survives', () => {
|
|
expect(simplify([[0, 0], [0, 1], [1, 1]], 0.00005)).toEqual([[0, 0], [0, 1], [1, 1]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-009: a deviation under the tolerance is dropped, one over it is kept', () => {
|
|
const jitter: Array<[number, number]> = [[0, 0], [0.00001, 0.5], [0, 1]];
|
|
const corner: Array<[number, number]> = [[0, 0], [0.001, 0.5], [0, 1]];
|
|
expect(simplify(jitter, 0.00005)).toHaveLength(2);
|
|
expect(simplify(corner, 0.00005)).toHaveLength(3);
|
|
});
|
|
|
|
it('DAWARICH-GEO-010: two points or fewer are returned untouched', () => {
|
|
const pair: Array<[number, number]> = [[0, 0], [1, 1]];
|
|
expect(simplify(pair, 0.00005)).toBe(pair);
|
|
expect(simplify([], 0.00005)).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-024: a loop back to the start is measured from the point, not from a zero-length baseline', () => {
|
|
// A day that ends where it began gives the algorithm a segment whose two
|
|
// ends are the same coordinate. The gradient it normally projects onto does
|
|
// not exist there, so the distance has to fall back to "how far is this
|
|
// point from that one". Without it the division is 0/0 and every detour on
|
|
// a round trip would simplify away as NaN.
|
|
const loop: Array<[number, number]> = [[0, 0], [0, 1], [0, 0]];
|
|
expect(simplify(loop, 0.00005)).toEqual(loop);
|
|
// And the same shape, a metre wide instead of a degree, still collapses.
|
|
expect(simplify([[0, 0], [0, 0.00001], [0, 0]], 0.00005)).toEqual([[0, 0], [0, 0]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-025: an unreadable latitude cannot collapse the longitude scale', () => {
|
|
// The metric scales longitude by the cosine of the latitude, and guards
|
|
// that scale so it is never zero. A latitude that is not a real number
|
|
// gives no cosine at all, and the fallback keeps the scale at 1 rather
|
|
// than multiplying every longitude by nothing. The bad point measures as
|
|
// no deviation and is dropped; the ends of the line still come back.
|
|
expect(simplify([[0, 0], [NaN, 0.5], [0, 1]], 0.00005)).toEqual([[0, 0], [0, 1]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-011: the first and last point are always kept', () => {
|
|
const zigzag: Array<[number, number]> = [[0, 0], [1, 0.25], [0, 0.5], [1, 0.75], [0, 1]];
|
|
const out = simplify(zigzag, 0.00005);
|
|
expect(out[0]).toEqual([0, 0]);
|
|
expect(out[out.length - 1]).toEqual([0, 1]);
|
|
});
|
|
});
|
|
|
|
// ── capPoints ────────────────────────────────────────────────────────────────
|
|
|
|
describe('capPoints', () => {
|
|
it('DAWARICH-GEO-012: a line already under the cap is untouched', () => {
|
|
const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2]];
|
|
expect(capPoints(line, 5)).toBe(line);
|
|
expect(capPoints(line, 3)).toBe(line);
|
|
});
|
|
|
|
it('DAWARICH-GEO-013: thins to the cap on an even stride', () => {
|
|
const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]];
|
|
expect(capPoints(line, 3)).toEqual([[0, 0], [2, 2], [4, 4]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-014: the line still starts and ends where the day did', () => {
|
|
const line: Array<[number, number]> = Array.from({ length: 1000 }, (_, i) => [i / 1000, 0]);
|
|
const out = capPoints(line, 100);
|
|
expect(out).toHaveLength(100);
|
|
expect(out[0]).toEqual(line[0]);
|
|
expect(out[out.length - 1]).toEqual(line[line.length - 1]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-015: a cap below two is refused rather than producing a degenerate line', () => {
|
|
const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]];
|
|
expect(capPoints(line, 1)).toBe(line);
|
|
expect(capPoints(line, 0)).toBe(line);
|
|
});
|
|
});
|
|
|
|
// ── geoJsonToLatLng ──────────────────────────────────────────────────────────
|
|
|
|
describe('geoJsonToLatLng', () => {
|
|
it('DAWARICH-GEO-016: swaps GeoJSON [lng, lat] into TREK [lat, lng]', () => {
|
|
expect(geoJsonToLatLng([[13.4, 52.5]])).toEqual([[52.5, 13.4]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-017: parses numeric strings as coordinates', () => {
|
|
expect(geoJsonToLatLng([['13.4', '52.5']])).toEqual([[52.5, 13.4]]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-018: skips pairs that are not pairs', () => {
|
|
expect(geoJsonToLatLng([[13.4], 'nope', null, {}, []])).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-019: skips pairs that are not numbers', () => {
|
|
expect(geoJsonToLatLng([['east', 'north'], [null, 52.5], [13.4, undefined]])).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-020: keeps the exact range limits and drops anything past them', () => {
|
|
expect(geoJsonToLatLng([[180, 90], [-180, -90]])).toEqual([[90, 180], [-90, -180]]);
|
|
expect(geoJsonToLatLng([[180.1, 10], [-180.1, 10], [10, 90.1], [10, -90.1]])).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-021: keeps the usable pairs out of a mixed list', () => {
|
|
expect(geoJsonToLatLng([[13.4, 52.5], 'nope', [999, 999], [2.35, 48.85]])).toEqual([
|
|
[52.5, 13.4],
|
|
[48.85, 2.35],
|
|
]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-022: a non-array is an empty line, not a throw', () => {
|
|
expect(geoJsonToLatLng('nope')).toEqual([]);
|
|
expect(geoJsonToLatLng(null)).toEqual([]);
|
|
expect(geoJsonToLatLng(undefined)).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-GEO-023: ignores anything past the first two entries of a pair', () => {
|
|
// GeoJSON positions may carry an elevation; TREK draws in two dimensions.
|
|
expect(geoJsonToLatLng([[13.4, 52.5, 34]])).toEqual([[52.5, 13.4]]);
|
|
});
|
|
});
|
|
|
|
// ── bucketPointsByDay ────────────────────────────────────────────────────────
|
|
|
|
describe('bucketPointsByDay', () => {
|
|
const acrossMidnight = (): DawarichSlimPoint[] => [
|
|
point('2024-06-15T10:00:00Z', 10, 10),
|
|
point('2024-06-15T23:59:59Z', 10, 11),
|
|
point('2024-06-16T00:00:00Z', 11, 11),
|
|
point('2024-06-16T10:00:00Z', 11, 12),
|
|
];
|
|
|
|
it('DAWARICH-BUCKET-001: midnight is exact — a point at 00:00:00 belongs to the new day', () => {
|
|
const days = bucketPointsByDay(acrossMidnight(), 0);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
|
|
expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T10:00:00.000Z');
|
|
expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T23:59:59.000Z');
|
|
expect(days[1]!.segments[0]!.startedAt).toBe('2024-06-16T00:00:00.000Z');
|
|
expect(days[1]!.segments[0]!.endedAt).toBe('2024-06-16T10:00:00.000Z');
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-002: the seam is stitched — a day starts where the previous one ended', () => {
|
|
const days = bucketPointsByDay(acrossMidnight(), 0);
|
|
const previousLast = days[0]!.segments[0]!.points.at(-1);
|
|
// The stitched point is drawn but does not move the day's own times.
|
|
expect(days[1]!.segments[0]!.points[0]).toEqual(previousLast);
|
|
expect(days[1]!.segments[0]!.points).toEqual([[10, 11], [11, 11], [11, 12]]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-003: the first day is not stitched to anything', () => {
|
|
const days = bucketPointsByDay(
|
|
[point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)],
|
|
0,
|
|
);
|
|
expect(days[0]!.segments[0]!.points).toEqual([[10, 10], [10, 11]]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-004: the offset decides the day — an early UTC point is still yesterday in New York', () => {
|
|
const days = bucketPointsByDay(
|
|
[point('2024-06-15T02:00:00Z', 10, 10), point('2024-06-15T03:00:00Z', 11, 11)],
|
|
-300,
|
|
);
|
|
expect(days).toHaveLength(1);
|
|
expect(days[0]!.date).toBe('2024-06-14');
|
|
// The segment times stay the real instants; only the bucket is local.
|
|
expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T02:00:00.000Z');
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-005: a positive offset pushes a late UTC point into tomorrow', () => {
|
|
const days = bucketPointsByDay(
|
|
[point('2024-06-15T22:00:00Z', 10, 10), point('2024-06-15T23:00:00Z', 11, 11)],
|
|
540,
|
|
);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-006: points arriving out of order are sorted by time', () => {
|
|
const days = bucketPointsByDay(
|
|
[
|
|
point('2024-06-15T18:00:00Z', 11, 11),
|
|
point('2024-06-15T06:00:00Z', 10, 10),
|
|
point('2024-06-15T12:00:00Z', 10, 11),
|
|
],
|
|
0,
|
|
);
|
|
expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T06:00:00.000Z');
|
|
expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T18:00:00.000Z');
|
|
expect(days[0]!.segments[0]!.points[0]).toEqual([10, 10]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-007: unusable points are dropped and a day that loses its line is not emitted', () => {
|
|
const days = bucketPointsByDay(
|
|
[
|
|
{ latitude: 'north', longitude: 10, timestamp: 1_718_445_600 },
|
|
{ latitude: 95, longitude: 10, timestamp: 1_718_445_601 },
|
|
{ latitude: 10, longitude: 200, timestamp: 1_718_445_602 },
|
|
{ latitude: 10, longitude: 10, timestamp: null } as unknown as DawarichSlimPoint,
|
|
point('2024-06-15T10:00:00Z', 10, 10),
|
|
],
|
|
0,
|
|
);
|
|
// Only one usable point survived, so there is no line to draw.
|
|
expect(days).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-008: an empty recording is an empty result', () => {
|
|
expect(bucketPointsByDay([], 0)).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-009: honours the per-day cap and the tolerance', () => {
|
|
const days = bucketPointsByDay(
|
|
[
|
|
point('2024-06-15T00:00:00Z', 0, 0),
|
|
point('2024-06-15T01:00:00Z', 1, 0),
|
|
point('2024-06-15T02:00:00Z', 0, 1),
|
|
point('2024-06-15T03:00:00Z', 1, 1),
|
|
point('2024-06-15T04:00:00Z', 0, 2),
|
|
],
|
|
0,
|
|
{ maxPointsPerDay: 3, epsilon: 0.00005 },
|
|
);
|
|
const line = days[0]!.segments[0]!.points;
|
|
expect(line).toHaveLength(3);
|
|
expect(line[0]).toEqual([0, 0]);
|
|
expect(line.at(-1)).toEqual([0, 2]);
|
|
});
|
|
|
|
it('DAWARICH-BUCKET-010: a recorded day carries no mode and no measured distance', () => {
|
|
const days = bucketPointsByDay(
|
|
[point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)],
|
|
0,
|
|
);
|
|
expect(days[0]!.segments[0]!.mode).toBeNull();
|
|
expect(days[0]!.segments[0]!.distanceMeters).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── bucketTracksByDay ────────────────────────────────────────────────────────
|
|
|
|
describe('bucketTracksByDay', () => {
|
|
it('DAWARICH-TRACK-001: a track inside one day stays whole and keeps its measured distance', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]], {
|
|
distance: 4321,
|
|
dominant_mode: 'car',
|
|
}),
|
|
]);
|
|
expect(days).toEqual([
|
|
{
|
|
date: '2024-06-15',
|
|
segments: [
|
|
{
|
|
// GeoJSON [lng, lat] in, TREK [lat, lng] out.
|
|
points: [[10, 10], [11, 11], [12, 11]],
|
|
mode: 'car',
|
|
startedAt: '2024-06-15T10:00:00Z',
|
|
endedAt: '2024-06-15T12:00:00Z',
|
|
distanceMeters: 4321,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-002: the local day comes from the offset the track carries', () => {
|
|
// 00:30+02:00 is still 22:30Z the day before, but the recording user saw
|
|
// the 16th, which is the day TREK must file it under.
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-16T00:30:00+02:00', '2024-06-16T01:30:00+02:00', [[10, 10], [11, 11], [11, 12]]),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-003: a track over midnight is split, and the two halves meet at midnight', () => {
|
|
const days = bucketTracksByDay([
|
|
track(
|
|
'2024-06-15T23:00:00Z',
|
|
'2024-06-16T01:00:00Z',
|
|
[[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]],
|
|
{ distance: 9000, dominant_mode: 'walk' },
|
|
),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
|
|
|
|
const first = days[0]!.segments[0]!;
|
|
const second = days[1]!.segments[0]!;
|
|
expect(first.startedAt).toBe('2024-06-15T23:00:00.000Z');
|
|
expect(first.endedAt).toBe('2024-06-16T00:00:00.000Z');
|
|
expect(second.startedAt).toBe('2024-06-16T00:00:00.000Z');
|
|
expect(second.endedAt).toBe('2024-06-16T01:00:00Z');
|
|
// The cut point belongs to both halves, so the line has no gap.
|
|
expect(second.points[0]).toEqual(first.points.at(-1));
|
|
expect(first.mode).toBe('walk');
|
|
expect(second.mode).toBe('walk');
|
|
});
|
|
|
|
it('DAWARICH-TRACK-004: a split piece reports no distance — only a whole track measured one', () => {
|
|
const days = bucketTracksByDay([
|
|
track(
|
|
'2024-06-15T23:00:00Z',
|
|
'2024-06-16T01:00:00Z',
|
|
[[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]],
|
|
{ distance: 9000 },
|
|
),
|
|
]);
|
|
expect(days.flatMap((d) => d.segments).map((s) => s.distanceMeters)).toEqual([null, null]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-005: a track spanning three days is cut twice, by index proportion of its span', () => {
|
|
const days = bucketTracksByDay([
|
|
track(
|
|
'2024-06-15T12:00:00Z',
|
|
'2024-06-17T12:00:00Z',
|
|
[[10, 10], [10.5, 11], [11, 10], [11.5, 11], [12, 10], [12.5, 11], [13, 10]],
|
|
{ distance: 100 },
|
|
),
|
|
]);
|
|
expect(
|
|
days.map((d) => ({ date: d.date, from: d.segments[0]!.startedAt, to: d.segments[0]!.endedAt })),
|
|
).toEqual([
|
|
{ date: '2024-06-15', from: '2024-06-15T12:00:00.000Z', to: '2024-06-16T00:00:00.000Z' },
|
|
{ date: '2024-06-16', from: '2024-06-16T00:00:00.000Z', to: '2024-06-17T00:00:00.000Z' },
|
|
{ date: '2024-06-17', from: '2024-06-17T00:00:00.000Z', to: '2024-06-17T12:00:00Z' },
|
|
]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-006: days come back in calendar order however the features arrived', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-17T08:00:00Z', '2024-06-17T09:00:00Z', [[10, 10], [11, 11], [11, 12]]),
|
|
track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]),
|
|
track('2024-06-16T06:00:00Z', '2024-06-16T07:00:00Z', [[30, 30], [31, 31], [31, 32]]),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16', '2024-06-17']);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-007: segments within a day come back in start order', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]),
|
|
track('2024-06-15T06:00:00Z', '2024-06-15T07:00:00Z', [[30, 30], [31, 31], [31, 32]]),
|
|
track('2024-06-15T12:00:00Z', '2024-06-15T13:00:00Z', [[40, 40], [41, 41], [41, 42]]),
|
|
]);
|
|
expect(days).toHaveLength(1);
|
|
expect(days[0]!.segments.map((s) => s.startedAt)).toEqual([
|
|
'2024-06-15T06:00:00Z',
|
|
'2024-06-15T12:00:00Z',
|
|
'2024-06-15T18:00:00Z',
|
|
]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-008: a track without both timestamps is skipped', () => {
|
|
const days = bucketTracksByDay([
|
|
track(undefined, '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]),
|
|
track('2024-06-15T10:00:00Z', undefined, [[10, 10], [11, 11], [11, 12]]),
|
|
{ geometry: { coordinates: [[10, 10], [11, 11]] }, properties: null },
|
|
]);
|
|
expect(days).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-009: a track without a drawable line is skipped', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10]]),
|
|
track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', 'nope'),
|
|
{ geometry: null, properties: { start_at: '2024-06-15T10:00:00Z', end_at: '2024-06-15T12:00:00Z' } },
|
|
]);
|
|
expect(days).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-010: a backwards or zero-length track is treated as whole, on its start day', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-16T01:00:00Z', '2024-06-15T23:00:00Z', [[10, 10], [11, 11], [11, 12]], {
|
|
distance: 50,
|
|
}),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
|
|
expect(days[0]!.segments[0]!.distanceMeters).toBe(50);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-011: a two-point track over midnight is not split at all', () => {
|
|
// One segment cannot become two drawable pieces: cutting it would hand one
|
|
// day a single point and the other nothing, and the tail would vanish. So
|
|
// the whole track goes on the day it started, with its real times intact —
|
|
// the split is declined rather than faked.
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T23:00:00Z', '2024-06-16T01:00:00Z', [[10, 10], [11, 12]], { distance: 100 }),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15']);
|
|
expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-16T01:00:00Z');
|
|
// Whole, so it still reports the distance Dawarich actually measured.
|
|
expect(days[0]!.segments[0]!.distanceMeters).toBe(100);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-014: enough points to split across midnight still splits', () => {
|
|
// The counterpart to 011: three segments over one midnight leave both days
|
|
// a drawable line, so the approximation is applied.
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T22:00:00Z', '2024-06-16T02:00:00Z', [[10, 10], [10.5, 10.5], [11, 11], [11.5, 11.5]]),
|
|
]);
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
|
|
for (const day of days) expect(day.segments[0]!.points.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-012: honours the per-day cap and the tolerance', () => {
|
|
const zigzag = Array.from(
|
|
{ length: 40 },
|
|
(_, i): [number, number] => [10 + i / 10, i % 2 === 0 ? 10 : 11],
|
|
);
|
|
const days = bucketTracksByDay([track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', zigzag)], {
|
|
maxPointsPerDay: 5,
|
|
epsilon: 0.00005,
|
|
});
|
|
expect(days[0]!.segments[0]!.points).toHaveLength(5);
|
|
});
|
|
|
|
it('DAWARICH-TRACK-015: a cut that lands on the last point ends the walk instead of emitting an empty tail', () => {
|
|
// Midnight falls at 98% of this track's time span, so the proportional cut
|
|
// puts every point before it. What is left afterwards is one point, which
|
|
// is not a line: the loop stops there rather than pushing a tail piece that
|
|
// would render as nothing and carry a start after its own end.
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T00:00:00Z', '2024-06-16T00:30:00Z', [[10, 10], [11, 12], [12, 10]], {
|
|
distance: 7000,
|
|
}),
|
|
]);
|
|
|
|
expect(days.map((d) => d.date)).toEqual(['2024-06-15']);
|
|
const segment = days[0]!.segments[0]!;
|
|
expect(segment.points).toEqual([[10, 10], [12, 11], [10, 12]]);
|
|
expect(segment.startedAt).toBe('2024-06-15T00:00:00.000Z');
|
|
expect(segment.endedAt).toBe('2024-06-16T00:00:00.000Z');
|
|
// It was cut, even though only one piece came out, so the measured distance
|
|
// no longer describes it.
|
|
expect(segment.distanceMeters).toBeNull();
|
|
});
|
|
|
|
it('DAWARICH-TRACK-013: an empty feature list is an empty result', () => {
|
|
expect(bucketTracksByDay([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── countPoints ──────────────────────────────────────────────────────────────
|
|
|
|
describe('countPoints', () => {
|
|
const day = (...segments: Array<Array<[number, number]>>): DawarichTrackDay => ({
|
|
date: '2024-06-15',
|
|
segments: segments.map((points) => ({
|
|
points,
|
|
mode: null,
|
|
startedAt: '2024-06-15T10:00:00Z',
|
|
endedAt: '2024-06-15T12:00:00Z',
|
|
distanceMeters: null,
|
|
})),
|
|
});
|
|
|
|
it('DAWARICH-COUNT-001: nothing drawn is zero', () => {
|
|
expect(countPoints([])).toBe(0);
|
|
expect(countPoints([day()])).toBe(0);
|
|
});
|
|
|
|
it('DAWARICH-COUNT-002: sums every segment of every day', () => {
|
|
const days = [day([[0, 0], [1, 1]], [[0, 0], [1, 1], [2, 2]]), day([[0, 0], [1, 1]])];
|
|
expect(countPoints(days)).toBe(7);
|
|
});
|
|
|
|
it('DAWARICH-COUNT-003: agrees with what the bucketing actually produced', () => {
|
|
const days = bucketTracksByDay([
|
|
track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]),
|
|
track('2024-06-16T10:00:00Z', '2024-06-16T12:00:00Z', [[20, 20], [21, 21], [21, 22]]),
|
|
]);
|
|
expect(countPoints(days)).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('visited cities over a long range', () => {
|
|
const DAY = 86_400_000;
|
|
|
|
it('DAWARICH-CITIES-001: a range within one window stays one request, bounds untouched', () => {
|
|
const from = new Date('2026-09-01T00:00:00Z');
|
|
const to = new Date('2026-09-02T00:00:00Z');
|
|
expect(splitWindow(from, to, 30)).toEqual([{ from, to }]);
|
|
});
|
|
|
|
it('DAWARICH-CITIES-002: a year becomes months that cover it without a gap or an overlap', () => {
|
|
// Dawarich reads start_at..end_at as inclusive on both ends, so touching
|
|
// bounds would hand the point on the seam to two windows.
|
|
const from = new Date('2025-09-14T20:00:00Z');
|
|
const to = new Date(from.getTime() + 365 * DAY);
|
|
const windows = splitWindow(from, to, 30);
|
|
|
|
expect(windows).toHaveLength(13);
|
|
expect(windows[0].from).toEqual(from);
|
|
expect(windows[windows.length - 1].to).toEqual(to);
|
|
for (let i = 1; i < windows.length; i++) {
|
|
expect(windows[i].from.getTime() - windows[i - 1].to.getTime()).toBe(1000);
|
|
}
|
|
for (const window of windows) {
|
|
expect(window.to.getTime() - window.from.getTime()).toBeLessThanOrEqual(30 * DAY);
|
|
}
|
|
});
|
|
|
|
it('DAWARICH-CITIES-003: bounds are whole seconds, the unit that goes on the wire', () => {
|
|
const windows = splitWindow(new Date('2026-01-01T00:00:00.750Z'), new Date('2026-03-01T00:00:00.250Z'), 30);
|
|
for (const window of windows) {
|
|
expect(window.from.getMilliseconds()).toBe(0);
|
|
expect(window.to.getMilliseconds()).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('DAWARICH-CITIES-004: a range that ends before it starts asks nothing', () => {
|
|
expect(splitWindow(new Date('2026-09-02T00:00:00Z'), new Date('2026-09-01T00:00:00Z'), 30)).toEqual([]);
|
|
});
|
|
|
|
it('DAWARICH-CITIES-005: a city seen in two months adds up its time and keeps the later visit', () => {
|
|
const merged = mergeVisitedCountries([
|
|
[{ country: 'Germany', cities: [{ city: 'Rostock', points: 10, stayed_for: 90, timestamp: 1_700_000_000 }] }],
|
|
[
|
|
{ country: 'Germany', cities: [{ city: 'Rostock', points: 5, stayed_for: 60, timestamp: 1_702_000_000 }, { city: 'Berlin', stayed_for: 120 }] },
|
|
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
|
|
],
|
|
]);
|
|
|
|
expect(merged).toEqual([
|
|
{
|
|
country: 'Germany',
|
|
cities: [
|
|
{ city: 'Rostock', points: 15, stayed_for: 150, timestamp: 1_702_000_000 },
|
|
{ city: 'Berlin', stayed_for: 120 },
|
|
],
|
|
},
|
|
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
|
|
]);
|
|
});
|
|
|
|
it('DAWARICH-CITIES-006: an unvalidated payload loses its unnamed entries, not its countries', () => {
|
|
const merged = mergeVisitedCountries([
|
|
[
|
|
null as never,
|
|
{ country: 42 as never, cities: [] },
|
|
{ country: 'Denmark', cities: null as never },
|
|
],
|
|
[{ country: 'Denmark', cities: [null as never, {} as never, { city: 'Gedser', points: '7' as never }] }],
|
|
]);
|
|
|
|
// A count that arrived as a string is still a count; a timestamp nobody sent
|
|
// stays absent instead of turning into -Infinity.
|
|
expect(merged).toEqual([{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' }] }]);
|
|
|
|
const again = mergeVisitedCountries([
|
|
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' as never }] }],
|
|
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: 3 }] }],
|
|
]);
|
|
expect(again[0].cities[0]).toEqual({ city: 'Gedser', points: 10, stayed_for: undefined, timestamp: undefined });
|
|
});
|
|
});
|