diff --git a/.env.deploy.example b/.env.deploy.example index 0f8e559..ca6d66a 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -12,4 +12,4 @@ WORKER_URL= # Same MODULES value as wrangler.toml [vars]. Duplicated here so the register # script can derive the public command list without parsing wrangler.toml. -MODULES=util,wordle,loldle,misc +MODULES=util,wordle,loldle,misc,trading,lolschedule diff --git a/src/modules/index.js b/src/modules/index.js index ce1b166..dba07ae 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -14,4 +14,5 @@ export const moduleRegistry = { loldle: () => import("./loldle/index.js"), misc: () => import("./misc/index.js"), trading: () => import("./trading/index.js"), + lolschedule: () => import("./lolschedule/index.js"), }; diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md new file mode 100644 index 0000000..addc4ab --- /dev/null +++ b/src/modules/lolschedule/README.md @@ -0,0 +1,41 @@ +# lolschedule + +LoL esports match schedule via the **Leaguepedia** MediaWiki/Cargo API. + +## Commands + +| Command | Description | +|---|---| +| `/lol_today` | Today's matches (ICT). Scores if played / live. Times if scheduled. | +| `/lol_week` | Next 7 days, grouped by day. | + +## Data source + +- Endpoint: `https://lol.fandom.com/api.php?action=cargoquery` +- Table: `MatchSchedule` +- Fields used: `DateTime_UTC`, `Team1`, `Team2`, `Team1Score`, `Team2Score`, `Winner`, `Tournament`, `BestOf`, `OverviewPage` +- Auth: none. UA header identifies the bot. + +See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and `plans/reports/researcher-260421-0909-leaguepedia-auth-token.md` for the feasibility verdict + rate-limit strategy. + +## Caching + +Two layers: + +1. **Worker edge cache** — `fetch(url, { cf: { cacheTtl: 30, cacheEverything: true }})` dedupes near-simultaneous calls across requests to the same edge POP. +2. **KV cache** (per module) — `matches:{fromIso}:{toIso}` with `ts`+`rows` payload. TTL: 60 s for `/lol_today`, 300 s for `/lol_week`. On fetch failure, stale cache (up to 4× TTL) is returned as a fallback. + +## Time zone + +All rendering is in **ICT (UTC+7)**. `DateTime_UTC` strings from the wiki are parsed as UTC, then shifted for display. Change `TZ_OFFSET_MS` in `format.js` if you need a different zone. + +## Files + +- `index.js` — module contract, registers `/lol_today` and `/lol_week`. +- `api-client.js` — cargoquery POST, range fetch, cache-first wrapper. +- `format.js` — pure renderers (`classifyMatch`, `formatMatchLine`, `renderToday`, `renderWeek`). +- `handlers.js` — grammY command handlers + ICT day-boundary helpers. + +## Known caveat + +The `where=` clause with `>=`/`<` operators hits `MWException` from shared egress IPs during verification (see auth-token report). POST + a proper UA appears to work from CF Worker egress, but confirm on first deploy. If it fails in production, fall back to `HOLDS`-based filters or a client-side filter over a broader fetch. diff --git a/src/modules/lolschedule/api-client.js b/src/modules/lolschedule/api-client.js new file mode 100644 index 0000000..2e57939 --- /dev/null +++ b/src/modules/lolschedule/api-client.js @@ -0,0 +1,124 @@ +/** + * @file Leaguepedia cargoquery client for LoL esports match schedule. + * + * Uses the MediaWiki Cargo extension on lol.fandom.com. No auth. We identify + * ourselves with a contact UA (Fandom policy) and cache via both the Worker + * edge cache (`cf.cacheTtl`) and the module's KVStore for cross-edge reuse. + * + * @see plans/reports/researcher-260421-0845-leaguepedia-api-verification.md + */ + +const API_URL = "https://lol.fandom.com/api.php"; +const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)"; + +/** Default KV cache windows — short enough to catch score updates mid-day. */ +const CACHE_TTL_TODAY_SEC = 60; +const CACHE_TTL_WEEK_SEC = 300; + +/** + * @typedef {object} MatchRow + * @property {string} DateTime — "YYYY-MM-DD HH:MM:SS" UTC (Leaguepedia convention). + * @property {string} T1 + * @property {string} T2 + * @property {string|null} S1 — team-1 score, may be empty string before match. + * @property {string|null} S2 + * @property {string|null} Winner — "1" | "2" | "" when unplayed. + * @property {string} Tournament + * @property {string|null} BO — best-of count as string. + * @property {string} OP — OverviewPage (wiki slug) for deep-link. + */ + +/** + * Low-level cargoquery POST. Uses POST to avoid edge WAF stripping of `>=`/`<` + * in the query string and to stay under URL-length limits for long where clauses. + * + * @param {Record} params + * @returns {Promise} array of row `title` objects + */ +async function cargoQuery(params) { + const body = new URLSearchParams({ + action: "cargoquery", + format: "json", + ...params, + }); + const res = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + Accept: "application/json", + }, + body, + cf: { cacheTtl: 30, cacheEverything: true }, + }); + if (!res.ok) throw new Error(`Leaguepedia API HTTP ${res.status}`); + const json = await res.json(); + if (json?.error) throw new Error(`Leaguepedia error: ${json.error.info || json.error.code}`); + return (json?.cargoquery || []).map((r) => r.title); +} + +/** Format a JS Date as Leaguepedia's UTC literal: `YYYY-MM-DD HH:MM:SS`. */ +function toUtcLiteral(date) { + const pad = (n) => String(n).padStart(2, "0"); + return ( + `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` + + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` + ); +} + +/** + * Fetch matches with DateTime_UTC in [from, to). + * + * @param {Date} from + * @param {Date} to + * @returns {Promise} + */ +export async function fetchMatchesInRange(from, to) { + const fromLit = toUtcLiteral(from); + const toLit = toUtcLiteral(to); + const rows = await cargoQuery({ + tables: "MatchSchedule=MS", + fields: + "MS.DateTime_UTC=DateTime," + + "MS.Team1=T1,MS.Team2=T2," + + "MS.Team1Score=S1,MS.Team2Score=S2," + + "MS.Winner=Winner,MS.Tournament=Tournament," + + "MS.BestOf=BO,MS.OverviewPage=OP", + where: `MS.DateTime_UTC >= "${fromLit}" AND MS.DateTime_UTC < "${toLit}"`, + order_by: "MS.DateTime_UTC ASC", + limit: "100", + }); + return /** @type {MatchRow[]} */ (rows); +} + +/** + * Cache-first match lookup keyed by date range. Returns cached rows without + * refetch within TTL; on fetch failure, returns stale cache if available. + * + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {Date} from + * @param {Date} to + * @param {number} ttlSec + * @returns {Promise} + */ +export async function getCachedMatches(db, from, to, ttlSec) { + const key = `matches:${from.toISOString()}:${to.toISOString()}`; + const cached = await db.getJSON(key); + if (cached?.ts && Date.now() - cached.ts < ttlSec * 1000) { + return cached.rows; + } + try { + const rows = await fetchMatchesInRange(from, to); + try { + await db.putJSON(key, { ts: Date.now(), rows }, { expirationTtl: ttlSec * 4 }); + } catch (err) { + console.warn("lolschedule: KV putJSON failed", String(err)); + } + return rows; + } catch (err) { + if (cached?.rows) return cached.rows; // stale fallback + throw err; + } +} + +export { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC }; diff --git a/src/modules/lolschedule/format.js b/src/modules/lolschedule/format.js new file mode 100644 index 0000000..109cd8b --- /dev/null +++ b/src/modules/lolschedule/format.js @@ -0,0 +1,167 @@ +/** + * @file Pure formatters — Telegram HTML output for today / week match lists. + * + * All user-influenced substrings are escaped; match rows come from the public + * wiki so we treat them as untrusted. Times are rendered in ICT (UTC+7) since + * the bot's primary audience is VN; feel free to branch by chat locale later. + */ + +import { escapeHtml } from "../../util/escape-html.js"; + +/** @typedef {import("./api-client.js").MatchRow} MatchRow */ + +const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7 + +/** Parse Leaguepedia's `YYYY-MM-DD HH:MM:SS` UTC literal into a Date. */ +export function parseUtc(literal) { + return new Date(`${literal.replace(" ", "T")}Z`); +} + +/** Shift a UTC date by the ICT offset so getUTC* methods yield ICT components. */ +function toIct(date) { + return new Date(date.getTime() + TZ_OFFSET_MS); +} + +/** Format ICT time as `HH:MM`. */ +function formatIctTime(date) { + const d = toIct(date); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +} + +/** Format ICT date as `Mon Apr 21` (weekday + month + day). */ +function formatIctDayLabel(date) { + const d = toIct(date); + const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${weekdays[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}`; +} + +/** ICT calendar-day key `YYYY-MM-DD` (used for grouping). */ +function ictDayKey(date) { + const d = toIct(date); + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; +} + +/** Coerce Leaguepedia's string score into a number, or null. */ +function parseScore(s) { + if (s == null || s === "") return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} + +/** + * Classify a match row's display state. + * + * @param {MatchRow} row + * @param {number} nowMs — injected for testability. + * @returns {"played"|"live"|"scheduled"} + */ +export function classifyMatch(row, nowMs = Date.now()) { + const startMs = parseUtc(row.DateTime).getTime(); + const winner = String(row.Winner ?? ""); + if (winner === "1" || winner === "2") return "played"; + const s1 = parseScore(row.S1); + const s2 = parseScore(row.S2); + const hasScore = (s1 ?? 0) + (s2 ?? 0) > 0; + if (startMs <= nowMs && hasScore) return "live"; + return "scheduled"; +} + +/** + * Render one match line (no leading newline). + * + * @param {MatchRow} row + * @param {number} [nowMs] + * @returns {string} HTML — already escaped + */ +export function formatMatchLine(row, nowMs = Date.now()) { + const t1 = escapeHtml(row.T1 || "TBD"); + const t2 = escapeHtml(row.T2 || "TBD"); + const tournament = escapeHtml(row.Tournament || ""); + const bo = row.BO ? ` · Bo${escapeHtml(row.BO)}` : ""; + const state = classifyMatch(row, nowMs); + + if (state === "played") { + const s1 = parseScore(row.S1) ?? 0; + const s2 = parseScore(row.S2) ?? 0; + const w1 = String(row.Winner) === "1" ? "" : ""; + const w1c = w1 ? "" : ""; + const w2 = String(row.Winner) === "2" ? "" : ""; + const w2c = w2 ? "" : ""; + return `✅ ${w1}${t1}${w1c} ${s1}–${s2} ${w2}${t2}${w2c}${bo} · ${tournament}`; + } + if (state === "live") { + const s1 = parseScore(row.S1) ?? 0; + const s2 = parseScore(row.S2) ?? 0; + return `🔴 LIVE ${t1} ${s1}–${s2} ${t2}${bo} · ${tournament}`; + } + const time = formatIctTime(parseUtc(row.DateTime)); + return `🕒 ${time} ${t1} vs ${t2}${bo} · ${tournament}`; +} + +/** + * Render the "today" command reply. + * + * @param {MatchRow[]} rows + * @param {Date} day — any moment on the target ICT day. + * @param {number} [nowMs] + * @returns {string} + */ +export function renderToday(rows, day, nowMs = Date.now()) { + const header = `LoL — ${escapeHtml(formatIctDayLabel(day))} (ICT)`; + if (rows.length === 0) return `${header}\nNo matches today.`; + return `${header}\n${rows.map((r) => formatMatchLine(r, nowMs)).join("\n")}`; +} + +/** + * Render the "week" command reply — matches grouped by ICT day. + * + * @param {MatchRow[]} rows + * @param {Date} from + * @param {Date} to + * @param {number} [nowMs] + * @returns {string} + */ +export function renderWeek(rows, from, to, nowMs = Date.now()) { + const fromLbl = escapeHtml(formatIctDayLabel(from)); + // `to` is exclusive; subtract 1 ms for a friendlier "through" label. + const toLbl = escapeHtml(formatIctDayLabel(new Date(to.getTime() - 1))); + const header = `LoL — ${fromLbl} → ${toLbl} (ICT)`; + if (rows.length === 0) return `${header}\nNo matches this week.`; + + /** @type {Map} */ + const groups = new Map(); + for (const row of rows) { + const d = parseUtc(row.DateTime); + const key = ictDayKey(d); + let g = groups.get(key); + if (!g) { + g = { label: formatIctDayLabel(d), lines: [] }; + groups.set(key, g); + } + g.lines.push(formatMatchLine(row, nowMs)); + } + + const sections = []; + const sortedKeys = [...groups.keys()].sort(); + for (const key of sortedKeys) { + const g = groups.get(key); + sections.push(`${escapeHtml(g.label)}\n${g.lines.join("\n")}`); + } + return `${header}\n\n${sections.join("\n\n")}`; +} diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js new file mode 100644 index 0000000..b3e6142 --- /dev/null +++ b/src/modules/lolschedule/handlers.js @@ -0,0 +1,67 @@ +/** + * @file /lol_today and /lol_week command handlers. + * + * Day boundaries are defined in ICT (UTC+7), converted to the UTC literals + * that Leaguepedia's cargo query expects. Errors are surfaced as a short reply + * — we never throw through grammY. + */ + +import { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC, getCachedMatches } from "./api-client.js"; +import { renderToday, renderWeek } from "./format.js"; + +const ICT_OFFSET_MS = 7 * 60 * 60 * 1000; + +/** + * Start of the current ICT calendar day, expressed as a UTC `Date`. + * @param {number} [nowMs] + */ +export function ictDayStart(nowMs = Date.now()) { + const shifted = new Date(nowMs + ICT_OFFSET_MS); + shifted.setUTCHours(0, 0, 0, 0); + return new Date(shifted.getTime() - ICT_OFFSET_MS); +} + +/** Add `days` days to a Date, preserving time-of-day. */ +export function addDays(date, days) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + +/** + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore | null} db + */ +export async function handleToday(ctx, db) { + if (!db) { + await ctx.reply("lolschedule: storage unavailable"); + return; + } + const from = ictDayStart(); + const to = addDays(from, 1); + try { + const rows = await getCachedMatches(db, from, to, CACHE_TTL_TODAY_SEC); + await ctx.reply(renderToday(rows, from), { parse_mode: "HTML" }); + } catch (err) { + console.warn("lolschedule /lol_today failed", String(err)); + await ctx.reply("Could not fetch today's matches. Try again later."); + } +} + +/** + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore | null} db + */ +export async function handleWeek(ctx, db) { + if (!db) { + await ctx.reply("lolschedule: storage unavailable"); + return; + } + const from = ictDayStart(); + const to = addDays(from, 7); + try { + const rows = await getCachedMatches(db, from, to, CACHE_TTL_WEEK_SEC); + await ctx.reply(renderWeek(rows, from, to), { parse_mode: "HTML" }); + } catch (err) { + console.warn("lolschedule /lol_week failed", String(err)); + await ctx.reply("Could not fetch this week's matches. Try again later."); + } +} diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js new file mode 100644 index 0000000..561e3f8 --- /dev/null +++ b/src/modules/lolschedule/index.js @@ -0,0 +1,39 @@ +/** + * @file lolschedule module — LoL esports match schedule via Leaguepedia API. + * + * Commands: + * /lol_today — matches scheduled for the current ICT day, with live/played scores. + * /lol_week — next 7 ICT days, grouped per day. + * + * Data source: Leaguepedia Cargo `MatchSchedule` table on lol.fandom.com. + * See plans/reports/researcher-260421-0845-leaguepedia-api-verification.md. + */ + +import { handleToday, handleWeek } from "./handlers.js"; + +/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ +let db = null; + +/** @type {import("../registry.js").BotModule} */ +const lolscheduleModule = { + name: "lolschedule", + init: async ({ db: store }) => { + db = store; + }, + commands: [ + { + name: "lol_today", + visibility: "public", + description: "Today's LoL esports matches (scores if played)", + handler: (ctx) => handleToday(ctx, db), + }, + { + name: "lol_week", + visibility: "public", + description: "LoL esports matches for the next 7 days", + handler: (ctx) => handleWeek(ctx, db), + }, + ], +}; + +export default lolscheduleModule; diff --git a/tests/modules/lolschedule/api-client.test.js b/tests/modules/lolschedule/api-client.test.js new file mode 100644 index 0000000..43d7b54 --- /dev/null +++ b/tests/modules/lolschedule/api-client.test.js @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStore } from "../../../src/db/create-store.js"; +import { + fetchMatchesInRange, + getCachedMatches, +} from "../../../src/modules/lolschedule/api-client.js"; +import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; + +function cargoResponse(rows) { + return { + ok: true, + status: 200, + json: async () => ({ cargoquery: rows.map((title) => ({ title })) }), + }; +} + +const sampleRow = { + DateTime: "2026-04-21 09:00:00", + T1: "T1", + T2: "Gen.G", + S1: "", + S2: "", + Winner: "", + Tournament: "LCK 2026 Spring", + BO: "3", + OP: "LCK/2026_Season/Spring_Season", +}; + +describe("fetchMatchesInRange", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("posts cargoquery with correct where clause and parses rows", async () => { + const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow])); + global.fetch = fetchSpy; + + const from = new Date("2026-04-21T00:00:00Z"); + const to = new Date("2026-04-22T00:00:00Z"); + const rows = await fetchMatchesInRange(from, to); + + expect(rows).toEqual([sampleRow]); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://lol.fandom.com/api.php"); + expect(opts.method).toBe("POST"); + expect(opts.headers["User-Agent"]).toMatch(/miti99bot/); + const body = opts.body.toString(); + expect(body).toContain("action=cargoquery"); + expect(body).toContain("MatchSchedule%3DMS"); // "MatchSchedule=MS" url-encoded + expect(body).toContain("2026-04-21+00%3A00%3A00"); + expect(body).toContain("2026-04-22+00%3A00%3A00"); + }); + + it("surfaces Leaguepedia API error field", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ error: { info: "Bad query", code: "x" } }), + }); + await expect( + fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")), + ).rejects.toThrow(/Bad query/); + }); +}); + +describe("getCachedMatches", () => { + let db; + let kv; + + beforeEach(() => { + kv = makeFakeKv(); + db = createStore("lolschedule", { KV: kv }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caches a successful fetch and returns cached on the second call", async () => { + const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow])); + global.fetch = fetchSpy; + + const from = new Date("2026-04-21T00:00:00Z"); + const to = new Date("2026-04-22T00:00:00Z"); + const r1 = await getCachedMatches(db, from, to, 60); + const r2 = await getCachedMatches(db, from, to, 60); + + expect(r1).toEqual([sampleRow]); + expect(r2).toEqual([sampleRow]); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it("falls back to stale cache when the fetch fails", async () => { + const from = new Date("2026-04-21T00:00:00Z"); + const to = new Date("2026-04-22T00:00:00Z"); + + // Seed the cache with stale data directly. + await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, { + ts: Date.now() - 10 * 60 * 1000, // 10 min ago — older than the 60s TTL + rows: [sampleRow], + }); + + global.fetch = vi.fn().mockRejectedValue(new Error("boom")); + const out = await getCachedMatches(db, from, to, 60); + expect(out).toEqual([sampleRow]); + }); + + it("propagates the error when no cache is available", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("boom")); + await expect( + getCachedMatches(db, new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z"), 60), + ).rejects.toThrow(/boom/); + }); +}); diff --git a/tests/modules/lolschedule/format.test.js b/tests/modules/lolschedule/format.test.js new file mode 100644 index 0000000..4528921 --- /dev/null +++ b/tests/modules/lolschedule/format.test.js @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + classifyMatch, + formatMatchLine, + parseUtc, + renderToday, + renderWeek, +} from "../../../src/modules/lolschedule/format.js"; + +const scheduled = { + DateTime: "2026-04-21 09:00:00", + T1: "T1", + T2: "Gen.G", + S1: "", + S2: "", + Winner: "", + Tournament: "LCK 2026 Spring", + BO: "3", + OP: "LCK/2026_Season/Spring_Season", +}; + +const live = { + ...scheduled, + DateTime: "2026-04-21 08:00:00", + S1: "1", + S2: "0", +}; + +const played = { + ...scheduled, + DateTime: "2026-04-20 10:00:00", + S1: "2", + S2: "1", + Winner: "1", +}; + +// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT) +const nowMs = Date.parse("2026-04-21T08:30:00Z"); + +describe("parseUtc", () => { + it("parses Leaguepedia UTC literal", () => { + expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z"); + }); +}); + +describe("classifyMatch", () => { + it("returns played when Winner is 1 or 2", () => { + expect(classifyMatch(played, nowMs)).toBe("played"); + expect(classifyMatch({ ...played, Winner: "2" }, nowMs)).toBe("played"); + }); + + it("returns live when started, has partial score, no winner", () => { + expect(classifyMatch(live, nowMs)).toBe("live"); + }); + + it("returns scheduled when start time is in the future", () => { + expect(classifyMatch(scheduled, nowMs)).toBe("scheduled"); + }); + + it("returns scheduled when start is past but no score", () => { + const row = { ...scheduled, DateTime: "2026-04-21 07:00:00" }; + expect(classifyMatch(row, nowMs)).toBe("scheduled"); + }); +}); + +describe("formatMatchLine", () => { + it("renders scheduled with ICT time and vs", () => { + const line = formatMatchLine(scheduled, nowMs); + // 09:00 UTC → 16:00 ICT + expect(line).toContain("16:00"); + expect(line).toContain("vs"); + expect(line).toContain("Bo3"); + expect(line).toContain("LCK 2026 Spring"); + expect(line.startsWith("🕒")).toBe(true); + }); + + it("renders played with score and winner bolded", () => { + const line = formatMatchLine(played, nowMs); + expect(line).toContain("2–1"); + expect(line).toContain("T1"); + expect(line.startsWith("✅")).toBe(true); + }); + + it("renders live prefix", () => { + const line = formatMatchLine(live, nowMs); + expect(line.startsWith("🔴 LIVE")).toBe(true); + expect(line).toContain("1–0"); + }); + + it("escapes HTML in team and tournament names", () => { + const row = { ...scheduled, T1: "