fix(web): запоздавшее scroll после ухода с доски не затирает запомненную прокрутку; e2e M-3 на реальном щелчке (M-3, #29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 02:03:25 +04:00
parent 8d31fdf111
commit d54596776e
3 changed files with 31 additions and 5 deletions

View File

@ -64,10 +64,16 @@ test("M-3: прокрутка доски после возврата из кар
await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(100); expect(y).toBeGreaterThan(100);
// событие прокрутки приходит на следующем кадре: без ожидания страница уходит раньше, чем доска запомнит позицию // реальный щелчок по ⓘ карточки, видимой в окне после прокрутки (иначе Playwright сам сдвинет страницу)
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); const key = await page.evaluate(() => {
// щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом const vh = window.innerHeight;
await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click()); const a = [...document.querySelectorAll<HTMLElement>(".board .card[data-key] a.open-card")]
.find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
});
expect(key).not.toBeNull();
const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
await link.click();
await expect(page).toHaveURL(/\/app\/issues\//); await expect(page).toHaveURL(/\/app\/issues\//);
await page.getByRole("link", { name: "Доска", exact: true }).click(); await page.getByRole("link", { name: "Доска", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);

View File

@ -141,4 +141,22 @@ describe("BoardCanvas", () => {
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("M-3: запоздавшее scroll после ухода (прокрутка зажата в 0) не затирает запомненное", () => {
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
const handlers: EventListener[] = [];
const add = window.addEventListener.bind(window);
vi.spyOn(window, "addEventListener").mockImplementation((t: string, h: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => {
if (t === "scroll") handlers.push(h as EventListener);
add(t, h, o);
});
const r = renderWithProviders(<BoardCanvas {...baseProps()} />);
Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
fireEvent.scroll(window);
r.unmount();
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
handlers.forEach((h) => h(new Event("scroll"))); // событие, дошедшее до слушателя уже после снятия доски
expect(readBoardScroll()).toMatchObject({ y: 640 });
vi.restoreAllMocks();
});
}); });

View File

@ -146,7 +146,9 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
// под короткую страницу карточки // под короткую страницу карточки
useEffect(() => { useEffect(() => {
const w = wrapRef.current; const w = wrapRef.current;
const save = () => saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 }); // после ухода со страницы браузер зажимает прокрутку короткой страницы в 0 и шлёт scroll: доску уже убрали из DOM —
// такое событие не должно затирать запомненное
const save = () => { if (w && !w.isConnected) return; saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 }); };
window.addEventListener("scroll", save, { passive: true }); window.addEventListener("scroll", save, { passive: true });
w?.addEventListener("scroll", save, { passive: true }); w?.addEventListener("scroll", save, { passive: true });
return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); }; return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };