From 7f18fcb6db78ab45d6a8f44a5779215307797c02 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 7 Oct 2026 17:41:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=B1=D1=80=D0=B0=D1=81=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20speed-=D0=BF=D1=80=D0=BE=D0=B1=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 7 +--- backend/src/routes/speed-probes-write.test.ts | 10 ++++- backend/src/routes/uptime.ts | 2 +- .../src/services/speed-probe-replacement.ts | 9 ++-- docs/ux-ui-plan.md | 8 ++-- docs/ux-ui-progress.md | 12 +++++- docs/ux-ui-stage-12.md | 10 +++++ lib/speed-probe-snapshot.test.ts | 8 +++- lib/speed-probe-snapshot.ts | 8 ++-- packages/contracts/src/uptime-speed.ts | 12 ++++++ tests/e2e/uptime-links.spec.ts | 42 +++++++++++++++++++ 11 files changed, 106 insertions(+), 22 deletions(-) diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 7d7d1206..f4ebbdbc 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -5,7 +5,7 @@ import { parseSpeedRunResponse } from "@/lib/speed-run-response" import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes" -import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts" +import { sameSpeedMeasurement, speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts" import probeStyles from "./probe-rows.module.css" @@ -2155,10 +2155,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {srvProbes.map((probe) => { const dst = allServers.find((s) => s.id === probe.dstServerId) const run = speedRuns.find((r) => - r.srcServerId === probe.srcServerId && - r.dstServerId === probe.dstServerId && - (r.srcInterface ?? "") === probe.srcInterface && - (r.dstInterface ?? "") === probe.dstInterface, + sameSpeedMeasurement(r, probe), ) const useRun = run && (run.status === "running" || ( probe.lastStatus != null && probe.lastRunAt != null && run.startedAt >= Date.parse(probe.lastRunAt) diff --git a/backend/src/routes/speed-probes-write.test.ts b/backend/src/routes/speed-probes-write.test.ts index 5013d13d..b3bd6cfc 100644 --- a/backend/src/routes/speed-probes-write.test.ts +++ b/backend/src/routes/speed-probes-write.test.ts @@ -36,7 +36,15 @@ const saved = await put({ probes: [probe] }); assert.equal(saved.statusCode, 200 assert.equal(rows.has("old"), false); assert.equal(rows.get("s1")?.enabled, false); assert.equal(rows.get("s1")?.srcInterface, "ether1") rows.get("s1")!.lastTxAvgMbps = 42 await put({ probes: [{ ...probe, durationSec: 20, lastTxAvgMbps: 0 }] }) -assert.equal(rows.get("s1")?.lastTxAvgMbps, 42); assert.equal(rows.get("s1")?.durationSec, 20) +assert.equal(rows.get("s1")?.lastTxAvgMbps, null); assert.equal(rows.get("s1")?.durationSec, 20) +for (const edit of [{ srcInterface: "ether3" }, { dstInterface: "ether3" }, { protocol: "udp" }, { direction: "receive" }]) { + rows.get("s1")!.lastTxAvgMbps = 42 + await put({ probes: [{ ...probe, ...edit }] }) + assert.equal(rows.get("s1")?.lastTxAvgMbps, null) + rows.get("s1")!.lastTxAvgMbps = 0 + await put({ probes: [{ ...probe, ...edit, enabled: true }] }) + assert.equal(rows.get("s1")?.lastTxAvgMbps, 0) +} const beforePairChange = rows.get("s1")!.srcServerId await put({ probes: [{ ...probe, srcServerId: 2, dstServerId: 1 }] }) assert.notEqual(rows.get("s1")?.srcServerId, beforePairChange) diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 3ec7aa4c..683bfe00 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -208,7 +208,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { // Serialize complete replacements from all backend processes, including an empty catalog. await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-speed-probes'))`) await run({ - listProbes: async () => tx.select({ id: uptimeSpeedProbes.id, srcServerId: uptimeSpeedProbes.srcServerId, dstServerId: uptimeSpeedProbes.dstServerId }).from(uptimeSpeedProbes).for("update"), + listProbes: async () => tx.select().from(uptimeSpeedProbes).for("update"), remove: async id => { await tx.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, id)) }, upsert: async (row, createdAt, resetObservations) => { const { id, ...patch } = row diff --git a/backend/src/services/speed-probe-replacement.ts b/backend/src/services/speed-probe-replacement.ts index a8baa17d..4a1472af 100644 --- a/backend/src/services/speed-probe-replacement.ts +++ b/backend/src/services/speed-probe-replacement.ts @@ -1,20 +1,21 @@ -import { matchesSpeedProbeEndpoints, type SpeedProbeEndpoints } from "./speed-run-persistence.js" +import { sameSpeedMeasurement } from "@mmapp/contracts" import type { SpeedProbeWrite } from "@mmapp/contracts" export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string } export type SpeedProbeTransaction = { - listProbes: () => Promise + listProbes: () => Promise remove: (id: string) => Promise upsert: (row: SpeedProbePatch, createdAt: string, resetObservations: boolean) => Promise } export type SpeedProbeStore = { transaction: (run: (tx: SpeedProbeTransaction) => Promise) => Promise } -/** The adapter must lock the catalog and commit all configuration changes atomically. Observations are preserved for the same pair and reset when its endpoints change. */ +/** The adapter must lock the catalog and commit all configuration changes atomically. Observations are preserved only for the same measurement parameters. */ export async function replaceSpeedProbes(store: SpeedProbeStore, probes: SpeedProbeWrite[], now = new Date().toISOString()): Promise { await store.transaction(async tx => { const nextIds = new Set(probes.map(row => row.id)) const previous = new Map((await tx.listProbes()).map(row => [row.id, row])) for (const id of previous.keys()) if (!nextIds.has(id)) await tx.remove(id) for (const [sortOrder, row] of probes.entries()) { - const reset = !matchesSpeedProbeEndpoints(previous.get(row.id), row) + const old = previous.get(row.id) + const reset = !old || !sameSpeedMeasurement(old, row) await tx.upsert({ ...row, sortOrder, updatedAt: now }, now, reset) } }) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index deae5cc2..8962f82b 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,12 +1,12 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. -Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. +Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-07. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. -## Актуальная точка продолжения — 2026-10-04 +## Актуальная точка продолжения — 2026-10-07 -Этапы0–11 завершены в описанных локальных границах. Этап12 продолжается: доступность12.1, достоверность12.2.1–12.2.10 и профили12.3.1–12.3.63 записаны в журнале. Последняя общая main регрессия12.4.6 на991f2b3:1161 из1162 passed,1 focus race терминала, исправленный в12.4.6.1 с20+15 профильными проверками; context12.4.7 наb1f8326 дала221/221passed. Полный источник данных сохраняется, большие DOM ограничены страницами/окнами. Внешняя12.5 и реальные интеграции не приняты. Подробная история и точные ограничения: docs/ux-ui-stage-12.md. +Этапы0–11 завершены в описанных локальных границах. Этап12 продолжается; блоки до12.3.81 отражены в журнале. Общие main12.4.8 на579e816:1176/1176 и context12.4.9 наa3fcba0:221/221 passed. Последующие изменения speed-test проверены профильными тестами; эти общие прогоны не относятся к новому HEAD. Внешняя12.5 и реальные интеграции не приняты. Точные результаты и ограничения: [ux-ui-stage-12.md](ux-ui-stage-12.md). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 9309f2b3..315b2aa8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -32,7 +32,15 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.80 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.81; внешняя12.5 не выполнена. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.81 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.82; внешняя12.5 не выполнена. | + +## 12.3.81 — снимки и история при изменении параметров измерения (2026-10-07) + +Общий sameSpeedMeasurement сравнивает пару, интерфейсы, протокол, направление и длительность в валидных read/write формах. Каталог читает полный конфигурационный снимок под прежним FOR UPDATE; смена любого параметра сбрасывает9 observations. Frontend публикует это правило только после API confirmation и выбирает историю по тем же параметрам. Enable/reorder сохраняют последние наблюдения, в том числе0; history не удаляется. ReUI компоненты и сетевые команды сохранены. + +10 pure snapshot checks, HTTP/DI/RBAC каталога (все изменяемые параметры и сохранение0 при enable),74browser uptime/large history,8uptime context, targeted lint0errors/0warnings, non-incremental TypeScript и contracts/backend/frontend production builds прошли. Новый браузерный сценарий: save500 сохраняет80; успешная смена длительности20 и возврат10 оставляют snapshot unknown, история80 и KPI1run остаются. Перегенерированные3 исторических PNG восстановлены. Реальные PG locks/native tests требуют12.5. + +Следующий12.3.82: POST receipt пока не возвращает серверное время, live publish использует время браузера и ставит Ping date даже без afterBtPing; исправить совместимо и проверить неполный/невалидный receipt. Общие main/context результаты не переименованы в проверки нового HEAD. ## 12.3.80 — принадлежность результата параметрам speed-пробы (2026-10-07) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 58cf4ab0..3154fec2 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,5 +1,7 @@ # Этап 12: итоговая приёмка UX/UI +Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. + План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. | Блок | Проверка и результат | Критерий остановки | @@ -910,3 +912,11 @@ Preflight перед созданием RouterOS client проверяет id, 23 pure rate/status/persistence проверки прошли: 7 новых проверяют каждый изменённый параметр для late success/error, неизменный контекст с явным0 и preflight. HTTP/DI проверил409 как для пары, так и для параметров, прежние26 invalid inputs/defaults/RBAC; backend/contracts build passed. Реального PostgreSQL/RouterOS не было: source review условного SQL и DI не заменяют12.5. Это сравнение параметров, не версия строки и не защита от ABA изменения с возвратом тех же значений. Следующий12.3.81: каталог и frontend пока сбрасывают observations только при смене пары; распространить правило на все параметры измерения, сохраняя историю и enable/reorder. + +## 12.3.81 — снимки и история при изменении параметров измерения (2026-10-07) + +Общий sameSpeedMeasurement сравнивает пару, интерфейсы, протокол, направление и длительность в валидных read/write формах. Каталог читает полный конфигурационный снимок под прежним FOR UPDATE; смена любого параметра сбрасывает9 observations. Frontend публикует это правило только после API confirmation и выбирает историю по тем же параметрам. Enable/reorder сохраняют последние наблюдения, в том числе0; history не удаляется. ReUI компоненты и сетевые команды сохранены. + +10 pure snapshot checks, HTTP/DI/RBAC каталога (все изменяемые параметры и сохранение0 при enable),74browser uptime/large history,8uptime context, targeted lint0errors/0warnings, non-incremental TypeScript и contracts/backend/frontend production builds прошли. Новый браузерный сценарий: save500 сохраняет80; успешная смена длительности20 и возврат10 оставляют snapshot unknown, история80 и KPI1run остаются. Перегенерированные3 исторических PNG восстановлены. Реальные PG locks/native tests требуют12.5. + +Следующий12.3.82: POST receipt пока не возвращает серверное время, live publish использует время браузера и ставит Ping date даже без afterBtPing; исправить совместимо и проверить неполный/невалидный receipt. Общие main/context результаты не переименованы в проверки нового HEAD. diff --git a/lib/speed-probe-snapshot.test.ts b/lib/speed-probe-snapshot.test.ts index 6464693f..ebd053a7 100644 --- a/lib/speed-probe-snapshot.test.ts +++ b/lib/speed-probe-snapshot.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { emptySpeedProbeObservations } from "@mmapp/contracts" import { publishSpeedProbeSnapshots } from "./speed-probe-snapshot" -const probe = { id: "p", srcServerId: "1", dstServerId: "2", enabled: true, ...emptySpeedProbeObservations, lastTxAvgMbps: 0 as number | null, lastRxAvgMbps: 80 as number | null, lastStatus: "done" as "done" | "error" | null, lastRunAt: "2026-10-07T00:00:00Z" as string | null } +const probe = { id: "p", srcServerId: "1", dstServerId: "2", srcInterface: "ether1", dstInterface: "ether2", protocol: "tcp" as const, direction: "both" as "both" | "transmit" | "receive", durationSec: "10", enabled: true, ...emptySpeedProbeObservations, lastTxAvgMbps: 0 as number | null, lastRxAvgMbps: 80 as number | null, lastStatus: "done" as "done" | "error" | null, lastRunAt: "2026-10-07T00:00:00Z" as string | null } test("same pair preserves latest zero and snapshot instead of readonly values copied into a stale draft", () => { const next = { ...probe, enabled: false, lastTxAvgMbps: 999, lastRxAvgMbps: 999 } const [saved] = publishSpeedProbeSnapshots([probe], [next]) @@ -25,3 +25,9 @@ test("new row and reordered existing rows retain correct ownership without mutat assert.deepEqual(saved.map(p => p.id), ["new", "p"]) assert.equal(next.lastRxAvgMbps, 80) }) + +for (const edit of [{ srcInterface: "ether3" }, { dstInterface: "ether3" }, { protocol: "udp" as const }, { direction: "transmit" as const }, { durationSec: "20" }]) test("changed measurement " + Object.keys(edit)[0] + " resets all observations without altering history", () => { + const [saved] = publishSpeedProbeSnapshots([probe], [{ ...probe, ...edit }]) + for (const key of Object.keys(emptySpeedProbeObservations)) assert.equal(saved[key as keyof typeof emptySpeedProbeObservations], null) + assert.equal(probe.lastRxAvgMbps, 80) +}) diff --git a/lib/speed-probe-snapshot.ts b/lib/speed-probe-snapshot.ts index 67fc27e4..bc8bb482 100644 --- a/lib/speed-probe-snapshot.ts +++ b/lib/speed-probe-snapshot.ts @@ -1,12 +1,12 @@ -import { emptySpeedProbeObservations, type SpeedProbeRead } from "@mmapp/contracts" +import { emptySpeedProbeObservations, sameSpeedMeasurement, type SpeedMeasurementParameters, type SpeedProbeRead } from "@mmapp/contracts" type Observations = Pick -type Pair = { id: string; srcServerId: string; dstServerId: string } -/** Publish confirmed configuration while preserving the latest snapshot only for the same pair. */ +type Pair = { id: string } & SpeedMeasurementParameters +/** Publish confirmed configuration while preserving the latest snapshot only for the same measurement parameters. */ export function publishSpeedProbeSnapshots>(previous: readonly T[], next: readonly T[]) { const byId = new Map(previous.map(row => [row.id, row])) return next.map(row => { const old = byId.get(row.id) - if (!old || old.srcServerId !== row.srcServerId || old.dstServerId !== row.dstServerId) { + if (!old || !sameSpeedMeasurement(old, row)) { return { ...row, ...emptySpeedProbeObservations } } const observations = Object.fromEntries(Object.keys(emptySpeedProbeObservations).map(key => [key, old[key as keyof Observations] ?? null])) as Observations diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts index 9f423cfd..b21f38a9 100644 --- a/packages/contracts/src/uptime-speed.ts +++ b/packages/contracts/src/uptime-speed.ts @@ -56,3 +56,15 @@ export const speedTestCommandSchema = z.object({ durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)).default(10), }).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться") export type SpeedTestCommand = z.infer + +export type SpeedMeasurementParameters = { + srcServerId: string | number; dstServerId: string | number; srcInterface?: string; dstInterface?: string; + protocol: "tcp" | "udp"; direction: "both" | "transmit" | "receive"; durationSec: string | number +} +/** Compare validated configuration across HTTP read/write forms; enable/order are not measurement parameters. */ +export function sameSpeedMeasurement(a: SpeedMeasurementParameters, b: SpeedMeasurementParameters): boolean { + return String(a.srcServerId) === String(b.srcServerId) && String(a.dstServerId) === String(b.dstServerId) + && (a.srcInterface ?? "").trim() === (b.srcInterface ?? "").trim() + && (a.dstInterface ?? "").trim() === (b.dstInterface ?? "").trim() + && a.protocol === b.protocol && a.direction === b.direction && Number(a.durationSec) === Number(b.durationSec) +} diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index c2454742..43d223f7 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -861,3 +861,45 @@ test("Speed snapshot: смена пары сбрасывает снимок по await expect(history).toContainText("80") expect(writes).toBe(3) }) + +test("Speed snapshot: смена длительности сбрасывает снимок, прежняя история не накладывается", async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write", false, true) + await page.getByRole("button", { name: "Пауза", exact: true }).click() + await page.getByRole("button", { name: /Скорость/ }).click() + await page.route("**/api/uptime/speed-test", route => route.fulfill({ json: { ok: true, result: { runId: route.request().postDataJSON().runId, txAvgMbps: 30, rxAvgMbps: 80 } } })) + let fail = true, writes = 0 + await page.route("**/api/uptime/speed-probes", route => { + if (route.request().method() !== "PUT") return route.fallback() + writes++ + return route.fulfill(fail ? { status: 500, json: { error: "Пара не сохранена" } } : { json: { ok: true } }) + }) + const run = page.getByRole("button", { name: "Запустить", exact: true }) + const row = () => page.getByRole("button", { name: "Запустить", exact: true, includeHidden: true }).locator("xpath=../../..") + await run.click() + await expect(row().getByText("80 Мбит/с", { exact: true })).toBeVisible() + const history = page.getByRole("table") + await expect(history).toContainText("80") + const summary = page.locator('[aria-label="Сводка speed-проб"]') + await expect(summary).toContainText("1 run") + await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click() + const dialog = page.getByRole("dialog") + const duration = () => dialog.locator('[data-slot="field"]').filter({ has: page.locator('[data-slot="field-label"]').filter({ hasText: /^Сек$/ }) }).getByRole("textbox") + await duration().fill("20") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog.getByRole("alert")).toContainText("Пара не сохранена") + await expect(row()).toContainText("80 Мбит/с") + fail = false + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog).toBeHidden() + await expect(row()).toContainText("20") + await expect(row()).not.toContainText("80 Мбит/с") + await expect(summary).toContainText("1 run") + await expect(history).toContainText("80") + await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click() + await duration().fill("10") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog).toBeHidden() + await expect(row()).not.toContainText("80 Мбит/с") + await expect(history).toContainText("80") + expect(writes).toBe(3) +})