fix(web): строка пакета переживает «Перечитать», снимок из файла при недоступном хабе, подхват только refresh (T10.1, T10.3–T10.6, #29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab3b05007a
commit
841ce4ea45
@ -12,7 +12,8 @@ describe("statusText", () => {
|
||||
expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
|
||||
expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
|
||||
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
|
||||
expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла");
|
||||
expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
|
||||
.toBe("файл не разобран: в файле нет строк с задачами");
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ export type BoardStatus =
|
||||
| { kind: "batch-failed"; id: number; error: string }
|
||||
| { kind: "batch-cancelled"; id: number }
|
||||
| { kind: "batch-lost"; id: number; reason: string }
|
||||
| { kind: "hub-down"; hasData: boolean }
|
||||
| { kind: "hub-down"; hasData: boolean; fromFile: boolean }
|
||||
| { kind: "file-error"; fileName: string; reason: string }
|
||||
| { kind: "json-applied"; fileName: string }
|
||||
| { kind: "error"; message: string };
|
||||
@ -28,7 +28,8 @@ export function statusText(s: BoardStatus): { text: string; warn: boolean } {
|
||||
case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
|
||||
case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
|
||||
case "hub-down": return {
|
||||
text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||||
text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
|
||||
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||||
warn: true,
|
||||
};
|
||||
case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };
|
||||
|
||||
@ -4,8 +4,9 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { getToken, setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { dictsFromSettings } from "./dicts.ts";
|
||||
import settings from "./__fixtures__/settings.json";
|
||||
@ -15,9 +16,8 @@ const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures_
|
||||
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 hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }),
|
||||
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
|
||||
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 }; };
|
||||
|
||||
@ -60,13 +60,12 @@ describe("useBoardSource", () => {
|
||||
|
||||
it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
|
||||
const slow = deferred<Response>();
|
||||
const h = hub({ "GET /board": () => slow.promise });
|
||||
const { result } = hook();
|
||||
hub({ "GET /board": () => slow.promise });
|
||||
const { result, client } = 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);
|
||||
await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу
|
||||
expect(result.current.source).toBe("file");
|
||||
expect(result.current.label).toBe("jira-export.csv (из файла)");
|
||||
act(() => result.current.reload());
|
||||
@ -86,6 +85,7 @@ describe("useBoardSource", () => {
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
|
||||
expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается
|
||||
});
|
||||
|
||||
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
|
||||
@ -169,7 +169,7 @@ describe("useBoardSource", () => {
|
||||
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 }));
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: false }));
|
||||
expect(result.current.model.nodes.length).toBe(10);
|
||||
});
|
||||
|
||||
@ -192,4 +192,84 @@ describe("useBoardSource", () => {
|
||||
expect(result.current.source).toBe("hub");
|
||||
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
|
||||
});
|
||||
it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
|
||||
hub({ "GET /batches/:id": () => batch("running") });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
});
|
||||
|
||||
it("T10.4: JSON-снимок без данных хаба — «на доске снимок из файла»", async () => {
|
||||
hub({ "GET /board": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "hub-down", fromFile: false }));
|
||||
act(() => { result.current.loadFile("old-board.json", fx("old-board.json")); });
|
||||
expect(result.current.source).toBe("file");
|
||||
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
|
||||
});
|
||||
|
||||
it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => {
|
||||
let polls = 0, down = false;
|
||||
hub({
|
||||
"GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); },
|
||||
"GET /batches/:id": () => { if (++polls >= 3) down = true; return polls < 3 ? batch("running") : batch("done"); },
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }));
|
||||
expect(result.current.source).toBe("file");
|
||||
});
|
||||
|
||||
it("уход со страницы и возврат во время пакета — пакет подхвачен снова, один опрос (Review Focus 2)", async () => {
|
||||
let listed: unknown[] = [];
|
||||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||||
const first = hook();
|
||||
await waitFor(() => expect(first.result.current.model.nodes.length).toBe(10));
|
||||
await act(() => first.result.current.refreshFromJira());
|
||||
listed = [{ id: 7, author: "viewer", status: "running" }];
|
||||
first.unmount();
|
||||
const second = hook(first.client);
|
||||
await waitFor(() => expect(second.result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
expect(second.result.current.refreshing).toBe(true);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
});
|
||||
|
||||
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
|
||||
const h = hub({
|
||||
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
|
||||
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
|
||||
await sleep(50);
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
|
||||
});
|
||||
it("T2.1: 401 на запуске пакета с текущим токеном — токен сброшен", async () => {
|
||||
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
expect(getToken()).toBe("");
|
||||
});
|
||||
|
||||
it("T2.1: запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
|
||||
const slow = deferred<Response>();
|
||||
hub({ "GET /agent": () => slow.promise });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
let run!: Promise<void>;
|
||||
act(() => { run = result.current.refreshFromJira(); });
|
||||
setToken("new-tok");
|
||||
slow.resolve(json(401, { error: "unauthorized" }));
|
||||
await act(() => run);
|
||||
expect(getToken()).toBe("new-tok");
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,27 +42,43 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
|
||||
const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]);
|
||||
|
||||
const report = useCallback((e: unknown, hasData: boolean) => {
|
||||
// свежие значения для эффектов, которые не должны перезапускаться от их смены
|
||||
const sourceRef = useRef(source);
|
||||
useEffect(() => { sourceRef.current = source; }, [source]);
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
const skipPickup = useRef(false); // T10.1: снимок, перечитанный после своего пакета, — пакет уже завершён
|
||||
|
||||
const report = useCallback((e: unknown, hasData: boolean, fromFile = false) => {
|
||||
if (e instanceof AuthError) dropTokenOn(e);
|
||||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData });
|
||||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData, fromFile });
|
||||
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||||
}, []);
|
||||
|
||||
// хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими)
|
||||
useEffect(() => {
|
||||
if (board.error && !(board.error instanceof AuthError)) report(board.error, !!board.data);
|
||||
if (!board.error || board.error instanceof AuthError) return;
|
||||
skipPickup.current = false;
|
||||
report(board.error, !!board.data, sourceRef.current.kind === "file");
|
||||
}, [board.error, board.errorUpdatedAt, board.data, report]);
|
||||
|
||||
// M-3: после каждой успешной загрузки снимка — подхватить незавершённый пакет viewer
|
||||
// M-3 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer
|
||||
useEffect(() => {
|
||||
if (!board.dataUpdatedAt || active) return;
|
||||
if (!board.dataUpdatedAt) return;
|
||||
if (skipPickup.current) { skipPickup.current = false; return; }
|
||||
if (activeRef.current) return;
|
||||
let gone = false;
|
||||
api.get<{ batches: Batch[] }>("/batches", { limit: 20 }).then((r) => {
|
||||
(async () => {
|
||||
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
|
||||
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||||
if (b && !gone) { failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); }
|
||||
}).catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||||
if (!b || gone) return;
|
||||
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
|
||||
if (gone || full.commands?.[0]?.type !== "refresh") return;
|
||||
failures.current = 0;
|
||||
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
|
||||
})().catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||||
return () => { gone = true; };
|
||||
}, [board.dataUpdatedAt, active]);
|
||||
}, [board.dataUpdatedAt]);
|
||||
|
||||
// M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд
|
||||
const batch = useQuery({
|
||||
@ -94,6 +110,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
failures.current = 0;
|
||||
setActive(null);
|
||||
qc.removeQueries({ queryKey: ["batch", id] });
|
||||
skipPickup.current = true;
|
||||
void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1)
|
||||
}, [qc]);
|
||||
|
||||
@ -146,7 +163,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setSource({ kind: "hub" });
|
||||
setEvent(null);
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
void qc.refetchQueries({ queryKey: qk.board });
|
||||
}, [qc]);
|
||||
|
||||
@ -156,7 +173,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
try {
|
||||
if (!isJson) {
|
||||
setSource({ kind: "file", model: fromCsv(text, fileName, dicts) });
|
||||
setEvent(null);
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
return null;
|
||||
}
|
||||
const r = fromJson(text, fileName, dicts);
|
||||
@ -166,7 +183,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
|
||||
void qc.refetchQueries({ queryKey: qk.board });
|
||||
} else { // хаб не ответил — снимок из файла
|
||||
setSource({ kind: "file", model: r.model });
|
||||
setEvent({ kind: "hub-down", hasData: true });
|
||||
setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true });
|
||||
}
|
||||
return r;
|
||||
} catch (e) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user