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

26
tests/e2e/account.spec.ts Normal file
View file

@ -0,0 +1,26 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page, user = 'demo', pass = 'demo') {
await page.goto('/login/');
await page.fill('#lg-username', user);
await page.fill('#lg-password', pass);
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('account page loads the profile and updates the email', async ({ page }) => {
await login(page);
await page.goto('/user/account/');
await expect(page.locator('#ac-email')).toHaveValue('demo@example.com');
await page.fill('#ac-email', 'demo2@example.com');
await page.getByRole('button', { name: /save|сохранить/i }).click();
await expect(page.locator('.toast')).toBeVisible();
});
test('delete-data requires an inline confirm', async ({ page }) => {
await login(page);
await page.goto('/user/account/');
await page.getByRole('button', { name: /delete my data|удалить мои данные/i }).click();
await page.getByTestId('confirm-delete-data').click();
await expect(page.locator('.toast')).toBeVisible();
});

54
tests/e2e/admin.spec.ts Normal file
View file

@ -0,0 +1,54 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page, user: string, pass: string) {
await page.goto('/login/');
await page.fill('#lg-username', user);
await page.fill('#lg-password', pass);
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('a non-staff user cannot reach the admin panel', async ({ page }) => {
await login(page, 'demo', 'demo');
// The menu entry is staff-only...
await page.getByTestId('nav-menu').click();
await expect(page.getByTestId('nav-admin')).toHaveCount(0);
// ...and visiting the route directly redirects away from it.
await page.goto('/admin/');
await expect(page).toHaveURL(/\/app/);
await expect(page.getByTestId('admin-table')).toHaveCount(0);
});
test('staff can list, search and deactivate a user', async ({ page }) => {
await login(page, 'admin', 'admin');
await page.getByTestId('nav-menu').click();
await page.getByTestId('nav-admin').click();
await expect(page.getByTestId('admin-table')).toBeVisible();
// Both seeded users are listed.
await expect(page.getByTestId('admin-row')).toHaveCount(2);
// Search narrows the list.
await page.getByTestId('admin-search').fill('demo');
await expect(page.getByTestId('admin-row')).toHaveCount(1);
// Deactivation asks for an inline confirm first.
await page.getByTestId('admin-deactivate').click();
await page.getByTestId('admin-confirm-deactivate').click();
await expect(page.getByTestId('admin-state')).toContainText(/отключён|disabled/i);
// And it can be reversed.
await page.getByTestId('admin-activate').click();
await expect(page.getByTestId('admin-state')).toContainText(/активен|active/i);
});
test('staff cannot deactivate their own account', async ({ page }) => {
await login(page, 'admin', 'admin');
await page.goto('/admin/');
await page.getByTestId('admin-search').fill('admin');
await expect(page.getByTestId('admin-row')).toHaveCount(1);
// The row is marked as "you" and offers no deactivate control.
await expect(page.getByTestId('admin-row')).toContainText(/вы|you/i);
await expect(page.getByTestId('admin-deactivate')).toHaveCount(0);
});

24
tests/e2e/auth.spec.ts Normal file
View file

@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
test('anonymous visitor is redirected to /login', async ({ page }) => {
await page.goto('/app/');
await expect(page).toHaveURL(/\/login/);
});
test('valid credentials log in and reach the app', async ({ page }) => {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
// Who you are signed in as lives behind the burger.
await page.getByTestId('nav-menu').click();
await expect(page.getByTestId('app-heading')).toContainText('demo');
});
test('invalid credentials show an inline error', async ({ page }) => {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'wrong');
await page.click('button[type=submit]');
await expect(page.getByTestId('login-error')).toBeVisible();
});

52
tests/e2e/curve.spec.ts Normal file
View file

@ -0,0 +1,52 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
async function openCurveEditor(page: Page) {
// The builder (and so the curve editor) is desktop-only.
await page.setViewportSize({ width: 1400, height: 900 });
await login(page);
// The curve editor is a leaf modal opened from a piecewise stage inside the
// inline profile builder — never a standalone entry point (guidelines §6.4).
await page.selectOption('#cp-profile', 'custom_profile');
await expect(page.getByTestId('profile-builder')).toBeVisible();
await page.getByTestId('stage-solver').first().selectOption('piecewise');
await page.getByTestId('stage-open-curve').click();
await expect(page.getByTestId('curve-modal')).toBeVisible();
}
test('the curve editor imports CSV and applies the curve', async ({ page }) => {
await openCurveEditor(page);
await expect(page.getByTestId('curve-chart')).toBeVisible();
await page.getByTestId('curve-csv-toggle').click();
await page.getByTestId('curve-csv').fill('time_s,altitude_m,rate_ms\n0,0,0\n600,3000,5\n1200,9000,10');
await page.getByTestId('curve-csv-import').click();
// Three imported points, and the implied-rate readout follows them.
await expect(page.getByTestId('curve-rows').locator('tr')).toHaveCount(3);
await expect(page.getByTestId('curve-rates')).toContainText('5.00 m/s');
await expect(page.getByTestId('curve-rates')).toContainText('10.00 m/s');
await page.getByTestId('curve-apply').click();
await expect(page.getByTestId('curve-modal')).toHaveCount(0);
// The applied curve is recorded on the stage (badge shows the point count).
await expect(page.getByTestId('stage-open-curve')).toContainText('3');
});
test('a non-monotonic curve is flagged and cannot be applied', async ({ page }) => {
await openCurveEditor(page);
await page.getByTestId('curve-csv-toggle').click();
// Time goes backwards on the third row.
await page.getByTestId('curve-csv').fill('0,0,0\n600,3000,5\n300,9000,10');
await page.getByTestId('curve-csv-import').click();
await expect(page.getByTestId('curve-problems')).toBeVisible();
await expect(page.getByTestId('curve-apply')).toBeDisabled();
});

View file

@ -0,0 +1,38 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('an ensemble run draws a probabilistic landing footprint', async ({ page }) => {
await login(page);
// The run is asynchronous: it returns a job, then the UI polls until it settles.
await page.getByTestId('run-ensemble-btn').click();
await expect(page.getByTestId('ensemble-summary')).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('ensemble-summary')).toContainText('21');
// The footprint is its own scene: member scatter + mean + 95% ellipse.
const ensembleLayers = () =>
page.evaluate(() => {
const map = (window as unknown as { _sfMap?: { getStyle(): { layers: { id: string }[] } } })
._sfMap;
if (!map) return [] as string[];
return map
.getStyle()
.layers.map((l) => l.id)
.filter((id) => id.startsWith('ens/'))
.map((id) => id.split('__')[1]);
});
await expect.poll(ensembleLayers).toContain('ellipse');
await expect.poll(ensembleLayers).toContain('mean');
// One dot per member.
await expect
.poll(async () => (await ensembleLayers()).filter((id) => id.startsWith('m-')).length)
.toBe(21);
});

63
tests/e2e/export.spec.ts Normal file
View file

@ -0,0 +1,63 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('a computed scenario exports as GPX, KML and CSV', async ({ page }) => {
await login(page);
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
for (const fmt of ['gpx', 'kml', 'csv'] as const) {
const download = page.waitForEvent('download');
await page.getByTestId(`export-${fmt}`).click();
const file = await download;
expect(file.suggestedFilename()).toMatch(new RegExp(`\\.${fmt}$`));
}
});
test('a shared link opens read-only without a session and can be revoked', async ({
page,
context
}) => {
await login(page);
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
// Mint a share link from the history row.
await page.goto('/user/predictions/');
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByTestId('history-share').first().click();
await expect(page.locator('.toast')).toBeVisible();
const url = await page.evaluate(() => navigator.clipboard.readText());
expect(url).toMatch(/\/p\/[0-9a-f-]{36}\//);
// The link resolves for a visitor with no session at all.
const anon = await context.browser()!.newContext();
const anonPage = await anon.newPage();
await anonPage.goto(url);
await expect(anonPage.getByTestId('shared-summary')).toBeVisible({ timeout: 15000 });
// It is read-only: no run controls, but the data is still exportable.
await expect(anonPage.getByTestId('run-btn')).toHaveCount(0);
await expect(anonPage.getByTestId('shared-export-gpx')).toBeVisible();
// Revoking breaks the link. The share endpoint is keyed by the prediction id
// (the token only grants read access), so take the id from the shared page.
const predictionId = await anonPage.evaluate(async (u: string) => {
const token = u.split('/p/')[1].replace(/\/$/, '');
const res = await fetch(`/api/predictions/shared/${token}/`);
return (await res.json()).id as string;
}, url);
const revoke = await page.request.delete(`/api/predictions/${predictionId}/share/`);
expect(revoke.ok()).toBe(true);
await anonPage.reload();
await expect(anonPage.getByTestId('shared-error')).toBeVisible();
await anon.close();
});

68
tests/e2e/flights.spec.ts Normal file
View file

@ -0,0 +1,68 @@
import { test, expect, type Page } from '@playwright/test';
const PUBLIC_ID = '550e8400-e29b-41d4-a716-446655440000'; // "ЯКС-1", public
const UNLISTED_ID = '550e8400-e29b-41d4-a716-446655440001'; // "Тестовый", unlisted
const PRIVATE_ID = '550e8400-e29b-41d4-a716-446655440002'; // "Секретный", admin-owned
async function login(page: Page, user: string, pass: string) {
await page.goto('/login/');
await page.fill('#lg-username', user);
await page.fill('#lg-password', pass);
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('the public flight list is reachable without signing in', async ({ page }) => {
await page.goto('/flights/');
await expect(page.getByTestId('flights-list')).toBeVisible();
// Only public flights are listed — unlisted and private ones are not.
await expect(page.getByTestId('flights-list')).toContainText('ЯКС-1');
await expect(page.getByTestId('flights-list')).not.toContainText('Тестовый');
await expect(page.getByTestId('flights-list')).not.toContainText('Секретный');
});
test('a shared flight link opens anonymously and streams telemetry', async ({ page }) => {
await page.goto(`/track/${PUBLIC_ID}/`);
await expect(page.getByTestId('track-name')).toHaveText('ЯКС-1');
// History loads, then the socket takes over — no session involved.
await expect(page.getByTestId('track-readout')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('track-status')).toContainText(/на связи|live/i, {
timeout: 15000
});
});
test('an unlisted flight opens by link but is absent from the list', async ({ page }) => {
await page.goto(`/track/${UNLISTED_ID}/`);
await expect(page.getByTestId('track-name')).toHaveText('Тестовый');
});
test('a private flight is not readable by others', async ({ page }) => {
// Anonymous: hidden.
await page.goto(`/track/${PRIVATE_ID}/`);
await expect(page.getByTestId('track-error')).toBeVisible();
// A signed-in non-owner: still hidden.
await login(page, 'demo', 'demo');
await page.goto(`/track/${PRIVATE_ID}/`);
await expect(page.getByTestId('track-error')).toBeVisible();
});
test('the owner sees a private flight and can change its tier', async ({ page }) => {
await login(page, 'admin', 'admin');
await page.goto(`/track/${PRIVATE_ID}/`);
await expect(page.getByTestId('track-name')).toHaveText('Секретный');
// Owners get the privacy control; it takes effect immediately.
const privacy = page.getByTestId('track-privacy');
await expect(privacy).toHaveValue('private');
await privacy.selectOption('public');
await expect(page.getByTestId('track-card')).toContainText(/публичный|public/i);
// Now it shows up on the public map, and reverting hides it again.
await page.goto('/flights/');
await expect(page.getByTestId('flights-list')).toContainText('Секретный');
await page.goto(`/track/${PRIVATE_ID}/`);
await page.getByTestId('track-privacy').selectOption('private');
await page.goto('/flights/');
await expect(page.getByTestId('flights-list')).not.toContainText('Секретный');
});

66
tests/e2e/history.spec.ts Normal file
View file

@ -0,0 +1,66 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
/** Every run is persisted server-side, so running one seeds the history. */
async function runPrediction(page: Page) {
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
}
test('a run appears in history with its landing and flight time', async ({ page }) => {
await login(page);
await runPrediction(page);
await page.goto('/user/predictions/');
await expect(page.getByTestId('history-table')).toBeVisible();
const rows = page.getByTestId('history-table').locator('tbody tr');
await expect(rows.first()).toBeVisible();
// Mock history is shared across the run and may also hold ensemble rows, so
// assert that at least one single-run row carries a derived flight time.
await expect(rows.filter({ hasText: /\d+ч \d+мин|\dh \d+min/ }).first()).toBeVisible();
await page.getByTestId('history-detail').first().click();
await expect(page.getByTestId('history-detail-card')).toContainText('complete');
});
test('deleting a prediction offers undo and restores it', async ({ page }) => {
await login(page);
await runPrediction(page);
await page.goto('/user/predictions/');
// The mock keeps history in dev-server state shared by the whole run, so
// assert on the change rather than an absolute count.
const rows = page.getByTestId('history-table').locator('tbody tr');
await expect(rows.first()).toBeVisible();
const before = await rows.count();
await page.getByTestId('history-delete').first().click();
await expect(page.getByTestId('undo-bar')).toBeVisible();
if (before === 1) {
await expect(page.getByTestId('history-empty')).toBeVisible();
} else {
await expect(rows).toHaveCount(before - 1);
}
// Undo cancels the pending server call and restores the row.
await page.getByTestId('undo-delete').click();
await expect(page.getByTestId('history-table').locator('tbody tr')).toHaveCount(before);
});
test('a past run can be loaded back into a new scenario', async ({ page }) => {
await login(page);
await runPrediction(page);
await page.goto('/user/predictions/');
await page.getByTestId('history-load').first().click();
// It lands on the map view with an extra scenario built from the stored request.
await expect(page).toHaveURL(/\/app/);
await expect(page.getByTestId('select-scenario')).toHaveCount(2);
});

41
tests/e2e/library.spec.ts Normal file
View file

@ -0,0 +1,41 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('loading a saved point links it, editing marks it modified', async ({ page }) => {
await login(page);
const chip = page.getByTestId('point-chip');
await expect(chip).toContainText(/Своя точка|Custom/);
await page.selectOption('#cp-point', '1'); // seeded "sjsa-start"
await expect(chip).toContainText('sjsa-start');
await expect(chip).not.toContainText(/изменено|modified/);
// Editing a linked value dirties it — the saved point itself is untouched.
await page.fill('#cp-lat', '63.5');
await expect(chip).toContainText(/изменено|modified/);
await expect(page.getByTestId('point-update')).toBeEnabled();
});
test('applying a template shows its provenance chip', async ({ page }) => {
await login(page);
await page.selectOption('#cp-template', '1'); // seeded "Высотный"
await expect(page.getByTestId('template-chip')).toContainText('Высотный');
// The template's burst altitude was copied into the live scenario.
await expect(page.locator('#cp-burst')).toHaveValue('33000');
});
test('save-as-new adds a point to the library', async ({ page }) => {
await login(page);
await page.getByTestId('point-save-as').click();
await page.getByTestId('point-name').fill('e2e-point');
await page.getByTestId('point-save').click();
await expect(page.getByTestId('point-chip')).toContainText('e2e-point');
await expect(page.locator('#cp-point option', { hasText: 'e2e-point' })).toHaveCount(1);
});

15
tests/e2e/map.spec.ts Normal file
View file

@ -0,0 +1,15 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('the map renders on /app after login', async ({ page }) => {
await login(page);
// MapLibre mounts its WebGL canvas once the style loads.
await expect(page.locator('.maplibregl-canvas')).toBeVisible({ timeout: 20000 });
});

View file

@ -0,0 +1,16 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('configure + run a prediction and see the flight time', async ({ page }) => {
await login(page);
// A default scenario is auto-created on /app; run it via the Conditions panel.
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
});

89
tests/e2e/profile.spec.ts Normal file
View file

@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
/** The builder is desktop-only, so pin a wide viewport before revealing it. */
async function openBuilder(page: Page) {
await page.setViewportSize({ width: 1400, height: 900 });
await login(page);
await page.selectOption('#cp-profile', 'custom_profile');
await expect(page.getByTestId('profile-builder')).toBeVisible();
}
test('the builder is revealed only by the custom profile', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await login(page);
// Progressive disclosure: the standard profile shows no stage list.
await expect(page.getByTestId('profile-builder')).toHaveCount(0);
await page.selectOption('#cp-profile', 'custom_profile');
await expect(page.getByTestId('profile-builder')).toBeVisible();
// It starts from the standard flight expressed as stages.
await expect(page.getByTestId('stage-card')).toHaveCount(2);
});
test('stages can be added, reordered and removed', async ({ page }) => {
await openBuilder(page);
const names = () => page.getByTestId('stage-name').evaluateAll((els) =>
els.map((e) => (e as HTMLInputElement).value)
);
expect(await names()).toEqual(['ascent', 'descent']);
await page.getByTestId('add-stage').click();
await expect(page.getByTestId('stage-card')).toHaveCount(3);
// Reordering is by buttons so it stays keyboard-reachable.
await page.getByTestId('stage-down').first().click();
expect(await names()).toEqual(['descent', 'ascent', 'stage-3']);
await page.getByTestId('stage-delete').last().click();
await expect(page.getByTestId('stage-card')).toHaveCount(2);
});
test('a stage with no exit is reported as invalid', async ({ page }) => {
await openBuilder(page);
await expect(page.getByTestId('profile-problems')).toHaveCount(0);
// Remove the ascent stage's only "advance when" condition.
await page.getByTestId('advance-row').first().getByRole('button').click();
await expect(page.getByTestId('profile-problems')).toContainText(/условия выхода|no exit/i);
});
test('an abort constraint must point at an existing stage', async ({ page }) => {
await openBuilder(page);
await page.getByTestId('add-abort').first().click();
// It defaults to falling back to the first stage, which is itself — pick another.
const fallback = page.getByTestId('abort-fallback').first();
await fallback.selectOption('descent');
await expect(page.getByTestId('profile-problems')).toHaveCount(0);
await fallback.selectOption('');
await expect(page.getByTestId('profile-problems')).toContainText(/Резервная|fallback/i);
});
test('a custom profile runs through the v2 endpoint and draws a trajectory', async ({ page }) => {
await openBuilder(page);
// Change the ascent rate through the stage, not the flat form.
await page.getByTestId('stage-rate').first().fill('7');
const v2 = page.waitForRequest(
(r) => r.url().includes('/api/predictions/v2/') && r.method() === 'POST'
);
await page.getByTestId('run-btn').click();
const request = await v2;
// The request carries the stage list in the predictor's own shape.
const body = request.postDataJSON();
expect(body.profile).toHaveLength(2);
expect(body.profile[0].model).toMatchObject({ type: 'constant_rate', rate: 7 });
expect(body.profile[1].model.type).toBe('parachute_descent');
expect(body.launch).toHaveProperty('time');
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
});

View file

@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
test('a visitor can register and lands in the app', async ({ page }) => {
await page.goto('/register/');
await page.fill('#rg-username', 'newbie');
await page.fill('#rg-email', 'newbie@example.com');
await page.fill('#rg-password', 's3curePass!');
await page.fill('#rg-password2', 's3curePass!');
await page.click('button[type=submit]');
await page.getByTestId('nav-menu').click();
await expect(page.getByTestId('app-heading')).toContainText('newbie');
});
test('duplicate username shows an inline error', async ({ page }) => {
await page.goto('/register/');
await page.fill('#rg-username', 'demo'); // seeded in the mock
await page.fill('#rg-email', 'dupe@example.com');
await page.fill('#rg-password', 's3curePass!');
await page.fill('#rg-password2', 's3curePass!');
await page.click('button[type=submit]');
await expect(page.getByTestId('register-error')).toBeVisible();
});

View file

@ -0,0 +1,51 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
/** Trajectory layers currently on the map, one scene per scenario. */
const trackScenes = (page: Page) =>
page.evaluate(() => {
const map = (window as unknown as { _sfMap?: { getStyle(): { layers: { id: string }[] } } })
._sfMap;
if (!map) return [];
return [
...new Set(
map
.getStyle()
.layers.map((l) => l.id)
.filter((id) => id.startsWith('sc/'))
.map((id) => id.split('__')[0])
)
];
});
test('two scenarios overlay independently and visibility toggles one off', async ({ page }) => {
await login(page);
// First scenario (auto-created on landing).
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
await expect.poll(() => trackScenes(page)).toHaveLength(1);
// A second scenario with different parameters draws its own scene.
await page.getByTestId('add-scenario').click();
await page.fill('#cp-burst', '25000');
await page.getByTestId('run-btn').click();
await expect.poll(() => trackScenes(page)).toHaveLength(2);
// Both landings are listed for comparison, with a spread between them.
await page.getByTestId('tab-compare').click();
await expect(page.getByTestId('compare-table').locator('tbody tr')).toHaveCount(2);
await expect(page.getByTestId('compare-spread')).toBeVisible();
// Hiding one scenario removes only its layers.
await page.getByTestId('tab-scenarios').click();
await page.getByTestId('toggle-visibility').first().click();
await expect.poll(() => trackScenes(page)).toHaveLength(1);
});

View file

@ -0,0 +1,39 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('settings persist across a reload and switch the UI language', async ({ page }) => {
await login(page);
await page.getByTestId('tab-settings').click();
// Coordinate format is a display-only setting; it must survive a reload.
await page.getByTestId('set-format.coords').selectOption('dms');
await page.getByTestId('set-format.units').selectOption('imperial');
// Locale takes effect immediately (dictionaries swap in place).
await page.getByTestId('set-locale').selectOption('en');
await expect(page.getByTestId('tab-settings')).toHaveText('Settings');
await page.reload();
await page.getByTestId('tab-settings').click();
await expect(page.getByTestId('set-format.coords')).toHaveValue('dms');
await expect(page.getByTestId('set-format.units')).toHaveValue('imperial');
await expect(page.getByTestId('set-locale')).toHaveValue('en');
});
test('the comparison tab reports landing coordinates for a run scenario', async ({ page }) => {
await login(page);
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
await page.getByTestId('tab-compare').click();
await expect(page.getByTestId('compare-table')).toBeVisible();
// One scenario → a row, but no spread figure yet (that needs two).
await expect(page.getByTestId('compare-spread')).toHaveCount(0);
});

66
tests/e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,66 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('every panel tab renders without a console error', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
await login(page);
await expect(page.locator('.maplibregl-canvas')).toBeVisible({ timeout: 20000 });
for (const tab of ['scenarios', 'compare', 'wind', 'settings']) {
await page.getByTestId(`tab-${tab}`).click();
await expect(page.getByTestId(`tab-${tab}`)).toHaveAttribute('aria-current', 'page');
}
// The timeline and both collapse handles are always present.
await expect(page.getByTestId('timeline')).toBeVisible();
await expect(page.getByTestId('toggle-left')).toBeVisible();
await expect(page.getByTestId('toggle-right')).toBeVisible();
expect(errors).toEqual([]);
});
test('panels collapse, reopen, and remember their state across a reload', async ({ page }) => {
await login(page);
await expect(page.getByTestId('panel-left')).toBeVisible();
await page.getByTestId('toggle-left').click();
await expect(page.getByTestId('panel-left')).toHaveCount(0);
await expect(page.getByTestId('toggle-left')).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(page.getByTestId('panel-left')).toHaveCount(0);
await page.getByTestId('toggle-left').click();
await expect(page.getByTestId('panel-left')).toBeVisible();
});
test('both columns stay open on a narrow viewport, side by side', async ({ page }) => {
await login(page);
await page.setViewportSize({ width: 800, height: 720 });
// Below 1024px the columns share the width instead of excluding each other.
await expect(page.getByTestId('panel-left')).toBeVisible();
await expect(page.getByTestId('panel-right')).toBeVisible();
// ...and they do not overlap: the map keeps a strip between them.
const left = await page.getByTestId('panel-left').boundingBox();
const right = await page.getByTestId('panel-right').boundingBox();
expect(left).not.toBeNull();
expect(right).not.toBeNull();
expect(left!.x + left!.width).toBeLessThan(right!.x);
// Closing one leaves the other untouched.
await page.getByTestId('toggle-right').click();
await expect(page.getByTestId('panel-right')).toHaveCount(0);
await expect(page.getByTestId('panel-left')).toBeVisible();
});

View file

@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('the timeline activates after a run and scrubs the flight', async ({ page }) => {
await login(page);
// No result yet → the scrubber is inert.
const range = page.getByTestId('tl-range');
await expect(range).toBeDisabled();
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
// A result sets the global domain, enabling playback.
await expect(range).toBeEnabled();
await expect(page.getByTestId('tl-elapsed')).toHaveText('00:00:00');
// Seeking moves the clock (and with it every visible scenario's cursor).
await range.fill('3600000'); // one hour into the flight
await expect(page.getByTestId('tl-elapsed')).toHaveText('01:00:00');
// Play flips the control to pause, then pausing restores it.
await page.getByTestId('tl-play').click();
await expect(page.getByTestId('tl-pause')).toBeVisible();
await page.getByTestId('tl-pause').click();
await expect(page.getByTestId('tl-play')).toBeVisible();
});
/**
* Regression: durations are not whole seconds, so a stepped slider stopped just
* short of max and the cursor stayed in its "in flight" state forever.
*/
test('scrubbing to the very end lands the cursor', async ({ page }) => {
await login(page);
// A rate that yields a duration which is NOT a whole number of seconds —
// with a 1s-stepped slider the thumb stops short of max and never lands.
await page.fill('#cp-ascent', '5.3');
await page.getByTestId('run-btn').click();
await expect(page.getByTestId('flight-time')).toBeVisible({ timeout: 15000 });
const cursorLayers = () =>
page.evaluate(() => {
// `_sfMap` is the raw MapLibre instance exposed by <Map /> in dev builds.
const map = (window as unknown as { _sfMap: { getStyle(): { layers: { id: string }[] } } })
._sfMap;
return map
.getStyle()
.layers.map((l) => l.id)
.filter((id) => id.startsWith('cursor/'))
.map((id) => id.split('__')[1]);
});
const range = page.getByTestId('tl-range');
// The scenario card updates before the renderer publishes the clock domain,
// so wait for the scrubber to go live or `max` still reads 0.
await expect(range).toBeEnabled();
const max = await range.getAttribute('max');
expect(Number(max)).toBeGreaterThan(0);
// Rendering is effect-driven, so poll rather than asserting once.
// Mid-flight: the animated (ring + core) marker.
await range.fill(String(Number(max) / 2));
await expect.poll(cursorLayers).toEqual(['marker-ring', 'marker-core']);
// At the end: swapped for the static landed marker.
await range.fill(String(max));
await expect.poll(cursorLayers).toEqual(['marker-core']);
});

View file

@ -0,0 +1,61 @@
import { test, expect, type Page } from '@playwright/test';
const SAT_ID = '550e8400-e29b-41d4-a716-446655440000';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('an invalid satellite id is rejected before connecting', async ({ page }) => {
await login(page);
// Tracking is its own mode now, reached from the header.
await page.getByTestId('mode-track').click();
await page.getByTestId('tr-advanced').locator('summary').click();
await page.getByTestId('tr-id').fill('not-a-uuid');
await page.getByTestId('tr-connect').click();
await expect(page.getByTestId('tr-error')).toBeVisible();
await expect(page.getByTestId('tr-status')).toContainText(/ошибка|error/i);
});
test('following a satellite loads history, goes live, and draws the track', async ({ page }) => {
await login(page);
// Tracking is its own mode now, reached from the header.
await page.getByTestId('mode-track').click();
await page.getByTestId('tr-advanced').locator('summary').click();
await page.getByTestId('tr-id').fill(SAT_ID);
await page.getByTestId('tr-connect').click();
// History (20 packets) arrives over REST, then the socket reports "live".
await expect(page.getByTestId('tr-status')).toContainText(/на связи|live/i, { timeout: 15000 });
await expect(page.getByTestId('tr-readout')).toBeVisible();
// Returns 0 until the map's style has loaded, so the polls below just wait.
const trackLayerCount = () =>
page.evaluate(() => {
const map = (window as unknown as { _sfMap?: { getStyle(): { layers: { id: string }[] } } })
._sfMap;
if (!map) return 0;
return map
.getStyle()
.layers.map((l) => l.id)
.filter((id) => id.startsWith('telemetry__')).length;
});
// The actual track is drawn in its own scene (line + current-position marker).
await expect.poll(trackLayerCount).toBeGreaterThan(0);
// Live packets keep arriving over the WebSocket (~1/s), extending the track.
const before = await page.getByTestId('tr-readout').innerText();
await expect
.poll(async () => page.getByTestId('tr-readout').innerText(), { timeout: 15000 })
.not.toBe(before);
// Disconnecting clears the track and returns to the idle state.
await page.getByTestId('tr-disconnect').click();
await expect(page.getByTestId('tr-status')).toContainText(/не подключено|not connected/i);
await expect.poll(trackLayerCount).toBe(0);
});

26
tests/e2e/wind.spec.ts Normal file
View file

@ -0,0 +1,26 @@
import { test, expect, type Page } from '@playwright/test';
async function login(page: Page) {
await page.goto('/login/');
await page.fill('#lg-username', 'demo');
await page.fill('#lg-password', 'demo');
await page.click('button[type=submit]');
await expect(page.getByTestId('nav-menu')).toBeVisible();
}
test('the wind layer toggles on, loads a field, and toggles off', async ({ page }) => {
await login(page);
// Off by default: no particle canvas, no status line.
await expect(page.locator('canvas.wind-particles')).toHaveCount(0);
await page.getByTestId('tab-wind').click();
await page.getByTestId('wind-toggle').check();
await expect(page.getByTestId('wind-status')).toContainText(/загружено|loaded/i, {
timeout: 15000
});
await expect(page.locator('canvas.wind-particles')).toHaveCount(1);
await page.getByTestId('wind-toggle').uncheck();
await expect(page.locator('canvas.wind-particles')).toHaveCount(0);
});

View file

@ -0,0 +1,5 @@
// Stub for SvelteKit's virtual `$app/navigation` module under Vitest.
// Tests that care about navigation override these with vi.mock().
export const goto = async () => {};
export const invalidate = async () => {};
export const invalidateAll = async () => {};

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
});
});