Skip to content

test(fields): drive the type="number" widgets with values a browser can deliver - #6850

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6793-number-widget-test-oracle
Aug 30, 2026
Merged

test(fields): drive the type="number" widgets with values a browser can deliver#6850
os-sam merged 1 commit into
mainfrom
claude/issue-6793-number-widget-test-oracle

Conversation

@claude

@claude claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #6793

⛔ Deliberately not Fixes. This delivers one half of that card and defers the other half to an unmerged sibling's file — see "What is deferred" below. Merging this must not close #6793.

What this adds

One new test file, packages/fields/src/__tests__/NumberInputWidgets.browserDeliverable.test.tsx, and an empty-frontmatter changeset. No widget source is touched, and no existing assertion moved.

#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, and says so.

Nothing recorded the other half: no test in this repository 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. This file is that oracle.

What was measured, on this base

Chromium 141.0.7390.37 via Playwright 1.62.1 (executablePath: '/opt/pw-browsers/chromium'), a real number input 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 ""       ''

Pasting (keyboard.insertText) lands 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 — that route is #6780's subject, not this card's.

The load-bearing shape: a browser does not deliver one post-sanitization string, it delivers a sequence of them, one per accepted keystroke. "1e" makes it 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. That is why the new suite drives arrays.

The per-case table, re-derived rather than quoted

old test drove is the whole fabricated string the existing pin feeds; browser delivers is the measured keystroke sequence; product emits is what the real widget does when driven that way. All three columns measured here, not transcribed.

widget user types old test drove old oracle emits browser delivers product emits disposition
currency 12abc '12abc' 12 '1', '12' 12 agree by luck
currency 1.2.3 '1.2.3' 1.2 '1', '1.2', '1.23' 1.23 oracle wrong
currency 0x10 '0x10' 0 '0', '01', '010' 10 oracle wrong
currency 1e '1e' 1 '1', '' null oracle wrong
currency (nothing) '' no call (no event) no call agree
percent 12abc '12abc' 0.12 '1', '12' 0.12 agree by luck
percent 1.2.3 '1.2.3' 0.012 '1', '1.2', '1.23' 0.0123 oracle wrong
percent 0x10 '0x10' 0 '0', '01', '010' 0.1 oracle wrong
percent 1e '1e' 0.01 '1', '' null oracle wrong
percent (nothing) '' no call (no event) no call agree

6 of 10 — re-derived, and it reproduces. divergence.test asserts the six by name, computed by driving each case both ways through the real widget, so the figure cannot rot back into a quotation.

Which side is wrong, per case: the oracle, in all six. The product reads what the browser hands it and gets the right answer on every row. Nothing in the widgets moved, and nothing should.

The two widgets the card names but #6765 never measured

widget user types old-style drive emits browser delivers product emits
number 12abc NaN '1', '12' 12
number 1.2.3 NaN '1', '1.2', '1.23' 1.23
number 0x10 16 '0', '01', '010' 10
number 1e NaN '1', '' null
number (nothing) no call (no event) no call
geolocation 12abc NaN '1', '12' 12
geolocation 1.2.3 NaN '1', '1.2', '1.23' 1.23
geolocation 0x10 16 '0', '01', '010' 10
geolocation 1e NaN '1', '' undefined
geolocation (nothing) no call (no event) no call

These parse with Number() rather than parseFloat, so a fabricated string makes them emit NaN — a reading that would reach a form and a record. On every string a browser can actually deliver they agree with the parseFloat pair. The one asymmetry that is reachable — GeolocationField clearing to undefined where the other three clear to null — is pinned as current behaviour here and filed separately as #6848; it is not changed by this PR.

The #6801 interaction, measured rather than reasoned

The same harness was run against origin/main and against PR #6801's head (adb3c3ace), on all 20 rows above:

rows compared: 20   rows whose EMISSIONS changed under PR #6801: 0

None of the six disagreements resolve when #6801 lands. That PR announces bad input (validity.badInput to a refusal message, a border and aria-invalid) and states in its own source that it deliberately leaves every emission unchanged; the measurement agrees. So this card's deliverable is independent of that ruling, exactly as its triage said.

The new suite was also run on #6801's tree, alongside that PR's own two suites: 3 files, 117 tests, all passing. It is forward-compatible by measurement, not by assertion.

What is deferred, and why

Files PR #6801 modifies, checked one by one before anything was touched:

file PR #6801 this PR
packages/fields/src/widgets/CurrencyField.tsx modified not touched
packages/fields/src/widgets/PercentField.tsx modified not touched
packages/fields/src/widgets/NumberField.tsx modified not touched
packages/fields/src/widgets/GeolocationField.tsx modified not touched
packages/fields/src/widgets/numberBadInput.tsx added not touched
packages/fields/src/__tests__/NumberInputWidgets.environmentDivergence.test.tsx modified DEFERRED
packages/fields/src/__tests__/numberInputBrowserReadings.ts added DEFERRED
packages/fields/src/__tests__/NumberInputWidgets.badInputAnnounce.test.tsx added not touched
content/docs/**, packages/fields/README.md, .changeset/6780-*.md modified/added not touched

The ten fabricated-string cases live in NumberInputWidgets.environmentDivergence.test.tsx, which PR #6801 modifies. Rewriting those bodies is the other half of #6793 and it is deferred to a follow-up on #6801 rather than raced here. The measured record this file carries also belongs, once #6801 lands, next to the numbers in that PR's new numberInputBrowserReadings.ts; the docstring says so, and the keystroke table is a strict superset of that module's BROWSER_READINGS (its last elements are those same readings), so the fold is mechanical rather than a reconciliation of two dialects.

One claim in the card that does not survive re-derivation

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 (10 files): exactly one drives such a string — NumberInputWidgets.environmentDivergence.test.tsx, which exists to record the divergence. Every other one already drives '10', '15', '75', '6000', '2', '1234.56', '1234.567' — all browser-deliverable. The card flagged this as its own confidence gap 2 ("not a file count I have taken"), and the correction is written into the new file's docstring rather than left in a PR description.

Ablation

Run on PR #6801's tree so no deferred file was mutated on this branch. Each leg: mutate, prove the mutation reached disk (a grep -cF on the removed text and on the injected text, plus git hash-object against the HEAD blob), run, restore with git checkout HEAD -- (absolute path), prove the restore with the blob hash and an empty git diff HEAD. Both widgets are imported by relative source path, so vitest transforms the source directly — there is no dist in the resolution and no rebuild leg to get wrong.

[currency] CurrencyField.tsx  parseFloat -> Math.trunc(parseFloat), null -> 0
  before: removed-text 1, injected-text 0   after: removed-text 0, injected-text 1
  mutated blob 0a06007a5... differs from HEAD blob 8efa4e368...
  => Tests  3 failed | 22 passed (25)
     x 'currency': typing '1.2.3' emits 1.23
     x 'currency': typing '1e' emits null
     x measures ten cases and finds exactly six disagreements
  restored blob 8efa4e368... == HEAD; git diff HEAD EMPTY

[number] NumberField.tsx  Number(val) -> parseInt(val, 10)
  before: removed-text 1, injected-text 0   after: removed-text 0, injected-text 1
  mutated blob 74e724f3d... differs from HEAD blob aac4f9b49...
  => Tests  2 failed | 23 passed (25)
     x 'number': typing '1.2.3' emits 1.23
     x and finds eight in the two widgets the card never measured
  restored blob aac4f9b49... == HEAD; git diff HEAD EMPTY

Verification

Union run after the final commit, 162c22566:

pnpm exec vitest run packages/fields                      -> Test Files 120 passed (120), Tests 1997 passed (1997)
pnpm exec vitest run .../NumberInputWidgets.browserDeliverable.test.tsx
   + .../NumberInputWidgets.environmentDivergence.test.tsx -> Test Files 2 passed (2), Tests 41 passed (41)
pnpm --filter @object-ui/fields run type-check            -> exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm --filter @object-ui/fields run lint                  -> 895 problems (0 errors, 895 warnings), all pre-existing
node scripts/check-changeset-presence.mjs                 -> "declares 1 changeset(s) ... EMPTY frontmatter ... a complete answer to this gate"
node scripts/check-changeset-no-major.mjs                 -> "No changeset declares a `major` bump."
node scripts/check-changeset-fixed.mjs                    -> "All workspace packages are in the changeset fixed group."
node scripts/check-changeset-overwrite.mjs                -> "No pre-existing changeset was modified or deleted."
pnpm run check:control-bytes                              -> "OK (scanned 5649 tracked text file(s))"
pnpm run check:phantom-deps                               -> "Every in-scope import is declared by the package that publishes it."
pnpm run check:vi-mock-specifiers                         -> "OK (3956 tracked source file(s) ...)"
pnpm run check:self-import                                -> "No package names itself inside its own src/."
pnpm run check:shell-escape-residue                       -> "OK (4/4 root(s) resolved ...)"

The type-check is known to cover the new file rather than merely to have run: an earlier revision of it produced TS2352 from that exact path under the same command.

⚠️ Declared narrowing: lint was run for @object-ui/fields (the only package this diff touches), not repo-wide. eslint.config.js configures no type-aware rules (no project / projectService), so a new file cannot move any untouched file's verdict. CI runs the full farm regardless.

The changeset carries an empty frontmatter because the gate's own printed verdict named that as the answer for a change that releases nothing: no widget source is touched.


Generated by Claude Code

…an deliver

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 `<input type="number">` 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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3174.0 KB 3222.7 KB
Main entry chunk (gzip) 148.1 KB 350 KB
Entry file index-BFe99tIZ.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.50KB 116.32KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.03KB 32.64KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 76.75KB 25.49KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants