Ctrl K
ComunidadGuías…

test/shared/dates-property.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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,
    );
  });
});