Files
Live_RC/tests/series_logic.test.js
larssand b5707f92d1 feat(series): run each round from the series page
Step 3 becomes a rounds table: pick drivers inline (copy from the
previous round, add a late entry), set qualifying rounds/finals/max
cars per heat per round, see the status, and create qualifying and
finals from there. Qualifying heats are now split evenly so nobody
drives alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:55:36 +02:00

130 lines
8.4 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("who scores is an explicit choice: an open series counts every driver even with a club filled in", () => {
const clubs = { member: "Östersunds RC", guest: "Other RC" };
const day = [{ driverId: "guest", hasLap: true }, { driverId: "member", hasLap: true }];
const open = normalizeSeries({ club: "Östersunds RC", scoring: "all" });
assert.equal(open.scoring, "all");
assert.equal(scoreSeriesEvent(open, day, { getDriverClub: (id) => clubs[id] }).get("guest").points, 100);
const members = normalizeSeries({ club: "Östersunds RC", scoring: "club" });
assert.equal(scoreSeriesEvent(members, day, { getDriverClub: (id) => clubs[id] }).get("member").points, 100);
assert.equal(normalizeSeries({}).scoring, "all", "new series are open by default");
assert.equal(normalizeSeries({ club: "Östersunds RC" }).scoring, "club", "a series saved with a club before the choice existed stays members-only");
});
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"]]);
});
test("round status follows the race's sessions", async () => {
const { getSeriesRoundStatus } = await import("../src/series_logic.js");
const q = (status) => ({ type: "qualification", status });
const f = (status) => ({ type: "final", status });
assert.equal(getSeriesRoundStatus([]), "not_started");
assert.equal(getSeriesRoundStatus([q("ready"), q("ready")]), "qual_ready");
assert.equal(getSeriesRoundStatus([q("finished"), q("ready")]), "qual_running");
assert.equal(getSeriesRoundStatus([q("finished"), q("finished")]), "qual_done");
assert.equal(getSeriesRoundStatus([q("finished"), f("ready")]), "finals");
assert.equal(getSeriesRoundStatus([q("finished"), f("finished")]), "done");
});
test("qualifying heats are split as evenly as possible - the heat count follows the field", async () => {
const { splitIntoBalancedGroups } = await import("../src/event_race_logic.js");
const sizes = (n, max) => splitIntoBalancedGroups(Array.from({ length: n }, (_, i) => i), max).map((group) => group.length);
assert.deepEqual(sizes(9, 8), [5, 4], "never 8 + 1");
assert.deepEqual(sizes(6, 6), [6]);
assert.deepEqual(sizes(13, 6), [5, 4, 4]);
assert.deepEqual(sizes(0, 6), []);
assert.deepEqual(splitIntoBalancedGroups(["a", "b", "c", "d", "e"], 3), [["a", "b", "c"], ["d", "e"]], "order is kept");
});