test/shared/dates-property.test.ts
Rama main · réplica de lectura del repositorio original.
// Property tests for the pure date-string math: addDays and dateRange over
// generated real calendar dates, addMonthsIso's time-of-day and clamping
// guarantees, and parseIsoDateParam's totality. fast-check runs each property
// with a fixed seed and run count, so every run generates the same cases.
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import * as fc from "fast-check";
import {
addDays,
addMonthsIso,
dateRange,
parseIsoDateParam,
} from "#shared/dates.ts";
import { parseDateStringOrThrow } from "#shared/validation/date-string.ts";
const seedArgs = { numRuns: 100, seed: 20261018 } as const;
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** A generator of [year, month, day] triples over real months, with the day
* capped at 28 so every triple is a valid date. */
const ymdParts = (): fc.Arbitrary<[number, number, number]> =>
fc.tuple(
fc.integer({ max: 2100, min: 2000 }),
fc.integer({ max: 12, min: 1 }),
fc.integer({ max: 28, min: 1 }),
);
/** The YYYY-MM-DD string a triple names. */
const isoDay = ([year, month, day]: readonly [
number,
number,
number,
]): string =>
`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
/** A generator of real calendar dates between 2000 and 2100, branded through
* the same parse boundary production callers use. */
const calendarDate = ymdParts().map((parts) =>
parseDateStringOrThrow(isoDay(parts), "a generated calendar date"),
);
/** A generator of real full ISO instants between 2000 and 2100, with the day
* capped at 28 so every generated instant is valid. */
const isoInstant = ymdParts().chain(([year, month, day]) =>
fc
.tuple(
fc.integer({ max: 23, min: 0 }),
fc.integer({ max: 59, min: 0 }),
fc.integer({ max: 59, min: 0 }),
)
.map(([hour, minute, second]) =>
new Date(
Date.UTC(year, month - 1, day, hour, minute, second),
).toISOString(),
),
);
/** The days a Gregorian month holds. */
const daysInMonth = (year: number, month: number): number =>
new Date(Date.UTC(year, month, 0)).getUTCDate();
describe("addDays properties", () => {
test("is invertible: adding then subtracting the same days returns the date", () => {
fc.assert(
fc.property(
calendarDate,
fc.integer({ max: 1000, min: -1000 }),
(date, days) => {
expect(addDays(addDays(date, days), -days)).toBe(date);
},
),
seedArgs,
);
});
test("always returns a real calendar date", () => {
fc.assert(
fc.property(
calendarDate,
fc.integer({ max: 1000, min: -1000 }),
(date, days) => {
const shifted = addDays(date, days);
expect(shifted).toMatch(CALENDAR_DATE);
const parsed = new Date(`${shifted}T00:00:00Z`);
expect(parsed.getUTCFullYear()).toBe(Number(shifted.slice(0, 4)));
expect(parsed.getUTCMonth() + 1).toBe(Number(shifted.slice(5, 7)));
expect(parsed.getUTCDate()).toBe(Number(shifted.slice(8, 10)));
},
),
seedArgs,
);
});
});
describe("dateRange properties", () => {
test("returns start..end inclusive, and nothing when start is past end", () => {
fc.assert(
fc.property(
calendarDate,
fc.integer({ max: 60, min: -30 }),
(start, span) => {
const end = addDays(start, span);
const range = dateRange(start, end);
if (span < 0) {
expect(range).toEqual([]);
} else {
expect(range.length).toBe(span + 1);
expect(range[0]).toBe(start);
expect(range.at(-1)).toBe(end);
}
},
),
seedArgs,
);
});
});
describe("addMonthsIso properties", () => {
test("lands on the expected clamped day, with the clock time kept", () => {
fc.assert(
fc.property(
isoInstant,
fc.integer({ max: 24, min: -24 }),
(iso, months) => {
if (months === 0) return;
const source = new Date(iso);
// The target month arithmetic runs through month index 0..11 with
// the year carrying the overflow, so January plus one month is
// February of the same year and December plus one is the next
// January.
const targetMonthIndex = source.getUTCMonth() + months;
const targetYear =
source.getUTCFullYear() + Math.floor(targetMonthIndex / 12);
const targetMonth = ((targetMonthIndex % 12) + 12) % 12;
const expectedDay = Math.min(
source.getUTCDate(),
daysInMonth(targetYear, targetMonth + 1),
);
const expected = new Date(
Date.UTC(
targetYear,
targetMonth,
expectedDay,
source.getUTCHours(),
source.getUTCMinutes(),
source.getUTCSeconds(),
source.getUTCMilliseconds(),
),
).toISOString();
expect(addMonthsIso(iso, months)).toBe(expected);
},
),
seedArgs,
);
});
test("clamps a month-end day onto the target month's last day", () => {
// A source day the target month cannot hold (31 into a 30- or 28-day
// month) must land on that month's last day, not spill into it, in both
// shift directions.
fc.assert(
fc.property(
fc.integer({ max: 2100, min: 2001 }),
fc.integer({ max: 11, min: 0 }),
fc.integer({ max: 24, min: -24 }),
(year, monthIndex, months) => {
if (months === 0) return;
const sourceDay = 31;
if (daysInMonth(year, monthIndex + 1) < 31) return;
const source = new Date(Date.UTC(year, monthIndex, sourceDay));
const shifted = addMonthsIso(source.toISOString(), months);
const targetMonthIndex = monthIndex + months;
const targetYear = year + Math.floor(targetMonthIndex / 12);
const targetMonth = ((targetMonthIndex % 12) + 12) % 12;
expect(new Date(shifted).getUTCDate()).toBe(
Math.min(31, daysInMonth(targetYear, targetMonth + 1)),
);
},
),
seedArgs,
);
});
});
describe("parseIsoDateParam properties", () => {
test("is total on arbitrary input", () => {
fc.assert(
fc.property(
// A plain string almost never forms a date. Mix in date-shaped
// strings with impossible months and days, so the oracle below sees
// both outcomes.
fc.oneof(
fc.string({ maxLength: 40 }),
fc.stringMatching(/^\d{4}-(0\d|1[0-3])-([0-3]\d)$/),
),
(value) => {
const parsed = new Date(`${value}T00:00:00Z`);
const realDay =
CALENDAR_DATE.test(value) &&
parsed.getUTCFullYear() === Number(value.slice(0, 4)) &&
parsed.getUTCMonth() + 1 === Number(value.slice(5, 7)) &&
parsed.getUTCDate() === Number(value.slice(8, 10));
const result = parseIsoDateParam(value);
expect(result).toBe(realDay ? value : null);
},
),
seedArgs,
);
});
test("returns real calendar dates and rejects same-shape fakes", () => {
fc.assert(
fc.property(
calendarDate,
fc.integer({ max: 12, min: 1 }),
(date, fakeDay) => {
expect(parseIsoDateParam(date)).toBe(date);
// The same shape with an impossible day: always rejected, even
// though the string reads like a date.
const fake = `${date.slice(0, 8)}${String(29 + (fakeDay % 3)).padStart(2, "0")}`;
const parsed = new Date(`${fake}T00:00:00Z`);
const fakeIsReal = parsed.getUTCDate() === Number(fake.slice(8, 10));
expect(parseIsoDateParam(fake)).toBe(fakeIsReal ? fake : null);
},
),
seedArgs,
);
});
});