import { describe, expect, it, vi } from "vitest"; import { useState } from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { UnitNumberInput, type UnitNumberInputProps, } from "./unit-number-input"; import type { UnitSystem } from "@/lib/units"; import { usePointer } from "@/test/pointer"; // What only a render reaches: that a diver typing whole feet gets those same whole // feet back after the box has been through metres and a ``. // `lib/units.test.ts` proves the arithmetic; this proves the wiring around it - the // draft state, the two cleared sentinels, and the converted native bounds. // Controlled, like the react-hook-form fields this stands in for: `onChange` is // what moves the value, so a component that fails to emit shows up as a box that // will not type. function Harness({ onCommit, initial = null, ...props }: Omit & { onCommit?: (value: number | null | "") => void; initial?: number | null | ""; }) { const [value, setValue] = useState(initial); return ( { setValue(next); onCommit?.(next); }} /> ); } const box = () => screen.getByLabelText("measurement") as HTMLInputElement; describe("UnitNumberInput in imperial", () => { it("shows a stored metric value in whole imperial units", () => { render( , // ); expect(box().value).toBe("100"); }); it("commits the metres behind what was typed", async () => { const onCommit = vi.fn(); render( , // ); await userEvent.type(box(), "100"); expect(onCommit).toHaveBeenLastCalledWith(30.48); }); // The round trip that the whole entry design rests on: 98 ft is 29.8704 m, which // is committed as 29.87 and reads back as the 98 ft it came from. it("reads back what the diver typed, after a blur reformats it", async () => { render(); await userEvent.type(box(), "98"); await userEvent.tab(); expect(box().value).toBe("98"); }); it("commits whole metres for an integer dimension", async () => { const onCommit = vi.fn(); render( , ); await userEvent.type(box(), "1220"); await userEvent.tab(); expect(onCommit).toHaveBeenLastCalledWith(372); expect(box().value).toBe("1220"); }); // The accepted loss, pinned here as well as in `units.test.ts` so it is visible // where a diver would meet it: an `Integer` column cannot hold the metres 50 ft // is, and 15 m reads back as 49 ft. it("loses a foot of visibility to the integer column", async () => { const onCommit = vi.fn(); render( , ); await userEvent.type(box(), "50"); await userEvent.tab(); expect(onCommit).toHaveBeenLastCalledWith(15); expect(box().value).toBe("49"); }); it("steps by one and converts its bounds inward", () => { render( , ); expect(box().step).toBe("1"); expect(box().min).toBe("-1476"); expect(box().max).toBe("21325"); expect(box().placeholder).toBe("e.g. 1220"); }); it("accepts an altitude below sea level", async () => { const onCommit = vi.fn(); render( , ); await userEvent.type(box(), "-1411"); await userEvent.tab(); expect(onCommit).toHaveBeenLastCalledWith(-430); expect(box().value).toBe("-1411"); }); // Why the box keeps a draft of what is being typed. Committing on every // keystroke without one, the *converted* value would be written straight back // under the cursor: 50 ft commits 15 m, and 15 m is 49 ft, so the "0" would turn // into a "9" as it was typed. The correction belongs on blur, not mid-word. it("does not reformat under the cursor while typing", async () => { render(); await userEvent.type(box(), "50"); expect(box().value).toBe("50"); await userEvent.tab(); expect(box().value).toBe("49"); }); }); describe("UnitNumberInput in metric", () => { // Metric is never rounded: a value imported at three decimals is legible as the // three decimals it is until the diver edits it. it("shows the stored value untouched", () => { render(); expect(box().value).toBe("18.288"); }); it("shows an exporter's float noise as the number it is", () => { render(); expect(box().value).toBe("21.8"); }); it("shows an integer and an empty value as they are", () => { const { unmount } = render( , ); expect(box().value).toBe("30"); unmount(); render(); expect(box().value).toBe(""); }); // Display only: nothing reaches the form until the diver types, and then it is // what they typed. it("commits nothing for a value it only trimmed", async () => { const onCommit = vi.fn(); render( , ); await userEvent.click(box()); await userEvent.tab(); expect(onCommit).not.toHaveBeenCalled(); }); it("keeps the caller's bounds and placeholder", () => { render( , ); expect(box().min).toBe("-50"); expect(box().max).toBe("50"); expect(box().placeholder).toBe("e.g. 22.5"); }); // `step` is not among them, and that is the point: it is a native *constraint*, // so anything finer than the column behind it makes a stored value unsavable // with no message anywhere. A `Float` column has no such precision to declare. it("leaves a float dimension unconstrained", () => { render(); expect(box().step).toBe("any"); expect(box().validity.stepMismatch).toBe(false); }); // The two `Integer` columns, where whole numbers *are* the contract - the same // answer `isIntegerDimension` gives the parsing and the commit just beside it. it("steps an integer dimension by one", () => { render(); expect(box().step).toBe("1"); }); // The one deliberate change on the metric side, and invisible for anything typed // at the step: entry now rounds to the API's own two decimals, which is what the // temperature box already did and its float siblings didn't. it("rounds entry to two decimals", async () => { const onCommit = vi.fn(); render(); await userEvent.type(box(), "30.526"); expect(onCommit).toHaveBeenLastCalledWith(30.53); }); }); describe("UnitNumberInput's cleared sentinel", () => { // Both spellings are real and in use, and a box that only spoke one of them would // silently change what the other's form submits. it("clears to null by default", async () => { const onCommit = vi.fn(); render( , ); await userEvent.clear(box()); expect(onCommit).toHaveBeenLastCalledWith(null); }); it("clears to the empty string when asked to", async () => { const onCommit = vi.fn(); render( , ); await userEvent.clear(box()); expect(onCommit).toHaveBeenLastCalledWith(""); }); }); describe("UnitNumberInput when the entry units change under it", () => { // The tempting free lunch - "clicking the toggle blurs the box, and blur already // resets the draft" - is false on macOS Safari and Firefox, where clicking a // ` ); } it("re-displays the committed value in the new system", async () => { render(); await userEvent.type(box(), "3000"); expect(box().value).toBe("3000"); // The draft is still live - the box has never been blurred. fireEvent.click(screen.getByText("to metric")); expect(box()).toHaveFocus(); expect(box().value).toBe("206.84"); }); it("parses the next keystroke in the new system", async () => { const onCommit = vi.fn(); render(); await userEvent.type(box(), "3000"); fireEvent.click(screen.getByText("to metric")); // Typed straight on, without clearing first, so what it lands on says which // string the box was holding: "206.84" re-displayed in bar, or the stale // "3000" draft that would make this 30000. await userEvent.type(box(), "0"); // What it committed is the assertion, not what the box reads: a number input // sanitizes the trailing zero back off "206.840" on its own. expect(onCommit).toHaveBeenLastCalledWith(206.84); }); it("leaves the draft alone when the units have not moved", async () => { render( , // ); await userEvent.type(box(), "30.526"); // Still mid-word: the reformat to 30.53 belongs on blur, and a guard that // fired on every render would take it here instead. expect(box().value).toBe("30.526"); }); }); describe("UnitNumberInput under a finger", () => { it("is text on a decimal keypad when it cannot go below zero", () => { usePointer("coarse"); render(); expect(box()).toHaveAttribute("type", "text"); expect(box()).toHaveAttribute("inputmode", "decimal"); }); it("reads a comma as the decimal point", async () => { usePointer("coarse"); const onCommit = vi.fn(); render( , ); await userEvent.type(box(), "18,5"); expect(onCommit).toHaveBeenLastCalledWith(18.5); expect(box().value).toBe("18,5"); }); it("takes whole numbers on a digit keypad for an integer dimension", () => { usePointer("coarse"); render(); expect(box()).toHaveAttribute("inputmode", "numeric"); }); it("stays a number input where a value can be negative", () => { // No iOS keypad has a minus key. usePointer("coarse"); render(); expect(box()).toHaveAttribute("type", "number"); expect(box()).not.toHaveAttribute("inputmode"); }); it("refuses a value outside its bounds, as a number input would", async () => { usePointer("coarse"); render(); await userEvent.type(box(), "301"); expect(box().validationMessage).toBe("Enter 300 or less."); await userEvent.clear(box()); await userEvent.type(box(), "200"); expect(box().validity.valid).toBe(true); }); it("is a number input for a mouse", () => { render(); expect(box()).toHaveAttribute("type", "number"); }); });