fix: волна по финальному ревью — I-2 fromFile при обновлении из файла, тесты, комментарии, спека (#29)

I-2, M-4..M-8, M-11, M-13, M-16, M-18, T7.3, M-1..M-3, M-17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 02:40:33 +04:00
parent d377a57b53
commit bb3ddd1770
18 changed files with 94 additions and 27 deletions

View File

@ -1,7 +1,7 @@
# jira-hub # jira-hub
## Что это ## Что это
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/web-app-followups/`. Хаб моста 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. - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git.

View File

@ -25,9 +25,9 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2
## Decisions ## Decisions
1. **Прокрутка доски (M-3).** При размонтировании `BoardPage` запоминает `window.scrollX/scrollY` (и `scrollLeft` 1. **Прокрутка доски (M-3).** `BoardCanvas` запоминает `window.scrollX/scrollY` (и `scrollLeft`
горизонтальной обёртки холста, если она прокручивается сама) в переменной модуля; при монтировании, после первой горизонтальной обёртки `.wrap`, если она прокручивается сама) в переменной модуля по событиям `scroll` окна и `.wrap`;
отрисовки холста с данными, восстанавливает. Перезагрузка страницы открывает доску сверху, как сейчас. Состояние при монтировании восстанавливает в `useLayoutEffect` после `placed` (первой отрисовки холста с данными). Перезагрузка страницы открывает доску сверху, как сейчас. Состояние
источника не поднимается выше страницы — rejected часть M-3 (решение владельца). источника не поднимается выше страницы — rejected часть M-3 (решение владельца).
2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока 2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока
`active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а `active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а
@ -41,7 +41,7 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2
снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1). снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1).
5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только 5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только
если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется. если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется.
6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «хаб отверг запрос: <detail или error>»; `HubDownError` — прежний 6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «Хаб отверг проверку токена: <detail или error>»; `HubDownError` — прежний
текст «хаб не отвечает». текст «хаб не отвечает».
7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым 7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым
вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке. вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке.
@ -58,7 +58,7 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2
автоматической высотой. В `scenarios.json` добавляется сценарий `saved-short` с сохранённой раскладкой четырёх карточек автоматической высотой. В `scenarios.json` добавляется сценарий `saved-short` с сохранённой раскладкой четырёх карточек
разной высоты (120, 136, 200, 320), эталоны перегенерируются `npm run golden` из `links.html@3931a88`; прежние разной высоты (120, 136, 200, 320), эталоны перегенерируются `npm run golden` из `links.html@3931a88`; прежние
сценарии и фикстура `hub-basic.board.json` не меняются. сценарии и фикстура `hub-basic.board.json` не меняются.
13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа в `web/e2e/hub.ts` и `playwright.config.ts` берёт её же; 13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа (`HUB_PORT`) в `web/playwright.config.ts`, `web/e2e/hub.ts` импортирует её;
`scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e `scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e
проходит 10 раз подряд. проходит 10 раз подряд.
14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель, 14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель,
@ -157,4 +157,4 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2
добавлена защита `isConnected` (запоздавшее scroll после ухода с доски не затирает запомненное), e2e M-3 переписан на добавлена защита `isConnected` (запоздавшее scroll после ухода с доски не затирает запомненное), e2e M-3 переписан на
клик по видимой карточке без ожидания кадров. Обоснование: воспроизвести потерю прокрутки у пользователя, кликающего по клик по видимой карточке без ожидания кадров. Обоснование: воспроизвести потерю прокрутки у пользователя, кликающего по
видимой карточке, не удалось ни в e2e, ни вручную. Цена ошибки: если дефект есть у реальных пользователей (например, видимой карточке, не удалось ни в e2e, ни вручную. Цена ошибки: если дефект есть у реальных пользователей (например,
в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup размонтирования. в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup `useLayoutEffect` (до снятия DOM).

View File

@ -10,7 +10,7 @@ dev-зависимости, неаккуратный React-код, слабые
- M-3: при возврате с карточки задачи на доску восстанавливается прокрутка; держать в памяти снимок из файла и строку - M-3: при возврате с карточки задачи на доску восстанавливается прокрутка; держать в памяти снимок из файла и строку
состояния не нужно — данные идут из хаба, файл — запасной путь (эта часть M-3 — rejected); состояния не нужно — данные идут из хаба, файл — запасной путь (эта часть M-3 — rejected);
- шрифты — те же IBM Plex Sans и Mono, но из сборки, без обращения в интернет; - шрифты — те же IBM Plex Sans и Mono, но из сборки, без обращения в интернет;
- M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется. - M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется (показано владельцу в сводке понимания до утверждения разделов, возражений не было).
## What Changes ## What Changes

View File

@ -73,7 +73,7 @@ SHALL проверять агента (`GET /agent`; офлайн — преду
`POST /refresh {full: true, author: "viewer"}`, опрашивать `GET /batches/{id}` каждые 3 с одним опросом до `POST /refresh {full: true, author: "viewer"}`, опрашивать `GET /batches/{id}` каждые 3 с одним опросом до
терминального статуса пакета и затем перечитывать `GET /board`. Кнопка «Обновить из Jira» SHALL быть недоступна, терминального статуса пакета и затем перечитывать `GET /board`. Кнопка «Обновить из Jira» SHALL быть недоступна,
пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет `refresh` с автором `viewer` SHALL пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет `refresh` с автором `viewer` SHALL
подхватываться при каждой успешной загрузке из хаба; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок подхватываться при каждой успешной загрузке из хаба, кроме перечитывания снимка после завершения собственного пакета; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок
«хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Шапка доски SHALL показывать «хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Шапка доски SHALL показывать
источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет
выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка

View File

@ -200,7 +200,7 @@ def h_app(hub, conn, m, q, body):
try: try:
f = (hub.web_dir / rest).resolve() f = (hub.web_dir / rest).resolve()
except (OSError, ValueError): except (OSError, ValueError):
raise ApiError(404, "not_found", "/app/" + rest) raise ApiError(404, "not_found", "/app/" + rest) from None
if not f.is_relative_to(assets) or not f.is_file(): if not f.is_relative_to(assets) or not f.is_file():
raise ApiError(404, "not_found", "/app/" + rest) raise ApiError(404, "not_found", "/app/" + rest)
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE) return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)

View File

@ -14,6 +14,8 @@ export default defineConfig({
command: "npm run build && bash scripts/test-hub.sh", command: "npm run build && bash scripts/test-hub.sh",
url: `${HUB_URL}/app/`, url: `${HUB_URL}/app/`,
reuseExistingServer: false, reuseExistingServer: false,
stdout: "pipe",
stderr: "pipe",
timeout: 120_000, timeout: 120_000,
env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) }, env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) },
}, },

View File

@ -5,7 +5,7 @@ set -euo pipefail
WEB=$(cd "$(dirname "$0")/.." && pwd) WEB=$(cd "$(dirname "$0")/.." && pwd)
ROOT=$(cd "$WEB/.." && pwd) ROOT=$(cd "$WEB/.." && pwd)
export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}" 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 case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac
# чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем # чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем
case "$JIRA_HUB_HOME" in case "$JIRA_HUB_HOME" in

View File

@ -0,0 +1,37 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { qk, useIssues, useSaveNote } from "./queries.ts";
import type { IssueDetail } from "./types.ts";
const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ key: `A-${i}` }));
describe("запросы", () => {
beforeEach(() => setToken("tok"));
it("getNextPageParam: 50 → offset 50, 17 → undefined", async () => {
const seen: (string | null)[] = [];
mockFetch((_m, url) => { seen.push(url.searchParams.get("offset")); return json(200, { issues: rows(seen.length === 1 ? 50 : 17) }); });
const client = new QueryClient();
const { result } = renderHook(() => useIssues({}), { wrapper: wrap(client) });
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
await result.current.fetchNextPage();
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
expect(seen).toEqual(["0", "50"]);
});
it("useSaveNote(A-1): ответ пишется в карточку A-1", async () => {
const note = { issue_key: "A-1", comment: "x", updated_by: "web" };
mockFetch(() => json(200, note));
const client = new QueryClient();
client.setQueryData(qk.issue("A-1"), { key: "A-1", note: null } as unknown as IssueDetail);
const { result } = renderHook(() => useSaveNote("A-1"), { wrapper: wrap(client) });
await result.current.mutateAsync({} as never);
expect(client.getQueryData<IssueDetail>(qk.issue("A-1"))?.note).toEqual(note);
});
});

View File

@ -1,7 +1,7 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { AuthError, dropTokenOn } from "./client.ts"; import { AuthError, dropTokenOn } from "./client.ts";
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл» // 401 на запрос с текущим токеном — экран входа (запоздавший 401 прежнего токена игнорируется, R-3)
export function makeQueryClient(): QueryClient { export function makeQueryClient(): QueryClient {
const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); }; const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); };
return new QueryClient({ return new QueryClient({

View File

@ -9,6 +9,8 @@ import { clearToken, getAuth, setToken } from "./token.ts";
const app = <AuthGate><p>страница приложения</p></AuthGate>; const app = <AuthGate><p>страница приложения</p></AuthGate>;
function Page() { useBoard(); return <p>страница приложения</p>; }
describe("вход", () => { describe("вход", () => {
beforeEach(() => clearToken()); beforeEach(() => clearToken());
@ -60,7 +62,6 @@ describe("вход", () => {
it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => { it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => {
setToken("stale"); setToken("stale");
mockFetch(() => json(401, { error: "unauthorized" })); mockFetch(() => json(401, { error: "unauthorized" }));
function Page() { useBoard(); return <p>страница приложения</p>; }
renderWithProviders(<AuthGate><Page /></AuthGate>); renderWithProviders(<AuthGate><Page /></AuthGate>);
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
@ -72,7 +73,6 @@ describe("вход", () => {
let release!: (r: Response) => void; let release!: (r: Response) => void;
const slow = new Promise<Response>((r) => { release = r; }); const slow = new Promise<Response>((r) => { release = r; });
mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {})));
function Page() { useBoard(); return <p>страница приложения</p>; }
const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>); const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
act(() => setToken("new-token")); act(() => setToken("new-token"));
release(json(401, { error: "unauthorized" })); release(json(401, { error: "unauthorized" }));

View File

@ -51,4 +51,15 @@ describe("выборка = links.html@3931a88", () => {
check([], golden.facets.rememberedAll); check([], golden.facets.rememberedAll);
check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome); 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);
});
}); });

View File

@ -35,11 +35,13 @@ export function sprintBox(model: BoardModel): Box {
if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" }; if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" };
return { kind: "multi" }; return { kind: "multi" };
} }
// список исполнителей имеет смысл, если есть хотя бы один назначенный (единственный пустой ключ — «все без исполнителя»)
const isMultiWho = (names: string[]) => names.length > 0 && !(names.length === 1 && names[0] === "");
// то же для исполнителей // то же для исполнителей
export function whoBox(model: BoardModel, f: Filters): Box { export function whoBox(model: BoardModel, f: Filters): Box {
if (!model.nodes.length) return { kind: "none" }; if (!model.nodes.length) return { kind: "none" };
const names = Object.keys(whoItems(model, f).total); 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" }; return { kind: "multi" };
} }
@ -59,6 +61,6 @@ export function initialWho(model: BoardModel, f: Filters, remembered: unknown):
if (!model.nodes.length) return new Set(); if (!model.nodes.length) return new Set();
const { total } = whoItems(model, f); const { total } = whoItems(model, f);
const names = Object.keys(total); const names = Object.keys(total);
if (!names.length || (names.length === 1 && names[0] === "")) return new Set(); if (!isMultiWho(names)) return new Set();
return new Set(strList(remembered).filter((w) => total[w] !== undefined)); return new Set(strList(remembered).filter((w) => total[w] !== undefined));
} }

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; 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 type { BoardSnapshot } from "../../api/types.ts";
import { renderWithProviders } from "../../test/render.tsx"; import { renderWithProviders } from "../../test/render.tsx";
import { computeVisible } from "../analysis/visible.ts"; import { computeVisible } from "../analysis/visible.ts";
@ -129,6 +129,8 @@ describe("BoardCanvas", () => {
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", p.vis.lanes.length - 1); expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", p.vis.lanes.length - 1);
}); });
afterEach(() => { Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); });
it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => { it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => {
const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
const first = renderWithProviders(<BoardCanvas {...baseProps()} />); const first = renderWithProviders(<BoardCanvas {...baseProps()} />);
@ -138,8 +140,6 @@ describe("BoardCanvas", () => {
first.unmount(); first.unmount();
renderWithProviders(<BoardCanvas {...baseProps()} />); renderWithProviders(<BoardCanvas {...baseProps()} />);
expect(to).toHaveBeenCalledWith(0, 640); expect(to).toHaveBeenCalledWith(0, 640);
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
vi.restoreAllMocks();
}); });
it("M-3: запоздавшее scroll после ухода (прокрутка зажата в 0) не затирает запомненное", () => { it("M-3: запоздавшее scroll после ухода (прокрутка зажата в 0) не затирает запомненное", () => {
@ -157,6 +157,5 @@ describe("BoardCanvas", () => {
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
handlers.forEach((h) => h(new Event("scroll"))); // событие, дошедшее до слушателя уже после снятия доски handlers.forEach((h) => h(new Event("scroll"))); // событие, дошедшее до слушателя уже после снятия доски
expect(readBoardScroll()).toMatchObject({ y: 640 }); expect(readBoardScroll()).toMatchObject({ y: 640 });
vi.restoreAllMocks();
}); });
}); });

View File

@ -13,7 +13,7 @@ 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); }; const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
return ( return (
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => ( <Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
<button type="button" className="btn dd-btn" aria-haspopup="dialog" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}> <button type="button" className="btn dd-btn" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
<span className="lbl">{label}</span><span className="car">▼</span> <span className="lbl">{label}</span><span className="car">▼</span>
</button> </button>
)}> )}>

View File

@ -192,6 +192,7 @@ describe("useBoardSource", () => {
expect(result.current.source).toBe("hub"); expect(result.current.source).toBe("hub");
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" }); expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
}); });
it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => { it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
hub({ "GET /batches/:id": () => batch("running") }); hub({ "GET /batches/:id": () => batch("running") });
const { result } = hook(); const { result } = hook();
@ -213,6 +214,16 @@ describe("useBoardSource", () => {
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }); expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
}); });
it("«Обновить из Jira» при показанном CSV: хаб недоступен — «на доске снимок из файла»", async () => {
hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
act(() => { result.current.loadFile("jira-export.csv", CSV); });
expect(result.current.source).toBe("file");
await act(() => result.current.refreshFromJira());
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
});
it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => { it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => {
let polls = 0, down = false; let polls = 0, down = false;
hub({ hub({
@ -252,7 +263,8 @@ describe("useBoardSource", () => {
expect(result.current.refreshing).toBe(false); expect(result.current.refreshing).toBe(false);
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
}); });
it("T2.1: 401 на запуске пакета с текущим токеном — токен сброшен", async () => {
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) }); hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
const { result } = hook(); const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
@ -260,7 +272,7 @@ describe("useBoardSource", () => {
expect(getToken()).toBe(""); expect(getToken()).toBe("");
}); });
it("T2.1: запоздавший 401 прежнему токену не сбрасывает новый токен", async () => { it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
const slow = deferred<Response>(); const slow = deferred<Response>();
hub({ "GET /agent": () => slow.promise }); hub({ "GET /agent": () => slow.promise });
const { result } = hook(); const { result } = hook();

View File

@ -154,7 +154,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
setActive({ id: b.id, agentOffline: !agent.online }); setActive({ id: b.id, agentOffline: !agent.online });
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false }); setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false });
} catch (e) { } catch (e) {
report(e, !!board.data); const fromFile = sourceRef.current.kind === "file";
report(e, !!board.data || fromFile, fromFile);
} finally { } finally {
startingRef.current = false; startingRef.current = false;
setStarting(false); setStarting(false);

View File

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
import { setToken } from "../auth/token.ts"; import { setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json"; import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts"; import { json, mockFetch } from "../test/fetchMock.ts";
@ -118,18 +119,20 @@ describe("список задач", () => {
expect(sel).toHaveValue("AWPCA_Спринт 0"); expect(sel).toHaveValue("AWPCA_Спринт 0");
}); });
it("T5.1: значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => { it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => {
const board = deferred<Response>(); const board = deferred<Response>();
mockFetch((_m, url) => { mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings); if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return board.promise; if (url.pathname === "/board") return board.promise;
return json(200, { issues: [row(1)] }); return json(200, { issues: [row(1)] });
}); });
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); const { client } = renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" }); await screen.findByRole("link", { name: "AURORA-9001" });
const sel = await screen.findByLabelText("Спринт"); const sel = await screen.findByLabelText("Спринт");
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
expect(client.getQueryData(qk.board)).toBeUndefined();
board.resolve(json(200, SNAP)); board.resolve(json(200, SNAP));
await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // снимок реально применён
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument()); await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument());
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
expect(sel).toHaveValue("AWPCA_Спринт 2"); expect(sel).toHaveValue("AWPCA_Спринт 2");

View File

@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
// сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) // сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5)
const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts
const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"];
export default defineConfig({ export default defineConfig({