Add DateTime value object with comprehensive unit tests

- Implemented DateTime class for handling ISO date-time parsing, formatting, and validation.
- Added InvalidDateTimeError for error handling in date-time operations.
- Created extensive unit tests covering various scenarios for date-time creation, parsing, and formatting, ensuring robust functionality and error handling.
- Included tests for timezone handling, invalid inputs, and edge cases to validate the DateTime implementation.
This commit is contained in:
shancheas
2026-08-20 18:31:46 +07:00
parent 289ab1006e
commit 8e77231537
3 changed files with 535 additions and 0 deletions
@@ -0,0 +1,227 @@
import { InvalidDateTimeError } from './invalid-date-time.error';
const DEFAULT_TZ_FALLBACK = 'GMT+7';
/** ECMAScript Date time value limits (±100_000_000 days from epoch). */
const MAX_UNIX_MS = 8_640_000_000_000_000;
/** ISO datetime with optional fractional seconds and optional Z / offset. */
const ISO_DATETIME =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
const TZ_OFFSET =
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
function pad2(n: number): string {
return n.toString().padStart(2, '0');
}
function pad3(n: number): string {
return n.toString().padStart(3, '0');
}
function parseFractionalMs(raw: string | undefined): number {
if (raw === undefined) {
return 0;
}
// Truncate beyond millisecond precision (do not round — avoids second carry).
return Number.parseInt(raw.slice(0, 3).padEnd(3, '0'), 10);
}
function assertValidOffset(hours: number, minutes: number, sign: number): void {
if (minutes > 59) {
throw new InvalidDateTimeError();
}
// Real fixed-offset range is -12:00 to +14:00.
const total = sign * (hours * 60 + minutes);
if (total < -12 * 60 || total > 14 * 60) {
throw new InvalidDateTimeError();
}
}
/**
* Parses a fixed-offset timezone string into minutes east of UTC.
* Accepts: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700, Z, UTC, GMT (and negatives).
*/
export function parseTimezoneOffsetMinutes(raw: string): number {
const trimmed = raw.trim();
const match = TZ_OFFSET.exec(trimmed);
if (!match) {
throw new InvalidDateTimeError();
}
// Z / UTC / GMT alone leave the sign capture unset.
if (match[1] === undefined) {
return 0;
}
const sign = match[1] === '-' ? -1 : 1;
const hours = Number.parseInt(match[2], 10);
const minutes = match[3] !== undefined ? Number.parseInt(match[3], 10) : 0;
assertValidOffset(hours, minutes, sign);
return sign * (hours * 60 + minutes);
}
export function resolveDefaultTimezone(): string {
const fromEnv = process.env.DEFAULT_TIMEZONE;
if (fromEnv === undefined || fromEnv.trim() === '') {
return DEFAULT_TZ_FALLBACK;
}
return fromEnv.trim();
}
function resolveDefaultOffsetMinutes(): number {
return parseTimezoneOffsetMinutes(resolveDefaultTimezone());
}
function formatOffset(offsetMinutes: number): string {
const sign = offsetMinutes < 0 ? '-' : '+';
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `${sign}${pad2(hours)}:${pad2(minutes)}`;
}
function utcMsFromParts(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
ms: number,
offsetMinutes: number,
): number {
const asUtc = Date.UTC(year, month - 1, day, hour, minute, second, ms);
const d = new Date(asUtc);
// Reject overflow (e.g. Feb 30) via round-trip on calendar components.
if (
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day ||
d.getUTCHours() !== hour ||
d.getUTCMinutes() !== minute ||
d.getUTCSeconds() !== second ||
d.getUTCMilliseconds() !== ms
) {
throw new InvalidDateTimeError();
}
return asUtc - offsetMinutes * 60_000;
}
function formatInOffset(utcMs: number, offsetMinutes: number): string {
const shifted = new Date(utcMs + offsetMinutes * 60_000);
if (Number.isNaN(shifted.getTime())) {
throw new InvalidDateTimeError();
}
const y = shifted.getUTCFullYear();
const m = pad2(shifted.getUTCMonth() + 1);
const d = pad2(shifted.getUTCDate());
const h = pad2(shifted.getUTCHours());
const min = pad2(shifted.getUTCMinutes());
const s = pad2(shifted.getUTCSeconds());
const ms = pad3(shifted.getUTCMilliseconds());
return `${y}-${m}-${d}T${h}:${min}:${s}.${ms}${formatOffset(offsetMinutes)}`;
}
export class DateTime {
private static readonly createToken = Symbol('DateTime.create');
private constructor(
private readonly unixMs: number,
token: symbol,
) {
if (token !== DateTime.createToken) {
throw new TypeError(
'DateTime can only be created via DateTime.create() or DateTime.fromUnixMs()',
);
}
Object.freeze(this);
}
static create(raw: string): DateTime {
if (typeof raw !== 'string') {
throw new InvalidDateTimeError();
}
const trimmed = raw.trim();
const match = ISO_DATETIME.exec(trimmed);
if (!match) {
throw new InvalidDateTimeError();
}
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
const hour = Number.parseInt(match[4], 10);
const minute = Number.parseInt(match[5], 10);
const second = Number.parseInt(match[6], 10);
const ms = parseFractionalMs(match[7]);
let offsetMinutes: number;
if (match[8] !== undefined && match[8].toUpperCase() === 'Z') {
offsetMinutes = 0;
} else if (match[9] !== undefined) {
const sign = match[9] === '-' ? -1 : 1;
const oh = Number.parseInt(match[10], 10);
const om = match[11] !== undefined ? Number.parseInt(match[11], 10) : 0;
assertValidOffset(oh, om, sign);
offsetMinutes = sign * (oh * 60 + om);
} else {
offsetMinutes = resolveDefaultOffsetMinutes();
}
const utcMs = utcMsFromParts(
year,
month,
day,
hour,
minute,
second,
ms,
offsetMinutes,
);
return new DateTime(utcMs, DateTime.createToken);
}
static fromUnixMs(ms: number): DateTime {
if (
typeof ms !== 'number' ||
!Number.isSafeInteger(ms) ||
Math.abs(ms) > MAX_UNIX_MS
) {
throw new InvalidDateTimeError();
}
return new DateTime(ms, DateTime.createToken);
}
get value(): number {
return this.unixMs;
}
equals(other: DateTime): boolean {
return other instanceof DateTime && this.unixMs === other.unixMs;
}
format(tz?: string): string {
const offsetMinutes =
tz === undefined
? resolveDefaultOffsetMinutes()
: parseTimezoneOffsetMinutes(tz);
return formatInOffset(this.unixMs, offsetMinutes);
}
toString(): string {
return this.format();
}
toJSON(): number {
return this.unixMs;
}
}