import { act, cleanup, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QuestionSnapList } from "./question-snap-list"; import { computeQuestionLift, updateQuestionKeyboardHeight, } from "./question-viewport-coordinator"; vi.mock("@/translations/provider", () => ({ useI18n: () => ({ dictionary: {} }), })); describe("QuestionSnapList keyboard interaction", () => { afterEach(() => { cleanup(); document.body.classList.remove("question-keyboard-open"); document.documentElement.style.removeProperty("--question-lift-y"); }); it("computes a stable occlusion lift without overshoot", () => { expect( computeQuestionLift({ baseTop: 300, baseBottom: 600, visibleTop: 100, visibleBottom: 500, }), ).toBe(150); expect( computeQuestionLift({ baseTop: 300, baseBottom: 600, visibleTop: 100, visibleBottom: 800, }), ).toBe(0); }); it("does not treat pointer contact as input focus", () => { render( , ); fireEvent.pointerDown( screen.getByRole("textbox", { name: "Question one" }), ); expect(document.body).not.toHaveClass("question-keyboard-open"); }); it("activates only on real focus and resets before changing questions", async () => { render( , ); const firstInput = screen.getByRole("textbox", { name: "Question one" }); const secondInput = document.querySelector( 'input[aria-label="Question two"]', ); const content = firstInput.closest(".question-snap-content"); vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ top: 300, bottom: 600, left: 0, right: 300, width: 300, height: 300, x: 0, y: 300, toJSON: () => ({}), }); firstInput.focus(); updateQuestionKeyboardHeight(300); await waitFor(() => { expect(document.body).toHaveClass("question-keyboard-open"); }); fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), { deltaY: 100, }); await waitFor(() => { expect(document.body).not.toHaveClass("question-keyboard-open"); expect(firstInput).not.toHaveFocus(); expect(secondInput).not.toHaveFocus(); expect( document.documentElement.style.getPropertyValue("--question-lift-y"), ).toBe("0px"); }); }); it("does not keep question keyboard state for a dialog input", async () => { render( <>
, ); const questionInput = screen.getByRole("textbox", { name: "Question input", }); const content = questionInput.closest( ".question-snap-content", ); vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ top: 300, bottom: 600, left: 0, right: 300, width: 300, height: 300, x: 0, y: 300, toJSON: () => ({}), }); questionInput.focus(); updateQuestionKeyboardHeight(300); await waitFor(() => { expect(document.body).toHaveClass("question-keyboard-open"); }); screen.getByRole("textbox", { name: "Dialog search" }).focus(); await waitFor(() => { expect(document.body).not.toHaveClass("question-keyboard-open"); }); }); it("raises again when Flutter reopens the keyboard without another focusin", async () => { render( , ); const input = screen.getByRole("textbox", { name: "Persistent input" }); const content = input.closest(".question-snap-content"); vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({ top: 300, bottom: 600, left: 0, right: 300, width: 300, height: 300, x: 0, y: 300, toJSON: () => ({}), }); input.focus(); updateQuestionKeyboardHeight(300); await waitFor(() => expect(document.body).toHaveClass("question-keyboard-open"), ); updateQuestionKeyboardHeight(0); await waitFor(() => { expect(document.body).not.toHaveClass("question-keyboard-open"); expect(input).toHaveFocus(); }); updateQuestionKeyboardHeight(300); await waitFor(() => { expect(document.body).toHaveClass("question-keyboard-open"); expect(input).toHaveFocus(); }); }); describe("touch isolation and first-tap focus reliability", () => { it("does not step question or blur active input when touch has jitter on input", () => { const onActiveIndexChange = vi.fn(); render(
, ); const input = screen.getByRole("textbox", { name: "Full Name" }); input.focus(); expect(input).toHaveFocus(); // Finger touches input with 12px jitter/drift (typical coarse touch contact area shift) fireEvent.touchStart(input, { touches: [{ clientY: 300 }], target: input, }); fireEvent.touchMove(input, { touches: [{ clientY: 288 }], target: input, }); fireEvent.touchEnd(input, { changedTouches: [{ clientY: 288 }], target: input, }); // Input should remain focused, and active question must remain index 0 expect(input).toHaveFocus(); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); it("does not engage drag or blur input when touched while snap animation is settling", () => { const onActiveIndexChange = vi.fn(); render(