238 lines
9.6 KiB
TypeScript
238 lines
9.6 KiB
TypeScript
import { useState } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { MockInstance } from "vitest";
|
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { useTaskTitleSelectionRestore } from "./use-task-title-selection-restore";
|
|
|
|
const LONG = "T".repeat(60);
|
|
|
|
type TitleInput = HTMLInputElement | HTMLTextAreaElement;
|
|
|
|
function Harness({ initial }: { initial: string }) {
|
|
const [value, setValue] = useState(initial);
|
|
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
data-testid="title"
|
|
value={value}
|
|
onChange={(e) => setValue(clampChange(e))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function HarnessTextarea({ initial }: { initial: string }) {
|
|
const [value, setValue] = useState(initial);
|
|
const { inputRef, clampChange } = useTaskTitleSelectionRestore<HTMLTextAreaElement>(value);
|
|
return (
|
|
<textarea
|
|
ref={inputRef}
|
|
data-testid="title"
|
|
value={value}
|
|
onChange={(e) => setValue(clampChange(e))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ControlledHarness({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
data-testid="title"
|
|
value={value}
|
|
onChange={(e) => onChange(clampChange(e))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Simulate typing: write the DOM value through the prototype setter (React's
|
|
* instance value tracker would otherwise swallow the change), place the caret,
|
|
* then dispatch the change event React maps to onChange.
|
|
*/
|
|
function simulateInsert(
|
|
input: TitleInput,
|
|
value: string,
|
|
caret: number,
|
|
setSelectionRange: MockInstance,
|
|
) {
|
|
const setNativeValue = Object.getOwnPropertyDescriptor(
|
|
input.constructor.prototype,
|
|
"value",
|
|
)!.set!;
|
|
setNativeValue.call(input, value);
|
|
input.setSelectionRange(caret, caret);
|
|
setSelectionRange.mockClear();
|
|
fireEvent.change(input);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("useTaskTitleSelectionRestore - commit-path caret restore", () => {
|
|
it("clamps the value to 60 code points on change", () => {
|
|
render(<Harness initial={LONG.slice(0, 59)} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
simulateInsert(input, LONG, 60, vi.spyOn(HTMLInputElement.prototype, "setSelectionRange"));
|
|
expect(input.value).toHaveLength(60);
|
|
});
|
|
|
|
it.each([
|
|
[0, 2],
|
|
[6, 8],
|
|
[58, 60],
|
|
])("pins the caret after inserting mid-title at the cap (insert at %i)", (caret, expected) => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
render(<Harness initial={LONG} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
// Simulate the DOM right after typing "XY" at `caret`: 62 code points,
|
|
// caret after the insert.
|
|
simulateInsert(
|
|
input,
|
|
`${LONG.slice(0, caret)}XY${LONG.slice(caret)}`,
|
|
caret + 2,
|
|
setSelectionRange,
|
|
);
|
|
|
|
expect(input.value).toHaveLength(60);
|
|
expect(input.value.slice(caret, caret + 2)).toBe("XY");
|
|
// The caret must be re-pinned after React rewrites the truncated value.
|
|
expect(setSelectionRange).toHaveBeenCalledWith(expected, expected);
|
|
});
|
|
|
|
it("leaves the caret alone when the clamp does not truncate", () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
render(<Harness initial={LONG.slice(0, 30)} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
simulateInsert(input, `${"T".repeat(30)}XY`, 32, setSelectionRange);
|
|
expect(input.value).toHaveLength(32);
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips the restore when the input is not focused", () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
render(<Harness initial={LONG} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not replay a stale selection from an unfocused truncating change", () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
render(<Harness initial={LONG} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
// Truncating change while the input is not focused: records then discards.
|
|
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
|
// A later focused, non-truncating change must not restore the old caret.
|
|
input.focus();
|
|
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6, 58)}`, 8, setSelectionRange);
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useTaskTitleSelectionRestore - bail-out caret restore", () => {
|
|
it("restores the caret when a truncating keystroke leaves the clamped value unchanged", async () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
render(<Harness initial={LONG} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
// Typing the same character into an all-same-char title at the cap: the
|
|
// clamped value equals the committed value, so React bails out of the
|
|
// render (no layout effect) but still restores the controlled DOM value
|
|
// after the event, resetting the caret to the end. The hook must re-pin
|
|
// it after that write.
|
|
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
|
await act(async () => {});
|
|
expect(input.value).toHaveLength(60);
|
|
expect(setSelectionRange).toHaveBeenCalledWith(6, 6);
|
|
});
|
|
|
|
it("does not replay the caret on a later commit after a same-result bail-out", async () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
const onChange = vi.fn();
|
|
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
// Typing the same character at the cap: the clamped value equals the
|
|
// committed value, so the render bails out.
|
|
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
|
expect(onChange).toHaveBeenCalledWith(LONG);
|
|
await act(async () => {});
|
|
// The same-key restore happens exactly once, via the immediate path.
|
|
expect(setSelectionRange).toHaveBeenCalledTimes(1);
|
|
setSelectionRange.mockClear();
|
|
// An unrelated value update must not replay the caret.
|
|
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not apply a pending commit-path caret to an unrelated value commit", async () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
const onChange = vi.fn();
|
|
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
// A truncating change records the caret for the commit path...
|
|
const next = `${LONG.slice(0, 6)}XY${LONG.slice(6, 58)}`;
|
|
simulateInsert(input, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
|
expect(onChange).toHaveBeenCalledWith(next);
|
|
// ...but the parent rejects it (no state update), and an unrelated value
|
|
// commit arrives later: the stale caret must not be applied to it.
|
|
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
|
await act(async () => {});
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not restore a stale caret when the value changes in the same turn as a bail-out", async () => {
|
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, "setSelectionRange");
|
|
const onChange = vi.fn();
|
|
const { rerender } = render(<ControlledHarness value={LONG} onChange={onChange} />);
|
|
const input = screen.getByTestId("title") as HTMLInputElement;
|
|
input.focus();
|
|
// Schedules the bail-out restore for the old value...
|
|
simulateInsert(input, `${LONG}T`, 6, setSelectionRange);
|
|
// ...but the controlled value changes synchronously before the microtask
|
|
// runs, so the restore must be superseded.
|
|
rerender(<ControlledHarness value={"U".repeat(60)} onChange={onChange} />);
|
|
await act(async () => {});
|
|
expect(setSelectionRange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useTaskTitleSelectionRestore - textarea path", () => {
|
|
it("pins the caret after a truncating change in a textarea", () => {
|
|
const setSelectionRange = vi.spyOn(HTMLTextAreaElement.prototype, "setSelectionRange");
|
|
render(<HarnessTextarea initial={LONG} />);
|
|
const textarea = screen.getByTestId("title") as HTMLTextAreaElement;
|
|
textarea.focus();
|
|
simulateInsert(textarea, `${LONG.slice(0, 6)}XY${LONG.slice(6)}`, 8, setSelectionRange);
|
|
|
|
expect(textarea.value).toHaveLength(60);
|
|
expect(textarea.value.slice(6, 8)).toBe("XY");
|
|
expect(setSelectionRange).toHaveBeenCalledWith(8, 8);
|
|
});
|
|
|
|
it("restores the caret on a same-result bail-out in a textarea", async () => {
|
|
const setSelectionRange = vi.spyOn(HTMLTextAreaElement.prototype, "setSelectionRange");
|
|
render(<HarnessTextarea initial={LONG} />);
|
|
const textarea = screen.getByTestId("title") as HTMLTextAreaElement;
|
|
textarea.focus();
|
|
simulateInsert(textarea, `${LONG}T`, 6, setSelectionRange);
|
|
await act(async () => {});
|
|
expect(textarea.value).toHaveLength(60);
|
|
expect(setSelectionRange).toHaveBeenCalledWith(6, 6);
|
|
});
|
|
});
|