Initial
This commit is contained in:
commit
b43955e0d9
162 changed files with 15425 additions and 0 deletions
55
tests/unit/authStore.test.ts
Normal file
55
tests/unit/authStore.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
tests/unit/boundingBox.test.ts
Normal file
35
tests/unit/boundingBox.test.ts
Normal 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
84
tests/unit/client.test.ts
Normal 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
87
tests/unit/curve.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
tests/unit/ensemble.test.ts
Normal file
49
tests/unit/ensemble.test.ts
Normal 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
66
tests/unit/export.test.ts
Normal 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 & <B>');
|
||||
});
|
||||
});
|
||||
|
||||
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
13
tests/unit/geo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
tests/unit/history.test.ts
Normal file
69
tests/unit/history.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
tests/unit/persisted.test.ts
Normal file
47
tests/unit/persisted.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
41
tests/unit/prediction.test.ts
Normal file
41
tests/unit/prediction.test.ts
Normal 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
144
tests/unit/profile.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
32
tests/unit/provenance.test.ts
Normal file
32
tests/unit/provenance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
tests/unit/settings.test.ts
Normal file
43
tests/unit/settings.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
88
tests/unit/telemetry.test.ts
Normal file
88
tests/unit/telemetry.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
81
tests/unit/timeline.test.ts
Normal file
81
tests/unit/timeline.test.ts
Normal 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
70
tests/unit/wind.test.ts
Normal 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 north→south 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue