|
| 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#8853 — ONE authored `field.format`, one meaning, across the two |
| 11 | + * neighbouring date cell renderers. |
| 12 | + * |
| 13 | + * ## What was measured, before anything was changed |
| 14 | + * |
| 15 | + * Driven end to end through a REAL `ObjectGrid` column (not inferred from a |
| 16 | + * source read): one object with a `date` field and a `datetime` field, BOTH |
| 17 | + * authored `format: 'relative'`, one row, one instant |
| 18 | + * (`2026-09-11T09:30:00.000Z`), clock pinned to `2026-09-09T12:00:00.000Z`, |
| 19 | + * `en-US`. The grid's own body cells came back as |
| 20 | + * |
| 21 | + * ["1Open", "Row One", "In 2 days", "Sep 11, 2026, 09:30 AM"] |
| 22 | + * ^ date ^ datetime, same key, dropped |
| 23 | + * |
| 24 | + * and after the call-site mapping, as |
| 25 | + * |
| 26 | + * ["1Open", "Row One", "In 2 days", "In 2 days"] |
| 27 | + * |
| 28 | + * The defect's whole signature is a SILENT drop — the runtime accepts the key, |
| 29 | + * parses it, drops it, and renders something that still looks like a |
| 30 | + * legitimate date — so a green suite proves nothing by itself. Every case |
| 31 | + * below therefore asserts a rendered FACE, with controls that say what the |
| 32 | + * probe would have shown had it been fooled. |
| 33 | + * |
| 34 | + * ## The controls, and why each one is here |
| 35 | + * |
| 36 | + * - **Positive control** — the `date` cell, in the SAME run, on the SAME |
| 37 | + * instant. It already honoured `'relative'`, so it is what "honoured" looks |
| 38 | + * like; without it, a datetime assertion could be measuring a broken clock |
| 39 | + * or a dead locale channel rather than the vocabulary. |
| 40 | + * - **Negative control** — an OUT-OF-WINDOW value. `formatRelativeDate` falls |
| 41 | + * back to the absolute form beyond ±7 days, so out there `format: |
| 42 | + * 'relative'` and no format at all render IDENTICALLY on the fully working |
| 43 | + * `date` cell. That is the trap objectui#8352's card recorded: a probe built |
| 44 | + * on an out-of-window value reports "identical" on a working path and on a |
| 45 | + * broken one alike. The pin below asserts that collapse explicitly, so the |
| 46 | + * in-window choice everywhere else is a measured decision rather than luck. |
| 47 | + * |
| 48 | + * ## Directions |
| 49 | + * |
| 50 | + * Reverting the call-site mapping in `DateTimeCellRenderer` turns the repro, |
| 51 | + * the `'short'` face and the out-of-window datetime face RED, and leaves every |
| 52 | + * control and regression pin GREEN — the controls measure surfaces this change |
| 53 | + * does not touch, which is exactly why they can vouch for the run. Widening |
| 54 | + * `formatDateTime`'s published signature instead (the refused route) turns the |
| 55 | + * "published signature untouched" pins RED. Adding a refusal for a spelling |
| 56 | + * that renders today turns the regression pins RED. |
| 57 | + */ |
| 58 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 59 | +import React from 'react'; |
| 60 | +import { render, cleanup } from '@testing-library/react'; |
| 61 | +import '@testing-library/jest-dom'; |
| 62 | +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; |
| 63 | +import { DateCellRenderer, DateTimeCellRenderer, formatDateTime, formatDateTimeCompactParts } from '../index'; |
| 64 | + |
| 65 | +/** The pinned clock, and two values chosen on either side of the ±7-day window. */ |
| 66 | +const CLOCK = '2026-09-09T12:00:00.000Z'; |
| 67 | +/** +2 days: inside the window, so the relative face is a PHRASE and discriminates. */ |
| 68 | +const IN_WINDOW = '2026-09-11T09:30:00.000Z'; |
| 69 | +/** +11 days: outside it, where the relative face collapses onto the absolute one. */ |
| 70 | +const OUT_OF_WINDOW = '2026-09-20T09:30:00.000Z'; |
| 71 | + |
| 72 | +/** `en-US` plus one NON-US locale, the same pairing the #7443 pins use. */ |
| 73 | +const LOCALES = ['en-US', 'zh', 'de-DE']; |
| 74 | + |
| 75 | +/** |
| 76 | + * Same session harness as `datetime-compact-style-7443.test.tsx`: the tag is |
| 77 | + * set as the TENANT locale because that is the channel objectui#4468 made |
| 78 | + * every date branch read, and `persistLanguage={false}` keeps each case on its |
| 79 | + * own language instead of inheriting the previous one's. |
| 80 | + */ |
| 81 | +function renderSession(locale: string, node: React.ReactElement) { |
| 82 | + return render( |
| 83 | + <I18nProvider |
| 84 | + config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} |
| 85 | + persistLanguage={false} |
| 86 | + > |
| 87 | + <LocalizationProvider value={{ locale }}>{node}</LocalizationProvider> |
| 88 | + </I18nProvider>, |
| 89 | + ); |
| 90 | +} |
| 91 | + |
| 92 | +/** Render one cell at the pinned clock and return its text, nothing retained. */ |
| 93 | +function faceOf(node: React.ReactElement, locale = 'en-US'): string { |
| 94 | + vi.useFakeTimers(); |
| 95 | + vi.setSystemTime(new Date(CLOCK)); |
| 96 | + try { |
| 97 | + const { container } = renderSession(locale, node); |
| 98 | + return container.textContent ?? ''; |
| 99 | + } finally { |
| 100 | + cleanup(); |
| 101 | + vi.useRealTimers(); |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +const dateCell = (value: string, format?: string) => ( |
| 106 | + <DateCellRenderer |
| 107 | + value={value} |
| 108 | + field={{ type: 'date', name: 'follow_up_on', ...(format === undefined ? {} : { format }) } as any} |
| 109 | + /> |
| 110 | +); |
| 111 | + |
| 112 | +const dateTimeCell = (value: string, format?: string) => ( |
| 113 | + <DateTimeCellRenderer |
| 114 | + value={value} |
| 115 | + field={{ type: 'datetime', name: 'follow_up_at', ...(format === undefined ? {} : { format }) } as any} |
| 116 | + /> |
| 117 | +); |
| 118 | + |
| 119 | +/** The compact face as the cell paints it — two spans, so no separating space. */ |
| 120 | +function compactCellText(value: string, locale: string): string { |
| 121 | + const parts = formatDateTimeCompactParts(value, { locale })!; |
| 122 | + return `${parts.date}${parts.time}`; |
| 123 | +} |
| 124 | + |
| 125 | +afterEach(() => { |
| 126 | + cleanup(); |
| 127 | + vi.useRealTimers(); |
| 128 | +}); |
| 129 | + |
| 130 | +describe('THE REPRO — `format: relative` means the same thing in both cells (#8853)', () => { |
| 131 | + it('a datetime cell honours `relative`, and renders the phrase its date sibling renders', () => { |
| 132 | + const dateFace = faceOf(dateCell(IN_WINDOW, 'relative')); |
| 133 | + const dateTimeFace = faceOf(dateTimeCell(IN_WINDOW, 'relative')); |
| 134 | + |
| 135 | + // The anchor, so a redesign that moved both sides together cannot pass. |
| 136 | + expect(dateFace).toBe('In 2 days'); |
| 137 | + expect(dateTimeFace).toBe('In 2 days'); |
| 138 | + // The claim itself: ONE authored key, ONE meaning, across the two cells. |
| 139 | + expect(dateTimeFace).toBe(dateFace); |
| 140 | + // And what it used to be, named — not merely "something else". |
| 141 | + expect(dateTimeFace).not.toBe('Sep 11, 2026, 09:30 AM'); |
| 142 | + }); |
| 143 | + |
| 144 | + it.each(LOCALES)('%s — the two cells agree in a non-US locale too', (locale) => { |
| 145 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'relative'), locale)).toBe( |
| 146 | + faceOf(dateCell(IN_WINDOW, 'relative'), locale), |
| 147 | + ); |
| 148 | + }); |
| 149 | + |
| 150 | + it('`short` reaches the dense face of THIS type — the time of day is kept', () => { |
| 151 | + // objectui#8352 mapped `'short'` to the arm's own narrow face: `formatDate`'s |
| 152 | + // `'short'` for a date, the compact datetime face for a datetime. Here that |
| 153 | + // face is the one this cell already paints, so a measure tile and a grid |
| 154 | + // cell showing the same instant agree. |
| 155 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe(compactCellText(IN_WINDOW, 'en-US')); |
| 156 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/20269:30 am'); |
| 157 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).not.toBe('Sep 11, 2026, 09:30 AM'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('the relative face is painted in ONE span, not the compact two', () => { |
| 161 | + vi.useFakeTimers(); |
| 162 | + vi.setSystemTime(new Date(CLOCK)); |
| 163 | + const { container } = renderSession('en-US', dateTimeCell(IN_WINDOW, 'relative')); |
| 164 | + expect(container.querySelectorAll('span > span')).toHaveLength(0); |
| 165 | + vi.useRealTimers(); |
| 166 | + }); |
| 167 | +}); |
| 168 | + |
| 169 | +describe('POSITIVE CONTROL — the date cell, unchanged, in the same run', () => { |
| 170 | + it('still honours `relative` and `short`, and still defaults to relative', () => { |
| 171 | + expect(faceOf(dateCell(IN_WINDOW, 'relative'))).toBe('In 2 days'); |
| 172 | + expect(faceOf(dateCell(IN_WINDOW, 'short'))).toBe("Sep 11, '26"); |
| 173 | + expect(faceOf(dateCell(IN_WINDOW, undefined))).toBe('In 2 days'); |
| 174 | + }); |
| 175 | + |
| 176 | + it("`compact` stays inert on a date cell — the vocabulary was widened on ONE side", () => { |
| 177 | + // The reverse asymmetry the card names. Honouring it here would be a |
| 178 | + // second, unruled change: `'compact'` falls to the date arm's default |
| 179 | + // face, exactly as objectui#8352 declared for the measure path. |
| 180 | + expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe('Sep 11'); |
| 181 | + expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe(faceOf(dateCell(IN_WINDOW, 'no-such-face'))); |
| 182 | + }); |
| 183 | +}); |
| 184 | + |
| 185 | +describe('NEGATIVE CONTROL — out of the ±7-day window the probe cannot discriminate', () => { |
| 186 | + it('on the WORKING date cell, `relative` and no format render identically out there', () => { |
| 187 | + // This is the trap. Had the cases above used this value, "identical" |
| 188 | + // would have been the answer on a working path and a broken one alike. |
| 189 | + const authored = faceOf(dateCell(OUT_OF_WINDOW, 'relative')); |
| 190 | + const absent = faceOf(dateCell(OUT_OF_WINDOW, undefined)); |
| 191 | + expect(authored).toBe(absent); |
| 192 | + expect(authored).toBe('Sep 20'); |
| 193 | + }); |
| 194 | + |
| 195 | + it('the in-window value DOES discriminate on that same cell — the probe has teeth', () => { |
| 196 | + expect(faceOf(dateCell(IN_WINDOW, 'relative'))).not.toBe('Sep 11'); |
| 197 | + }); |
| 198 | + |
| 199 | + it('an out-of-window datetime renders the absolute DATE face — the declared cost', () => { |
| 200 | + // ⚠️ The time of day is gone. `formatRelativeDate`'s out-of-window branch |
| 201 | + // renders through `toLocaleDateString` and has no time component to add; |
| 202 | + // that window belongs to that function and is INHERITED here rather than |
| 203 | + // re-decided at this call site (objectui#4576). `'relative'` is day-granular |
| 204 | + // by construction, so its degraded form is a day face — and nothing was |
| 205 | + // taken from a working feature, since this cell ignored the word outright |
| 206 | + // before. Pinned so the cost cannot drift silently in either direction. |
| 207 | + expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe('Sep 20'); |
| 208 | + expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe( |
| 209 | + faceOf(dateCell(OUT_OF_WINDOW, 'relative')), |
| 210 | + ); |
| 211 | + }); |
| 212 | +}); |
| 213 | + |
| 214 | +describe('REGRESSION — every datetime cell that rendered before renders the same', () => { |
| 215 | + it.each(LOCALES)('%s — no format is still the compact face', (locale) => { |
| 216 | + expect(faceOf(dateTimeCell(IN_WINDOW, undefined), locale)).toBe( |
| 217 | + compactCellText(IN_WINDOW, locale), |
| 218 | + ); |
| 219 | + }); |
| 220 | + |
| 221 | + it('an explicit `compact` is still the compact face', () => { |
| 222 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'compact'))).toBe(compactCellText(IN_WINDOW, 'en-US')); |
| 223 | + }); |
| 224 | + |
| 225 | + it('an authored EMPTY string is still the compact face, not the verbose one', () => { |
| 226 | + // `||`, not `??` — the spelling objectui#7443 chose so an authored empty |
| 227 | + // string stays on the compact face. The mapping above must not disturb it. |
| 228 | + expect(faceOf(dateTimeCell(IN_WINDOW, ''))).toBe(compactCellText(IN_WINDOW, 'en-US')); |
| 229 | + }); |
| 230 | + |
| 231 | + it('an unrecognised spelling still falls to the verbose default — no refusal was added', () => { |
| 232 | + // ⛔ Rejecting a currently-accepted spelling would be a breaking narrowing |
| 233 | + // of a published metadata surface. It renders, as it always did. |
| 234 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'default'))).toBe('Sep 11, 2026, 09:30 AM'); |
| 235 | + expect(faceOf(dateTimeCell(IN_WINDOW, 'YYYY-MM-DD'))).toBe('Sep 11, 2026, 09:30 AM'); |
| 236 | + }); |
| 237 | + |
| 238 | + it('an absent field is still tolerated, on the compact face', () => { |
| 239 | + expect(faceOf(<DateTimeCellRenderer value={IN_WINDOW} field={undefined as any} />)).toBe( |
| 240 | + compactCellText(IN_WINDOW, 'en-US'), |
| 241 | + ); |
| 242 | + }); |
| 243 | +}); |
| 244 | + |
| 245 | +describe('the PUBLISHED signature was not moved — the mapping lives at the call site', () => { |
| 246 | + it('formatDateTime still declares exactly two parameters', () => { |
| 247 | + // `(value, options?)` is 2; the refused `(value, style?, options?)` is 3. |
| 248 | + expect(formatDateTime.length).toBe(2); |
| 249 | + }); |
| 250 | + |
| 251 | + it("formatDateTime's own vocabulary is untouched: it still ignores `relative` and `short`", () => { |
| 252 | + // The function was NOT widened. It answers the verbose face for both |
| 253 | + // words, and the CELL is what maps them — which is why this file and the |
| 254 | + // function's own pins can disagree about `'relative'` without either being |
| 255 | + // wrong. |
| 256 | + const verbose = formatDateTime(IN_WINDOW, { locale: 'en-US' }); |
| 257 | + expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'relative' })).toBe(verbose); |
| 258 | + expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'short' })).toBe(verbose); |
| 259 | + expect(verbose).toBe('Sep 11, 2026, 09:30 AM'); |
| 260 | + }); |
| 261 | +}); |
0 commit comments