fix(map): восстановить легенду и убрать лишние рамки фокуса

This commit is contained in:
Denozordec committed 2026-10-15 23:36:19 +07:00
1 parent 1c11a133f3
commit eb24a08981
5 files changed
+155 -10

No files matched your search

+14 -10
View File
@@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion" import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
import {toast} from "sonner" import {toast} from "sonner"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { NetworkMapLegend } from "@/components/network-map-legend"
import { import {
servers as mockServers, servers as mockServers,
greTunnels as mockGreTunnels, greTunnels as mockGreTunnels,
@@ -556,7 +557,7 @@ function GreEdgeMetricBadge({
"Клик — панель справа." "Клик — панель справа."
return ( return (
<g <g
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`} transform={`translate(${mx},${my})`}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
onClick={(e) => { e.stopPropagation(); onOpen(e) }} onClick={(e) => { e.stopPropagation(); onOpen(e) }}
> >
<title>{hint}</title> <title>{hint}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect <rect
x={-bw / 2} x={-bw / 2}
y={-bh / 2} y={-bh / 2}
@@ -607,7 +609,7 @@ function NetflowRateBadge({
const bh = showDir ? 32 : 20 const bh = showDir ? 32 : 20
return ( return (
<g <g
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="focus-visible:outline-2 focus-visible:outline-ring" role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="network-map-focusable"
onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}} onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`} transform={`translate(${mx},${my})`}
style={{ cursor: onOpen ? "pointer" : "default" }} style={{ cursor: onOpen ? "pointer" : "default" }}
@@ -617,6 +619,7 @@ function NetflowRateBadge({
<title> <title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно. Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
</title> </title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect <rect
x={-bw / 2} x={-bw / 2}
y={-bh / 2} y={-bh / 2}
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
const ts = TYPE_STYLE[n.type] const ts = TYPE_STYLE[n.type]
return ( return (
<g className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1} <g className="network-map-focusable" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${n.x},${n.y})`} transform={`translate(${n.x},${n.y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -699,6 +702,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
onMouseEnter={() => onHoverChange(true)} onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)} onMouseLeave={() => onHoverChange(false)}
> >
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
{n.status === "online" && isVis && ( {n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7"> <circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
</circle> </circle>
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
<circle r={ts.r + 12} fill={ss.glow} /> <circle r={ts.r + 12} fill={ss.glow} />
)} )}
{isSel && ( {isSel && (
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" /> <circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.45" />
)} )}
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} /> <circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}> <g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
@@ -807,7 +811,7 @@ function ServiceNode({
const bh = MAP_SERVICE_NODE_H const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : "" const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return ( return (
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="focus-visible:outline-2 focus-visible:outline-ring" <g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${x},${y})`} transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -816,6 +820,7 @@ function ServiceNode({
onClick={(e) => { e.stopPropagation(); onClick() }} onClick={(e) => { e.stopPropagation(); onClick() }}
> >
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title> <title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 3} y={-bh / 2 - 3} width={bw + 6} height={bh + 6} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
{isSel && ( {isSel && (
<rect <rect
x={-bw / 2 - 6} x={-bw / 2 - 6}
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
}) { }) {
const r = 18 const r = 18
return ( return (
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring" <g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
transform={`translate(${x},${y})`} transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }} style={{ cursor: isDragged ? "grabbing" : "grab" }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }} onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}> onClick={e => { e.stopPropagation(); onSelect() }}>
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
{active && ( {active && (
<circle r={r + 6} fill={color} opacity="0.12"> <circle r={r + 6} fill={color} opacity="0.12">
</circle> </circle>
@@ -1718,6 +1724,7 @@ function NetworkMapContent() {
if (!svg) return if (!svg) return
// Clone with full viewBox (reset pan/zoom) // Clone with full viewBox (reset pan/zoom)
const clone = svg.cloneNode(true) as SVGSVGElement const clone = svg.cloneNode(true) as SVGSVGElement
clone.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove())
clone.setAttribute("viewBox", `0 0 ${W} ${H}`) clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
clone.setAttribute("width", String(W)) clone.setAttribute("width", String(W))
clone.setAttribute("height", String(H)) clone.setAttribute("height", String(H))
@@ -2252,10 +2259,7 @@ function NetworkMapContent() {
{/* ── Canvas ── */} {/* ── Canvas ── */}
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}> <div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm"> <NetworkMapLegend />
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
</details>
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p> <p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
<svg <svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help" ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
+12
View File
@@ -2,6 +2,18 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
.network-map-focusable {
outline: none;
}
.network-map-focus-ring {
opacity: 0;
pointer-events: none;
}
.network-map-focusable:focus-visible > .network-map-focus-ring {
opacity: 1;
}
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { useEffect, useRef } from "react"
import { Badge } from "@/components/ui/badge"
export function NetworkMapLegend() {
const details = useRef<HTMLDetailsElement>(null)
useEffect(() => {
if (window.matchMedia("(max-width: 767px)").matches && details.current) details.current.open = false
}, [])
return (
<details ref={details} open data-map-legend className="absolute left-3 top-3 z-20 max-w-[calc(100%-1.5rem)] rounded-lg border bg-popover text-popover-foreground shadow-sm">
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring">Легенда карты</summary>
<div role="region" aria-label="Легенда карты" className="flex max-h-[42vh] w-44 max-w-full flex-col gap-2 overflow-y-auto border-t px-3 py-2 text-[11px]">
<div>
<p className="mb-1.5 font-medium text-muted-foreground">Статус устройства</p>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{[["var(--status-online)", "Онлайн"], ["var(--status-degraded)", "Внимание"], ["var(--status-offline)", "Оффлайн"], ["var(--muted-foreground)", "Неизвестно"]].map(([color, label]) => (
<span key={label} className="flex items-center gap-1.5"><span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: color }} />{label}</span>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Тип узла</p>
{[["JH", "JumpHost", "var(--chart-1)"], ["EN", "Exit Node", "var(--chart-3)"], ["HR", "Home Router", "var(--chart-2)"]].map(([label, name, color]) => (
<span key={label} className="flex items-center gap-2"><Badge variant="secondary" style={{ color }} className="min-w-8 justify-center text-[10px]">{label}</Badge><span>{name}</span></span>
))}
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded-full border border-current" />WAN · аплинк</span>
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded border border-current" />Сервис / страна</span>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Связи и измерения</p>
{[
["GRE", "var(--status-online)", undefined],
["GRE + IPsec", "var(--status-online)", "7 4"],
["Поток NetFlow", "var(--chart-4)", "6 5"],
["WAN → JH · модель", "var(--muted-foreground)", "5 4"],
].map(([label, color, dash]) => (
<span key={label} className="flex items-center gap-2"><svg aria-hidden width="28" height="12" className="shrink-0"><line x1="0" y1="6" x2="28" y2="6" stroke={color} strokeWidth="2" strokeDasharray={dash} /></svg>{label}</span>
))}
<p className="text-muted-foreground">RTT · задержка пробы. «—» · нет измерения.</p>
<p className="text-muted-foreground">NetFlow · трафик за 5 минут, не пропускная способность канала.</p>
</div>
</div>
</details>
)
}
+8
View File
@@ -38,6 +38,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
## Production270 — легенда и контуры фокуса карты (2026-10-15)
По замечанию владельца выполнено сравнение с последним доступным тегом v1.42.0. Там легенда была раскрытой графической панелью, а выбранный узел имел полупрозрачный пунктирный круг. Текущий свёрнутый текстовый details заменён компонентом NetworkMapLegend: образцы статусов, JH/EN/HR, формы WAN/сервисов, GRE/IPsec/NetFlow/модель WAN→JH, пояснения RTT/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом.
Прямоугольный outline по SVG bounding box заменён на focus-visible контур по форме устройства/WAN/сервиса/метрики. Мышь его не показывает, Tab/Enter работают; выделение узла сохранено с opacity0.45 как в1.42. Контуры фокуса удаляются из клона при экспорте SVG, чтобы CSS-зависимое скрытие не создавало лишние обводки в файле. ReUI-примитивы не перерабатывались; Badge используется штатный, цвета — существующие semantic tokens.
Проверки:13 прежних data E2E и4 новых UI E2E passed (legend desktop/mobile, pointer/keyboard focus, экспорт). TypeScript и scoped ESLint passed. Первый прогон выявил неточность текстового locator вокруг Badge; подпись выделена отдельным span и сценарий повторно прошёл. Скриншоты ПК/телефона просмотрены, после просмотра мобильная легенда сделана свёрнутой. Превью tmp/map270-desktop.png и tmp/map270-mobile.png вне Git. Финальная семантическая правка цвета образца GRE не меняет геометрию/поведение. Изменения локальные, без push/deploy; production остаётся2.0.1 из269. Следующий шаг — публикация подготовленных Filters268 и визуального патча270 после разрешения владельца; отдельно остаются read-only DNS и трафик.
## Production269 — фактическое обновление frontend и узлы карты (2026-10-15) ## Production269 — фактическое обновление frontend и узлы карты (2026-10-15)
По повторной жалобе подтверждено: работающий frontend всё ещё2.0.0/72bf648c, хотя все jobs CI2467 завершились success и2.0.1 опубликован. API принимает исправленная схема4servers/25GRE/0probes, разовые задержки171/498/56мс. После отдельного разрешения владельца обновлён только frontend по проверенному digest2.0.1/042407f0; новый контейнер healthy, backend/PostgreSQL ID и StartedAt сохранены. Публичный manifest2.0.1 и браузер подтвердили4 узла (3JH/1EN),25GRE в данных и3 отображённые связи. Ошибка первичной валидации исчезла. Права read-only блокируют DNS resolve-hosts (mm:network:write), поэтому все линии/FQDN ещё не подтверждены; права не менялись. [Процедура и откат](../deploy/PRODUCTION-FRONTEND-269.md), приватный скриншот tmp/prod-map269.jpg вне Git. Блок268 Filters остаётся локальным и в этом обновлении отсутствует. Следующее действие — отдельная проверка read-only разрешения DNS и продолжение трафика; готовый Filters-патч требует публикации. По повторной жалобе подтверждено: работающий frontend всё ещё2.0.0/72bf648c, хотя все jobs CI2467 завершились success и2.0.1 опубликован. API принимает исправленная схема4servers/25GRE/0probes, разовые задержки171/498/56мс. После отдельного разрешения владельца обновлён только frontend по проверенному digest2.0.1/042407f0; новый контейнер healthy, backend/PostgreSQL ID и StartedAt сохранены. Публичный manifest2.0.1 и браузер подтвердили4 узла (3JH/1EN),25GRE в данных и3 отображённые связи. Ошибка первичной валидации исчезла. Права read-only блокируют DNS resolve-hosts (mm:network:write), поэтому все линии/FQDN ещё не подтверждены; права не менялись. [Процедура и откат](../deploy/PRODUCTION-FRONTEND-269.md), приватный скриншот tmp/prod-map269.jpg вне Git. Блок268 Filters остаётся локальным и в этом обновлении отсутствует. Следующее действие — отдельная проверка read-only разрешения DNS и продолжение трафика; готовый Filters-патч требует публикации.
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test"
const emptyFlow = { hops: [], live: false, rangeMinutes: 5, windowSec: 300, totalBytes: 0, namedBytes: 0, unclassifiedBytes: 0, asnLoaded: false, countryLoaded: false, services: [], serviceEdges: [], servicePaths: [], countries: [], countryEdges: [], countryPaths: [], countryServiceGroups: [], mapServiceMinSharePct: 5, dedupApplied: false, excludeMeshApplied: false, excludeOverlayApplied: false }
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [
{ id: 1, name: "Тест JH", host: "192.0.2.1", type: "jump-host", country: "RU", enabled: true, status: "online" },
{ id: 2, name: "Тест EN", host: "192.0.2.2", type: "exit-node", country: "SE", enabled: true, status: "online" },
] : path === "/api/filters/gre-tunnels" ? { tunnels: [], failures: [] }
: path === "/api/uptime/speed-probes" ? { probes: [] }
: path === "/api/traffic/flow/map-hops" ? emptyFlow
: path === "/api/auth/config" ? { required: false } : {} })
})
})
test("Карта: раскрытая легенда объясняет статусы, типы, линии и неизвестные измерения", async ({ page }) => {
await page.goto("/network-map")
const legend = page.getByRole("region", { name: "Легенда карты" })
await expect(legend).toBeVisible()
for (const text of ["Онлайн", "Внимание", "Оффлайн", "Неизвестно", "JumpHost", "Exit Node", "Home Router", "GRE + IPsec", "Поток NetFlow"]) await expect(legend.getByText(text, { exact: true })).toBeVisible()
await expect(legend).toContainText("нет измерения")
await expect(legend).toContainText("не пропускная способность канала")
await page.locator("[data-map-legend] summary").click()
await expect(legend).not.toBeVisible()
await page.locator("[data-map-legend] summary").press("Enter")
await expect(legend).toBeVisible()
await legend.getByText("Онлайн", { exact: true }).click()
await page.screenshot({ path: "tmp/map270-desktop.png" })
})
test("Карта: мышь не рисует прямоугольную рамку, клавиатурный фокус остаётся видимым", async ({ page }) => {
await page.goto("/network-map")
const node = page.getByRole("button", { name: "Устройство Тест JH", exact: true })
await expect(node).toBeVisible()
await node.click()
await expect(node).toHaveCSS("outline-style", "none")
await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "0")
// Return to this object through real keyboard navigation, not programmatic focus.
await node.press("Shift+Tab")
await page.keyboard.press("Tab")
await expect(node).toBeFocused()
await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "1")
await node.press("Enter")
await expect(node).toHaveAttribute("aria-pressed", "false")
})
test("Карта: легенда помещается на телефоне и сворачивается", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto("/network-map")
const legend = page.getByRole("region", { name: "Легенда карты" })
await expect(page.locator("[data-map-legend]")).not.toHaveAttribute("open", "")
await page.screenshot({ path: "tmp/map270-mobile.png" })
await page.locator("[data-map-legend] summary").click()
await expect(legend).toBeVisible()
const bounds = await legend.boundingBox()
expect(bounds!.x).toBeGreaterThanOrEqual(0)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
await page.locator("[data-map-legend] summary").click()
await expect(legend).not.toBeVisible()
})
test("Карта: экспорт не добавляет клавиатурные контуры всем объектам", async ({ page }) => {
await page.goto("/network-map")
await expect(page.getByRole("button", { name: "Устройство Тест JH", exact: true })).toBeVisible()
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "Экспорт SVG", exact: true }).click()
const stream = await (await download).createReadStream()
let svg = ""
for await (const chunk of stream!) svg += chunk.toString()
expect(svg).not.toContain("network-map-focus-ring")
expect(svg).toContain("Тест JH")
})