diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CLAUDE.md b/CLAUDE.md index 7e68acc..fda9888 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/README.md b/README.md index 381e66f..7e38464 100644 --- a/README.md +++ b/README.md @@ -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/). diff --git a/docs/superpowers/plans/2026-10-15-web-app-followups.md b/docs/superpowers/plans/2026-10-15-web-app-followups.md new file mode 100644 index 0000000..e45d107 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-web-app-followups.md @@ -0,0 +1,1151 @@ +# Web-app follow-ups (#29) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** закрыть все 46 пунктов jira-hub#29 (отложенные находки change `web-app`): видимое поведение доски, входа и страниц +задач, шрифты без интернета, уязвимость dev-зависимости, React-код и тесты. + +**Architecture:** правки внутри существующего веб-приложения `web/` (React 19 + TS + Vite, TanStack Query, React Router) и +одной функции хаба `h_app`. Новых модулей два: `web/src/board/scrollMemory.ts` (прокрутка доски между страницами) и +`web/src/api/queryClient.test.ts`. API хаба, контракт с агентом, ключи `localStorage` и логика доски не меняются. + +**Tech Stack:** Python 3.13 + pytest (хаб); TypeScript, React 19, TanStack Query 5, React Router 8, Vitest 5 + Testing +Library (jsdom), Playwright (e2e на тестовом хабе :8791), npm. + +**Spec:** `openspec/changes/web-app-followups/` (proposal.md, design.md — решения 1–14 и таблица пунктов, specs/web-app, +specs/hub-api). + +## Global Constraints + +- Язык комментариев, сообщений коммитов и текстов интерфейса — русский; идентификаторы — английские. +- Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 `; в теме коммита — `(#29)`. +- Новые npm-зависимости — точные версии: `npm install --before=2026-10-01 --save-exact <пакет>`; `package-lock.json` в git. +- `tsconfig`: `strict`, `erasableSyntaxOnly` — без parameter properties в классах (поля объявлять явно, как в `ApiError`). +- Ключи `localStorage` (`jira-hub-token`, `graph-<имя>…`) не меняются. +- Живой хаб :8790 и `~/.local/share/jira-hub` не трогать; e2e и ручные проверки — только тестовый хаб :8791 + (`bash web/scripts/test-hub.sh`). +- Поведение доски на тех же данных совпадает с golden-эталонами `links.html@3931a88`; эталон не подгоняется под код. +- Каждый пункт #29 в отчёте задачи — с маркером и статусом `fixed ` / `rejected <причина>`. +- Зелёным считается только весь набор: `make check` (pytest, tsc, eslint, vitest); для задач 4 и 7 — ещё `cd web && npm run e2e`. +- Перед изменением — `codegraph_explore` с `projectPath` = абсолютный путь worktree по своей области. + +## Review Focus + +1. Запрос ушёл со старым токеном, пользователь успел войти с новым, и только потом пришёл 401 — новый токен остаётся, + страница не сменяется экраном входа (Task 2, тест «запоздавший 401 от прежнего токена…»). +2. Пользователь ушёл с доски в карточку задачи, пока пакет «Обновить из Jira» выполняется, и вернулся — пакет снова + опрашивается, ровно одним опросом, кнопка недоступна (Task 3, тест «уход и возврат во время пакета»). +3. Прокрутка восстанавливается не позицией на момент ухода, сжатой браузером под короткую страницу карточки, а той, что + была на доске (Task 4: запоминание по событию scroll; e2e «M-3: прокрутка доски после возврата…»). +4. Адрес списка задач с фильтром, значение которого есть на доске, а снимок доски ещё грузится, — без мигания пометки + «(нет на доске)» (Task 5, тест «пока снимок доски грузится…»). +5. Сборка без интернета: в собранном `index.html` и CSS нет `http(s)://` ссылок (Task 7, шаг 2). + +--- + +### Task 1: Хаб — нулевой байт в пути, типы файлов сборки, дубль импорта (T1.1, T1.4, T16.1) + +Модель исполнителя: haiku (готовый код). + +**Files:** +- Modify: `src/jira_hub/api.py` (функция `h_app`) +- Modify: `tests/test_web_app.py` +- Modify: `tests/test_api.py:161` (локальный `import urllib.request, urllib.error`) + +**Interfaces:** +- Consumes: `api.Hub(db_path, backups_dir=None, release_dir=None, web_dir=None)`, `api.ApiError(status, error, detail)`. +- Produces: ничего нового для других задач. + +- [ ] **Step 1: Падающие тесты** — в `tests/test_web_app.py`, в фикстуре `app_hub` после строки с `font.bin` добавить файлы: + +```python + for name, data in (("logo.svg", b""), ("p.png", b"\x89PNG"), ("f.ico", b"\x00"), ("f.woff2", b"wOF2"), + ("index-abc123.js.map", b"{}")): + (web / "assets" / name).write_bytes(data) +``` + +и в конец файла: + +```python +@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 +``` + +- [ ] **Step 2: Запустить** `uv run pytest tests/test_web_app.py -q` — ожидается FAIL: `test_nul_in_asset_path_is_404` + получает 500 (`ValueError: embedded null byte` из `Path.resolve`). Тесты типов проходят сразу — это покрытие (T1.4). + +- [ ] **Step 3: Реализация** — в `h_app` заменить блок `if rest.startswith("assets/"):` на: + +```python + 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() + try: + f = (hub.web_dir / rest).resolve() + except (OSError, ValueError): + raise ApiError(404, "not_found", "/app/" + rest) + 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) +``` + +- [ ] **Step 4: T16.1** — в `tests/test_api.py` удалить строку ` import urllib.request, urllib.error` внутри теста + (модули уже импортированы в начале файла). + +- [ ] **Step 5: Проверка** `uv run pytest -q` — весь набор зелёный. + +- [ ] **Step 6: Коммит** + +```bash +git add src/jira_hub/api.py tests/test_web_app.py tests/test_api.py +git commit -m "fix(hub): нулевой байт в пути /app/assets — 404; тесты типов файлов сборки (T1.1, T1.4, T16.1, #29)" +``` + +--- + +### Task 2: Клиент и вход — токен в AuthError, 4xx на входе, тесты 401 (T3.2–T3.6) + +Модель исполнителя: sonnet (интеграция: клиент, queryClient, useBoardSource, экран входа). + +**Files:** +- Modify: `web/src/api/client.ts` +- Modify: `web/src/api/queryClient.ts` +- Modify: `web/src/board/useBoardSource.ts` (функция `report`) +- Modify: `web/src/auth/LoginScreen.tsx` +- Create: `web/src/api/queryClient.test.ts` +- Modify: `web/src/api/client.test.ts`, `web/src/auth/token.test.ts`, `web/src/auth/LoginScreen.test.tsx` + +**Interfaces:** +- Consumes: `getToken()`, `clearToken(reason)`, `setToken()` из `auth/token.ts`. +- Produces: + - `class AuthError extends Error { readonly token: string; constructor(message: string, token: string) }` — токен, + с которым ушёл запрос; + - `function dropTokenOn(e: AuthError): void` (в `api/client.ts`) — очищает токен (`"bad"`, если токен был, иначе + `"none"`), только если `e.token === getToken()`. Все места, где по `AuthError` вызывался `clearToken`, вызывают + `dropTokenOn`. + +- [ ] **Step 1: Найти все места** `grep -rn "instanceof AuthError" web/src` — ожидается `api/queryClient.ts`, + `board/useBoardSource.ts`, `auth/LoginScreen.tsx` и тесты. Других вызовов `clearToken` по `AuthError` быть не должно. + +- [ ] **Step 2: Падающие тесты клиента** — в `web/src/api/client.test.ts` добавить: + +```ts + 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" }); + }); +``` + + (импорт: `import { clearToken, getAuth, setToken } from "../auth/token.ts";` и `dropTokenOn` из `./client.ts`). + +- [ ] **Step 3: Тесты queryClient** — создать `web/src/api/queryClient.test.ts`: + +```ts +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); + }); +}); +``` + +- [ ] **Step 4: T3.4** — в `web/src/auth/token.test.ts`, в тест «закрытое хранилище…», добавить перед `import`: + +```ts + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); +``` + + и после `expect(t.getToken()).toBe("mem");`: + +```ts + t.clearToken("bad"); + expect(t.getAuth()).toEqual({ token: "", reason: "bad" }); +``` + +- [ ] **Step 5: Экран входа** — в `web/src/auth/LoginScreen.test.tsx` заменить тавтологичный тест «после 401 в приложении + экран входа поясняет причину» (T3.3, T3.6) на сквозные и добавить тест 4xx (T3.2): + +```tsx + it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => { + setToken("stale"); + mockFetch(() => json(401, { error: "unauthorized" })); + function Page() { useBoard(); return

страница приложения

; } + renderWithProviders(); + 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((r) => { release = r; }); + mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); + function Page() { useBoard(); return

страница приложения

; } + const { client } = renderWithProviders(); + 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 userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/заголовок Authorization без Bearer/)).toBeInTheDocument(); + expect(screen.queryByText(/Хаб не отвечает/)).not.toBeInTheDocument(); + }); +``` + + Импорты: `act` — из `@testing-library/react`, `qk`, `useBoard` — из `../api/queries.ts`, `setToken` — из `./token.ts`. + +- [ ] **Step 6: Запустить** `cd web && npx vitest run src/api src/auth` — ожидается FAIL: нет `dropTokenOn`, у `AuthError` + нет `token`, 4xx показывает «Хаб не отвечает», запоздавший 401 стирает новый токен. + +- [ ] **Step 7: Реализация `client.ts`** — заменить `export class AuthError extends Error {}` на: + +```ts +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"); +} +``` + + импорт: `import { clearToken, getToken, TOKEN_RE } from "../auth/token.ts";`. В `call()`: + `throw new AuthError("нет токена", token)`, `throw new AuthError("недопустимые символы в токене", token)`, + `if (resp.status === 401) throw new AuthError("токен не подошёл", token);`. + +- [ ] **Step 8: `queryClient.ts`** — `const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); };`, + импорт `import { AuthError, dropTokenOn } from "./client.ts";`, импорт из `../auth/token.ts` удалить. + +- [ ] **Step 9: `useBoardSource.ts`** — в `report`: `if (e instanceof AuthError) dropTokenOn(e);` вместо + `clearToken("bad")`; импорт `dropTokenOn` из `../api/client.ts`, импорт `clearToken` удалить, если больше не нужен. + +- [ ] **Step 10: `LoginScreen.tsx`** — импорт `ApiError` из `../api/client.ts`; в `catch`: + +```ts + setError(e instanceof AuthError ? BAD_TOKEN + : e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}` // T3.2: 4xx — текст хаба + : HUB_DOWN); +``` + +- [ ] **Step 11: Проверка** `make check` — зелёный. + +- [ ] **Step 12: Коммит** + +```bash +git add web/src/api web/src/auth web/src/board/useBoardSource.ts +git commit -m "fix(web): запоздавший 401 прежнего токена не выбрасывает на вход, 4xx на входе — текст хаба (T3.2–T3.6, #29)" +``` + +--- + +### Task 3: Источник доски — строка пакета, снимок из файла, подхват только refresh (T10.1, T10.3–T10.6) + +Модель исполнителя: sonnet (интеграция, тайминги опроса). + +**Files:** +- Modify: `web/src/board/status.ts`, `web/src/board/status.test.ts` +- Modify: `web/src/board/useBoardSource.ts` +- Modify: `web/src/board/useBoardSource.test.tsx` + +**Interfaces:** +- Consumes: `dropTokenOn` (Task 2), `api.get`, `qk.board`, `Batch.commands?: BatchCommand[]`. +- Produces: `BoardStatus` вариант `{ kind: "hub-down"; hasData: boolean; fromFile: boolean }` (поле `fromFile` + обязательное); `statusText` для `fromFile: true` — «хаб не отвечает — на доске снимок из файла». + +- [ ] **Step 1: Тест текста** — в `status.test.ts` заменить строку с `hub-down` на: + +```ts + expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные"); + expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла"); +``` + +- [ ] **Step 2: Тесты источника** — в `useBoardSource.test.tsx`: + - хелпер `hook` принимает клиент: + +```tsx +const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }), + { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); +``` + + (константу `wrapper` удалить; импорт `qk` из `../api/queries.ts`); + - в тесте «M-1: поздний ответ…» (T10.5) заменить `await waitFor(() => expect(h.count("GET /board")).toBe(1)); await sleep(20);` + на `const { result, client } = hook();` (вместо прежнего `const { result } = hook();`) и + +```tsx + await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу +``` + + - в тесте «M-2» после `expect(h.count("GET /board")).toBe(2);` добавить + `expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается`; + - в тесте «хаб остановлен при «Перечитать»» ожидание — `{ kind: "hub-down", hasData: true, fromFile: false }`; + - новые тесты: + +```tsx + 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("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); // только проверка типа, опроса нет + }); +``` + +- [ ] **Step 3: Запустить** `cd web && npx vitest run src/board/status.test.ts src/board/useBoardSource.test.tsx` — + ожидается FAIL: нет `fromFile`, строка пакета стирается, пакет `ensure_fields` подхватывается, лишний `GET /batches`. + +- [ ] **Step 4: `status.ts`** — вариант `| { kind: "hub-down"; hasData: boolean; fromFile: boolean }` и + +```ts + case "hub-down": return { + text: s.fromFile ? "хаб не отвечает — на доске снимок из файла" + : s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»", + warn: true, + }; +``` + +- [ ] **Step 5: `useBoardSource.ts`** — изменения (остальное без изменений): + +```ts + // свежие значения для эффектов, которые не должны перезапускаться от их смены + 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) }); + }, []); + + useEffect(() => { + 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 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer + useEffect(() => { + if (!board.dataUpdatedAt) return; + if (skipPickup.current) { skipPickup.current = false; return; } + if (activeRef.current) return; + let gone = false; + (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) return; + const full = await api.get(`/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]); +``` + + в `finish`: перед `void qc.invalidateQueries(...)` — `skipPickup.current = true;`; + в `reload`: `setEvent((cur) => (cur?.kind === "batch" ? cur : null));` вместо `setEvent(null)`; + в `loadFile`, ветка CSV: `setEvent((cur) => (cur?.kind === "batch" ? cur : null));` вместо `setEvent(null)`; + в `loadFile`, ветка «хаб не ответил — снимок из файла»: + `setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true });`. + ESLint `react-hooks/exhaustive-deps` не должен ругаться: refs в зависимости не входят. + +- [ ] **Step 6: Проверка** `make check`; затем `cd web && for i in $(seq 1 10); do npx vitest run src/board/useBoardSource.test.tsx || break; done` — 10 зелёных прогонов подряд. + +- [ ] **Step 7: Коммит** + +```bash +git add web/src/board/status.ts web/src/board/status.test.ts web/src/board/useBoardSource.ts web/src/board/useBoardSource.test.tsx +git commit -m "fix(web): строка пакета переживает «Перечитать», снимок из файла при недоступном хабе, подхват только refresh (T10.1, T10.3–T10.6, #29)" +``` + +--- + +### Task 4: Холст и страница доски — прокрутка, memo карточек, обработчики React (M-3, T11.1–T11.4, T12.1–T12.4) + +Модель исполнителя: sonnet (интеграция с перетаскиванием и раскладкой; e2e обязателен). + +**Files:** +- Create: `web/src/board/scrollMemory.ts` +- Modify: `web/src/board/components/BoardCanvas.tsx`, `Card.tsx`, `DatesBar.tsx`, `Dropdown.tsx`, `MultiSelect.tsx` +- Modify: `web/src/board/useBoardPrefs.ts`, `web/src/board/BoardPage.tsx` +- Modify: `web/src/board/components/BoardCanvas.test.tsx`, `web/src/board/BoardPage.test.tsx` +- Create: `web/src/board/components/Card.test.ts`, `web/src/board/components/Dropdown.test.tsx`, `web/src/board/components/DatesBar.test.tsx` +- Modify: `web/e2e/board.spec.ts` + +**Interfaces:** +- Produces: + - `scrollMemory.ts`: `interface BoardScroll { x: number; y: number; left: number }`, + `readBoardScroll(): BoardScroll | null`, `saveBoardScroll(s: BoardScroll | null): void`; + - `Card.tsx`: `export function cardPropsEqual(a: CardProps, b: CardProps): boolean` — компаратор `memo`. + +- [ ] **Step 1: `scrollMemory.ts`** + +```ts +// 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; }; +``` + +- [ ] **Step 2: Падающие тесты холста** — в `BoardCanvas.test.tsx`: + - T11.4, тест «сужение выборки…»: последнюю проверку заменить на + `await waitFor(() => expect(board().querySelectorAll(".card[data-key]")).toHaveLength(narrow.nodes.length));` + (тест — `async`, `waitFor` из `@testing-library/react`); + - T11.4, тест «полоса дат…»: + +```tsx + const wrap = document.querySelector(".wrap") as HTMLElement; + expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`); +``` + + - T11.3 и M-3, новые тесты: + +```tsx + 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(".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("Гудвилл", 3); + }); + + it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => { + const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const first = renderWithProviders(); + Object.defineProperty(window, "scrollY", { configurable: true, value: 640 }); + fireEvent.scroll(window); + expect(readBoardScroll()).toMatchObject({ y: 640 }); + first.unmount(); + renderWithProviders(); + expect(to).toHaveBeenCalledWith(0, 640); + Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); + vi.restoreAllMocks(); + }); +``` + + в `web/src/test/setup.ts` в `afterEach` после `cleanup()` добавить `saveBoardScroll(null);` (импорт из + `../board/scrollMemory.ts`) — память прокрутки не переходит между тестами; + вынести создание пропсов из `draw` в `baseProps(over = {})` (тот же объект, что сейчас собирает `draw`), `draw` + вызывает `renderWithProviders()`; импорт `readBoardScroll, saveBoardScroll` из + `../scrollMemory.ts`. Если `onMoveLane` получает не 3, а индекс последней дорожки другой длины — проверять + `vis.lanes.length - 1` той же выборки. + +- [ ] **Step 3: Тест компаратора** — создать `Card.test.ts`: + +```ts +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); + }); +}); +``` + +- [ ] **Step 4: Тесты Dropdown и DatesBar** — `Dropdown.test.tsx`: + +```tsx +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( }>{() =>

меню

}
); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByText("меню")).toBeInTheDocument(); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); +``` + + `DatesBar.test.tsx`: + +```tsx +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(); + 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); + }); +}); +``` + + +- [ ] **Step 5: Тесты страницы** — в `BoardPage.test.tsx`: + +```tsx + 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(, { 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(); + }); +``` + + (`renderWithProviders` уже возвращает `unmount`; `page` вернуть его результат; `vi` — из `vitest`, `StrictMode` — + из `react`: React в StrictMode вызывает функцию-updater дважды, поэтому до исправления запись будет двойной.) + +- [ ] **Step 6: Запустить** `cd web && npx vitest run src/board` — ожидается FAIL новых тестов. + +- [ ] **Step 7: Реализация `Card.tsx`** — перед `export const Card`: + +```ts +// 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; +} +``` + + и второй аргумент `memo(function Card(...) {...}, cardPropsEqual)`. + +- [ ] **Step 8: `BoardCanvas.tsx`, прокрутка** — импорт `readBoardScroll, saveBoardScroll` из `../scrollMemory.ts`, + `useEffect` из `react`; + после `useLayoutEffect` с `syncScroll`: + +```ts + // 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; + const save = () => 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); }; + }, []); +``` + +- [ ] **Step 9: `DatesBar.tsx`** — импорт `useEffect`; после `const raf = useRef(0);`: + +```ts + useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []); // T11.2 +``` + +- [ ] **Step 10: `Dropdown.tsx`** — `const toggle = () => { if (!open) onOpen?.(); setOpen(!open); };` // T12.1: побочное действие вне updater + +- [ ] **Step 11: `useBoardPrefs.ts`** — `toggle` и `toggleHidden` без побочных действий в updater (T12.2): + +```ts + const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => { + 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 toggleHidden = useCallback((what: "kind" | "edge", value: string) => { + 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]); +``` + +- [ ] **Step 12: `BoardPage.tsx`** — `useRef`/`useLayoutEffect` шапки заменить на ref-колбэк с очисткой (T12.3; React 19 + поддерживает возврат функции очистки из ref-колбэка); импорт `useCallback`, убрать неиспользуемые: + +```ts + // высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем + const headRef = useCallback((el: HTMLDivElement | null) => { + if (!el) return; + const root = document.documentElement.style; + const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px"); + set(); + const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set); + ro?.observe(el); + return () => { ro?.disconnect(); root.removeProperty("--head-h"); }; + }, []); +``` + +- [ ] **Step 13: `MultiSelect.tsx`** — кнопка `aria-haspopup="dialog"`; список + `
` (T12.4: флажки — это группа, не listbox без option). + +- [ ] **Step 14: e2e прокрутки** — в `web/e2e/board.spec.ts`: + +```ts +test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 600 }); + await login(page); + 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 сам сдвинет страницу перед переходом + await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).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); +}); +``` + +- [ ] **Step 15: Проверка** `make check` и `cd web && npm run e2e` — зелёные. + +- [ ] **Step 16: Коммит** + +```bash +git add web/src/board web/e2e/board.spec.ts +git commit -m "feat(web): прокрутка доски после возврата, memo карточек, обработчики без побочных действий в updater (M-3, T11.1–T11.4, T12.1–T12.4, #29)" +``` + +--- + +### Task 5: Задачи и карточка — фильтр вне вариантов, ссылки, команды, заметка (T13.1, O-10, O-11, T14.2, T14.3) + +Модель исполнителя: haiku (готовый код). + +**Files:** +- Modify: `web/src/issues/options.ts`, `web/src/issues/IssuesPage.tsx`, `web/src/issues/IssuePage.tsx`, + `web/src/issues/NoteForm.tsx`, `web/src/issues/issues.css` +- Modify: `web/src/issues/IssuesPage.test.tsx`, `web/src/issues/IssuePage.test.tsx` + +**Interfaces:** +- Produces: `withCurrent(opts: Opt[], value: string | undefined, loaded: boolean): Opt[]` в `options.ts`; + `type Opt = { value: string; label: string }` экспортируется. + +- [ ] **Step 1: Падающие тесты** — в `IssuesPage.test.tsx` (хелперы файла — `mockFetch`, `json`, снимок доски из + `../board/__fixtures__/hub-basic.board.json`, как в соседних тестах): + +```tsx + it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => { + hub(() => [row(1)]); + renderWithProviders(, { 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("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => { + mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return new Promise(() => {}); // снимок не приходит до конца теста + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { 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(); + }); +``` + + (импорт `within` из `@testing-library/react`.) + В `IssuePage.test.tsx`: + +```tsx + 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"); + }); +``` + + T14.2 — в тестах «поздний ответ прежней карточки…» и «ответ «Сохранить» после ухода…» заменить + `await new Promise((r) => setTimeout(r, 20));`: + - в первом — на `await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());` + (клиент создать в тесте `const client = makeQueryClient();` и передать `open("/issues/AURORA-9001", client)`); + - во втором — на `await waitFor(() => expect(client.isMutating()).toBe(0));`. + Пауза 450 мс в `IssuesPage.test.tsx` («T13.2: внешняя смена адреса…») не трогается: это проверка, что за время задержки + поиска (300 мс) адрес не перезаписан, — ей нужно реальное время. + +- [ ] **Step 2: Запустить** `cd web && npx vitest run src/issues` — FAIL новых тестов. + +- [ ] **Step 3: `options.ts`** — `export type Opt = …` и + +```ts +// 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]; +``` + +- [ ] **Step 4: `IssuesPage.tsx`** — в `SELECTS.map`: + `{withCurrent(opts[name], filters[name], !!board.data).map((o) => )}`. + +- [ ] **Step 5: `IssuePage.tsx`** — таблицу команд обернуть: + +```tsx + {i.commands.length ? ( + …как сейчас…
+ ) :

команд по задаче не было

} +``` + + `issues.css` — добавить `.issue a{color:var(--accent)}` (O-10). + +- [ ] **Step 6: `NoteForm.tsx`** — `import { useEffect, useId, useMemo, useState } from "react";`, + `const listId = useId();`, `` и `` (T14.3). + +- [ ] **Step 7: Проверка** `make check` — зелёный. + +- [ ] **Step 8: Коммит** + +```bash +git add web/src/issues +git commit -m "fix(web): фильтр из адреса вне вариантов, ссылки цветом темы, «команд по задаче не было», тесты без пауз (T13.1, O-10, O-11, T14.2, T14.3, #29)" +``` + +--- + +### Task 6: Модель, хранилище, эталоны (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4) + +Модель исполнителя: sonnet (перегенерация эталонов и разбор расхождений). + +**Files:** +- Modify: `web/src/test/canon.ts`, `web/src/test/canon.test.ts` +- Modify: `web/src/board/analysis/facets.ts` (`initialWho`) +- Modify: `web/src/board/storage.ts` (`colWidths`), `web/src/board/storage.test.ts` +- Modify: `web/src/board/model/fromJson.ts` (порядок импортов) +- Modify: `web/src/board/model/util.test.ts` +- Modify: `web/src/board/__fixtures__/scenarios.json`; перегенерировать `web/src/board/__fixtures__/*.expected.json` + +**Interfaces:** без новых экспортов. + +- [ ] **Step 1: T2.1** — тест в `canon.test.ts`: + +```ts + it("порядок Set не зависит от локали: сравнение по кодам символов", () => { + expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]); + }); +``` + + реализация в `canon.ts`: сортировка `.sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; })`. + +- [ ] **Step 2: T6.3** — в `util.test.ts`: + +```ts + 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 }); + }); +``` + +- [ ] **Step 3: T9.1 и T9.3** — в `storage.test.ts` (новый `describe("операции хранилища")`, `beforeEach(() => localStorage.clear())`): + +```ts + 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(); + }); +``` + + Если ожидаемое имя в `exportFileName` расходится с фактическим, свериться с `links.html@3931a88` + (`git show 3931a88:src/jira_hub/web/links.html | grep -n "links-"`): мерило — старая страница, тест правится по ней. + Если `JsonImport` требует других полей, взять форму из `types.ts`. + +- [ ] **Step 4: Запустить** `cd web && npx vitest run src/test src/board/storage.test.ts src/board/model` — FAIL: + `colWidths` пропускает строки и `null`, порядок `canon` локальный. + +- [ ] **Step 5: Реализация** + - `storage.ts`: + +```ts +export function colWidths(): Record { + const v = store.json(fileKey("colw"), {}); + if (!isObj(v)) return {}; + return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record; // T9.1 +} +``` + + - `facets.ts`, `initialWho` (T7.1, `whoItems` один раз; условие — то же, что у `whoBox`): + +```ts +export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set { + if (!model.nodes.length) return new Set(); + const { total } = whoItems(model, f); + const names = Object.keys(total); + if (!names.length || (names.length === 1 && names[0] === "")) return new Set(); + return new Set(strList(remembered).filter((w) => total[w] !== undefined)); +} +``` + + - `fromJson.ts` (T9.4): порядок импортов — внешние/`../constants.ts`, `../storage.ts`, `../types.ts`, затем `./links.ts`, + `./util.ts` (по пути, по алфавиту). + +- [ ] **Step 6: T5.1** — в `scenarios.json` добавить последним элементом: + +```json + {"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}}} +``` + +- [ ] **Step 7: Перегенерировать эталоны** `cd web && npm run golden`, затем `git diff --stat src/board/__fixtures__/`. + Ожидается: в `hub-basic.expected.json` новый сценарий `saved-short` с числовыми `clamp` у четырёх карточек; прочие + изменения — только порядок элементов из `Set` после смены сортировки `canon` (T2.1). Любое другое изменение эталона — + стоп и разбор: это расхождение генератора, не кода. + +- [ ] **Step 8: Проверка** `make check` — зелёный; `layout.test.ts` проходит сценарий `saved-short` + (`grep -c '"[0-9]"' src/board/__fixtures__/hub-basic.expected.json` больше прежнего). + +- [ ] **Step 9: Коммит** + +```bash +git add web/src/test web/src/board +git commit -m "test(web): сценарий saved-short для обрезки заголовков, canon без локали, тесты хранилища и util (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4, #29)" +``` + +--- + +### Task 7: Окружение и e2e — шрифты, source-map-js, порт, тесты оболочки (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6) + +Модель исполнителя: sonnet (зависимости, сборка, e2e). + +**Files:** +- Modify: `web/package.json`, `web/package-lock.json`, `web/index.html`, `web/src/main.tsx` +- Modify: `web/src/App.tsx`, `web/src/App.test.tsx`, `web/src/shell/theme.test.ts` +- Modify: `web/playwright.config.ts`, `web/e2e/hub.ts`, `web/e2e/geometry.spec.ts`, `web/e2e/board.spec.ts` +- Modify: `web/scripts/test-hub.sh` + +**Interfaces:** +- Produces: `export const HUB_PORT = 8791; export const HUB_URL = \`http://127.0.0.1:${HUB_PORT}\`;` в `playwright.config.ts` + (рядом с `HUB_HOME`); `e2e/hub.ts` берёт `HUB_URL` оттуда. + +- [ ] **Step 1: Шрифты (T2.2)** — `cd web && npm install --before=2026-10-01 --save-exact @fontsource/ibm-plex-sans @fontsource/ibm-plex-mono`; + в `main.tsx` до импорта `App`: + +```ts +// шрифты входят в сборку: приложение не ходит в интернет (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"; +``` + + из `index.html` удалить строку ``. + +- [ ] **Step 2: Проверка сборки без внешних адресов** — `npm run build && grep -rEn "https?://" ../src/jira_hub/web/app/index.html ../src/jira_hub/web/app/assets/*.css` + — ожидается пусто; `ls ../src/jira_hub/web/app/assets/*.woff2` — файлы есть. + +- [ ] **Step 3: source-map-js (O-1)** — в `package.json` добавить `"overrides": { "source-map-js": "1.2.2" }`, + `npm install --before=2026-10-01`, `npm audit` — ожидается 0 high. Если high остаётся — откатить `overrides`, в отчёте + O-1 `rejected` с выводом `npm audit`. + +- [ ] **Step 4: Тесты оболочки** — `App.test.tsx`: + - T4.4: один импорт `import { render, screen } from "@testing-library/react";`; + - T4.1: тест «переход «Задачи» и обратно» дополнить: + +```tsx + await userEvent.click(screen.getByRole("link", { name: "Доска" })); + expect(router.state.location.pathname).toBe("/app/board"); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); +``` + + - T4.2: тест «неизвестный путь…» дополнить: + +```tsx + expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board"); + expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues"); +``` + + - T4.3: в тесте «исключение в странице…» `` заменить на `` (`import { Layout } from "./shell/Layout.tsx";`); + в `App.tsx` удалить `App.Layout = Layout;` и импорт `Layout`. + - T4.5: `theme.test.ts` — `afterEach(() => document.documentElement.removeAttribute("data-theme"));` (импорт `afterEach`). + +- [ ] **Step 5: Порт одной константой (T15.4)** — `playwright.config.ts`: + +```ts +export const HUB_PORT = 8791; +export const HUB_URL = `http://127.0.0.1:${HUB_PORT}`; +… + use: { baseURL: HUB_URL, … }, + webServer: { …, url: `${HUB_URL}/app/`, env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) } }, +``` + + `e2e/hub.ts`: `import { HUB_HOME, HUB_URL } from "../playwright.config.ts";`, `const BASE = HUB_URL;`. + +- [ ] **Step 6: Занятый порт (T15.6)** — в `scripts/test-hub.sh` перед `rm -rf`: + +```bash +if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3 +fi +``` + + Проверка: запустить `bash scripts/test-hub.sh` в фоне, второй запуск — выход 3 с этим текстом; остановить первый. + +- [ ] **Step 7: geometry без тавтологии (T15.1)** — в `geometry.spec.ts`, тест «высота карточек…», вместо сравнения с + формулой проверять, что содержимое помещается и высоты на сетке: + +```ts + const rows = await page.evaluate(() => [...document.querySelectorAll(".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.fits, `${r.key}: содержимое не помещается`).toBe(true); + expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0); + } + expect(new Set(rows.map((r) => r.h)).size, "все карточки одной высоты — высота не по содержимому").toBeGreaterThan(1); +``` + +- [ ] **Step 8: растяжка (T15.2)** — в `board.spec.ts`, тест «растяжка за уголок…»: `import { CW } from "../src/board/constants.ts";`; + `await expect.poll(async () => (await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);` и + `await expect.poll(async () => Math.round((await card.boundingBox())!.width)).toBe(CW);`. + +- [ ] **Step 9: Проверка** `make check`; `cd web && for i in $(seq 1 10); do npm run e2e || break; done` — 10 зелёных + прогонов подряд (Review Focus 5 — шаг 2). + +- [ ] **Step 10: Коммит** + +```bash +git add web/package.json web/package-lock.json web/index.html web/src web/playwright.config.ts web/e2e web/scripts/test-hub.sh +git commit -m "build(web): шрифты в сборке, source-map-js 1.2.2, порт e2e одной константой, тесты оболочки и геометрии (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6, #29)" +``` + +--- + +### Task 8: Документация и ручная проверка (закрытие #29) + +Модель исполнителя: haiku (документы и сверка). + +**Files:** +- Modify: `CLAUDE.md` (строка «Спеки: … текущего change нет …») +- Modify: `README.md` (раздел веб-приложения — шрифты в сборке, без интернета) +- Modify: `openspec/changes/web-app-followups/design.md` (раздел «Decisions made without discussion» — рулинги исполнения) + +- [ ] **Step 1: `CLAUDE.md`** — заменить «текущего change нет (последний — `openspec/changes/archive/2026-10-15-web-app/`)» + на «текущий change — `openspec/changes/web-app-followups/`». + +- [ ] **Step 2: `README.md`** — в разделе о веб-приложении одна строка: «Шрифты (IBM Plex) входят в сборку: приложение + работает без доступа в интернет». + +- [ ] **Step 3: Ручной проход на тестовом хабе** — `bash web/scripts/test-hub.sh` (фон), `cd web && npm run build`, + открыть `http://127.0.0.1:8791/app/board` в Playwright (токен — `web/.e2e-hub/tokens.json`, поле `client`): + прокрутка после «ⓘ» и «Доска»; «Перечитать» во время «Обновить из Jira» (учебный агент — `e2e/hub.ts` + `agentServeRefresh`) не стирает строку пакета; `/app/issues?sprint=AWPCA_Спринт 0` — пометка «(нет на доске)»; + карточка задачи без команд — «команд по задаче не было», ссылки цветом темы в светлой и тёмной теме; + во вкладке Network нет запросов вне `127.0.0.1:8791`. Тестовый хаб остановить. + +- [ ] **Step 4: Проверка** `make check`, `openspec validate web-app-followups --strict`. + +- [ ] **Step 5: Коммит** + +```bash +git add CLAUDE.md README.md openspec/changes/web-app-followups/design.md +git commit -m "docs: web-app-followups — CLAUDE.md, README, рулинги исполнения (#29)" +``` diff --git a/openspec/changes/web-app-followups/.openspec.yaml b/openspec/changes/web-app-followups/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/web-app-followups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/web-app-followups/design.md b/openspec/changes/web-app-followups/design.md new file mode 100644 index 0000000..94c8d3a --- /dev/null +++ b/openspec/changes/web-app-followups/design.md @@ -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 ` или `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` → «Хаб отверг проверку токена: »; `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`, перезапуск. diff --git a/openspec/changes/web-app-followups/proposal.md b/openspec/changes/web-app-followups/proposal.md new file mode 100644 index 0000000..8611201 --- /dev/null +++ b/openspec/changes/web-app-followups/proposal.md @@ -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. diff --git a/openspec/changes/web-app-followups/specs/hub-api/spec.md b/openspec/changes/web-app-followups/specs/hub-api/spec.md new file mode 100644 index 0000000..e6db0ae --- /dev/null +++ b/openspec/changes/web-app-followups/specs/hub-api/spec.md @@ -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 diff --git a/openspec/changes/web-app-followups/specs/web-app/spec.md b/openspec/changes/web-app-followups/specs/web-app/spec.md new file mode 100644 index 0000000..aa6a018 --- /dev/null +++ b/openspec/changes/web-app-followups/specs/web-app/spec.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ac01160..260b499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 14f57e3..ba29d91 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index 4f35df8..97aa31b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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: diff --git a/tests/test_db.py b/tests/test_db.py index 8fd7025..28f692b 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 24c128c..bfa7007 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -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""), ("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 diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 5519d69..7a8886a 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -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(".board .card[data-key] a.open-card")] + .find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; }); + return a?.closest(".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); +}); diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts index f828d31..28231f5 100644 --- a/web/e2e/geometry.spec.ts +++ b/web/e2e/geometry.spec.ts @@ -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(); - document.querySelectorAll(".measure .card[data-key]").forEach((e) => m.set(e.dataset.key!, e.offsetHeight)); - return [...document.querySelectorAll(".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(".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 }) => { diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 2f1f5c5..621bc7d 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -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); diff --git a/web/index.html b/web/index.html index 3a30c3c..5f2db89 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,6 @@ Хаб Jira -
diff --git a/web/package-lock.json b/web/package-lock.json index 858ee1c..30ac58f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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": { diff --git a/web/package.json b/web/package.json index ea29d51..5927f5e 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 7c05817..61997b0 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -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) }, }, }); diff --git a/web/scripts/test-hub.sh b/web/scripts/test-hub.sh index 879c516..bdf3736 100644 --- a/web/scripts/test-hub.sh +++ b/web/scripts/test-hub.sh @@ -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 diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f82b69c..fde4622 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); 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: , children: [{ path: "x", element: }] }], + const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }], { initialEntries: ["/x"] }); render(); expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 1ad4257..02634e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 }) { ); } -App.Layout = Layout; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 9f8070e..d24f776 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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)["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); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8ea73d6..41e74e1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(method: string, path: string, body?: unknown, token = getToken()): Promise { - 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 = { 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(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}`); diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts new file mode 100644 index 0000000..ed8eabc --- /dev/null +++ b/web/src/api/queries.test.ts @@ -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(qk.issue("A-1"))?.note).toEqual(note); + }); +}); diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts new file mode 100644 index 0000000..14affb2 --- /dev/null +++ b/web/src/api/queryClient.test.ts @@ -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); + }); +}); diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts index 7462f94..037358e 100644 --- a/web/src/api/queryClient.ts +++ b/web/src/api/queryClient.ts @@ -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 }), diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index fa4b1e3..a4ffa71 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -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 =

страница приложения

; +function Page() { useBoard(); return

страница приложения

; } + 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(); + 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((r) => { release = r; }); + mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); + const { client } = renderWithProviders(); + 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(); }); }); diff --git a/web/src/auth/LoginScreen.tsx b/web/src/auth/LoginScreen.tsx index fba6398..c8c3553 100644 --- a/web/src/auth/LoginScreen.tsx +++ b/web/src/auth/LoginScreen.tsx @@ -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); } diff --git a/web/src/auth/token.test.ts b/web/src/auth/token.test.ts index b1d9ac5..574ade9 100644 --- a/web/src/auth/token.test.ts +++ b/web/src/auth/token.test.ts @@ -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 () => { diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index 19fc5ed..9ef0da5 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -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(, { 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(); + }); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index 03ae483..ecc58c2 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -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(null); const saveRef = useRef<(() => void) | null>(null); - const headRef = useRef(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

Загрузка настроек хаба…

; if (settings.error) return

Настройки хаба не загрузились: {settings.error.message}

; diff --git a/web/src/board/__fixtures__/hub-basic.expected.json b/web/src/board/__fixtures__/hub-basic.expected.json index 4c33bc1..f59f2fa 100644 --- a/web/src/board/__fixtures__/hub-basic.expected.json +++ b/web/src/board/__fixtures__/hub-basic.expected.json @@ -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": "без срока1", + "left": "20px", + "title": "плановая дата не задана" + }, + { + "className": "dlabel past", + "innerHTML": "10.101", + "left": "316px", + "title": "срок прошёл" + }, + { + "className": "dlabel past", + "innerHTML": "12.101", + "left": "612px", + "title": "срок прошёл" + }, + { + "className": "dlabel", + "innerHTML": "16.101", + "left": "908px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "18.101", + "left": "1204px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "20.103", + "left": "1500px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "25.101", + "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": { diff --git a/web/src/board/__fixtures__/scenarios.json b/web/src/board/__fixtures__/scenarios.json index 1390c4f..e225c00 100644 --- a/web/src/board/__fixtures__/scenarios.json +++ b/web/src/board/__fixtures__/scenarios.json @@ -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}}} ] diff --git a/web/src/board/analysis/analysis.test.ts b/web/src/board/analysis/analysis.test.ts index 8e2b6d6..bf0aa8d 100644 --- a/web/src/board/analysis/analysis.test.ts +++ b/web/src/board/analysis/analysis.test.ts @@ -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); + }); }); diff --git a/web/src/board/analysis/facets.ts b/web/src/board/analysis/facets.ts index 0840e52..2b526aa 100644 --- a/web/src/board/analysis/facets.ts +++ b/web/src/board/analysis/facets.ts @@ -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 { - 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)); } diff --git a/web/src/board/components/BoardCanvas.test.tsx b/web/src/board/components/BoardCanvas.test.tsx index 757fe14..5c0b041 100644 --- a/web/src/board/components/BoardCanvas.test.tsx +++ b/web/src/board/components/BoardCanvas.test.tsx @@ -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[0]> = {}) { +function baseProps(over: Partial[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(), emptyText: "пусто", + return { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set(), emptyText: "пусто", onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over }; +} +function draw(over: Partial[0]> = {}) { + const props = baseProps(over); renderWithProviders(); 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(); 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(".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(); + Object.defineProperty(window, "scrollY", { configurable: true, value: 640 }); + fireEvent.scroll(window); + expect(readBoardScroll()).toMatchObject({ y: 640 }); + first.unmount(); + renderWithProviders(); + 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(); + 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 }); }); }); diff --git a/web/src/board/components/BoardCanvas.tsx b/web/src/board/components/BoardCanvas.tsx index ff2b6b7..4915326 100644 --- a/web/src/board/components/BoardCanvas.tsx +++ b/web/src/board/components/BoardCanvas.tsx @@ -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) diff --git a/web/src/board/components/Card.test.ts b/web/src/board/components/Card.test.ts new file mode 100644 index 0000000..2945119 --- /dev/null +++ b/web/src/board/components/Card.test.ts @@ -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); + }); +}); diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx index 615eac0..9d05362 100644 --- a/web/src/board/components/Card.tsx +++ b/web/src/board/components/Card.tsx @@ -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
); -}); +}, cardPropsEqual); diff --git a/web/src/board/components/DatesBar.test.tsx b/web/src/board/components/DatesBar.test.tsx new file mode 100644 index 0000000..6f4e373 --- /dev/null +++ b/web/src/board/components/DatesBar.test.tsx @@ -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(); + 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); + }); +}); diff --git a/web/src/board/components/DatesBar.tsx b/web/src/board/components/DatesBar.tsx index dda5021..ccbb2e8 100644 --- a/web/src/board/components/DatesBar.tsx +++ b/web/src/board/components/DatesBar.tsx @@ -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, c: DateCol) => { ev.preventDefault(); const handle = ev.currentTarget, x0 = ev.clientX; diff --git a/web/src/board/components/Dropdown.test.tsx b/web/src/board/components/Dropdown.test.tsx new file mode 100644 index 0000000..8be5b6f --- /dev/null +++ b/web/src/board/components/Dropdown.test.tsx @@ -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( }>{() =>

меню

}
); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByText("меню")).toBeInTheDocument(); + fireEvent.click(screen.getByText("открыть")); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/board/components/Dropdown.tsx b/web/src/board/components/Dropdown.tsx index 6a87228..f7eca59 100644 --- a/web/src/board/components/Dropdown.tsx +++ b/web/src/board/components/Dropdown.tsx @@ -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 (
{ if (e.key === "Escape") setOpen(false); }}> {button({ open, toggle })} diff --git a/web/src/board/components/MultiSelect.tsx b/web/src/board/components/MultiSelect.tsx index 77d6587..67d28ad 100644 --- a/web/src/board/components/MultiSelect.tsx +++ b/web/src/board/components/MultiSelect.tsx @@ -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 ( setQ("")} button={({ open, toggle }) => ( - )}> {() => ( <> setQ(e.target.value)} /> -
+
{!shown.length &&
ничего не найдено
} {shown.map((i) => (
diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 55ceea3..76f404d 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -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 = () => { let resolve!: (v: T) => void; const promise = new Promise((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(, { 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(); + 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(, { 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(() => {}); // снимок не приходит до конца теста + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { 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(); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 28fc3ef..2015915 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -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() { ))} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index c8e624f..833deb4 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -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 }) { {TEXTS.map(([f, label]) => (