diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 97f46c9e..e58ba867 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,5 +1,6 @@ "use client" +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion" import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity" import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost" import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state" @@ -265,19 +266,20 @@ const TOPO_H = 360 const NODE_R = 34 const NODE_THEME = { - Pending: { fill: "#2d1e06", stroke: "#fbbf24", glow: "transparent" }, - Unknown: { fill: "#111827", stroke: "#9ca3af", glow: "transparent" }, - Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" }, - "2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" }, - Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" }, - none: { fill: "#111827", stroke: "#374151", glow: "transparent" }, + Pending: { fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "transparent" }, + Unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" }, + Full: { fill: "var(--status-online-bg)", stroke: "var(--status-online-fg)", glow: "var(--status-online-bg)" }, + "2-Way":{ fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "var(--status-degraded-bg)" }, + Down: { fill: "var(--status-offline-bg)", stroke: "var(--status-offline-fg)", glow: "transparent" }, + none: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" }, } const EDGE_COLOR: Record = { - Attempt: "#fbbf24", Init: "#fbbf24", ExStart: "#fbbf24", Exchange: "#fbbf24", Loading: "#fbbf24", Unknown: "#9ca3af", - Full: "#4ade80", - "2-Way":"#fbbf24", - Down: "#f87171", + Pending: "var(--status-degraded-fg)", + Attempt: "var(--status-degraded-fg)", Init: "var(--status-degraded-fg)", ExStart: "var(--status-degraded-fg)", Exchange: "var(--status-degraded-fg)", Loading: "var(--status-degraded-fg)", Unknown: "var(--muted-foreground)", + Full: "var(--status-online-fg)", + "2-Way":"var(--status-degraded-fg)", + Down: "var(--status-offline-fg)", } interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number } @@ -361,14 +363,6 @@ function moveInArray(arr: T[], from: number, to: number): T[] { return copy } -function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) { - if (state === "Full" || state === "Up") - return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25" - if (state === "2-Way" || state === "Init") - return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25" - return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25" -} - function Chip({ children, color }: { children: React.ReactNode; color?: string }) { return ( (null) + useEffect(() => { + const svg = svgRef.current, viewport = svg?.parentElement + if (!svg || !viewport) return + const center = () => { + const node = graphNodes.find(node => node.id === selectedId) + const point = svg.createSVGPoint(); point.x = node?.x ?? TOPO_W / 2 + const matrix = svg.getScreenCTM() + if (matrix) viewport.scrollLeft += point.matrixTransform(matrix).x - viewport.getBoundingClientRect().left - viewport.clientWidth / 2 + } + center() + const observer = new ResizeObserver(center); observer.observe(viewport) + return () => observer.disconnect() + }, [graphNodes, selectedId]) const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n])) function nodeState(id: string): keyof typeof NODE_THEME { @@ -402,9 +410,9 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo } return ( - onNodeClick(null)} > @@ -412,14 +420,14 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo - + {graphEdges.map((e, i) => { const a = nodeMap[e.from] const b = nodeMap[e.to] if (!a || !b) return null - const color = EDGE_COLOR[e.state] ?? "#64748b" + const color = EDGE_COLOR[e.state] ?? "var(--muted-foreground)" const isDashed = e.state !== "Full" const active = highlightId === e.from || highlightId === e.to || selectedId === e.from || selectedId === e.to @@ -428,7 +436,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo const mx = (a.x + b.x) / 2 const my = (a.y + b.y) / 2 return ( - + @@ -440,8 +448,8 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo )} - + {e.cost ?? "—"} @@ -461,68 +469,46 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo (e.from === highlightId && e.to === node.id) || (e.to === highlightId && e.from === node.id)) return ( - { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}> - {state === "Full" && !dimmed && ( + onClick={e => { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onNodeClick(isSel ? null : node.id) } + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onNodeClick(null) } + }}> + {showDots && state === "Full" && !dimmed && ( - )} {state === "2-Way" && !dimmed && } - {isSel && } - {isHov && !isSel && } + {isSel && } + {isHov && !isSel && } + stroke={isSel ? "var(--foreground)" : theme.stroke} strokeWidth={isSel ? 2.8 : 1.8} /> {node.site !== "—" && ( - + node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" : + node.site === "FRA" ? "var(--warning)" : "var(--success)"} /> + {node.site.slice(0,3)} )} - + {node.site !== "—" ? node.site : "?"} - {node.short} - + {node.label.replace(/^mt-/, "")} ) })} - {/* legend */} - - - ЛЕГЕНДА OSPF - {([ - { state: "Full", label: "Full adjacency" }, - { state: "2-Way", label: "2-Way partial" }, - { state: "Down", label: "Down" }, - ] as const).map(({ state, label }, i) => { - const c = EDGE_COLOR[state] ?? "#64748b" - return ( - - - - {label} - - ) - })} - - - клик по узлу — подробности · цифра = cost - - ) } @@ -554,29 +540,30 @@ function NodeDetailPanel({ const theme = NODE_THEME[panelState] const siteFill = - node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" : - node.site === "FRA" ? "#b45309" : "#065f46" + node.site === "—" ? "var(--muted)" : node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" : + node.site === "FRA" ? "var(--warning)" : "var(--success)" return ( -
-
+
{ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose() } }}> +
+ style={{ background: siteFill, color: node.site === "—" ? "var(--muted-foreground)" : "var(--primary-foreground)" }}> {node.site !== "—" ? node.site.slice(0,3) : "?"}
-

{node.label}

-

Router-ID: {routerId}

+

{node.label}

+

Router-ID: {routerId}

-
-
+
- {panelState} + {panelState === "Pending" ? "Согласование" : panelState === "Unknown" ? "Неизвестно" : panelState}
{fullCnt > 0 && Full ×{fullCnt}} @@ -585,19 +572,19 @@ function NodeDetailPanel({
-
-

Соседи ({adjacencies.length})

- {adjacencies.length === 0 &&

Нет активных adjacency

} -
+
+

Соседи ({adjacencies.length})

+ {adjacencies.length === 0 &&

Нет активных adjacency

} +
{adjacencies.map((adj, i) => { - const ec = EDGE_COLOR[adj.state] ?? "#64748b" + const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)" return (
-

{adj.peerLabel}

-

{adj.iface} · cost {adj.cost ?? "—"}

-

{adj.uptime}

+

{adj.peerLabel}

+

{adj.iface} · cost {adj.cost ?? "—"}

+

{adj.uptime}

{adj.state}
@@ -607,19 +594,19 @@ function NodeDetailPanel({
-

- Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})} +

+ Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})}

- {ifaces.length === 0 &&

Нет данных

} -
+ {ifaces.length === 0 &&

Нет данных

} +
{ifaces.map(iface => (
- - {iface.interfaceName} - + + {iface.interfaceName} + {iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`} - cost {iface.cost ?? "—"} + cost {iface.cost ?? "—"}
))}
@@ -780,7 +767,7 @@ function InterfacesTab({ )} {isLive && filterServerId !== ALL_SERVERS_ID && ( )} @@ -830,7 +817,7 @@ function InterfacesTab({ onDragOver={e => { e.preventDefault(); setDragOver(item.key) }} onDrop={() => !isLive && onDrop(router.routerKey, ag.area, item.key)} className={cn( - "flex items-center gap-3 px-4 py-2.5 select-none transition-colors", + "flex flex-wrap items-center gap-3 px-4 py-2.5 select-none transition-colors sm:flex-nowrap", !isLive && "cursor-grab", dragging === item.key && "opacity-40", dragOver === item.key && dragging !== item.key && "bg-primary/5", @@ -906,6 +893,11 @@ function NeighborsTab({ hasSearch: boolean }) { const [highlightId, setHighlightId] = useState(null) + const reducedMotion = usePrefersReducedMotion() + function closeNode() { + const previous = selectedId; onSelect(null) + if (previous) requestAnimationFrame(() => document.querySelector(`[data-ospf-node="${CSS.escape(previous)}"]`)?.focus()) + } const [showDots, setShowDots] = useState(true) dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown") @@ -914,11 +906,12 @@ function NeighborsTab({ return (
, iconClassName: "text-muted-foreground", @@ -944,8 +937,8 @@ function NeighborsTab({ {selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) && Узел OSPF недоступенУзел из ссылки отсутствует в текущем срезе.} {graphUnknown &&

Топология OSPF неизвестна до успешного опроса всех устройств.

} {!graphUnknown && graphNodes.length > 0 && ( -
-
+
+
- {!selectedId && ( -
+
кликните по узлу для деталей
)} @@ -980,12 +973,17 @@ function NeighborsTab({ routerIds={routerIds} neighbors={neighbors} items={items} - onClose={() => onSelect(null)} + onClose={closeNode} /> )}
)} + {!graphUnknown && graphNodes.length > 0 &&
+ {(["Full", "2-Way", "Pending", "Down", "Unknown"] as const).map(state => )} + Цифра на связи — cost. Узел открывается нажатием или Enter. + Схему можно прокручивать по горизонтали. +
} , iconClassName: "text-muted-foreground", @@ -1066,7 +1065,7 @@ function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[ }, { id: "down", - label: "Down / Admin", + label: Down/Adm, value: dataIncomplete ? "—" : downCount, icon: , iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground", @@ -1307,7 +1306,7 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte <> @@ -1316,11 +1315,11 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte } banner={
-
+
{TABS.map(t => ( - : {row.original.localLabel} ), meta: { headerTitle: "Роутер", @@ -188,7 +189,7 @@ function OspfNeighborsDataGrid({ }, }, ], - [], + [onSelect, selectedRouterId], ) const rowSelection = useMemo(() => { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 80bf2f04..141464f3 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -151,3 +151,14 @@ HTTP-фикстуры; layout/клавиатура проверены. Productio | BGP · Stats 12 / Frame | [Аналитика](ux-ui/screens/bgp-analytics-desktop.png) | [Аналитика](ux-ui/screens/bgp-analytics-mobile.png) | Точный срез → достоверные суммы → сравнение | 27 E2E/сборки прошли; реальные RouterOS/JWT/БД не проверены. Далее — OSPF, точное продолжение в журнале. + +## OSPF — локальная визуальная приёмка 2026-09-30 + +| Экран / референс | ПК | Телефон | Схема | +| --- | --- | --- | --- | +| Интерфейсы · [List 9](https://reui.io/preview/base/list-9?ref=mcp) / [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) | [Снимок](ux-ui/screens/ospf-interfaces-desktop.png) | [Снимок](ux-ui/screens/ospf-interfaces-mobile.png) | Устройство → свежесть → интерфейсы → подтверждённая операция | +| Соседи · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/ospf-neighbors-desktop.png) | [Снимок](ux-ui/screens/ospf-neighbors-mobile.png) | Поиск → один срез графа/таблицы → фактическое состояние | +| Детали · Frame | [Снимок](ux-ui/screens/ospf-detail-desktop.png) | [Снимок](ux-ui/screens/ospf-detail-mobile.png) | URL узла → Router ID → соседи/интерфейсы → возврат фокуса | +| BFD · Data Grid / Stats 12 | [Снимок](ux-ui/screens/ospf-bfd-desktop.png) | [Снимок](ux-ui/screens/ospf-bfd-mobile.png) | Срез → состояния → наблюдаемые таймеры/счётчики | + +HTTP-фикстуры; локальная приёмка: 43 основных + 8 source E2E, 9 unit, production frontend. Реальный RouterOS/JWT/БД не проверен. Далее рекурсивные маршруты; точное продолжение в журнале. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 39a9d56b..371b53db 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.22, достоверные метаданные/узлы OSPF; далее layout/keyboard | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.23; OSPF принят локально, далее рекурсивные маршруты | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.23 — адаптивность и локальная приёмка OSPF (2026-09-30) + +- Одна профильная сводка на вкладку вместо дублирования. Мобильные вкладки/таблицы прокручиваются внутри контейнеров; детали находятся под графом. Выбранный узел центрируется, легенда читабельна и включает согласование/неизвестность; семантические цвета текущей темы. +- Поиск, вкладки, кнопки таблицы и узлы доступны с клавиатуры. Enter/Space открывают детали; Escape закрывает и возвращает фокус. Reduced motion отключает SVG-анимацию, переключатель учитывает настройку устройства. Пульсация меняет прозрачность, сохраняя стабильную цель клика. +- Проверки: 43 профильных E2E, восемь source/history E2E, девять unit, TS/lint и production frontend (34 страницы) прошли. После последних коротких подписей повторно прошли три layout/motion сценария; восемь desktop/mobile снимков просмотрены. Первичный прогон: 44/45, нестабильная SVG-геометрия исправлена и identity click повторно прошёл без force. +- ReUI checklist применён в границах OSPF: Frame/Data Grid и общий KpiStatGrid переиспользованы, registry и общая тема не изменены. Backend regression/build прошли в 10.22; backend после него не менялся. +- OSPF завершён в границах локальной приёмки; реальные RouterOS/DB/JWT остаются внешней проверкой. Далее 10.24: рекурсивные маршруты — сохранение формы, строгий receipt, чтение/контекст/права. Последняя квота: 25% окна, 35% недели использовано; продолжать по поручению пользователя. + ## Блок 10.22 — метаданные и однозначные узлы OSPF (2026-09-30) - Неизвестный subtype OSPF-маршрута показывается нейтрально, без intra-area fallback. Unknown multihop BFD не становится false. Legacy parsers совместимы; строгий каталог проверен raw fixtures. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 96defe38..a82f9d7b 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -140,3 +140,7 @@ q/node сохраняются вместе с server/tab. Таблица и то ## 10.22. Подтверждённые метаданные/узлы Route subtype и BFD multihop неизвестны без наблюдения. Router ID refs/ambiguity не создают ложную связь устройств; только уникальный фактический ID объединяет узлы. 13 E2E, 2 identity unit, backend regression/build и TS/lint прошли. Далее layout/keyboard/motion/приёмка. + +## 10.23. Приёмка OSPF на ПК/телефоне + +Срез устройства → свежесть → профильная сводка → граф/таблица → детали → подтверждение API. Детали на телефоне под графом; Escape возвращает фокус; reduced motion соблюдается. 43 основных + 8 source E2E, 9 unit и production frontend прошли; screenshot/keyboard/motion проверены. Прежние BGP/OSPF backend fixtures/build прошли в 10.22. OSPF принят локально, следующий раздел — рекурсивные маршруты. Реальный RouterOS/JWT/DB не проверен. diff --git a/docs/ux-ui/screens/ospf-bfd-desktop.png b/docs/ux-ui/screens/ospf-bfd-desktop.png new file mode 100644 index 00000000..76d233d0 Binary files /dev/null and b/docs/ux-ui/screens/ospf-bfd-desktop.png differ diff --git a/docs/ux-ui/screens/ospf-bfd-mobile.png b/docs/ux-ui/screens/ospf-bfd-mobile.png new file mode 100644 index 00000000..96ba87f1 Binary files /dev/null and b/docs/ux-ui/screens/ospf-bfd-mobile.png differ diff --git a/docs/ux-ui/screens/ospf-detail-desktop.png b/docs/ux-ui/screens/ospf-detail-desktop.png new file mode 100644 index 00000000..bb49b70e Binary files /dev/null and b/docs/ux-ui/screens/ospf-detail-desktop.png differ diff --git a/docs/ux-ui/screens/ospf-detail-mobile.png b/docs/ux-ui/screens/ospf-detail-mobile.png new file mode 100644 index 00000000..eecd3cc8 Binary files /dev/null and b/docs/ux-ui/screens/ospf-detail-mobile.png differ diff --git a/docs/ux-ui/screens/ospf-interfaces-desktop.png b/docs/ux-ui/screens/ospf-interfaces-desktop.png new file mode 100644 index 00000000..66a5b946 Binary files /dev/null and b/docs/ux-ui/screens/ospf-interfaces-desktop.png differ diff --git a/docs/ux-ui/screens/ospf-interfaces-mobile.png b/docs/ux-ui/screens/ospf-interfaces-mobile.png new file mode 100644 index 00000000..a7c77432 Binary files /dev/null and b/docs/ux-ui/screens/ospf-interfaces-mobile.png differ diff --git a/docs/ux-ui/screens/ospf-neighbors-desktop.png b/docs/ux-ui/screens/ospf-neighbors-desktop.png new file mode 100644 index 00000000..f8d2299f Binary files /dev/null and b/docs/ux-ui/screens/ospf-neighbors-desktop.png differ diff --git a/docs/ux-ui/screens/ospf-neighbors-mobile.png b/docs/ux-ui/screens/ospf-neighbors-mobile.png new file mode 100644 index 00000000..86a95dd2 Binary files /dev/null and b/docs/ux-ui/screens/ospf-neighbors-mobile.png differ diff --git a/hooks/use-prefers-reduced-motion.ts b/hooks/use-prefers-reduced-motion.ts new file mode 100644 index 00000000..d4282f66 --- /dev/null +++ b/hooks/use-prefers-reduced-motion.ts @@ -0,0 +1,5 @@ +"use client" +import { useSyncExternalStore } from "react" +const query = "(prefers-reduced-motion: reduce)" +function subscribe(callback: () => void) { const media=window.matchMedia(query); media.addEventListener("change",callback); return ()=>media.removeEventListener("change",callback) } +export function usePrefersReducedMotion() { return useSyncExternalStore(subscribe,()=>window.matchMedia(query).matches,()=>false) } diff --git a/tests/e2e/ospf-data.spec.ts b/tests/e2e/ospf-data.spec.ts index 838cb5bf..6ab23bba 100644 --- a/tests/e2e/ospf-data.spec.ts +++ b/tests/e2e/ospf-data.spec.ts @@ -59,9 +59,9 @@ test("OSPF: частичный отказ сохраняет устройств await expect(rows.filter({ hasText: "Роутер 2" })).toContainText("Неизвестно") await expect(rows.filter({ hasText: "198.51.100.1" })).toContainText("Full") await page.getByRole("option").filter({ hasText: "Роутер 1" }).click() - await expect(page.getByLabel("Сводка OSPF", { exact: true })).not.toContainText("неизвестно") + await expect(page.getByLabel("Сводка соседей OSPF", { exact: true })).not.toContainText("—") await page.getByRole("option").filter({ hasText: "Роутер 2" }).click() - await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно") + await expect(page.getByLabel("Сводка соседей OSPF", { exact: true })).toContainText("—") phase = 2 await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click() await expect(rows).toHaveCount(0) diff --git a/tests/e2e/ospf-layout.spec.ts b/tests/e2e/ospf-layout.spec.ts new file mode 100644 index 00000000..77e64f33 --- /dev/null +++ b/tests/e2e/ospf-layout.spec.ts @@ -0,0 +1,48 @@ +import {test,expect,type Page} from "@playwright/test" +import {mkdirSync} from "node:fs" +const host=(id:number)=>({serverId:id,serverName:"Роутер "+id,serverSite:"",serverCountry:"RU"}) +async function setup(page: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"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,enabled:true,status:null,country:"RU"})):path==="/api/ospf/catalog"?{interfaces:[1,2].map(id=>({...host(id),id:"i",instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,cost:id===1?20:null,type:"ptp",disabled:false,inactive:false,priority:0,helloInterval:10,deadInterval:40,useBfd:true})),neighbors:[1,2].map(id=>({...host(id),id:"n",address:"192.0.2."+(3-id),routerId:"192.0.2."+(3-id),instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,state:id===1?"Full":"Exchange",uptime:"1h",priority:0,stateChanges:0})),instances:[1,2].map(id=>({...host(id),id:"s",name:"default",routerId:"192.0.2."+id,version:2,disabled:false,inactive:false,redistribute:""})),bfdSessions:[1,2].map(id=>({...host(id),id:"b",localAddr:"192.0.2."+id,remoteAddr:"192.0.2."+(3-id),interface:"ether"+id,state:"Up",uptime:"1h",multihop:id===1?false:null,multiplier:id===1?0:null,txInterval:id===1?0:null,rxInterval:id===1?0:null,holdTime:id===1?0:null,packetsRx:id===1?0:null,packetsTx:id===1?0:null,stateChanges:id===1?0:null})),routes:[],failures:[]}:path==="/api/auth/config"?{required:false}:{}})}) +} +for(const device of ["desktop","mobile"] as const)test("OSPF: layout/keyboard "+device,async({page})=>{ + await page.setViewportSize(device==="desktop"?{width:1440,height:1000}:{width:390,height:844}) + await page.emulateMedia({reducedMotion:"reduce"}) + await page.clock.install({time:new Date("2026-09-30T05:00:00Z")}) + await setup(page);await page.goto("/ospf?server=1") + await expect(page.getByText("ether1",{exact:true})).toBeVisible() + await expect(page.getByRole("textbox",{name:"Поиск в текущем разделе OSPF…"})).toBeVisible() + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + const tabs=page.getByRole("button",{name:"BFD",exact:true}).locator("..") + expect(await tabs.evaluate(el=>el.getBoundingClientRect().right<=innerWidth)).toBe(true) + await page.addStyleTag({content:"nextjs-portal { display:none!important; }"});mkdirSync("docs/ux-ui/screens",{recursive:true}) + await page.screenshot({path:"docs/ux-ui/screens/ospf-interfaces-"+device+".png",animations:"disabled"}) + await page.getByRole("button",{name:"Соседи",exact:true}).focus();await page.keyboard.press("Enter") + const graph=page.getByRole("group",{name:"Топология OSPF"}) + await expect(graph).toBeVisible() + await expect(graph.locator("animate,animateMotion")).toHaveCount(0) + await page.screenshot({path:"docs/ux-ui/screens/ospf-neighbors-"+device+".png",animations:"disabled"}) + const open=page.locator("tr[data-row-id]").getByRole("button",{name:"Открыть узел Роутер 1",exact:true}) + await open.focus();await page.keyboard.press("Enter") + const details=page.getByRole("region",{name:"Детали узла OSPF"});await expect(details).toBeVisible() + await details.scrollIntoViewIfNeeded() + expect(await details.evaluate(el=>el.getBoundingClientRect().right<=innerWidth)).toBe(true) + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + await page.screenshot({path:"docs/ux-ui/screens/ospf-detail-"+device+".png",animations:"disabled"}) + await details.getByRole("button",{name:"Закрыть",exact:true}).focus();await page.keyboard.press("Escape") + await expect(details).toHaveCount(0) + const node=graph.getByRole("button",{name:"Открыть узел Роутер 1",exact:true});await expect(node).toBeFocused() + await page.keyboard.press(" ");await expect(details).toBeVisible() + await page.keyboard.press("Escape");await expect(details).toHaveCount(0) + await page.getByRole("button",{name:"BFD",exact:true}).focus();await page.keyboard.press("Enter") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + await page.screenshot({path:"docs/ux-ui/screens/ospf-bfd-"+device+".png",animations:"disabled"}) +}) +test("OSPF: reduced motion и управление анимацией",async({page})=>{ + await page.emulateMedia({reducedMotion:"reduce"});await setup(page);await page.goto("/ospf?tab=neighbors") + const graph=page.getByRole("group",{name:"Топология OSPF"}),toggle=page.getByRole("button",{name:"Анимация",exact:true}) + await expect(toggle).toBeDisabled();await expect(toggle).toHaveAttribute("aria-pressed","false");await expect(graph.locator("animate,animateMotion")).toHaveCount(0) + await page.emulateMedia({reducedMotion:"no-preference"});await expect(toggle).toBeEnabled();await expect.poll(()=>graph.locator("animate,animateMotion").count()).toBeGreaterThan(0) + await toggle.click();await expect(toggle).toHaveAttribute("aria-pressed","false");await expect(graph.locator("animate,animateMotion")).toHaveCount(0) +})