fix(ui): проверить терминал и мобильный выбор устройства

This commit is contained in:
Denozordec committed 2026-10-01 03:59:47 +07:00
1 parent ea4095d1f1
commit 8bda85129f
23 files changed
+102 -9

No files matched your search

+7 -5
View File
@@ -67,9 +67,10 @@ function ServerRailMobileButton() {
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail() const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
return ( return (
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className={collapsed ? undefined : "md:hidden"} className={cn("min-w-0 max-w-full",collapsed ? undefined : "md:hidden")}
aria-label={collapsed ? "Показать список серверов" : "Серверы"} aria-label={collapsed ? "Показать список серверов" : "Серверы"}
onClick={() => { onClick={() => {
if (window.matchMedia(DESKTOP_MQ).matches) { if (window.matchMedia(DESKTOP_MQ).matches) {
@@ -79,8 +80,8 @@ function ServerRailMobileButton() {
openRail() openRail()
}} }}
> >
<ServerIcon data-icon="inline-start" /> <ServerIcon aria-hidden="true" data-icon="inline-start" />
{selectedLabel} <span className="min-w-0 truncate">{selectedLabel}</span>
</Button> </Button>
) )
} }
@@ -123,8 +124,9 @@ function ServerRailLayout({
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
useEffect(() => { useEffect(() => {
setCollapsed(readCollapsed()) let cancelled=false
setHydrated(true) queueMicrotask(()=>{if(cancelled)return;setCollapsed(readCollapsed());setHydrated(true)})
return()=>{cancelled=true}
}, []) }, [])
useEffect(() => { useEffect(() => {
+9
View File
@@ -190,3 +190,12 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
| Детали · 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) | Устройство → фактические поля → действия → возврат | | Детали · 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 и пустые состояния приняты в 10.54: 39 основных + 12 source E2E, 10 unit и production frontend build. Реальные RouterOS/JWT/БД остаются внешней приёмкой; далее терминал. Оболочка и клавиатурные действия проверены (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.57
REST запрос → ожидание → подтверждённый output / ошибка с сохранением команды. Выбор устройства и поле команды остаются доступными на ПК и телефоне. Референс — существующий ReUI Frame и List 9; детали проверок в журнале.
| Экран | ПК | Телефон |
| --- | --- | --- |
| Готовность | [PNG](ux-ui/screens/terminal-ready-desktop.png) | [390 px](ux-ui/screens/terminal-ready-mobile.png), [320 px](ux-ui/screens/terminal-ready-narrow.png) |
| Вывод | [PNG](ux-ui/screens/terminal-output-desktop.png) | [390 px](ux-ui/screens/terminal-output-mobile.png), [320 px](ux-ui/screens/terminal-output-narrow.png) |
+8 -1
View File
@@ -26,7 +26,7 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.56; оптимизатор и карта приняты локально, терминал в работе | | 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.57; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,13 @@
## Блок 10.57 — локальная приёмка терминала (2026-10-01)
- Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода.
- Снимки ready/output для ПК, 390 и 320 px сохранены. Общая мобильная кнопка ServerRail теперь ограничена шириной и сокращает длинное имя; гидратация учитывает cleanup. 12 регрессионных сценариев GRE/VXLAN/IPsec/OSPF/recursive прошли; восемь обновлённых снимков просмотрены и сохранены. ReUI Frame checklist повторён.
- Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве.
- Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б.
## Блок 10.56 — честный backend exec (2026-10-01) ## Блок 10.56 — честный backend exec (2026-10-01)
- BFD/API отказ не подменяется пустым успехом: upstream ошибка возвращает 502/error, неподдерживаемая команда или неверные аргументы — 400/error; успешный output сохраняет прежнюю форму. Frontend 10.55 понимает новые ошибки и legacy error: output. - BFD/API отказ не подменяется пустым успехом: upstream ошибка возвращает 502/error, неподдерживаемая команда или неверные аргументы — 400/error; успешный output сохраняет прежнюю форму. Frontend 10.55 понимает новые ошибки и legacy error: output.
+11 -2
View File
@@ -30,8 +30,9 @@
| 3 | Рекурсивные маршруты | Ответы каталогов и смена устройства требуют проверки; операции связаны с историей | Readiness, точный serverId, повтор/ошибка, URL | | 3 | Рекурсивные маршруты | Ответы каталогов и смена устройства требуют проверки; операции связаны с историей | Readiness, точный serverId, повтор/ошибка, URL |
| 4 | Оптимизатор | Ошибка зависимого каталога скрывается; общий сбой удаляет последние данные | Неполнота явно показана, применение требует достоверного контекста | | 4 | Оптимизатор | Ошибка зависимого каталога скрывается; общий сбой удаляет последние данные | Неполнота явно показана, применение требует достоверного контекста |
| 5 | Карта сети | GRE failures игнорируются; отказ GRE/probes превращается в пустые данные; нет общей защиты поздних ответов | Кэш и неизвестные рёбра/узлы, смена источника, пустые/ошибочные состояния | | 5 | Карта сети | GRE failures игнорируются; отказ GRE/probes превращается в пустые данные; нет общей защиты поздних ответов | Кэш и неизвестные рёбра/узлы, смена источника, пустые/ошибочные состояния |
| 6 | Терминал и диагностика | Проверить права, готовность устройства, поздний вывод и повторные отправки | Точный контекст, сохранение вывода, подтверждение выполнения | | 6 | Терминал | Проверить права, готовность устройства, поздний вывод и повторные отправки | Точный контекст, сохранение вывода, подтверждение выполнения |
| 7 | Приёмка | ПК/телефон, клавиатура, переходы и профильная регрессия | Схемы/PNG, сборки, сценарии; внешние ограничения отдельно | | 7 | Диагностика GRE: /servers/:id/probes/run, ros-src-address, filters/gre-tunnels | Отмена показана как done, нет строгого output/single-flight/source cleanup; каталоги маскируют ошибки | Подтверждённый результат, отмена отдельно, точный контекст, ошибки и права; расписание в 11Б |
| 8 | Приёмка | ПК/телефон, клавиатура, переходы и профильная регрессия | Схемы/PNG, сборки, сценарии; внешние ограничения отдельно |
## Совместимость и границы ## Совместимость и границы
@@ -329,3 +330,11 @@ Shared контракт и backend observed исходные значения/д
- Отсутствующие BGP/BFD status, OSPF cost/type/BFD/version/area/changes и firewall counter не выдаются за active/down/10/broadcast/no/2/0. Ping timeout виден без TTL, неизвестная packet loss — ?, нулевые измерения сохранены. Resource memory отказывается от NaN/overflow. - Отсутствующие BGP/BFD status, OSPF cost/type/BFD/version/area/changes и firewall counter не выдаются за active/down/10/broadcast/no/2/0. Ping timeout виден без TTL, неизвестная packet loss — ?, нулевые измерения сохранены. Resource memory отказывается от NaN/overflow.
- createExecRoutes допускает DI для тестов; production defaults — прежние DB/MikrotikClient. test:exec включён в backend test. Route/RBAC DI тесты, backend test:auth, build и lint прошли. Подписанные JWT проверяют текущий middleware до device lookup; это не приёмка реального портала/RouterOS/БД. - createExecRoutes допускает DI для тестов; production defaults — прежние DB/MikrotikClient. test:exec включён в backend test. Route/RBAC DI тесты, backend test:auth, build и lint прошли. Подписанные JWT проверяют текущий middleware до device lookup; это не приёмка реального портала/RouterOS/БД.
- Далее source/cancel/timeout/пустой/disabled/invalid URL E2E терминала, адаптивные снимки и production frontend; затем локальное завершение этапа 10. - Далее source/cancel/timeout/пустой/disabled/invalid URL E2E терминала, адаптивные снимки и production frontend; затем локальное завершение этапа 10.
## Блок 10.57 — локальная приёмка терминала (2026-10-01)
- Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода.
- Снимки ready/output для ПК, 390 и 320 px сохранены. Общая мобильная кнопка ServerRail теперь ограничена шириной и сокращает длинное имя; гидратация учитывает cleanup. 12 регрессионных сценариев GRE/VXLAN/IPsec/OSPF/recursive прошли; восемь обновлённых снимков просмотрены и сохранены. ReUI Frame checklist повторён.
- Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве.
- Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], testIgnore: ["**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server=base.webServer
if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"terminal-context.spec.ts",
webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}},
})
+22
View File
@@ -0,0 +1,22 @@
import {test,expect} from "@playwright/test"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
const row=(name:string)=>({id:1,name,host:"192.0.2.1",country:"RU",enabled:true,status:"online",os:null})
for(const change of ["backend","mode"])for(const part of ["catalog","exec"])for(const fails of [false,true])test("Терминал: поздний "+part+"/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[row(u.port==="8101"?"Новый источник":"Прежний источник")]:p.endsWith("/exec")?{output:"новый вывод"}:{}})})
await page.goto("/settings");await page.getByRole("link",{name:/^Терминал/}).click();await page.getByRole("option",{name:/Прежний источник/}).click()
await expect(page.getByRole("textbox",{name:"Команда · Прежний источник"})).toBeEnabled()
const path=part==="catalog"?"/api/servers":"/api/servers/1/exec"
await page.route("**/api/**",async r=>{const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!==path||started)return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"Прежняя ошибка терминала"}}:{headers,json:part==="catalog"?[row("Поздний каталог")]:{output:"Поздний вывод терминала"}})}catch{}finally{completed=true}})
if(part==="catalog")await page.getByRole("button",{name:"Обновить устройства",exact:true}).click()
else{await page.getByRole("textbox",{name:/^Команда ·/}).fill("interface print");await page.getByRole("button",{name:"Выполнить",exact:true}).click()}
await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click()
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.getByRole("link",{name:/^Терминал/}).click()
if(change==="backend")await page.getByRole("option",{name:/Новый источник/}).click();else await page.getByRole("option").first().click()
await expect(page.getByRole("textbox",{name:/^Команда ·/})).toBeEnabled()
release();await expect.poll(()=>completed).toBe(true);await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
await expect(page.getByText("Поздний каталог",{exact:false})).toHaveCount(0);await expect(page.getByText("Поздний вывод терминала",{exact:false})).toHaveCount(0);await expect(page.getByText("Прежняя ошибка терминала",{exact:false})).toHaveCount(0)
if(change==="backend")await expect(page.getByRole("textbox",{name:"Команда · Новый источник"})).toBeEnabled();else await expect(page.getByRole("status").filter({hasText:"Демо: команды не отправляются"})).toBeVisible()
})
+18
View File
@@ -46,3 +46,21 @@ test("Терминал: reader видит устройство, выполнен
await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}})) await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}))
await page.goto("/terminal?server=1");await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Для выполнения требуется mm:servers:write"})).toBeVisible() await page.goto("/terminal?server=1");await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Для выполнения требуется mm:servers:write"})).toBeVisible()
}) })
for(const q of ["server=1&server=2","server=1&host=192.0.2.1","server=bad%20id"])test("Терминал: неверный URL не читает каталог "+q,async({page})=>{
let reads=0;await page.route("**/api/servers",r=>{reads++;return r.fulfill({json:devices})})
await page.goto("/terminal?"+q);await expect(page.getByRole("alert").filter({hasText:"Некорректная ссылка"})).toBeVisible();expect(reads).toBe(0)
await page.getByRole("button",{name:"Сбросить контекст терминала"}).click();await expect(page.getByRole("option",{name:/Первый/})).toBeVisible();expect(reads).toBe(1)
})
test("Терминал: disabled и известный пустой каталог",async({page})=>{
await page.route("**/api/servers",r=>r.fulfill({json:[{...devices[0],enabled:false}]}));await page.goto("/terminal?server=1")
await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Устройство отключено"})).toBeVisible()
await page.route("**/api/servers",r=>r.fulfill({json:[]}));await page.goto("/terminal");await expect(page.getByText("Устройств пока нет",{exact:true})).toBeVisible();await expect(command(page)).toHaveCount(0)
})
test("Терминал: таймаут сохраняет команду и не повторяет автоматически",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("**/api/servers/1/exec",async r=>{calls++;await gate;await r.fulfill({json:{output:"запоздалый ответ"}}).catch(()=>{})})
await page.clock.install();await page.goto("/terminal?server=1");await command(page).fill("interface print");await command(page).press("Enter");await expect.poll(()=>calls).toBe(1)
await page.clock.fastForward(60010);await expect(page.getByRole("alert").filter({hasText:"Время ожидания истекло"})).toBeVisible();await expect(command(page)).toHaveValue("interface print");await expect(command(page)).toBeEnabled();expect(calls).toBe(1)
release();await expect(page.getByRole("region",{name:"Вывод терминала"})).not.toContainText("запоздалый ответ")
})
+18
View File
@@ -0,0 +1,18 @@
import {test,expect} from "@playwright/test"
for(const width of [1440,390,320])test("Терминал: адаптивный вывод, поле и быстрые команды "+width,async({page})=>{
const row={id:1,name:"Домашний роутер с длинным названием и описанием устройства",host:"192.0.2.1",country:"RU",enabled:true,status:null,os:null}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[row]:p==="/api/auth/config"?{required:false}:p.endsWith("/exec")?{output:Array.from({length:1200},(_,i)=>"Строка "+i+" "+"x".repeat(160)).join("\n")}:{}})})
await page.setViewportSize({width,height:900});await page.goto("/terminal?server=1")
const input=page.getByRole("textbox",{name:/^Команда ·/}),output=page.getByRole("region",{name:"Вывод терминала"})
await expect(input).toBeEnabled();await expect(output).not.toContainText("Connected")
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
if(width<768){const control=(await page.getByRole("button",{name:"Серверы",exact:true}).boundingBox())!;expect(control.x+control.width).toBeLessThanOrEqual(width)}
await page.screenshot({path:"docs/ux-ui/screens/terminal-ready-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"})
await page.getByRole("combobox",{name:"Быстрая команда"}).selectOption("interface print");await input.press("Enter")
await expect(input).toHaveValue("");await expect(output).toContainText("Показаны последние 1000 строк");await expect(output).toContainText("Строка 1199");await expect(output).not.toContainText("Строка 0 ")
expect(await output.evaluate(e=>e.scrollHeight>e.clientHeight)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
const box=(await input.boundingBox())!;expect(box.width).toBeGreaterThan(100);expect(box.y+box.height).toBeLessThanOrEqual(900)
await page.screenshot({path:"docs/ux-ui/screens/terminal-output-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"})
await page.getByRole("button",{name:"Очистить вывод терминала"}).click();await expect(output).toContainText("Вывод очищен.")
if(width<768){await page.getByRole("button",{name:"Серверы",exact:true}).click();const dialog=page.getByRole("dialog");await expect(dialog).toBeVisible();await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Серверы",exact:true})).toBeFocused()}
})