|
| 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#11693 — an instant renders in the display zone the host declared, |
| 11 | + * and in the viewer's zone when none is declared; a date-only value names its |
| 12 | + * day in every case. |
| 13 | + * |
| 14 | + * The console's localization fetch dropped the `timezone` the server answers, |
| 15 | + * so every face rendered in the viewer's zone whatever the workspace |
| 16 | + * configured. The zone now enters through `setDisplayTimeZone`, the one input |
| 17 | + * every face in this module reads, and nowhere else. |
| 18 | + * |
| 19 | + * ── How "the viewer's zone" is driven ────────────────────────────────────── |
| 20 | + * It is not driven: the suite runs in UTC (`vitest.config.mts`, |
| 21 | + * objectui#8366), so the viewer's zone here IS UTC, and the rig case below |
| 22 | + * reds if that ever stops being true. The display zone is the explicit |
| 23 | + * per-case input that config's header asks non-UTC coverage to use. |
| 24 | + * `America/Los_Angeles` (UTC-7 in September) is the discriminating direction |
| 25 | + * for the date-only pins: a date-only value is built at UTC midnight here, |
| 26 | + * which is the evening BEFORE in Los Angeles, so a zone that wrongly reached |
| 27 | + * it would name August 31st. Each of those pins carries that control. |
| 28 | + */ |
| 29 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 30 | +import { |
| 31 | + formatDate, |
| 32 | + formatDateTime, |
| 33 | + formatDateTimeCompactParts, |
| 34 | + formatRelativeDate, |
| 35 | + getDisplayTimeZone, |
| 36 | + setDisplayTimeZone, |
| 37 | + subscribeDisplayTimeZone, |
| 38 | + toDisplayDate, |
| 39 | + toDisplayEndDate, |
| 40 | + toInclusiveEndDay, |
| 41 | +} from '../date-display'; |
| 42 | + |
| 43 | +const WEST = 'America/Los_Angeles'; |
| 44 | +const EAST = 'Asia/Shanghai'; |
| 45 | +const L = 'en-US'; |
| 46 | +/** 03:00 on Sep 2nd in UTC, 20:00 on Sep 1st in Los Angeles, 11:00 on Sep 2nd in Shanghai. */ |
| 47 | +const INSTANT = '2026-09-02T03:00:00.000Z'; |
| 48 | +const DAY = '2026-09-01'; |
| 49 | + |
| 50 | +afterEach(() => { |
| 51 | + setDisplayTimeZone(undefined); |
| 52 | + vi.useRealTimers(); |
| 53 | + vi.restoreAllMocks(); |
| 54 | +}); |
| 55 | + |
| 56 | +describe('rig (objectui#11693)', () => { |
| 57 | + it('the viewer zone this suite renders in is UTC, unlike both display zones used below', () => { |
| 58 | + expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('UTC'); |
| 59 | + }); |
| 60 | +}); |
| 61 | + |
| 62 | +describe('an instant renders in the display zone when one is set, else in the viewer zone (objectui#11693)', () => { |
| 63 | + beforeEach(() => { |
| 64 | + vi.useFakeTimers(); |
| 65 | + vi.setSystemTime(new Date(INSTANT)); |
| 66 | + }); |
| 67 | + |
| 68 | + it('formatDateTime default face', () => { |
| 69 | + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM'); |
| 70 | + setDisplayTimeZone(WEST); |
| 71 | + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 1, 2026, 08:00 PM'); |
| 72 | + setDisplayTimeZone(EAST); |
| 73 | + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 11:00 AM'); |
| 74 | + }); |
| 75 | + |
| 76 | + it('the compact face and its two halves take ONE zone', () => { |
| 77 | + expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/2/2026', time: '3:00 am' }); |
| 78 | + expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/2/2026 3:00 am'); |
| 79 | + setDisplayTimeZone(WEST); |
| 80 | + expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/1/2026', time: '8:00 pm' }); |
| 81 | + expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/1/2026 8:00 pm'); |
| 82 | + }); |
| 83 | + |
| 84 | + it('a Date instant takes the zone too, as the datetime cell hands one', () => { |
| 85 | + setDisplayTimeZone(WEST); |
| 86 | + expect(formatDateTime(new Date(INSTANT), { locale: L })).toBe('Sep 1, 2026, 08:00 PM'); |
| 87 | + }); |
| 88 | + |
| 89 | + it('formatDate names the day the instant falls on in the zone, on the default and the short face', () => { |
| 90 | + expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 2'); |
| 91 | + expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 2, '26"); |
| 92 | + setDisplayTimeZone(WEST); |
| 93 | + expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 1'); |
| 94 | + expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 1, '26"); |
| 95 | + }); |
| 96 | + |
| 97 | + it('the current year is the zone\'s current year', () => { |
| 98 | + // 03:00 on Jan 1st 2027 in UTC is still Dec 31st 2026 in Los Angeles. |
| 99 | + vi.setSystemTime(new Date('2027-01-01T03:00:00.000Z')); |
| 100 | + const lastEvening = '2026-12-31T20:00:00.000Z'; |
| 101 | + expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31, 2026'); |
| 102 | + setDisplayTimeZone(WEST); |
| 103 | + expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31'); |
| 104 | + }); |
| 105 | + |
| 106 | + it('formatRelativeDate counts days from the zone\'s today, to the day the instant falls on there', () => { |
| 107 | + // 23:00 on Sep 1st in UTC: yesterday from UTC's Sep 2nd, today in Los Angeles. |
| 108 | + expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Yesterday'); |
| 109 | + // 20:00 on Sep 2nd in UTC: today in UTC, 04:00 on Sep 3rd in Shanghai. |
| 110 | + expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Today'); |
| 111 | + setDisplayTimeZone(WEST); |
| 112 | + expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Today'); |
| 113 | + setDisplayTimeZone(EAST); |
| 114 | + expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Tomorrow'); |
| 115 | + }); |
| 116 | +}); |
| 117 | + |
| 118 | +describe('a date-only value names its day whatever the display zone (objectui#11693)', () => { |
| 119 | + beforeEach(() => { |
| 120 | + vi.useFakeTimers(); |
| 121 | + vi.setSystemTime(new Date(INSTANT)); |
| 122 | + setDisplayTimeZone(WEST); |
| 123 | + }); |
| 124 | + |
| 125 | + it('control: the same instant, as a plain Date, names August 31st in the zone', () => { |
| 126 | + // What every pin below would read if the zone reached a calendar day. |
| 127 | + const sameInstant = new Date(toDisplayDate(DAY).getTime()); |
| 128 | + expect(formatDate(sameInstant, undefined, { locale: L })).toBe('Aug 31'); |
| 129 | + }); |
| 130 | + |
| 131 | + it('the string, on every face', () => { |
| 132 | + expect(formatDate(DAY, undefined, { locale: L })).toBe('Sep 1'); |
| 133 | + expect(formatDate(DAY, 'short', { locale: L })).toBe("Sep 1, '26"); |
| 134 | + expect(formatDateTime(DAY, { locale: L })).toBe('Sep 1, 2026, 12:00 AM'); |
| 135 | + expect(formatDateTimeCompactParts(DAY, { locale: L })).toEqual({ date: '9/1/2026', time: '12:00 am' }); |
| 136 | + }); |
| 137 | + |
| 138 | + it('the Date toDisplayDate built for it, as the sub-grid date cell hands one on', () => { |
| 139 | + expect(formatDate(toDisplayDate(DAY), undefined, { locale: L })).toBe('Sep 1'); |
| 140 | + }); |
| 141 | + |
| 142 | + it('the day toInclusiveEndDay names for a date-only end', () => { |
| 143 | + expect(formatDate(toInclusiveEndDay(toDisplayEndDate(DAY)), undefined, { locale: L })).toBe('Sep 1'); |
| 144 | + }); |
| 145 | + |
| 146 | + it('the relative face counts it from the zone\'s today without moving the day', () => { |
| 147 | + // Today is Sep 1st in Los Angeles, Sep 2nd in UTC. |
| 148 | + expect(formatRelativeDate(DAY, { locale: L })).toBe('Today'); |
| 149 | + expect(formatDate(DAY, 'relative', { locale: L })).toBe('Today'); |
| 150 | + setDisplayTimeZone(undefined); |
| 151 | + expect(formatRelativeDate(DAY, { locale: L })).toBe('Yesterday'); |
| 152 | + }); |
| 153 | + |
| 154 | + it('beyond the relative window, the absolute fallback still names the day', () => { |
| 155 | + vi.setSystemTime(new Date('2026-09-30T03:00:00.000Z')); |
| 156 | + expect(formatRelativeDate(DAY, { locale: L })).toBe('Sep 1'); |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +describe('setDisplayTimeZone (objectui#11693)', () => { |
| 161 | + it('reads back what it was given, and clears on undefined or an empty string', () => { |
| 162 | + expect(getDisplayTimeZone()).toBeUndefined(); |
| 163 | + setDisplayTimeZone(WEST); |
| 164 | + expect(getDisplayTimeZone()).toBe(WEST); |
| 165 | + setDisplayTimeZone(''); |
| 166 | + expect(getDisplayTimeZone()).toBeUndefined(); |
| 167 | + setDisplayTimeZone(EAST); |
| 168 | + setDisplayTimeZone(undefined); |
| 169 | + expect(getDisplayTimeZone()).toBeUndefined(); |
| 170 | + }); |
| 171 | + |
| 172 | + it('a name this runtime does not know clears the zone with a warning, so no face throws', () => { |
| 173 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 174 | + setDisplayTimeZone(WEST); |
| 175 | + setDisplayTimeZone('Middle/Earth'); |
| 176 | + expect(getDisplayTimeZone()).toBeUndefined(); |
| 177 | + expect(warn).toHaveBeenCalledTimes(1); |
| 178 | + expect(String(warn.mock.calls[0][0])).toContain('Middle/Earth'); |
| 179 | + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM'); |
| 180 | + }); |
| 181 | + |
| 182 | + it('tells a subscriber about a change, once, and not about a repeat', () => { |
| 183 | + const listener = vi.fn(); |
| 184 | + const unsubscribe = subscribeDisplayTimeZone(listener); |
| 185 | + setDisplayTimeZone(WEST); |
| 186 | + setDisplayTimeZone(WEST); |
| 187 | + expect(listener).toHaveBeenCalledTimes(1); |
| 188 | + setDisplayTimeZone(undefined); |
| 189 | + expect(listener).toHaveBeenCalledTimes(2); |
| 190 | + unsubscribe(); |
| 191 | + setDisplayTimeZone(EAST); |
| 192 | + expect(listener).toHaveBeenCalledTimes(2); |
| 193 | + }); |
| 194 | +}); |
0 commit comments