Initial
This commit is contained in:
commit
b43955e0d9
162 changed files with 15425 additions and 0 deletions
26
tests/e2e/account.spec.ts
Normal file
26
tests/e2e/account.spec.ts
Normal 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
54
tests/e2e/admin.spec.ts
Normal 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
24
tests/e2e/auth.spec.ts
Normal 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
52
tests/e2e/curve.spec.ts
Normal 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();
|
||||
});
|
||||
38
tests/e2e/ensemble.spec.ts
Normal file
38
tests/e2e/ensemble.spec.ts
Normal 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
63
tests/e2e/export.spec.ts
Normal 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
68
tests/e2e/flights.spec.ts
Normal 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
66
tests/e2e/history.spec.ts
Normal 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
41
tests/e2e/library.spec.ts
Normal 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
15
tests/e2e/map.spec.ts
Normal 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 });
|
||||
});
|
||||
16
tests/e2e/prediction.spec.ts
Normal file
16
tests/e2e/prediction.spec.ts
Normal 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
89
tests/e2e/profile.spec.ts
Normal 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 });
|
||||
});
|
||||
22
tests/e2e/register.spec.ts
Normal file
22
tests/e2e/register.spec.ts
Normal 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();
|
||||
});
|
||||
51
tests/e2e/scenarios.spec.ts
Normal file
51
tests/e2e/scenarios.spec.ts
Normal 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);
|
||||
});
|
||||
39
tests/e2e/settings.spec.ts
Normal file
39
tests/e2e/settings.spec.ts
Normal 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
66
tests/e2e/smoke.spec.ts
Normal 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();
|
||||
});
|
||||
75
tests/e2e/timeline.spec.ts
Normal file
75
tests/e2e/timeline.spec.ts
Normal 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']);
|
||||
});
|
||||
61
tests/e2e/tracking.spec.ts
Normal file
61
tests/e2e/tracking.spec.ts
Normal 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
26
tests/e2e/wind.spec.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue