This commit is contained in:
gili8420 2026-08-14 00:01:49 +09:00
commit b43955e0d9
162 changed files with 15425 additions and 0 deletions

View file

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$api', () => {
const state = { authed: false, username: 'demo' };
return {
setUnauthorizedHandler: vi.fn(),
authApi: {
session: vi.fn(async () => ({ isAuthenticated: state.authed })),
whoami: vi.fn(async () => ({ username: state.username, is_staff: false })),
login: vi.fn(async () => {
state.authed = true;
state.username = 'demo';
return { detail: 'ok' };
}),
logout: vi.fn(async () => {
state.authed = false;
state.username = 'demo';
}),
register: vi.fn(async (u: string) => {
state.authed = true;
state.username = u;
return { detail: 'ok' };
})
}
};
});
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
import { get } from 'svelte/store';
import { authStore } from '$auth';
beforeEach(() => authStore.logout());
describe('authStore', () => {
it('starts unknown then resolves anonymous', async () => {
const s = await authStore.refresh();
expect(s.status).toBe('anonymous');
expect(get(authStore).username).toBeNull();
});
it('becomes authenticated after login', async () => {
await authStore.login('demo', 'demo');
const s = get(authStore);
expect(s.status).toBe('authenticated');
expect(s.username).toBe('demo');
expect(s.isStaff).toBe(false);
});
it('registers, auto-logs-in, and exposes the new username', async () => {
await authStore.register('newbie', 'newbie@example.com', 's3curePass!');
const s = get(authStore);
expect(s.status).toBe('authenticated');
expect(s.username).toBe('newbie');
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { computeBoundingBox, boundingBoxRing } from '$domain';
import type { LatLngTuple } from '$domain';
describe('computeBoundingBox', () => {
it('returns null for an empty path', () => {
expect(computeBoundingBox([])).toBeNull();
});
it('wraps the extremes and expands by the margin', () => {
const path: LatLngTuple[] = [
[62.0, 129.0],
[62.5, 130.0]
];
const box = computeBoundingBox(path, 0)!;
expect(box.south).toBeCloseTo(62.0, 6);
expect(box.north).toBeCloseTo(62.5, 6);
expect(box.west).toBeCloseTo(129.0, 6);
expect(box.east).toBeCloseTo(130.0, 6);
const padded = computeBoundingBox(path, 5)!;
expect(padded.south).toBeLessThan(62.0);
expect(padded.north).toBeGreaterThan(62.5);
expect(padded.west).toBeLessThan(129.0);
expect(padded.east).toBeGreaterThan(130.0);
});
});
describe('boundingBoxRing', () => {
it('is a closed ring of 5 corners (SW repeated)', () => {
const ring = boundingBoxRing({ south: 1, west: 2, north: 3, east: 4 });
expect(ring).toHaveLength(5);
expect(ring[0]).toEqual(ring[4]);
});
});

84
tests/unit/client.test.ts Normal file
View file

@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { api, ApiError, setUnauthorizedHandler } from '$api';
function mockFetch(status: number, body: unknown) {
return vi.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
})
);
}
beforeEach(() => {
document.cookie = 'csrftoken=abc';
});
describe('api client', () => {
it('prepends the base URL and returns parsed JSON', async () => {
vi.stubGlobal('fetch', mockFetch(200, { username: 'demo' }));
const out = await api.get<{ username: string }>('/whoami/');
expect(out).toEqual({ username: 'demo' });
const url = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(String(url)).toContain('/api/whoami/');
});
it('throws ApiError carrying the backend detail on 4xx', async () => {
vi.stubGlobal('fetch', mockFetch(400, { detail: 'Invalid credentials.' }));
await expect(api.post('/login/', {})).rejects.toMatchObject({
name: 'ApiError',
status: 400,
detail: 'Invalid credentials.'
});
});
// The backend does not speak one error shape: DMR's response validator emits
// `detail` as a list of {msg}, Pydantic bodies arrive as a bare list, and a
// few endpoints use DRF-style field maps. Stringifying any of those yields
// "[object Object]" in the UI, so each shape needs flattening.
it('flattens a list-of-objects detail from DMR response validation', async () => {
vi.stubGlobal(
'fetch',
mockFetch(422, { detail: [{ msg: 'Returned status code 400 is not specified', type: 'value_error' }] })
);
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Returned status code 400 is not specified'
});
});
it('flattens a bare list body of Pydantic errors', async () => {
vi.stubGlobal(
'fetch',
mockFetch(400, [
{ msg: 'Username must be at least 3 characters', loc: ['username'] },
{ msg: 'Invalid email format', loc: ['email'] }
])
);
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Username must be at least 3 characters; Invalid email format'
});
});
it('flattens a DRF-style field error map', async () => {
vi.stubGlobal('fetch', mockFetch(400, { non_field_errors: ['Point already saved.'] }));
await expect(api.post('/points/', {})).rejects.toMatchObject({
detail: 'Point already saved.'
});
});
it('never surfaces [object Object] for an unrecognised shape', async () => {
vi.stubGlobal('fetch', mockFetch(400, { detail: { nested: { deep: true } } }));
await expect(api.post('/register/', {})).rejects.toMatchObject({
detail: 'Request failed (400)'
});
});
it('invokes the unauthorized handler on 401', async () => {
const handler = vi.fn();
setUnauthorizedHandler(handler);
vi.stubGlobal('fetch', mockFetch(401, { detail: 'Unauthorized' }));
await expect(api.get('/whoami/')).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalledOnce();
});
});

87
tests/unit/curve.test.ts Normal file
View file

@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import {
normalizeCurve,
validateCurve,
impliedRates,
parseCurveCsv,
curveToCsv
} from '$domain';
import type { RateCurvePoint } from '$domain';
const curve: RateCurvePoint[] = [
{ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 },
{ order: 1, time_constraint: 100, alt_constraint: 500, rate: 0 },
{ order: 2, time_constraint: 200, alt_constraint: 1500, rate: 0 }
];
describe('normalizeCurve', () => {
it('sorts by order and renumbers 0..n-1', () => {
const shuffled: RateCurvePoint[] = [
{ order: 7, time_constraint: 200, alt_constraint: 1500, rate: 0 },
{ order: 2, time_constraint: 0, alt_constraint: 0, rate: 0 }
];
expect(normalizeCurve(shuffled).map((p) => [p.order, p.time_constraint])).toEqual([
[0, 0],
[1, 200]
]);
});
});
describe('validateCurve', () => {
it('accepts a strictly increasing curve', () => {
expect(validateCurve(curve)).toEqual([]);
});
it('rejects fewer than two points', () => {
expect(validateCurve([curve[0]])).toContain('curve.errTooFewPoints');
});
it('rejects non-monotonic time (equal or backwards steps)', () => {
const flat = [...curve, { order: 3, time_constraint: 200, alt_constraint: 1600, rate: 0 }];
expect(validateCurve(flat)).toContain('curve.errNonMonotonicTime');
});
it('rejects negative time and altitude', () => {
const bad: RateCurvePoint[] = [
{ order: 0, time_constraint: -10, alt_constraint: -5, rate: 0 },
{ order: 1, time_constraint: 10, alt_constraint: 100, rate: 0 }
];
const problems = validateCurve(bad);
expect(problems).toContain('curve.errNegativeTime');
expect(problems).toContain('curve.errNegativeAltitude');
});
});
describe('impliedRates', () => {
it('derives the vertical rate of each segment', () => {
const rates = impliedRates(curve);
expect(rates).toHaveLength(2);
expect(rates[0].rate).toBeCloseTo(5, 6); // 500 m / 100 s
expect(rates[1].rate).toBeCloseTo(10, 6); // 1000 m / 100 s
});
it('skips zero-length segments instead of dividing by zero', () => {
const flat: RateCurvePoint[] = [
{ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 },
{ order: 1, time_constraint: 0, alt_constraint: 100, rate: 0 }
];
expect(impliedRates(flat)).toEqual([]);
});
});
describe('parseCurveCsv', () => {
it('parses rows, tolerating a header, blank lines and semicolons', () => {
const csv = 'time_s,altitude_m,rate_ms\n0,0,0\n\n100;500;5\n';
const points = parseCurveCsv(csv);
expect(points).toHaveLength(2);
expect(points[1]).toMatchObject({ order: 1, time_constraint: 100, alt_constraint: 500, rate: 5 });
});
it('returns nothing for text with no numeric rows', () => {
expect(parseCurveCsv('hello\nworld')).toEqual([]);
});
it('round-trips through curveToCsv', () => {
expect(parseCurveCsv(curveToCsv(curve))).toHaveLength(curve.length);
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { parseEnsemble, ellipseRing } from '$domain';
import type { EnsembleFootprint } from '$domain';
describe('parseEnsemble', () => {
it('narrows an ensemble payload', () => {
const parsed = parseEnsemble({
ensemble: { members: [{ member: 0, landing: { lat: 1, lng: 2, alt: 0 } }], failed: [] }
});
expect(parsed?.members).toHaveLength(1);
expect(parsed?.footprint).toBeNull();
});
it('rejects a plain single-run result', () => {
expect(parseEnsemble({ prediction: [] })).toBeNull();
expect(parseEnsemble(null)).toBeNull();
expect(parseEnsemble({ ensemble: {} })).toBeNull();
});
});
describe('ellipseRing', () => {
const footprint: EnsembleFootprint = {
count: 5,
mean: { lat: 62, lng: 129 },
radius_km: 10,
ellipse: { semi_major_km: 20, semi_minor_km: 10, bearing_deg: 0 }
};
it('returns a closed ring centred on the mean', () => {
const ring = ellipseRing(footprint, 32);
expect(ring).toHaveLength(33);
expect(ring[0][0]).toBeCloseTo(ring[32][0], 9);
expect(ring[0][1]).toBeCloseTo(ring[32][1], 9);
const lats = ring.map((p) => p[0]);
expect((Math.min(...lats) + Math.max(...lats)) / 2).toBeCloseTo(62, 6);
});
it('corrects longitude for latitude so the ellipse is not stretched', () => {
const ring = ellipseRing(footprint, 64);
const lats = ring.map((p) => p[0]);
const lngs = ring.map((p) => p[1]);
// 20 km north-south at bearing 0 => ~0.18° of latitude.
expect(Math.max(...lats) - 62).toBeCloseTo(20 / 111.32, 3);
// 10 km east-west spans more degrees at 62°N than it would at the equator.
const halfSpanDeg = Math.max(...lngs) - 129;
expect(halfSpanDeg).toBeGreaterThan(10 / 111.32);
expect(halfSpanDeg).toBeCloseTo(10 / (111.32 * Math.cos((62 * Math.PI) / 180)), 3);
});
});

66
tests/unit/export.test.ts Normal file
View file

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { toGpx, toKml, toCsv, parsePrediction, EXPORT_MIME, exportPrediction } from '$domain';
import type { PredictionStage } from '$domain';
const stages: PredictionStage[] = [
{
stage: 'ascent',
trajectory: [
{ altitude: 0, datetime: '2026-01-01T00:00:00Z', latitude: 62.0, longitude: 129.0 },
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 }
]
},
{
stage: 'descent',
trajectory: [
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 },
{ altitude: 0, datetime: '2026-01-01T01:30:00Z', latitude: 62.05, longitude: 129.4 }
]
}
];
const prediction = parsePrediction(stages);
describe('toGpx', () => {
it('emits one trkpt per sample with elevation and time', () => {
const gpx = toGpx(prediction, 'Flight 1');
expect(gpx).toContain('<gpx version="1.1"');
expect(gpx.match(/<trkpt /g)).toHaveLength(4);
expect(gpx).toContain('lat="62.000000" lon="129.000000"');
expect(gpx).toContain('<ele>30000.0</ele>');
expect(gpx).toContain('<time>2026-01-01T00:00:00.000Z</time>');
});
it('escapes the name so a quote cannot break the XML', () => {
expect(toGpx(prediction, 'A & <B>')).toContain('A &amp; &lt;B&gt;');
});
});
describe('toKml', () => {
it('orders coordinates longitude-first and marks the key points', () => {
const kml = toKml(prediction);
expect(kml).toContain('<kml xmlns="http://www.opengis.net/kml/2.2">');
// lon,lat,alt — the opposite order to GPX.
expect(kml).toContain('129.000000,62.000000,0.0');
expect(kml).toContain('<name>Launch</name>');
expect(kml).toContain('<name>Burst</name>');
expect(kml).toContain('<name>Landing</name>');
});
});
describe('toCsv', () => {
it('writes a header plus one row per sample', () => {
const lines = toCsv(prediction).split('\n');
expect(lines[0]).toBe('datetime_utc,latitude,longitude,altitude_m');
expect(lines).toHaveLength(5);
expect(lines[1]).toBe('2026-01-01T00:00:00.000Z,62.000000,129.000000,0.0');
});
});
describe('exportPrediction', () => {
it('dispatches by format and exposes a mime type for each', () => {
expect(exportPrediction(prediction, 'gpx')).toContain('<gpx');
expect(exportPrediction(prediction, 'kml')).toContain('<kml');
expect(exportPrediction(prediction, 'csv')).toContain('datetime_utc');
expect(Object.keys(EXPORT_MIME).sort()).toEqual(['csv', 'gpx', 'kml']);
});
});

13
tests/unit/geo.test.ts Normal file
View file

@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { haversineMeters } from '$domain';
describe('haversineMeters', () => {
it('is zero for identical points', () => {
expect(haversineMeters({ lat: 55, lon: 37 }, { lat: 55, lon: 37 })).toBe(0);
});
it('matches a known great-circle distance (London→Paris ≈ 343 km)', () => {
const d = haversineMeters({ lat: 51.5074, lon: -0.1278 }, { lat: 48.8566, lon: 2.3522 });
expect(d).toBeGreaterThan(340_000);
expect(d).toBeLessThan(346_000);
});
});

View file

@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { toHistoryRow } from '$domain';
import type { RawPrediction } from '$domain';
const result: RawPrediction = {
metadata: { start_datetime: '2026-01-01T00:00:00Z', complete_datetime: '2026-01-01T01:30:00Z' },
prediction: [
{
stage: 'ascent',
trajectory: [
{ altitude: 0, datetime: '2026-01-01T00:00:00Z', latitude: 62.0, longitude: 129.0 },
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 }
]
},
{
stage: 'descent',
trajectory: [
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 },
{ altitude: 0, datetime: '2026-01-01T01:30:00Z', latitude: 62.05, longitude: 129.4 }
]
}
]
};
describe('toHistoryRow', () => {
it('summarises a stored prediction into landing and flight time', () => {
const row = toHistoryRow({ id: 'a', created_at: '2026-01-01T00:00:00Z', result });
expect(row.broken).toBe(false);
expect(row.landing).toEqual({ lat: 62.05, lng: 129.4 });
expect(row.flightTime).toBe(90 * 60);
expect(row.createdAt.toISOString()).toBe('2026-01-01T00:00:00.000Z');
});
it('marks a run with no result as broken rather than throwing', () => {
const row = toHistoryRow({ id: 'b', created_at: '2026-01-01T00:00:00Z', result: null });
expect(row.broken).toBe(true);
expect(row.landing).toBeNull();
expect(row.flightTime).toBe(0);
});
it('summarises an ensemble run by its footprint instead of a trajectory', () => {
const row = toHistoryRow({
id: 'e',
created_at: '2026-01-01T00:00:00Z',
result: {
ensemble: {
members: [{ member: 0, landing: { lat: 62, lng: 129, alt: 0 } }],
failed: [],
footprint: {
count: 21,
mean: { lat: 62.5, lng: 129.5 },
radius_km: 4.2,
ellipse: { semi_major_km: 7, semi_minor_km: 3, bearing_deg: 10 }
}
}
}
});
expect(row.kind).toBe('ensemble');
expect(row.members).toBe(21);
expect(row.landing).toEqual({ lat: 62.5, lng: 129.5 });
expect(row.broken).toBe(false);
});
it('marks a truncated result (single stage) as broken', () => {
const partial = { ...result, prediction: [result.prediction[0]] };
const row = toHistoryRow({ id: 'c', created_at: '2026-01-01T00:00:00Z', result: partial });
expect(row.broken).toBe(true);
});
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import { persisted } from '$state';
beforeEach(() => localStorage.clear());
describe('persisted', () => {
it('uses the initial value when storage is empty', () => {
const s = persisted('k1', 42);
expect(get(s)).toBe(42);
});
it('writes updates to localStorage', () => {
const s = persisted('k2', { n: 1 });
s.set({ n: 2 });
expect(JSON.parse(localStorage.getItem('k2')!)).toEqual({ n: 2 });
});
it('rehydrates a value written by a previous instance', () => {
persisted('k3', 'a').set('b');
const s2 = persisted('k3', 'a');
expect(get(s2)).toBe('b');
});
/**
* Regression: constructing a store used to broadcast the value it had just
* read, so opening a second tab pushed its stale snapshot over the first one.
*/
it('does not broadcast the value read at construction', () => {
const posted: unknown[] = [];
class SpyChannel {
onmessage: ((e: { data: unknown }) => void) | null = null;
postMessage(v: unknown) {
posted.push(v);
}
}
vi.stubGlobal('BroadcastChannel', SpyChannel);
localStorage.setItem('k4', JSON.stringify({ n: 1 }));
const s = persisted('k4', { n: 0 });
expect(get(s)).toEqual({ n: 1 });
expect(posted).toEqual([]); // nothing announced merely by mounting
s.set({ n: 2 });
expect(posted).toEqual([{ n: 2 }]); // local changes still propagate
vi.unstubAllGlobals();
});
});

View file

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { parsePrediction } from '$domain';
import type { PredictionStage } from '$domain';
const stages: PredictionStage[] = [
{
stage: 'ascent',
trajectory: [
{ altitude: 0, datetime: '2026-01-01T00:00:00Z', latitude: 62.0, longitude: 129.0 },
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 }
]
},
{
stage: 'descent',
trajectory: [
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 },
{ altitude: 0, datetime: '2026-01-01T01:30:00Z', latitude: 62.05, longitude: 129.4 }
]
}
];
describe('parsePrediction', () => {
it('flattens ascent + descent into one flight_path', () => {
const p = parsePrediction(stages);
expect(p.flight_path).toHaveLength(4);
expect(p.timestamps).toHaveLength(4);
});
it('derives launch, burst, and landing points', () => {
const p = parsePrediction(stages);
expect(p.launch.latlng.lat).toBe(62.0); // ascent[0]
expect(p.burst.latlng.alt).toBe(30000); // descent[0]
expect(p.landing.latlng.lat).toBe(62.05); // descent[last]
expect(p.flight_time).toBe(90 * 60); // 90 minutes, in seconds
expect(p.profile).toBe('standard_profile');
});
it('throws when there are fewer than two stages', () => {
expect(() => parsePrediction([stages[0]])).toThrow();
});
});

144
tests/unit/profile.test.ts Normal file
View file

@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import {
standardStages,
floatStages,
validateProfile,
toV2Request,
stageSummary,
DEFAULT_FLIGHT_PARAMETERS
} from '$domain';
import type { ProfileStage } from '$domain';
const params = { ...DEFAULT_FLIGHT_PARAMETERS, ascent_rate: 5, descent_rate: 6, burst_altitude: 30000 };
describe('presets', () => {
it('expresses the standard flight as ascent → descent stages', () => {
const stages = standardStages(params);
expect(stages.map((s) => s.name)).toEqual(['ascent', 'descent']);
expect(stages[0].solver).toMatchObject({ type: 'constant_rate', rate: 5 });
expect(stages[0].advanceWhen[0]).toMatchObject({ type: 'altitude', op: '>=', limit: 30000 });
// Descent ends on the ground, not at an altitude.
expect(stages[1].solver).toMatchObject({ type: 'parachute_descent', sea_level_rate: 6 });
expect(stages[1].advanceWhen[0].type).toBe('terrain_contact');
});
it('floats by holding altitude on the wind solver', () => {
const stages = floatStages(params);
expect(stages[1].solver.type).toBe('wind');
expect(stages[1].advanceWhen[0].type).toBe('time');
});
});
describe('validateProfile', () => {
it('accepts the standard preset', () => {
expect(validateProfile(standardStages(params))).toEqual([]);
});
it('rejects an empty profile', () => {
expect(validateProfile([])).toEqual(['profileBuilder.errNoStages']);
});
it('rejects a stage that can never end', () => {
const stages = standardStages(params);
stages[0].advanceWhen = [];
expect(validateProfile(stages)).toContain('profileBuilder.errNoExit');
});
it('rejects a fallback pointing at a stage that does not exist', () => {
const stages = standardStages(params);
stages[0].abortIf = [
{ type: 'time', op: '>=', limit: 100, action: 'fallback', fallback: 'nowhere' }
];
expect(validateProfile(stages)).toContain('profileBuilder.errMissingFallback');
stages[0].abortIf[0].fallback = 'descent';
expect(validateProfile(stages)).not.toContain('profileBuilder.errMissingFallback');
});
it('rejects a scalar constraint missing its operator or limit', () => {
const stages = standardStages(params);
stages[0].advanceWhen = [{ type: 'altitude', action: 'stop' }];
expect(validateProfile(stages)).toContain('profileBuilder.errIncompleteConstraint');
});
it('rejects duplicate and blank stage names', () => {
const dup: ProfileStage[] = [...standardStages(params)];
dup[1] = { ...dup[1], name: 'ascent' };
expect(validateProfile(dup)).toContain('profileBuilder.errDuplicateName');
const blank = standardStages(params);
blank[0].name = ' ';
expect(validateProfile(blank)).toContain('profileBuilder.errUnnamedStage');
});
});
describe('toV2Request', () => {
it('maps stages to the predictor contract', () => {
const req = toV2Request(standardStages(params), params, '2026-01-01T12:00:00.000Z', {
source: 'gfs-0p50-3h'
});
expect(req.launch).toEqual({
time: '2026-01-01T12:00:00.000Z',
latitude: params.launch_latitude,
longitude: params.launch_longitude,
altitude: params.launch_altitude
});
expect(req.direction).toBe('forward');
expect(req.source).toBe('gfs-0p50-3h');
expect(req.profile[0].model).toEqual({ type: 'constant_rate', rate: 5, include_wind: true });
expect(req.profile[1].model).toEqual({
type: 'parachute_descent',
sea_level_rate: 6,
include_wind: true
});
});
it('folds advance and abort constraints into one list, keeping their actions', () => {
const stages = standardStages(params);
stages[0].abortIf = [
{ type: 'time', op: '>=', limit: 7200, action: 'fallback', fallback: 'descent' }
];
const req = toV2Request(stages, params, '2026-01-01T12:00:00.000Z');
expect(req.profile[0].constraints).toHaveLength(2);
expect(req.profile[0].constraints[1]).toEqual({
type: 'time',
op: '>=',
limit: 7200,
action: 'fallback',
fallback: 'descent'
});
});
it('omits absent optional fields rather than sending nulls', () => {
const req = toV2Request(standardStages(params), params, '2026-01-01T12:00:00.000Z');
expect(req.profile[1].constraints[0]).toEqual({ type: 'terrain_contact', action: 'stop' });
expect('source' in req).toBe(false);
});
it('maps curve points to piecewise segments', () => {
const stages: ProfileStage[] = [
{
name: 'ascent',
solver: {
type: 'piecewise',
include_wind: true,
segments: [{ order: 0, time_constraint: 0, alt_constraint: 0, rate: 5 }]
},
advanceWhen: [{ type: 'terrain_contact', action: 'stop' }],
abortIf: []
}
];
const req = toV2Request(stages, params, '2026-01-01T12:00:00.000Z');
expect(req.profile[0].model.segments).toEqual([
{ reference: 'profile_start', time: 0, altitude: 0, rate: 5 }
]);
});
});
describe('stageSummary', () => {
it('summarises a stage in one line', () => {
const [ascent, descent] = standardStages(params);
expect(stageSummary(ascent)).toBe('ascent · 5 m/s → 30000 m');
expect(stageSummary(descent)).toBe('descent · 6 m/s → ground');
});
});

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { pointDirty, templateDirty, DEFAULT_FLIGHT_PARAMETERS } from '$domain';
import type { FlightParameters, SavedPoint } from '$domain';
const point: SavedPoint = { id: 1, name: 'p', lat: 62.5, lon: 129.5, alt: 100 };
const paramsAt = (lat: number, lon: number, alt: number): FlightParameters => ({
...DEFAULT_FLIGHT_PARAMETERS,
launch_latitude: lat,
launch_longitude: lon,
launch_altitude: alt
});
describe('pointDirty', () => {
it('is clean when coordinates match the saved point', () => {
expect(pointDirty(paramsAt(62.5, 129.5, 100), point)).toBe(false);
});
it('is dirty when any coordinate diverges', () => {
expect(pointDirty(paramsAt(62.5001, 129.5, 100), point)).toBe(true);
expect(pointDirty(paramsAt(62.5, 129.5, 101), point)).toBe(true);
});
});
describe('templateDirty', () => {
it('is clean against an identical copy', () => {
const tpl = { ...DEFAULT_FLIGHT_PARAMETERS };
expect(templateDirty({ ...DEFAULT_FLIGHT_PARAMETERS }, tpl)).toBe(false);
});
it('is dirty when a parameter changes', () => {
const tpl = { ...DEFAULT_FLIGHT_PARAMETERS };
expect(templateDirty({ ...DEFAULT_FLIGHT_PARAMETERS, ascent_rate: 7 }, tpl)).toBe(true);
});
});

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { getPath, setPath, DEFAULT_SETTINGS } from '$features/settings/store';
import { formatAltitude, formatRate, formatCoords, toDms, formatTime } from '$domain';
describe('getPath / setPath', () => {
it('reads a nested value', () => {
expect(getPath(DEFAULT_SETTINGS, 'format.units')).toBe('metric');
expect(getPath(DEFAULT_SETTINGS, 'map.baseLayer')).toBe('osm');
});
it('returns undefined for a missing path', () => {
expect(getPath(DEFAULT_SETTINGS, 'nope.missing')).toBeUndefined();
});
it('sets immutably, leaving the original untouched', () => {
const next = setPath(DEFAULT_SETTINGS, 'format.units', 'imperial');
expect(getPath(next, 'format.units')).toBe('imperial');
expect(DEFAULT_SETTINGS.format.units).toBe('metric');
// Sibling branches are preserved.
expect(getPath(next, 'map.baseLayer')).toBe('osm');
});
});
describe('display formatting', () => {
it('converts altitude and rate for imperial display only', () => {
expect(formatAltitude(1000, 'metric')).toBe('1000 m');
expect(formatAltitude(1000, 'imperial')).toBe('3281 ft');
expect(formatRate(5, 'metric')).toBe('5.0 m/s');
expect(formatRate(5, 'imperial')).toBe('984 ft/min');
});
it('formats coordinates as DD or DMS with hemispheres', () => {
expect(formatCoords(62.5, 129.25, 'dd')).toBe('62.5000, 129.2500');
expect(toDms(62.5, 'lat')).toBe('62°30\'00.00"N');
expect(toDms(-0.1278, 'lon')).toContain('W');
});
it('labels UTC and leaves local unlabelled', () => {
const d = new Date('2026-01-01T07:08:09Z');
expect(formatTime(d, 'utc')).toBe('07:08:09 UTC');
expect(formatTime(d, 'local')).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
});

View file

@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { parseTelemetry, computeDeviations, distHaversine, parsePrediction } from '$domain';
import type { TelemetryPoint, PredictionStage } from '$domain';
const points: TelemetryPoint[] = [
{ latitude: 62.0, longitude: 129.0, altitude: 0, datetime: '2026-01-01T00:00:00Z', payload: '{}' },
{
latitude: 62.05,
longitude: 129.1,
altitude: 15000,
datetime: '2026-01-01T00:30:00Z',
payload: '{}'
}
];
describe('parseTelemetry', () => {
it('builds a flight path and takes the first sample as launch', () => {
const t = parseTelemetry(points);
expect(t.flight_path).toEqual([
[62.0, 129.0, 0],
[62.05, 129.1, 15000]
]);
expect(t.launch.latlng.lat).toBe(62.0);
expect(t.datapoints).toHaveLength(2);
});
it('throws on an empty series', () => {
expect(() => parseTelemetry([])).toThrow();
});
});
describe('distHaversine', () => {
it('is zero for identical points and ~343 km London→Paris', () => {
expect(distHaversine({ lat: 55, lng: 37 }, { lat: 55, lng: 37 })).toBe(0);
const d = distHaversine({ lat: 51.5074, lng: -0.1278 }, { lat: 48.8566, lng: 2.3522 });
expect(d).toBeGreaterThan(340);
expect(d).toBeLessThan(346);
});
});
describe('computeDeviations', () => {
// Prediction runs 00:00 → 01:30 along the same corridor as the telemetry.
const stages: PredictionStage[] = [
{
stage: 'ascent',
trajectory: [
{ altitude: 0, datetime: '2026-01-01T00:00:00Z', latitude: 62.0, longitude: 129.0 },
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 }
]
},
{
stage: 'descent',
trajectory: [
{ altitude: 30000, datetime: '2026-01-01T01:00:00Z', latitude: 62.1, longitude: 129.2 },
{ altitude: 0, datetime: '2026-01-01T01:30:00Z', latitude: 62.05, longitude: 129.4 }
]
}
];
const prediction = parsePrediction(stages);
it('matches each sample to the closest prediction point', () => {
const devs = computeDeviations(points, prediction);
expect(devs).toHaveLength(2);
// First sample sits exactly on the predicted launch point.
expect(devs[0].horizontal).toBeCloseTo(0, 6);
expect(devs[0].vertical).toBe(0);
// Second sample is offset from the prediction, both laterally and in altitude.
expect(devs[1].horizontal).toBeGreaterThan(0);
expect(devs[1].altActual).toBe(15000);
});
it('skips samples outside the prediction window', () => {
const outside: TelemetryPoint[] = [
{
latitude: 62,
longitude: 129,
altitude: 0,
datetime: '2025-12-31T00:00:00Z', // a day early
payload: '{}'
}
];
expect(computeDeviations(outside, prediction)).toHaveLength(0);
});
it('returns nothing without telemetry', () => {
expect(computeDeviations([], prediction)).toEqual([]);
});
});

View file

@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
// Import the store module directly: the feature barrel re-exports .svelte
// components, which the unit runner (no Svelte plugin) cannot transform.
import { timelineStore, positionAt } from '$features/timeline/store';
beforeEach(() => {
timelineStore.pause();
timelineStore.setRange(0, 0);
timelineStore.setSpeed(1);
timelineStore.setMarkers([]);
});
describe('timelineStore', () => {
it('clamps seek to the current range', () => {
timelineStore.setRange(0, 10_000);
timelineStore.seek(99_999);
expect(get(timelineStore).time).toBe(10_000);
timelineStore.seek(-5);
expect(get(timelineStore).time).toBe(0);
});
it('clamps the current time when the range shrinks', () => {
timelineStore.setRange(0, 10_000);
timelineStore.seek(9_000);
timelineStore.setRange(0, 4_000);
expect(get(timelineStore).time).toBe(4_000);
});
it('ignores play when there is no duration', () => {
timelineStore.play();
expect(get(timelineStore).playing).toBe(false);
});
it('plays and pauses when a duration exists', () => {
timelineStore.setRange(0, 10_000);
timelineStore.play();
expect(get(timelineStore).playing).toBe(true);
timelineStore.pause();
expect(get(timelineStore).playing).toBe(false);
});
it('rewinds to min on reset', () => {
timelineStore.setRange(0, 10_000);
timelineStore.seek(7_000);
timelineStore.reset();
expect(get(timelineStore).time).toBe(0);
expect(get(timelineStore).playing).toBe(false);
});
it('stores the playback speed', () => {
timelineStore.setSpeed(5);
expect(get(timelineStore).speed).toBe(5);
});
});
describe('positionAt', () => {
const path: [number, number, ...number[]][] = [
[60, 100],
[62, 102],
[64, 104]
];
it('returns the first point at t=0 and the last at t=duration', () => {
expect(positionAt(path, 0, 1000)).toEqual([60, 100]);
expect(positionAt(path, 1000, 1000)).toEqual([64, 104]);
});
it('interpolates linearly between samples', () => {
// Quarter of the way = halfway along the first segment.
const p = positionAt(path, 250, 1000)!;
expect(p[0]).toBeCloseTo(61, 6);
expect(p[1]).toBeCloseTo(101, 6);
});
it('clamps outside the flight window and handles an empty path', () => {
expect(positionAt(path, -100, 1000)).toEqual([60, 100]);
expect(positionAt(path, 99_999, 1000)).toEqual([64, 104]);
expect(positionAt([], 0, 1000)).toBeNull();
});
});

70
tests/unit/wind.test.ts Normal file
View file

@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { decodeWindField, createWindInterpolator } from '$domain';
import type { WindField, WindHeader } from '$domain';
/**
* A 2×2 grid that reproduces the predictor's real conventions: longitudes in the
* 0..360 frame (358 0, i.e. -2° 0°) and a northsouth scan (la1 > la2) with
* a *positive* dy. Getting either wrong misplaces every vector.
*/
const header: WindHeader = {
parameterUnit: 'm.s-1',
parameterNumberName: 'wind',
nx: 2,
ny: 2,
lo1: 358,
la1: 10,
lo2: 0,
la2: 8,
dx: 2,
dy: 2,
refTime: '2026-01-01T00:00:00Z'
};
// Row-major: [ (10,-2) (10,0) ] then [ (8,-2) (8,0) ]
const field: WindField = [
{ header, data: [10, 0, 0, 0] }, // U (eastward)
{ header, data: [0, 10, 0, 0] } // V (northward)
];
describe('decodeWindField', () => {
it('places vectors using the grid extent, not raw dx/dy', () => {
const v = decodeWindField(field);
expect(v).toHaveLength(4);
// First point: la1/lo1 wrapped from 358 into (-180, 180].
expect(v[0].lat).toBe(10);
expect(v[0].lng).toBe(-2);
// Second column steps east across the 0/360 seam.
expect(v[1].lng).toBe(0);
// Second row scans southward despite dy being positive.
expect(v[2].lat).toBe(8);
});
it('derives speed and the bearing the wind blows TO', () => {
const v = decodeWindField(field);
expect(v[0].speed).toBeCloseTo(10, 6);
expect(v[0].bearing).toBeCloseTo(90, 6); // pure U → eastward
expect(v[1].bearing).toBeCloseTo(0, 6); // pure V → northward
});
});
describe('createWindInterpolator', () => {
it('returns the exact grid value at a node', () => {
const at = createWindInterpolator(field);
expect(at(-2, 10)).toEqual([10, 0]);
expect(at(0, 10)).toEqual([0, 10]);
});
it('blends between nodes', () => {
const at = createWindInterpolator(field);
const mid = at(-1, 10)!;
expect(mid[0]).toBeCloseTo(5, 6);
expect(mid[1]).toBeCloseTo(5, 6);
});
it('returns null outside the grid', () => {
const at = createWindInterpolator(field);
expect(at(-2, 20)).toBeNull(); // north of la1
expect(at(-2, 0)).toBeNull(); // south of la2
});
});