Files
gacha-event-tracker/test/daily.test.ts
T
Lucas WintherandClaude Opus 5 00c3f5e2da refactor: move the server-reset clock out of daily.ts
Ticks are about to stop being its only caller: the countdown needs the same
grid, and daily.ts already imports time.ts, so asking for it the other way
would close a cycle. time.ts is where it belongs anyway — it has held
REGION_RESET_UTC_OFFSET all along, and serverOffsetUtc is that table's
accessor. Nothing changes shape; `shift` is exported as `resetShiftMs`
because dayKey now reads it across a module boundary.

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

411 lines
15 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
dailiesId,
dailyDays,
dailyOverride,
dailySummary,
dayKey,
isDaily,
msUntilReset,
nextResetMs,
resolveDaily,
streakOf,
} from "../src/shared/daily.ts";
import {
DAY,
HOUR,
resetHourFor,
RESET_HOUR_LOCAL,
} from "../src/shared/time.ts";
const at = (iso: string) => Date.parse(iso);
describe("isDaily", () => {
const base = { type: "other" as const, title: "", summary: null };
test("a login campaign always repeats", () => {
expect(isDaily({ ...base, type: "login", title: "Traveler's Log" })).toBe(true);
});
test("reads the phrasing sources actually use", () => {
for (const title of [
"Daily Check-In Rewards",
"Sign-in Event",
"7-Day Login Bonus",
"Log-in Campaign: Frostlands",
]) {
expect(isDaily({ ...base, title })).toBe(true);
}
});
test("finds it in the summary when the title is a codename", () => {
expect(
isDaily({
...base,
title: "Mutual Aid in Bloom",
summary: "Complete a task each day to earn Primogems.",
}),
).toBe(true);
});
test("an ordinary event gets no checklist", () => {
// A false positive puts a twenty-box checklist on a story chapter, which
// the reader then has to work out and dismiss.
expect(
isDaily({
...base,
type: "banner",
title: "Mutual Aid in Bloom",
summary: "Limited character banner rerun.",
}),
).toBe(false);
expect(isDaily({ ...base, type: "challenge", title: "Spiral Abyss" })).toBe(false);
});
});
describe("resolveDaily", () => {
const detected = { type: "login" as const, title: "Daily Check-In", summary: null };
const plain = { type: "story" as const, title: "Chapter Three", summary: null };
test("detection stands until the reader says otherwise", () => {
expect(resolveDaily(detected, undefined)).toBe(true);
expect(resolveDaily(plain, undefined)).toBe(false);
});
test("the reader can mark an event the source never called daily", () => {
// The case this exists for: a grind whose page never prints the word, but
// which the player knows resets every day.
expect(resolveDaily(plain, true)).toBe(true);
});
test("detection can be switched off entirely", () => {
// The preference only silences the guess. Nothing the reader marked is
// affected, and no logged day is touched, so it is reversible.
expect(resolveDaily(detected, undefined, false)).toBe(false);
expect(resolveDaily(detected, true, false)).toBe(true);
expect(resolveDaily(plain, true, false)).toBe(true);
expect(resolveDaily(detected, undefined, true)).toBe(true);
});
test("the reader can unmark a false positive", () => {
// A banner whose blurb happens to mention "daily login rewards" should not
// be stuck with a twenty-box checklist the reader cannot dismiss.
expect(resolveDaily(detected, false)).toBe(false);
});
});
describe("dailyOverride", () => {
test("agreeing with detection records nothing", () => {
// Storing "yes" on an event already detected as daily would freeze today's
// guess into the reader's data, so a later parser fix could never reach it.
expect(dailyOverride(true, true)).toBeUndefined();
expect(dailyOverride(false, false)).toBeUndefined();
});
test("disagreeing with detection records the disagreement", () => {
expect(dailyOverride(true, false)).toBe(true);
expect(dailyOverride(false, true)).toBe(false);
});
test("round-trips: overriding then changing back leaves no trace", () => {
const detectedDaily = false;
const on = dailyOverride(true, detectedDaily);
expect(resolveDaily({ type: "story", title: "x", summary: null }, on)).toBe(true);
const off = dailyOverride(false, detectedDaily);
expect(off).toBeUndefined();
});
});
describe("dayKey", () => {
test("the game day rolls at 04:00 server time, not midnight", () => {
// Asia is UTC+8, so its 04:00 reset is 20:00 UTC the day before. Someone
// playing at 02:00 local is still on the previous day's dailies, and a
// naive UTC date would tick the wrong box for four hours every night.
expect(dayKey(at("2026-08-15T19:59:00Z"), "asia")).toBe("2026-08-15");
expect(dayKey(at("2026-08-15T20:00:00Z"), "asia")).toBe("2026-08-16");
});
test("each region rolls at its own instant", () => {
const instant = at("2026-08-16T02:00:00Z");
// Europe (UTC+1) resets at 03:00 UTC, so 02:00 is still the 15th there,
// while Asia rolled over six hours earlier.
expect(dayKey(instant, "europe")).toBe("2026-08-15");
expect(dayKey(instant, "asia")).toBe("2026-08-16");
});
test("survives a UTC month boundary", () => {
expect(dayKey(at("2026-09-01T00:30:00Z"), "america")).toBe("2026-08-31");
});
});
describe("a game whose server map differs from the default", () => {
// Endfield has two server groups, not three: Europe is served off the
// Americas machine on a fixed UTC-5, so a European player's 04:00 reset is
// 09:00 UTC — six hours after the HoYo/Kuro Europe pattern. Asia has its own
// server and is unaffected.
const morning = at("2026-08-16T08:00:00Z");
test("the override beats the regional default for that region", () => {
expect(dayKey(morning, "europe", "endfield")).toBe("2026-08-15");
expect(dayKey(morning, "europe", "genshin")).toBe("2026-08-16");
});
test("rolls exactly on 09:00 UTC in Europe", () => {
expect(dayKey(at("2026-08-16T08:59:59Z"), "europe", "endfield")).toBe(
"2026-08-15",
);
expect(dayKey(at("2026-08-16T09:00:00Z"), "europe", "endfield")).toBe(
"2026-08-16",
);
expect(nextResetMs(morning, "europe", "endfield")).toBe(
at("2026-08-16T09:00:00Z"),
);
});
test("a region with its own server keeps the answer it always had", () => {
// The override is per region on purpose. A blanket per-game offset would
// drag Asia — which genuinely has its own Endfield server — onto the
// Americas clock, moving a reader's already-logged day keys by eleven
// hours to fix a bug they never had.
for (const region of ["asia", "america"] as const) {
for (const hour of [0, 6, 9, 14, 20, 23]) {
const t = at(`2026-08-16T${String(hour).padStart(2, "0")}:30:00Z`);
expect(dayKey(t, region, "endfield")).toBe(dayKey(t, region));
}
}
});
test("a game with no override still follows its region", () => {
// Europe is UTC+1, so 03:00 UTC. Asia rolled six hours before that.
const instant = at("2026-08-16T02:00:00Z");
expect(dayKey(instant, "europe", "genshin")).toBe("2026-08-15");
expect(dayKey(instant, "asia", "genshin")).toBe("2026-08-16");
// …and matches the answer given with no game at all.
expect(dayKey(instant, "europe", "genshin")).toBe(dayKey(instant, "europe"));
});
test("the checklist counts days on the clock that reader is actually on", () => {
const start = at("2026-08-16T09:00:00Z"); // an endfield europe reset
expect(dailyDays(start, start + 3 * DAY, "europe", "endfield")).toEqual([
"2026-08-16",
"2026-08-17",
"2026-08-18",
]);
const summary = dailySummary({
startsMs: start,
endsMs: start + 3 * DAY,
region: "europe",
game: "endfield",
now: at("2026-08-17T08:00:00Z"), // still the 16th on a UTC-5 server
logged: [],
});
expect(summary.today).toBe("2026-08-16");
expect(summary.msUntilReset).toBe(HOUR);
});
test("an Asia reader's checklist is byte-identical to before the override", () => {
// The regression this guards: an Endfield event whose end lands between the
// two candidate resets loses its final claimable day if the wrong clock is
// used, and a day that leaves `dailyDays` can never be ticked or untucked
// again — the pip is simply not rendered.
const start = at("2026-08-06T04:00:00Z");
const end = at("2026-08-19T22:00:00Z");
expect(dailyDays(start, end, "asia", "endfield")).toEqual(
dailyDays(start, end, "asia"),
);
expect(dailyDays(start, end, "asia", "endfield")).toContain("2026-08-20");
});
});
describe("nextResetMs", () => {
test("is the next reset strictly after the instant given", () => {
const reset = at("2026-08-15T20:00:00Z"); // asia
expect(nextResetMs(reset - 1, "asia")).toBe(reset);
// Standing exactly on a reset, the next one is tomorrow's — otherwise the
// countdown would read "0s" for a whole tick.
expect(nextResetMs(reset, "asia")).toBe(reset + DAY);
});
test("msUntilReset never exceeds a day", () => {
for (const region of ["asia", "america", "europe"] as const) {
const left = msUntilReset(at("2026-08-15T11:22:33Z"), region);
expect(left).toBeGreaterThan(0);
expect(left).toBeLessThanOrEqual(DAY);
}
});
});
describe("dailyDays", () => {
const start = at("2026-08-12T20:00:00Z"); // an asia reset instant
test("one entry per claimable day", () => {
const days = dailyDays(start, start + 7 * DAY, "asia");
expect(days).toEqual([
"2026-08-13",
"2026-08-14",
"2026-08-15",
"2026-08-16",
"2026-08-17",
"2026-08-18",
"2026-08-19",
]);
});
test("an end landing on a reset gives you nothing that day", () => {
// Ending at 04:00 means the final day was never claimable; listing it
// would show a box that can only ever be a miss.
const days = dailyDays(start, start + 3 * DAY, "asia");
expect(days).toHaveLength(3);
expect(days?.at(-1)).toBe("2026-08-15");
});
test("an unannounced end yields no checklist rather than a made-up one", () => {
// This is the endsAt: null rule. Filling twenty boxes from a guessed end
// date is exactly the fabrication the schema exists to prevent.
expect(dailyDays(start, null, "asia")).toBeNull();
});
test("a nonsense window still returns something finite", () => {
expect(dailyDays(start, start + 400 * DAY, "asia")).toHaveLength(200);
expect(dailyDays(start, start - DAY, "asia")).toEqual(["2026-08-13"]);
});
});
describe("dailySummary", () => {
const start = at("2026-08-12T20:00:00Z");
const week = { startsMs: start, endsMs: start + 7 * DAY, region: "asia" as const };
test("counts what is left including today", () => {
const s = dailySummary({
...week,
now: at("2026-08-15T12:00:00Z"),
logged: ["2026-08-13", "2026-08-15"],
});
expect(s.today).toBe("2026-08-15");
expect(s.doneToday).toBe(true);
expect(s.logged).toBe(2);
expect(s.remaining).toBe(5); // 15th through 19th
expect(s.missed).toBe(1); // the 14th
});
test("a day the reader missed is reported, not hidden", () => {
const s = dailySummary({
...week,
now: at("2026-08-16T12:00:00Z"),
logged: [],
});
expect(s.doneToday).toBe(false);
expect(s.missed).toBe(3);
expect(s.remaining).toBe(4);
});
test("an unannounced end still counts the ticks", () => {
const s = dailySummary({
...week,
endsMs: null,
now: at("2026-08-16T12:00:00Z"),
logged: ["2026-08-14", "2026-08-15"],
});
expect(s.days).toBeNull();
expect(s.remaining).toBeNull();
expect(s.missed).toBeNull();
expect(s.logged).toBe(2);
});
test("ticks outside the published window are never discarded", () => {
// If a source quietly moves a date, the reader's own record of having
// played still stands — nothing else holds a copy of it.
const s = dailySummary({
...week,
endsMs: null,
now: at("2026-08-16T12:00:00Z"),
logged: ["2020-01-01"],
});
expect(s.logged).toBe(1);
});
test("resets are reported against the reader's own region", () => {
const evening = at("2026-08-15T21:00:00Z");
expect(dailySummary({ ...week, now: evening, logged: [] }).msUntilReset).toBe(
23 * HOUR,
);
});
});
describe("streakOf", () => {
test("counts back from yesterday when today is not done yet", () => {
// Otherwise a fortnight's run reads as broken every morning, which is the
// one time the number actually matters to the reader.
expect(
streakOf(["2026-08-10", "2026-08-11", "2026-08-12"], "2026-08-13"),
).toBe(3);
});
test("includes today once it is done", () => {
expect(streakOf(["2026-08-12", "2026-08-13"], "2026-08-13")).toBe(2);
});
test("a gap ends the run", () => {
expect(streakOf(["2026-08-09", "2026-08-11"], "2026-08-12")).toBe(1);
expect(streakOf([], "2026-08-12")).toBe(0);
});
test("skips a month boundary correctly", () => {
expect(streakOf(["2026-07-31", "2026-08-01"], "2026-08-01")).toBe(2);
});
});
describe("dailiesId", () => {
test("cannot collide with an event ID", () => {
// Event IDs are `game:slug:date`; these are deliberately two segments.
expect(dailiesId("genshin")).toBe("dailies:genshin");
expect(dailiesId("genshin").split(":")).toHaveLength(2);
});
});
describe("a game whose day rolls on a different hour", () => {
// Reverse: 1999 runs one global server on UTC-5 and resets at 05:00 rather
// than 04:00, so its day rolls at 10:00 UTC. Both facts are read off the
// source: every row of its event list states (UTC-5) and runs 05:00 → 04:59.
test("rolls on 10:00 UTC, not the 09:00 a UTC-5 game would", () => {
expect(dayKey(at("2026-08-16T09:59:59Z"), "europe", "r1999")).toBe(
"2026-08-15",
);
expect(dayKey(at("2026-08-16T10:00:00Z"), "europe", "r1999")).toBe(
"2026-08-16",
);
expect(nextResetMs(at("2026-08-16T08:00:00Z"), "europe", "r1999")).toBe(
at("2026-08-16T10:00:00Z"),
);
});
test("every region gets the same answer, because there is one server", () => {
const instant = at("2026-08-16T09:30:00Z");
const keys = (["asia", "america", "europe"] as const).map((r) =>
dayKey(instant, r, "r1999"),
);
expect(new Set(keys).size).toBe(1);
});
test("resetHourFor defaults to 04:00 for every other game", () => {
// Adding the field must not have moved a single existing reader's day key,
// which is why it is an override rather than a per-game table.
expect(resetHourFor("r1999")).toBe(5);
for (const game of ["genshin", "endfield", "arknights", undefined] as const) {
expect(resetHourFor(game)).toBe(RESET_HOUR_LOCAL);
}
// A lane the reader invented has no server map and takes the default too.
expect(resetHourFor("mygame:my-own-game")).toBe(RESET_HOUR_LOCAL);
});
test("an event's checklist counts days on the 05:00 clock", () => {
const start = at("2026-08-13T10:00:00Z"); // an r1999 reset
expect(dailyDays(start, start + 3 * DAY, "europe", "r1999")).toEqual([
"2026-08-13",
"2026-08-14",
"2026-08-15",
]);
});
});