fix(ui): подтверждать копирование правил Filters
This commit is contained in:
1 parent
1cf7297d64
commit
8412ba9bbe
5 files changed
+141
-33
No files matched your search
+39
-31
@@ -877,30 +877,23 @@ function CopyRulesSheet({
|
||||
open: boolean; onClose: () => void
|
||||
sourceServerId: string; sourceRules: FilterRule[]
|
||||
rulesets: ServerFilterRuleset[]
|
||||
onCopy: (targetServerId: string, rules: FilterRule[], mode: CopyMode) => void
|
||||
onCopy: (targetServerId: string, rules: FilterRule[], mode: CopyMode) => Promise<string | null>
|
||||
serversList: Server[]
|
||||
greByServer: Record<string, GreTunnel[]>
|
||||
ensureGreFor?: (serverId: string) => void
|
||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||
ensureRecursiveFor?: (serverId: string) => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(sourceRules.map(r => r.id)))
|
||||
const [targetId, setTargetId] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<CopyMode>("append")
|
||||
const [done, setDone] = useState(false)
|
||||
const [copying, setCopying] = useState(false)
|
||||
const [copyError, setCopyError] = useState<string | null>(null)
|
||||
const copyLock = useRef(false)
|
||||
|
||||
const sourceRecursiveRoutes = recRoutesByServer[sourceServerId] ?? []
|
||||
const targetRecursiveRoutes = targetId ? (recRoutesByServer[targetId] ?? []) : []
|
||||
|
||||
// Reset each time the sheet opens
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!open) { setDone(false); return }
|
||||
setSelected(new Set(sourceRules.map(r => r.id)))
|
||||
setTargetId(null); setMode("append"); setDone(false)
|
||||
})
|
||||
}, [open, sourceRules])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !ensureGreFor) return
|
||||
ensureGreFor(sourceServerId)
|
||||
@@ -975,8 +968,11 @@ function CopyRulesSheet({
|
||||
)
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (!targetId || selectedRules.length === 0) return
|
||||
async function handleCopy() {
|
||||
if (copyLock.current || !targetId || selectedRules.length === 0) return
|
||||
copyLock.current = true
|
||||
setCopying(true)
|
||||
setCopyError(null)
|
||||
const copied: FilterRule[] = selectedRules.map(r => {
|
||||
if ((r.action ?? "route") === "blackhole") {
|
||||
return { ...r, id: newId() }
|
||||
@@ -998,15 +994,23 @@ function CopyRulesSheet({
|
||||
gatewayTunnelId: m?.tunnelId ?? "",
|
||||
}
|
||||
})
|
||||
onCopy(targetId, copied, mode)
|
||||
setDone(true)
|
||||
setTimeout(() => onClose(), 900)
|
||||
try {
|
||||
const error = await onCopy(targetId, copied, mode)
|
||||
if (error) setCopyError(error)
|
||||
else onClose()
|
||||
} catch (error) {
|
||||
setCopyError(error instanceof Error ? error.message : "Не удалось скопировать правила")
|
||||
} finally {
|
||||
copyLock.current = false
|
||||
setCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canCopy = targetId !== null && selectedRules.length > 0 && !done
|
||||
const close = () => { if (!copyLock.current) onClose() }
|
||||
const canCopy = targetId !== null && selectedRules.length > 0 && !copying
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) close() }}>
|
||||
<SheetContent side="right" className="sm:max-w-[500px] p-0 flex flex-col" showCloseButton={false}>
|
||||
|
||||
{/* header */}
|
||||
@@ -1024,13 +1028,13 @@ function CopyRulesSheet({
|
||||
<span className="text-muted-foreground">целевой сервер</span>
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5">
|
||||
<Button variant="ghost" size="icon-sm" onClick={close} disabled={copying} aria-label="Закрыть копирование" className="shrink-0 mt-0.5">
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto flex flex-col divide-y divide-border">
|
||||
<fieldset disabled={copying} aria-busy={copying} className="flex-1 min-h-0 overflow-y-auto flex flex-col divide-y divide-border">
|
||||
|
||||
{/* ── rule selection ── */}
|
||||
<div className="px-5 py-4 flex flex-col gap-2">
|
||||
@@ -1053,7 +1057,7 @@ function CopyRulesSheet({
|
||||
: undefined
|
||||
const isSelected = selected.has(r.id)
|
||||
return (
|
||||
<button key={r.id} type="button" onClick={() => toggleRule(r.id)}
|
||||
<button key={r.id} type="button" aria-pressed={isSelected} onClick={() => toggleRule(r.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-lg border text-left transition-all",
|
||||
isSelected
|
||||
@@ -1106,7 +1110,7 @@ function CopyRulesSheet({
|
||||
const ruleCount = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
|
||||
const isTarget = targetId === s.id
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => setTargetId(s.id)}
|
||||
<button key={s.id} type="button" aria-pressed={isTarget} onClick={() => setTargetId(s.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2.5 rounded-lg border text-left transition-all",
|
||||
isTarget
|
||||
@@ -1136,7 +1140,7 @@ function CopyRulesSheet({
|
||||
{ v: "append" as CopyMode, label: "Добавить", sub: `+${selectedRules.length} к ${targetRules.length} существующим` },
|
||||
{ v: "replace" as CopyMode, label: "Заменить", sub: `${targetRules.length} правил → ${selectedRules.length} новых` },
|
||||
] as const).map(opt => (
|
||||
<button key={opt.v} type="button" onClick={() => setMode(opt.v)}
|
||||
<button key={opt.v} type="button" aria-pressed={mode === opt.v} onClick={() => setMode(opt.v)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 text-left px-3 py-2.5 rounded-lg border transition-all",
|
||||
mode === opt.v
|
||||
@@ -1218,18 +1222,20 @@ function CopyRulesSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
{copyError && <p role="alert" className="px-5 py-3 text-sm text-destructive">{copyError}</p>}
|
||||
{copying && <p role="status" className="px-5 py-2 text-sm text-muted-foreground">Ожидание подтверждения копирования…</p>}
|
||||
|
||||
{/* footer */}
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button variant="outline" onClick={close} disabled={copying} className="flex-1">Отмена</Button>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!canCopy}
|
||||
className="flex-1"
|
||||
>
|
||||
{done
|
||||
? <><CheckIcon className="size-4" />Скопировано!</>
|
||||
{copying
|
||||
? <><LoaderCircleIcon className="size-4 animate-spin" />Копирование…</>
|
||||
: <><CopyIcon className="size-4" />Копировать {selectedRules.length} правил</>
|
||||
}
|
||||
</Button>
|
||||
@@ -1630,10 +1636,11 @@ export default function FiltersPage() {
|
||||
updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
|
||||
const handleCopyRules = useCallback(async (targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
|
||||
const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
|
||||
const next = mode === "replace" ? rules : [...existing, ...rules]
|
||||
void applyRules(targetServerId, next, "copy")
|
||||
const result = await applyRules(targetServerId, next, "copy")
|
||||
return result.ok ? null : result.error ?? "Не удалось скопировать правила"
|
||||
}, [rulesets, applyRules])
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (mutationsLocked || index === 0) return
|
||||
@@ -1904,6 +1911,7 @@ export default function FiltersPage() {
|
||||
/>
|
||||
|
||||
<CopyRulesSheet
|
||||
key={`${copyOpen}-${selectedServerId}`}
|
||||
open={copyOpen}
|
||||
onClose={() => setCopyOpen(false)}
|
||||
sourceServerId={selectedServerId}
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-спLine truncated
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-спLine truncated
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -25,12 +25,20 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.35 — IPsec/GRE/VXLAN, Firewall и сохранение Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.36 — IPsec/GRE/VXLAN, Firewall, сохранение и копирование Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.36 — подтверждённое копирование Filters (2026-09-30)
|
||||
|
||||
- CopyRulesSheet ожидает applyRules через асинхронный onCopy. Преждевременные done и таймер закрытия удалены: форма закрывается после ok=true, при отказе сохраняет выбранные правила, целевой сервер и режим append/replace и показывает API-ошибку в alert. Общий applyRules продолжает обновлять целевой список только после подтверждения.
|
||||
- Синхронный замок формы исключает двойную отправку. Fieldset и закрытие/отмена заблокированы во время запроса; Escape сохраняет открытую форму, status сообщает ожидание. Выбранные правила, цель и режим обозначены aria-pressed. Новый сеанс формы инициализируется ключом открытия/исходного устройства; обновление sourceRules больше не сбрасывает пользовательский выбор через effect. Существующие Sheet/Button и маппинг GRE/рекурсивных шлюзов сохранены.
|
||||
- Пять E2E прошли: два сценария копирования append/replace (подмножество правил, сохранение/замена правил цели, новые ID, 403, пустой ответ, ok=false, повтор, двойное нажатие, блокировка/ошибка/повторное открытие и переход к целевой таблице), прежние два сохранения и таблица. Первый прогон копирования остановился только на ошибочном button-селекторе панели серверов; после замены на option весь прогон прошёл. TypeScript прошёл; новый тест lint-чистый, исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Последние build: frontend — 9.31, backend — 9.32, в этом блоке не повторялись.
|
||||
- Следующий точный шаг: Filters — удаление правил с сохранением подтверждения/ошибки и защитой повторов, затем перемещение и общий замок восстановления/обновления. Схемы ответов, гонки чтения/смены backend, первоначальная подмена live-ошибки моками и достоверность чтения целевого ruleset ещё открыты. Маппинг шлюзов и применение на настоящих RouterOS/JWT/БД этим блоком не приняты; этап 9 открыт.
|
||||
- Квота: 59% пятичасовой/9% недельной на старте, 61%/10% после проверок. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.35 — подтверждённое сохранение Filters (2026-09-30)
|
||||
|
||||
- Создание/редактирование правила ожидает результат PUT /api/filters/rules. applyRules возвращает результат, требует ok=true и обновляет live-список только после подтверждения; при ошибке прежний список остаётся. Подтверждённые rules из ответа имеют приоритет над отправленным списком. Общий путь применяется также к копированию/удалению/переносу, но их отдельный UX ещё не принят.
|
||||
|
||||
@@ -181,3 +181,7 @@ Backend сообщает failures по срезу, неполный snapshot о
|
||||
## Блок 9.35 — сохранение Filters (2026-09-30)
|
||||
|
||||
Создание и редактирование ожидают ok=true, ошибки сохраняют форму и прежние строки. Блокируются повторы/закрытие, есть inline-ошибка и статус ожидания. Повторное открытие начинает новую форму. Пять профильных E2E, TypeScript и lint новых файлов прошли; исходные замечания страницы сохранены. Далее — CopyRulesSheet: убрать преждевременный done, дождаться применения и сохранить выбор при ошибке. Загрузка/схемы/гонки Filters и реальные устройства ещё не проверены; frontend build — 9.31, backend — 9.32.
|
||||
|
||||
## Блок 9.36 — копирование Filters (2026-09-30)
|
||||
|
||||
Копирование append/replace ожидает подтверждения API; ошибка сохраняет правила, цель и режим. Повторы и закрытие заблокированы до ответа, преждевременный done/таймер удалены. Пять E2E, TypeScript и lint нового теста прошли; исходные замечания страницы сохранены. Далее — подтверждение удаления Filters, затем перемещение и общий замок. Гонки/схемы/загрузка целевого ruleset и реальный маппинг шлюзов остаются открытыми; frontend build — 9.31, backend — 9.32.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import type { FilterRule } from "../../lib/data"
|
||||
|
||||
for (const mode of ["append", "replace"] as const) {
|
||||
test(`Filters: копирование ${mode} подтверждает API и сохраняет выбор при ошибке`, async ({ page }) => {
|
||||
const makeRule = (id: string, community: string, description: string): FilterRule => ({ id, community, description, gateway: "", gatewayTunnelId: "", action: "blackhole" })
|
||||
const sourceRules = [makeRule("source-1", "65001:101", "Выбранное правило"), makeRule("source-2", "65001:102", "Исключённое правило")]
|
||||
let targetRules = [makeRule("target-1", "65002:201", "Правило цели")]
|
||||
let attempts = 0
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
|
||||
attempts++
|
||||
const body = route.request().postDataJSON()
|
||||
expect(body.serverId).toBe("2")
|
||||
expect(body.source).toBe("copy")
|
||||
expect(body.rules).toHaveLength(mode === "append" ? 2 : 1)
|
||||
const copied = body.rules.find((r: FilterRule) => r.community === "65001:101")
|
||||
expect(copied).toMatchObject({ description: "Выбранное правило", action: "blackhole" })
|
||||
expect(copied.id).not.toBe("source-1")
|
||||
expect(body.rules.some((r: FilterRule) => r.community === "65001:102")).toBe(false)
|
||||
if (mode === "append") expect(body.rules[0]).toEqual(targetRules[0])
|
||||
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Копирование запрещено тест" } }) }
|
||||
if (attempts === 2) return route.fulfill({ json: {} })
|
||||
if (attempts === 3) return route.fulfill({ json: { ok: false } })
|
||||
targetRules = body.rules
|
||||
return route.fulfill({ json: { ok: true, rules: targetRules } })
|
||||
}
|
||||
const requested = new URL(route.request().url()).searchParams.get("serverId")
|
||||
const rulesets = [{ serverId: "1", rules: sourceRules }, { serverId: "2", rules: targetRules }]
|
||||
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: requested ? rulesets.filter(r => r.serverId === requested) : rulesets, greTunnels: [], live: true }
|
||||
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: id === 1 ? "Исходный роутер" : "Целевой роутер", host: `192.0.2.${id}`, type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }))
|
||||
: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
|
||||
: path === "/api/recursive-routes" ? { routes: [] } : {} })
|
||||
})
|
||||
await page.goto("/filters")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await page.getByRole("button", { name: "Копировать", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
const excluded = dialog.getByRole("button", { name: /65001:102/ })
|
||||
const chosen = dialog.getByRole("button", { name: /65001:101/ })
|
||||
await excluded.click()
|
||||
const target = dialog.getByRole("button", { name: /Целевой роутер/ })
|
||||
await target.click()
|
||||
const copyMode = dialog.getByRole("button", { name: mode === "append" ? /^Добавить/ : /^Заменить/ })
|
||||
await copyMode.click()
|
||||
const submit = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
|
||||
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения копирования")
|
||||
for (const control of [excluded, chosen, target, copyMode, dialog.getByRole("button", { name: "Отмена", exact: true }), dialog.getByRole("button", { name: "Закрыть копирование", exact: true })]) await expect(control).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.getByText("Скопировано!", { exact: true })).toHaveCount(0)
|
||||
release!()
|
||||
await expect(dialog.getByRole("alert")).toContainText("Копирование запрещено тест")
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await expect(target).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(copyMode).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(chosen).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(excluded).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
|
||||
await submit.click()
|
||||
if (attempt < 3) {
|
||||
await expect.poll(() => attempts).toBe(attempt + 1)
|
||||
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
|
||||
}
|
||||
}
|
||||
await expect(dialog).toHaveCount(0)
|
||||
expect(attempts).toBe(4)
|
||||
await expect(rows).toHaveCount(2)
|
||||
await page.getByRole("button", { name: "Копировать", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(excluded).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(dialog.getByRole("button", { name: /Целевой роутер/ })).toHaveAttribute("aria-pressed", "false")
|
||||
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
|
||||
await page.getByRole("option", { name: /Целевой роутер/ }).click()
|
||||
await expect(rows).toHaveCount(mode === "append" ? 2 : 1)
|
||||
await expect(rows.filter({ hasText: "Выбранное правило" })).toHaveCount(1)
|
||||
await expect(rows.filter({ hasText: "Исключённое правило" })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user