Group any number of races in freely chosen classes into a series with a points table (ÖRC/100-80-70/F1/custom), best-N or drop-X counting, a club filter that re-ranks members without guests, tie-breaks, rounds created from a date list, and per-class standings with print and PDF. Also fixes PDF export of wide tables: they are compacted and switched to landscape instead of truncating names and dropping columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
6.2 KiB
JavaScript
96 lines
6.2 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { normalizeSeries, getSeriesPointsForPlace, scoreSeriesEvent, getCountedResultsCount, buildSeriesStandings, parsePointsTable, SERIES_POINT_PRESETS } from "../src/series_logic.js";
|
|
|
|
const orc = normalizeSeries({ id: "s", name: "ÖRC Winter", club: "Östersunds RC", classIds: ["2wd"], countMode: "best", countValue: 9 });
|
|
|
|
test("ÖRC points: 100, 90, 82 ... 48, then -4 per place down to 10 from place 20", () => {
|
|
assert.deepEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((place) => getSeriesPointsForPlace(orc, place)), [100, 90, 82, 75, 69, 64, 60, 56, 52, 48]);
|
|
assert.deepEqual([11, 12, 13, 19, 20, 25].map((place) => getSeriesPointsForPlace(orc, place)), [44, 40, 36, 12, 10, 10]);
|
|
const f1 = normalizeSeries({ pointsTable: SERIES_POINT_PRESETS.f1.table, pointsStep: 0, pointsMin: 0 });
|
|
assert.equal(getSeriesPointsForPlace(f1, 11), 0);
|
|
assert.deepEqual(parsePointsTable("100, 80 70;60 x -5"), [100, 80, 70, 60]);
|
|
});
|
|
|
|
test("only club members score; they are re-ranked without guests; no lap means 0 points", () => {
|
|
const clubs = { a: "Östersunds RC", guest: "Other RC", b: "östersunds rc ", c: "Östersunds RC" };
|
|
const scored = scoreSeriesEvent(orc, [
|
|
{ driverId: "guest", hasLap: true }, { driverId: "a", hasLap: true }, { driverId: "b", hasLap: true }, { driverId: "c", hasLap: false },
|
|
], { getDriverClub: (id) => clubs[id] });
|
|
assert.equal(scored.has("guest"), false, "a guest races but doesn't score");
|
|
assert.deepEqual(scored.get("a"), { place: 1, points: 100, driverName: undefined }, "first member after the guest is ranked 1st");
|
|
assert.equal(scored.get("b").points, 90, "club name match ignores case and spaces");
|
|
assert.deepEqual([scored.get("c").place, scored.get("c").points], [null, 0]);
|
|
const open = normalizeSeries({ club: "" });
|
|
assert.equal(scoreSeriesEvent(open, [{ driverId: "guest", hasLap: true }]).get("guest").points, 100, "no club set: everyone scores");
|
|
});
|
|
|
|
test("best N, or completed minus X (so a cancelled event lowers the count)", () => {
|
|
assert.equal(getCountedResultsCount(orc, 12), 9);
|
|
assert.equal(getCountedResultsCount(orc, 4), 4);
|
|
const drop3 = normalizeSeries({ countMode: "drop", countValue: 3 });
|
|
assert.equal(getCountedResultsCount(drop3, 12), 9);
|
|
assert.equal(getCountedResultsCount(drop3, 10), 7, "two cancelled events: 10 - 3");
|
|
assert.equal(getCountedResultsCount(drop3, 2), 1, "at least one result");
|
|
assert.equal(getCountedResultsCount(drop3, 0), 0);
|
|
});
|
|
|
|
function series3(extra = {}) {
|
|
return normalizeSeries({ id: "s", club: "", classIds: ["2wd"], eventIds: ["e1", "e2", "e3", "e4"], countMode: "best", countValue: 2, ...extra });
|
|
}
|
|
const events = [
|
|
{ id: "e1", name: "R1", date: "2026-11-01", classId: "2wd" },
|
|
{ id: "e2", name: "R2", date: "2026-11-15", classId: "2wd" },
|
|
{ id: "e3", name: "R3", date: "2026-11-29", classId: "2wd" },
|
|
{ id: "e4", name: "R4", date: "2026-12-13", classId: "2wd" }, // not run yet
|
|
];
|
|
const row = (driverId) => ({ driverId, driverName: driverId.toUpperCase(), hasLap: true });
|
|
|
|
test("standings count the best results, strike the rest, and absence is a 0 that can be struck", () => {
|
|
const dayResults = new Map([["e1", [row("a"), row("b")]], ["e2", [row("b"), row("a")]], ["e3", [row("a")]]]);
|
|
const [table] = buildSeriesStandings(series3(), { events, dayResults, getDriverName: (id) => id.toUpperCase() });
|
|
assert.equal(table.completedCount, 3);
|
|
assert.equal(table.countedCount, 2);
|
|
assert.equal(table.events.length, 4, "the not-yet-run event is listed as a column");
|
|
const [first, second] = table.rows;
|
|
assert.equal(first.driverName, "A");
|
|
assert.equal(first.total, 200, "two wins counted, the 2nd place struck");
|
|
assert.deepEqual(first.results.map((result) => [result.points, result.counted]), [[100, true], [90, false], [100, true]]);
|
|
assert.equal(second.total, 190);
|
|
assert.deepEqual(second.results.map((result) => [result.points, result.counted, Boolean(result.absent)]), [[90, true, false], [100, true, false], [0, false, true]]);
|
|
assert.equal(second.starts, 2);
|
|
});
|
|
|
|
test("ties: more wins first, then the latest common event, otherwise a shared position", () => {
|
|
const all = normalizeSeries({ id: "s", classIds: ["2wd"], eventIds: ["e1", "e2", "e3"], countMode: "best", countValue: 3, pointsTable: [10, 10, 5], pointsStep: 0, pointsMin: 0 });
|
|
// A: 1st, 3rd, 2nd = 10+5+10 = 25 (1 win). B: 2nd, 1st, 3rd = 10+10+5 = 25 (1 win). C: 3rd, 2nd, 1st = 25.
|
|
const dayResults = new Map([
|
|
["e1", [row("a"), row("b"), row("c")]],
|
|
["e2", [row("b"), row("c"), row("a")]],
|
|
["e3", [row("c"), row("a"), row("b")]],
|
|
]);
|
|
const [table] = buildSeriesStandings(all, { events, dayResults });
|
|
// Same wins/2nds/3rds for all - the latest event (e3) decides: C, A, B.
|
|
assert.deepEqual(table.rows.map((entry) => [entry.driverId, entry.total, entry.rank]), [["c", 25, 1], ["a", 25, 2], ["b", 25, 3]]);
|
|
|
|
const winsDecide = new Map([["e1", [row("a"), row("b")]], ["e2", [row("a"), row("b")]], ["e3", [row("b"), row("a")]]]);
|
|
const twoWins = normalizeSeries({ ...all, pointsTable: [10, 10] });
|
|
assert.deepEqual(buildSeriesStandings(twoWins, { events, dayResults: winsDecide })[0].rows.map((entry) => entry.driverId), ["a", "b"], "A has two wins against one");
|
|
|
|
const shared = new Map([["e1", [row("a"), row("b")]]]);
|
|
const equal = normalizeSeries({ ...all, pointsTable: [10, 10] });
|
|
const sharedRows = buildSeriesStandings(equal, { events, dayResults: shared })[0].rows;
|
|
assert.equal(sharedRows[0].rank, 1);
|
|
assert.equal(sharedRows[1].rank, 2, "different places in the latest event still separate them");
|
|
});
|
|
|
|
test("each chosen class gets its own table; events of other classes are ignored", () => {
|
|
const multi = normalizeSeries({ id: "m", classIds: ["2wd", "4wd"], eventIds: ["e1", "x1"], countValue: 9 });
|
|
const tables = buildSeriesStandings(multi, {
|
|
events: [events[0], { id: "x1", name: "R1 4WD", date: "2026-11-01", classId: "4wd" }, { id: "other", name: "Not in series", classId: "2wd" }],
|
|
dayResults: new Map([["e1", [row("a")]], ["x1", [row("a"), row("b")]], ["other", [row("z")]]]),
|
|
classNames: new Map([["2wd", "2WD"], ["4wd", "4WD"]]),
|
|
});
|
|
assert.deepEqual(tables.map((table) => [table.className, table.rows.map((entry) => entry.driverId).join(",")]), [["2WD", "a"], ["4WD", "a,b"]]);
|
|
});
|