From 162c22566b617663b9d081026fab33a6acd029eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 23:57:42 +0000 Subject: [PATCH] test(fields): drive the type="number" widgets with values a browser can deliver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6765 pinned that happy-dom does not implement the HTML value-sanitization algorithm, so a `fireEvent.change` in this package can put text in `.value` that no browser ever puts there. That file records the divergence deliberately, as one half of a disagreement. Nothing recorded the other half: no test drove these widgets with the values a real browser delivers and asserted the emission. The class had a pinned oracle for a fiction and no oracle at all for the product. Measured for this suite on this base — Chromium 141.0.7390.37 via Playwright 1.62.1, a real `` driven key by key, reading `.value` after every keystroke: typed "12abc" '' 1:'1' 2:'12' a:'12' b:'12' c:'12' typed "1.2.3" '' 1:'1' .:'1' 2:'1.2' .:'1.2' 3:'1.23' typed "0x10" '' 0:'0' x:'0' 1:'01' 0:'010' typed "1e" '' 1:'1' e:'' <- validity.badInput becomes TRUE typed "" '' A browser delivers a SEQUENCE of post-sanitization readings, one per accepted keystroke, not a single string — which is why the new suite drives arrays. It also re-derives the card's quoted "6 of 10" instead of repeating it, extends the class to `NumberField` and `GeolocationField` (which objectui#6765 never measured), and carries a self-guard asserting that every string it drives is one the platform itself reads without `validity.badInput`, so it cannot regress into driving the fiction. No widget source is touched. Part of #6793 --- ...umber-widget-browser-deliverable-oracle.md | 7 + ...erInputWidgets.browserDeliverable.test.tsx | 342 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 .changeset/6793-number-widget-browser-deliverable-oracle.md create mode 100644 packages/fields/src/__tests__/NumberInputWidgets.browserDeliverable.test.tsx diff --git a/.changeset/6793-number-widget-browser-deliverable-oracle.md b/.changeset/6793-number-widget-browser-deliverable-oracle.md new file mode 100644 index 000000000..0b939c573 --- /dev/null +++ b/.changeset/6793-number-widget-browser-deliverable-oracle.md @@ -0,0 +1,7 @@ +--- +--- + +Test-only change: the `type="number"` field widgets (`CurrencyField`, `PercentField`, +`NumberField`, `GeolocationField`) gain a suite that drives them with the keystroke +sequences a real Chromium was measured to deliver, and asserts what each one emits. +No published behaviour changes — the widgets are untouched. diff --git a/packages/fields/src/__tests__/NumberInputWidgets.browserDeliverable.test.tsx b/packages/fields/src/__tests__/NumberInputWidgets.browserDeliverable.test.tsx new file mode 100644 index 000000000..03da94057 --- /dev/null +++ b/packages/fields/src/__tests__/NumberInputWidgets.browserDeliverable.test.tsx @@ -0,0 +1,342 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6793 — drive the `type="number"` widget class with what a browser + * CAN deliver, and assert what the product actually does with it. + * + * ## The gap this closes + * + * `NumberInputWidgets.environmentDivergence.test.tsx` (objectui#6765) pins that + * happy-dom does not implement the HTML value-sanitization algorithm, so a + * `fireEvent.change` in this package can put text in `.value` that no browser + * ever puts there. That file records the divergence deliberately, as ONE HALF + * of a disagreement, and says so. + * + * What nothing recorded is the other half: **no test in this repository drove + * these widgets with the values a real browser delivers and asserted the + * emission.** So the class had a pinned oracle for a fiction and no oracle at + * all for the product. This file is that oracle, and it is deliberately unable + * to become the other thing — every string it drives is asserted to be one the + * platform itself reads without `validity.badInput` (see the last describe). + * + * ## What was MEASURED for this file, on this base + * + * Chromium 141.0.7390.37 via Playwright 1.62.1 + * (`executablePath: '/opt/pw-browsers/chromium'`), a real `` + * on a real page, driven KEY BY KEY with `keyboard.press`, reading `.value` and + * `validity.badInput` after every keystroke: + * + * ``` + * typed "12abc" '' 1:'1' 2:'12' a:'12' b:'12' c:'12' + * typed "1.2.3" '' 1:'1' .:'1' 2:'1.2' .:'1.2' 3:'1.23' + * typed "0x10" '' 0:'0' x:'0' 1:'01' 0:'010' + * typed "1e" '' 1:'1' e:'' <- and validity.badInput becomes TRUE + * typed "" '' + * ``` + * + * ⭐ The load-bearing shape, and the reason this file drives ARRAYS: a browser + * does not deliver one post-sanitization string, it delivers a SEQUENCE of + * them, one per accepted keystroke. `"1e"` is the case that makes the + * difference visible — the box moves `'' -> '1' -> ''`, so React sees a real + * change and the widget is told to clear. Driving the single value `''` into a + * box that already reads `''` is not a change at all and emits nothing, which + * is a different fact about a different user action (see the empty row). + * + * The same keystroke runs pasted (`keyboard.insertText`) land on the identical + * final readings; a programmatic `.value = x` write is a third route and lands + * on `''` for every one of these strings, with `badInput` FALSE — Chromium + * reports that it cannot read the USER's input, and a script write has no user + * input to fail on. That third route is objectui#6780's subject, not this + * card's; its full matrix is in that card's PR. + * + * ## The card's 6-of-10, RE-DERIVED rather than quoted + * + * objectui#6793 relayed "the oracle and the product disagree in 6 of 10 + * measured cases" from objectui#6780 / #6765. Re-derived here by driving each + * case BOTH ways through the real widgets — the fabricated whole string, and + * the measured browser sequence — the count reproduces exactly, and + * `divergence.test` below asserts the six by name so the figure cannot rot into + * a quotation again. + * + * ⚠️ The card's other claim does NOT survive re-derivation, and is corrected + * here rather than repeated: *"every existing unit test for these widgets + * drives a string no browser can deliver."* Measured across every test file in + * this repository that renders `CurrencyField`, `PercentField`, `NumberField` + * or `GeolocationField`, exactly ONE drives such a string — + * `NumberInputWidgets.environmentDivergence.test.tsx`, which exists to record + * that divergence. Every other one already drives `'10'`, `'15'`, `'75'`, + * `'6000'`, `'2'`, `'1234.56'`, `'1234.567'` — all browser-deliverable. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { FieldMetadata } from '@object-ui/types'; +import { CurrencyField } from '../widgets/CurrencyField'; +import { PercentField } from '../widgets/PercentField'; +import { NumberField } from '../widgets/NumberField'; +import { GeolocationField, type GeolocationValue } from '../widgets/GeolocationField'; + +type WidgetName = 'currency' | 'percent' | 'number' | 'geolocation'; + +/** + * What a real Chromium leaves in `.value` after each keystroke, for the four + * strings objectui#6765 measured plus the empty one — reduced to the readings + * that CHANGE, because React only reports a change. + * + * ⚠️ The last element of each array is the same final reading objectui#6765 + * pinned as `BROWSER_READINGS` (`'12'`, `'1.23'`, `'010'`). This table is a + * superset of that list, not a second dialect of it: when objectui#6780's + * `__tests__/numberInputBrowserReadings.ts` lands, this belongs beside those + * numbers in that one module. Kept here for now because that file does not + * exist on `main` and this card must not edit an unmerged sibling's source. + */ +const BROWSER_KEYSTROKES: ReadonlyArray<{ typed: string; delivers: readonly string[] }> = [ + { typed: '12abc', delivers: ['1', '12'] }, + { typed: '1.2.3', delivers: ['1', '1.2', '1.23'] }, + { typed: '0x10', delivers: ['0', '01', '010'] }, + { typed: '1e', delivers: ['1', ''] }, + // Nothing typed: the box never changes, so no `change` event is delivered at + // all. Driving `''` here rather than `[]` would be the same non-event. + { typed: '', delivers: [] }, +]; + +/** + * A host that ECHOES the emission back into `value`, the way a real form does. + * These are CONTROLLED inputs: with a bare spy host `value` never moves, React + * sees no change between two identical values, and every reading after the + * first is suppressed. A sequence cannot be driven without this. + * + * `echo` is off only for the single-event fabricated drives in the divergence + * derivation below: those model one synthetic event rather than a typing + * session, and echoing a `NaN` emission back into a controlled input makes + * React warn about the `value` attribute for a reading no user can produce. + */ +const renderHost = (widget: WidgetName, echo = true) => { + const onChange = vi.fn(); + const field = { + currency: { name: 'amount', type: 'currency', currency: 'USD', precision: 2 }, + percent: { name: 'rate', type: 'percent', precision: 2 }, + number: { name: 'qty', type: 'number', precision: 2 }, + geolocation: { name: 'where', type: 'geolocation' }, + }[widget] as FieldMetadata; + const Host = () => { + // Two states rather than one `unknown`: three of these widgets are scalar + // and one is composite, so a single loosely-typed slot would be the only + // place in this file the compiler stops checking the thing being measured. + const [scalar, setScalar] = React.useState(null); + const [coords, setCoords] = React.useState({}); + const onScalar = (next: number) => { + onChange(next); + if (echo) setScalar(next); + }; + const onCoords = (next: GeolocationValue) => { + onChange(next); + if (echo) setCoords(next); + }; + switch (widget) { + case 'currency': + return ; + case 'percent': + return ; + case 'number': + return ; + case 'geolocation': + return ; + } + }; + const { container } = render(); + return { + input: container.querySelector('input[type=number]') as HTMLInputElement, + onChange, + }; +}; + +/** + * `GeolocationField` is a composite: it emits the whole coordinate object, and + * the box this file drives is its LATITUDE. Read that one key so the four + * widgets are comparable; `NO_CALL` distinguishes "never told" from "told + * nothing". + */ +const NO_CALL = Symbol('onChange was never called'); + +const emissionsOf = (widget: WidgetName, deliveries: readonly string[], echo = true) => { + const { input, onChange } = renderHost(widget, echo); + for (const delivered of deliveries) { + fireEvent.change(input, { target: { value: delivered } }); + } + const calls = onChange.mock.calls.map(([emitted]) => + widget === 'geolocation' && emitted != null + ? (emitted as { latitude?: number }).latitude + : emitted, + ); + cleanup(); + return { calls, last: calls.length === 0 ? NO_CALL : calls[calls.length - 1] }; +}; + +/** What each widget is MEASURED to emit once the browser's keystrokes arrive. */ +const PRODUCT: ReadonlyArray<{ + widget: WidgetName; + typed: string; + emits: number | null | undefined | typeof NO_CALL; +}> = [ + // `parseFloat` widgets. The percent pair stores fractions, so 12% is 0.12. + { widget: 'currency', typed: '12abc', emits: 12 }, + { widget: 'currency', typed: '1.2.3', emits: 1.23 }, + { widget: 'currency', typed: '0x10', emits: 10 }, + { widget: 'currency', typed: '1e', emits: null }, + { widget: 'currency', typed: '', emits: NO_CALL }, + { widget: 'percent', typed: '12abc', emits: 0.12 }, + { widget: 'percent', typed: '1.2.3', emits: 0.0123 }, + { widget: 'percent', typed: '0x10', emits: 0.1 }, + { widget: 'percent', typed: '1e', emits: null }, + { widget: 'percent', typed: '', emits: NO_CALL }, + // `Number()` widgets. On the strings a browser can actually deliver they + // agree with the `parseFloat` pair on every row — the two parsers only part + // company on residue, which never arrives. + { widget: 'number', typed: '12abc', emits: 12 }, + { widget: 'number', typed: '1.2.3', emits: 1.23 }, + { widget: 'number', typed: '0x10', emits: 10 }, + { widget: 'number', typed: '1e', emits: null }, + { widget: 'number', typed: '', emits: NO_CALL }, + // ⚠️ Not a transcription slip: `GeolocationField` clears a coordinate to + // `undefined` where the other three clear to `null`, because it builds an + // object (`fieldValue ? Number(fieldValue) : undefined`) instead of emitting + // a scalar. Reachable by any user who empties the box. Pinned as the product + // behaviour it is, and reported rather than "fixed" under this card. + { widget: 'geolocation', typed: '12abc', emits: 12 }, + { widget: 'geolocation', typed: '1.2.3', emits: 1.23 }, + { widget: 'geolocation', typed: '0x10', emits: 10 }, + { widget: 'geolocation', typed: '1e', emits: undefined }, + { widget: 'geolocation', typed: '', emits: NO_CALL }, +]; + +const deliveriesFor = (typed: string) => + BROWSER_KEYSTROKES.find(row => row.typed === typed)!.delivers; + +describe('objectui#6793 — the product, driven the way a browser drives it', () => { + it.each(PRODUCT)( + '$widget: typing $typed emits $emits', + ({ widget, typed, emits }) => { + const { last } = emissionsOf(widget, deliveriesFor(typed)); + expect(last).toBe(emits); + }, + ); + + it('never emits NaN on any keystroke of any measured sequence', () => { + // The whole-sequence claim, not just the final one. It is worth its own + // assertion because two of these widgets parse with `Number()`, which + // answers `NaN` for residue — the reading that would reach a form, and a + // record, if residue ever arrived. Measured: it does not. + for (const { widget } of PRODUCT) { + for (const { typed, delivers } of BROWSER_KEYSTROKES) { + const { calls } = emissionsOf(widget, delivers); + for (const emitted of calls) { + expect( + typeof emitted === 'number' && Number.isNaN(emitted), + `${widget} emitted NaN while the browser delivered ${JSON.stringify(typed)}`, + ).toBe(false); + } + } + } + }); +}); + +describe('objectui#6793 — the 6-of-10 divergence, re-derived', () => { + /** + * The card quoted "6 of 10". This derives it: for each of the ten cases + * objectui#6765 pinned, drive the fabricated whole string the old oracle + * drives, drive the browser's real keystrokes, and compare the two emissions. + * + * ⛔ The fabricated column is NOT an endorsement of anything. It is measured + * here only to produce the count, exactly as the divergence pin does. + */ + const CARD_CASES = PRODUCT.filter(c => c.widget === 'currency' || c.widget === 'percent'); + + it('measures ten cases and finds exactly six disagreements', () => { + expect(CARD_CASES).toHaveLength(10); + const diverging = CARD_CASES.filter(({ widget, typed }) => { + const fabricated = emissionsOf(widget, [typed], false).last; + const browser = emissionsOf(widget, deliveriesFor(typed)).last; + return !Object.is(fabricated, browser); + }); + expect(diverging.map(c => `${c.widget}:${c.typed}`)).toEqual([ + 'currency:1.2.3', + 'currency:0x10', + 'currency:1e', + 'percent:1.2.3', + 'percent:0x10', + 'percent:1e', + ]); + expect(diverging).toHaveLength(6); + }); + + it('and finds eight in the two widgets the card never measured', () => { + // `NumberField` and `GeolocationField` are named by the card but were not + // in objectui#6765's table. They diverge harder, because `Number()` answers + // `NaN` where `parseFloat` answers a truncation: four of five rows each. + const rest = PRODUCT.filter(c => c.widget === 'number' || c.widget === 'geolocation'); + const diverging = rest.filter(({ widget, typed }) => { + const fabricated = emissionsOf(widget, [typed], false).last; + const browser = emissionsOf(widget, deliveriesFor(typed)).last; + return !Object.is(fabricated, browser); + }); + expect(diverging.map(c => `${c.widget}:${c.typed}`)).toEqual([ + 'number:12abc', + 'number:1.2.3', + 'number:0x10', + 'number:1e', + 'geolocation:12abc', + 'geolocation:1.2.3', + 'geolocation:0x10', + 'geolocation:1e', + ]); + }); +}); + +describe('objectui#6793 — this file cannot regress into driving a fiction', () => { + /** + * The self-guard, and the reason this suite is allowed to exist alongside the + * divergence pin. Every string driven above is checked against the PLATFORM's + * own predicate — `validity.badInput`, the browser stating in its own words + * whether it can read the text. happy-dom implements that predicate even + * though it skips the sanitization, which is precisely what makes the check + * possible here: the four strings the old oracle drives all answer TRUE, and + * every string this file drives answers FALSE. + */ + it('drives only values the platform reads as a number', () => { + const input = document.createElement('input'); + input.type = 'number'; + for (const { typed, delivers } of BROWSER_KEYSTROKES) { + for (const delivered of delivers) { + input.value = delivered; + expect( + input.validity.badInput, + `${JSON.stringify(delivered)} (from typing ${JSON.stringify(typed)}) is not ` + + 'something this environment reads as a number, so it cannot be a browser reading', + ).toBe(false); + } + } + }); + + it('and the strings it deliberately does not drive are the unreadable ones', () => { + // The contrast, so the guard above cannot be read as vacuous: the four + // whole strings objectui#6765's table drives are exactly the ones the + // platform refuses. That is the difference between the two suites, stated + // as an assertion rather than as prose. + const input = document.createElement('input'); + input.type = 'number'; + for (const { typed } of BROWSER_KEYSTROKES) { + if (typed === '') continue; + input.value = typed; + expect(input.validity.badInput, `${JSON.stringify(typed)} became readable`).toBe(true); + } + }); +});