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( Full Name , ); 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( , ); const textarea = screen.getByRole("textbox", { name: "Bio description", }); textarea.focus(); // Simulate touching during animation or right as question is rendered fireEvent.touchStart(textarea, { touches: [{ clientY: 400 }], target: textarea, }); fireEvent.touchEnd(textarea, { changedTouches: [{ clientY: 390 }], target: textarea, }); expect(textarea).toHaveFocus(); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); it("does not step question on option button/label with micro-jitter (clean tap)", () => { const onActiveIndexChange = vi.fn(); render( Select Option Next Step , ); const button = screen.getByRole("button", { name: "Select Option" }); // Small jitter (delta 8px < INTERACTIVE_DRAG_SLOP 16px) fireEvent.touchStart(button, { touches: [{ clientX: 100, clientY: 250 }], target: button, }); fireEvent.touchMove(button, { touches: [{ clientX: 100, clientY: 242 }], target: button, }); fireEvent.touchEnd(button, { changedTouches: [{ clientX: 100, clientY: 242 }], target: button, }); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); it("allows intentional vertical drag starting on a label/button to swipe questions (soft ownership)", () => { const onActiveIndexChange = vi.fn(); render( Option 1 Option 2 , ); const label = screen.getByText("Option 1"); // Intentional drag (startY: 300, endY: 200 -> deltaY = 100 > slop) fireEvent.touchStart(label, { touches: [{ clientX: 100, clientY: 300 }], target: label, }); fireEvent.touchMove(label, { touches: [{ clientX: 100, clientY: 200 }], target: label, }); fireEvent.touchEnd(label, { changedTouches: [{ clientX: 100, clientY: 200 }], target: label, }); expect(onActiveIndexChange).toHaveBeenCalledWith(1); }); it("suppresses trailing click on option after vertical drag swipe", () => { const onClick = vi.fn(); render( Option Button Slide 2 , ); const button = screen.getByRole("button", { name: "Option Button" }); const region = screen.getByRole("region", { name: "Questions" }); // Swipe drag starting on button fireEvent.touchStart(button, { touches: [{ clientX: 100, clientY: 300 }], target: button, }); fireEvent.touchMove(button, { touches: [{ clientX: 100, clientY: 200 }], target: button, }); fireEvent.touchEnd(button, { changedTouches: [{ clientX: 100, clientY: 200 }], target: button, }); // Synthetic click dispatched after drag fireEvent.click(button); expect(onClick).not.toHaveBeenCalled(); }); it("allows intentional vertical swipe on non-interactive question background", () => { const onActiveIndexChange = vi.fn(); render( Slide 1 Slide 2 , ); const region = screen.getByRole("region", { name: "Questions" }); // Fast upward flick on background area fireEvent.touchStart(region, { touches: [{ clientX: 100, clientY: 400 }], target: region, }); fireEvent.touchEnd(region, { changedTouches: [{ clientX: 100, clientY: 320 }], target: region, }); expect(onActiveIndexChange).toHaveBeenCalledWith(1); }); it("does not swipe or blur when touching and dragging inside a text input", () => { const onActiveIndexChange = vi.fn(); render( , ); const input = screen.getByRole("textbox", { name: "First Name" }); input.focus(); // Large drag on input (e.g. user selecting text or scrolling within text input) fireEvent.touchStart(input, { touches: [{ clientX: 100, clientY: 400 }], target: input, }); fireEvent.touchMove(input, { touches: [{ clientX: 100, clientY: 200 }], target: input, }); fireEvent.touchEnd(input, { changedTouches: [{ clientX: 100, clientY: 200 }], target: input, }); expect(input).toHaveFocus(); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); it("does not swipe on hard-ignored controls like range sliders", () => { const onActiveIndexChange = vi.fn(); render( Slide 2 , ); const slider = screen.getByRole("slider", { name: "Volume slider" }); fireEvent.touchStart(slider, { touches: [{ clientX: 100, clientY: 300 }], target: slider, }); fireEvent.touchMove(slider, { touches: [{ clientX: 100, clientY: 200 }], target: slider, }); fireEvent.touchEnd(slider, { changedTouches: [{ clientX: 100, clientY: 200 }], target: slider, }); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); }); describe("Periodic scroll hint idle cycle", () => { it("follows 2s idle -> 2s visible -> 2s hidden -> repeat cycle and resets on user interaction", () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const { container } = render( Scroll icon} > Question 1 Question 2 , ); const hintWrapper = container.querySelector(".motion-safe\\:animate-bounce"); expect(hintWrapper).not.toBeNull(); // Initially hidden (waiting 2s) expect(hintWrapper).toHaveClass("opacity-0"); expect(hintWrapper).not.toHaveClass("opacity-100"); // Advance 1s: still hidden act(() => { vi.advanceTimersByTime(1000); }); expect(hintWrapper).toHaveClass("opacity-0"); // Advance another 1s (total 2s idle): now visible act(() => { vi.advanceTimersByTime(1000); }); expect(hintWrapper).toHaveClass("opacity-100"); // Advance 2s while visible (total 4s): becomes hidden act(() => { vi.advanceTimersByTime(2000); }); expect(hintWrapper).toHaveClass("opacity-0"); // Advance 2s while hidden (total 6s): becomes visible again (cycle repeat) act(() => { vi.advanceTimersByTime(2000); }); expect(hintWrapper).toHaveClass("opacity-100"); // User interacts (touchstart): immediately hides and restarts 2s idle timer const region = screen.getByRole("region", { name: "Questions" }); act(() => { fireEvent.touchStart(region); }); expect(hintWrapper).toHaveClass("opacity-0"); // Advance 1.5s after touch: still hidden act(() => { vi.advanceTimersByTime(1500); }); expect(hintWrapper).toHaveClass("opacity-0"); // Advance another 500ms (total 2s after touch): becomes visible again act(() => { vi.advanceTimersByTime(500); }); expect(hintWrapper).toHaveClass("opacity-100"); vi.useRealTimers(); }); }); describe("Overflowing inline-options vs non-overflowing/sheet questions", () => { it("allows inner scrolling for overflowing radio questions before snapping to next question", () => { const onActiveIndexChange = vi.fn(); const { container } = render( Option 1 Option 2 Option 3 Option 4 Question 2 , ); const contentEl = container.querySelector(".question-snap-content"); expect(contentEl).not.toBeNull(); if (contentEl) { // Mock overflowing height: scrollHeight 600px > clientHeight 400px (maxScroll = 200px) Object.defineProperty(contentEl, "scrollHeight", { value: 600, configurable: true }); Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true }); contentEl.scrollTop = 0; } const region = screen.getByRole("region", { name: "Questions" }); // Drag up while at top (scrollTop = 0 < maxScroll = 200): should NOT flip question fireEvent.touchStart(region, { touches: [{ clientX: 100, clientY: 300 }], }); fireEvent.touchMove(region, { touches: [{ clientX: 100, clientY: 200 }], }); fireEvent.touchEnd(region, { changedTouches: [{ clientX: 100, clientY: 200 }], }); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); // Now simulate user having scrolled to bottom of options (scrollTop = 200) if (contentEl) { contentEl.scrollTop = 200; } // Drag up at the bottom: now it SHOULD snap to next question fireEvent.touchStart(region, { touches: [{ clientX: 100, clientY: 300 }], }); fireEvent.touchMove(region, { touches: [{ clientX: 100, clientY: 150 }], }); fireEvent.touchEnd(region, { changedTouches: [{ clientX: 100, clientY: 150 }], }); expect(onActiveIndexChange).toHaveBeenCalledWith(1); }); it("immediately snaps for non-overflowing questions (e.g. dropdown or short question)", () => { const onActiveIndexChange = vi.fn(); const { container } = render( Dropdown Trigger Question 2 , ); const contentEl = container.querySelector(".question-snap-content"); if (contentEl) { // Fits in screen: scrollHeight 300 <= clientHeight 400 Object.defineProperty(contentEl, "scrollHeight", { value: 300, configurable: true }); Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true }); } const region = screen.getByRole("region", { name: "Questions" }); fireEvent.touchStart(region, { touches: [{ clientX: 100, clientY: 300 }], }); fireEvent.touchMove(region, { touches: [{ clientX: 100, clientY: 150 }], }); fireEvent.touchEnd(region, { changedTouches: [{ clientX: 100, clientY: 150 }], }); expect(onActiveIndexChange).toHaveBeenCalledWith(1); }); }); });