import { describe, expect, test } from "bun:test"; import { parseAdjacentFullRange, parseFullRange, parseLabelledStartEnd, parseMonthDayRange, parseMonthDayYear, parseOpenRange, parseSlashDateTimeRange, parseYearFirstSlashRange, } from "../src/ingest/dates.ts"; describe("parseMonthDayYear", () => { test("parses a full date at day precision", () => { expect(parseMonthDayYear("August 12, 2026")).toEqual({ iso: "2026-08-12T00:00:00.000Z", precision: "day", }); }); test("accepts abbreviated months with and without a period", () => { expect(parseMonthDayYear("Apr. 29, 2026")?.iso).toBe( "2026-04-29T00:00:00.000Z", ); expect(parseMonthDayYear("Sept 3, 2026")?.iso).toBe( "2026-09-03T00:00:00.000Z", ); }); test("returns null rather than guessing a missing year", () => { expect(parseMonthDayYear("August 12")).toBeNull(); }); test("rejects impossible calendar dates", () => { expect(parseMonthDayYear("February 30, 2026")).toBeNull(); }); test("rejects an unknown month name", () => { expect(parseMonthDayYear("Smarch 3, 2026")).toBeNull(); }); }); describe("parseMonthDayRange", () => { test("applies the stated year to both ends", () => { expect(parseMonthDayRange("August 12 - September 21, 2026")).toEqual({ start: { iso: "2026-08-12T00:00:00.000Z", precision: "day" }, end: { iso: "2026-09-21T00:00:00.000Z", precision: "day" }, }); }); test("rolls the start back a year when the range crosses New Year", () => { // "December 28 - January 4, 2027" — the stated year belongs to the end. const r = parseMonthDayRange("December 28 - January 4, 2027"); expect(r?.start.iso).toBe("2026-12-28T00:00:00.000Z"); expect(r?.end.iso).toBe("2027-01-04T00:00:00.000Z"); }); test("handles en dash and abbreviated months", () => { const r = parseMonthDayRange("Apr. 29 – May 13, 2026"); expect(r?.start.iso).toBe("2026-04-29T00:00:00.000Z"); expect(r?.end.iso).toBe("2026-05-13T00:00:00.000Z"); }); test("returns null for a year-less range", () => { // Game8 summary tables render "08/12 - 08/24". Guessing the year here is // exactly the failure the product exists to prevent. expect(parseMonthDayRange("08/12 - 08/24")).toBeNull(); }); }); describe("parseSlashDateTimeRange", () => { test("parses a timed range at exact precision", () => { expect( parseSlashDateTimeRange("2021/01/16 04:00 - 2021/01/31 03:59"), ).toEqual({ start: { iso: "2021-01-16T04:00:00.000Z", precision: "exact" }, end: { iso: "2021-01-31T03:59:00.000Z", precision: "exact" }, }); }); test("ignores trailing prose after the range", () => { const r = parseSlashDateTimeRange( "2021/01/16 04:00 - 2021/01/31 03:59 Currently Unavailable", ); expect(r?.end.iso).toBe("2021-01-31T03:59:00.000Z"); }); test("returns null for non-date prose", () => { expect(parseSlashDateTimeRange("Permanently Available")).toBeNull(); }); }); describe("parseFullRange", () => { test("reads a year from each side", () => { expect(parseFullRange("Aug. 14, 2026 - Aug. 24, 2026")).toEqual({ start: { iso: "2026-08-14T00:00:00.000Z", precision: "day" }, end: { iso: "2026-08-24T00:00:00.000Z", precision: "day" }, }); }); test("ignores prose trailing the range", () => { const r = parseFullRange( "July 30, 2026 - August 13, 2026 Reach Union Level 8 to unlock", ); expect(r?.end.iso).toBe("2026-08-13T00:00:00.000Z"); }); test("does not fire on a range with the year only at the end", () => { // That shape belongs to parseMonthDayRange, which rolls the start year. expect(parseFullRange("August 12 - September 21, 2026")).toBeNull(); }); }); describe("parseOpenRange", () => { test("keeps a real start when the end is not a date", () => { // "End of 4.6" and "Permanent" are honest unknowns, not parse failures. for (const input of [ "Jul. 24, 2026 - End of 4.6", "July 10, 2026 - Permanent", "August 3, 2026", ]) { const r = parseOpenRange(input); expect(r?.end).toBeNull(); expect(r?.start.iso.slice(0, 4)).toBe("2026"); } }); test("returns null when there is no full date at all", () => { expect(parseOpenRange("08/12 - 08/24")).toBeNull(); expect(parseOpenRange("Releases in Version 3.6")).toBeNull(); }); }); describe("parseAdjacentFullRange", () => { test("reads two dates separated by nothing but whitespace", () => { // Persona 5 separates the halves of a duration cell with an
, which a // tag-stripping reader flattens to a single space. expect(parseAdjacentFullRange("July 30, 2026 August 13, 2026")).toEqual({ start: { iso: "2026-07-30T00:00:00.000Z", precision: "day" }, end: { iso: "2026-08-13T00:00:00.000Z", precision: "day" }, }); }); test("requires a year on both halves", () => { expect(parseAdjacentFullRange("July 30, 2026 August 13")).toBeNull(); }); test("refuses a second half that is only partly a date", () => { // "July 16/30" names two candidate ends. Reading either as the end would // be a coin flip presented as a fact. expect(parseAdjacentFullRange("June 25, 2026 July 16/30, 2026")).toBeNull(); }); test("is anchored, so prose after a date is not read as an end", () => { // Without the anchors, "Day 3" parses as a month and a day. expect( parseAdjacentFullRange("August 12, 2026 Day 3 rewards are doubled"), ).toBeNull(); expect(parseAdjacentFullRange("Starts August 12, 2026")).toBeNull(); }); test("rejects an impossible calendar date rather than rolling it over", () => { expect(parseAdjacentFullRange("February 30, 2026 March 4, 2026")).toBeNull(); }); }); describe("parseLabelledStartEnd", () => { test("reads a labelled cell", () => { expect( parseLabelledStartEnd("Start: April 28, 2025 End: June 12, 2025"), ).toEqual({ start: { iso: "2025-04-28T00:00:00.000Z", precision: "day" }, end: { iso: "2025-06-12T00:00:00.000Z", precision: "day" }, }); }); test("reports no end when the end half is not a date", () => { // "Permanent" is the source telling us there is no deadline. Inventing one // is the failure this codebase exists to prevent. const permanent = parseLabelledStartEnd( "Start: January 24, 2025 End: Permanent", ); expect(permanent?.start.iso).toBe("2025-01-24T00:00:00.000Z"); expect(permanent?.end).toBeNull(); expect(parseLabelledStartEnd("Start: January 24, 2025 End: TBD")?.end).toBeNull(); expect(parseLabelledStartEnd("Start: January 24, 2025")?.end).toBeNull(); }); test("returns null when the start half is not a date", () => { expect(parseLabelledStartEnd("Start: After maintenance End: TBD")).toBeNull(); expect(parseLabelledStartEnd("Ends after 30 days of making your account.")).toBeNull(); }); test("requires the colons, so prose containing 'end' does not split", () => { // Without them, any sentence mentioning an end would look like a boundary. expect( parseLabelledStartEnd("Start the quest before August 12, 2026 to end the arc"), ).toBeNull(); }); }); describe("parseYearFirstSlashRange", () => { test("reads a year-first range at day precision", () => { expect(parseYearFirstSlashRange("2026/07/30 – 2026/08/20")).toEqual({ start: { iso: "2026-07-30T00:00:00.000Z", precision: "day" }, end: { iso: "2026-08-20T00:00:00.000Z", precision: "day" }, }); }); test("accepts a hyphen as well as an en dash", () => { expect(parseYearFirstSlashRange("2026/07/30 - 2026/08/20")?.end.iso).toBe( "2026-08-20T00:00:00.000Z", ); }); test("ignores an ISO timestamp sitting beside the range", () => { // The Arknights wiki prints the countdown target in the same cell: // "2026/07/30 – 2026/08/20; ends in 2026-08-20T10:59:59+00:00". The dashes // in the ISO half must not be read as a second range. const r = parseYearFirstSlashRange( "2026/07/30 – 2026/08/20; ends in 2026-08-20T10:59:59+00:00", ); expect(r?.start.iso).toBe("2026-07-30T00:00:00.000Z"); expect(r?.end.iso).toBe("2026-08-20T00:00:00.000Z"); }); test("rejects an impossible calendar date rather than rolling it over", () => { expect(parseYearFirstSlashRange("2026/02/30 – 2026/03/17")).toBeNull(); }); test("returns null when either side is missing its year", () => { expect(parseYearFirstSlashRange("07/30 – 2026/08/20")).toBeNull(); expect(parseYearFirstSlashRange("2026/07/30 – 08/20")).toBeNull(); }); });