test(web): e2e широкой доски и фильтров, сид WIDE_E2E, тесты AgileGate (#112, T9.4, T12.2–T12.4)

Также: шеврон свёртки секции «Бэклог» — свёрнуто вниз, развёрнуто вверх (T4.5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 21:04:57 +04:00
parent 52645c74da
commit f21dea39b6
4 changed files with 180 additions and 18 deletions

View File

@ -2,18 +2,23 @@ import { expect, test } from "@playwright/test";
import { login } from "./hub.ts"; import { login } from "./hub.ts";
// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27) // экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27)
const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" }); type Page = import("@playwright/test").Page;
const side = (page: Page) => page.getByRole("complementary", { name: "Панель приложения" });
// строка списка / карточка колонки по ключу задачи (ссылка с точным именем), без привязки к порядку в DOM
const itemOf = (page: Page, key: string) => page.getByRole("listitem").filter({ has: page.getByRole("link", { name: key, exact: true }) });
const colOf = (page: Page, name: string) => page.getByRole("region", { name, exact: true });
test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => { test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => {
await login(page, "/app/jira/backlog?project=AWPCA"); await login(page, "/app/jira/backlog?project=AWPCA");
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible(); await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible();
const past = page.getByText("Прошлые спринты (1)"); const past = page.getByText("Прошлые спринты (1)", { exact: true });
await expect(past).toBeVisible(); await expect(past).toBeVisible();
await past.click(); await past.click();
await page.getByText("AWPCA_Спринт 1").first().click(); const pastSprint = page.locator("details.agile-past__sprint").filter({ has: page.locator("summary", { hasText: "AWPCA_Спринт 1" }) });
const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last(); await pastSprint.locator("summary").click();
const row = pastSprint.getByRole("listitem").filter({ has: page.getByRole("link", { name: "AURORA-9001", exact: true }) });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.click(); await row.click();
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
@ -28,9 +33,9 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible(); await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible();
for (const name of ["К выполнению", "В работе", "Готово"]) { for (const name of ["К выполнению", "В работе", "Готово"]) {
await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); await expect(page.getByRole("heading", { name, exact: true, level: 3 })).toBeVisible();
} }
await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click(); await itemOf(page, "AURORA-9001").click();
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
@ -39,19 +44,19 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => { test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => {
await login(page, "/app/jira/kanban?project=KANBAN_E2E"); await login(page, "/app/jira/kanban?project=KANBAN_E2E");
await expect(page.getByText("Бэклог (1)")).toBeVisible(); await expect(page.getByText("Бэклог (1)", { exact: true })).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible(); await expect(colOf(page, "В работе")).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible(); await expect(colOf(page, "Готово")).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0); await expect(colOf(page, "Бэклог")).toHaveCount(0);
await expect(page.getByText("за 14 дней")).toBeVisible(); await expect(page.getByText("за 14 дней", { exact: true })).toBeVisible();
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible(); await expect(colOf(page, "В работе").getByRole("link", { name: "AURORA-9102", exact: true })).toBeVisible();
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible(); await expect(colOf(page, "Готово").getByRole("link", { name: "AURORA-9103", exact: true })).toBeVisible();
}); });
test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => { test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => {
await login(page, "/app/jira/backlog?project=KANBAN_E2E"); await login(page, "/app/jira/backlog?project=KANBAN_E2E");
await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible(); await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible();
await page.getByRole("link", { name: "Связи", exact: true }).last().click(); await page.getByRole("main").getByRole("link", { name: "Связи", exact: true }).click();
await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/); await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/);
}); });
@ -72,10 +77,98 @@ test("900 px: колонки «Спринта» прокручиваются в
await expect(page.locator(".agile-col").first()).toBeVisible(); await expect(page.locator(".agile-col").first()).toBeVisible();
const m = await page.evaluate(() => { const m = await page.evaluate(() => {
const d = document.documentElement, c = document.querySelector<HTMLElement>(".agile-columns")!; const d = document.documentElement, c = document.querySelector<HTMLElement>(".agile-columns")!;
return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], bottom: c.getBoundingClientRect().bottom,
gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left }; gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left };
}); });
expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0); expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0);
expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]); expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]);
expect(m.cols[0]).toBeGreaterThan(m.cols[1]); expect(m.cols[0]).toBeGreaterThan(m.cols[1]);
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(900);
});
test("«Широкая доска» 2560×1300: колонки одной высоты, доска в окне, горизонтальная прокрутка внутри, «Без колонки» достижима", async ({ page }) => {
await page.setViewportSize({ width: 2560, height: 1300 });
await login(page, "/app/jira/sprint?project=WIDE_E2E");
await expect(colOf(page, "Колонка 1")).toBeVisible();
await expect(colOf(page, "Без колонки")).toHaveCount(1);
const m = await page.evaluate(() => {
const board = document.querySelector<HTMLElement>(".agile-columns")!;
const heights = [...document.querySelectorAll<HTMLElement>(".agile-col")].map((c) => c.getBoundingClientRect().height);
return { heights, bottom: board.getBoundingClientRect().bottom, inner: window.innerHeight, width: board.getBoundingClientRect().width,
sw: board.scrollWidth, cw: board.clientWidth, count: heights.length };
});
expect(m.count, "11 колонок и «Без колонки»").toBe(12);
expect(Math.max(...m.heights) - Math.min(...m.heights), "высоты колонок равны").toBeLessThanOrEqual(1);
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(m.inner);
expect(m.sw, "доска прокручивается по горизонтали").toBeGreaterThan(m.cw);
expect(m.width, "доска шире 1440 px (нет max-width)").toBeGreaterThan(1440);
await page.evaluate(() => { const b = document.querySelector<HTMLElement>(".agile-columns")!; b.scrollLeft = b.scrollWidth; });
const extra = colOf(page, "Без колонки");
await expect(extra).toBeInViewport({ ratio: 1 });
const doc = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]);
expect(doc[0], "страница без горизонтальной прокрутки").toBeLessThanOrEqual(doc[1]);
});
test("узкий контейнер: на 900 px вместо панели «Эпики» — выбор «Эпик», на 1600 px — панель и кнопка", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page, "/app/jira/backlog?project=AWPCA");
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeHidden();
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeHidden();
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeVisible();
await page.setViewportSize({ width: 1600, height: 900 });
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeVisible();
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeHidden();
});
test("«Бэклог» AWPCA: панель «Эпики», выбор эпика оставляет только его задачи, свёртка секции переживает перезагрузку", async ({ page }) => {
await login(page, "/app/jira/backlog?project=AWPCA");
const panel = page.getByRole("complementary", { name: "Эпики" });
await expect(panel).toBeVisible();
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
await panel.getByRole("button", { name: /AURORA-9100/ }).click();
await expect(itemOf(page, "AURORA-9001")).toBeVisible();
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
await expect(itemOf(page, "AURORA-9008")).toHaveCount(0);
await panel.getByRole("button", { name: "Все задачи" }).click();
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
const toggle = page.getByRole("button", { name: "AWPCA_Спринт 3" });
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
await page.reload();
await expect(page.getByRole("button", { name: "AWPCA_Спринт 3" })).toHaveAttribute("aria-expanded", "false");
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
});
test("«Спринт» AWPCA: выбор двух спринтов пишет sprint=102,103 в адрес, скрытая колонка «Готово» переживает перезагрузку", async ({ page }) => {
await login(page, "/app/jira/sprint?project=AWPCA");
await expect(colOf(page, "Готово")).toBeVisible();
await page.getByRole("button", { name: "AWPCA_Спринт 2" }).click();
await page.getByRole("checkbox", { name: /AWPCA_Спринт 3/ }).click();
await expect(page).toHaveURL(/sprint=102(,|%2C)103/);
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Колонки" }).click();
await page.getByRole("checkbox", { name: "Готово", exact: true }).click();
await expect(colOf(page, "Готово")).toHaveCount(0);
await page.reload();
await expect(colOf(page, "В работе")).toBeVisible();
await expect(colOf(page, "Готово")).toHaveCount(0);
});
test("390×800: страница доски без горизонтальной прокрутки документа, полоса прокрутки доски в окне", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await login(page, "/app/jira/sprint?project=WIDE_E2E");
await expect(colOf(page, "Колонка 1")).toBeVisible();
const m = await page.evaluate(() => {
const d = document.documentElement, b = document.querySelector<HTMLElement>(".agile-columns")!;
return { doc: [d.scrollWidth, d.clientWidth], bottom: b.getBoundingClientRect().bottom, inner: window.innerHeight, sw: b.scrollWidth, cw: b.clientWidth };
});
expect(m.doc[0], JSON.stringify(m)).toBeLessThanOrEqual(m.doc[1]);
// нижний край доски на 390×800 (находка T8.1, замер): аннотация, а не утверждение — см. отчёт Task 8
test.info().annotations.push({ type: "замер 390×800", description: JSON.stringify(m) });
expect(m.sw).toBeGreaterThan(m.cw);
}); });

View File

@ -36,9 +36,10 @@ for n, i in enumerate(issues, 1):
i["story_points"] = float(n % 5 + 1) i["story_points"] = float(n % 5 + 1)
today = date.today() today = date.today()
iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00" iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00"
now_iso = iso(today, "12:00") # фиксированная дата «Готово» — вчера в полдень: «за 14 дней» от «сегодня 12:00» не зависит (T12.2)
done_iso = iso(today - timedelta(days=1), "12:00")
issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний", issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний",
"labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})} "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": done_iso} if st == "Готово" else {})}
for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)] for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)]
jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120) jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120)
# WIDE_E2E: широкая scrum-доска (11 колонок, 40 задач в одной, статус вне колонок) — воспроизводит баг прокрутки (#112, T10.3) # WIDE_E2E: широкая scrum-доска (11 колонок, 40 задач в одной, статус вне колонок) — воспроизводит баг прокрутки (#112, T10.3)

View File

@ -0,0 +1,68 @@
import { 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 type { AgileResponse } from "../api/types.ts";
const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null };
const data: AgileResponse = {
board: { id: 4216, type: "scrum", columns: [{ name: "Новая", statuses: ["Новая"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
sprints: [], backlog: [], board_issues: [],
};
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const base = (method: string, pathname: string, projects: unknown[]) => {
const k = `${method} ${pathname}`;
if (k === "GET /projects") return json(200, { projects });
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: [] });
return null;
};
describe("AgileGate (T9.4)", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("пока /agile не ответил — «Загрузка…»", async () => {
mockFetch((method, url) => {
if (`${method} ${url.pathname}` === "GET /agile") return new Promise<Response>(() => {});
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
});
open("/app/jira/sprint?project=AWPCA");
expect(await screen.findByText("Загрузка…")).toBeInTheDocument();
});
it("ошибка /agile — текст ошибки и «Повторить», щелчок повторяет запрос", async () => {
let calls = 0;
mockFetch((method, url) => {
if (`${method} ${url.pathname}` === "GET /agile") { calls += 1; return calls === 1 ? json(500, { error: "hub_down" }) : json(200, data); }
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
});
open("/app/jira/sprint?project=AWPCA");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(screen.queryByText("Загрузка…")).not.toBeInTheDocument();
await userEvent.click(retry);
await waitFor(() => expect(calls).toBe(2));
await waitFor(() => expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument());
});
it("меню kanban-проекта: «Kanban» есть, «Бэклог» и «Спринт» нет", async () => {
mockFetch((method, url) => base(method, url.pathname, [K]) ?? json(200, { ...data, board: { ...data.board, type: "kanban" }, sprints: [], backlog: null }));
open("/app/jira/kanban?project=KAN");
const menu = await screen.findByRole("group", { name: "Jira" });
expect(await within(menu).findByRole("link", { name: "Kanban" })).toBeInTheDocument();
expect(within(menu).queryByRole("link", { name: "Бэклог" })).not.toBeInTheDocument();
expect(within(menu).queryByRole("link", { name: "Спринт" })).not.toBeInTheDocument();
});
});

View File

@ -29,7 +29,7 @@ function Toggle({ titleId, bodyId, collapsed, onToggle, children }: {
<h2 id={titleId} className="agile-section__title"> <h2 id={titleId} className="agile-section__title">
<button type="button" className="agile-section__toggle" aria-expanded={!collapsed} aria-controls={bodyId} onClick={onToggle}> <button type="button" className="agile-section__toggle" aria-expanded={!collapsed} aria-controls={bodyId} onClick={onToggle}>
{children} {children}
{collapsed ? <IconChevronUp /> : <IconChevronDown />} {collapsed ? <IconChevronDown /> : <IconChevronUp />}
</button> </button>
</h2> </h2>
); );