Files
summercms/admin/tests/app/dateFormat.test.ts
Jakub Zych d5494faa81 test(12.2-05): SPA unit tests for the phase and the check-phase12.2 gate
- sessionKey, dateFormat, FileuploadField (protected thumbnails and
  keyboard reorder backstops), RelationChildModal, RelationPivotModal,
  RelationManager row actions and create-screen deferral, date and time
  list cells; typed deferred relation-schema and file-list fixtures
- scripts/check-phase12.2.sh: go, security (named tests, refuses missing
  or skipped), spa, openapi, dist, docs, hygiene and app stages, a
  detector self-test, one PASS or FAIL line per stage under --all
2026-10-02 20:51:36 +02:00

209 lines
9.2 KiB
TypeScript

// Datepicker value conversions (Phase 12.2, D-18 to D-21): parse and emit
// per mode, ignoreTimezone, display formats, week start and bounds. The
// datetime cases run in a fixed non-UTC zone.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
CalendarDate,
CalendarDateTime,
Time,
ZonedDateTime,
resetLocalTimeZone,
setLocalTimeZone,
} from '@internationalized/date'
import {
DEFAULT_DISPLAY,
boundValue,
dateMode,
dayBounds,
emitFieldValue,
formatDisplay,
parseFieldValue,
startValue,
weekStart,
} from '../../src/app/dateFormat'
beforeEach(() => setLocalTimeZone('Europe/Warsaw'))
afterEach(() => resetLocalTimeZone())
describe('dateMode', () => {
it('defaults to datetime', () => {
expect(dateMode('date')).toBe('date')
expect(dateMode('time')).toBe('time')
expect(dateMode('datetime')).toBe('datetime')
expect(dateMode(undefined)).toBe('datetime')
expect(dateMode(null)).toBe('datetime')
expect(dateMode('week')).toBe('datetime')
})
})
describe('parseFieldValue', () => {
it('reads null, empty and unreadable values as null', () => {
for (const mode of ['date', 'datetime', 'time'] as const) {
expect(parseFieldValue(mode, null)).toBeNull()
expect(parseFieldValue(mode, undefined)).toBeNull()
expect(parseFieldValue(mode, '')).toBeNull()
expect(parseFieldValue(mode, ' ')).toBeNull()
expect(parseFieldValue(mode, 42)).toBeNull()
expect(parseFieldValue(mode, 'garbage')).toBeNull()
}
})
it('reads a date as a calendar day, ignoring any time part', () => {
expect(parseFieldValue('date', '2026-10-02')?.toString()).toBe('2026-10-02')
expect(parseFieldValue('date', '2026-10-02T23:30:00Z')?.toString()).toBe('2026-10-02')
expect(parseFieldValue('date', '2026-10-02')).toBeInstanceOf(CalendarDate)
})
it('reads a time with or without seconds or a date', () => {
expect(parseFieldValue('time', '14:30')?.toString()).toBe('14:30:00')
expect(parseFieldValue('time', '14:30:15')?.toString()).toBe('14:30:15')
expect(parseFieldValue('time', '2026-10-02T08:05:00Z')?.toString()).toBe('08:05:00')
expect(parseFieldValue('time', '14:30')).toBeInstanceOf(Time)
})
it('shows a datetime in the local zone, a zone-less one read as UTC', () => {
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime
expect(zoned).toBeInstanceOf(ZonedDateTime)
expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([12, 30])
const winter = parseFieldValue('datetime', '2026-01-15 10:30:00') as ZonedDateTime
expect([winter.hour, winter.minute]).toEqual([11, 30])
const offset = parseFieldValue('datetime', '2026-10-02T10:30:00+02:00') as ZonedDateTime
expect([offset.hour, offset.minute]).toEqual([10, 30])
})
it('keeps the UTC wall clock with ignoreTimezone', () => {
const wall = parseFieldValue('datetime', '2026-10-02T10:30:00Z', true)
expect(wall).toBeInstanceOf(CalendarDateTime)
expect(wall?.toString()).toBe('2026-10-02T10:30:00')
})
})
describe('emitFieldValue', () => {
it('emits null for no value', () => {
expect(emitFieldValue('date', null)).toBeNull()
expect(emitFieldValue('datetime', undefined)).toBeNull()
expect(emitFieldValue('time', null)).toBeNull()
})
it('emits a date as YYYY-MM-DD and a time as HH:MM:SS', () => {
expect(emitFieldValue('date', new CalendarDate(26, 1, 2))).toBe('0026-01-02')
expect(emitFieldValue('date', new CalendarDateTime(2026, 10, 2, 23, 59))).toBe('2026-10-02')
expect(emitFieldValue('time', new Time(7, 5))).toBe('07:05:00')
expect(emitFieldValue('time', new CalendarDate(2026, 10, 2))).toBeNull()
expect(emitFieldValue('date', new Time(7, 5))).toBeNull()
})
it('emits a local datetime as a UTC instant without milliseconds', () => {
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30))).toBe('2026-10-02T10:30:00Z')
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 1, 1, 0, 30))).toBe('2025-12-31T23:30:00Z')
expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2))).toBe('2026-10-01T22:00:00Z')
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z')
expect(emitFieldValue('datetime', zoned)).toBe('2026-10-02T10:30:00Z')
})
it('emits the wall clock unchanged with ignoreTimezone', () => {
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30), true)).toBe('2026-10-02T12:30:00Z')
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime
expect(emitFieldValue('datetime', zoned, true)).toBe('2026-10-02T12:30:00Z')
expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2), true)).toBe('2026-10-02T00:00:00Z')
})
it('round-trips every mode', () => {
for (const [mode, text] of [
['date', '2026-02-28'],
['time', '23:59:59'],
['datetime', '2026-03-29T01:30:00Z'],
] as const) {
expect(emitFieldValue(mode, parseFieldValue(mode, text))).toBe(text)
}
expect(emitFieldValue('datetime', parseFieldValue('datetime', '2026-10-02T10:30:00Z', true), true)).toBe('2026-10-02T10:30:00Z')
})
})
describe('formatDisplay', () => {
const day = new CalendarDate(2026, 10, 2)
const at = new CalendarDateTime(2026, 10, 2, 14, 5, 9)
it('uses the mode default without a display format', () => {
expect(formatDisplay(day, 'date', undefined, 'en')).toBe('2026-10-02')
expect(formatDisplay(at, 'datetime', '', 'en')).toBe('2026-10-02 14:05')
expect(formatDisplay(new Time(9, 3), 'time', null, 'en')).toBe('09:03')
expect(DEFAULT_DISPLAY).toEqual({ date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm', time: 'HH:mm' })
expect(formatDisplay(null, 'date', 'DD', 'en')).toBe('')
})
it('renders every token', () => {
expect(formatDisplay(at, 'datetime', 'DD.MM.YYYY HH:mm:ss', 'pl')).toBe('02.10.2026 14:05:09')
expect(formatDisplay(at, 'datetime', 'D/M/YY H:mm', 'en')).toBe('2/10/26 14:05')
expect(formatDisplay(at, 'datetime', 'hh:mm A', 'en')).toBe('02:05 PM')
expect(formatDisplay(new CalendarDateTime(2026, 10, 2, 0, 5), 'datetime', 'h:mm a', 'en')).toBe('12:05 am')
expect(formatDisplay(at, 'datetime', 'dddd, D MMMM YYYY', 'en')).toBe('Friday, 2 October 2026')
expect(formatDisplay(at, 'datetime', 'ddd D MMM', 'en')).toBe('Fri 2 Oct')
expect(formatDisplay(at, 'datetime', 'YYYY-MM-DD [at] HH:mm', 'en')).toBe('2026-10-02 at 14:05')
})
it('names months and days in the admin locale, genitive next to a day', () => {
expect(formatDisplay(day, 'date', 'D MMMM YYYY', 'pl')).toBe('2 października 2026')
expect(formatDisplay(day, 'date', 'MMMM YYYY', 'pl')).toBe('październik 2026')
expect(formatDisplay(day, 'date', 'dddd', 'pl')).toBe('piątek')
})
it('leaves clock tokens empty on a date and date tokens empty on a time', () => {
expect(formatDisplay(day, 'date', 'YYYY-MM-DD HH:mm', 'en')).toBe('2026-10-02 :')
expect(formatDisplay(new Time(9, 3), 'time', 'DD HH:mm', 'en')).toBe(' 09:03')
})
})
describe('weekStart', () => {
it('takes an explicit firstDay', () => {
expect(weekStart('pl', 0)).toBe(0)
expect(weekStart('en-US', 6)).toBe(6)
})
it('falls back to the locale for a missing or invalid firstDay', () => {
expect(weekStart('pl')).toBe(1)
expect(weekStart('en-US')).toBe(0)
expect(weekStart('pl', 7)).toBe(1)
expect(weekStart('pl', 1.5)).toBe(1)
expect(weekStart('en-US', null)).toBe(0)
})
})
describe('dayBounds and boundValue', () => {
const now = new CalendarDate(2026, 10, 2)
it('prefers minDate and maxDate over yearRange', () => {
const bounds = dayBounds({ minDate: '2026-01-01', maxDate: '2026-12-31T00:00:00Z', yearRange: [5] }, now)
expect(bounds.min?.toString()).toBe('2026-01-01')
expect(bounds.max?.toString()).toBe('2026-12-31')
expect(dayBounds({ minDate: 'garbage' }, now)).toEqual({})
})
it('reads both yearRange forms', () => {
const around = dayBounds({ yearRange: [10] }, now)
expect([around.min?.toString(), around.max?.toString()]).toEqual(['2016-01-01', '2036-12-31'])
const span = dayBounds({ yearRange: [2000, 2040] }, now)
expect([span.min?.toString(), span.max?.toString()]).toEqual(['2000-01-01', '2040-12-31'])
expect(dayBounds({ yearRange: [] }, now)).toEqual({})
})
it('turns a bound day into the start or end of that day per mode', () => {
const day = new CalendarDate(2026, 10, 2)
expect(boundValue(day, 'date', false, true)?.toString()).toBe('2026-10-02')
expect(boundValue(day, 'time', false, true)).toBeUndefined()
expect(boundValue(undefined, 'date', false, true)).toBeUndefined()
expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59')
const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime
expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([0, 0])
})
it('starts a fresh picker at today in its value type', () => {
expect(startValue('date', false)).toBeInstanceOf(CalendarDate)
expect(startValue('datetime', true)).toBeInstanceOf(CalendarDateTime)
expect(startValue('datetime', false)).toBeInstanceOf(ZonedDateTime)
expect(startValue('time', false)).toBeInstanceOf(ZonedDateTime)
})
})