sdlc-hub/web/src/board/useBoardSource.test.tsx

196 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { makeQueryClient } from "../api/queryClient.ts";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { dictsFromSettings } from "./dicts.ts";
import settings from "./__fixtures__/settings.json";
import { useBoardSource } from "./useBoardSource.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
const SNAP = JSON.parse(fx("hub-basic.board.json"));
const CSV = fx("jira-export.csv");
const { dicts } = dictsFromSettings(settings);
const wrapper = ({ children }: { children: ReactNode }) =>
<QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
const calls: string[] = [];
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
const fetch = mockFetch(async (method, url, body) => {
const k = `${method} ${url.pathname}`;
calls.push(k);
inFlight[k] = (inFlight[k] ?? 0) + 1;
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
try {
const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
if (h) return await h(url, body);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
return json(404, { error: "not_found" });
} finally { inFlight[k] -= 1; }
});
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
}
const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }) =>
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
describe("useBoardSource", () => {
beforeEach(() => setToken("tok"));
it("строит доску из снимка хаба и подписывает источник", async () => {
hub();
const { result } = hook();
expect(result.current.status).toEqual({ kind: "loading" });
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
expect(result.current.source).toBe("hub");
expect(result.current.label).toMatch(/^Хаб · данные на /);
expect(result.current.status).toBeNull();
});
it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
const slow = deferred<Response>();
const h = hub({ "GET /board": () => slow.promise });
const { result } = hook();
act(() => { result.current.loadFile("jira-export.csv", CSV); });
expect(result.current.source).toBe("file");
slow.resolve(json(200, SNAP));
await waitFor(() => expect(h.count("GET /board")).toBe(1));
await sleep(20);
expect(result.current.source).toBe("file");
expect(result.current.label).toBe("jira-export.csv (из файла)");
act(() => result.current.reload());
await waitFor(() => expect(result.current.source).toBe("hub"));
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
});
it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); });
expect(h.count("POST /refresh")).toBe(1);
expect(result.current.refreshing).toBe(true);
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
expect(result.current.refreshing).toBe(false);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
});
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
let listed: unknown[] = [];
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
const { result } = hook();
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
expect(result.current.refreshing).toBe(false);
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
act(() => result.current.reload());
await waitFor(() => expect(result.current.refreshing).toBe(true));
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
});
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
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-lost", id: 7 }));
expect(result.current.refreshing).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
for (const status of [400, 422]) {
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, refreshing сброшен`, async () => {
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
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-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
expect(result.current.refreshing).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
}
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
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-lost", reason: "хаб не отвечает" }));
expect(h.count("GET /batches/7")).toBe(5);
await sleep(50);
expect(h.count("GET /batches/7")).toBe(5);
expect(result.current.refreshing).toBe(false);
});
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => {
let polls = 0;
hub({
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
});
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", agentOffline: true }));
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
});
it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => {
let polls = 0;
hub({ "GET /batches/:id": () => (++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: "batch-done", id: 7 }));
expect(result.current.source).toBe("file");
});
it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => {
let down = false;
hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
down = true;
act(() => result.current.reload());
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true }));
expect(result.current.model.nodes.length).toBe(10);
});
it("плохой файл — file-error, прежняя доска на месте", async () => {
hub();
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); });
expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" });
expect(result.current.source).toBe("hub");
});
it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => {
hub();
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
let r: unknown = null;
act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); });
expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] });
expect(result.current.source).toBe("hub");
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
});
});