- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet Sixteen winners entering the first scoring round get a floor - AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8])) instead of one shared avg([5-8]) pool - Eliminated teams now always show their correct final rank (e.g. T33 for NCAAM R64 losers) even during partial round scoring, by deriving the rank label from the total match count per round rather than the dynamic still-alive count - Discord notifications now fire when a drafted participant is eliminated with 0 points (e.g. R64 losers whose team score doesn't change) - Discord notifications no longer include all prior matches — scoped to the current batch via matchIds - Discord section headers have spacing; rank medals replaced with numbers - Rounds auto-complete when all matches are marked done; manual Complete Round dropdown removed - Fixed double Discord notification when the last match in a round triggers auto-complete: processPlayoffEvent now accepts skipRecalculate so the caller controls when the notification fires - Fixed falsy check on finalPosition=0 in calculateTeamScore - Undrafted 0-point participants filtered from Eliminated Teams display Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { sendDiscordWebhook, sendStandingsUpdateNotification } from "../discord";
|
|
|
|
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
|
|
|
|
function mockFetch(status: number, body = "") {
|
|
return vi.fn().mockResolvedValue({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => body,
|
|
});
|
|
}
|
|
|
|
function getDescription(): string {
|
|
const body = JSON.parse(
|
|
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
|
|
);
|
|
return body.embeds[0].description as string;
|
|
}
|
|
|
|
describe("sendDiscordWebhook", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", mockFetch(204));
|
|
});
|
|
|
|
it("POSTs JSON to the webhook URL", async () => {
|
|
await sendDiscordWebhook(WEBHOOK_URL, { content: "hello" });
|
|
|
|
expect(fetch).toHaveBeenCalledWith(WEBHOOK_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: "hello" }),
|
|
});
|
|
});
|
|
|
|
it("throws on non-2xx response", async () => {
|
|
vi.stubGlobal("fetch", mockFetch(400, "Bad Request"));
|
|
|
|
await expect(sendDiscordWebhook(WEBHOOK_URL, {})).rejects.toThrow(
|
|
"Discord webhook failed: 400"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("sendStandingsUpdateNotification", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", mockFetch(204));
|
|
});
|
|
|
|
it("sends an embed with the season name as title", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
});
|
|
|
|
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
expect(body.embeds[0].title).toBe("📊 Standings Update — My League 2025");
|
|
});
|
|
|
|
it("shows point deltas as integers in description", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "Test League",
|
|
standings: [
|
|
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
|
{ teamId: "b", teamName: "Beta", totalPoints: 100, rank: 2 },
|
|
],
|
|
previousStandings: new Map([
|
|
["a", 125],
|
|
["b", 100],
|
|
]),
|
|
});
|
|
|
|
const desc = getDescription();
|
|
expect(desc).toContain("Alpha");
|
|
expect(desc).toContain("+25 pts");
|
|
expect(desc).not.toContain("+25.0");
|
|
// Beta didn't change — no delta shown
|
|
expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/);
|
|
});
|
|
|
|
it("includes sport name and event name in header when both provided", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
sportName: "UEFA Champions League",
|
|
eventName: "Knockout Stage",
|
|
});
|
|
|
|
const desc = getDescription();
|
|
expect(desc).toContain("UEFA Champions League — Knockout Stage");
|
|
});
|
|
|
|
it("shows only event name when no sport name provided", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
eventName: "Quarter Finals",
|
|
});
|
|
|
|
const desc = getDescription();
|
|
expect(desc).toContain("Quarter Finals");
|
|
expect(desc).not.toContain("—\n");
|
|
});
|
|
|
|
it("omits header section when neither sport name nor event name provided", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
});
|
|
|
|
const desc = getDescription();
|
|
// Description should start directly with the standings section
|
|
expect(desc).toContain("**Current Standings**");
|
|
expect(desc).not.toContain("**Scored Matches**");
|
|
});
|
|
|
|
it("lists scored matches above standings", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
scoredMatches: [
|
|
{ winnerName: "Real Madrid", loserName: "Bayern Munich" },
|
|
{ winnerName: "Arsenal", loserName: "PSG" },
|
|
],
|
|
});
|
|
|
|
const desc = getDescription();
|
|
expect(desc).toContain("**Scored Matches**");
|
|
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
|
|
expect(desc).toContain("• **Arsenal** def. PSG");
|
|
// Scored matches section should appear before standings
|
|
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings"));
|
|
});
|
|
|
|
it("uses plain numbers for all ranks in description", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [
|
|
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
|
|
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
|
|
{ teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 },
|
|
{ teamId: "d", teamName: "Delta", totalPoints: 75, rank: 4 },
|
|
],
|
|
previousStandings: new Map(),
|
|
});
|
|
|
|
const desc = getDescription();
|
|
expect(desc).toContain("1. Alpha");
|
|
expect(desc).toContain("2. Beta");
|
|
expect(desc).toContain("3. Gamma");
|
|
expect(desc).toContain("4. Delta");
|
|
});
|
|
|
|
it("does not show fields on the embed", async () => {
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl: WEBHOOK_URL,
|
|
seasonName: "My League 2025",
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
|
|
previousStandings: new Map(),
|
|
});
|
|
|
|
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
expect(body.embeds[0].fields).toBeUndefined();
|
|
});
|
|
});
|