5efeae4ece
* feat: allow multiline custom answers in clarification overlay Swap the single-line custom-answer input for an auto-growing textarea so users can type multi-line replies to ask_user_question. Enter submits, Shift+Enter inserts a newline; the box grows to ~6 lines then scrolls. Adds unit and e2e coverage. * fix: address review feedback on multiline clarification input - Always preventDefault on desktop plain Enter so an empty draft can't leak a stray newline before the trim guard (Claude/CodeRabbit). - On coarse-pointer devices let Enter insert a newline and submit via the existing Submit button; hide the keyboard hints on touch (mobile parity). - Preserve newlines in the resolved clarification history message via whitespace-pre-wrap + items-start. - Strengthen e2e to guard against fused lines; add Ctrl+Enter and touch tests. * fix: touch submit button and IME guard for clarification input - Guard against submitting on the Enter that confirms an IME candidate (e.nativeEvent.isComposing) on CJK keyboards. - Add an inline Send button on coarse-pointer devices so single-question custom answers (which have no overlay Submit button) have a touch-reachable send path. Fixes the regression from switching touch Enter to newline. * refactor: extract clarification custom-input controls - Pull the Enter/⇧↵ hints and touch Send button into a CustomInputControls subcomponent to shrink ClarificationCustomInput. - Compute the trimmed draft once and reuse it across the keydown handler and the Send button (disabled state, click, styling) so the conditions can't drift. - Add a test asserting the touch Send button is disabled while isSubmitting. * fix: ignore auto-repeat Enter in clarification custom input A held Enter key emits auto-repeat keydown events; guard the handler with e.repeat so it can't fire onSubmit more than once. Adds a regression test. * fix: suppress newline on repeat Enter and add mobile clarification e2e - Repeat Enter on desktop now calls preventDefault before bailing, so a held key can't leak newlines into the current or next question's textarea after the carousel advances; it still never re-fires onSubmit/finalize. - Add a mobile-chrome (Pixel 5) e2e covering the touch path: Enter inserts a newline and the inline Send button submits the multiline answer. * test: consolidate clarification e2e seed helper Extract the shared seed → navigate → waitForLoad flow into e2e/helpers/clarification.ts and delegate from the clarification, resize, and mobile specs instead of duplicating it in each.
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
import { cleanup, render, fireEvent } from "@testing-library/react";
|
|
import { ClarificationCustomInput } from "./clarification-overlay-parts";
|
|
|
|
// Mutable pointer state so individual tests can flip to a touch device without
|
|
// touching matchMedia internals.
|
|
const { pointer } = vi.hoisted(() => ({ pointer: { isFinePointer: true } }));
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => pointer,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
pointer.isFinePointer = true;
|
|
});
|
|
|
|
const INPUT_TESTID = "clarification-input";
|
|
const TOUCH_SUBMIT_TESTID = "clarification-custom-submit";
|
|
const MULTILINE = "line one\nline two";
|
|
|
|
function makeProps(overrides: Partial<Parameters<typeof ClarificationCustomInput>[0]> = {}) {
|
|
return {
|
|
draft: "",
|
|
isSubmitting: false,
|
|
committedText: null,
|
|
active: false,
|
|
onChange: vi.fn(),
|
|
onSubmit: vi.fn(),
|
|
onRequestFinalSubmit: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// fireEvent.keyDown returns false when a handler called preventDefault.
|
|
function pressEnter(el: HTMLElement, init: Partial<KeyboardEventInit> = {}): boolean {
|
|
return fireEvent.keyDown(el, { key: "Enter", ...init });
|
|
}
|
|
|
|
describe("ClarificationCustomInput multiline", () => {
|
|
it("renders a textarea so answers can span multiple lines", () => {
|
|
const { getByTestId } = render(<ClarificationCustomInput {...makeProps()} />);
|
|
expect(getByTestId(INPUT_TESTID).tagName).toBe("TEXTAREA");
|
|
});
|
|
|
|
it("submits the trimmed draft on plain Enter", () => {
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: " hello ", onSubmit })} />,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
|
|
expect(notDefaulted).toBe(false); // preventDefault fired
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith("hello");
|
|
});
|
|
|
|
it("swallows plain Enter on an empty draft without inserting a stray newline", () => {
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: " ", onSubmit })} />,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
|
|
expect(notDefaulted).toBe(false); // preventDefault fired → no phantom newline
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT submit on Shift+Enter — the newline falls through to the textarea", () => {
|
|
const onSubmit = vi.fn();
|
|
const onRequestFinalSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput
|
|
{...makeProps({ draft: "line one", onSubmit, onRequestFinalSubmit })}
|
|
/>,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { shiftKey: true });
|
|
expect(notDefaulted).toBe(true); // default not prevented → newline inserted
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
expect(onRequestFinalSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves inner newlines when submitting a multi-line draft (trims ends only)", () => {
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: `\n${MULTILINE}\n`, onSubmit })} />,
|
|
);
|
|
pressEnter(getByTestId(INPUT_TESTID));
|
|
expect(onSubmit).toHaveBeenCalledWith(MULTILINE);
|
|
});
|
|
|
|
it("finalizes the bundle on Cmd+Enter without per-question submit", () => {
|
|
const onSubmit = vi.fn();
|
|
const onRequestFinalSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput
|
|
{...makeProps({ draft: "answer", onSubmit, onRequestFinalSubmit })}
|
|
/>,
|
|
);
|
|
pressEnter(getByTestId(INPUT_TESTID), { metaKey: true });
|
|
expect(onRequestFinalSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("finalizes the bundle on Ctrl+Enter without per-question submit", () => {
|
|
const onSubmit = vi.fn();
|
|
const onRequestFinalSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput
|
|
{...makeProps({ draft: "answer", onSubmit, onRequestFinalSubmit })}
|
|
/>,
|
|
);
|
|
pressEnter(getByTestId(INPUT_TESTID), { ctrlKey: true });
|
|
expect(onRequestFinalSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores auto-repeat Enter but still suppresses the newline (no double submit)", () => {
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: "answer", onSubmit })} />,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { repeat: true });
|
|
// preventDefault still fires so a held key can't leak a newline into this or
|
|
// the next question's textarea, but onSubmit does not run again.
|
|
expect(notDefaulted).toBe(false);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not submit while an IME candidate is being composed", () => {
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: "候補", onSubmit })} />,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { isComposing: true });
|
|
expect(notDefaulted).toBe(true); // default not prevented → IME confirms candidate
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("ClarificationCustomInput on touch devices", () => {
|
|
it("Enter inserts a newline instead of submitting", () => {
|
|
pointer.isFinePointer = false;
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: "line one", onSubmit })} />,
|
|
);
|
|
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
|
|
expect(notDefaulted).toBe(true); // default not prevented → newline inserted
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides the keyboard hints on touch devices", () => {
|
|
pointer.isFinePointer = false;
|
|
const { queryByText } = render(<ClarificationCustomInput {...makeProps()} />);
|
|
expect(queryByText("⇧↵ newline")).toBeNull();
|
|
});
|
|
|
|
it("shows a Send button on touch that submits the trimmed draft", () => {
|
|
pointer.isFinePointer = false;
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: `${MULTILINE} `, onSubmit })} />,
|
|
);
|
|
fireEvent.click(getByTestId(TOUCH_SUBMIT_TESTID));
|
|
expect(onSubmit).toHaveBeenCalledWith(MULTILINE);
|
|
});
|
|
|
|
it("disables the touch Send button for an empty draft", () => {
|
|
pointer.isFinePointer = false;
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput {...makeProps({ draft: " ", onSubmit })} />,
|
|
);
|
|
const button = getByTestId(TOUCH_SUBMIT_TESTID) as HTMLButtonElement;
|
|
expect(button.disabled).toBe(true);
|
|
fireEvent.click(button);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disables the touch Send button while a submit is in flight", () => {
|
|
pointer.isFinePointer = false;
|
|
const onSubmit = vi.fn();
|
|
const { getByTestId } = render(
|
|
<ClarificationCustomInput
|
|
{...makeProps({ draft: "answer", isSubmitting: true, onSubmit })}
|
|
/>,
|
|
);
|
|
const button = getByTestId(TOUCH_SUBMIT_TESTID) as HTMLButtonElement;
|
|
expect(button.disabled).toBe(true);
|
|
fireEvent.click(button);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
});
|