sdlc-hub/docs/superpowers/plans/2026-10-25-web-bugfixes.md
Andrey Epifancev ba1db6568c План: пакет веб-багов web-bugfixes (#124), задачи 1–8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:52:35 +04:00

35 KiB
Raw Blame History

Пакет веб-багов (web-bugfixes) 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: убрать видимые пользователю ошибки веба хаба из пакета A сверки (sdlc-hub#124), каждую закрепив тестом, который до правки падает.

Architecture: только клиент (web/src/): точечные правки окна карточки, разборщика вики-разметки, трекера состояния модулей, вложений, доски и UI-кита. Одна новая чистая функция wikiPlain для текста ленты. API, схема, MCP и агенты не меняются.

Tech Stack: React 19 + TypeScript + Vite, React Router (data router), TanStack Query, Vitest + Testing Library, Playwright.

Spec: openspec/changes/web-bugfixes/ — design.md (решения 1–21, ссылки на строки main 1405ccd), specs/web-app/spec.md (дельта требований и сценарии), proposal.md.

Global Constraints

  • Меняется только web/src/ (+ web/e2e/issues.spec.ts). Сервер, API, MCP, агенты — без изменений.
  • Язык комментариев и сообщений коммитов — русский; идентификаторы — английские. Плотность комментариев — как в окружающем коде.
  • Разметка MUST NOT превращаться в HTML строкой (innerHTML, dangerouslySetInnerHTML).
  • Тесты — рядом с кодом (*.test.ts(x)), запросы к хабу мокаются mockFetch/json из web/src/test/fetchMock.ts.
  • Каждая задача начинается с теста, который падает до правки (RED подтверждается запуском и фиксируется в отчёте).
  • Тексты интерфейса — дословно из спеки: «Вид работы в Jira», «Назад», «Закрыть», «Остаться», «Уйти без сохранения заметки?», «Повторить», «Загрузка…», «хаб не отвечает», «нет в зеркале», «Задачи {key} нет в зеркале хаба.».
  • Коммит в конце каждой задачи; сообщение заканчивается строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Зелёным считается только весь набор: финально make check из корня репозитория и cd web && npm run e2e.

Review Focus

  1. Ctrl/Cmd/средний щелчок по ключу в списке «Задачи» по-прежнему открывает карточку в новой вкладке (перехват только обычного щелчка) — тест в Task 4.
  2. Окно, открытое прямым адресом (depth = 0), и двойной ✕: адрес — список, дальше по истории не уходит — тест в Task 4.
  3. wikiPlain на незакрытой разметке, неизвестных макросах и пустом тексте не бросает и отдаёт текст как есть; пустой комментарий в ленте — по-прежнему «комментарий: —» — тест в Task 2.
  4. Настройки ещё не пришли или без kind_labels: все метки — в «Метках», строки «Вид работы в Jira» нет — тест в Task 3.
  5. Смена проекта A → B → A во время опроса: пакет A снова подхватывается при возврате (сброс не «запирает» модуль) — тест в Task 5.

Task 1: Вики-разметка — краевые случаи (#56 M-5, M-6)

Files:

  • Modify: web/src/modules/jira/issues/wiki/inline.ts (ESCAPABLE :21, bracket :148-152)
  • Modify: web/src/modules/jira/issues/wiki/parse.ts (MACRO :17, closeMacro :103-116, row :118-139)
  • Test: web/src/modules/jira/issues/wiki/inline.test.ts, web/src/modules/jira/issues/wiki/parse.test.ts

Interfaces:

  • Consumes: —

  • Produces: прежние parseWiki(text, opts): Block[], parseInline(line, opts, label?): Inline[] с исправленным разбором.

  • Step 1: Write the failing tests

В inline.test.ts:

it("\\# — символ решётки (M-5)", () => {
  expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
});
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
  expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
});

В parse.test.ts:

it("\\# в начале строки — абзац, не список", () => {
  expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
});
it("quote и noformat с параметрами (M-5)", () => {
  expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
  expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
});
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
  expect(parseWiki("{code}   \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
});
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
  const [table] = parseWiki("|[draft|итог|готово|");
  expect(table).toMatchObject({ t: "table" });
  expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
  const [table] = parseWiki("|[док|https://x.ru]|b|");
  expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
});
  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/issues/wiki/ Expected: FAIL — новые тесты (кроме последнего, он фиксирует текущее поведение и должен быть зелёным).

  • Step 3: Implement

  • ESCAPABLE — добавить #.

  • MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/; имя тега в closeMacro — первая группа (без параметров), закрывающий тег — {имя}.

  • closeMacro: остаток строки открывающего тега, состоящий из одних пробелов, в body не попадает (text.trim() вместо text в условии для k === start).

  • bracket: при body.startsWith("~") логин — body.slice(1).split("|")[0].trim(); пустой или с пробелом — null, как сейчас.

  • row: [ увеличивает depth, только если в s правее позиции есть ] (s.indexOf("]", j + 1) >= 0).

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/wiki/ Expected: PASS (все тесты каталога, включая прежние).

  • Step 5: Commit
git add web/src/modules/jira/issues/wiki/
git commit -m "Вики-разметка: \\#, макросы с параметрами, [~логин|текст], пробелы после {code}, непарная [ в таблице (#124, #56 M-5, M-6)"

Task 2: Текст комментария в ленте без разметки (#103 С6)

Files:

  • Create: web/src/modules/jira/issues/wiki/plain.ts
  • Modify: web/src/modules/jira/feed/events.ts (eventText, ветка comment :72-75)
  • Test: web/src/modules/jira/issues/wiki/plain.test.ts (новый), web/src/modules/jira/feed/events.test.ts

Interfaces:

  • Consumes: parseWiki, Block, Inline, WikiOptions из Task 1 (parse.ts, inline.ts).

  • Produces: export function wikiPlain(text: string, opts?: WikiOptions): string — текст как читается отрендеренным, блоки через пробел (не одна строка — «одна строка» и обрезку делает eventText).

  • Step 1: Write the failing tests

plain.test.ts:

import { wikiPlain } from "./plain.ts";
it("выделение, ссылки, упоминание, заголовок", () => {
  expect(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]").replace(/\s+/g, " ").trim())
    .toBe("Итог Готово, см. протокол и @ivanov");
});
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
  expect(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}").replace(/\s+/g, " ").trim()).toBe("https://x.ru a_b x = 1");
});
it("ключ задачи и вложение — текстом", () => {
  const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
  expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
  expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
  expect(wikiPlain("")).toBe("");
});

В events.test.ts:

it("комментарий в ленте — без вики-разметки (С6)", () => {
  expect(eventText({ ...commentEvent, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))
    .toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("пустой комментарий — по-прежнему «—»", () => {
  expect(eventText({ ...commentEvent, to: "  " })).toBe("комментарий: —");
});

(commentEvent — событие kind: "comment" по образцу уже имеющихся в events.test.ts.)

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/issues/wiki/plain.test.ts src/modules/jira/feed/events.test.ts Expected: FAIL — нет модуля plain.ts; тест С6 видит сырую разметку.

  • Step 3: Implement

wikiPlain обходит дерево parseWiki: text — текст; метки выделения — дети; code — текст; link — дети (у [адрес] дети уже содержат адрес); issue — key, если детей нет, иначе дети; attachment — дети; mention — "@" + login; br — пробел; блоки (heading, p, list с пунктами и вложенными списками, table по ячейкам, pre — text, quote — вложенные блоки, hr — пусто) соединяются пробелом. В eventText ветка comment: oneLine(wikiPlain(e.to ?? "")), остальное (обрезка 300 и «…», «—» для пустого) — как сейчас.

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/wiki/ src/modules/jira/feed/ Expected: PASS.

  • Step 5: Commit
git add web/src/modules/jira/issues/wiki/plain.ts web/src/modules/jira/issues/wiki/plain.test.ts web/src/modules/jira/feed/
git commit -m "Лента: текст комментария без вики-разметки Jira (#124, #103 С6)"

Task 3: Карточка — связи «нет в зеркале», окно 404, метки видов, пустое описание (#42 M-5, #103, #56 M-4)

Files:

  • Modify: web/src/modules/jira/issues/card.ts (новая функция)
  • Modify: web/src/modules/jira/issues/IssueCard.tsx (404/ошибка :34-49, Links :110-134, описание :79, OtherFields :225-237)
  • Modify: web/src/modules/jira/issues/overlay.tsx (передать в карточку onBack, если card.depth >= 1 … см. Step 3)
  • Test: web/src/modules/jira/issues/card.test.ts (если нет — создать), web/src/modules/jira/issues/IssueCard.test.tsx, web/src/modules/jira/issues/IssueModal.test.tsx

Interfaces:

  • Consumes: JIRA_URL (../board/constants.ts), settings.data.kind_labels (вид → метка).

  • Produces:

    • export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] } в card.ts — kinds — названия видов в порядке меток задачи, rest — прочие метки;
    • IssueCard получает необязательный проп onBack?: () => void; IssueModal прокидывает его из useIssueOverlay.
  • Step 1: Write the failing tests

card.test.ts:

it("метки видов работ отдельно от прочих", () => {
  expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
    .toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
});
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
  expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
});

IssueCard.test.tsx:

  • «Прочие поля»: задача с метками ["AWPCA", "kind-dev"], настройки фикстуры settings.json → видны пары «Вид работы в Jira» / «разработка» и «Метки» / «AWPCA», текста kind-dev в карточке нет.
  • описание " \n\n" → нет заголовка «Описание».
  • связь на ключ вне зеркала (summary: null, status: null) → в «Связях» getByRole("link", { name: /AURORA-1999/ }) имеет href = ${JIRA_URL}/browse/AURORA-1999 и target="_blank"; рядом текст «нет в зеркале».

IssueModal.test.tsx (сценарий спеки «Задача не в зеркале после перехода»): из списка открыта AURORA-9002, в окне переход по ссылке на ключ, для которого хаб отвечает 404 (json(404, {}) в hub() для этого ключа) → в окне «Задачи AURORA-1999 нет в зеркале хаба.» и кнопки «Назад» и «Закрыть»; «Назад» — заголовок окна снова AURORA-9002; при новом переходе и «Закрыть» — окна нет, адрес — список. Прямой адрес /app/jira/issues/AURORA-1999 (404) → только «Закрыть».

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/issues/card.test.ts src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/IssueModal.test.tsx Expected: FAIL — нет splitKindLabels, метка kind-dev видна в «Метках», рамка описания есть, ссылка ведёт на /jira/issues/…, кнопки «Назад» нет.

  • Step 3: Implement

  • OtherFields: строки «Вид работы в Jira» (kinds.join(", ")) и «Метки» (rest.join(", ")) вместо прежней «Метки»; kind_labels — из useSettings() (передать пропом из IssueCard). Положение «Вид работы в Jira» — сразу перед «Метками».

  • Описание: условие i.description?.trim().

  • Links: при l.summary === null && l.status === null ключ — <a href={${JIRA_URL}/browse/${l.key}} target="_blank" rel="noopener"> со значком IconExternal (как «Открыть в Jira»), иначе — IssueLink, как сейчас.

  • useIssueOverlay: «Назад» — только если окно открыто переходом из другой карточки. По openCardState (modal.ts:25-28): из списка depth = 1, direct = false; прямой адрес — depth = 0, direct = true; каждый переход внутри окна — depth + 1. Отсюда onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined; прокинуть через IssueModal в IssueCard. Тесты: «из списка — без Назад», «после перехода — с Назад», «прямой адрес — без Назад», «переход из прямого адреса — с Назад».

  • 404 и ошибка загрузки: action — «Назад» (если onBack) и «Закрыть» / «Повторить», «Назад» (если есть), «Закрыть».

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/ Expected: PASS.

  • Step 5: Commit
git add web/src/modules/jira/issues/
git commit -m "Карточка: связь вне зеркала ведёт в Jira, «Назад» у окна 404, метки видов работ отдельно, пустое описание (#124, #42 M-5, #103, #56 M-4)"

Task 4: Окно карточки — поиск, двойное закрытие, подтверждение ухода (#42 M-1, M-2, M-4)

Files:

  • Modify: web/src/modules/jira/issues/IssuesPage.tsx (:14-38, open :50)
  • Modify: web/src/modules/jira/issues/IssueTable.tsx (ключ :38 — обычный щелчок через onOpen)
  • Modify: web/src/modules/jira/issues/overlay.tsx (close :37-41)
  • Modify: web/src/modules/jira/issues/NoteForm.tsx (подтверждение ухода :82-89, эффект :42)
  • Test: web/src/modules/jira/issues/IssueModal.test.tsx, web/src/modules/jira/issues/IssuesPage.test.tsx, web/e2e/issues.spec.ts

Interfaces:

  • Consumes: openCardState, cardPath (modal.ts, IssueLink.tsx).

  • Produces: IssueTable сохраняет проп onOpen(key: string): void — теперь его зовут и строка, и обычный щелчок по ключу.

  • Step 1: Write the failing tests

IssueModal.test.tsx:

  • «поиск перед открытием не закрывает окно» (сценарий спеки): список, в поле поиска (getByRole("searchbox")) набрано «свод», сразу щёлкнут ключ AURORA-9002 (без ожидания 300 мс) → окно открыто; после await new Promise(r => setTimeout(r, 400)) окно всё ещё открыто (screen.getByRole("dialog")), router.state.location.pathname — /app/jira/issues/AURORA-9002; после ✕ адрес — /app/jira/issues с q=свод. То же для щелчка по ячейке темы строки.
  • «двойной щелчок по ✕ не уводит дальше назад»: история ["/app/jira/links", "/app/jira/issues"], открыта AURORA-9002, переход по связи на AURORA-9001, await userEvent.dblClick(✕) → адрес — /app/jira/issues, не /app/jira/links.
  • то же для прямого адреса (Review Focus 2): история ["/app/jira/issues/AURORA-9002"], двойной ✕ → /app/jira/issues.
  • подтверждение ухода: в форме заметки правка, нажата Esc → getByRole("alertdialog", { name: "Уйти без сохранения заметки?" }), document.activeElement — кнопка «Остаться».

IssuesPage.test.tsx (Review Focus 1): у ключа строки href прежний (/jira/issues/AURORA-9001), щелчок с ctrlKey (fireEvent.click(link, { ctrlKey: true })) не вызывает навигацию внутри приложения (адрес страницы не меняется).

web/e2e/issues.spec.ts: сценарий «поиск перед открытием карточки» — ввести текст в поиск и сразу щёлкнуть первую строку; через 600 мс окно открыто; Escape — адрес списка содержит q=.

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/issues/IssueModal.test.tsx src/modules/jira/issues/IssuesPage.test.tsx Expected: FAIL — окно закрывается через 300 мс; двойной ✕ уходит на /app/jira/links; у подтверждения нет имени и фокуса.

  • Step 3: Implement

  • IssuesPage: функция openCard(key) — снять таймер поиска; если q отличается от filters.q, записать q в адрес (replace) и открыть карточку с фоном, у которого search уже содержит q (navigate(cardPath(key), { state: openCardState({ ...loc, search: <поиск с q> }) }), written.current = q). Её получает IssueTable как onOpen. Таймер не перезаводится, пока страница под окном (адрес filters.q совпадает с q после записи — эффект ничего не делает).

  • IssueTable: ключ — та же ссылка IssueLink (адрес и состояние для новой вкладки), но обычный щелчок (левая кнопка без ctrl/meta/shift/alt) — preventDefault() и onOpen(key).

  • overlay.tsx: closingKey = useRef<string | null>(null); close выходит, если closingKey.current === location.key, иначе запоминает location.key и закрывает как сейчас. Сброс — эффект по смене location.key.

  • NoteForm: у блока подтверждения ухода aria-label="Уйти без сохранения заметки?"; ref на кнопку «Остаться»; при blocker.state === "blocked" — scrollIntoView (как сейчас) и focus() этой кнопки.

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/ && npm run e2e -- issues.spec.ts Expected: PASS.

  • Step 5: Commit
git add web/src/modules/jira/issues/ web/e2e/issues.spec.ts
git commit -m "Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)"

Task 5: Строка состояния — ответ опроса прежнего проекта (#116)

Files:

  • Modify: web/src/shell/activity/ActivityProvider.tsx (эффект опроса :126-154)
  • Test: web/src/shell/activity/activity.test.tsx

Interfaces:

  • Consumes: —

  • Produces: поведение ModuleActivity без изменений интерфейса.

  • Step 1: Write the failing test

В activity.test.tsx — детерминированный тест «ответ опроса после смены проекта не возвращает событие»: ответ GET /batches/9 (пакет wiki_sync проекта AWPCA) задерживается промисом-заглушкой (deferred из web/src/test/batchHub.ts); когда первый ответ показан (event.kind === "batch"), следующий ответ держится; в одном act вызвать m.setProject("STOCKS_DRRK") и разрешить задержанный ответ → после await waitFor(...) и sleep(50) m.now().wiki.event равно null. Исполнитель подбирает порядок так, чтобы тест падал на текущем коде (RED обязателен, см. Risks в design.md); если воспроизвести в одном такте не удаётся — описать в отчёте, как получен RED (например, pollMs 0 и разрешение ответа синхронно перед setProject).

Второй тест (Review Focus 5): A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается (event { kind: "batch", id: 9 }).

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/shell/activity/activity.test.tsx Expected: FAIL — первый тест: событие batch id 9 после смены проекта.

  • Step 3: Implement

В начале эффекта опроса: if (!active || activeRef.current !== active) return; — ответ обрабатывается, только если отслеживаемый пакет тот же объект, что в activeRef (смена проекта синхронно обнуляет activeRef в setActiveNow). Проверить, что setActiveNow({ ...active, own: true }) (:138) и track/start по-прежнему проходят: они сами обновляют activeRef.

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/shell/activity/activity.test.tsx и прогон в цикле: for i in $(seq 20); do npx vitest run src/shell/activity/activity.test.tsx --silent || break; done Expected: PASS все 20 прогонов.

  • Step 5: Commit
git add web/src/shell/activity/
git commit -m "Состояние модулей: ответ опроса прежнего проекта не возвращает событие (#124, #116)"

Task 6: Список задач ждёт настройки (#60 M-3)

Files:

  • Modify: web/src/modules/jira/issues/IssuesPage.tsx (запрос :45-48, «Загрузка…» :60-61)
  • Modify: web/src/modules/jira/api/queries.ts (useIssues — необязательный параметр enabled)
  • Test: web/src/modules/jira/issues/IssuesPage.test.tsx

Interfaces:

  • Consumes: useSettings() (isPending).

  • Produces: useIssues(filters: IssueFilters, opts?: { enabled?: boolean }).

  • Step 1: Write the failing test

«закрытый статус в адресе до настроек» (сценарий спеки): ответ /settings задержан; маршрут /issues?status=Готово; пока настройки не пришли — issuesCalls(fetch) пуст, видно «Загрузка…», нет «По этим фильтрам задач нет»; после ответа /settings (с «Готово» в closed_statuses) — ровно один вызов /issues с status=Готово и без hide_closed. Второй тест (R-11): /settings отвечает 500 → один вызов /issues с hide_closed=1.

  • Step 2: Run test to verify it fails

Run: cd web && npx vitest run src/modules/jira/issues/IssuesPage.test.tsx Expected: FAIL — первый вызов /issues уходит до настроек с hide_closed=1.

  • Step 3: Implement

useIssues(query, { enabled: !settings.isPending }); пока настройки грузятся, «Загрузка…» (условие подсказки — по list.isPending || settings.isPending), сообщение «По этим фильтрам задач нет» — только после ответа списка.

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/ Expected: PASS.

  • Step 5: Commit
git add web/src/modules/jira/
git commit -m "Список задач: запрос после настроек, без мелькания «задач нет» (#124, #60 M-3)"

Task 7: Вложения — прежняя ошибка, 401 при опросе, порядок хаба (#86 T6.2, T6.3, M-4)

Files:

  • Modify: web/src/modules/jira/issues/attachments/useAttachmentDownload.ts
  • Modify: web/src/modules/jira/issues/attachments/AttachmentList.tsx (newestFirst :14-17, ошибка :36)
  • Modify: web/src/modules/jira/issues/wiki/inline.ts (findAttachment :36-45)
  • Test: web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx, web/src/modules/jira/issues/IssueCard.test.tsx, web/src/modules/jira/issues/wiki/inline.test.ts

Interfaces:

  • Consumes: dropTokenOn, AuthError, HubDownError (web/src/api/client.ts).

  • Produces: DownloadStatus дополняется полем ok: boolean (успех в этой карточке); IDLE.ok = false.

  • Step 1: Write the failing tests

  • useAttachmentDownload.test.tsx: опрос GET /attachments/{id} отвечает 401 → вызван сброс токена (токен в хранилище пуст / экран входа по образцу других тестов AuthError), текста ошибки нет; ответ — сеть недоступна (HubDownError) → status(id).error === "хаб не отвечает".

  • IssueCard.test.tsx (сценарий «Повтор после ошибки»): вложение с state: "failed", error: "сбой", «Повторить», хаб отдаёт ready и файл, перечитанная карточка всё ещё отдаёт failed (задержать) → текста «сбой» нет.

  • IssueCard.test.tsx: вложения в ответе хаба в порядке [b (created null), a (created 2026-10-20)] → в разделе в том же порядке.

  • inline.test.ts: два вложения «скрин.png» — [{id: "5", created: null}, {id: "9", created: "2026-10-20T10:00:00.000+0300"}] → !скрин.png! даёт attachment с id: "5" (первое в порядке хаба).

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/issues/attachments/ src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/wiki/inline.test.ts Expected: FAIL.

  • Step 3: Implement

  • В useAttachmentDownload: общая функция разбора ошибки — AuthError → dropTokenOn(e) без текста; HubDownError → «хаб не отвечает»; прочие — errorText. Успех — put(id, { busy: false, error: null, ok: true }); start сбрасывает ok.

  • AttachmentList: порядок — как в items; серверный failed показывается, только если !ok. Убрать newestFirst (и его экспорт, если других потребителей нет).

  • findAttachment: первое вложение с этим именем в порядке opts.attachments.

  • Step 4: Run tests to verify they pass

Run: cd web && npx vitest run src/modules/jira/issues/ Expected: PASS.

  • Step 5: Commit
git add web/src/modules/jira/issues/
git commit -m "Вложения: без прежней ошибки после повтора, 401 — экран входа, порядок хаба (#124, #86 T6.2, T6.3, M-4)"

Task 8: Доска, проекты, поиск UI-кита (#35 T4.1/M-15, #64 T9.1, M-6, #68 T3.3) и итоговая проверка

Files:

  • Modify: web/src/modules/jira/board/useBoardPrefs.ts (toggle, toggleHidden :39-54)
  • Modify: web/src/modules/jira/board/model/fromHub.ts (hasSprint :30)
  • Modify: web/src/projects/ProjectGate.tsx (:11)
  • Modify: web/src/ui/inputs.tsx (SearchInput :15-24)
  • Test: web/src/modules/jira/board/BoardPage.test.tsx или новый useBoardPrefs.test.tsx, тест модели доски рядом с fromHub.ts, web/src/projects/projects.test.tsx, web/src/ui/forms.test.tsx

Interfaces:

  • Consumes: useProjects() (refetch) из web/src/api/queries.ts.

  • Produces: —

  • Step 1: Write the failing tests

  • useBoardPrefs: renderHook; в одном act два вызова toggle(<флаг A>) и toggle(<флаг B>) → оба флага включены и в состоянии, и в localStorage; в одном act toggleHidden("kind", "x") и toggleHidden("kind", "y") → скрыты оба.

  • fromHub: снимок, где спринт только у эпика (тип «Эпик», neighbor: false), у остальных своих задач спринта нет → hasSprint === false.

  • projects.test.tsx: GET /projects → 500, затем 200 → видно «Проекты не загрузились: …» и кнопка «Повторить»; после нажатия — содержимое страницы проекта.

  • forms.test.tsx: SearchInput со значением, щелчок «Очистить» → onChange("") и фокус в поле поиска (document.activeElement === getByRole("searchbox")) — с обёрткой-состоянием, чтобы кнопка исчезла.

  • Step 2: Run tests to verify they fail

Run: cd web && npx vitest run src/modules/jira/board/ src/projects/ src/ui/ Expected: FAIL.

  • Step 3: Implement

  • useBoardPrefs: prefsRef с последним значением; toggle/toggleHidden считают новое значение из prefsRef.current, синхронно присваивают prefsRef.current = next, пишут st.writeFlag/st.writeHidden и setPrefs(next).

  • fromHub: hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint).

  • ProjectGate: action={<Button onClick={retry}>Повторить</Button>}, где retry перезапрашивает GET /projects.

  • SearchInput: ref на input; «Очистить» — onChange(""), затем ref.current?.focus().

  • Step 4: Run the whole suite

Run (из корня репозитория): make check, затем cd web && npm run e2e Expected: всё зелёное (pytest, lint, vitest, e2e).

  • Step 5: Commit
git add web/src/
git commit -m "Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)"