|
| 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#11752 — a summary (roll-up) field draws one stored value one way, |
| 11 | + * in the read-only form face (`SummaryField`) and in the table cell |
| 12 | + * (`getCellRenderer('summary')`, which is `FormulaCellRenderer`). |
| 13 | + * |
| 14 | + * Before this card the form face formatted the value by itself, by the |
| 15 | + * roll-up's aggregation `function`: `count` with `String(value)`, the other |
| 16 | + * four with `toFixed(2)`. So a `sum` over `15750.5` read `15750.50`, ungrouped |
| 17 | + * and in every locale, while the cell drew the same JS number through the |
| 18 | + * number cell (objectui#11683) and read `15,750.5`. |
| 19 | + * |
| 20 | + * The rule both faces now read, whatever the function: |
| 21 | + * |
| 22 | + * - the shared emptiness floor (`isEmptyValue`) on the coerced value; |
| 23 | + * - a JS number through `formatNumberFieldValue`, the call every number face |
| 24 | + * makes: the display locale's grouping, the width a declared `scale` |
| 25 | + * gives, the value's natural precision when none is declared. A `count` |
| 26 | + * is a whole number, so it reads whole; |
| 27 | + * - anything else as the text the cell prints. |
| 28 | + * |
| 29 | + * The pins run per aggregation function x face x locale, then compare each |
| 30 | + * face with the `number` field's own face for the same value, and end with an |
| 31 | + * equality leg that compares the two summary faces with each other rather than |
| 32 | + * with a literal, so a change that moves one face alone goes red. |
| 33 | + * |
| 34 | + * Every case renders through a real `I18nProvider` and `LocalizationProvider`, |
| 35 | + * so `zh-CN` is a Chinese session on both channels. `textContent` is the |
| 36 | + * measured property: it is what a copy takes and a screen reader reads. |
| 37 | + */ |
| 38 | +import React from 'react'; |
| 39 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 40 | +import { render, cleanup } from '@testing-library/react'; |
| 41 | +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; |
| 42 | +import { NumberCellRenderer, NumberField, getCellRenderer } from '../index'; |
| 43 | +import { SummaryField } from '../widgets/SummaryField'; |
| 44 | + |
| 45 | +afterEach(() => cleanup()); |
| 46 | + |
| 47 | +const noop = () => {}; |
| 48 | + |
| 49 | +/** A session per locale: the UI language and the display locale agree. */ |
| 50 | +const SESSIONS = { |
| 51 | + 'en-US': 'en', |
| 52 | + 'zh-CN': 'zh', |
| 53 | +} as const; |
| 54 | +type Locale = keyof typeof SESSIONS; |
| 55 | +const LOCALES = Object.keys(SESSIONS) as Locale[]; |
| 56 | + |
| 57 | +function renderSession(locale: Locale, node: React.ReactElement) { |
| 58 | + return render( |
| 59 | + <I18nProvider config={{ defaultLanguage: SESSIONS[locale], detectBrowserLanguage: false }} persistLanguage={false}> |
| 60 | + <LocalizationProvider value={{ locale }}>{node}</LocalizationProvider> |
| 61 | + </I18nProvider>, |
| 62 | + ); |
| 63 | +} |
| 64 | + |
| 65 | +function textOf(locale: Locale, node: React.ReactElement): string { |
| 66 | + const { container } = renderSession(locale, node); |
| 67 | + const text = container.textContent ?? ''; |
| 68 | + cleanup(); |
| 69 | + return text; |
| 70 | +} |
| 71 | + |
| 72 | +const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; |
| 73 | +type Fn = (typeof FUNCTIONS)[number]; |
| 74 | +type Extra = Record<string, unknown>; |
| 75 | + |
| 76 | +/** A roll-up shaped like the showcase's `expense_report.total_amount`. */ |
| 77 | +const summaryField = (fn: Fn, extra: Extra = {}) => |
| 78 | + ({ |
| 79 | + type: 'summary', |
| 80 | + name: 'total_amount', |
| 81 | + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: fn }, |
| 82 | + ...extra, |
| 83 | + }) as never; |
| 84 | + |
| 85 | +const Cell = getCellRenderer('summary'); |
| 86 | + |
| 87 | +/** The read-only form face, as a record form renders a summary widget. */ |
| 88 | +const formFace = (fn: Fn, value: unknown, extra: Extra = {}) => ( |
| 89 | + <SummaryField value={value} onChange={noop} field={summaryField(fn, extra)} readonly /> |
| 90 | +); |
| 91 | + |
| 92 | +/** The table cell face, resolved the way every list surface resolves it. */ |
| 93 | +const cellFace = (fn: Fn, value: unknown, extra: Extra = {}) => ( |
| 94 | + <Cell value={value} field={summaryField(fn, extra)} /> |
| 95 | +); |
| 96 | + |
| 97 | +const FACES = [ |
| 98 | + ['form', formFace], |
| 99 | + ['cell', cellFace], |
| 100 | +] as const; |
| 101 | + |
| 102 | +/** The shared "No value" affordance. */ |
| 103 | +const affordance = (root: HTMLElement) => root.querySelector('[data-slot="empty-value"]'); |
| 104 | + |
| 105 | +/** What a `number` field draws for the same value, per face. */ |
| 106 | +const numberCell = (locale: Locale, value: number, extra: Extra = {}) => |
| 107 | + textOf(locale, <NumberCellRenderer value={value} field={{ type: 'number', name: 'n', ...extra } as never} />); |
| 108 | +const numberForm = (locale: Locale, value: number, extra: Extra = {}) => |
| 109 | + textOf(locale, <NumberField value={value} onChange={noop} field={{ type: 'number', name: 'n', ...extra } as never} readonly />); |
| 110 | + |
| 111 | +// ── per function x face x locale ────────────────────────────────────────── |
| 112 | + |
| 113 | +/** The grouped reading of `15750.5`; both sessions group with a comma. */ |
| 114 | +const SUM_TEXT: Record<Locale, string> = { 'en-US': '15,750.5', 'zh-CN': '15,750.5' }; |
| 115 | +/** The grouped reading of a count of `12000`. */ |
| 116 | +const COUNT_TEXT: Record<Locale, string> = { 'en-US': '12,000', 'zh-CN': '12,000' }; |
| 117 | + |
| 118 | +describe('every aggregation function reads the number face, in both faces (objectui#11752)', () => { |
| 119 | + describe.each(LOCALES)('%s', (locale) => { |
| 120 | + describe.each(['sum', 'avg', 'min', 'max'] as const)('%s', (fn) => { |
| 121 | + it.each(FACES)("%s — 15750.5 reads grouped at its own precision, not toFixed's 15750.50", (_face, draw) => { |
| 122 | + const text = textOf(locale, draw(fn, 15750.5)); |
| 123 | + expect(text).toBe(SUM_TEXT[locale]); |
| 124 | + expect(text).not.toBe('15750.50'); |
| 125 | + }); |
| 126 | + |
| 127 | + it.each(FACES)('%s — a declared scale is the width', (_face, draw) => { |
| 128 | + expect(textOf(locale, draw(fn, 15750.5, { scale: 2 }))).toBe(`${SUM_TEXT[locale]}0`); |
| 129 | + }); |
| 130 | + }); |
| 131 | + |
| 132 | + describe('count', () => { |
| 133 | + it.each(FACES)('%s — a count stays whole, and groups like any number', (_face, draw) => { |
| 134 | + const text = textOf(locale, draw('count', 12000)); |
| 135 | + expect(text).toBe(COUNT_TEXT[locale]); |
| 136 | + expect(text).not.toMatch(/[.]\d/); |
| 137 | + }); |
| 138 | + |
| 139 | + it.each(FACES)('%s — a small count reads as it is', (_face, draw) => { |
| 140 | + expect(textOf(locale, draw('count', 42))).toBe('42'); |
| 141 | + }); |
| 142 | + }); |
| 143 | + |
| 144 | + describe.each(FUNCTIONS)('%s', (fn) => { |
| 145 | + // `count` and `sum` take `0` over an empty child set (objectstack's |
| 146 | + // `summaryEmptySetValue`): a stored zero is a value, not an empty one. |
| 147 | + it.each(FACES)('%s — 0 reads 0, not 0.00 and not the empty affordance', (_face, draw) => { |
| 148 | + const { container } = renderSession(locale, draw(fn, 0)); |
| 149 | + expect(container.textContent).toBe('0'); |
| 150 | + expect(affordance(container)).toBeNull(); |
| 151 | + }); |
| 152 | + |
| 153 | + it.each(FACES)('%s — the number is not drawn in monospace', (_face, draw) => { |
| 154 | + const { container } = renderSession(locale, draw(fn, 15750.5)); |
| 155 | + expect(container.querySelector('.font-mono')).toBeNull(); |
| 156 | + }); |
| 157 | + }); |
| 158 | + }); |
| 159 | + |
| 160 | + it.each(FACES)('%s — the display locale reaches the formatter: de-DE groups with a dot', (_face, draw) => { |
| 161 | + const { container } = render( |
| 162 | + <LocalizationProvider value={{ locale: 'de-DE' }}>{draw('sum', 15750.5)}</LocalizationProvider>, |
| 163 | + ); |
| 164 | + expect(container.textContent).toBe('15.750,5'); |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +// ── the number field's own faces ────────────────────────────────────────── |
| 169 | + |
| 170 | +describe("each summary face is the `number` field's face for the same value (objectui#11752)", () => { |
| 171 | + const VALUES: Array<[string, number, Extra]> = [ |
| 172 | + ['a fraction', 15750.5, {}], |
| 173 | + ['a whole count', 12000, {}], |
| 174 | + ['an average with no declared width', 10 / 3, {}], |
| 175 | + ['a declared scale', 15750.5, { scale: 2 }], |
| 176 | + ['a negative minimum', -42.25, {}], |
| 177 | + ]; |
| 178 | + |
| 179 | + describe.each(LOCALES)('%s', (locale) => { |
| 180 | + it.each(VALUES)('%s', (_label, value, extra) => { |
| 181 | + for (const fn of FUNCTIONS) { |
| 182 | + expect(textOf(locale, formFace(fn, value, extra)), `form, ${fn}`).toBe(numberForm(locale, value, extra)); |
| 183 | + expect(textOf(locale, cellFace(fn, value, extra)), `cell, ${fn}`).toBe(numberCell(locale, value, extra)); |
| 184 | + } |
| 185 | + }); |
| 186 | + }); |
| 187 | +}); |
| 188 | + |
| 189 | +// ── empty and non-number values ─────────────────────────────────────────── |
| 190 | + |
| 191 | +describe('empty and non-number values read the cell text (objectui#11752)', () => { |
| 192 | + // `min` / `max` / `avg` stay `null` over an empty child set. The form face |
| 193 | + // read `value == null`, so `''` and `[]` drew a blank span with no |
| 194 | + // accessible name here and the shared affordance in the table. |
| 195 | + it.each(FACES)("%s — null, undefined, '' and [] draw the shared empty affordance", (_face, draw) => { |
| 196 | + for (const fn of FUNCTIONS) { |
| 197 | + for (const value of [null, undefined, '', []]) { |
| 198 | + const { container } = renderSession('en-US', draw(fn, value)); |
| 199 | + expect(affordance(container), `${fn} holding ${JSON.stringify(value)}`).not.toBeNull(); |
| 200 | + cleanup(); |
| 201 | + } |
| 202 | + } |
| 203 | + }); |
| 204 | + |
| 205 | + // A summary declares no `returnType`, so only a JS number is a number: a |
| 206 | + // string of digits stays the text the cell prints. |
| 207 | + it.each(FACES)('%s — a string of digits stays text', (_face, draw) => { |
| 208 | + expect(textOf('en-US', draw('sum', '15750.5'))).toBe('15750.5'); |
| 209 | + }); |
| 210 | +}); |
| 211 | + |
| 212 | +// ── the equality leg ────────────────────────────────────────────────────── |
| 213 | + |
| 214 | +/** |
| 215 | + * One stored value reads the same in the form and in the cell: every row is |
| 216 | + * an (aggregation function, value, declaration) triple, drawn in both faces |
| 217 | + * in both sessions, and the two texts are compared with each other. |
| 218 | + */ |
| 219 | +describe('one stored value, one reading: the form face equals the cell (objectui#11752)', () => { |
| 220 | + const ROWS: Array<[string, Fn, unknown, Extra]> = [ |
| 221 | + ['count, small', 'count', 42, {}], |
| 222 | + ['count, large', 'count', 12000, {}], |
| 223 | + ['count, empty child set', 'count', 0, {}], |
| 224 | + ['sum, the card value', 'sum', 15750.5, {}], |
| 225 | + ['sum, empty child set', 'sum', 0, {}], |
| 226 | + ['sum, declared scale', 'sum', 15750.5, { scale: 2 }], |
| 227 | + ['sum, declared ungrouped', 'sum', 15750.5, { useGrouping: false }], |
| 228 | + ['avg, a repeating fraction', 'avg', 10 / 3, {}], |
| 229 | + ['avg, declared scale', 'avg', 10 / 3, { scale: 2 }], |
| 230 | + ['min, negative', 'min', -42.25, {}], |
| 231 | + ['max, large', 'max', 1234567.891, {}], |
| 232 | + ['max, numeric string', 'max', '15750.5', {}], |
| 233 | + ]; |
| 234 | + |
| 235 | + describe.each(LOCALES)('%s', (locale) => { |
| 236 | + it.each(ROWS)('%s', (_label, fn, value, extra) => { |
| 237 | + const form = textOf(locale, formFace(fn, value, extra)); |
| 238 | + const cell = textOf(locale, cellFace(fn, value, extra)); |
| 239 | + expect(form).not.toBe(''); |
| 240 | + expect(form).toBe(cell); |
| 241 | + }); |
| 242 | + }); |
| 243 | +}); |
0 commit comments