@object-ui/fields 17.3.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* objectui#4553 phase 2 — `formatPercent` was the last formatter outside the
|
|
11
|
+
* display-locale channel, and it was outside it in a different way from its
|
|
12
|
+
* siblings.
|
|
13
|
+
*
|
|
14
|
+
* PR #4557 threaded the gantt tooltip's number and currency rows and measured
|
|
15
|
+
* that percent could not follow: `formatPercent(value, precision)` took no
|
|
16
|
+
* locale parameter, and its whole body was
|
|
17
|
+
* `${percentDisplayValue(value).toFixed(precision)}%`. It built no
|
|
18
|
+
* `Intl.NumberFormat` and never reached `formatDisplayNumber`, so its output
|
|
19
|
+
* was not the MACHINE's locale (the defect its siblings had) but NO locale at
|
|
20
|
+
* all: an ASCII decimal mark, never a grouping separator, byte-identical on
|
|
21
|
+
* every machine on earth.
|
|
22
|
+
*
|
|
23
|
+
* That is why grouping and locale land together here. `1235%` is not merely
|
|
24
|
+
* un-German — it is wrong in en-US too, where the number is `1,235%`. So the
|
|
25
|
+
* English output MOVES, and that move is the fix, not a regression.
|
|
26
|
+
*
|
|
27
|
+
* ── Directions, predicted in writing BEFORE the run, then measured ───────
|
|
28
|
+
* Runner: node v22.22.2 / ICU 78.2 / machine locale en-US.
|
|
29
|
+
*
|
|
30
|
+
* en 1234.5 p0 `1235%` → `1,235%` RED — the grouping move
|
|
31
|
+
* de 1234.5 p0 `1235%` → `1.235 %` RED — separators inverted + NBSP
|
|
32
|
+
* de 80 p0 `80%` → `80 %` RED — de writes a no-break space
|
|
33
|
+
* before the sign; en does not
|
|
34
|
+
* de 12.5 p1 `12.5%` → `12,5 %` RED — the ruling's named form
|
|
35
|
+
* en 80 p0 `80%` → `80%` PIN, green both sides
|
|
36
|
+
* en 12.5 p1 `12.5%` → `12.5%` PIN, green both sides
|
|
37
|
+
* fraction scaling (0.8 → 80%) PIN, green both sides
|
|
38
|
+
*
|
|
39
|
+
* The `en` pins are byte-identical because `en` and the runner's `en-US` agree
|
|
40
|
+
* and because grouping only shows from four digits up — they are NOT evidence
|
|
41
|
+
* the fix works; the `de` cases and the en GROUPING case carry that.
|
|
42
|
+
*
|
|
43
|
+
* Provider-less by construction (objectui#4514): these are pure-function cases
|
|
44
|
+
* that touch no hook. The provider-mounted half of this card lives in
|
|
45
|
+
* `PercentCellRenderer.locale.test.tsx`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { describe, it, expect } from 'vitest';
|
|
49
|
+
import { formatPercent } from '../index';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* German puts a NO-BREAK SPACE (U+00A0) between the number and the percent
|
|
53
|
+
* sign. Written as an escape rather than pasted, so the expectation is
|
|
54
|
+
* readable and greppable in both spellings.
|
|
55
|
+
*/
|
|
56
|
+
const NBSP = '\u00a0';
|
|
57
|
+
|
|
58
|
+
describe('formatPercent groups its output (objectui#4553)', () => {
|
|
59
|
+
/**
|
|
60
|
+
* THE en RED CASE, and the one that justifies calling this a defect rather
|
|
61
|
+
* than a localization nicety: four digits went out ungrouped in English too.
|
|
62
|
+
*/
|
|
63
|
+
it('en groups from four digits up — the output MOVES, and that is the fix', () => {
|
|
64
|
+
expect(formatPercent(1234.5, 0, 'en')).toBe('1,235%');
|
|
65
|
+
expect(formatPercent(1000000, 0, 'en')).toBe('1,000,000%');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** PIN, green both sides: below the grouping threshold nothing changes. */
|
|
69
|
+
it('en output below the grouping threshold is byte-identical (must-not-change)', () => {
|
|
70
|
+
expect(formatPercent(80, 0, 'en')).toBe('80%');
|
|
71
|
+
expect(formatPercent(12.5, 1, 'en')).toBe('12.5%');
|
|
72
|
+
expect(formatPercent(33.33, 2, 'en')).toBe('33.33%');
|
|
73
|
+
expect(formatPercent(0, 0, 'en')).toBe('0%');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('formatPercent follows the display locale (objectui#4553)', () => {
|
|
78
|
+
/**
|
|
79
|
+
* The separators invert AND a no-break space appears before the sign, so the
|
|
80
|
+
* German form cannot coincide with the machine's on this runner.
|
|
81
|
+
*/
|
|
82
|
+
it('de inverts the separators and spaces the percent sign', () => {
|
|
83
|
+
expect(formatPercent(1234.5, 0, 'de')).toBe(`1.235${NBSP}%`);
|
|
84
|
+
expect(formatPercent(12.5, 1, 'de')).toBe(`12,5${NBSP}%`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Even with no separator in play, German still differs from English: the
|
|
89
|
+
* space before the sign is part of the locale's percent CONVENTION, which is
|
|
90
|
+
* what routing through `Intl` buys over appending a literal '%'.
|
|
91
|
+
*/
|
|
92
|
+
it('de spaces the sign even for a value with no separators', () => {
|
|
93
|
+
expect(formatPercent(80, 0, 'de')).toBe(`80${NBSP}%`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** `Intl` accepts `'zh'` verbatim — no mapping table anywhere. */
|
|
97
|
+
it('zh renders its own convention', () => {
|
|
98
|
+
expect(formatPercent(1234.5, 0, 'zh')).toBe('1,235%');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A malformed tag from a tenant config must never take a cell down —
|
|
103
|
+
* `formatDisplayNumber` catches it and retries without the locale.
|
|
104
|
+
*/
|
|
105
|
+
it('a malformed locale tag falls back instead of throwing', () => {
|
|
106
|
+
expect(() => formatPercent(80, 0, 'not a locale')).not.toThrow();
|
|
107
|
+
expect(formatPercent(80, 0, 'not a locale')).toContain('80');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('formatPercent keeps its existing contract (objectui#4553 must-not-change)', () => {
|
|
112
|
+
/**
|
|
113
|
+
* PIN: the fraction/whole disambiguation is `percentDisplayValue`'s, shared
|
|
114
|
+
* with the dashboard measure formatter. The locale parameter must not have
|
|
115
|
+
* moved it.
|
|
116
|
+
*/
|
|
117
|
+
it('still scales a fraction-stored percent and passes a whole one through', () => {
|
|
118
|
+
expect(formatPercent(0.8, 0, 'en')).toBe('80%');
|
|
119
|
+
expect(formatPercent(0.5, 0, 'en')).toBe('50%');
|
|
120
|
+
expect(formatPercent(0.075, 1, 'en')).toBe('7.5%');
|
|
121
|
+
// >= 1 is already in display magnitude and is NOT scaled again.
|
|
122
|
+
expect(formatPercent(80, 0, 'en')).toBe('80%');
|
|
123
|
+
expect(formatPercent(100, 0, 'en')).toBe('100%');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still honors the precision it is given', () => {
|
|
127
|
+
expect(formatPercent(33.333, 0, 'en')).toBe('33%');
|
|
128
|
+
expect(formatPercent(33.333, 1, 'en')).toBe('33.3%');
|
|
129
|
+
expect(formatPercent(33.333, 2, 'en')).toBe('33.33%');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('still handles negatives', () => {
|
|
133
|
+
expect(formatPercent(-45.5, 1, 'en')).toBe('-45.5%');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The parameter is OPTIONAL and third, matching `formatNumber(value,
|
|
138
|
+
* decimals, locale)` and `formatCurrency(value, currency, locale)`. An
|
|
139
|
+
* existing caller passing nothing still gets the runtime default locale —
|
|
140
|
+
* though it now also gets grouping, which is the deliberate output move.
|
|
141
|
+
*/
|
|
142
|
+
it('is callable with no locale, and with no precision either', () => {
|
|
143
|
+
expect(() => formatPercent(80)).not.toThrow();
|
|
144
|
+
expect(formatPercent(80)).toBe('80%');
|
|
145
|
+
expect(formatPercent(80, 0)).toBe('80%');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* objectui#4590 — `formatPercent` rounds a tie the wrong way, because it renders
|
|
11
|
+
* a value that is ALREADY in percentage points through `Intl`'s
|
|
12
|
+
* `style: 'percent'`, which wants a FRACTION.
|
|
13
|
+
*
|
|
14
|
+
* The old body divided by 100 so `Intl` could multiply straight back:
|
|
15
|
+
*
|
|
16
|
+
* formatDisplayNumber(displayValue / 100, { style: 'percent', … })
|
|
17
|
+
*
|
|
18
|
+
* That round trip is not value-preserving. `Intl` formats from the SHORTEST
|
|
19
|
+
* decimal representation of the double it is handed, and `displayValue / 100`
|
|
20
|
+
* has a different shortest representation from `displayValue`: `1.005` is
|
|
21
|
+
* `1.005`, but `1.005 / 100` is `0.010049999999999999`, which percent-scales to
|
|
22
|
+
* `1.0049999999999999` and rounds DOWN. The DIVISION loses the digit, not the
|
|
23
|
+
* rounding. It is therefore a numeral defect and not a convention one — it
|
|
24
|
+
* reproduces identically in every locale, in Arabic-Indic digits too.
|
|
25
|
+
*
|
|
26
|
+
* The fix is the option #4576 / PR #4589 put in `@object-ui/core` for exactly
|
|
27
|
+
* this: `style: 'percentPoints'` formats the points DIRECTLY (`Intl`'s
|
|
28
|
+
* `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`), which was
|
|
29
|
+
* measured to give a byte-identical percent affix to `style: 'percent'` across
|
|
30
|
+
* all 171 locale tags tested.
|
|
31
|
+
*
|
|
32
|
+
* ── PREDICTIONS, written before the run ─────────────────────────────────────
|
|
33
|
+
* Runner measured: node v22.22.2, ICU 78.2, machine locale en-US.
|
|
34
|
+
*
|
|
35
|
+
* RED before the fix — the numerals:
|
|
36
|
+
* formatPercent(1.005, 2, 'en-US') `1.00%` → `1.01%`
|
|
37
|
+
* formatPercent(1.025, 2, 'en-US') `1.02%` → `1.03%`
|
|
38
|
+
* formatPercent(1.45, 1, 'en-US') `1.4%` → `1.5%`
|
|
39
|
+
* formatPercent(1.055, 2, 'en-US') `1.05%` → `1.06%`
|
|
40
|
+
* formatPercent(99999.995, 2, 'en') `99,999.99%` → `100,000.00%`
|
|
41
|
+
* MAX_SAFE_INTEGER p0 `9,007,199,254,740,990%` → `…991%`
|
|
42
|
+
* 1e23 p0 `99,999,999,999,999,990,000,000%`
|
|
43
|
+
* → `100,000,000,000,000,000,000,000%`
|
|
44
|
+
* the same four ties in de-DE / tr-TR / ar-EG — the locale-independence
|
|
45
|
+
* claim, red in every one of them
|
|
46
|
+
*
|
|
47
|
+
* GREEN ON BOTH SIDES — the must-not-change set, and the STOP condition if
|
|
48
|
+
* any of it moves (this card is NUMERAL-only; a convention move is a defect
|
|
49
|
+
* in the fix, not a pin to update):
|
|
50
|
+
* the percent CONVENTION of all ten locales at 1234.5 p1 — de/fr/ru/sv's
|
|
51
|
+
* no-break space, tr's PREFIX sign, ar's U+066A + U+061C, en/ja/zh's bare
|
|
52
|
+
* suffix, fr's U+202F group separator, bn's Bengali digits
|
|
53
|
+
* the sign position and glyph on negatives (sv-SE's U+2212 included)
|
|
54
|
+
* the affix parity check against `Intl`'s own `style: 'percent'`
|
|
55
|
+
* `percentDisplayValue`'s fraction/whole scaling — upstream of the render
|
|
56
|
+
* and deliberately untouched by this card
|
|
57
|
+
*
|
|
58
|
+
* MEASURED, this call shape, old route vs new: 10 locales x 18 values x 4
|
|
59
|
+
* precisions = 720 combinations — 0 convention diffs, 130 numeral diffs (13 per
|
|
60
|
+
* locale, the SAME 13 in every locale). On the wide en-US grid the issue
|
|
61
|
+
* quantified (0.005 steps to 2000, precisions 0/1/2) 27,577 of 1,200,003 forms
|
|
62
|
+
* move. Every one is a last-digit-off-by-one.
|
|
63
|
+
*
|
|
64
|
+
* Every non-ASCII byte below is written as a `\u` escape, never pasted.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { describe, it, expect } from 'vitest';
|
|
68
|
+
import { formatPercent } from '../index';
|
|
69
|
+
|
|
70
|
+
/** U+00A0 NO-BREAK SPACE — de/fr/ru/sv put one before the percent sign. */
|
|
71
|
+
const NBSP = '\u00a0';
|
|
72
|
+
/** U+202F NARROW NO-BREAK SPACE — fr's group separator. */
|
|
73
|
+
const NNBSP = '\u202f';
|
|
74
|
+
/** U+066A ARABIC PERCENT SIGN, U+061C ARABIC LETTER MARK. */
|
|
75
|
+
const ARABIC_PERCENT = '\u066a';
|
|
76
|
+
const ALM = '\u061c';
|
|
77
|
+
|
|
78
|
+
describe('formatPercent rounds a tie half-up on the authored decimal (#4590)', () => {
|
|
79
|
+
/**
|
|
80
|
+
* MOVING PINS — the issue's measured table, verbatim. Each was the artefact
|
|
81
|
+
* of the `/ 100` round trip; the right-hand column is half-up on the decimal
|
|
82
|
+
* the author actually wrote.
|
|
83
|
+
*/
|
|
84
|
+
it.each([
|
|
85
|
+
[1.005, 2, '1.01%', '1.00%'],
|
|
86
|
+
[1.025, 2, '1.03%', '1.02%'],
|
|
87
|
+
[1.45, 1, '1.5%', '1.4%'],
|
|
88
|
+
[1.055, 2, '1.06%', '1.05%'],
|
|
89
|
+
])('formatPercent(%s, %s) is %s (was %s)', (value, precision, expected) => {
|
|
90
|
+
expect(formatPercent(value, precision, 'en-US')).toBe(expected);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A tie that carries into a new digit COLUMN rather than just flipping the
|
|
95
|
+
* last one — the round trip lost the carry as well as the digit.
|
|
96
|
+
*/
|
|
97
|
+
it('carries across the grouping boundary: 99999.995 to 2 decimals', () => {
|
|
98
|
+
// was `99,999.99%`
|
|
99
|
+
expect(formatPercent(99999.995, 2, 'en-US')).toBe('100,000.00%');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The defect is in the VALUE, not the convention, so it must reproduce in
|
|
104
|
+
* every locale — including one that writes the sign in front and one that
|
|
105
|
+
* uses neither ASCII digits nor an ASCII sign.
|
|
106
|
+
*/
|
|
107
|
+
it('the same ties move in every locale — this is a numeral defect', () => {
|
|
108
|
+
expect(formatPercent(1.005, 2, 'de-DE')).toBe(`1,01${NBSP}%`); // was `1,00 %`
|
|
109
|
+
expect(formatPercent(1.45, 1, 'de-DE')).toBe(`1,5${NBSP}%`); // was `1,4 %`
|
|
110
|
+
expect(formatPercent(1.005, 2, 'tr-TR')).toBe('%1,01'); // was `%1,00`
|
|
111
|
+
// U+0661 U+066B U+0660 U+0661 — Arabic-Indic `1`, decimal separator, `0`, `1`
|
|
112
|
+
expect(formatPercent(1.005, 2, 'ar-EG')).toBe(
|
|
113
|
+
`\u0661\u066b\u0660\u0661${ARABIC_PERCENT}${ALM}`,
|
|
114
|
+
); // was `\u0661\u066b\u0660\u0660` + the same affix
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('formatPercent keeps every digit at the top of the double range (#4590)', () => {
|
|
119
|
+
/**
|
|
120
|
+
* MOVING PINS, before/after verbatim. `Number.MAX_SAFE_INTEGER` percentage
|
|
121
|
+
* points used to render with its last digit replaced by a zero, and 1e23 used
|
|
122
|
+
* to render as a number that is not 1e23 at all. Both are the same artefact
|
|
123
|
+
* as the ties, at the other end of the range: divide by 100 and the shortest
|
|
124
|
+
* representation of the quotient no longer round-trips.
|
|
125
|
+
*/
|
|
126
|
+
it('MAX_SAFE_INTEGER keeps its last digit', () => {
|
|
127
|
+
// was `9,007,199,254,740,990%` — a 1 turned into a 0
|
|
128
|
+
expect(formatPercent(Number.MAX_SAFE_INTEGER, 0, 'en-US')).toBe('9,007,199,254,740,991%');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('1e23 renders as 1e23', () => {
|
|
132
|
+
// was `99,999,999,999,999,990,000,000%`
|
|
133
|
+
expect(formatPercent(1e23, 0, 'en-US')).toBe('100,000,000,000,000,000,000,000%');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('MUST NOT CHANGE: the locale percent CONVENTION (#4590 is numeral-only)', () => {
|
|
138
|
+
/**
|
|
139
|
+
* The affix is what `style: 'percentPoints'` promises to preserve, so this is
|
|
140
|
+
* the half that proves the fix did not become a convention change. Byte-exact
|
|
141
|
+
* strings, chosen at a magnitude and precision where no tie is in play — they
|
|
142
|
+
* are GREEN on both sides of the fix, and a diff here is a STOP condition.
|
|
143
|
+
*/
|
|
144
|
+
it.each([
|
|
145
|
+
['en-US', '1,234.5%'],
|
|
146
|
+
['de-DE', `1.234,5${NBSP}%`],
|
|
147
|
+
['fr-FR', `1${NNBSP}234,5${NBSP}%`],
|
|
148
|
+
['tr-TR', '%1.234,5'], // sign in FRONT
|
|
149
|
+
['ar-EG', `\u0661\u066c\u0662\u0663\u0664\u066b\u0665${ARABIC_PERCENT}${ALM}`],
|
|
150
|
+
['ja-JP', '1,234.5%'],
|
|
151
|
+
['zh-CN', '1,234.5%'],
|
|
152
|
+
['ru-RU', `1${NBSP}234,5${NBSP}%`],
|
|
153
|
+
['sv-SE', `1${NBSP}234,5${NBSP}%`],
|
|
154
|
+
['bn-IN', `\u09e7,\u09e8\u09e9\u09ea.\u09eb%`],
|
|
155
|
+
])('%s renders its own percent convention, unchanged', (locale, expected) => {
|
|
156
|
+
expect(formatPercent(1234.5, 1, locale)).toBe(expected);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sign POSITION and sign GLYPH are convention too — Turkish puts the minus
|
|
161
|
+
* outside its prefixed percent sign, Arabic leads with U+061C, and Swedish
|
|
162
|
+
* uses U+2212 MINUS SIGN rather than ASCII hyphen-minus. None of it moves.
|
|
163
|
+
*/
|
|
164
|
+
it.each([
|
|
165
|
+
['en-US', '-45.5%'],
|
|
166
|
+
['de-DE', `-45,5${NBSP}%`],
|
|
167
|
+
['tr-TR', '-%45,5'],
|
|
168
|
+
['ar-EG', `${ALM}-\u0664\u0665\u066b\u0665${ARABIC_PERCENT}${ALM}`],
|
|
169
|
+
['sv-SE', `\u221245,5${NBSP}%`],
|
|
170
|
+
])('%s keeps its negative-sign convention', (locale, expected) => {
|
|
171
|
+
expect(formatPercent(-45.5, 1, locale)).toBe(expected);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The parity claim asserted rather than trusted: the affix `formatPercent`
|
|
176
|
+
* now produces must be the one `Intl`'s OWN `style: 'percent'` produces for
|
|
177
|
+
* the same locale. Comparing the affix alone (digits stripped) is deliberate
|
|
178
|
+
* — the numerals are exactly what this card moves, so a whole-string compare
|
|
179
|
+
* would re-assert the defect.
|
|
180
|
+
*/
|
|
181
|
+
it('the affix is byte-identical to Intl style:percent, for every locale here', () => {
|
|
182
|
+
const affix = (rendered: string) => rendered.replace(/[\p{Nd}]/gu, '');
|
|
183
|
+
for (const locale of ['en-US', 'de-DE', 'fr-FR', 'tr-TR', 'ar-EG', 'ja-JP',
|
|
184
|
+
'zh-CN', 'ru-RU', 'sv-SE', 'bn-IN']) {
|
|
185
|
+
const cell = formatPercent(1234.5, 1, locale);
|
|
186
|
+
const intl = new Intl.NumberFormat(locale, {
|
|
187
|
+
style: 'percent',
|
|
188
|
+
minimumFractionDigits: 1,
|
|
189
|
+
maximumFractionDigits: 1,
|
|
190
|
+
}).format(12.345);
|
|
191
|
+
expect(affix(cell), `percent affix for ${locale}`).toBe(affix(intl));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('MUST NOT CHANGE: percent SCALING is upstream of the render (#4590)', () => {
|
|
197
|
+
/**
|
|
198
|
+
* `percentDisplayValue` (a stored fraction below 1 scales by 100, a value at
|
|
199
|
+
* or above 1 passes through) decides WHICH number gets rendered; this card
|
|
200
|
+
* changes only HOW that number is rendered. Pinned unmoved so a future reader
|
|
201
|
+
* cannot mistake the two halves for one.
|
|
202
|
+
*/
|
|
203
|
+
it('a stored fraction still scales, a stored whole number still does not', () => {
|
|
204
|
+
expect(formatPercent(0.8, 0, 'en-US')).toBe('80%');
|
|
205
|
+
expect(formatPercent(0.5, 0, 'en-US')).toBe('50%');
|
|
206
|
+
expect(formatPercent(0.075, 1, 'en-US')).toBe('7.5%');
|
|
207
|
+
expect(formatPercent(80, 0, 'en-US')).toBe('80%');
|
|
208
|
+
expect(formatPercent(100, 0, 'en-US')).toBe('100%');
|
|
209
|
+
// The boundary itself: 1 is NOT a fraction, so it stays 1%.
|
|
210
|
+
expect(formatPercent(1, 0, 'en-US')).toBe('1%');
|
|
211
|
+
// …which is why the tie cases above are authored just above 1.
|
|
212
|
+
expect(formatPercent(0.9999, 0, 'en-US')).toBe('100%');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('a malformed locale tag still falls back instead of throwing', () => {
|
|
216
|
+
expect(() => formatPercent(80, 0, 'not a locale')).not.toThrow();
|
|
217
|
+
expect(formatPercent(80, 0, 'not a locale')).toContain('80');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('is still callable with no locale and no precision', () => {
|
|
221
|
+
expect(formatPercent(80)).toBe('80%');
|
|
222
|
+
expect(formatPercent(80, 0)).toBe('80%');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* End-to-end: BOTH select paths associate the form's visible label with the
|
|
11
|
+
* control the user operates (objectui#3976, the built-in half of #3306).
|
|
12
|
+
*
|
|
13
|
+
* There are two `select` paths through the form renderer and they do not share
|
|
14
|
+
* an implementation:
|
|
15
|
+
*
|
|
16
|
+
* - **registered widget** — `type: 'field:select'` (what `mapFieldTypeToFormType`
|
|
17
|
+
* emits for object-driven forms) resolves to this package's `SelectField`.
|
|
18
|
+
* objectui#3306 moved its DOM pass-through from Radix's `Select.Root` — which
|
|
19
|
+
* renders no element and silently drops what it does not recognise — onto
|
|
20
|
+
* `SelectTrigger`, the focusable `<button role="combobox">`.
|
|
21
|
+
* - **built-in branch** — a hand-written `type: 'select'` never reaches the
|
|
22
|
+
* registry at all (`'select'` is a `BUILTIN_FIELD_TYPES` member), so the form
|
|
23
|
+
* renderer's own `Select` branch renders it. That branch kept spreading onto
|
|
24
|
+
* Root until objectui#3976: the visible label pointed at an id no element
|
|
25
|
+
* carried, `getByLabelText` found nothing, and `aria-describedby` /
|
|
26
|
+
* `aria-invalid` / `aria-required` never reached the DOM either.
|
|
27
|
+
*
|
|
28
|
+
* The paths are pinned side by side, in one file, precisely because the fork is
|
|
29
|
+
* what caused the bug: #3306 fixed one half and the other silently kept the
|
|
30
|
+
* defect for as long as nobody wrote a bare `type: 'select'` into a test. The
|
|
31
|
+
* registered half here is a POSITIVE CONTROL — it must stay green through any
|
|
32
|
+
* change to the built-in branch.
|
|
33
|
+
*
|
|
34
|
+
* The widget is registered raw rather than through `registerAllFields()`, which
|
|
35
|
+
* wraps every loader in `React.lazy`: an unbounded module load inside a bounded
|
|
36
|
+
* `findBy`/`waitFor` window is the repo's known flake generator (AGENTS.md
|
|
37
|
+
* 测试纪律, objectui#3010). Same component, no Suspense boundary.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
41
|
+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
42
|
+
import '@testing-library/jest-dom';
|
|
43
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
44
|
+
// Module scope: pulls in the form renderer's registration side effect.
|
|
45
|
+
import '@object-ui/components';
|
|
46
|
+
|
|
47
|
+
import { SelectField } from '../widgets/SelectField';
|
|
48
|
+
|
|
49
|
+
const OPTIONS = [
|
|
50
|
+
{ label: 'Alpha', value: 'alpha' },
|
|
51
|
+
{ label: 'Beta', value: 'beta' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
ComponentRegistry.register('select', SelectField as any, {
|
|
56
|
+
namespace: 'field',
|
|
57
|
+
skipFallback: true,
|
|
58
|
+
});
|
|
59
|
+
}, 30000);
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
if (!(Element.prototype as any).scrollIntoView) {
|
|
63
|
+
(Element.prototype as any).scrollIntoView = () => {};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
cleanup();
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** The real form renderer hosting one field. */
|
|
73
|
+
function renderForm(field: any, defaultValue: unknown = undefined) {
|
|
74
|
+
const Form = ComponentRegistry.get('form')!;
|
|
75
|
+
return render(
|
|
76
|
+
<Form
|
|
77
|
+
schema={{
|
|
78
|
+
type: 'form',
|
|
79
|
+
mode: 'create',
|
|
80
|
+
showSubmit: true,
|
|
81
|
+
showCancel: false,
|
|
82
|
+
submitLabel: 'Create',
|
|
83
|
+
defaultValues: { [field.name]: defaultValue },
|
|
84
|
+
fields: [field],
|
|
85
|
+
onSubmit: () => {},
|
|
86
|
+
}}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every `<label for=…>` in the document, paired with its resolved target. */
|
|
92
|
+
function labelTargets(): Array<{ text: string; forId: string; resolves: boolean }> {
|
|
93
|
+
return Array.from(document.querySelectorAll('label'))
|
|
94
|
+
.map((el) => ({
|
|
95
|
+
text: (el.textContent ?? '').trim(),
|
|
96
|
+
forId: el.getAttribute('for') ?? '',
|
|
97
|
+
resolves: !!el.getAttribute('for') && !!document.getElementById(el.getAttribute('for')!),
|
|
98
|
+
}))
|
|
99
|
+
.filter((l) => l.forId !== '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The trigger button, by role — fails loudly if it stops being a combobox. */
|
|
103
|
+
function trigger(name: string): Element {
|
|
104
|
+
const el = document.querySelector(`[data-field="${name}"] [role="combobox"]`);
|
|
105
|
+
if (!el) throw new Error(`no select trigger rendered for field "${name}"`);
|
|
106
|
+
return el;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The guarantees a select must give whichever path rendered it. Run twice, so
|
|
113
|
+
* neither path can drift away from the other again.
|
|
114
|
+
*/
|
|
115
|
+
const PATHS = [
|
|
116
|
+
['built-in branch (bare `type: select`, objectui#3976)', 'select'],
|
|
117
|
+
['registered widget (`field:select`, objectui#3306 — positive control)', 'field:select'],
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
describe.each(PATHS)('%s', (_label, type) => {
|
|
121
|
+
const field = { name: 'choice', label: 'Choice', type, options: OPTIONS };
|
|
122
|
+
|
|
123
|
+
it('emits no label pointing at an id nothing carries', () => {
|
|
124
|
+
renderForm(field);
|
|
125
|
+
|
|
126
|
+
const labels = labelTargets();
|
|
127
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
128
|
+
expect(labels.filter((l) => !l.resolves)).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('resolves the visible label to the trigger button', () => {
|
|
132
|
+
renderForm(field);
|
|
133
|
+
|
|
134
|
+
// `getAllByLabelText` walks `for`→id, so it guards the association itself:
|
|
135
|
+
// on the built-in path this used to find NOTHING at all.
|
|
136
|
+
const labelled = screen.getAllByLabelText('Choice');
|
|
137
|
+
expect(labelled).toHaveLength(1);
|
|
138
|
+
expect(labelled[0]).toBe(trigger('choice'));
|
|
139
|
+
expect(labelled[0].tagName).toBe('BUTTON');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('gives the combobox the label as its accessible name', () => {
|
|
143
|
+
renderForm(field);
|
|
144
|
+
|
|
145
|
+
expect(screen.getByRole('combobox', { name: 'Choice' })).toBe(trigger('choice'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('carries the required state on the trigger', () => {
|
|
149
|
+
renderForm({ ...field, required: true });
|
|
150
|
+
|
|
151
|
+
expect(trigger('choice')).toHaveAttribute('aria-required', 'true');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('announces invalid on the trigger and links the message', async () => {
|
|
155
|
+
renderForm({ ...field, required: true }, null);
|
|
156
|
+
|
|
157
|
+
// A valid field SAYS it is valid rather than staying mute (#3222/#3306).
|
|
158
|
+
expect(trigger('choice')).toHaveAttribute('aria-invalid', 'false');
|
|
159
|
+
|
|
160
|
+
submit();
|
|
161
|
+
|
|
162
|
+
const message = await screen.findByText('Choice is required');
|
|
163
|
+
await waitFor(() => expect(trigger('choice')).toHaveAttribute('aria-invalid', 'true'));
|
|
164
|
+
expect(message.id).not.toBe('');
|
|
165
|
+
const describedBy = trigger('choice').getAttribute('aria-describedby') ?? '';
|
|
166
|
+
expect(describedBy.split(/\s+/)).toContain(message.id);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps `name` on Root, where the submitted hidden select is', () => {
|
|
170
|
+
// The whitelist both paths share: `name` is the ONE key Root consumes — it
|
|
171
|
+
// forwards it to the hidden native `<select>` that takes part in form
|
|
172
|
+
// submission. On the trigger it would sit uselessly on a non-submitter.
|
|
173
|
+
renderForm(field);
|
|
174
|
+
|
|
175
|
+
expect(document.querySelector('[data-field="choice"] select[aria-hidden="true"]')).toHaveAttribute(
|
|
176
|
+
'name',
|
|
177
|
+
'choice',
|
|
178
|
+
);
|
|
179
|
+
expect(trigger('choice')).not.toHaveAttribute('name');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('the fork itself (objectui#3976 / #3231)', () => {
|
|
184
|
+
it('a bare `type: select` renders the BUILT-IN branch even though field:select resolves', () => {
|
|
185
|
+
// Measured, not inferred — this is the fact that made #3976 a built-in-side
|
|
186
|
+
// bug rather than a duplicate of #3306: `renderFieldComponent` skips the
|
|
187
|
+
// registry entirely for a `BUILTIN_FIELD_TYPES` member, so `SelectField` is
|
|
188
|
+
// NOT what renders a hand-written `type: 'select'`, even here where the real
|
|
189
|
+
// widget is registered under `field:select`.
|
|
190
|
+
expect(ComponentRegistry.get('field:select')).toBe(SelectField);
|
|
191
|
+
|
|
192
|
+
renderForm({ name: 'choice', label: 'Choice', type: 'select', options: OPTIONS });
|
|
193
|
+
|
|
194
|
+
// `SelectField` stamps this locator on the trigger it renders; the built-in
|
|
195
|
+
// branch does not. Same role, different implementation.
|
|
196
|
+
expect(document.querySelector('[data-testid="select-trigger-choice"]')).toBeNull();
|
|
197
|
+
expect(trigger('choice')).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
});
|