sdlc-hub/web/src/modules/jira/feed/FeedPage.test.tsx
Andrey Epifancev 2cb454af8b refactor(web): модуль jira и оболочка из списка модулей (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:02 +04:00

188 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 { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createMemoryRouter } from "react-router";
import { focusManager } from "@tanstack/react-query";
import { act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../../../App.tsx";
import type { ChangeEvent } from "../../../api/types.ts";
import { setToken } from "../../../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { routes } from "../../../routes.tsx";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { issue } from "../../../test/issueFixtures.ts";
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 };
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status",
author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over,
});
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
function hub(changes: () => Changes = () => ({ changes: [] })) {
const calls: URL[] = [];
mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (k === "GET /changes") {
calls.push(url);
const r = changes();
return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r });
}
if (k === "GET /projects") return json(200, { projects: [A, B] });
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null });
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
return json(404, { error: "not_found" });
});
return calls;
}
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const page = () => screen.findByTestId("page-feed");
describe("лента изменений", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); });
it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 9, 22, 10, 0));
const calls = hub();
open("/app/feed?project=AWPCA");
await page();
await waitFor(() => expect(calls.length).toBe(1));
expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime());
vi.setSystemTime(new Date(2026, 9, 22, 11, 30));
act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await waitFor(() => expect(calls.length).toBe(2));
expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime());
expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime());
});
it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => {
const calls = hub(() => ({ changes: [
ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }),
ev({ at: minutesAgo(40), kind: "description", field: "description" }),
] }));
open("/app/feed?project=AWPCA");
await page();
const groups = await screen.findAllByTestId("feed-group");
expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]);
expect(within(groups[0]).getByText("API свода")).toBeInTheDocument();
expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument();
expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument();
expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument();
const q = calls[0].searchParams;
expect(q.get("project")).toBe("AWPCA");
expect(q.get("kinds")).toBe("status,description,dates,comment");
const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime());
expect(q.get("limit")).toBe("2000");
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText(/3 события/)).toBeInTheDocument();
});
it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
const calls = hub();
const router = open("/app/feed?project=AWPCA");
await page();
await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status"));
expect(router.state.location.pathname).toBe("/app/feed");
await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
});
it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
hub(() => ({ changes: [ev({})] }));
const router = open("/app/feed?project=AWPCA&period=7d");
await page();
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
const d = await screen.findByRole("dialog");
expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument();
expect(screen.getByTestId("page-feed")).toBeInTheDocument();
await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
});
it("M-1: /app/feed без project — проект дописывается в адрес", async () => {
hub();
const router = open("/app/feed");
await page();
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
});
it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => {
hub(() => ({ truncated: true, changes: [ev({ author: null })] }));
open("/app/feed?project=AWPCA");
const notice = await screen.findByText("показаны последние 1 событий — выберите период короче");
const group = screen.getByTestId("feed-group");
expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument();
expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0);
});
it("пустой период, truncated, ошибка с «Повторить»", async () => {
let mode: "empty" | "truncated" | "error" = "empty";
hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" })
: mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] });
open("/app/feed?project=AWPCA");
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
mode = "truncated";
await userEvent.click(screen.getByRole("radio", { name: "Вчера" }));
expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
mode = "error";
await userEvent.click(screen.getByRole("radio", { name: "7 дней" }));
const retry = await screen.findByRole("button", { name: "Повторить" });
mode = "empty";
await userEvent.click(retry);
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
});
it("все флажки сняты — подсказка, запроса нет", async () => {
const calls = hub();
open("/app/feed?project=AWPCA&kinds=status");
await page();
await waitFor(() => expect(calls.length).toBe(1));
await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" }));
expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument();
expect(calls.length).toBe(1);
});
it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => {
const calls = hub();
open("/app/feed?project=AWPCA&period=foo&kinds=status,foo");
await page();
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
expect(calls[0].searchParams.get("kinds")).toBe("status");
expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked();
});
it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => {
const calls = hub();
const router = open("/app/feed?project=AWPCA&period=2h&kinds=status");
await page();
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS");
await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS"));
expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status");
expect(router.state.location.pathname).toBe("/app/feed");
expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status");
});
});