- 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
209 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
})
|