Merge pull request 'web-app-followups: 46 отложенных пунктов веб-приложения (#29)' (#33) from feat/29-web-app-followups into main
This commit is contained in:
commit
835199d3ac
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.13
|
||||
@ -1,11 +1,11 @@
|
||||
# jira-hub
|
||||
|
||||
## Что это
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последние — `openspec/changes/archive/2026-10-15-web-app/` и `openspec/changes/archive/2026-10-15-agent-v9-hardening/`).
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`).
|
||||
|
||||
## Стек и ограничения
|
||||
- Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git.
|
||||
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
|
||||
- Python 3.13 от uv (`.python-version`, `tool.uv.python-preference = "only-managed"`: системный Python miniconda несёт SQLite 3.51.1 с взаимной блокировкой, O-14 #29), uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
||||
|
||||
@ -11,6 +11,10 @@ HTTP API в локальной сети и MCP-сервер для Claude. Ха
|
||||
uv sync
|
||||
```
|
||||
|
||||
Python для хаба uv ставит свой (3.13, `.python-version`; системный и conda-Python не берутся — `tool.uv` в
|
||||
`pyproject.toml`): в SQLite 3.51.0–3.51.1 есть взаимная блокировка, от которой хаб зависал под параллельными запросами.
|
||||
Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`.
|
||||
|
||||
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`):
|
||||
`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
|
||||
|
||||
@ -156,7 +160,8 @@ make web
|
||||
launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||||
```
|
||||
|
||||
Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Раскладка доски и вход сохраняются: приложение
|
||||
Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Шрифты (IBM Plex) входят в сборку: приложение
|
||||
работает без доступа в интернет. Раскладка доски и вход сохраняются: приложение
|
||||
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
|
||||
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
|
||||
|
||||
|
||||
1151
docs/superpowers/plans/2026-10-15-web-app-followups.md
Normal file
1151
docs/superpowers/plans/2026-10-15-web-app-followups.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/web-app-followups/.openspec.yaml
Normal file
2
openspec/changes/web-app-followups/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-15
|
||||
172
openspec/changes/web-app-followups/design.md
Normal file
172
openspec/changes/web-app-followups/design.md
Normal file
@ -0,0 +1,172 @@
|
||||
## Context
|
||||
|
||||
`main` d38fac8: веб-приложение хаба (`web/`, React 19 + TS + Vite, TanStack Query, React Router) под `/app/`, спека
|
||||
`openspec/specs/web-app/`, хаб раздаёт сборку (`h_app` в `api.py`). Отложенные находки change `web-app` собраны в
|
||||
jira-hub#29 (46 пунктов, маркеры сохраняются из PR #27). Устройство, на которое опираются правки:
|
||||
|
||||
- `api/client.ts` — единственный HTTP-клиент: `call()` берёт токен через `getToken()`, 401 → `AuthError`, сеть и 5xx →
|
||||
`HubDownError`, прочие 4xx → `ApiError {status, error, detail}`. Токен очищается `clearToken("bad")` (из
|
||||
`useBoardSource.report` и глобального обработчика ошибок `queryClient`), `AuthGate` показывает `LoginScreen`.
|
||||
- `board/useBoardSource.ts` — источник доски: `source` (hub | file), `event` (одна строка состояния), `active`
|
||||
(опрашиваемый пакет); подхват незавершённого пакета `viewer` по каждому `board.dataUpdatedAt` через `GET /batches`.
|
||||
Состояние локально для `BoardPage` и пропадает при уходе со страницы; снимок хаба — в кэше TanStack Query,
|
||||
раскладка и фильтры — в `localStorage`.
|
||||
- `GET /batches` отдаёт пакеты без команд (`queue.list_batches`); `GET /batches/{id}` — с `commands[]` (`type`).
|
||||
- `issues/IssuesPage.tsx` — фильтры в query-строке, варианты — из `filterOptions(board, settings)`.
|
||||
- `index.html` подключает IBM Plex Sans/Mono с fonts.googleapis.com; `npm audit` — 1 high в `source-map-js@1.2.1`
|
||||
(транзитивная dev-зависимость), исправление — 1.2.2 (2026-09-30).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** закрыть jira-hub#29 целиком: каждый пункт — `fixed <commit>` или `rejected <причина>`; поведение доски на тех
|
||||
же данных совпадает с текущим, кроме пунктов группы A; `make check` и e2e зелёные, e2e устойчив к повторам.
|
||||
|
||||
**Non-Goals:** см. proposal.md.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Прокрутка доски (M-3).** `BoardCanvas` запоминает `window.scrollX/scrollY` (и `scrollLeft`
|
||||
горизонтальной обёртки `.wrap`, если она прокручивается сама) в переменной модуля по событиям `scroll` окна и `.wrap`;
|
||||
при монтировании восстанавливает в `useLayoutEffect` после `placed` (первой отрисовки холста с данными). Перезагрузка страницы открывает доску сверху, как сейчас. Состояние
|
||||
источника не поднимается выше страницы — rejected часть M-3 (решение владельца).
|
||||
2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока
|
||||
`active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а
|
||||
следующий ответ опроса возвращает строку пакета.
|
||||
3. **Ошибка хаба при снимке из файла (T10.4).** Событие `hub-down` получает признак `fromFile`; текст — «хаб не отвечает —
|
||||
на доске снимок из файла». Он ставится, когда снимок хаба не загрузился при показанном файле (перечитывание после
|
||||
завершения пакета) и когда JSON-снимок загружен без данных хаба; `hasData` в последнем случае — есть ли в модели задачи.
|
||||
«Перечитать» по-прежнему возвращает источник «хаб».
|
||||
4. **Подхват пакета (T10.6, T10.1).** Кандидат из `GET /batches` (автор `viewer`, `queued`/`running`) проверяется запросом
|
||||
`GET /batches/{id}`: подхватывается, только если первая команда — `refresh`. Подхват не запускается по обновлению
|
||||
снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1).
|
||||
5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только
|
||||
если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется.
|
||||
6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «Хаб отверг проверку токена: <detail или error>»; `HubDownError` — прежний
|
||||
текст «хаб не отвечает».
|
||||
7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым
|
||||
вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке.
|
||||
8. **Карточка задачи (O-10, O-11).** Ссылки страниц задач — `color: var(--accent)` из темы (как в списке); пустой
|
||||
`commands` — строка «команд по задаче не было» вместо пустой таблицы.
|
||||
9. **NUL в пути (T1.1).** `h_app` отвечает 404 `not_found`, если в декодированном пути есть `\x00` или разрешение пути
|
||||
падает с `ValueError`/`OSError`; остальное поведение `h_app` не меняется.
|
||||
10. **Шрифты (T2.2).** `@fontsource/ibm-plex-sans` (400, 500, 600) и `@fontsource/ibm-plex-mono` (500), импорт CSS в
|
||||
`main.tsx`; ссылка на Google Fonts из `index.html` удаляется. Файлы `woff2` попадают в `assets/`, хаб уже отдаёт
|
||||
`font/woff2`. Версии — точные, `npm install --before=2026-10-01 --save-exact`.
|
||||
11. **source-map-js (O-1).** `overrides: {"source-map-js": "1.2.2"}` в `package.json`, проверка `npm audit` (0 high)
|
||||
и сборка; если 1.2.2 не закрывает уязвимость — O-1 rejected с текстом аудита.
|
||||
12. **Эталоны (T5.1).** `layout.clamp` — число строк заголовка при ручной высоте карточки; «unset» — у карточек с
|
||||
автоматической высотой. В `scenarios.json` добавляется сценарий `saved-short` с сохранённой раскладкой четырёх карточек
|
||||
разной высоты (120, 136, 200, 320), эталоны перегенерируются `npm run golden` из `links.html@3931a88`; прежние
|
||||
сценарии и фикстура `hub-basic.board.json` не меняются.
|
||||
13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа (`HUB_PORT`) в `web/playwright.config.ts`, `web/e2e/hub.ts` импортирует её;
|
||||
`scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e
|
||||
проходит 10 раз подряд.
|
||||
14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель,
|
||||
хранилище, эталоны; окружение и e2e; документация. Исполнение — SDD-контроллер (sonnet), задачи с готовым кодом —
|
||||
haiku, интеграция — sonnet, финальное ревью ветки — opus.
|
||||
|
||||
## Пункты #29 и их судьба
|
||||
|
||||
| Маркер | Что делаем | Задача плана |
|
||||
|---|---|---|
|
||||
| T1.1 | 404 на NUL в пути (решение 9) | хаб |
|
||||
| T1.4 | тест Content-Type для svg/png/ico/woff2/map | хаб |
|
||||
| T16.1 | убрать дубль `import urllib.*` | хаб |
|
||||
| T3.2 | 4xx на входе — текст ошибки хаба (решение 6) | клиент и вход |
|
||||
| T3.3 | содержательный тест 401 и тесты `queryClient`/`queries` | клиент и вход |
|
||||
| T3.4 | `token.test` проверяет `removeItem` при закрытом хранилище | клиент и вход |
|
||||
| T3.5 | токен в `AuthError` (решение 5) | клиент и вход |
|
||||
| T3.6 | интеграционный тест AuthError → LoginScreen | клиент и вход |
|
||||
| T10.1 | без лишнего `GET /batches` (решение 4) | источник доски |
|
||||
| T10.3 | строка пакета не стирается (решение 2) | источник доски |
|
||||
| T10.4 | «на доске снимок из файла» (решение 3) | источник доски |
|
||||
| T10.5 | тест «поздний ответ» проверяет, что ответ хаба пришёл после файла | источник доски |
|
||||
| T10.6 | подхват только `refresh` (решение 4) | источник доски |
|
||||
| M-3 | прокрутка (решение 1); хранение источника — rejected | холст и страница доски |
|
||||
| T11.1 | стабильные пропсы `Card`, `memo` срабатывает | холст и страница доски |
|
||||
| T11.2 | отмена `requestAnimationFrame` в `DatesBar` | холст и страница доски |
|
||||
| T11.3 | тест перетаскивания дорожки | холст и страница доски |
|
||||
| T11.4 | точные проверки в тестах I-2/I-3 | холст и страница доски |
|
||||
| T12.1 | `onOpen()` вне updater `setOpen` | холст и страница доски |
|
||||
| T12.2 | `writeFlag`/`writeHidden` вне updater | холст и страница доски |
|
||||
| T12.3 | зависимости `useLayoutEffect`, сброс `--head-h` | холст и страница доски |
|
||||
| T12.4 | MultiSelect: `role="group"` с флажками вместо `listbox` без `option` | холст и страница доски |
|
||||
| T13.1 | значение фильтра вне вариантов (решение 7) | задачи и карточка |
|
||||
| O-10 | ссылки цветом темы (решение 8) | задачи и карточка |
|
||||
| O-11 | «команд по задаче не было» (решение 8) | задачи и карточка |
|
||||
| T14.2 | ожидания через `waitFor`/`expect.poll` вместо пауз 20 мс | задачи и карточка |
|
||||
| T14.3 | уникальный id `datalist` (`useId`) | задачи и карточка |
|
||||
| T2.1 | `canon.ts` — сравнение по кодам символов | модель, хранилище, эталоны |
|
||||
| T5.1 | сценарий `saved-short` для `layout.clamp` (решение 12) | модель, хранилище, эталоны |
|
||||
| T6.3 | прямые тесты `util.ts` | модель, хранилище, эталоны |
|
||||
| T7.1 | `initialWho` считает `whoItems` один раз | модель, хранилище, эталоны |
|
||||
| T9.1 | `colWidths()` пропускает только конечные числа | модель, хранилище, эталоны |
|
||||
| T9.3 | тесты `toggleLane`/`moveLane`/`resetLayout`/`exportFileName`/`mode=all`, закрытое хранилище | модель, хранилище, эталоны |
|
||||
| T9.4 | порядок импортов `fromJson.ts` | модель, хранилище, эталоны |
|
||||
| T2.2 | шрифты из `@fontsource` (решение 10) | окружение и e2e |
|
||||
| O-1 | `overrides` source-map-js (решение 11) | окружение и e2e |
|
||||
| T4.1 | тест «Задачи и обратно» с обратным переходом | окружение и e2e |
|
||||
| T4.2 | тест ссылок страницы «не найдено» | окружение и e2e |
|
||||
| T4.3 | убрать `App.Layout`, тест берёт `Layout` | окружение и e2e |
|
||||
| T4.4 | один импорт из `@testing-library/react` | окружение и e2e |
|
||||
| T4.5 | очистка атрибутов в `theme.test` | окружение и e2e |
|
||||
| T15.1 | высота карточек в geometry сверяется с эталоном, не с формулой | окружение и e2e |
|
||||
| T15.2 | растяжка через `expect.poll`, без литерала 240 | окружение и e2e |
|
||||
| T15.4 | порт — одна константа (решение 13) | окружение и e2e |
|
||||
| T15.6 | проверка свободного порта (решение 13) | окружение и e2e |
|
||||
| M-5 | rejected: хаб запускается из checkout, wheel не используется | — |
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Перегенерация эталонов (T5.1) может показать расхождение порта с `links.html` в `layout.clamp` → это дефект порта,
|
||||
чинится в коде, эталон не подгоняется.
|
||||
- Стабилизация пропсов `Card` (T11.1) затрагивает перетаскивание → e2e board/geometry обязательны в задаче.
|
||||
- Восстановление прокрутки до отрисовки высот даст прыжок → восстанавливать после первой отрисовки с данными.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Нумерация R-n — заново в пределах этой фичи.
|
||||
|
||||
- **R-1 (решение 1), 15.10.2026.** Прокрутка хранится в переменной модуля, не в `sessionStorage`: переживает переходы
|
||||
внутри вкладки, но не перезагрузку. Обоснование: владелец согласился на «помнить, пока открыта вкладка» в смысле
|
||||
переходов; перезагрузка — новая сессия доски. Цена ошибки: если нужна и после F5 — заменить на `sessionStorage`, одна строка.
|
||||
- **R-2 (решение 4), 15.10.2026.** Тип пакета определяется дополнительным `GET /batches/{id}` по кандидату, а не
|
||||
изменением `GET /batches` в хабе. Обоснование: API хаба в этом change не меняется (Non-Goals web-app). Цена: один лишний
|
||||
запрос при найденном кандидате.
|
||||
- **R-3 (решение 5), 15.10.2026.** Запоздавший 401 со старым токеном молча игнорируется. Обоснование: новый токен уже
|
||||
проверен `GET /settings`. Цена: если новый токен тоже станет неверным, экран входа появится на следующем запросе.
|
||||
- **R-4 (решение 7), 15.10.2026.** Значение фильтра вне вариантов подписывается «(нет на доске)». Цена — текст подписи.
|
||||
- **R-5 (решение 11), 15.10.2026.** O-1 закрывается `overrides`, а не обновлением прямых зависимостей. Обоснование:
|
||||
уязвимость только в транзитивной dev-зависимости; обновление vite/vitest шире задачи. Цена: `overrides` надо снять, когда
|
||||
прямые зависимости подтянут 1.2.2 сами.
|
||||
- **R-6 (решение 12), 15.10.2026.** T5.1 закрывается новым сценарием `saved-short` с ручными высотами, а не новыми
|
||||
задачами в фикстуре: обрезку заголовка в генераторе определяет только ручная высота (заглушка DOM меряет все карточки
|
||||
одинаково). Цена: длинные темы на реальной вёрстке проверяет e2e, не эталон.
|
||||
- **R-7 (M-5), 15.10.2026.** M-5 rejected — показано владельцу в сводке понимания до утверждения разделов, возражений не было.
|
||||
- **R-8, 15.10.2026.** Коммит 2024a66 (Task 1) с трейлером `Claude Haiku 5.5` вместо `Opus 5.5` не переписывается: он уже
|
||||
запушен, amend потребовал бы force-push в ветку. Обоснование: трейлер — метаданные, поведение не затронуто. Цена ошибки:
|
||||
в истории ветки один коммит с другим именем модели; при желании владельца правится rebase перед merge. Следующим
|
||||
исполнителям правило трейлера выписано в контракт явно (O-12).
|
||||
- **R-9, 15.10.2026.** T15.1 закрыт утверждением `some(h !== 120)` вместо `new Set(heights).size > 1`: на фикстуре hub-basic
|
||||
все 5 карточек имеют высоту 152px, разнообразия высот нет, требование плана невыполнимо. Утверждения `fits` и кратности 8
|
||||
оставлены. Обоснование: тавтологичное сравнение с самой собой убрано, проверка осталась содержательной. Цена ошибки:
|
||||
e2e не поймает регресс, при котором все карточки получают одинаковую, но не 120px, высоту; полноценная проверка требует
|
||||
карточки с другим содержимым в фикстуре тестового хаба.
|
||||
- **R-10, 15.10.2026.** Провал ручной проверки прокрутки (Task 8) принят как артефакт сценария, а не дефект продукта:
|
||||
реальный `locator.click()` по карточке вне окна прокручивает страницу сам, и это законное scroll-событие. В продукте
|
||||
добавлена защита `isConnected` (запоздавшее scroll после ухода с доски не затирает запомненное), e2e M-3 переписан на
|
||||
клик по видимой карточке без ожидания кадров. Обоснование: воспроизвести потерю прокрутки у пользователя, кликающего по
|
||||
видимой карточке, не удалось ни в e2e, ни вручную. Цена ошибки: если дефект есть у реальных пользователей (например,
|
||||
в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup `useLayoutEffect` (до снятия DOM).
|
||||
- **R-11, 15.10.2026.** O-14 (зависание тестового хаба в e2e) — не дефект приложения, а взаимная блокировка в unix-VFS
|
||||
SQLite 3.51.1 (исправлена в 3.51.2): стеки снятого зависшего хаба — все потоки запросов в `sqlite3.connect`
|
||||
(`findReusableFd`) и один в `close` (`sqlite3WalClose` → `unixLock`), ждут мьютексы друг друга. SQLite 3.51.1 — у Python
|
||||
miniconda, на котором стоял `.venv` (и у живого хаба). Хаб переведён на Python 3.13 от uv (python-build-standalone,
|
||||
SQLite 3.50.4): `.python-version` = 3.13, `[tool.uv] python-preference = "only-managed"`; тест
|
||||
`test_sqlite_without_unix_vfs_deadlock` не даёт вернуться на 3.51.0–3.51.1. Проверка: на старом окружении хаб завис на
|
||||
3-м и 5-м прогонах e2e, на новом — 20 прогонов подряд без зависаний (и 20 — на Python 3.14 с SQLite 3.53.4). Python 3.14
|
||||
не взят: в нём `Path.is_dir()` при отказе доступа возвращает False вместо исключения, и реле пишет «папки нет» вместо
|
||||
«нет доступа» (падает `test_permission_denied_warns_once_then_works`). Обоснование: правка в границах #29, потому что
|
||||
без неё не выполняется условие I-1 (10 прогонов e2e подряд); код хаба не меняется. Цена ошибки: живой хаб при первом
|
||||
запуске после merge сам пересоздаёт `.venv` на Python от uv (нужен интернет для загрузки, ~1 мин); если что-то пойдёт
|
||||
не так — убрать `[tool.uv]` и `.python-version`, `uv sync`, перезапуск.
|
||||
47
openspec/changes/web-app-followups/proposal.md
Normal file
47
openspec/changes/web-app-followups/proposal.md
Normal file
@ -0,0 +1,47 @@
|
||||
## Why
|
||||
|
||||
Change `web-app` (jira-hub#21, PR #27) слит, финальное ревью не нашло Critical и Important. 46 мелких находок
|
||||
отложены в jira-hub#29: недочёты видимого поведения доски, входа и страниц задач, внешние шрифты, уязвимость в
|
||||
dev-зависимости, неаккуратный React-код, слабые и недостающие тесты. Решение владельца 15.10.2026 — закрыть #29
|
||||
целиком одним change.
|
||||
|
||||
Решения владельца (brainstorming 15.10.2026):
|
||||
- берутся все 46 пунктов (группы A — поведение, B — окружение, C — качество кода, D — тесты);
|
||||
- M-3: при возврате с карточки задачи на доску восстанавливается прокрутка; держать в памяти снимок из файла и строку
|
||||
состояния не нужно — данные идут из хаба, файл — запасной путь (эта часть M-3 — rejected);
|
||||
- шрифты — те же IBM Plex Sans и Mono, но из сборки, без обращения в интернет;
|
||||
- M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется (показано владельцу в сводке понимания до утверждения разделов, возражений не было).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Доска**: восстановление прокрутки при возврате на `/app/board` (M-3); строка «пакет #N выполняется» не стирается
|
||||
«Перечитать» и загрузкой файла (T10.3); при показанном снимке из файла ошибка хаба — «хаб не отвечает — на доске снимок
|
||||
из файла» (T10.4); подхватывается только пакет обновления из Jira (T10.6); без лишнего `GET /batches` после
|
||||
завершения пакета (T10.1).
|
||||
- **Вход**: ответ 4xx при проверке токена — текст ошибки хаба, а не «хаб не отвечает» (T3.2); 401 на запрос со старым
|
||||
токеном после входа с новым не выбрасывает на экран входа (T3.5).
|
||||
- **Задачи**: значение фильтра из адреса, которого нет среди вариантов, показывается в списке (T13.1); ссылки карточки —
|
||||
цветом темы (O-10); пустой список команд — текстом (O-11).
|
||||
- **Хаб**: путь `/app/assets/…` с нулевым байтом — 404, а не 500 (T1.1).
|
||||
- **Сборка**: шрифты из npm-пакетов `@fontsource`, без Google Fonts (T2.2); `source-map-js` поднимается до исправленной
|
||||
версии через `overrides` (O-1).
|
||||
- **Код и тесты** без изменения поведения: группы C и D (см. design.md, таблица пунктов).
|
||||
- Спеки: `web-app` — MODIFIED «Вход по токену», «Доска связей», «Источник данных доски», «Список задач», «Карточка
|
||||
задачи», «Сборка и разработка»; `hub-api` — MODIFIED «Раздача веб-приложения».
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Новые страницы, запись в Jira (`viewer-edit`), изменения API хаба.
|
||||
- Изменения логики доски (модель, выборка, критический путь, проблемы, раскладка): эталоны `links.html@3931a88`
|
||||
остаются мерилом; меняются только фикстуры эталонов (T5.1), не поведение.
|
||||
- Хранение состояния доски (снимок файла, событие, опрос) между страницами — rejected часть M-3.
|
||||
- Включение сборки приложения в wheel (M-5).
|
||||
|
||||
## Impact
|
||||
|
||||
- `web/`: `src/auth`, `src/api`, `src/board` (useBoardSource, BoardPage, BoardCanvas, Card, DatesBar, Dropdown,
|
||||
MultiSelect, useBoardPrefs, storage, facets), `src/issues`, `src/test`, `e2e/`, `scripts/`, `index.html`,
|
||||
`package.json`/`package-lock.json` (+ `@fontsource/ibm-plex-sans`, `@fontsource/ibm-plex-mono`, `overrides`).
|
||||
- `src/jira_hub/api.py` (`h_app`), `tests/test_web_app.py`, `tests/test_api.py`.
|
||||
- Контракт с агентом, MCP, схема базы и API не меняются. Ключи `localStorage` не меняются.
|
||||
- Issue: aep/jira-hub#29.
|
||||
39
openspec/changes/web-app-followups/specs/hub-api/spec.md
Normal file
39
openspec/changes/web-app-followups/specs/hub-api/spec.md
Normal file
@ -0,0 +1,39 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Раздача веб-приложения
|
||||
Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_hub/web/app/`, читая файлы
|
||||
с диска на каждый запрос:
|
||||
- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/`, не
|
||||
существует или содержит нулевой байт, — 404; `Content-Type` по расширению (`.js` — `text/javascript; charset=utf-8`, `.css` —
|
||||
`text/css; charset=utf-8`, `.svg` — `image/svg+xml`, `.png` — `image/png`, `.ico` — `image/x-icon`, `.woff2` —
|
||||
`font/woff2`, `.map` — `application/json`, прочее — `application/octet-stream`);
|
||||
`Cache-Control: public, max-age=31536000, immutable`;
|
||||
- любой другой `GET /app` или `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`;
|
||||
- если `index.html` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не
|
||||
собрано и нужно выполнить `make web` в каталоге jira-hub;
|
||||
- `GET /` и `GET /links` — 302 с `Location: /app/board`.
|
||||
Сборка приложения не хранится в git; её создаёт `make web`.
|
||||
|
||||
#### Scenario: Файл сборки
|
||||
- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js`
|
||||
- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла
|
||||
|
||||
#### Scenario: Путь приложения
|
||||
- **WHEN** запрошен `GET /app/issues/AURORA-1620`
|
||||
- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache`
|
||||
|
||||
#### Scenario: Выход за каталог сборки
|
||||
- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`)
|
||||
- **THEN** 404, содержимое файлов вне `assets/` не отдаётся
|
||||
|
||||
#### Scenario: Приложение не собрано
|
||||
- **WHEN** каталога сборки нет, запрошен `GET /app/board`
|
||||
- **THEN** 503, тело — HTML с подсказкой `make web`
|
||||
|
||||
#### Scenario: Старый адрес доски
|
||||
- **WHEN** запрошен `GET /links` или `GET /`
|
||||
- **THEN** 302, `Location: /app/board`
|
||||
|
||||
#### Scenario: Нулевой байт в пути
|
||||
- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js`
|
||||
- **THEN** 404 `not_found`, а не 500
|
||||
163
openspec/changes/web-app-followups/specs/web-app/spec.md
Normal file
163
openspec/changes/web-app-followups/specs/web-app/spec.md
Normal file
@ -0,0 +1,163 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Вход по токену
|
||||
Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом
|
||||
`jira-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса.
|
||||
Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с
|
||||
подсказкой `uv run jira-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода.
|
||||
Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен»
|
||||
SHALL очищать токен и показывать экран входа. Ответ 401 SHALL выводить на экран входа, только если запрос ушёл с текущим токеном: запоздавший 401 на
|
||||
запрос с прежним токеном после входа с новым не учитывается. Если проверка токена получила ответ 4xx, кроме 401,
|
||||
экран входа SHALL показывать текст ошибки хаба, а не «хаб не отвечает».
|
||||
|
||||
#### Scenario: Первый вход
|
||||
- **WHEN** приложение открыто впервые, токена нет
|
||||
- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён
|
||||
|
||||
#### Scenario: Устаревший токен
|
||||
- **WHEN** сохранённый токен не подходит и запрос ответил 401
|
||||
- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны
|
||||
|
||||
#### Scenario: Токен от прежней доски
|
||||
- **WHEN** в `localStorage` есть `jira-hub-token`, сохранённый страницей `links.html`
|
||||
- **THEN** приложение входит с ним без запроса токена
|
||||
|
||||
#### Scenario: Запоздавший 401 от прежнего токена
|
||||
- **WHEN** запрос ушёл с прежним токеном, пользователь вошёл с новым, и после этого запрос ответил 401
|
||||
- **THEN** приложение остаётся на странице, новый токен сохранён
|
||||
|
||||
#### Scenario: Хаб отверг проверку токена
|
||||
- **WHEN** проверка `GET /settings` ответила 400 с `detail`
|
||||
- **THEN** экран входа показывает текст `detail`, а не «хаб не отвечает»
|
||||
|
||||
### Requirement: Доска связей
|
||||
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
|
||||
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
|
||||
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
|
||||
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
|
||||
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи»,
|
||||
«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
|
||||
связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в
|
||||
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
|
||||
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
|
||||
на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
|
||||
ведущий на `/app/issues/{key}`; ключ карточки по-прежнему открывает задачу в Jira. Действия «Сменить токен» в меню
|
||||
доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
|
||||
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
|
||||
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
|
||||
При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
|
||||
прокрутку; после перезагрузки страницы доска открывается сверху.
|
||||
|
||||
#### Scenario: Раскладка переживает переход
|
||||
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
|
||||
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
|
||||
|
||||
#### Scenario: Совпадение со старой доской
|
||||
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
|
||||
и проблемы старой страницей и приложением
|
||||
- **THEN** результаты совпадают
|
||||
|
||||
#### Scenario: Открыть карточку
|
||||
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
|
||||
- **THEN** открыта `/app/issues/AURORA-1620` внутри приложения
|
||||
|
||||
#### Scenario: Возврат с карточки
|
||||
- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем выбран раздел «Доска»
|
||||
- **THEN** доска показана с той же прокруткой
|
||||
|
||||
### Requirement: Источник данных доски
|
||||
Доска SHALL хранить явный источник данных — хаб или файл. Пока показан снимок из файла (CSV или JSON-снимок), ответы
|
||||
хаба SHALL его не заменять; «Перечитать» SHALL возвращать источник «хаб» и загружать `GET /board` заново без обращения к
|
||||
Jira. Импорт JSON в режиме хаба SHALL применять раскладку, дорожки и фильтры и перечитывать хаб. «Обновить из Jira»
|
||||
SHALL проверять агента (`GET /agent`; офлайн — предупреждение, пакет всё равно ставится), ставить
|
||||
`POST /refresh {full: true, author: "viewer"}`, опрашивать `GET /batches/{id}` каждые 3 с одним опросом до
|
||||
терминального статуса пакета и затем перечитывать `GET /board`. Кнопка «Обновить из Jira» SHALL быть недоступна,
|
||||
пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет `refresh` с автором `viewer` SHALL
|
||||
подхватываться при каждой успешной загрузке из хаба, кроме перечитывания снимка после завершения собственного пакета; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок
|
||||
«хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Шапка доски SHALL показывать
|
||||
источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет
|
||||
выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка
|
||||
разбора файла — «файл не разобран: <причина>»); новое событие заменяет предыдущее целиком. Пока пакет выполняется,
|
||||
«Перечитать» и загрузка файла SHALL не стирать событие пакета.
|
||||
|
||||
#### Scenario: Поздний ответ хаба не затирает файл
|
||||
- **WHEN** запрос `GET /board` в пути, и в это время загружен CSV
|
||||
- **THEN** после ответа хаба на доске по-прежнему данные CSV с пометкой «из файла»
|
||||
|
||||
#### Scenario: Нет второго полного прогона
|
||||
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, доска перерисована (фильтр, «Перечитать»)
|
||||
- **THEN** кнопка остаётся недоступной, в хабе один пакет, его опрашивает один опрос
|
||||
|
||||
#### Scenario: Пакет пропал
|
||||
- **WHEN** опрос пакета получил 404
|
||||
- **THEN** опрос остановлен, показано сообщение, кнопка «Обновить из Jira» снова доступна
|
||||
|
||||
#### Scenario: Пакет после «Перечитать»
|
||||
- **WHEN** пакет `viewer` поставлен с другой вкладки и не завершён, нажато «Перечитать»
|
||||
- **THEN** доска показывает «пакет #N выполняется» и по завершении перечитывает данные
|
||||
|
||||
#### Scenario: «Перечитать» во время пакета
|
||||
- **WHEN** пакет `viewer` выполняется и нажато «Перечитать»
|
||||
- **THEN** шапка по-прежнему показывает «пакет #N выполняется»
|
||||
|
||||
#### Scenario: Хаб недоступен при снимке из файла
|
||||
- **WHEN** хаб не отвечает и загружен JSON-снимок доски, либо на доске снимок из CSV и хаб не ответил на перечитывание
|
||||
снимка после завершения пакета
|
||||
- **THEN** шапка показывает «хаб не отвечает — на доске снимок из файла», а не «показаны прежние данные»
|
||||
|
||||
#### Scenario: Чужой пакет не подхватывается
|
||||
- **WHEN** незавершённый пакет автора `viewer` состоит из команд `ensure_fields`
|
||||
- **THEN** доска его не опрашивает, кнопка «Обновить из Jira» доступна
|
||||
|
||||
### Requirement: Список задач
|
||||
Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (ссылка на карточку), тема,
|
||||
тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы,
|
||||
статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид
|
||||
работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке
|
||||
адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди
|
||||
вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Задачи SHALL
|
||||
загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная.
|
||||
|
||||
#### Scenario: Фильтр в адресе
|
||||
- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
|
||||
- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
|
||||
адреса в новой вкладке показывает ту же выборку
|
||||
|
||||
#### Scenario: Конец списка
|
||||
- **WHEN** вторая порция вернула 17 задач
|
||||
- **THEN** в таблице 67 задач, «Показать ещё» не показана
|
||||
|
||||
#### Scenario: Фильтр по закрытому спринту
|
||||
- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет
|
||||
- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру
|
||||
|
||||
### Requirement: Карточка задачи
|
||||
Страница `/app/issues/{key}` SHALL показывать задачу из `GET /issues/{key}`: поля зеркала, ссылку на задачу в Jira,
|
||||
описание как текст с сохранёнными переносами строк (без разбора разметки Jira), исходящие и входящие связи с подписью
|
||||
типа (логический тип из `link_types`, иначе имя типа Jira) и ссылками на карточки связанных задач, 10 последних команд
|
||||
по задаче (id, пакет, тип, статус, время завершения, ошибка), а если команд не было — текст «команд по задаче не было». Если задачи нет в зеркале (404), страница SHALL сообщать
|
||||
об этом со ссылкой на список.
|
||||
|
||||
#### Scenario: Связанная задача
|
||||
- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621
|
||||
- **THEN** в карточке «блокирует начало → AURORA-1621», ключ ведёт на `/app/issues/AURORA-1621`
|
||||
|
||||
#### Scenario: Задача без команд
|
||||
- **WHEN** по AURORA-1620 не было команд
|
||||
- **THEN** в разделе «Последние команды» — текст «команд по задаче не было», таблицы нет
|
||||
|
||||
### Requirement: Сборка и разработка
|
||||
Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и
|
||||
`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в
|
||||
`src/jira_hub/web/app/`; каталог сборки не хранится в git. Шрифты и прочие ресурсы
|
||||
страницы SHALL входить в сборку: приложение не обращается к внешним адресам. `make check` SHALL запускать `uv run pytest`, проверку типов
|
||||
и линтер фронта и тесты фронта; зелёным считается только весь набор. `npm run dev` SHALL поднимать сервер разработки с
|
||||
прокси маршрутов API на хаб из `JIRA_HUB_URL` (по умолчанию тестовый `http://127.0.0.1:8791`).
|
||||
|
||||
#### Scenario: Сборка с нуля
|
||||
- **WHEN** в чистом checkout выполнен `make web`
|
||||
- **THEN** в `src/jira_hub/web/app/` есть `index.html` и `assets/`; `git status` не показывает файлов сборки
|
||||
|
||||
#### Scenario: Без интернета
|
||||
- **WHEN** приложение открыто на машине без доступа в интернет
|
||||
- **THEN** страница не запрашивает ресурсов вне хаба, текст набран IBM Plex Sans
|
||||
@ -20,3 +20,8 @@ packages = ["src/jira_hub"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.uv]
|
||||
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS
|
||||
# вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1
|
||||
python-preference = "only-managed"
|
||||
|
||||
@ -194,8 +194,13 @@ def h_app(hub, conn, m, q, body):
|
||||
return 503, Raw("text/html; charset=utf-8", NOT_BUILT)
|
||||
rest = unquote(m.group(1) or "")
|
||||
if rest.startswith("assets/"):
|
||||
if "\x00" in rest: # T1.1: нулевой байт — 404, а не 500 из resolve()
|
||||
raise ApiError(404, "not_found", "/app/" + rest.replace("\x00", "%00"))
|
||||
assets = (hub.web_dir / "assets").resolve()
|
||||
f = (hub.web_dir / rest).resolve()
|
||||
try:
|
||||
f = (hub.web_dir / rest).resolve()
|
||||
except (OSError, ValueError):
|
||||
raise ApiError(404, "not_found", "/app/" + rest) from None
|
||||
if not f.is_relative_to(assets) or not f.is_file():
|
||||
raise ApiError(404, "not_found", "/app/" + rest)
|
||||
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
|
||||
|
||||
@ -158,7 +158,6 @@ def test_bad_requests(hub):
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get("/issues", limit="abc")
|
||||
assert e.value.status == 400
|
||||
import urllib.request, urllib.error
|
||||
req = urllib.request.Request(hub["base"] + "/batches", data=b"{not json", method="POST",
|
||||
headers={"Authorization": "Bearer " + c.token})
|
||||
with pytest.raises(urllib.error.HTTPError) as e2:
|
||||
|
||||
@ -194,3 +194,9 @@ def test_migrated_claimed_redelivered_end_to_end(tmp_path):
|
||||
assert cmd["id"] == 1
|
||||
assert queue.get_command(conn, 1)["deliveries"] == 2
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sqlite_without_unix_vfs_deadlock():
|
||||
# O-14 (#29): в SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS (исправлена в 3.51.2) вешала хаб под
|
||||
# параллельными запросами — потоки ждали друг друга в sqlite3.connect/close. Python хаба — от uv (pyproject, tool.uv)
|
||||
assert sqlite3.sqlite_version_info[:2] != (3, 51) or sqlite3.sqlite_version_info >= (3, 51, 2), sqlite3.sqlite_version
|
||||
|
||||
@ -36,6 +36,9 @@ def app_hub(tmp_path):
|
||||
(web / "assets" / "index-abc123.js").write_text("console.log('ok')", encoding="utf-8")
|
||||
(web / "assets" / "index-abc123.css").write_text("body{}", encoding="utf-8")
|
||||
(web / "assets" / "font.bin").write_bytes(b"\x00\x01")
|
||||
for name, data in (("logo.svg", b"<svg/>"), ("p.png", b"\x89PNG"), ("f.ico", b"\x00"), ("f.woff2", b"wOF2"),
|
||||
("index-abc123.js.map", b"{}")):
|
||||
(web / "assets" / name).write_bytes(data)
|
||||
(tmp_path / "secret.txt").write_text("секрет", encoding="utf-8")
|
||||
srv, th, base = _serve(tmp_path, web)
|
||||
yield base
|
||||
@ -111,3 +114,16 @@ def test_old_board_addresses_redirect_to_app(app_hub, path):
|
||||
def test_links_page_is_gone():
|
||||
assert not hasattr(api, "PAGE")
|
||||
assert not (api.WEB_APP.parent / "links.html").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,ctype", [("logo.svg", "image/svg+xml"), ("p.png", "image/png"), ("f.ico", "image/x-icon"),
|
||||
("f.woff2", "font/woff2"), ("index-abc123.js.map", "application/json")])
|
||||
def test_asset_content_types(app_hub, name, ctype):
|
||||
status, r, _ = _get(app_hub, "/app/assets/" + name)
|
||||
assert status == 200 and r.getheader("Content-Type") == ctype
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/app/assets/%00", "/app/assets/a%00.js", "/app/assets/%00/../index-abc123.js"])
|
||||
def test_nul_in_asset_path_is_404(app_hub, path):
|
||||
status, _, _ = _get(app_hub, path)
|
||||
assert status == 404
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { CW } from "../src/board/constants.ts";
|
||||
import { agentServeRefresh, login } from "./hub.ts";
|
||||
|
||||
test.beforeEach(async ({ page }) => { await login(page); });
|
||||
@ -28,9 +29,9 @@ test("растяжка за уголок и возврат размера дво
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
expect((await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);
|
||||
await expect.poll(async () => (await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);
|
||||
await card.locator(".rsz").dblclick();
|
||||
expect(Math.round((await card.boundingBox())!.width)).toBe(240);
|
||||
await expect.poll(async () => Math.round((await card.boundingBox())!.width)).toBe(CW);
|
||||
});
|
||||
|
||||
test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => {
|
||||
@ -57,3 +58,23 @@ test("ⓘ открывает карточку задачи в приложени
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
|
||||
await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 600 });
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
expect(y).toBeGreaterThan(100);
|
||||
// реальный щелчок по ⓘ карточки, видимой в окне после прокрутки (иначе Playwright сам сдвинет страницу)
|
||||
const key = await page.evaluate(() => {
|
||||
const vh = window.innerHeight;
|
||||
const a = [...document.querySelectorAll<HTMLElement>(".board .card[data-key] a.open-card")]
|
||||
.find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
|
||||
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
|
||||
});
|
||||
expect(key).not.toBeNull();
|
||||
const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\//);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
@ -13,21 +13,17 @@ function watchErrors(page: Page): string[] {
|
||||
test("высота карточек берётся по содержимому из слоя измерения, а не 120px", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const rows = await page.evaluate(() => {
|
||||
const m = new Map<string, number>();
|
||||
document.querySelectorAll<HTMLElement>(".measure .card[data-key]").forEach((e) => m.set(e.dataset.key!, e.offsetHeight));
|
||||
return [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
|
||||
key: e.dataset.key!, h: parseFloat(e.style.height), measured: m.get(e.dataset.key!) ?? -1,
|
||||
measureStyleHeight: getComputedStyle(document.querySelector(`.measure .card[data-key="${e.dataset.key}"]`)!).height,
|
||||
}));
|
||||
});
|
||||
const rows = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
|
||||
key: e.dataset.key!, h: e.offsetHeight, fits: e.scrollHeight <= e.clientHeight + 1,
|
||||
})));
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const r of rows) {
|
||||
expect(r.measured, r.key).toBeGreaterThan(0);
|
||||
expect(r.h, r.key).toBe(Math.max(80, Math.ceil(r.measured / 8) * 8)); // measuredHeight: сетка 8, не меньше MINH
|
||||
expect(r.fits, `${r.key}: содержимое не помещается`).toBe(true);
|
||||
expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0);
|
||||
}
|
||||
// слой измерения не зажат в 120px: хотя бы одна карточка разной высоты с другой или не равна 120
|
||||
expect(rows.some((r) => r.measured !== 120) || new Set(rows.map((r) => r.measured)).size > 1).toBe(true);
|
||||
// на фикстуре hub-basic содержимое пяти карточек одной высоты, поэтому «разные высоты» не проверяем (отклонение от плана):
|
||||
// высота не равна прежней фиксированной 120px
|
||||
expect(rows.some((r) => r.h !== 120), "все карточки по 120px — высота не по содержимому").toBe(true);
|
||||
});
|
||||
|
||||
test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { HUB_HOME } from "../playwright.config.ts";
|
||||
import { HUB_HOME, HUB_URL } from "../playwright.config.ts";
|
||||
|
||||
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { agent: string; client: string };
|
||||
const BASE = "http://127.0.0.1:8791";
|
||||
const BASE = HUB_URL;
|
||||
|
||||
export async function login(page: Page, path = "/app/board") {
|
||||
await page.goto(path);
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Хаб Jira</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@ -6,6 +6,8 @@
|
||||
"": {
|
||||
"name": "jira-hub-web",
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||
"@tanstack/react-query": "5.104.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
@ -663,6 +665,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-sans": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
|
||||
"integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@ -3339,9 +3359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"golden": "node scripts/golden-from-links.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||
"@tanstack/react-query": "5.104.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
@ -35,5 +37,8 @@
|
||||
"typescript-eslint": "8.71.0",
|
||||
"vite": "8.3.1",
|
||||
"vitest": "5.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"source-map-js": "1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,21 @@ import { defineConfig } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub");
|
||||
export const HUB_PORT = 8791;
|
||||
export const HUB_URL = `http://127.0.0.1:${HUB_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "e2e",
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
use: { baseURL: "http://127.0.0.1:8791", trace: "retain-on-failure", viewport: { width: 1600, height: 1000 } },
|
||||
use: { baseURL: HUB_URL, trace: "retain-on-failure", viewport: { width: 1600, height: 1000 } },
|
||||
webServer: {
|
||||
command: "npm run build && bash scripts/test-hub.sh",
|
||||
url: "http://127.0.0.1:8791/app/",
|
||||
url: `${HUB_URL}/app/`,
|
||||
reuseExistingServer: false,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: 120_000,
|
||||
env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" },
|
||||
env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) },
|
||||
},
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ set -euo pipefail
|
||||
WEB=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ROOT=$(cd "$WEB/.." && pwd)
|
||||
export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}"
|
||||
PORT="${PORT:-8791}"
|
||||
PORT="${PORT:-8791}" # держать равным HUB_PORT в playwright.config.ts
|
||||
case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac
|
||||
# чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем
|
||||
case "$JIRA_HUB_HOME" in
|
||||
@ -13,6 +13,9 @@ case "$JIRA_HUB_HOME" in
|
||||
"$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;;
|
||||
*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;;
|
||||
esac
|
||||
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3
|
||||
fi
|
||||
rm -rf "$JIRA_HUB_HOME"
|
||||
cd "$ROOT"
|
||||
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "./App.tsx";
|
||||
import { clearToken, getAuth, setToken } from "./auth/token.ts";
|
||||
import { routes } from "./routes.tsx";
|
||||
import { Layout } from "./shell/Layout.tsx";
|
||||
import { json, mockFetch } from "./test/fetchMock.ts";
|
||||
|
||||
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
@ -24,11 +24,16 @@ describe("оболочка", () => {
|
||||
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
|
||||
expect(router.state.location.pathname).toBe("/app/issues");
|
||||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("link", { name: "Доска" }));
|
||||
expect(router.state.location.pathname).toBe("/app/board");
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("неизвестный путь — «не найдено» со ссылками", async () => {
|
||||
render(<App router={at("/app/nope")} />);
|
||||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board");
|
||||
expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues");
|
||||
});
|
||||
|
||||
it("«Сменить токен» показывает экран входа", async () => {
|
||||
@ -40,7 +45,7 @@ describe("оболочка", () => {
|
||||
|
||||
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
|
||||
const Boom = () => { throw new Error("сломалось"); };
|
||||
const router = createMemoryRouter([{ path: "/", element: <App.Layout />, children: [{ path: "x", element: <Boom /> }] }],
|
||||
const router = createMemoryRouter([{ path: "/", element: <Layout />, children: [{ path: "x", element: <Boom /> }] }],
|
||||
{ initialEntries: ["/x"] });
|
||||
render(<App router={router} />);
|
||||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||||
|
||||
@ -4,7 +4,6 @@ import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { makeQueryClient } from "./api/queryClient.ts";
|
||||
import { AuthGate } from "./auth/AuthGate.tsx";
|
||||
import { routes } from "./routes.tsx";
|
||||
import { Layout } from "./shell/Layout.tsx";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/controls.css";
|
||||
@ -20,4 +19,3 @@ export function App({ router }: { router?: Router }) {
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
App.Layout = Layout;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearToken, setToken } from "../auth/token.ts";
|
||||
import { clearToken, getAuth, setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts";
|
||||
import { api, ApiError, AuthError, checkToken, dropTokenOn, HubDownError } from "./client.ts";
|
||||
|
||||
describe("api", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
@ -21,6 +21,19 @@ describe("api", () => {
|
||||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>)["Content-Type"]).toBe("application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
it("AuthError хранит токен запроса; dropTokenOn не трогает новый токен (T3.5)", async () => {
|
||||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||||
const e = await api.get("/board").catch((err: unknown) => err) as AuthError;
|
||||
expect(e).toBeInstanceOf(AuthError);
|
||||
expect(e.token).toBe("tok");
|
||||
setToken("new-tok");
|
||||
dropTokenOn(e);
|
||||
expect(getAuth()).toEqual({ token: "new-tok", reason: "none" });
|
||||
setToken("tok");
|
||||
dropTokenOn(e);
|
||||
expect(getAuth()).toEqual({ token: "", reason: "bad" });
|
||||
});
|
||||
|
||||
it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => {
|
||||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||||
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON.
|
||||
// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail}
|
||||
import { getToken, TOKEN_RE } from "../auth/token.ts";
|
||||
import { clearToken, getToken, TOKEN_RE } from "../auth/token.ts";
|
||||
import type { HubSettings } from "./types.ts";
|
||||
|
||||
export class AuthError extends Error {}
|
||||
export class AuthError extends Error {
|
||||
readonly token: string; // токен, с которым ушёл запрос: запоздавший 401 прежнему токену не выбрасывает на вход (T3.5)
|
||||
constructor(message: string, token: string) {
|
||||
super(message);
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
// 401 выводит на экран входа, только если запрос ушёл с текущим токеном (R-3): новый токен уже проверен GET /settings
|
||||
export function dropTokenOn(e: AuthError): void {
|
||||
if (e.token !== getToken()) return;
|
||||
clearToken(e.token ? "bad" : "none");
|
||||
}
|
||||
|
||||
export class HubDownError extends Error {}
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@ -28,9 +41,9 @@ function withParams(path: string, params?: Params): string {
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
|
||||
if (!token) throw new AuthError("нет токена");
|
||||
if (!token) throw new AuthError("нет токена", token);
|
||||
// токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает»
|
||||
if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене");
|
||||
if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token);
|
||||
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
|
||||
let resp: Response;
|
||||
@ -39,7 +52,7 @@ async function call<T>(method: string, path: string, body?: unknown, token = get
|
||||
} catch {
|
||||
throw new HubDownError("хаб не отвечает");
|
||||
}
|
||||
if (resp.status === 401) throw new AuthError("токен не подошёл");
|
||||
if (resp.status === 401) throw new AuthError("токен не подошёл", token);
|
||||
let data: unknown = null;
|
||||
try { data = await resp.json(); } catch { /* тело не JSON */ }
|
||||
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
|
||||
|
||||
37
web/src/api/queries.test.ts
Normal file
37
web/src/api/queries.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { qk, useIssues, useSaveNote } from "./queries.ts";
|
||||
import type { IssueDetail } from "./types.ts";
|
||||
|
||||
const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client }, children);
|
||||
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ key: `A-${i}` }));
|
||||
|
||||
describe("запросы", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("getNextPageParam: 50 → offset 50, 17 → undefined", async () => {
|
||||
const seen: (string | null)[] = [];
|
||||
mockFetch((_m, url) => { seen.push(url.searchParams.get("offset")); return json(200, { issues: rows(seen.length === 1 ? 50 : 17) }); });
|
||||
const client = new QueryClient();
|
||||
const { result } = renderHook(() => useIssues({}), { wrapper: wrap(client) });
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
await result.current.fetchNextPage();
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||
expect(seen).toEqual(["0", "50"]);
|
||||
});
|
||||
|
||||
it("useSaveNote(A-1): ответ пишется в карточку A-1", async () => {
|
||||
const note = { issue_key: "A-1", comment: "x", updated_by: "web" };
|
||||
mockFetch(() => json(200, note));
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(qk.issue("A-1"), { key: "A-1", note: null } as unknown as IssueDetail);
|
||||
const { result } = renderHook(() => useSaveNote("A-1"), { wrapper: wrap(client) });
|
||||
await result.current.mutateAsync({} as never);
|
||||
expect(client.getQueryData<IssueDetail>(qk.issue("A-1"))?.note).toEqual(note);
|
||||
});
|
||||
});
|
||||
28
web/src/api/queryClient.test.ts
Normal file
28
web/src/api/queryClient.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getAuth, setToken } from "../auth/token.ts";
|
||||
import { AuthError, HubDownError } from "./client.ts";
|
||||
import { makeQueryClient } from "./queryClient.ts";
|
||||
|
||||
const fail = (e: Error) => makeQueryClient().fetchQuery({ queryKey: ["x"], queryFn: () => Promise.reject(e) }).catch(() => undefined);
|
||||
|
||||
describe("queryClient", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("401 с текущим токеном — экран входа с причиной «bad»", async () => {
|
||||
await fail(new AuthError("токен не подошёл", "tok"));
|
||||
expect(getAuth()).toEqual({ token: "", reason: "bad" });
|
||||
});
|
||||
it("401 с прежним токеном не стирает текущий (T3.5)", async () => {
|
||||
await fail(new AuthError("токен не подошёл", "old"));
|
||||
expect(getAuth()).toEqual({ token: "tok", reason: "none" });
|
||||
});
|
||||
it("прочие ошибки токен не трогают", async () => {
|
||||
await fail(new HubDownError("хаб не отвечает"));
|
||||
expect(getAuth().token).toBe("tok");
|
||||
});
|
||||
it("повторов нет: запрос с ошибкой выполняется один раз", async () => {
|
||||
let n = 0;
|
||||
await makeQueryClient().fetchQuery({ queryKey: ["y"], queryFn: () => { n += 1; return Promise.reject(new HubDownError("x")); } }).catch(() => undefined);
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,9 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { clearToken, getToken } from "../auth/token.ts";
|
||||
import { AuthError } from "./client.ts";
|
||||
import { AuthError, dropTokenOn } from "./client.ts";
|
||||
|
||||
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл»
|
||||
// 401 на запрос с текущим токеном — экран входа (запоздавший 401 прежнего токена игнорируется, R-3)
|
||||
export function makeQueryClient(): QueryClient {
|
||||
const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); };
|
||||
const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); };
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({ onError }),
|
||||
mutationCache: new MutationCache({ onError }),
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk, useBoard } from "../api/queries.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { renderWithProviders } from "../test/render.tsx";
|
||||
import { AuthGate } from "./AuthGate.tsx";
|
||||
import { clearToken, getAuth } from "./token.ts";
|
||||
import { clearToken, getAuth, setToken } from "./token.ts";
|
||||
|
||||
const app = <AuthGate><p>страница приложения</p></AuthGate>;
|
||||
|
||||
function Page() { useBoard(); return <p>страница приложения</p>; }
|
||||
|
||||
describe("вход", () => {
|
||||
beforeEach(() => clearToken());
|
||||
|
||||
@ -56,10 +59,34 @@ describe("вход", () => {
|
||||
expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("после 401 в приложении экран входа поясняет причину", async () => {
|
||||
clearToken("bad");
|
||||
it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => {
|
||||
setToken("stale");
|
||||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||||
renderWithProviders(<AuthGate><Page /></AuthGate>);
|
||||
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
|
||||
expect(getAuth()).toEqual({ token: "", reason: "bad" });
|
||||
});
|
||||
|
||||
it("запоздавший 401 от прежнего токена после входа с новым не выбрасывает на вход (T3.5)", async () => {
|
||||
setToken("old-token");
|
||||
let release!: (r: Response) => void;
|
||||
const slow = new Promise<Response>((r) => { release = r; });
|
||||
mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {})));
|
||||
const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
|
||||
act(() => setToken("new-token"));
|
||||
release(json(401, { error: "unauthorized" }));
|
||||
await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error")); // 401 точно обработан
|
||||
expect(screen.getByText("страница приложения")).toBeInTheDocument();
|
||||
expect(getAuth()).toEqual({ token: "new-token", reason: "none" });
|
||||
});
|
||||
|
||||
it("хаб отверг проверку токена (4xx) — текст хаба, не «хаб не отвечает» (T3.2)", async () => {
|
||||
mockFetch(() => json(400, { error: "bad_request", detail: "заголовок Authorization без Bearer" }));
|
||||
renderWithProviders(app);
|
||||
await waitFor(() => expect(getAuth().reason).toBe("bad"));
|
||||
expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(await screen.findByText(/заголовок Authorization без Bearer/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Хаб не отвечает/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { AuthError, checkToken } from "../api/client.ts";
|
||||
import { ApiError, AuthError, checkToken } from "../api/client.ts";
|
||||
import { setToken, TOKEN_RE } from "./token.ts";
|
||||
import { useAuth } from "./useAuth.ts";
|
||||
|
||||
@ -26,7 +26,9 @@ export function LoginScreen() {
|
||||
qc.clear();
|
||||
setToken(t);
|
||||
} catch (e) {
|
||||
setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN);
|
||||
setError(e instanceof AuthError ? BAD_TOKEN
|
||||
: e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}` // T3.2: 4xx — текст хаба
|
||||
: HUB_DOWN);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@ -25,10 +25,13 @@ describe("token", () => {
|
||||
it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||||
const t = await import("./token.ts");
|
||||
expect(t.getToken()).toBe("");
|
||||
t.setToken("mem");
|
||||
expect(t.getToken()).toBe("mem");
|
||||
t.clearToken("bad");
|
||||
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
|
||||
});
|
||||
|
||||
it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => {
|
||||
|
||||
@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { renderWithProviders } from "../test/render.tsx";
|
||||
@ -132,4 +133,29 @@ describe("страница доски", () => {
|
||||
page();
|
||||
expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("T12.3: --head-h снимается при уходе со страницы", async () => {
|
||||
hub();
|
||||
const r = page();
|
||||
await screen.findByText(/^Хаб · данные на/);
|
||||
r.unmount();
|
||||
expect(document.documentElement.style.getPropertyValue("--head-h")).toBe("");
|
||||
});
|
||||
|
||||
it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => {
|
||||
hub();
|
||||
renderWithProviders(<StrictMode><BoardPage /></StrictMode>, { route: "/board", path: "/board" });
|
||||
await screen.findByText(/^Хаб · данные на/);
|
||||
const set = vi.spyOn(Storage.prototype, "setItem");
|
||||
fireEvent.click(screen.getByRole("button", { name: /критический путь/ }));
|
||||
expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]);
|
||||
});
|
||||
|
||||
it("T12.4: список исполнителей — группа флажков с подписью", async () => {
|
||||
hub();
|
||||
page();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Все исполнители/ }));
|
||||
expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useSettings } from "../api/queries.ts";
|
||||
import { computeVisible, emptyReason } from "./analysis/visible.ts";
|
||||
import { BoardCanvas } from "./components/BoardCanvas.tsx";
|
||||
@ -25,19 +25,16 @@ export function BoardPage() {
|
||||
[src.model, prefs.filters, prefs.store.laneOrder, today]);
|
||||
const [exportText, setExportText] = useState<string | null>(null);
|
||||
const saveRef = useRef<(() => void) | null>(null);
|
||||
const headRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019)
|
||||
useLayoutEffect(() => {
|
||||
const el = headRef.current;
|
||||
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
|
||||
const headRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el) return;
|
||||
const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
|
||||
const root = document.documentElement.style;
|
||||
const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
|
||||
set();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(set);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
|
||||
}, []);
|
||||
|
||||
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
|
||||
if (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
|
||||
|
||||
@ -6312,6 +6312,788 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"saved-short": {
|
||||
"emptyReason": null,
|
||||
"layout": {
|
||||
"baseW": 1208,
|
||||
"board": {
|
||||
"height": "1124px",
|
||||
"width": "1208px"
|
||||
},
|
||||
"clamp": {
|
||||
"AURORA-9001": "2",
|
||||
"AURORA-9002": "3",
|
||||
"AURORA-9003": "unset",
|
||||
"AURORA-9004": "6",
|
||||
"AURORA-9005": "unset",
|
||||
"AURORA-9006": "unset",
|
||||
"AURORA-9007": "unset",
|
||||
"AURORA-9008": "12",
|
||||
"AURORA-9009": "unset"
|
||||
},
|
||||
"colBounds": {},
|
||||
"dateCols": [],
|
||||
"dateLabels": [
|
||||
{
|
||||
"className": "dlabel none",
|
||||
"innerHTML": "без срока<span class=\"n\">1</span>",
|
||||
"left": "20px",
|
||||
"title": "плановая дата не задана"
|
||||
},
|
||||
{
|
||||
"className": "dlabel past",
|
||||
"innerHTML": "10.10<span class=\"n\">1</span>",
|
||||
"left": "316px",
|
||||
"title": "срок прошёл"
|
||||
},
|
||||
{
|
||||
"className": "dlabel past",
|
||||
"innerHTML": "12.10<span class=\"n\">1</span>",
|
||||
"left": "612px",
|
||||
"title": "срок прошёл"
|
||||
},
|
||||
{
|
||||
"className": "dlabel",
|
||||
"innerHTML": "16.10<span class=\"n\">1</span>",
|
||||
"left": "908px",
|
||||
"title": "плановая дата окончания"
|
||||
},
|
||||
{
|
||||
"className": "dlabel",
|
||||
"innerHTML": "18.10<span class=\"n\">1</span>",
|
||||
"left": "1204px",
|
||||
"title": "плановая дата окончания"
|
||||
},
|
||||
{
|
||||
"className": "dlabel",
|
||||
"innerHTML": "20.10<span class=\"n\">3</span>",
|
||||
"left": "1500px",
|
||||
"title": "плановая дата окончания"
|
||||
},
|
||||
{
|
||||
"className": "dlabel",
|
||||
"innerHTML": "25.10<span class=\"n\">1</span>",
|
||||
"left": "1900px",
|
||||
"title": "плановая дата окончания"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M270 372 L291 372 Q295 372 295 376 L295 376 Q295 380 299 380 L320 380",
|
||||
"dst": "AURORA-9002",
|
||||
"hand": true,
|
||||
"src": "AURORA-9001",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M264 498 L288 498 Q298 498 298 488 L298 382 Q298 372 288 372 L270 372",
|
||||
"dst": "AURORA-9001",
|
||||
"hand": true,
|
||||
"src": "AURORA-9003",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M560 380 L726 380 Q736 380 736 370 L736 110 Q736 100 746 100 L912 100",
|
||||
"dst": "AURORA-9005",
|
||||
"hand": false,
|
||||
"src": "AURORA-9002",
|
||||
"type": "блокирует завершение"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M270 150 L285 150 Q295 150 295 160 L295 370 Q295 380 305 380 L320 380",
|
||||
"dst": "AURORA-9002",
|
||||
"hand": true,
|
||||
"src": "AURORA-9004",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M150 432 L150 516 Q150 526 160 526 L160 526 Q170 526 170 536 L170 620",
|
||||
"dst": "AURORA-9008",
|
||||
"hand": false,
|
||||
"src": "AURORA-9001",
|
||||
"type": "связанные"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"d": "M264 362 L320 362",
|
||||
"dst": "AURORA-9009",
|
||||
"hand": true,
|
||||
"src": "AURORA-9007",
|
||||
"type": "блокирует начало"
|
||||
}
|
||||
],
|
||||
"laneOf": {
|
||||
"AURORA-9001": 1,
|
||||
"AURORA-9002": 1,
|
||||
"AURORA-9003": 1,
|
||||
"AURORA-9004": 0,
|
||||
"AURORA-9005": 0,
|
||||
"AURORA-9006": 3,
|
||||
"AURORA-9007": 1,
|
||||
"AURORA-9008": 2,
|
||||
"AURORA-9009": 1
|
||||
},
|
||||
"lanes": [
|
||||
{
|
||||
"folded": false,
|
||||
"h": 262,
|
||||
"minH": 144,
|
||||
"name": "Гудвилл",
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"folded": false,
|
||||
"h": 308,
|
||||
"minH": 144,
|
||||
"name": "Тестовый эпик",
|
||||
"y": 262
|
||||
},
|
||||
{
|
||||
"folded": false,
|
||||
"h": 382,
|
||||
"minH": 144,
|
||||
"name": "AURORA-9999",
|
||||
"y": 570
|
||||
},
|
||||
{
|
||||
"folded": false,
|
||||
"h": 172,
|
||||
"minH": 144,
|
||||
"name": "Без эпика",
|
||||
"y": 952
|
||||
}
|
||||
],
|
||||
"pos": {
|
||||
"AURORA-9001": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 30,
|
||||
"y": 312
|
||||
},
|
||||
"AURORA-9002": {
|
||||
"h": 136,
|
||||
"w": 240,
|
||||
"x": 320,
|
||||
"y": 312
|
||||
},
|
||||
"AURORA-9003": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 24,
|
||||
"y": 438
|
||||
},
|
||||
"AURORA-9004": {
|
||||
"h": 200,
|
||||
"w": 240,
|
||||
"x": 30,
|
||||
"y": 50
|
||||
},
|
||||
"AURORA-9005": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 912,
|
||||
"y": 40
|
||||
},
|
||||
"AURORA-9006": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 24,
|
||||
"y": 992
|
||||
},
|
||||
"AURORA-9007": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 24,
|
||||
"y": 302
|
||||
},
|
||||
"AURORA-9008": {
|
||||
"h": 320,
|
||||
"w": 280,
|
||||
"x": 30,
|
||||
"y": 620
|
||||
},
|
||||
"AURORA-9009": {
|
||||
"h": 120,
|
||||
"w": 240,
|
||||
"x": 320,
|
||||
"y": 302
|
||||
}
|
||||
},
|
||||
"rel": {
|
||||
"AURORA-9001": {
|
||||
"auto": false,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 50,
|
||||
"w": 240,
|
||||
"x": 30
|
||||
},
|
||||
"AURORA-9002": {
|
||||
"auto": false,
|
||||
"h": 136,
|
||||
"other": 79,
|
||||
"ry": 50,
|
||||
"w": 240,
|
||||
"x": 320
|
||||
},
|
||||
"AURORA-9003": {
|
||||
"auto": true,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 176,
|
||||
"w": 240,
|
||||
"x": 24
|
||||
},
|
||||
"AURORA-9004": {
|
||||
"auto": false,
|
||||
"h": 200,
|
||||
"other": 79,
|
||||
"ry": 50,
|
||||
"w": 240,
|
||||
"x": 30
|
||||
},
|
||||
"AURORA-9005": {
|
||||
"auto": true,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 40,
|
||||
"w": 240,
|
||||
"x": 912
|
||||
},
|
||||
"AURORA-9006": {
|
||||
"auto": true,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 40,
|
||||
"w": 240,
|
||||
"x": 24
|
||||
},
|
||||
"AURORA-9007": {
|
||||
"auto": true,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 40,
|
||||
"w": 240,
|
||||
"x": 24
|
||||
},
|
||||
"AURORA-9008": {
|
||||
"auto": false,
|
||||
"h": 320,
|
||||
"other": 79,
|
||||
"ry": 50,
|
||||
"w": 280,
|
||||
"x": 30
|
||||
},
|
||||
"AURORA-9009": {
|
||||
"auto": true,
|
||||
"h": 120,
|
||||
"other": 79,
|
||||
"ry": 40,
|
||||
"w": 240,
|
||||
"x": 320
|
||||
}
|
||||
}
|
||||
},
|
||||
"visible": {
|
||||
"lanes": [
|
||||
{
|
||||
"keys": [
|
||||
"AURORA-9004",
|
||||
"AURORA-9005"
|
||||
],
|
||||
"name": "Гудвилл"
|
||||
},
|
||||
{
|
||||
"keys": [
|
||||
"AURORA-9001",
|
||||
"AURORA-9002",
|
||||
"AURORA-9003",
|
||||
"AURORA-9007",
|
||||
"AURORA-9009"
|
||||
],
|
||||
"name": "Тестовый эпик"
|
||||
},
|
||||
{
|
||||
"keys": [
|
||||
"AURORA-9008"
|
||||
],
|
||||
"name": "AURORA-9999"
|
||||
},
|
||||
{
|
||||
"keys": [
|
||||
"AURORA-9006"
|
||||
],
|
||||
"name": "Без эпика"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9002",
|
||||
"hand": true,
|
||||
"src": "AURORA-9001",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9001",
|
||||
"hand": true,
|
||||
"src": "AURORA-9003",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9005",
|
||||
"hand": false,
|
||||
"src": "AURORA-9002",
|
||||
"type": "блокирует завершение"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9002",
|
||||
"hand": true,
|
||||
"src": "AURORA-9004",
|
||||
"type": "блокирует начало"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9008",
|
||||
"hand": false,
|
||||
"src": "AURORA-9001",
|
||||
"type": "связанные"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dst": "AURORA-9009",
|
||||
"hand": true,
|
||||
"src": "AURORA-9007",
|
||||
"type": "блокирует начало"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9001",
|
||||
"kind": "разработка",
|
||||
"prio": "Высокий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Схема свода",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-16",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9003",
|
||||
"kind": "UI",
|
||||
"prio": "Критичный",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Backlog",
|
||||
"stub": false,
|
||||
"title": "Макет экрана",
|
||||
"who": ""
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-25",
|
||||
"epic": "Гудвилл",
|
||||
"focus": false,
|
||||
"key": "AURORA-9004",
|
||||
"kind": "решение",
|
||||
"prio": "Наивысший",
|
||||
"sprint": "AWPCA_Спринт 3",
|
||||
"status": "К выполнению",
|
||||
"stub": false,
|
||||
"title": "Решение по порогам",
|
||||
"who": "Сидорова Анна"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "",
|
||||
"epic": "Гудвилл",
|
||||
"focus": false,
|
||||
"key": "AURORA-9005",
|
||||
"kind": "тестирование",
|
||||
"prio": "Низкий",
|
||||
"sprint": "AWPCA_Спринт 3",
|
||||
"status": "Ожидание",
|
||||
"stub": false,
|
||||
"title": "Тест-кейсы",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-10",
|
||||
"epic": "Без эпика",
|
||||
"focus": false,
|
||||
"key": "AURORA-9006",
|
||||
"kind": "Задача",
|
||||
"prio": "Средний",
|
||||
"sprint": "",
|
||||
"status": "Готово",
|
||||
"stub": false,
|
||||
"title": "Задача без эпика",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-12",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9007",
|
||||
"kind": "аналитика",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 1",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Сосед по связям",
|
||||
"who": "Kozlov-AA"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-18",
|
||||
"epic": "AURORA-9999",
|
||||
"focus": false,
|
||||
"key": "AURORA-9008",
|
||||
"kind": "актуализация",
|
||||
"prio": "Самый низкий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "Актуализация ТЗ",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
{
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9009",
|
||||
"kind": "организационная",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "",
|
||||
"stub": false,
|
||||
"title": "Созвон с заказчиком",
|
||||
"who": "Иванов Иван Иванович"
|
||||
}
|
||||
],
|
||||
"problems": {
|
||||
"dates": [
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9001",
|
||||
"kind": "разработка",
|
||||
"prio": "Высокий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Схема свода",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
"text": "сроки совпадают (20.10): запаса нет"
|
||||
},
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-25",
|
||||
"epic": "Гудвилл",
|
||||
"focus": false,
|
||||
"key": "AURORA-9004",
|
||||
"kind": "решение",
|
||||
"prio": "Наивысший",
|
||||
"sprint": "AWPCA_Спринт 3",
|
||||
"status": "К выполнению",
|
||||
"stub": false,
|
||||
"title": "Решение по порогам",
|
||||
"who": "Сидорова Анна"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
"text": "блокер должен закончиться 25.10, а зависимая — уже 20.10"
|
||||
}
|
||||
],
|
||||
"hand": [
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9001",
|
||||
"kind": "разработка",
|
||||
"prio": "Высокий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Схема свода",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
"text": "Петров П. П. ждёт Иванов И. И."
|
||||
},
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-25",
|
||||
"epic": "Гудвилл",
|
||||
"focus": false,
|
||||
"key": "AURORA-9004",
|
||||
"kind": "решение",
|
||||
"prio": "Наивысший",
|
||||
"sprint": "AWPCA_Спринт 3",
|
||||
"status": "К выполнению",
|
||||
"stub": false,
|
||||
"title": "Решение по порогам",
|
||||
"who": "Сидорова Анна"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
"text": "Петров П. П. ждёт Сидорова А."
|
||||
},
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-12",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9007",
|
||||
"kind": "аналитика",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 1",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Сосед по связям",
|
||||
"who": "Kozlov-AA"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9009",
|
||||
"kind": "организационная",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "",
|
||||
"stub": false,
|
||||
"title": "Созвон с заказчиком",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
"text": "Иванов И. И. ждёт Kozlov-AA"
|
||||
}
|
||||
],
|
||||
"late": [
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-16",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9003",
|
||||
"kind": "UI",
|
||||
"prio": "Критичный",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Backlog",
|
||||
"stub": false,
|
||||
"title": "Макет экрана",
|
||||
"who": ""
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9001",
|
||||
"kind": "разработка",
|
||||
"prio": "Высокий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Схема свода",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
"text": "блокер в статусе «Backlog», а до срока зависимой 6 дн."
|
||||
},
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-25",
|
||||
"epic": "Гудвилл",
|
||||
"focus": false,
|
||||
"key": "AURORA-9004",
|
||||
"kind": "решение",
|
||||
"prio": "Наивысший",
|
||||
"sprint": "AWPCA_Спринт 3",
|
||||
"status": "К выполнению",
|
||||
"stub": false,
|
||||
"title": "Решение по порогам",
|
||||
"who": "Сидорова Анна"
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9002",
|
||||
"kind": "разработка",
|
||||
"prio": "Средний",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Ожидание разработки",
|
||||
"stub": false,
|
||||
"title": "API свода",
|
||||
"who": "Петров Пётр Петрович"
|
||||
},
|
||||
"text": "блокер в статусе «К выполнению», а до срока зависимой 6 дн."
|
||||
}
|
||||
],
|
||||
"unassigned": [
|
||||
{
|
||||
"a": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-16",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9003",
|
||||
"kind": "UI",
|
||||
"prio": "Критичный",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "Backlog",
|
||||
"stub": false,
|
||||
"title": "Макет экрана",
|
||||
"who": ""
|
||||
},
|
||||
"b": {
|
||||
"crit": false,
|
||||
"dim": false,
|
||||
"due": "2026-10-20",
|
||||
"epic": "Тестовый эпик",
|
||||
"focus": false,
|
||||
"key": "AURORA-9001",
|
||||
"kind": "разработка",
|
||||
"prio": "Высокий",
|
||||
"sprint": "AWPCA_Спринт 2",
|
||||
"status": "В работе",
|
||||
"stub": false,
|
||||
"title": "Схема свода",
|
||||
"who": "Иванов Иван Иванович"
|
||||
},
|
||||
"text": "Иванов И. И. ждёт задачу без исполнителя"
|
||||
}
|
||||
]
|
||||
},
|
||||
"removed": {
|
||||
"core": 9,
|
||||
"ctx": 0,
|
||||
"edge": 0,
|
||||
"loose": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"sprint-2": {
|
||||
"emptyReason": null,
|
||||
"layout": {
|
||||
|
||||
@ -10,5 +10,6 @@
|
||||
{"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]},
|
||||
{"name": "by-date", "filters": {"byDate": true}},
|
||||
{"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]},
|
||||
{"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}}
|
||||
{"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}},
|
||||
{"name": "saved-short", "filters": {}, "saved": {"AURORA-9001": {"x": 30, "ry": 50, "w": 240, "h": 120}, "AURORA-9002": {"x": 320, "ry": 50, "w": 240, "h": 136}, "AURORA-9004": {"x": 30, "ry": 50, "w": 240, "h": 200}, "AURORA-9008": {"x": 30, "ry": 50, "w": 280, "h": 320}}}
|
||||
]
|
||||
|
||||
@ -51,4 +51,15 @@ describe("выборка = links.html@3931a88", () => {
|
||||
check([], golden.facets.rememberedAll);
|
||||
check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome);
|
||||
});
|
||||
|
||||
it("initialWho: из запомненного остаются только исполнители, которые есть на доске", () => {
|
||||
const real = "Иванов Иван Иванович";
|
||||
expect(canon(initialWho(model, noFilters(), [real, "нет такого"]))).toEqual([real]);
|
||||
});
|
||||
|
||||
it("initialWho: все задачи без исполнителя — выбор пуст, даже если запомнен пустой ключ", () => {
|
||||
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
|
||||
const bare = fromHub({ ...snap, issues: snap.issues.map((i) => ({ ...i, assignee: null })) }, dicts);
|
||||
expect(initialWho(bare, noFilters(), [""]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,11 +35,13 @@ export function sprintBox(model: BoardModel): Box {
|
||||
if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" };
|
||||
return { kind: "multi" };
|
||||
}
|
||||
// список исполнителей имеет смысл, если есть хотя бы один назначенный (единственный пустой ключ — «все без исполнителя»)
|
||||
const isMultiWho = (names: string[]) => names.length > 0 && !(names.length === 1 && names[0] === "");
|
||||
// то же для исполнителей
|
||||
export function whoBox(model: BoardModel, f: Filters): Box {
|
||||
if (!model.nodes.length) return { kind: "none" };
|
||||
const names = Object.keys(whoItems(model, f).total);
|
||||
if (!names.length || (names.length === 1 && names[0] === "")) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" };
|
||||
if (!isMultiWho(names)) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" };
|
||||
return { kind: "multi" };
|
||||
}
|
||||
|
||||
@ -56,7 +58,9 @@ export function initialSprints(model: BoardModel, f: Filters, remembered: unknow
|
||||
}
|
||||
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
|
||||
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
|
||||
if (!model.nodes.length || whoBox(model, f).kind !== "multi") return new Set();
|
||||
if (!model.nodes.length) return new Set();
|
||||
const { total } = whoItems(model, f);
|
||||
const names = Object.keys(total);
|
||||
if (!isMultiWho(names)) return new Set();
|
||||
return new Set(strList(remembered).filter((w) => total[w] !== undefined));
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardSnapshot } from "../../api/types.ts";
|
||||
import { renderWithProviders } from "../../test/render.tsx";
|
||||
import { computeVisible } from "../analysis/visible.ts";
|
||||
@ -10,6 +10,7 @@ import { dictsFromSettings } from "../dicts.ts";
|
||||
import settings from "../__fixtures__/settings.json";
|
||||
import { fromHub } from "../model/fromHub.ts";
|
||||
import { noFilters } from "../types.ts";
|
||||
import { readBoardScroll } from "../scrollMemory.ts";
|
||||
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
|
||||
|
||||
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
|
||||
@ -17,11 +18,14 @@ const { dicts } = dictsFromSettings(settings);
|
||||
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||||
const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 };
|
||||
|
||||
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
function baseProps(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) };
|
||||
const vis = computeVisible(model, f, [], "2026-10-14");
|
||||
const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
|
||||
return { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
|
||||
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
|
||||
}
|
||||
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
const props = baseProps(over);
|
||||
renderWithProviders(<BoardCanvas {...props} />);
|
||||
return props;
|
||||
}
|
||||
@ -87,7 +91,7 @@ describe("BoardCanvas", () => {
|
||||
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", () => {
|
||||
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", async () => {
|
||||
const wide = computeVisible(model, noFilters(), [], "2026-10-14");
|
||||
const narrow = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]) }, [], "2026-10-14");
|
||||
expect(narrow.nodes.length).toBeLessThan(wide.nodes.length);
|
||||
@ -104,11 +108,54 @@ describe("BoardCanvas", () => {
|
||||
renderWithProviders(<Harness />);
|
||||
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(wide.nodes.length);
|
||||
fireEvent.click(screen.getByText("сузить"));
|
||||
expect(board().querySelectorAll(".card[data-key]").length).toBeLessThanOrEqual(narrow.nodes.length);
|
||||
await waitFor(() => expect(board().querySelectorAll(".card[data-key]")).toHaveLength(narrow.nodes.length));
|
||||
});
|
||||
|
||||
it("полоса дат получает ширину обёртки доски", () => {
|
||||
draw({ byDate: true });
|
||||
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toMatch(/px$|^0/);
|
||||
const wrap = document.querySelector(".wrap") as HTMLElement;
|
||||
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`);
|
||||
});
|
||||
|
||||
it("T11.3: перетаскивание названия дорожки вниз — onMoveLane с новым местом", () => {
|
||||
// в jsdom может не быть PointerEvent и захвата указателя — минимальные замены только для теста
|
||||
if (typeof window.PointerEvent === "undefined") Object.defineProperty(window, "PointerEvent", { configurable: true, value: class extends MouseEvent {} });
|
||||
if (typeof Element.prototype.setPointerCapture !== "function") Object.defineProperty(Element.prototype, "setPointerCapture", { configurable: true, value: () => {} });
|
||||
const p = draw();
|
||||
const name = [...board().querySelectorAll<HTMLElement>(".lane-name")].find((e) => e.textContent === "Гудвилл")!;
|
||||
fireEvent.pointerDown(name, { pointerId: 1, clientY: 5 });
|
||||
fireEvent.pointerMove(name, { pointerId: 1, clientY: 100000 });
|
||||
fireEvent.pointerUp(name, { pointerId: 1, clientY: 100000 });
|
||||
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", p.vis.lanes.length - 1);
|
||||
});
|
||||
|
||||
afterEach(() => { Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); });
|
||||
|
||||
it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => {
|
||||
const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
const first = renderWithProviders(<BoardCanvas {...baseProps()} />);
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
|
||||
fireEvent.scroll(window);
|
||||
expect(readBoardScroll()).toMatchObject({ y: 640 });
|
||||
first.unmount();
|
||||
renderWithProviders(<BoardCanvas {...baseProps()} />);
|
||||
expect(to).toHaveBeenCalledWith(0, 640);
|
||||
});
|
||||
|
||||
it("M-3: запоздавшее scroll после ухода (прокрутка зажата в 0) не затирает запомненное", () => {
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
const handlers: EventListener[] = [];
|
||||
const add = window.addEventListener.bind(window);
|
||||
vi.spyOn(window, "addEventListener").mockImplementation((t: string, h: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => {
|
||||
if (t === "scroll") handlers.push(h as EventListener);
|
||||
add(t, h, o);
|
||||
});
|
||||
const r = renderWithProviders(<BoardCanvas {...baseProps()} />);
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
|
||||
fireEvent.scroll(window);
|
||||
r.unmount();
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||
handlers.forEach((h) => h(new Event("scroll"))); // событие, дошедшее до слушателя уже после снятия доски
|
||||
expect(readBoardScroll()).toMatchObject({ y: 640 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
|
||||
import { alignEdges } from "../layout/align.ts";
|
||||
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
|
||||
import { readBoardScroll, saveBoardScroll } from "../scrollMemory.ts";
|
||||
import { saveLayout, savedLayout } from "../storage.ts";
|
||||
import type { Visible } from "../types.ts";
|
||||
import { Card } from "./Card.tsx";
|
||||
@ -131,6 +132,28 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
return () => ro.disconnect();
|
||||
}, [syncScroll, placed]);
|
||||
|
||||
// M-3: прокрутка доски переживает переход в карточку задачи и обратно (R-1); восстанавливаем, когда карточки расставлены
|
||||
const restore = useRef(readBoardScroll());
|
||||
useLayoutEffect(() => {
|
||||
const s = restore.current;
|
||||
if (!s || !placed) return;
|
||||
restore.current = null;
|
||||
window.scrollTo(s.x, s.y);
|
||||
if (wrapRef.current) wrapRef.current.scrollLeft = s.left;
|
||||
syncScroll();
|
||||
}, [placed, syncScroll]);
|
||||
// запоминаем по событиям прокрутки, а не при размонтировании: к моменту ухода браузер может уже сжать прокрутку
|
||||
// под короткую страницу карточки
|
||||
useEffect(() => {
|
||||
const w = wrapRef.current;
|
||||
// после ухода со страницы браузер зажимает прокрутку короткой страницы в 0 и шлёт scroll: доску уже убрали из DOM —
|
||||
// такое событие не должно затирать запомненное
|
||||
const save = () => { if (w && !w.isConnected) return; saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 }); };
|
||||
window.addEventListener("scroll", save, { passive: true });
|
||||
w?.addEventListener("scroll", save, { passive: true });
|
||||
return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };
|
||||
}, []);
|
||||
|
||||
const empty = !vis.nodes.length;
|
||||
const multiSprint = layout?.multiSprint ?? false;
|
||||
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)
|
||||
|
||||
16
web/src/board/components/Card.test.ts
Normal file
16
web/src/board/components/Card.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cardPropsEqual, type CardProps } from "./Card.tsx";
|
||||
|
||||
const node = { key: "A-1" } as CardProps["node"];
|
||||
const base: CardProps = { node, box: { x: 1, y: 2, w: 240, h: 120 }, rel: undefined, multiSprint: false, sprintSel: new Set() };
|
||||
|
||||
describe("cardPropsEqual (T11.1)", () => {
|
||||
it("новый объект box с теми же числами — карточка не перерисовывается", () => {
|
||||
expect(cardPropsEqual(base, { ...base, box: { x: 1, y: 2, w: 240, h: 120 } })).toBe(true);
|
||||
});
|
||||
it("сдвиг, другая карточка или другой выбор спринтов — перерисовка", () => {
|
||||
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -15,6 +15,13 @@ export interface CardProps {
|
||||
onAutoSize?: (key: string) => void;
|
||||
}
|
||||
|
||||
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
|
||||
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
|
||||
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
|
||||
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
|
||||
}
|
||||
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||||
@ -47,4 +54,4 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
|
||||
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, cardPropsEqual);
|
||||
|
||||
20
web/src/board/components/DatesBar.test.tsx
Normal file
20
web/src/board/components/DatesBar.test.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DateCol } from "../layout/layout.ts";
|
||||
import { DatesBar } from "./DatesBar.tsx";
|
||||
|
||||
describe("DatesBar (T11.2)", () => {
|
||||
it("отложенный кадр растяжки отменяется при размонтировании", () => {
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 42);
|
||||
const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
||||
const cols: DateCol[] = [{ date: "2026-10-20", x0: 0, w: 300, cls: "", count: 1 }];
|
||||
const r = render(<DatesBar cols={cols} width={300} scrollLeft={0} wrapW={300} inRef={createRef()} onColWidth={vi.fn()} />);
|
||||
const split = r.container.querySelector(".dsplit") as HTMLElement;
|
||||
fireEvent.pointerDown(split, { clientX: 300 });
|
||||
fireEvent.pointerMove(window, { clientX: 340 });
|
||||
expect(raf).toHaveBeenCalled();
|
||||
r.unmount();
|
||||
expect(caf).toHaveBeenCalledWith(42);
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { Fragment, useRef, type PointerEvent, type RefObject } from "react";
|
||||
import { Fragment, useEffect, useRef, type PointerEvent, type RefObject } from "react";
|
||||
import { COLW, GRID } from "../constants.ts";
|
||||
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
|
||||
|
||||
@ -8,6 +8,7 @@ export function DatesBar({ cols, width, scrollLeft, wrapW, inRef, onColWidth }:
|
||||
onColWidth(date: string, w: number | null): void;
|
||||
}) {
|
||||
const raf = useRef(0);
|
||||
useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []); // T11.2
|
||||
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
|
||||
ev.preventDefault();
|
||||
const handle = ev.currentTarget, x0 = ev.clientX;
|
||||
|
||||
16
web/src/board/components/Dropdown.test.tsx
Normal file
16
web/src/board/components/Dropdown.test.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Dropdown } from "./Dropdown.tsx";
|
||||
|
||||
describe("Dropdown (T12.1)", () => {
|
||||
it("onOpen вызывается один раз на открытие, в том числе в StrictMode", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<StrictMode><Dropdown onOpen={onOpen} button={({ toggle }) => <button onClick={toggle}>открыть</button>}>{() => <p>меню</p>}</Dropdown></StrictMode>);
|
||||
fireEvent.click(screen.getByText("открыть"));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("меню")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("открыть"));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -13,7 +13,7 @@ export function Dropdown({ button, className = "", children, onOpen }: {
|
||||
document.addEventListener("pointerdown", away);
|
||||
return () => document.removeEventListener("pointerdown", away);
|
||||
}, [open]);
|
||||
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; });
|
||||
const toggle = () => { if (!open) onOpen?.(); setOpen(!open); }; // T12.1: побочное действие вне updater
|
||||
return (
|
||||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||||
{button({ open, toggle })}
|
||||
|
||||
@ -13,14 +13,14 @@ export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||||
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
|
||||
return (
|
||||
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
|
||||
<button type="button" className="btn dd-btn" aria-haspopup="listbox" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||||
<button type="button" className="btn dd-btn" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||||
<span className="lbl">{label}</span><span className="car">▼</span>
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<>
|
||||
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="dd-list" role="listbox">
|
||||
<div className="dd-list" role="group" aria-label={allLabel}>
|
||||
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
|
||||
{shown.map((i) => (
|
||||
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
|
||||
import { strList } from "../storage.ts";
|
||||
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
|
||||
import { addLink, addStubs } from "./links.ts";
|
||||
import { strList } from "../storage.ts";
|
||||
import { sortableDate } from "./util.ts";
|
||||
|
||||
type Raw = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts";
|
||||
import { dayDiff, emptyModel, fmtDate, fmtStamp, notStarted, plural, shortName, sortableDate, unassigned, whoKey } from "./util.ts";
|
||||
|
||||
describe("util", () => {
|
||||
it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => {
|
||||
@ -19,4 +19,17 @@ describe("util", () => {
|
||||
expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6);
|
||||
expect(dayDiff("", "2026-10-20")).toBeNull();
|
||||
});
|
||||
it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => {
|
||||
expect(notStarted("Backlog")).toBe(true);
|
||||
expect(notStarted("К выполнению")).toBe(true);
|
||||
expect(notStarted("В работе")).toBe(false);
|
||||
expect(unassigned("")).toBe(true);
|
||||
expect(unassigned("Не назначен")).toBe(true);
|
||||
expect(unassigned("Иванов")).toBe(false);
|
||||
expect(whoKey(" Иванов ")).toBe("Иванов");
|
||||
expect(whoKey("unassigned")).toBe("");
|
||||
expect(fmtStamp("не дата")).toBe("не дата");
|
||||
expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/);
|
||||
expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
6
web/src/board/scrollMemory.ts
Normal file
6
web/src/board/scrollMemory.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// M-3: прокрутка доски на время жизни вкладки (R-1) — переход в карточку задачи и обратно её не сбрасывает,
|
||||
// перезагрузка страницы сбрасывает (значение живёт в памяти модуля)
|
||||
export interface BoardScroll { x: number; y: number; left: number }
|
||||
let saved: BoardScroll | null = null;
|
||||
export const readBoardScroll = (): BoardScroll | null => saved;
|
||||
export const saveBoardScroll = (s: BoardScroll | null): void => { saved = s; };
|
||||
@ -12,7 +12,8 @@ describe("statusText", () => {
|
||||
expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
|
||||
expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
|
||||
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
|
||||
expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла");
|
||||
expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
|
||||
.toBe("файл не разобран: в файле нет строк с задачами");
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ export type BoardStatus =
|
||||
| { kind: "batch-failed"; id: number; error: string }
|
||||
| { kind: "batch-cancelled"; id: number }
|
||||
| { kind: "batch-lost"; id: number; reason: string }
|
||||
| { kind: "hub-down"; hasData: boolean }
|
||||
| { kind: "hub-down"; hasData: boolean; fromFile: boolean }
|
||||
| { kind: "file-error"; fileName: string; reason: string }
|
||||
| { kind: "json-applied"; fileName: string }
|
||||
| { kind: "error"; message: string };
|
||||
@ -28,7 +28,8 @@ export function statusText(s: BoardStatus): { text: string; warn: boolean } {
|
||||
case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
|
||||
case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
|
||||
case "hub-down": return {
|
||||
text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||||
text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
|
||||
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||||
warn: true,
|
||||
};
|
||||
case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };
|
||||
|
||||
@ -5,13 +5,14 @@ import type { BoardSnapshot } from "../api/types.ts";
|
||||
import { canon } from "../test/canon.ts";
|
||||
import { computeVisible } from "./analysis/visible.ts";
|
||||
import { dictsFromSettings } from "./dicts.ts";
|
||||
import { exportJson } from "./exportJson.ts";
|
||||
import { exportFileName, exportJson } from "./exportJson.ts";
|
||||
import settings from "./__fixtures__/settings.json";
|
||||
import storageCases from "./__fixtures__/storage.cases.json";
|
||||
import { computeLayout, measuredHeight } from "./layout/layout.ts";
|
||||
import { fromHub } from "./model/fromHub.ts";
|
||||
import { fromJson } from "./model/fromJson.ts";
|
||||
import { applyJsonSettings, migrateMode, migrateStore, readPrefs, saveLayout, savedLayout, setColWidth, writeFlag } from "./storage.ts";
|
||||
import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, toggleLane, writeFlag } from "./storage.ts";
|
||||
import type { BoardModel, JsonImport } from "./types.ts";
|
||||
import { noFilters } from "./types.ts";
|
||||
|
||||
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
|
||||
@ -80,3 +81,48 @@ describe("хранилище = links.html@3931a88", () => {
|
||||
expect(savedLayout(false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("операции хранилища", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
it("colWidths пропускает только конечные числа (T9.1)", () => {
|
||||
localStorage.setItem("graph-colw:board", JSON.stringify({ "2026-10-20": 400, "2026-10-21": "x", "2026-10-22": null }));
|
||||
expect(colWidths()).toEqual({ "2026-10-20": 400 });
|
||||
});
|
||||
it("toggleLane сворачивает и разворачивает", () => {
|
||||
toggleLane("Гудвилл");
|
||||
expect(foldedSet()).toEqual(new Set(["Гудвилл"]));
|
||||
toggleLane("Гудвилл");
|
||||
expect(foldedSet()).toEqual(new Set());
|
||||
});
|
||||
it("moveLane переставляет и сохраняет имена вне текущей выборки", () => {
|
||||
localStorage.setItem("graph-lanes:board", JSON.stringify(["A", "B", "C", "Скрытая"]));
|
||||
expect(moveLane(["A", "B", "C"], "A", 2)).toBe(true);
|
||||
expect(laneOrder()).toEqual(["B", "C", "A", "Скрытая"]);
|
||||
expect(moveLane(["A", "B", "C"], "A", 5)).toBe(false);
|
||||
expect(moveLane(["A", "B", "C"], "нет", 0)).toBe(false);
|
||||
});
|
||||
it("resetLayout: обычный режим не трогает «по датам», режим дат чистит и ширины колонок", () => {
|
||||
for (const k of ["pos", "posdate", "lanes", "fold", "colw"]) localStorage.setItem(`graph-${k}:board`, "{}");
|
||||
resetLayout(false);
|
||||
expect(Object.keys(localStorage).sort()).toEqual(["graph-colw:board", "graph-posdate:board"]);
|
||||
resetLayout(true);
|
||||
expect(Object.keys(localStorage)).toEqual([]);
|
||||
});
|
||||
it("exportFileName: имя файла без .csv и недопустимых символов, дата из now", () => {
|
||||
expect(exportFileName({ fileName: "Jira (1).csv" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-Jira_1_-2026-10-14.json");
|
||||
expect(exportFileName({ fileName: "" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-board-2026-10-14.json");
|
||||
});
|
||||
it("applyJsonSettings: mode=all — пустой выбор спринтов", () => {
|
||||
applyJsonSettings({ layout: {}, layoutByDate: {}, laneOrder: [], collapsedLanes: null, dateColumns: null,
|
||||
filters: { mode: "all", sprints: ["S 2"] } } as unknown as JsonImport);
|
||||
expect(localStorage.getItem("graph-sprint:board")).toBe("[]");
|
||||
});
|
||||
it("закрытое хранилище: чтение — значения по умолчанию, запись и удаление без исключений", () => {
|
||||
const boom = () => { throw new DOMException("closed", "SecurityError"); };
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(boom);
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(boom);
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(boom);
|
||||
expect(laneOrder()).toEqual([]);
|
||||
expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -48,7 +48,8 @@ export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), []
|
||||
export const foldedSet = (): Set<string> => new Set(strList(store.json(fileKey("fold"), [])));
|
||||
export function colWidths(): Record<string, number> {
|
||||
const v = store.json<unknown>(fileKey("colw"), {});
|
||||
return isObj(v) ? v as Record<string, number> : {};
|
||||
if (!isObj(v)) return {};
|
||||
return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record<string, number>; // T9.1
|
||||
}
|
||||
|
||||
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища
|
||||
|
||||
@ -33,24 +33,21 @@ export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
|
||||
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
|
||||
|
||||
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
|
||||
setPrefs((p) => {
|
||||
if (name === "snap" || name === "table" || name === "problems") { st.writeFlag(name, !p[name]); return { ...p, [name]: !p[name] }; }
|
||||
st.writeFlag(name, !p.filters[name]);
|
||||
return { ...p, filters: { ...p.filters, [name]: !p.filters[name] } };
|
||||
});
|
||||
}, []);
|
||||
const isView = name === "snap" || name === "table" || name === "problems";
|
||||
const on = isView ? !prefs[name] : !prefs.filters[name];
|
||||
st.writeFlag(name, on);
|
||||
setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }));
|
||||
}, [prefs]);
|
||||
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
|
||||
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
|
||||
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
|
||||
setPrefs((p) => {
|
||||
const key = what === "kind" ? "hideKind" : "hideEdge";
|
||||
const next = new Set(p.filters[key]);
|
||||
if (next.has(value)) next.delete(value); else next.add(value);
|
||||
const filters = { ...p.filters, [key]: next };
|
||||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||||
return { ...p, filters };
|
||||
});
|
||||
}, []);
|
||||
const key = what === "kind" ? "hideKind" : "hideEdge";
|
||||
const next = new Set(prefs.filters[key]);
|
||||
if (next.has(value)) next.delete(value); else next.add(value);
|
||||
const filters = { ...prefs.filters, [key]: next };
|
||||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||||
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
|
||||
}, [prefs]);
|
||||
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
|
||||
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
|
||||
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
|
||||
|
||||
@ -4,8 +4,9 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { getToken, setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { dictsFromSettings } from "./dicts.ts";
|
||||
import settings from "./__fixtures__/settings.json";
|
||||
@ -15,9 +16,8 @@ const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures_
|
||||
const SNAP = JSON.parse(fx("hub-basic.board.json"));
|
||||
const CSV = fx("jira-export.csv");
|
||||
const { dicts } = dictsFromSettings(settings);
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
<QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
|
||||
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
|
||||
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }),
|
||||
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
@ -60,13 +60,12 @@ describe("useBoardSource", () => {
|
||||
|
||||
it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
|
||||
const slow = deferred<Response>();
|
||||
const h = hub({ "GET /board": () => slow.promise });
|
||||
const { result } = hook();
|
||||
hub({ "GET /board": () => slow.promise });
|
||||
const { result, client } = hook();
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
expect(result.current.source).toBe("file");
|
||||
slow.resolve(json(200, SNAP));
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(1));
|
||||
await sleep(20);
|
||||
await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу
|
||||
expect(result.current.source).toBe("file");
|
||||
expect(result.current.label).toBe("jira-export.csv (из файла)");
|
||||
act(() => result.current.reload());
|
||||
@ -86,6 +85,7 @@ describe("useBoardSource", () => {
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
|
||||
expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается
|
||||
});
|
||||
|
||||
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
|
||||
@ -169,7 +169,7 @@ describe("useBoardSource", () => {
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
down = true;
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true }));
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: false }));
|
||||
expect(result.current.model.nodes.length).toBe(10);
|
||||
});
|
||||
|
||||
@ -192,4 +192,96 @@ describe("useBoardSource", () => {
|
||||
expect(result.current.source).toBe("hub");
|
||||
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
|
||||
});
|
||||
|
||||
it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
|
||||
hub({ "GET /batches/:id": () => batch("running") });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
});
|
||||
|
||||
it("T10.4: JSON-снимок без данных хаба — «на доске снимок из файла»", async () => {
|
||||
hub({ "GET /board": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "hub-down", fromFile: false }));
|
||||
act(() => { result.current.loadFile("old-board.json", fx("old-board.json")); });
|
||||
expect(result.current.source).toBe("file");
|
||||
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
|
||||
});
|
||||
|
||||
it("«Обновить из Jira» при показанном CSV: хаб недоступен — «на доске снимок из файла»", async () => {
|
||||
hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
expect(result.current.source).toBe("file");
|
||||
await act(() => result.current.refreshFromJira());
|
||||
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
|
||||
});
|
||||
|
||||
it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => {
|
||||
let polls = 0, down = false;
|
||||
hub({
|
||||
"GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); },
|
||||
"GET /batches/:id": () => { if (++polls >= 3) down = true; return polls < 3 ? batch("running") : batch("done"); },
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }));
|
||||
expect(result.current.source).toBe("file");
|
||||
});
|
||||
|
||||
it("уход со страницы и возврат во время пакета — пакет подхвачен снова, один опрос (Review Focus 2)", async () => {
|
||||
let listed: unknown[] = [];
|
||||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||||
const first = hook();
|
||||
await waitFor(() => expect(first.result.current.model.nodes.length).toBe(10));
|
||||
await act(() => first.result.current.refreshFromJira());
|
||||
listed = [{ id: 7, author: "viewer", status: "running" }];
|
||||
first.unmount();
|
||||
const second = hook(first.client);
|
||||
await waitFor(() => expect(second.result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
expect(second.result.current.refreshing).toBe(true);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
});
|
||||
|
||||
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
|
||||
const h = hub({
|
||||
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
|
||||
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
|
||||
await sleep(50);
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
|
||||
});
|
||||
|
||||
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
|
||||
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
expect(getToken()).toBe("");
|
||||
});
|
||||
|
||||
it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
|
||||
const slow = deferred<Response>();
|
||||
hub({ "GET /agent": () => slow.promise });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
let run!: Promise<void>;
|
||||
act(() => { run = result.current.refreshFromJira(); });
|
||||
setToken("new-tok");
|
||||
slow.resolve(json(401, { error: "unauthorized" }));
|
||||
await act(() => run);
|
||||
expect(getToken()).toBe("new-tok");
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,10 +3,9 @@
|
||||
// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5)
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, ApiError, AuthError, HubDownError } from "../api/client.ts";
|
||||
import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../api/client.ts";
|
||||
import { qk, useBoard } from "../api/queries.ts";
|
||||
import type { AgentView, Batch, CreatedBatch } from "../api/types.ts";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { fromCsv } from "./model/fromCsv.ts";
|
||||
import { fromHub } from "./model/fromHub.ts";
|
||||
import { fromJson } from "./model/fromJson.ts";
|
||||
@ -43,27 +42,43 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
|
||||
const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]);
|
||||
|
||||
const report = useCallback((e: unknown, hasData: boolean) => {
|
||||
if (e instanceof AuthError) clearToken("bad");
|
||||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData });
|
||||
// свежие значения для эффектов, которые не должны перезапускаться от их смены
|
||||
const sourceRef = useRef(source);
|
||||
useEffect(() => { sourceRef.current = source; }, [source]);
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
const skipPickup = useRef(false); // T10.1: снимок, перечитанный после своего пакета, — пакет уже завершён
|
||||
|
||||
const report = useCallback((e: unknown, hasData: boolean, fromFile = false) => {
|
||||
if (e instanceof AuthError) dropTokenOn(e);
|
||||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData, fromFile });
|
||||
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||||
}, []);
|
||||
|
||||
// хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими)
|
||||
useEffect(() => {
|
||||
if (board.error && !(board.error instanceof AuthError)) report(board.error, !!board.data);
|
||||
if (!board.error || board.error instanceof AuthError) return;
|
||||
skipPickup.current = false;
|
||||
report(board.error, !!board.data, sourceRef.current.kind === "file");
|
||||
}, [board.error, board.errorUpdatedAt, board.data, report]);
|
||||
|
||||
// M-3: после каждой успешной загрузки снимка — подхватить незавершённый пакет viewer
|
||||
// M-3 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer
|
||||
useEffect(() => {
|
||||
if (!board.dataUpdatedAt || active) return;
|
||||
if (!board.dataUpdatedAt) return;
|
||||
if (skipPickup.current) { skipPickup.current = false; return; }
|
||||
if (activeRef.current) return;
|
||||
let gone = false;
|
||||
api.get<{ batches: Batch[] }>("/batches", { limit: 20 }).then((r) => {
|
||||
(async () => {
|
||||
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
|
||||
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||||
if (b && !gone) { failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); }
|
||||
}).catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||||
if (!b || gone) return;
|
||||
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
|
||||
if (gone || full.commands?.[0]?.type !== "refresh") return;
|
||||
failures.current = 0;
|
||||
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
|
||||
})().catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||||
return () => { gone = true; };
|
||||
}, [board.dataUpdatedAt, active]);
|
||||
}, [board.dataUpdatedAt]);
|
||||
|
||||
// M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд
|
||||
const batch = useQuery({
|
||||
@ -95,6 +110,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
failures.current = 0;
|
||||
setActive(null);
|
||||
qc.removeQueries({ queryKey: ["batch", id] });
|
||||
skipPickup.current = true;
|
||||
void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1)
|
||||
}, [qc]);
|
||||
|
||||
@ -138,7 +154,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
setActive({ id: b.id, agentOffline: !agent.online });
|
||||
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false });
|
||||
} catch (e) {
|
||||
report(e, !!board.data);
|
||||
const fromFile = sourceRef.current.kind === "file";
|
||||
report(e, !!board.data || fromFile, fromFile);
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
@ -147,7 +164,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setSource({ kind: "hub" });
|
||||
setEvent(null);
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
void qc.refetchQueries({ queryKey: qk.board });
|
||||
}, [qc]);
|
||||
|
||||
@ -157,7 +174,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
try {
|
||||
if (!isJson) {
|
||||
setSource({ kind: "file", model: fromCsv(text, fileName, dicts) });
|
||||
setEvent(null);
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
return null;
|
||||
}
|
||||
const r = fromJson(text, fileName, dicts);
|
||||
@ -167,7 +184,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
void qc.refetchQueries({ queryKey: qk.board });
|
||||
} else { // хаб не ответил — снимок из файла
|
||||
setSource({ kind: "file", model: r.model });
|
||||
setEvent({ kind: "hub-down", hasData: true });
|
||||
setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true });
|
||||
}
|
||||
return r;
|
||||
} catch (e) {
|
||||
|
||||
@ -104,17 +104,18 @@ describe("карточка задачи", () => {
|
||||
});
|
||||
|
||||
it("поздний ответ прежней карточки не подменяет новую", async () => {
|
||||
const client = makeQueryClient();
|
||||
const slow = deferred<Response>();
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
|
||||
return json(200, issue("AURORA-9002"));
|
||||
});
|
||||
const router = open("/issues/AURORA-9001");
|
||||
const router = open("/issues/AURORA-9001", client);
|
||||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slow.resolve(json(200, issue("AURORA-9001")));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
|
||||
});
|
||||
@ -137,7 +138,7 @@ describe("карточка задачи", () => {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
|
||||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await waitFor(() => expect(client.isMutating()).toBe(0));
|
||||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
|
||||
@ -178,4 +179,27 @@ describe("карточка задачи", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||
});
|
||||
|
||||
it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
|
||||
open("/issues/AURORA-1");
|
||||
const link = await screen.findByRole("link", { name: "к списку задач" });
|
||||
expect(link.closest(".issue")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
|
||||
open("/issues/AURORA-9001");
|
||||
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||||
open("/issues/AURORA-9001");
|
||||
const input = await screen.findByLabelText("Статус по факту");
|
||||
const id = input.getAttribute("list")!;
|
||||
expect(id).not.toBe("fact-statuses");
|
||||
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@ export function IssuePage() {
|
||||
|
||||
if (issue.isPending) return <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return <p data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
|
||||
return <p className="issue" data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
@ -52,13 +52,15 @@ export function IssuePage() {
|
||||
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
|
||||
</ul>
|
||||
<h3>Последние команды</h3>
|
||||
<table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
|
||||
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
{i.commands.length ? (
|
||||
<table className="grid">
|
||||
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
|
||||
<tbody>{i.commands.map((c) => (
|
||||
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
|
||||
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
) : <p className="hint">команд по задаче не было</p>}
|
||||
</div>
|
||||
<NoteForm key={i.key} issue={i} />
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
@ -10,6 +11,7 @@ import { renderWithProviders } from "../test/render.tsx";
|
||||
import { IssuesPage } from "./IssuesPage.tsx";
|
||||
|
||||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
const row = (n: number) => ({ ...SNAP.issues[1], key: `AURORA-${9000 + n}`, summary: `Задача ${n}`, fetched_at: "" });
|
||||
|
||||
function hub(pages: (params: URLSearchParams) => unknown[]) {
|
||||
@ -108,4 +110,44 @@ describe("список задач", () => {
|
||||
expect(before).toBe("POP");
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
});
|
||||
|
||||
it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
|
||||
hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
|
||||
const sel = await screen.findByLabelText("Спринт");
|
||||
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument());
|
||||
expect(sel).toHaveValue("AWPCA_Спринт 0");
|
||||
});
|
||||
|
||||
it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => {
|
||||
const board = deferred<Response>();
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/board") return board.promise;
|
||||
return json(200, { issues: [row(1)] });
|
||||
});
|
||||
const { client } = renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const sel = await screen.findByLabelText("Спринт");
|
||||
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
|
||||
expect(client.getQueryData(qk.board)).toBeUndefined();
|
||||
board.resolve(json(200, SNAP));
|
||||
await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // снимок реально применён
|
||||
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument());
|
||||
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
|
||||
expect(sel).toHaveValue("AWPCA_Спринт 2");
|
||||
});
|
||||
|
||||
it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return json(200, settings);
|
||||
if (url.pathname === "/board") return new Promise<Response>(() => {}); // снимок не приходит до конца теста
|
||||
return json(200, { issues: [row(1)] });
|
||||
});
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const sel = await screen.findByLabelText("Спринт");
|
||||
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
|
||||
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
|
||||
import { dueOf, epicNames, filterOptions } from "./options.ts";
|
||||
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
|
||||
import "./issues.css";
|
||||
|
||||
const SELECTS = [
|
||||
@ -47,7 +47,7 @@ export function IssuesPage() {
|
||||
<label key={name}>{label}
|
||||
<select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
|
||||
<option value="">все</option>
|
||||
{opts[name].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
{withCurrent(opts[name], filters[name], !!board.data).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useSaveNote, useSettings } from "../api/queries.ts";
|
||||
@ -37,6 +37,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||
const facts = Object.keys(settings.data?.status_labels ?? {});
|
||||
const listId = useId();
|
||||
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
|
||||
|
||||
const submit = () => save.mutate(changed, {
|
||||
@ -56,8 +57,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
</select>
|
||||
</label>
|
||||
<label>Статус по факту
|
||||
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
</label>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
|
||||
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
|
||||
.grid a{color:var(--accent)}
|
||||
.issue a{color:var(--accent)}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
|
||||
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
|
||||
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { BoardSnapshot, HubSettings } from "../api/types.ts";
|
||||
|
||||
type Opt = { value: string; label: string };
|
||||
export type Opt = { value: string; label: string };
|
||||
const uniq = (xs: (string | null | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))].sort((a, b) => a.localeCompare(b, "ru"));
|
||||
const isEpic = (t: string | null) => /^(Фича|Epic|Эпик)$/i.test(t ?? "");
|
||||
|
||||
@ -9,6 +9,12 @@ export const epicNames = (snap: BoardSnapshot | undefined): Record<string, strin
|
||||
|
||||
export const dueOf = (i: { planned_end: string | null; duedate: string | null }) => (i.planned_end || i.duedate || "").slice(0, 10);
|
||||
|
||||
// T13.1: значение фильтра из адреса, которого нет среди вариантов (закрытый спринт, ушедший исполнитель), видно в списке;
|
||||
// пока снимок доски не загружен, пометку не ставим (R-4)
|
||||
export const withCurrent = (opts: Opt[], value: string | undefined, loaded: boolean): Opt[] =>
|
||||
!value || opts.some((o) => o.value === value) ? opts
|
||||
: [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts];
|
||||
|
||||
// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API
|
||||
export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) {
|
||||
const issues = snap?.issues ?? [];
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// шрифты входят в сборку: приложение не ходит в интернет (T2.2)
|
||||
import "@fontsource/ibm-plex-sans/400.css";
|
||||
import "@fontsource/ibm-plex-sans/500.css";
|
||||
import "@fontsource/ibm-plex-sans/600.css";
|
||||
import "@fontsource/ibm-plex-mono/500.css";
|
||||
import { App } from "./App.tsx";
|
||||
import { applyTheme, readTheme } from "./shell/theme.ts";
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyTheme, readTheme, saveTheme } from "./theme.ts";
|
||||
|
||||
describe("тема", () => {
|
||||
afterEach(() => document.documentElement.removeAttribute("data-theme"));
|
||||
it("читает graph-theme, неизвестное значение — системная", () => {
|
||||
localStorage.setItem("graph-theme", "dark");
|
||||
expect(readTheme()).toBe("dark");
|
||||
|
||||
@ -11,4 +11,7 @@ describe("canon", () => {
|
||||
expect(canon(foreign)).toEqual(["a", "b"]);
|
||||
expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]);
|
||||
});
|
||||
it("порядок Set не зависит от локали: сравнение по кодам символов", () => {
|
||||
expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
const isSet = (v: unknown): v is Set<unknown> => Object.prototype.toString.call(v) === "[object Set]";
|
||||
|
||||
export function canon(v: unknown): unknown {
|
||||
if (isSet(v)) return [...v].map(canon).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
if (isSet(v)) return [...v].map(canon).sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; });
|
||||
if (Array.isArray(v)) return v.map(canon);
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { saveBoardScroll } from "../board/scrollMemory.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
saveBoardScroll(null);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5)
|
||||
const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791";
|
||||
const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts
|
||||
const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"];
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user