fix(ui): завершить доступное управление картой сети

This commit is contained in:
Denozordec committed 2026-10-01 03:26:31 +07:00
1 parent bc83b8dbbf
commit bc20e5b9f4
11 files changed
+116 -37

No files matched your search

+60 -35
View File
@@ -671,7 +671,7 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
)
}
function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onPointerDown }: {
n: Server & { x: number; y: number }
labelScale?: number
isSel: boolean; isVis: boolean; isDragged: boolean
@@ -681,7 +681,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
onDblClick: () => void
onHoverChange: (hov: boolean) => void
onContextMenu: (e: React.MouseEvent) => void
onMouseDown: (e: React.MouseEvent) => void
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
}) {
const ss = STATUS_STYLE[n.status]
const ts = TYPE_STYLE[n.type]
@@ -692,7 +692,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
transform={`translate(${n.x},${n.y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? 1 : 0.07}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onClick() }}
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
@@ -785,7 +785,7 @@ function ServiceNode({
dim,
shareLabel,
onClick,
onMouseDown,
onPointerDown,
}: {
label: string
share: number
@@ -801,7 +801,7 @@ function ServiceNode({
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
shareLabel?: string
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
}) {
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
@@ -812,7 +812,7 @@ function ServiceNode({
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
@@ -916,12 +916,12 @@ function ServicePathList({
)
}
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPointerDown }: {
x: number; y: number
wan: { name: string; isp: string; maxDl: number; maxUl: number }
color: string; active: boolean; isSel: boolean; isDragged: boolean
onSelect: () => void
onMouseDown: (e: React.MouseEvent) => void
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
}) {
const r = 18
return (
@@ -929,7 +929,7 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMo
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}>
{active && (
<circle r={r + 6} fill={color} opacity="0.12">
@@ -1766,6 +1766,8 @@ function NetworkMapContent() {
return () => svg.removeEventListener("wheel", handler)
}, [])
const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector<HTMLSelectElement>("[data-map-device-select]")?.focus())},[])
// ── Zoom helpers ──────────────────────────────────────────────────────────
const applyZoomCenter=useCallback((factor: number) => {
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
@@ -1798,6 +1800,7 @@ function NetworkMapContent() {
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
returnToMapControl()
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
@@ -1807,16 +1810,21 @@ function NetworkMapContent() {
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers])
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers,returnToMapControl])
// ── Drag ─────────────────────────────────────────────────────────────────
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
function onSvgPointerDown(e: React.PointerEvent<SVGSVGElement>) {
if(!e.isPrimary||e.button!==0)return
if(e.target!==e.currentTarget&&!(e.target instanceof Element&&e.target.hasAttribute("data-map-background")))return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
// Only pan on background clicks (node mousedown stops propagation)
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
setIsDragging(true)
}
function onSvgMouseMove(e: React.MouseEvent<SVGSVGElement>) {
function onSvgPointerMove(e: React.PointerEvent<SVGSVGElement>) {
if(!e.isPrimary)return
const nd = nodeDragRef.current
if (nd) {
const dx = e.clientX - nd.startX
@@ -1863,8 +1871,10 @@ function NetworkMapContent() {
return true
}
function onSvgMouseUp() {
function onSvgPointerUp(e:React.PointerEvent<SVGSVGElement>) {
if(!e.isPrimary||e.button!==0)return
if (finishNodeDrag()) return
if(!dragRef.current)return
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
@@ -1879,15 +1889,24 @@ function NetworkMapContent() {
}
// ── Node drag start ──────────────────────────────────────────────────────
function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) {
function onNodePointerDown(e: React.PointerEvent<SVGGElement>, n: Server & {x: number; y: number}) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
setDraggedNodeId(n.id)
}
function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) {
function onSatPointerDown(e: React.PointerEvent<SVGGElement>, homeId: string, wanIdx: number, x: number, y: number) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSatKey(`${homeId}-${wanIdx}`)
}
function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) {
function onServicePointerDown(e: React.PointerEvent<SVGGElement>, svcId: string, x: number, y: number) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSvcId(svcId)
}
@@ -2062,7 +2081,7 @@ function NetworkMapContent() {
Обновить
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={exportSvg}>
<Button type="button" variant="outline" size="sm" onClick={exportSvg} disabled={useLiveData&&(!hasSnapshot||!mapServers.length)}>
<DownloadIcon aria-hidden="true" className="size-4" />Экспорт SVG
</Button>
</>
@@ -2237,17 +2256,19 @@ function NetworkMapContent() {
<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>
<svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{if(e.target!==e.currentTarget||e.ctrlKey||e.metaKey||e.altKey)return;const delta=40/zoom;if(e.key==="ArrowLeft"||e.key==="ArrowRight"||e.key==="ArrowUp"||e.key==="ArrowDown"){e.preventDefault();e.stopPropagation();setPan(value=>({x:value.x+(e.key==="ArrowLeft"?-delta:e.key==="ArrowRight"?delta:0),y:value.y+(e.key==="ArrowUp"?-delta:e.key==="ArrowDown"?delta:0)}))}}}
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`}
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
onMouseMove={onSvgMouseMove}
onMouseUp={onSvgMouseUp}
onMouseLeave={() => {
finishNodeDrag()
dragRef.current = null; setIsDragging(false); setHoveredId(null)
}}
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none", touchAction:"none" }}
onPointerDown={onSvgPointerDown}
onPointerMove={onSvgPointerMove}
onPointerUp={onSvgPointerUp}
onPointerLeave={()=>setHoveredId(null)}
onPointerCancel={()=>{finishNodeDrag();suppressClickRef.current=false;dragRef.current=null;setIsDragging(false)}}
onLostPointerCapture={()=>{finishNodeDrag();dragRef.current=null;setIsDragging(false)}}
>
<defs>
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
@@ -2262,7 +2283,7 @@ function NetworkMapContent() {
</defs>
{/* Background grid */}
<rect width={W} height={H} fill="url(#map-dots)" />
<rect data-map-background x={pan.x} y={pan.y} width={W/zoom} height={viewportHeight/zoom} fill="url(#map-dots)" />
{/* ── GRE edges ── */}
{greEdges.map((e, i) => {
@@ -2474,7 +2495,7 @@ function NetworkMapContent() {
onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
onHoverChange={h => setHoveredId(h ? n.id : null)}
onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
onMouseDown={e => onNodeMouseDown(e, n)}
onPointerDown={e => onNodePointerDown(e, n)}
/>
))}
@@ -2498,7 +2519,7 @@ function NetworkMapContent() {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectWan(router, wIdx)
}}
onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)}
onPointerDown={e => onSatPointerDown(e, router.id, wIdx, sat.x, sat.y)}
/>
</g>
)
@@ -2521,7 +2542,7 @@ function NetworkMapContent() {
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
@@ -2546,7 +2567,7 @@ function NetworkMapContent() {
isDragged={draggedSvcId === svc.id}
destMode="services"
shareLabel="трафика страны"
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
@@ -2722,7 +2743,7 @@ function NetworkMapContent() {
{/* ── Keyboard hints (viewport-fixed) ── */}
{showHints && (
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + viewportHeight / zoom - 14})`}>
{[
["Scroll", "zoom"],
["+/-", "zoom in/out"],
@@ -2745,6 +2766,10 @@ function NetworkMapContent() {
)}
</svg>
{useLiveData&&!hasSnapshot&&dataLoading&&<p role="status" className="absolute inset-0 flex items-center justify-center bg-background/80 text-sm">Загружаем карту…</p>}
{(hasSnapshot||!useLiveData)&&!nodes.length&&<div role="status" className="absolute inset-0 flex flex-col gap-2 items-center justify-center bg-background/90 p-4 text-center"><p className="font-medium">Устройств на карте пока нет</p><p className="text-sm text-muted-foreground">В текущем источнике нет устройств для построения схемы.</p><Link href="/servers" className="text-sm underline">Открыть серверы</Link></div>}
{!!nodes.length&&!nodes.some(isVisible)&&<div role="status" className="absolute left-3 right-3 top-16 rounded-md border bg-popover p-3 text-sm"><p>Узлы по заданным условиям не найдены.</p><Button type="button" variant="outline" size="sm" className="mt-2" onClick={()=>updateUrl({q:null,filter:null})}>Сбросить срез карты</Button></div>}
{/* ── Zoom controls ── */}
<ZoomControls
zoom={zoom}
@@ -2815,7 +2840,7 @@ function NetworkMapContent() {
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button"
aria-label="Закрыть детали GRE" onClick={() => setSelectedGreEdge(null)}
aria-label="Закрыть детали GRE" onClick={() => {setSelectedGreEdge(null);returnToMapControl()}}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>
@@ -3011,7 +3036,7 @@ function NetworkMapContent() {
</p>
</div>
<button type="button"
aria-label="Закрыть детали назначения" onClick={() => setSelectedService(null)}
aria-label="Закрыть детали назначения" onClick={() => {setSelectedService(null);returnToMapControl()}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon aria-hidden="true" className="size-4" />
@@ -3206,7 +3231,7 @@ function NetworkMapContent() {
aria-label="Навести на карте" title="Навести на карте">
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null) }}
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null); returnToMapControl() }}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>
+1 -1
View File
@@ -189,4 +189,4 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Камера и читаемость малой топологии доработаны в 10.53 (16 E2E + 2 unit). Следом pointer/touch и пустые состояния; итоговая приёмка карты ещё не завершена.
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Камера и читаемость малой топологии доработаны в 10.53 (16 E2E + 2 unit). Pointer/touch и пустые состояния приняты в 10.54: 39 основных + 12 source E2E, 10 unit и production frontend build. Реальные RouterOS/JWT/БД остаются внешней приёмкой; далее терминал.
+10 -1
View File
@@ -26,13 +26,22 @@
| 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.53; оптимизатор принят локально, карта сети в работе |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.54; оптимизатор и карта приняты локально, далее терминал |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.54 — Pointer/touch и локальная приёмка карты (2026-10-01)
- Pointer Events/capture удерживают drag за пределами canvas; основной указатель отделён от остальных, cancel/lost capture завершают жест без залипания. Touch-action отключает прокрутку только на SVG; детали продолжают прокручиваться. Фокусируемый canvas поддерживает стрелки, zoom/fit имеют клавиатурные альтернативы.
- Закрытие деталей кнопкой/Escape возвращает фокус к выбору устройства. Пустой inventory явно предлагает серверы; поиск без результатов даёт сброс только среза. Без подтверждённого снимка экспорт недоступен; загрузка объявляется role=status.
- Карта принята локально: 39 основных + 12 source E2E, 10 unit, TypeScript, lint и production frontend build прошли. ReUI Frame checklist повторён, адаптивные снимки обновлены. Touch проверен через реальный Chromium input protocol; физических телефонов в приёмке нет.
- Границы: HTTP фикстуры не подтверждают RouterOS/JWT/БД; WAN пунктир остаётся моделью, Flow свежесть — время браузерного ответа; SVG флаги зависят от существующего CDN. Фильтр сохраняет приглушённые узлы как контекст, pan/zoom/drag локальные. SVG XML валиден, сторонние SVG viewers отдельно не проверены.
- Далее 10.55: терминал — API-контракт, выбранное устройство/host из URL, достоверность выполнения и ошибки/повтор/права. Этап 10 ещё не завершён.
## Блок 10.53 — камера и читаемость карты (2026-10-01)
- ViewBox учитывает реальное соотношение сторон canvas; начальное вписывание включает устройства, WAN, сервисы и подписи. Оно выполняется один раз на источник и не сбрасывает ручной zoom после обновления API. Кнопка/горячая клавиша вписывают текущие объекты; wheel/drag используют ту же геометрию.
+9
View File
@@ -301,3 +301,12 @@ Shared контракт и backend observed исходные значения/д
- Название вынесено под узел; на телефоне размер текста компенсирует SVG масштаб, длинная подпись сокращена при сохранённом полном accessible name/деталях. Страна обозначена ISO при отсутствии площадки и до загрузки внешнего изображения флага. Легенда — доступные раскрываемые обозначения вне SVG.
- WAN карточка получила Enter/Space и aria-pressed. 2 camera unit + 16 E2E layout/details/URL, TypeScript и lint прошли; после ISO fallback повторены 5 layout E2E, TypeScript и lint. Снимки обновлены.
- Далее pointer/touch, закрытие деталей с возвратом фокуса, пустой inventory/пустой срез; затем production build и локальная приёмка карты. Реальная сеть не проверена.
## Блок 10.54 — Pointer/touch и локальная приёмка карты (2026-10-01)
- Pointer Events/capture удерживают drag за пределами canvas; основной указатель отделён от остальных, cancel/lost capture завершают жест без залипания. Touch-action отключает прокрутку только на SVG; детали продолжают прокручиваться. Фокусируемый canvas поддерживает стрелки, zoom/fit имеют клавиатурные альтернативы.
- Закрытие деталей кнопкой/Escape возвращает фокус к выбору устройства. Пустой inventory явно предлагает серверы; поиск без результатов даёт сброс только среза. Без подтверждённого снимка экспорт недоступен; загрузка объявляется role=status.
- Карта принята локально: 39 основных + 12 source E2E, 10 unit, TypeScript, lint и production frontend build прошли. ReUI Frame checklist повторён, адаптивные снимки обновлены. Touch проверен через реальный Chromium input protocol; физических телефонов в приёмке нет.
- Границы: HTTP фикстуры не подтверждают RouterOS/JWT/БД; WAN пунктир остаётся моделью, Flow свежесть — время браузерного ответа; SVG флаги зависят от существующего CDN. Фильтр сохраняет приглушённые узлы как контекст, pan/zoom/drag локальные. SVG XML валиден, сторонние SVG viewers отдельно не проверены.
- Далее 10.55: терминал — API-контракт, выбранное устройство/host из URL, достоверность выполнения и ошибки/повтор/права. Этап 10 ещё не завершён.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+36
View File
@@ -62,3 +62,39 @@ test("Карта: пропорции камеры, ручной масштаб
const wan=page.getByRole("button",{name:"WAN WAN",exact:true});await wan.focus();await wan.press("Enter")
await expect(page).toHaveURL(/wan=0/);await expect(wan).toHaveAttribute("aria-pressed","true")
})
test("Карта: pointer capture, клавиатурный pan и возврат фокуса",async({page})=>{
await page.goto("/network-map");const map=page.getByRole("group",{name:"Интерактивная карта сети"}),node=page.getByRole("button",{name:/^Устройство Домашний/})
await expect(node).toBeVisible();await map.focus();const before=await map.getAttribute("viewBox");await map.press("ArrowDown");expect(await map.getAttribute("viewBox")).not.toBe(before)
await page.getByRole("combobox",{name:"Устройство на карте"}).selectOption("1")
const transform=await node.getAttribute("transform"),point=await node.evaluate((g:SVGGElement)=>{const matrix=g.getScreenCTM()!;return {x:matrix.e,y:matrix.f}})
await page.mouse.move(point.x,point.y);await page.mouse.down();await page.mouse.move(point.x+80,point.y+25,{steps:5});await page.mouse.up()
expect(await node.getAttribute("transform")).not.toBe(transform);await expect(page).toHaveURL(/server=1/)
await page.getByRole("button",{name:"Закрыть детали устройства"}).click();await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeFocused()
const box=(await map.boundingBox())!,pan=await map.getAttribute("viewBox")
await page.mouse.move(box.x+15,box.y+box.height-50);await page.mouse.down();await page.mouse.move(box.x-30,box.y+box.height-80,{steps:5});await page.mouse.up()
expect(await map.getAttribute("viewBox")).not.toBe(pan)
const stopped=await map.getAttribute("viewBox");await page.mouse.move(box.x+100,box.y+100);expect(await map.getAttribute("viewBox")).toBe(stopped)
})
test("Карта: touch pan и отмена жеста без залипания",async({page})=>{
await page.setViewportSize({width:390,height:900});await page.goto("/network-map")
const map=page.getByRole("group",{name:"Интерактивная карта сети"});await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
expect(await map.evaluate(e=>getComputedStyle(e).touchAction)).toBe("none")
const box=(await map.boundingBox())!,before=await map.getAttribute("viewBox"),session=await page.context().newCDPSession(page)
await session.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x:box.x+20,y:box.y+box.height-70,id:1}]})
await session.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x:box.x+70,y:box.y+box.height-100,id:1}]})
await session.send("Input.dispatchTouchEvent",{type:"touchCancel",touchPoints:[]})
expect(await map.getAttribute("viewBox")).not.toBe(before)
const stopped=await map.getAttribute("viewBox");await page.mouse.move(box.x+150,box.y+50);expect(await map.getAttribute("viewBox")).toBe(stopped)
await page.getByRole("button",{name:/^Устройство Домашний/}).click();await expect(page.getByRole("complementary",{name:"Детали объекта карты"})).toBeVisible()
})
test("Карта: пустой источник отличается от пустого среза",async({page})=>{
await page.goto("/network-map");await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
await page.getByRole("textbox",{name:"Поиск узла"}).fill("нет такого узла");await expect(page.getByRole("status").filter({hasText:"Узлы по заданным условиям"})).toBeVisible()
await page.getByRole("button",{name:"Сбросить срез карты"}).click();await expect(page.getByRole("textbox",{name:"Поиск узла"})).toHaveValue("")
await expect(page.getByRole("button",{name:/^Устройство Домашний/})).toBeVisible()
await page.route("**/api/servers",r=>r.fulfill({json:[]}));await page.route("**/api/filters/gre-tunnels",r=>r.fulfill({json:{tunnels:[],failures:[]}}))
await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByRole("status").filter({hasText:"Устройств на карте пока нет"})).toBeVisible();await expect(page.getByRole("link",{name:"Открыть серверы",exact:true})).toHaveAttribute("href","/servers")
await expect(page.getByRole("button",{name:"Экспорт SVG",exact:true})).toBeDisabled()
})