Skip to content

Commit 31938f0

Browse files
fix(fields): CurrencyField takes fraction digits from the currency, never the field-level precision (objectui#10276) (#10319)
Fixes #10276 Clause-②: no — one widget stops reading a spec key with the wrong meaning; no declared key, schema, export or accept set moves (carried from the claim, comment 5818421884). Dispatched implementation, `domain:ui#4` seat, session `session_01BP8CMtACxTdLjqR6rhd33C`. Draft until the seat lands it. ## What changed `CurrencyField` in `@object-ui/fields` took its one fraction-digit width from `currencyField?.precision ?? (currency ? currencyFractionDigits(currency) : 2)`. In `@objectstack/spec` the field-level `precision` is "Total digits (non-negative integer)" — a DECIMAL(18,2) amount is `precision: 18, scale: 2` — so a currency field declaring `precision: 18` rendered eighteen decimal places, offered `step="0.000000000000000001"` and rounded typed input to eighteen places on blur. The width is now `currency ? currencyFractionDigits(currency) : 2`: the resolved currency's ISO 4217 minor unit, and the historical 2 when no currency resolves (the same no-currency fallback `formatCurrency` uses for the grid cell). Neither `precision` nor `scale` is read. The one derived value still drives display, `step` and blur rounding, so the JPY coupling objectui#4361 pinned holds: whole yen, `step="1"`, blur rounds `1234.56` to `1235`. Governing text: the maintainer ruling recorded on objectstack-ai/objectstack#19910 (record `5805782503`, batch 218 item 2, letter 乙), whose item 3 names this exact reading as wrong and whose heading is "a currency's decimal places are the currency's, not a setting"; and the ruling on objectstack-ai/objectstack#19629 (letter B, record `5791803339`) that takes `scale` off the `currency` type. ## ⚠️ Where this departs from the triage notes — for the seat to confirm Triage comment `5817807194` says fraction digits come "from `scale` (when authored) or the currency's ISO 4217 digits", with the pin "only `scale: 0` ⇒ 0 decimals". This PR does **not** read `scale`, and pins `scale: 0` on a USD field as inert (`$1,234.50`), because: - the 乙 ruling's heading puts the decimal places on the currency, not on any setting; ruling B takes `scale` off the currency type and orders "no `scale` read on currency" for the sibling footer face; - `CurrencyFieldMetadata` in `@object-ui/types` does not declare `scale` — measured: `{ type: 'currency', name: 'x', label: 'X', scale: 2 }` typed as `CurrencyFieldMetadata` fails to compile against the built `@object-ui/types` dist with TS2353; - the widget did not read `scale` before this change either, so not reading it moves nothing; reading it would add a reader of a key the ruled spec change refuses at parse (objectstack-ai/objectstack#19909, open). The other two triage pins hold exactly as written: `precision: 18, scale: 2` ⇒ 2 decimals; `precision: 18` alone ⇒ the currency's digits (2 for CNY and for USD). Zero decimals keep their grouping separators — pinned on JPY (`¥1,234,567`). If the seat rules the `scale` reading instead, the change is the one derivation line plus the two `scale` pins. ## Measured, per the dispatch's mechanism assumptions - **A1 held.** One derived value drives display, `step` and blur rounding. The JPY step and blur pins stay green; the pin "USD is still 0.01, authored or derived", whose second half asserted `precision: 2` on JPY gives a 0.01 step, is rewritten (JPY with `precision: 2` now steps by 1; USD with `precision: 18` steps by 0.01). - **A2 done.** The derivation comment no longer says "An AUTHORED `precision` wins"; it states the ruled rule, cites both rulings, and records why neither `precision`, `scale` nor `currencyConfig.precision` is read. - **A3 falsified — the cell is clean.** `CurrencyCellRenderer` calls `formatCurrency(num, currency, locale)`, whose width is `isWhole ? 0 : currency ? currencyFractionDigits(currency) : 2`; it never reads the field. A grep of non-test `packages/*/src` and `apps/*/src` for `precision` reads finds no other currency path that turns the field-level `precision` into fraction digits. The grid summary footer and `ObjectMetricWidget` read `scale ?? 0` on a currency — that is objectui#10221's surface, not folded. - **A4.** The pinned `@objectstack/spec` (17.4.0) declares `scale` on `FieldSchema` for every type and still accepts it on a currency (`FieldSchema.safeParse({ name: 'amount', type: 'currency', scale: 0 })` succeeds); `@object-ui/types` declares `precision` but not `scale` on `CurrencyFieldMetadata`. Nothing reads the undeclared key here, and the gap agrees with ruling B's direction, so it is not reported as a finding. ## File surface — three additions beyond the claim, declared The claim named the widget, its tests and one changeset. This PR also touches: 1. `.changeset/9568-percent-widget-reads-scale.md` — pending (not yet in `packages/fields/CHANGELOG.md`); its closing paragraph described CurrencyField's `precision` read as live under objectui#4361's "authored `precision` wins", and it would ship in the same release as this change. Corrected per the dispatch's own instruction; its declaration is unchanged (`@object-ui/fields: minor`). The objectui#4361 text already published in the CHANGELOG is historical record and is not touched. 2. `packages/fields/src/widgets/PercentField.tsx` — one docblock sentence ("objectui#4361 ruled an authored `precision` wins over THAT") that this change makes false; comment-only, same package, same gates, and none of the 25 open PRs' file lists touches the file. 3. `content/docs/fields/currency.mdx` — the published page called `precision` "the decimal precision" and taught `precision: 2`; this change makes that false. It now says decimal places follow the currency and `precision` is the total digit count, and the example drops `precision: 2`. ## Changeset `.changeset/10276-currency-fraction-digits-not-precision.md`, `'@object-ui/fields': minor`. ⚠️ The dispatch named `patch`. `minor` because the AGENTS.md version rule marks objectui's own breaking changes `minor`, this reverses a rule the published CHANGELOG states ("an explicitly authored `precision` still wins"), and both precedents for the same reading on the percent faces (objectui#9295, objectui#9568) declared `minor`. In the fixed group the two bump the same while other `minor` changesets are pending; the seat may flip it. ## Tests and gates — all read at `8fb056145` - `pnpm exec vitest run --maxWorkers=2 packages/fields/` (repo root, under the shared verify lock) → `Test Files 178 passed | 1 skipped (179)`, `Tests 3013 passed | 7 skipped (3020)`. - `pnpm --filter @object-ui/fields type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) → exit 0, after building the dependency closure `pnpm --filter "@object-ui/fields^..." build`. The test file is in the test program: `tsc -p tsconfig.test.json --listFilesOnly` lists `CurrencyField.minorUnits.test.tsx` once among 179 test files. - `eslint .` in `packages/fields` (the unit CI's `turbo run lint` runs) → 264 files, 0 errors; the touched files' warnings are all `no-explicit-any` on lines this diff did not add (zero added lines contain `any`). - `check-changeset-presence` ✅ "3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"; `check-changeset-no-major` ✅; `check-changeset-fixed` ✅; `check-changeset-claims --json` ✅ "No pending changeset names a file this change touches"; `check-pending-changeset-literals` ✅; `check-changeset-overwrite` reports the 9568 edit (report-only; case 2, a deliberate prose correction, declaration unchanged). - `check:new-line-citations` → `VERDICT new-cross-file-line-citations: 0 new citation(s)`; `check-control-bytes` ✅; `check-doc-links`, `check-doc-fence-languages`, `check-doc-example-ids`, `check-doc-component-types`, `check-doc-expression-carriage` → exit 0. - `check-governed-queue-guard --test` over all six paths → NOT GOVERNED. - NOT MEASURED: `check-doc-snippet-types` as a whole (it builds a 35-package closure; CI owns it). Narrowed instead: the edited `currency.mdx` snippet compiles `--strict` against the built `@object-ui/types` dist, with a control snippet in the same program that fails (TS2353 on an undeclared key), so the types did resolve. Other packages' tests are not owed: no export, type or spec contract moves. ## Reverse verification Fix committed first (`3d6b06223`). A trap-guarded script restored `CurrencyField.tsx` to the base `8b1f06619`, confirmed the mutation on disk (old read `currencyField?.precision ??` count 1, new derivation count 0), and ran the test file (it imports the widget by relative path, so no build is involved): `Tests 9 failed | 15 passed (24)` — the nine are the six `precision` pins, the no-currency `precision` pin, the step pin and the `precision: 18` blur pin. The two `scale` pins stay green on base, as expected: base did not read `scale` either. Restore: `git checkout HEAD -- PATH`, then the working blob equals the `HEAD` blob (`879bb362…`) and `git diff HEAD` is empty. ## Acceptance notes - Out of scope, reported to the seat in the dev report, not filed here: in objectstack's spec, the objectstack-ai/objectstack#7918 field-level `precision` anchor in `FieldSchema`'s `superRefine` still reads a currency field's `precision` as its display width. Measured on the pinned 17.4.0: `{ type: 'currency', precision: 18, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` is refused ("currency USD has 2 fraction digits; `precision: 18` contradicts it", remedy "Declare `precision: 2`"), against the key's own describe "Total digits (non-negative integer)"; its comment's premise that objectui's CurrencyField reads the key stops being true with this PR. - Observation, no carrier: `ObjectForm`'s unregistered-widget fallback derives a currency field's `step` from `scale`; after the spec refuses `scale` on currency it resolves to `'any'`. Not reached by the registered `CurrencyField`. - Pre-existing and unchanged: the widget shows `$1,234.00` where the grid cell shows `$1,234` (the cell's wholeness switch). --- _Generated by [Claude Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8813335 commit 31938f0

6 files changed

Lines changed: 197 additions & 102 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@object-ui/fields': minor
3+
---
4+
5+
`CurrencyField` takes its fraction digits from the currency, never from the
6+
field-level `precision` (objectui#10276).
7+
8+
`@objectstack/spec` declares a field's `precision` as "Total digits
9+
(non-negative integer)" — the `p` of a `decimal(p, s)` column, so a
10+
DECIMAL(18,2) amount is `precision: 18, scale: 2`. The currency edit widget
11+
passed that count to `Intl` as the number of decimal places, so a currency
12+
field declaring `precision: 18` rendered eighteen decimal places, offered a
13+
`step` of `0.000000000000000001` and rounded typed input to eighteen places on
14+
blur. The currency cell renderer does not read `precision` and is unaffected.
15+
16+
The widget's one width is now the resolved currency's own ISO 4217 minor-unit
17+
count — two decimals for USD or CNY, none for JPY, three for KWD — and it drives
18+
the read-only display, the input's `step` and the blur rounding alike. A field
19+
with no resolvable currency keeps two decimals. The widget does not read `scale`
20+
for the width either (it never did), in line with the ruling on
21+
objectstack-ai/objectstack#19629 that takes `scale` off the `currency` type.
22+
23+
**Behaviour change** for currency fields that declare `precision`: the declared
24+
value no longer sets the decimal places shown, the `step`, or the blur rounding.
25+
Where it differed from the currency's own digit count (or from two decimals on a
26+
field with no currency), the output moves: `precision: 2` on a JPY field used to
27+
show `¥1,234.50` and now shows `¥1,235`; `precision: 0` on a USD field used to
28+
show `$1,235` and now shows `$1,234.50`. This reverses the "an explicitly
29+
authored `precision` still wins" rule objectui#4361 published, under the
30+
maintainer ruling recorded on objectstack-ai/objectstack#19910 that a
31+
currency's decimal places are the currency's, not a setting. Fields that declare
32+
no `precision`, or one equal to the currency's own digit count, render exactly
33+
as before.
34+
35+
**Migration.** Nothing to restate: a currency field's decimal places follow its
36+
currency. Keep `precision` only as the total digit count of the stored decimal.

‎.changeset/9568-percent-widget-reads-scale.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,8 @@ to make widget-versus-cell agreement a premise, and closing that gap needs its
4848
own ruling rather than arriving as a side effect of moving the member that is
4949
read.
5050

51-
`CurrencyField`'s own read of `precision` is untouched and is not the same
52-
shape: there the competing source is the currency's ISO 4217 minor-unit count,
53-
and objectui#4361 ruled an authored `precision` wins over that. It ruled nothing
54-
about `scale`. `CurrencyConfigSchema.precision` is a third surface again, with
55-
the opposite convention and its own `scale` alias, and the spec warns against
56-
conflating it with the field face.
51+
`CurrencyField` is not part of this change: a currency's decimal places are the
52+
currency's own ISO 4217 minor-unit count, and since objectui#10276 that widget
53+
reads neither `precision` nor `scale` for them. `CurrencyConfigSchema.precision`
54+
is a third surface again, with the opposite convention and its own `scale`
55+
alias, and the spec warns against conflating it with the field face.

‎content/docs/fields/currency.mdx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The Currency Field component provides a formatted currency input with proper loc
1717

1818
A currency field is authored as `CurrencyFieldMetadata` (`@object-ui/types`), which is
1919
the source of truth for the key set: it extends `BaseFieldMetadata` with the currency
20-
code, the decimal precision and the two numeric bounds.
20+
code, the total-digit `precision` and the two numeric bounds.
2121

2222
```ts
2323
import type { CurrencyFieldMetadata } from '@object-ui/types';
@@ -29,12 +29,18 @@ const amount: CurrencyFieldMetadata = {
2929
placeholder: '0.00',
3030
required: true,
3131
currency: 'USD',
32-
precision: 2,
3332
min: 0,
3433
max: 1000000,
3534
};
3635
```
3736

37+
**Decimal places come from the currency, not from a field key.** An amount shows its
38+
currency's own ISO 4217 minor unit — two decimals for USD or CNY, none for JPY, three
39+
for KWD — in the read-only display, in the input's `step` and in the rounding applied
40+
when the input loses focus. `precision` is the total digit count of the stored decimal
41+
(a DECIMAL(18,2) amount declares `precision: 18`), so it never sets how many decimals are
42+
shown. With no currency resolved at all, the amount shows two decimals.
43+
3844
The value being edited, and the `className` / `disabled` a host supplies, are **not**
3945
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
4046

‎packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx‎

Lines changed: 107 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,35 @@
77
*/
88

99
/**
10-
* objectui#4361, second path — `CurrencyField`'s `precision`.
10+
* `CurrencyField`'s fraction-digit width — two cards, one rule.
1111
*
12-
* `formatAmount` passes the field's `precision` to BOTH `Intl` bounds, and the
13-
* widget defaulted that precision to a literal `2`. So a JPY field that
14-
* declared no precision rendered `¥1,234.50` for the same reason
15-
* `formatCurrency` did, one layer up.
12+
* objectui#4361, second path: `formatAmount` passes the widget's width to
13+
* BOTH `Intl` bounds, and the widget defaulted that width to a literal `2`. So
14+
* a JPY field rendered `¥1,234.50` for the same reason `formatCurrency` did,
15+
* one layer up. That card made the width derive from the currency's own
16+
* ISO 4217 minor-unit count — pinned below, unchanged.
1617
*
17-
* The PM ruling on this card (issue #4361, claim comment) splits it in two,
18-
* and both halves are pinned here:
18+
* objectui#10276: that card ALSO let an authored field-level `precision` win
19+
* over the currency. `@objectstack/spec` declares that key as "Total digits
20+
* (non-negative integer)" — the `p` of a decimal(p, s) column, so a
21+
* DECIMAL(18,2) amount is `precision: 18, scale: 2` — and the widget passed it
22+
* to both `Intl` bounds as decimal places, rendering eighteen of them. The
23+
* maintainer ruling recorded on objectstack-ai/objectstack#19910 (batch #218
24+
* item 2) names that reading as wrong: a currency's decimal places are the
25+
* currency's, not a setting. The pins that used to say "an authored
26+
* `precision` wins" are REWRITTEN below to the ruled rule, not deleted: the
27+
* same inputs, now asserting that `precision` moves nothing.
1928
*
20-
* 1. **An explicitly authored `precision` WINS.** It is authored metadata and
21-
* this repo's convention is that authored metadata keeps priority. A JPY
22-
* field declaring `precision: 2` still renders `¥1,234.50` — whether that
23-
* combination should be REJECTED at publish time is a spec question filed
24-
* upstream (contract-first), not something the renderer decides by
25-
* overriding the author.
26-
* 2. **An ABSENT `precision` derives from the currency** instead of falling
27-
* back to 2.
29+
* `scale` is not a currency width either — the ruling on
30+
* objectstack-ai/objectstack#19629 (letter B) takes it off the `currency` type,
31+
* and `CurrencyFieldMetadata` in `@object-ui/types` does not declare it — so a
32+
* declared `scale` is pinned as inert too.
2833
*
29-
* MEASURED, per the ruling's stop condition — is "absent" distinguishable from
30-
* "authored 2" by the time the renderer sees it? Yes:
31-
*
32-
* - `CurrencyFieldMetadata.precision` is `precision?: number` in
33-
* `packages/types/src/field-types.ts` — optional, no default;
34-
* - the field-level key is `z.ZodOptional<z.ZodNumber>` in
35-
* `@objectstack/spec` (no `.default()`), so a parsed field metadata object
36-
* does NOT carry a materialized 2;
37-
* - the only `.default(2)` in the spec's currency surface is on
38-
* `CurrencyConfigSchema.precision` — a DIFFERENT key on a different object
39-
* (the `currencyConfig` block), which this widget never reads; and
40-
* - the `?? 2` itself lived in the widget, at `CurrencyField.tsx`.
41-
*
42-
* So the renderer holds an `undefined` it can act on, and this half lands.
43-
*
44-
* The derived precision is the widget's ONE precision, so it reaches the edit
45-
* affordances too (`step`, and the blur rounding) — pinned below. Leaving
46-
* those at 2 would have produced a JPY field that displays whole yen but
47-
* offers a 0.01 spinner step and rounds typed input to 1234.56 yen.
34+
* The width is the widget's ONE width, so it reaches the edit affordances too
35+
* (`step`, and the blur rounding) — pinned below. Leaving those at 2 would
36+
* produce a JPY field that displays whole yen but offers a 0.01 spinner step
37+
* and rounds typed input to 1234.56 yen; reading `precision` there would give
38+
* a DECIMAL(18,2) amount a `1e-18` step.
4839
*/
4940

5041
import { describe, it, expect, vi } from 'vitest';
@@ -79,13 +70,13 @@ const renderField = (
7970
</LocalizationProvider>,
8071
);
8172

82-
describe('CurrencyField — an ABSENT precision derives from the currency (objectui#4361)', () => {
83-
it('a JPY field that declares no precision shows no cents', () => {
73+
describe('CurrencyField — fraction digits derive from the currency (objectui#4361)', () => {
74+
it('a JPY field shows no cents', () => {
8475
const { container } = renderField(1234.5, { currency: 'JPY' }, { readonly: true });
8576
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
8677
});
8778

88-
it('a KWD field that declares no precision shows all three digits', () => {
79+
it('a KWD field shows all three digits', () => {
8980
const { container } = renderField(1.5, { currency: 'KWD' }, { readonly: true });
9081
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
9182
});
@@ -103,27 +94,71 @@ describe('CurrencyField — an ABSENT precision derives from the currency (objec
10394
);
10495
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
10596
});
97+
98+
it('a zero-digit currency keeps its grouping separators', () => {
99+
// Zero fraction digits is a WIDTH, not the ordinal reading
100+
// `formatDisplayNumber` gives `scale: 0` (objectui#4033): an amount keeps
101+
// its thousands separators.
102+
const { container } = renderField(1234567, { currency: 'JPY' }, { readonly: true });
103+
expect(normalizeNbsp(container.textContent)).toBe('¥1,234,567');
104+
});
106105
});
107106

108-
describe('CurrencyField — an AUTHORED precision still wins (authored metadata keeps priority)', () => {
109-
it('a JPY field declaring `precision: 2` keeps its two digits', () => {
110-
// The renderer does not overrule the author. Whether publish-time
111-
// validation should reject this combination is the upstream spec question
112-
// filed alongside this card — deliberately NOT decided here.
107+
describe('CurrencyField — the field-level `precision` is a TOTAL-digit count and sets no fraction digits (objectui#10276)', () => {
108+
it('a DECIMAL(18,2) amount — `precision: 18, scale: 2` — shows two decimals, not eighteen', () => {
109+
const { container } = renderField(
110+
1234.5,
111+
{ currency: 'USD', precision: 18, scale: 2 },
112+
{ readonly: true },
113+
);
114+
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
115+
});
116+
117+
it('`precision: 18` alone falls to the currency\'s own digits — USD', () => {
118+
const { container } = renderField(1234.5, { currency: 'USD', precision: 18 }, { readonly: true });
119+
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
120+
});
121+
122+
it('`precision: 18` alone falls to the currency\'s own digits — CNY', () => {
123+
const { container } = renderField(1234.5, { currency: 'CNY', precision: 18 }, { readonly: true });
124+
expect(normalizeNbsp(container.textContent)).toBe('CN¥1,234.50');
125+
});
126+
127+
// The three pins below are the objectui#4361 "an authored `precision` wins"
128+
// pins, REWRITTEN to the ruled rule on the same inputs: each used to assert
129+
// the authored count and now asserts the currency's.
130+
it('a JPY field declaring `precision: 2` shows whole yen', () => {
131+
// Was `¥1,234.50`.
113132
const { container } = renderField(1234.5, { currency: 'JPY', precision: 2 }, { readonly: true });
114-
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.50');
133+
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
115134
});
116135

117-
it('an authored `precision: 0` on a 2-digit currency still wins', () => {
118-
// The other direction, so the pin cannot be satisfied by "authored wins
119-
// only when it asks for MORE digits".
136+
it('a `precision: 0` on a 2-digit currency keeps its cents', () => {
137+
// Was `$1,235`. The other direction, so the pin cannot be satisfied by
138+
// "`precision` is ignored only when it asks for MORE digits".
120139
const { container } = renderField(1234.5, { currency: 'USD', precision: 0 }, { readonly: true });
121-
expect(normalizeNbsp(container.textContent)).toBe('$1,235');
140+
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
122141
});
123142

124-
it('an authored `precision: 3` on JPY wins', () => {
143+
it('a `precision: 3` on JPY shows whole yen', () => {
144+
// Was `¥1,234.500`.
125145
const { container } = renderField(1234.5, { currency: 'JPY', precision: 3 }, { readonly: true });
126-
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.500');
146+
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
147+
});
148+
});
149+
150+
describe('CurrencyField — a declared `scale` sets no fraction digits either', () => {
151+
// The ruling on objectstack-ai/objectstack#19629 (letter B) takes `scale`
152+
// off the `currency` type and `CurrencyFieldMetadata` does not declare it,
153+
// so the widget does not read it: the currency's own digits hold.
154+
it('`scale: 0` on a USD field keeps its cents', () => {
155+
const { container } = renderField(1234.5, { currency: 'USD', scale: 0 }, { readonly: true });
156+
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
157+
});
158+
159+
it('`scale: 2` on a JPY field shows whole yen', () => {
160+
const { container } = renderField(1234.5, { currency: 'JPY', scale: 2 }, { readonly: true });
161+
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
127162
});
128163
});
129164

@@ -145,29 +180,36 @@ describe('CurrencyField — CONTROL: the 2-digit and no-currency cases are uncha
145180
expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
146181
});
147182

183+
it('a field with NO currency ignores `precision` too', () => {
184+
const { container } = renderField(1234.5, { precision: 18 }, { readonly: true });
185+
expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
186+
});
187+
148188
it('a null value is still the empty marker, not a formatted zero', () => {
149189
const { container } = renderField(null, { currency: 'JPY' }, { readonly: true });
150190
expect(container.textContent).not.toContain('¥');
151191
});
152192
});
153193

154-
describe('CurrencyField — the derived precision is the widget\'s ONE precision', () => {
155-
it('a JPY field with no declared precision offers a whole-unit spinner step', () => {
194+
describe('CurrencyField — the derived width is the widget\'s ONE width', () => {
195+
it('a JPY field offers a whole-unit spinner step', () => {
156196
renderField(1234, { currency: 'JPY' });
157197
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
158198
});
159199

160-
it('a KWD field with no declared precision offers a three-digit step', () => {
200+
it('a KWD field offers a three-digit step', () => {
161201
renderField(1, { currency: 'KWD' });
162202
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.001');
163203
});
164204

165-
it('CONTROL: USD is still 0.01, authored or derived', () => {
166-
const { unmount } = renderField(1, { currency: 'USD' });
205+
it('the step follows the currency whatever `precision` declares', () => {
206+
// Rewritten from "USD is still 0.01, authored or derived", whose second
207+
// half pinned `precision: 2` on JPY to a 0.01 step.
208+
const { unmount } = renderField(1, { currency: 'USD', precision: 18 });
167209
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
168210
unmount();
169211
renderField(1, { currency: 'JPY', precision: 2 });
170-
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
212+
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
171213
});
172214

173215
it('blur rounds a typed amount to the currency\'s own unit', () => {
@@ -176,10 +218,19 @@ describe('CurrencyField — the derived precision is the widget\'s ONE precision
176218
const input = screen.getByRole('spinbutton');
177219
fireEvent.change(input, { target: { value: '1234.56' } });
178220
fireEvent.blur(input, { target: { value: '1234.56' } });
179-
// Before: 1234.56 yen — a sub-unit amount the currency cannot express.
221+
// Before objectui#4361: 1234.56 yen — a sub-unit amount the currency cannot express.
180222
expect(onChange).toHaveBeenLastCalledWith(1235);
181223
});
182224

225+
it('blur on a `precision: 18` USD field rounds to cents', () => {
226+
const onChange = vi.fn();
227+
renderField(null, { currency: 'USD', precision: 18 }, { onChange });
228+
const input = screen.getByRole('spinbutton');
229+
fireEvent.change(input, { target: { value: '1234.567' } });
230+
fireEvent.blur(input, { target: { value: '1234.567' } });
231+
expect(onChange).toHaveBeenLastCalledWith(1234.57);
232+
});
233+
183234
it('CONTROL: blur on a USD field still rounds to cents', () => {
184235
const onChange = vi.fn();
185236
renderField(null, { currency: 'USD' }, { onChange });

0 commit comments

Comments
 (0)