Files
gacha-event-tracker/test/dates.test.ts
T
Lucas WintherandClaude Opus 5 1dd58d3f52 feat(arknights): track Arknights from its own wiki
The most-named game in the release thread, and the cheapest one to add: the
GameId, hue and dailyTasks have been sitting in games.ts since launch with no
source behind them.

arknights.wiki.gg is a better source than Game8 for this game, but it is not the
mp-event shape wikigg.ts reads, so it gets its own parser. Two properties of the
page drive the code:

- Every row lists CN and Global, about five months apart. Only Global is
  published; a row without one yields no event rather than borrowing the CN
  date. A CN date on a Global calendar is a confidently wrong date.
- Only the next boundary carries a machine-readable timer — the end while an
  event runs, the start while it is upcoming — so precision differs per side and
  flips when the event goes live. An exact instant is accepted only when it
  falls on the same UTC day as the date beside it, because startsAt.slice(0, 10)
  is part of the event ID: a start that moved a day would orphan every
  completion mark on the morning the event began.

Also sets resetOffsets for all three regions to UTC-7. Not a blanket per-game
offset — Arknights genuinely runs one Global server for every region we model,
and the page evidences the clock: every ending event ends at 10:59:59Z, which is
03:59:59 at UTC-7, one second before a 04:00 reset.

Six events, counted independently against the page before and after parsing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:05:01 +02:00

237 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <hr>, 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();
});
});