feat: add Punishing: Gray Raven with karendar.com source
Add karendar.com as the event source for Punishing: Gray Raven (PGR). Karendar publishes a dedicated Global event calendar with exact UTC timestamps in clean server-rendered HTML. - Register 'pgr' in GameId and GAMES registry (#CC292B, Kuro Games). - Add parseWeekdayDayMonthYearUtc in src/ingest/dates.ts with unit tests. - Implement karendarParser in src/ingest/parsers/karendar.ts reading 'week', 'ongoing', and 'upcoming' sections, mapping indefinite/permanent ends to endsAt: null, and categorizing tags to EventType. - Register pgr-karendar-events in src/ingest/adapters/index.ts. - Add pinned fixture, expected output, and comprehensive test suites. - Update documentation in AGENTS.md, README.md, docs/INGESTION.md, and docs/SOURCES.md.
This commit is contained in:
@@ -64,6 +64,8 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
|
||||
// edge the source labels `ESTIMATED WEEK`, not an announced date — see
|
||||
// src/ingest/parsers/arustats.ts and docs/SOURCES.md § 14.
|
||||
{ adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
|
||||
// Punishing: Gray Raven (karendar.com). Exact UTC timestamps for the Global server.
|
||||
{ adapter: adapter("pgr-karendar-events"), fixture: "fixtures/pgr/karendar-events-2026-09-12" },
|
||||
];
|
||||
|
||||
async function runAdapter(adapter: Adapter, fixture: string) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapterById } from "../../src/ingest/adapters/index.ts";
|
||||
import { karendarParser } from "../../src/ingest/parsers/karendar.ts";
|
||||
|
||||
const FIXTURE = "fixtures/pgr/karendar-events-2026-09-12.html";
|
||||
const NOW = "2026-09-12T00:00:00.000Z";
|
||||
|
||||
const pgr = adapterById("pgr-karendar-events");
|
||||
if (pgr === undefined) throw new Error("no adapter 'pgr-karendar-events'");
|
||||
|
||||
function parse(html: string) {
|
||||
return pgr!.parse(html, {
|
||||
now: NOW,
|
||||
sourceUrl: pgr!.url,
|
||||
sourceId: pgr!.id,
|
||||
game: pgr!.game,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Karendar parser (Punishing: Gray Raven)", () => {
|
||||
test("structural detector recognises Karendar pages and rejects others", () => {
|
||||
expect(
|
||||
karendarParser.canParse(
|
||||
'<main><section id="ongoing"></section><section id="upcoming"></section><footer>Karendar</footer></main>',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(karendarParser.canParse("<html><body>Different site</body></html>")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
karendarParser.canParse(
|
||||
'<main><section id="ongoing"></section><footer>Karendar</footer></main>',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("publishes exact UTC timestamps with exact precision", async () => {
|
||||
const html = await Bun.file(FIXTURE).text();
|
||||
const events = parse(html);
|
||||
|
||||
// War Zone (weekly recurring combat)
|
||||
const warZone = events.find((e) => e.title === "War Zone");
|
||||
expect(warZone).toBeDefined();
|
||||
expect(warZone?.startsAt).toBe("2026-09-07T07:00:00.000Z");
|
||||
expect(warZone?.endsAt).toBe("2026-09-13T18:00:00.000Z");
|
||||
expect(warZone?.startPrecision).toBe("exact");
|
||||
expect(warZone?.endPrecision).toBe("exact");
|
||||
expect(warZone?.regionScoped).toBe(false);
|
||||
expect(warZone?.regionEnds).toBeNull();
|
||||
});
|
||||
|
||||
test("handles indefinite and permanent ends as endsAt null with precision unknown", async () => {
|
||||
const html = await Bun.file(FIXTURE).text();
|
||||
const events = parse(html);
|
||||
|
||||
// Main Story: Steering By Light (Permanent)
|
||||
const story = events.find(
|
||||
(e) => e.title === "Main Story: Steering By Light",
|
||||
);
|
||||
expect(story).toBeDefined();
|
||||
expect(story?.startsAt).toBe("2026-08-20T05:00:00.000Z");
|
||||
expect(story?.endsAt).toBeNull();
|
||||
expect(story?.endPrecision).toBe("unknown");
|
||||
|
||||
// Upcoming banner with unannounced end date
|
||||
const adelyde = events.find((e) =>
|
||||
e.title.includes("Adelyde: Anabasis"),
|
||||
);
|
||||
expect(adelyde).toBeDefined();
|
||||
expect(adelyde?.startsAt).toBe("2026-09-24T05:00:00.000Z");
|
||||
expect(adelyde?.endsAt).toBeNull();
|
||||
expect(adelyde?.endPrecision).toBe("unknown");
|
||||
});
|
||||
|
||||
test("skips archive (ended), tbc (unknown start), and codes sections", async () => {
|
||||
const html = await Bun.file(FIXTURE).text();
|
||||
const events = parse(html);
|
||||
|
||||
// In archive:
|
||||
expect(
|
||||
events.some((e) => e.title.includes("Version Maintenance – Steering By Light")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
events.some((e) => e.title.includes("One Heart, One Hertz Concert")),
|
||||
).toBe(false);
|
||||
|
||||
// In TBC:
|
||||
expect(events.some((e) => e.title === "Circuit Calculus")).toBe(false);
|
||||
expect(events.some((e) => e.title === "Wreck-It Huhu")).toBe(false);
|
||||
|
||||
// In codes:
|
||||
expect(events.some((e) => e.title.includes("PUNISHING0924"))).toBe(false);
|
||||
});
|
||||
|
||||
test("spot-checks specific events matching live page content", async () => {
|
||||
const html = await Bun.file(FIXTURE).text();
|
||||
const events = parse(html);
|
||||
|
||||
// 1. Karenina: Effulgence character debut banner
|
||||
const karenina = events.find((e) => e.title === "Karenina: Effulgence");
|
||||
expect(karenina).toBeDefined();
|
||||
expect(karenina?.type).toBe("banner");
|
||||
expect(karenina?.startsAt).toBe("2026-08-20T05:00:00.000Z");
|
||||
expect(karenina?.endsAt).toBe("2026-09-23T23:00:00.000Z");
|
||||
|
||||
// 2. Patch End Maintenance
|
||||
const maint = events.find(
|
||||
(e) => e.title === "Patch End Maintenance",
|
||||
);
|
||||
expect(maint).toBeDefined();
|
||||
expect(maint?.type).toBe("maintenance");
|
||||
expect(maint?.startsAt).toBe("2026-09-23T23:00:00.000Z");
|
||||
expect(maint?.endsAt).toBe("2026-09-24T05:00:00.000Z");
|
||||
|
||||
// 3. Collab banner: PGR x Date A Live V
|
||||
const collab = events.find((e) =>
|
||||
e.title.includes("PGR × Date A Live V – Kurumi Tokisaki"),
|
||||
);
|
||||
expect(collab).toBeDefined();
|
||||
expect(collab?.type).toBe("banner");
|
||||
expect(collab?.startsAt).toBe("2026-09-29T05:00:00.000Z");
|
||||
expect(collab?.endsAt).toBeNull();
|
||||
expect(collab?.endPrecision).toBe("unknown");
|
||||
|
||||
// 4. Overclock Simulation
|
||||
const overclock = events.find((e) => e.title === "Overclock Simulation");
|
||||
expect(overclock).toBeDefined();
|
||||
expect(overclock?.type).toBe("challenge");
|
||||
expect(overclock?.startsAt).toBe("2026-09-29T10:00:00.000Z");
|
||||
expect(overclock?.endsAt).toBe("2026-11-04T05:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseYearFirstSlashRange,
|
||||
parseIsoClockRangeUtc,
|
||||
parseIsoOffsetInstant,
|
||||
parseWeekdayDayMonthYearUtc,
|
||||
} from "../src/ingest/dates.ts";
|
||||
|
||||
describe("parseMonthDayYear", () => {
|
||||
@@ -539,3 +540,44 @@ describe("parseIsoOffsetInstant", () => {
|
||||
expect(parseIsoOffsetInstant("Starts 2026-08-03T21:00-07:00")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWeekdayDayMonthYearUtc", () => {
|
||||
test("parses full weekday date with exact UTC time", () => {
|
||||
expect(
|
||||
parseWeekdayDayMonthYearUtc("Mon, 7 Sept 2026, 07:00 UTC"),
|
||||
).toEqual({
|
||||
iso: "2026-09-07T07:00:00.000Z",
|
||||
precision: "exact",
|
||||
});
|
||||
expect(
|
||||
parseWeekdayDayMonthYearUtc("Thu, 20 Aug 2026, 05:00 UTC"),
|
||||
).toEqual({
|
||||
iso: "2026-08-20T05:00:00.000Z",
|
||||
precision: "exact",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts input without weekday or comma", () => {
|
||||
expect(parseWeekdayDayMonthYearUtc("7 Sept 2026 07:00 UTC")?.iso).toBe(
|
||||
"2026-09-07T07:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts optional seconds", () => {
|
||||
expect(
|
||||
parseWeekdayDayMonthYearUtc("Wed, 23 Sept 2026, 23:00:00 UTC")?.iso,
|
||||
).toBe("2026-09-23T23:00:00.000Z");
|
||||
});
|
||||
|
||||
test("returns null for non-date strings like Permanent or Unknown", () => {
|
||||
expect(parseWeekdayDayMonthYearUtc("Permanent")).toBeNull();
|
||||
expect(parseWeekdayDayMonthYearUtc("Unknown")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects missing year or invalid dates", () => {
|
||||
expect(parseWeekdayDayMonthYearUtc("Mon, 7 Sept, 07:00 UTC")).toBeNull();
|
||||
expect(
|
||||
parseWeekdayDayMonthYearUtc("Mon, 30 Feb 2026, 07:00 UTC"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user