From bd59204fcf56578d1643f00e1ea13272f1124dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:04:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B5=D0=B7=D0=B0=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B8=D1=87=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0:=20UI=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA/=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pcp-tgu-ops-ui: вкладка «Заявки», редактор планирования, утилиты uuid и e2e-verify скрипты, обновление зависимостей - pcp-ballistics-service, pcp-mission-planing-service: текущие правки --- .../controller/SatelliteController.kt | 2 +- .../PcpMissionPlaningServiceApplication.kt | 6 + services/pcp-tgu-ops-ui/e2e-verify/editor.mjs | 60 +++++ .../pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs | 79 ++++++ .../pcp-tgu-ops-ui/e2e-verify/requests.mjs | 225 ++++++++++++++++++ services/pcp-tgu-ops-ui/package-lock.json | 45 ++++ services/pcp-tgu-ops-ui/package.json | 7 +- .../features/tgu-planning/TguPlanningPage.tsx | 68 ++++-- .../tgu-requests/RequestDetailsPanel.tsx | 57 +++-- .../features/tgu-requests/TguRequestsTab.tsx | 3 +- .../src/styles/tgu-planning.css | 21 ++ .../pcp-tgu-ops-ui/src/utils/uuid.test.ts | 20 ++ services/pcp-tgu-ops-ui/src/utils/uuid.ts | 35 +++ 13 files changed, 588 insertions(+), 40 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/editor.mjs create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/requests.mjs create mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.ts diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt index be2be88..cf99c62 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService import space.nstart.pcp.pcp_request_service.service.SatelliteService -import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import java.time.LocalDateTime diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt index 22aaa4e..c439a39 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt @@ -2,10 +2,16 @@ package space.nstart.pcp.pcp_mission_planing_service import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Import import org.springframework.kafka.annotation.EnableKafka +import space.nstart.pcp.pcp_types_lib.configuration.GlobalExceptionHandler @EnableKafka @SpringBootApplication +// GlobalExceptionHandler лежит в пакете pcp-types-lib вне базового component-scan этого сервиса, +// поэтому регистрируем @ControllerAdvice явно — иначе CustomErrorException/CustomValidationException +// отдаются дефолтным WebFlux-хендлером как голый 500 без тела с сообщением. +@Import(GlobalExceptionHandler::class) class PcpMissionPlaningServiceApplication fun main(args: Array) { diff --git a/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs new file mode 100644 index 0000000..e5a1271 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs @@ -0,0 +1,60 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // выбрать КА 56756 и открыть редактор + await p.getByText("NORAD 56756", { exact: false }).first().click(); + await p.waitForTimeout(500); + await p.getByRole("tab", { name: "Редактор", exact: true }).click(); + await p.waitForTimeout(700); + + // контролы редактора + const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true }); + const drop = p.getByRole("button", { name: "+ Сброс", exact: true }); + (await shoot.count()) > 0 && (await drop.count()) > 0 + ? pass("editor:controls", "+ Съёмка / + Сброс присутствуют") + : fail("editor:controls"); + + // режим «Сброс» -> ЗРВ реальных станций + await drop.click(); + const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + rva && rva.status() === 200 + ? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`) + : fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван"); + + const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " "); + const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"]; + const found = stationNames.filter((s) => panelText.includes(s)); + found.length >= 2 + ? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`) + : fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`); + // зоны содержат виток + время (UTC-окно) + /виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[–-]\d{2}:\d{2}/.test(panelText) + ? pass("editor:drop-zone-format", "виток + временное окно показаны") + : fail("editor:drop-zone-format", panelText.slice(0, 200)); + + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs new file mode 100644 index 0000000..49fd206 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs @@ -0,0 +1,79 @@ +// Проверка keep-alive: состояние вкладок не сбрасывается при переключении. +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); +// только видимая (активная) вкладка — карта присутствует и в Заявках (рисование полигона) +const mapToggle = (label) => p.locator(".tgu-map-toolbar__toggles button:visible", { hasText: label }); +const isActive = async (loc) => ((await loc.getAttribute("class")) || "").includes("is-active"); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(600); + + // --- Карта: меняем UI-состояние (выключаем слой «Станции») --- + await tab("Карта").click(); + await p.waitForTimeout(500); + const stationsToggle = mapToggle("Станции"); + (await isActive(stationsToggle)) ? pass("map:stations-on-initially") : fail("map:stations-on-initially"); + await stationsToggle.click(); + await p.waitForTimeout(150); + (await isActive(stationsToggle)) === false + ? pass("map:stations-toggled-off") + : fail("map:stations-toggled-off"); + + // --- Заявки: открываем форму создания (внутреннее состояние panelMode) --- + await tab("Заявки").click(); + await p.waitForTimeout(400); + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(250); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:create-form-open") + : fail("requests:create-form-open"); + + // --- уходим на Планы и возвращаемся --- + await tab("Планы").click(); + await p.waitForTimeout(300); + + // Карта: слой «Станции» по-прежнему выключен? + await tab("Карта").click(); + await p.waitForTimeout(400); + (await isActive(mapToggle("Станции"))) === false + ? pass("map:state-preserved", "слой «Станции» остался выключен") + : fail("map:state-preserved", "состояние слоёв сбросилось"); + + // Заявки: форма создания всё ещё открыта? + await tab("Заявки").click(); + await p.waitForTimeout(400); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:state-preserved", "форма создания осталась открытой") + : fail("requests:state-preserved", "форма закрылась (состояние сброшено)"); + + // Скрытые вкладки не должны размонтироваться: в DOM присутствуют сразу несколько
+ await tab("Планы").click(); + await p.waitForTimeout(200); + const mains = await p.locator(".tgu-app-shell main").count(); + mains >= 3 + ? pass("keep-alive:tabs-mounted", `
в DOM: ${mains}`) + : fail("keep-alive:tabs-mounted", `ожидалось >=3, найдено ${mains}`); + +} catch (e) { + fail("EXCEPTION", e.message); +} finally { + if (consoleErrors.length) console.log("CONSOLE_ERRORS", JSON.stringify(consoleErrors, null, 2)); + const failed = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== ${results.length - failed}/${results.length} PASS, ${failed} FAIL ===`); + await b.close(); + process.exit(failed ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs new file mode 100644 index 0000000..2652dd8 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs @@ -0,0 +1,225 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); + +const api = []; +p.on("request", (r) => { if (r.url().includes("/api/")) api.push({ m: r.method(), u: r.url() }); }); +const apiResp = []; +p.on("response", (r) => { if (r.url().includes("/api/")) apiResp.push({ m: r.request().method(), u: r.url(), s: r.status() }); }); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); +// авто-принятие window.confirm (для удаления) +p.on("dialog", (d) => d.accept()); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // ---- A: 4 вкладки ---- + for (const t of ["Планы", "Карта", "Редактор", "Заявки"]) { + (await tab(t).count()) > 0 ? pass(`tab:${t}`) : fail(`tab:${t}`, "не найдена"); + } + + // ---- B: Карта — дефолтные слои ---- + await tab("Карта").click(); + await p.waitForTimeout(600); + const toggles = p.locator(".tgu-map-toolbar__toggles button"); + const n = await toggles.count(); + const labels = []; + for (let i = 0; i < n; i++) { + const el = toggles.nth(i); + labels.push({ t: (await el.innerText()).trim(), active: (await el.getAttribute("class") || "").includes("is-active") }); + } + console.log("LAYER_TOGGLES", JSON.stringify(labels)); + const wanted = ["Работы плана", "Станции", "Заявки на съёмку"]; + const got = labels.map((l) => l.t); + JSON.stringify(got) === JSON.stringify(wanted) + ? pass("map:layers-picker", "ровно planWorks/stations/requests") + : fail("map:layers-picker", `got=${JSON.stringify(got)}`); + labels.every((l) => l.active) + ? pass("map:layers-default-on", "все 3 активны") + : fail("map:layers-default-on", JSON.stringify(labels)); + const forbidden = got.some((t) => /трек|track|полос|swath|маркер|spacecraft/i.test(t)); + forbidden ? fail("map:no-tracks-swath-markers", "найден лишний слой") : pass("map:no-tracks-swath-markers"); + + // ---- C: Заявки — загрузка ---- + await tab("Заявки").click(); + await p.waitForTimeout(400); + const before = apiResp.length; + await p.getByRole("button", { name: "Загрузить", exact: true }).click(); + await p.waitForResponse((r) => r.url().includes("/v1/requests/map"), { timeout: 15000 }).catch(() => {}); + await p.waitForTimeout(800); + const mapCall = apiResp.find((r) => r.u.includes("/v1/requests/map")); + mapCall && mapCall.s === 200 ? pass("requests:load", `GET map ${mapCall.s}`) : fail("requests:load", JSON.stringify(mapCall)); + const subtitle = await p.locator(".tgu-map-toolbar__subtitle").first().innerText().catch(() => ""); + console.log("REQUESTS_SUBTITLE", subtitle); + + // ---- D: создание + валидации ---- + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(300); + const form = p.locator(".tgu-requests-form"); + const submitBtn = form.getByRole("button", { name: "Создать заявку", exact: true }); + const alert = form.locator(".tgu-alert--danger"); + + // d1: до рисования — кнопка disabled, подсказка про полигон + (await submitBtn.isDisabled()) ? pass("create:disabled-before-draw") : fail("create:disabled-before-draw"); + console.log("ALERT_INITIAL", await alert.innerText().catch(() => "(нет)")); + + // d2: рисуем полигон 3 кликами по карте + // :visible — keep-alive держит смонтированными карты сразу нескольких вкладок + const map = p.locator(".tgu-2d-map:visible"); + const box = await map.boundingBox(); + const pts = [ + { x: box.width * 0.45, y: box.height * 0.40 }, + { x: box.width * 0.60, y: box.height * 0.40 }, + { x: box.width * 0.52, y: box.height * 0.55 } + ]; + for (const pt of pts) { await map.click({ position: pt }); await p.waitForTimeout(150); } + const vtext = await form.locator(".tgu-requests-form__geo-status").innerText().catch(() => ""); + console.log("VERTEX_TEXT", vtext); + /Вершин:\s*3/.test(vtext) ? pass("create:3-vertices") : fail("create:3-vertices", vtext); + await form.getByRole("button", { name: "Завершить", exact: true }).click(); + await p.waitForTimeout(300); + + // d3: имя пустое -> валидация + let a = await alert.innerText().catch(() => ""); + /название/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:empty-name", a) : fail("validate:empty-name", a); + + // заполняем имя + await form.getByPlaceholder("Название задания").fill("E2E-VERIFY"); + await p.waitForTimeout(200); + + // d4: оба блока выключены + const opticsCb = form.locator("label.tgu-requests-form__block-toggle", { hasText: "Оптическая" }).getByRole("checkbox"); + await opticsCb.uncheck(); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /хотя бы один блок/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:no-optics-no-rsa", a) : fail("validate:no-optics-no-rsa", a); + await opticsCb.check(); + await p.waitForTimeout(200); + + // d5: from >= to + const begin = form.locator('input[type="datetime-local"]').nth(0); + const end = form.locator('input[type="datetime-local"]').nth(1); + const beginVal = await begin.inputValue(); + await end.fill("2000-01-01T00:00"); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /раньше конца/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:from-after-to", a) : fail("validate:from-after-to", a); + // восстановить корректный конец + await end.fill("2100-01-01T00:00"); + await p.waitForTimeout(200); + + // d6: submit -> POST 201 + новая заявка + (await submitBtn.isEnabled()) ? pass("create:enabled-when-valid") : fail("create:enabled-when-valid"); + const postBefore = apiResp.length; + await submitBtn.click(); + const postResp = await p.waitForResponse((r) => r.url().includes("/v1/requests") && r.request().method() === "POST", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + if (postResp && (postResp.status() === 201 || postResp.status() === 200)) { + pass("create:POST", `status ${postResp.status()}`); + // проверим тело запроса: WKT lon lat + id + const reqBody = postResp.request().postDataJSON?.() || JSON.parse(postResp.request().postData() || "{}"); + const wktOk = /POLYGON\s*\(\(/.test(reqBody.geometry || ""); + const idOk = /^[0-9a-f-]{36}$/i.test(reqBody.id || ""); + wktOk && idOk ? pass("create:payload", `wkt+uuid ok`) : fail("create:payload", JSON.stringify({ id: reqBody.id, geo: (reqBody.geometry||"").slice(0,30) })); + console.log("CREATE_WKT", (reqBody.geometry || "").slice(0, 80)); + } else { + fail("create:POST", postResp ? `status ${postResp.status()}` : "нет ответа"); + } + const noticeTxt = await p.locator(".tgu-alert--success").innerText().catch(() => ""); + /создана/i.test(noticeTxt) ? pass("create:notice", noticeTxt) : fail("create:notice", noticeTxt || "(нет)"); + + // ---- E: детали + удаление ---- + // клик по нарисованному полигону -> панель деталей. Свипаем точки внутри треугольника, + // т.к. точное соответствие пиксель↔гео зависит от проекции. + const delBtn = p.getByRole("button", { name: /Удалить заявку/, exact: false }); + const rows = p.locator(".tgu-map-request-row"); + const detailsHeading = p.locator("aside .tgu-panel-heading span", { hasText: "Заявки в точке" }); + const tip = p.locator(".tgu-2d-map__tooltip"); + // hover-свип: ищем пиксель, где появляется тултип нашей заявки (надёжно к проекции/виду) + const mbox = await map.boundingBox(); + let target = null; + outer: + for (let gx = 0.40; gx <= 0.65; gx += 0.02) { + for (let gy = 0.35; gy <= 0.58; gy += 0.02) { + await p.mouse.move(mbox.x + mbox.width * gx, mbox.y + mbox.height * gy); + await p.waitForTimeout(25); + if ((await tip.count()) > 0 && /E2E-VERIFY/.test(await tip.innerText().catch(() => ""))) { + target = { x: mbox.width * gx, y: mbox.height * gy }; + break outer; + } + } + } + let opened = false; + if (target) { + await map.click({ position: target }); + await p.waitForTimeout(400); + if ((await delBtn.count()) > 0 || (await detailsHeading.count()) > 0) opened = true; + } + if (opened) { + // если в точке несколько заявок — выбрать строку, чтобы появилась кнопка удаления + if ((await delBtn.count()) === 0 && (await rows.count()) > 0) { + await rows.first().click(); + await p.waitForTimeout(300); + } + pass("details:open", `панель деталей открылась (строк в точке: ${await rows.count() || 1})`); + + const initialDel = p.getByRole("button", { name: "Удалить заявку", exact: true }); + const confirmTitle = p.locator(".tgu-requests-confirm__title", { hasText: "Удалить заявку?" }); + const yesBtn = p.getByRole("button", { name: "Да, удалить", exact: true }); + const noBtn = p.getByRole("button", { name: "Нет, не удалять", exact: true }); + + // шаг 1: клик «Удалить заявку» -> появляется панель подтверждения с Да/Нет + await initialDel.first().click(); + await p.waitForTimeout(250); + (await confirmTitle.count()) > 0 && (await yesBtn.count()) > 0 && (await noBtn.count()) > 0 + ? pass("delete:confirm-panel", "панель «Удалить заявку?» с «Да, удалить»/«Нет, не удалять»") + : fail("delete:confirm-panel", `title=${await confirmTitle.count()} yes=${await yesBtn.count()} no=${await noBtn.count()}`); + + // «Нет, не удалять» — не шлёт DELETE и закрывает подтверждение + const delCallsBefore = apiResp.filter((r) => r.m === "DELETE").length; + await noBtn.click(); + await p.waitForTimeout(300); + (await confirmTitle.count()) === 0 && apiResp.filter((r) => r.m === "DELETE").length === delCallsBefore + ? pass("delete:cancel", "«Нет, не удалять» отменяет без DELETE") + : fail("delete:cancel"); + + // «Да, удалить» -> DELETE + await initialDel.first().click(); + await p.waitForTimeout(200); + await yesBtn.click(); + const delResp = await p.waitForResponse((r) => r.url().includes("/v1/requests/") && r.request().method() === "DELETE", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(800); + delResp && (delResp.status() === 200 || delResp.status() === 204) + ? pass("delete:DELETE", `status ${delResp.status()}`) + : fail("delete:DELETE", delResp ? `status ${delResp.status()}` : "нет DELETE-запроса"); + } else { + fail("details:open", "панель деталей не открылась (не попал по полигону)"); + } + + // ---- console errors ---- + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); + +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + console.log("\n=== API CALLS (последние 25) ==="); + for (const r of apiResp.slice(-25)) console.log(r.s, r.m, r.u.replace("http://localhost:5174", "")); + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/package-lock.json b/services/pcp-tgu-ops-ui/package-lock.json index 62a92dc..c0cd25c 100644 --- a/services/pcp-tgu-ops-ui/package-lock.json +++ b/services/pcp-tgu-ops-ui/package-lock.json @@ -18,6 +18,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } }, @@ -1937,6 +1938,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/services/pcp-tgu-ops-ui/package.json b/services/pcp-tgu-ops-ui/package.json index 88418f0..8dde952 100644 --- a/services/pcp-tgu-ops-ui/package.json +++ b/services/pcp-tgu-ops-ui/package.json @@ -11,15 +11,16 @@ }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4", - "typescript": "^5.9.3", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "typescript": "^5.9.3", + "vite": "^7.2.4" }, "devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index f3db83f..cd446e0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -28,6 +28,11 @@ export function TguPlanningPage() { const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); + // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии + // и дальше держим смонтированной (скрытой через display:none), чтобы её + // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось + // при переключении между вкладками. + const [visitedTabs, setVisitedTabs] = useState>(() => new Set(["timeline"])); const [decisionInFlight, setDecisionInFlight] = useState(false); const [decisionNotice, setDecisionNotice] = useState(); const [decisionError, setDecisionError] = useState(); @@ -80,6 +85,15 @@ export function TguPlanningPage() { void loadData(appliedRange); }, [appliedRange, loadData]); + useEffect(() => { + setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab))); + }, [activeTab]); + + // display:contents для активной вкладки сохраняет grid-раскладку shell + // (внутренний
остаётся прямым grid-элементом), display:none скрывает остальные. + const tabStyle = (tab: TguActiveTab) => + ({ display: activeTab === tab ? "contents" : "none" }) as const; + const platforms = useMemo( () => buildPlatformsForInterval(rawPlatforms, plans, appliedRange), [rawPlatforms, plans, appliedRange] @@ -152,7 +166,7 @@ export function TguPlanningPage() { /> } > - {activeTab === "timeline" ? ( +
setSelectedPlanId(undefined)} />
- ) : activeTab === "map" ? ( - - ) : activeTab === "requests" ? ( - - ) : ( - +
+ + {visitedTabs.has("map") && ( +
+ +
+ )} + + {visitedTabs.has("requests") && ( +
+ +
+ )} + + {visitedTabs.has("editor") && ( +
+ +
)} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx index 1f19094..d50252a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; type Props = { @@ -27,6 +28,13 @@ export function RequestDetailsPanel({ ? requests[0] : undefined; + // Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки, + // чтобы открытый запрос на удаление не «переезжал» на другую заявку. + const [confirmingDelete, setConfirmingDelete] = useState(false); + useEffect(() => { + setConfirmingDelete(false); + }, [selectedEntry?.item.id]); + return ( diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx index 261814e..62b15de 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -8,6 +8,7 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { generateUuid } from "../../utils/uuid"; import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; import { RequestDetailsPanel } from "./RequestDetailsPanel"; import { polygonToWkt } from "./requestGeometry"; @@ -195,7 +196,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa setCreating(true); setCreateError(undefined); try { - const id = crypto.randomUUID(); + const id = generateUuid(); await createRequest({ id, name: form.name, diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 40e9ae7..1152dd2 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -1319,6 +1319,27 @@ gap: 0.35rem; } +.tgu-requests-confirm { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 0.6rem; + border: 1px solid rgba(229, 115, 115, 0.45); + border-radius: 6px; + background: rgba(229, 115, 115, 0.08); +} + +.tgu-requests-confirm__title { + font-weight: 600; + color: var(--text); +} + +.tgu-requests-confirm__subtitle { + color: var(--text-dim); + font-size: 0.8rem; + word-break: break-word; +} + .tgu-requests-form__hint { color: var(--text-dim); font-size: 0.75rem; diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts new file mode 100644 index 0000000..a4d4f4c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { generateUuid, uuidV4Fallback } from "./uuid"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("generateUuid", () => { + it("возвращает валидный UUID v4", () => { + expect(generateUuid()).toMatch(UUID_V4); + }); +}); + +describe("uuidV4Fallback (insecure context)", () => { + it("даёт валидный UUID v4 без crypto.randomUUID", () => { + expect(uuidV4Fallback()).toMatch(UUID_V4); + }); + + it("генерирует разные значения", () => { + expect(uuidV4Fallback()).not.toBe(uuidV4Fallback()); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.ts new file mode 100644 index 0000000..0bd9dd9 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.ts @@ -0,0 +1,35 @@ +/** + * Генерация UUID v4. + * + * `crypto.randomUUID()` доступен только в secure context (https или localhost), + * поэтому при открытии фронта по сетевому адресу (http://:5174) он отсутствует + * и падает с «crypto.randomUUID is not a function». `crypto.getRandomValues`, наоборот, + * доступен и в insecure context — используем его как фолбэк (RFC 4122 v4). + */ +export function generateUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return uuidV4Fallback(); +} + +/** RFC 4122 v4 на основе crypto.getRandomValues (работает в insecure context). */ +export function uuidV4Fallback(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // версия 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); +}