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