- Parse Melee matches exports (one round per file, byes, null GameWins); one import form detects matches vs standings exports. Re-importing a round replaces just that round and keeps entered decks. - Per-player deck entry for sessions scored from matches. - node:test suite with anonymised fixtures; lockfile. - scripts/local.sh runs the compose stack under podman with its own .env.local, project name and volume. - README and CLAUDE.md docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
69 lines
3.2 KiB
TypeScript
69 lines
3.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { test } from "node:test";
|
|
import { computeStandings } from "../src/standings.ts";
|
|
import { csvRows, extract, extractMatches, isMatchExport, parseTable } from "../src/tables.ts";
|
|
|
|
const fixture = (f: string) => parseTable(readFileSync(new URL(`./fixtures/${f}`, import.meta.url), "utf8"));
|
|
|
|
test("matches export: detects shape, reads byes and null GameWins", () => {
|
|
const rows = fixture("matches.json");
|
|
assert.ok(isMatchExport(rows));
|
|
assert.deepEqual(extractMatches(rows), [
|
|
{ round: 4, a: "Eve Evans", b: null, w1: 2, w2: 0, draws: 0 },
|
|
{ round: 4, a: "Alice Archer", b: "Bob Baker", w1: 2, w2: 0, draws: 0 },
|
|
{ round: 4, a: "Cara Chen", b: "Dan Doyle", w1: 1, w2: 2, draws: 0 },
|
|
]);
|
|
});
|
|
|
|
test("matches export: unreported matches are skipped, empty file rejected", () => {
|
|
assert.throws(() => extractMatches([{ HasResult: false, Competitors: [] }]), /No completed matches/);
|
|
});
|
|
|
|
test("standings export: only the final round is used, real names", () => {
|
|
const rows = fixture("standings.json");
|
|
assert.ok(!isMatchExport(rows));
|
|
assert.deepEqual(extract(rows).map((r) => [r.name, r.points, r.wdl]), [
|
|
["Alice Archer", 6, [2, 0, 0]],
|
|
["Bob Baker", 1, [0, 1, 1]],
|
|
]);
|
|
});
|
|
|
|
test("generic CSV: quoted commas, header aliases, helpful error", () => {
|
|
assert.deepEqual(extract(parseTable('Rank,Player,Points,Deck\n1,"Bob, Jr",6,Elves\n')),
|
|
[{ name: "Bob, Jr", points: 6, deck: "Elves", wdl: null }]);
|
|
assert.throws(() => extract(parseTable("Rank,Foo\n1,x")), /Headers in the file: Rank, Foo/);
|
|
assert.deepEqual(csvRows('1,"a ""q"" b",x', ","), [["1", 'a "q" b', "x"]]);
|
|
});
|
|
|
|
test("rejects non-numeric scores in uploaded JSON", () => {
|
|
const bad = [{ Team: { Players: [{ Name: "A" }] }, RoundNumber: 1, Points: 3, MatchWins: "x", MatchDraws: 0, MatchLosses: 0 }];
|
|
assert.throws(() => extract(bad), /Bad numbers/);
|
|
});
|
|
|
|
test("leaderboard: best-N sessions count, bye is a win, W-D-L unknown for points-only imports", () => {
|
|
const names = new Map([[1, "Ann"], [2, "Bob"], [3, "Cy"]]);
|
|
const ms = [
|
|
{ session_id: 1, p1_id: 1, p2_id: 2, p1_deck: "Burn", p2_deck: "Elves", p1_wins: 2, p2_wins: 1 },
|
|
{ session_id: 1, p1_id: 3, p2_id: null, p1_deck: null, p2_deck: null, p1_wins: 2, p2_wins: 0 },
|
|
];
|
|
const rs = [
|
|
{ session_id: 2, player_id: 1, points: 9, deck: "Burn", wins: 3, draws: 0, losses: 0 },
|
|
{ session_id: 2, player_id: 2, points: 4, deck: null, wins: null, draws: null, losses: null },
|
|
];
|
|
const { rows, decks } = computeStandings(ms, rs, names, 1);
|
|
assert.deepEqual(rows.map((r) => [r.name, r.score, r.total, r.rec]), [
|
|
["Ann", 9, 12, [4, 0, 0]], // best 1 of {3, 9}
|
|
["Bob", 4, 4, null],
|
|
["Cy", 3, 3, [1, 0, 0]],
|
|
]);
|
|
assert.deepEqual(decks, [["Burn", [4, 0, 0]], ["Elves", [0, 0, 1]]]);
|
|
});
|
|
|
|
test("a session with match rows ignores its imported standings", () => {
|
|
const names = new Map([[1, "Ann"], [2, "Bob"]]);
|
|
const ms = [{ session_id: 1, p1_id: 1, p2_id: 2, p1_deck: null, p2_deck: null, p1_wins: 2, p2_wins: 0 }];
|
|
const rs = [{ session_id: 1, player_id: 2, points: 99, deck: null, wins: 9, draws: 0, losses: 0 }];
|
|
assert.equal(computeStandings(ms, rs, names, 6).rows.find((r) => r.name === "Bob")!.score, 0);
|
|
});
|