334 lines
20 KiB
TypeScript
334 lines
20 KiB
TypeScript
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { createMemoryRouter } from "react-router";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { App } from "../../../App.tsx";
|
||
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";
|
||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||
|
||
const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
|
||
|
||
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
|
||
key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null,
|
||
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
|
||
});
|
||
const iso = (offsetDays: number) => {
|
||
const d = new Date(Date.now() + offsetDays * 86_400_000);
|
||
const p = (n: number) => String(n).padStart(2, "0");
|
||
d.setHours(12, 0, 0, 0); // полдень местного времени и местное смещение: результат не зависит от пояса и часа суток
|
||
const off = -d.getTimezoneOffset(), a = Math.abs(off);
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000${off < 0 ? "-" : "+"}${p(Math.floor(a / 60))}${p(a % 60)}`;
|
||
};
|
||
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
|
||
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3),
|
||
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
|
||
});
|
||
const s102 = (over: Partial<AgileSprint> = {}) => sprint({
|
||
id: 102, name: "AWPCA_Sprint_3", goal: "Закрыть свод", count: 3, count_done: 1, points: 25, points_done: 8,
|
||
issues: [iss("AURORA-1", { story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" }), iss("AURORA-3", { status: "Странный" })], ...over,
|
||
});
|
||
const agile = (sprints: AgileSprint[] = [s102()], over: Partial<AgileResponse> = {}): AgileResponse => ({
|
||
board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
|
||
sprints, backlog: [], board_issues: null, ...over,
|
||
});
|
||
|
||
function hub(data: AgileResponse) {
|
||
mockFetch((method, url) => {
|
||
const k = `${method} ${url.pathname}`;
|
||
if (k === "GET /agile") return json(200, data);
|
||
if (k === "GET /projects") return json(200, { projects: [A] });
|
||
if (k === "GET /settings") return json(200, settings);
|
||
if (k === "GET /issues") return json(200, { issues: [] });
|
||
if (k === "GET /batches") return json(200, { batches: [] });
|
||
if (k === "GET /agents") return json(200, { agents: [] });
|
||
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
|
||
return json(404, { error: "not_found" });
|
||
});
|
||
}
|
||
const open = (path: string) => {
|
||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||
render(<App router={router} />);
|
||
return router;
|
||
};
|
||
const page = () => screen.findByTestId("page-sprint");
|
||
const jiraGroup = () => screen.getByRole("group", { name: "Jira" });
|
||
const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent);
|
||
|
||
describe("«Спринт»", () => {
|
||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||
|
||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => {
|
||
hub(agile());
|
||
open("/app/jira/links?project=AWPCA");
|
||
await within(jiraGroup()).findByRole("link", { name: "Спринт" });
|
||
expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
|
||
});
|
||
|
||
it("колонки доски с числом задач и «Без колонки»", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1", "1"]);
|
||
expect(within(cols[2] as HTMLElement).getByText("Тема AURORA-3")).toBeInTheDocument();
|
||
expect(p.querySelector(".agile-columns")).not.toBeNull();
|
||
const h = within(p).getByRole("heading", { name: "AWPCA_Sprint_3" });
|
||
expect(h.parentElement).toHaveTextContent("Закрыть свод");
|
||
expect(h.parentElement).toHaveTextContent("1 из 3 задач");
|
||
expect(h.parentElement).toHaveTextContent("8 из 25 SP");
|
||
});
|
||
|
||
it("«осталось 3 дн.» и «срок прошёл»", async () => {
|
||
hub(agile([s102({ end_date: iso(3) })]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
expect(await within(await page()).findByText("осталось 3 дн.")).toBeInTheDocument();
|
||
});
|
||
|
||
it("срок прошёл", async () => {
|
||
hub(agile([s102({ end_date: iso(-1) })]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
|
||
});
|
||
|
||
const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 });
|
||
const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 });
|
||
const picker = (p: HTMLElement) => p.querySelector<HTMLButtonElement>("button[aria-haspopup]")!;
|
||
const sprintGroup = () => screen.getByRole("group", { name: "Спринты" });
|
||
|
||
it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => {
|
||
hub(agile([s102(), s104()]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument();
|
||
expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(
|
||
expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"]));
|
||
expect(picker(p)).toHaveTextContent("Спринты: 2");
|
||
});
|
||
|
||
it("один активный: на кнопке «Спринты» его имя", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3");
|
||
});
|
||
|
||
it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => {
|
||
hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })]));
|
||
const router = open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-5");
|
||
expect(within(p).queryByText("Тема AURORA-6")).toBeNull();
|
||
await userEvent.click(picker(p));
|
||
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ }));
|
||
expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument();
|
||
expect(router.state.location.search).toContain("sprint=101%2C102");
|
||
expect(router.state.location.search).toContain("project=AWPCA");
|
||
expect(router.state.historyAction).toBe("REPLACE");
|
||
expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2);
|
||
expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1);
|
||
});
|
||
|
||
it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => {
|
||
hub(agile([s102(), s103()]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
await userEvent.click(picker(p));
|
||
const g = within(sprintGroup());
|
||
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked();
|
||
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked();
|
||
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled();
|
||
await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
|
||
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled();
|
||
});
|
||
|
||
it("задача в обоих выбранных спринтах — одна карточка", async () => {
|
||
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1);
|
||
});
|
||
|
||
it("?sprint=999 и ?sprint=abc — все активные", async () => {
|
||
hub(agile([s102(), s103()]));
|
||
open("/app/jira/sprint?project=AWPCA&sprint=999");
|
||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||
expect(screen.queryByText("Тема AURORA-88")).toBeNull();
|
||
});
|
||
|
||
it("мусорный ?sprint=abc — все активные", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA&sprint=abc");
|
||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||
});
|
||
|
||
it("?sprint=103 (будущий) — только он", async () => {
|
||
hub(agile([s102(), s103()]));
|
||
open("/app/jira/sprint?project=AWPCA&sprint=103");
|
||
const p = await page();
|
||
expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument();
|
||
expect(within(p).queryByText("Тема AURORA-1")).toBeNull();
|
||
});
|
||
|
||
it("устаревший ?sprint= (закрытый) — все активные", async () => {
|
||
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
|
||
open("/app/jira/sprint?project=AWPCA&sprint=101");
|
||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
|
||
});
|
||
|
||
it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => {
|
||
hub(agile([s103()]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = within(await page());
|
||
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
||
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
|
||
await userEvent.click(picker(await page()));
|
||
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
|
||
expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument();
|
||
expect(p.queryByText("Нет активного спринта")).toBeNull();
|
||
});
|
||
|
||
it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => {
|
||
const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] });
|
||
hub(agile([sp]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-2");
|
||
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov");
|
||
await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull());
|
||
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
|
||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]);
|
||
cleanup();
|
||
hub(agile([sp]));
|
||
open("/app/jira/backlog?project=AWPCA");
|
||
const b = await screen.findByTestId("page-backlog");
|
||
expect(await within(b).findByLabelText("Исполнитель")).toHaveValue("");
|
||
});
|
||
|
||
it("«Скрыть выполненные» убирает выполненные карточки", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-2");
|
||
await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" }));
|
||
expect(within(p).queryByText("Тема AURORA-2")).toBeNull();
|
||
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
|
||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
|
||
});
|
||
|
||
it("заголовки колонок — h3 (T10.3)", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||
});
|
||
|
||
it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
let p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
|
||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||
cleanup();
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||
});
|
||
|
||
it("последнюю видимую колонку снять нельзя", async () => {
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||
const g = within(screen.getByRole("group", { name: "Колонки" }));
|
||
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
|
||
await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
|
||
expect(colNames(p)).toEqual(["К выполнению"]);
|
||
expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
|
||
});
|
||
|
||
it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
|
||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
await within(p).findByText("Тема AURORA-1");
|
||
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||
const btn = within(p).getByRole("button", { name: /^Колонки/ });
|
||
expect(btn).toHaveTextContent(/^Колонки$/);
|
||
await userEvent.click(btn);
|
||
expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
|
||
});
|
||
|
||
it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => {
|
||
hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = await page();
|
||
expect(await within(p).findByText("Тема AURORA-E")).toBeInTheDocument();
|
||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||
expect(cols[0].querySelector(".agile-col__count")!.textContent).toBe("2");
|
||
});
|
||
|
||
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
|
||
hub(agile([sprint({ id: 105, name: "C", state: "closed" })]));
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = within(await page());
|
||
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
||
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
|
||
expect(p.queryByRole("button", { name: /^Колонки/ })).toBeNull(); // M-7: доски нет — меню не нужно
|
||
});
|
||
|
||
it("T7.2: меню «Колонки» строится по задачам без фильтров — «Без колонки» не пропадает из меню вместе с поиском", async () => {
|
||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Без колонки"] } } }));
|
||
hub(agile());
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
const p = within(await page());
|
||
await p.findByText("Тема AURORA-1");
|
||
await userEvent.type(p.getByRole("searchbox", { name: "Поиск по ключу или теме" }), "AURORA-1");
|
||
expect(colNames(await page())).toEqual(["К выполнению", "Готово"]);
|
||
const btn = p.getByRole("button", { name: "Колонки, скрыто 1" });
|
||
await userEvent.click(btn);
|
||
const g = within(screen.getByRole("group", { name: "Колонки" }));
|
||
expect(g.getByRole("checkbox", { name: "Без колонки" })).not.toBeChecked();
|
||
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
|
||
expect(JSON.parse(localStorage.getItem("sdlc-hub-agile")!).AWPCA.sprint.hiddenColumns).toEqual(["Готово", "Без колонки"]);
|
||
});
|
||
|
||
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {
|
||
hub({ board: null, sprints: [], backlog: null, board_issues: null });
|
||
open("/app/jira/sprint?project=AWPCA");
|
||
expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument();
|
||
expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" }).length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("щелчок по карточке открывает окно задачи поверх «Спринта»", async () => {
|
||
hub(agile());
|
||
const router = open("/app/jira/sprint?project=AWPCA");
|
||
const p = within(await page());
|
||
await userEvent.click(await p.findByText("Тема AURORA-1"));
|
||
await screen.findByRole("dialog");
|
||
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1");
|
||
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" }));
|
||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/sprint"));
|
||
});
|
||
});
|