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:
parent
d377a57b53
commit
bb3ddd1770
@ -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.
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 показывать
|
||||||
источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет
|
источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет
|
||||||
выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка
|
выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
37
web/src/api/queries.test.ts
Normal file
37
web/src/api/queries.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { setToken } from "../auth/token.ts";
|
||||||
|
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||||
|
import { qk, useIssues, useSaveNote } from "./queries.ts";
|
||||||
|
import type { IssueDetail } from "./types.ts";
|
||||||
|
|
||||||
|
const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) =>
|
||||||
|
createElement(QueryClientProvider, { client }, children);
|
||||||
|
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ key: `A-${i}` }));
|
||||||
|
|
||||||
|
describe("запросы", () => {
|
||||||
|
beforeEach(() => setToken("tok"));
|
||||||
|
|
||||||
|
it("getNextPageParam: 50 → offset 50, 17 → undefined", async () => {
|
||||||
|
const seen: (string | null)[] = [];
|
||||||
|
mockFetch((_m, url) => { seen.push(url.searchParams.get("offset")); return json(200, { issues: rows(seen.length === 1 ? 50 : 17) }); });
|
||||||
|
const client = new QueryClient();
|
||||||
|
const { result } = renderHook(() => useIssues({}), { wrapper: wrap(client) });
|
||||||
|
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||||
|
await result.current.fetchNextPage();
|
||||||
|
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||||
|
expect(seen).toEqual(["0", "50"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useSaveNote(A-1): ответ пишется в карточку A-1", async () => {
|
||||||
|
const note = { issue_key: "A-1", comment: "x", updated_by: "web" };
|
||||||
|
mockFetch(() => json(200, note));
|
||||||
|
const client = new QueryClient();
|
||||||
|
client.setQueryData(qk.issue("A-1"), { key: "A-1", note: null } as unknown as IssueDetail);
|
||||||
|
const { result } = renderHook(() => useSaveNote("A-1"), { wrapper: wrap(client) });
|
||||||
|
await result.current.mutateAsync({} as never);
|
||||||
|
expect(client.getQueryData<IssueDetail>(qk.issue("A-1"))?.note).toEqual(note);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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" }));
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user