import assert from "node:assert/strict"; // Series / cup end to end: create a series on the Series page, pick its club and class, // create two rounds from a list of dates, run the first round (seeded final results), and // check the standings: only club drivers score (re-ranked without the guest), the round not // run yet is an empty column, and the PDF export carries the same table. export async function runSeriesSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) { const patchState = async (patch) => { const current = await savedState(); Object.assign(current, patch); const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) }); assert.equal(response.ok, true); }; const dialogsAtStart = dialogs.length; const seeded = await savedState(); await patchState({ classes: [...seeded.classes, { id: "sr_2wd", name: "SR 2WD" }, { id: "sr_4wd", name: "SR 4WD" }], drivers: [ ...seeded.drivers, { id: "sr_guest", name: "SR Guest", classId: "sr_2wd", club: "Other RC", transponder: "8660001" }, { id: "sr_anna", name: "SR Anna", classId: "sr_2wd", club: "Östersunds RC", transponder: "8660002" }, { id: "sr_bo", name: "SR Bo", classId: "sr_2wd", club: "Östersunds RC", transponder: "8660003" }, ], }); try { await navigate("series", "seriesNew"); await evaluate(`document.getElementById("seriesNew").click()`); await waitFor(async () => Boolean(await evaluate(`document.getElementById("seriesForm")`)), "series form"); assert.equal(await evaluate(`document.querySelectorAll('#seriesForm input[name="classIds"]:checked').length`), 0, "a new series starts with no class ticked"); // Sorted by name, one row each. const classNames = await evaluate(`[...document.querySelectorAll(".series-class-row span")].map((node) => node.textContent.trim())`); assert.deepEqual(classNames.filter((name) => name.startsWith("SR ")), ["SR 2WD", "SR 4WD"]); // A tick is saved straight away - no Save button. await evaluate(`(() => { const box = document.querySelector('#seriesForm input[name="classIds"][value="sr_4wd"]'); box.checked = true; box.dispatchEvent(new Event("change", { bubbles: true })); })()`); await waitFor(async () => ((await savedState()).series || []).some((series) => series.classIds.includes("sr_4wd")), "class tick saved immediately"); await waitFor(async () => (await evaluate(`document.querySelector('.series-class-row input[value="sr_4wd"]')?.closest("label").classList.contains("is-selected")`)) === true, "the ticked class is highlighted"); console.log("PASS series classes are sorted, start unticked and are saved as soon as they are ticked"); await evaluate(`(() => { const form = document.getElementById("seriesForm"); form.elements.name.value = "SR Winter Cup"; form.elements.scoring.value = "club"; form.elements.club.value = "Östersunds RC"; form.querySelectorAll('input[name="classIds"]').forEach((box) => { box.checked = box.value === "sr_2wd"; }); form.elements.countMode.value = "drop"; form.elements.countValue.value = "1"; form.requestSubmit(); })()`); await waitFor(async () => (await savedState()).series?.some((series) => series.name === "SR Winter Cup"), "series saved"); const saved = (await savedState()).series.find((series) => series.name === "SR Winter Cup"); assert.deepEqual(saved.classIds, ["sr_2wd"]); assert.equal(saved.club, "Östersunds RC"); assert.equal(saved.scoring, "club"); assert.deepEqual(saved.pointsTable.slice(0, 3), [100, 90, 82], "the ÖRC table is the default"); await evaluate(`(() => { const form = document.getElementById("seriesCreateEvents"); form.elements.dates.value = "2026-11-15\\n2026-11-01"; form.elements.qualifyingRounds.value = "2"; form.elements.finalLegs.value = "1"; form.elements.carsPerHeat.value = "2"; form.requestSubmit(); })()`); await waitFor(async () => ((await savedState()).series.find((series) => series.id === saved.id)?.eventIds.length) === 2, "two rounds created"); const state = await savedState(); const rounds = state.series.find((series) => series.id === saved.id).eventIds.map((id) => state.events.find((event) => event.id === id)); assert.deepEqual(rounds.map((event) => [event.name, event.date, event.classId]).sort(), [["SR Winter Cup #1 SR 2WD", "2026-11-01", "sr_2wd"], ["SR Winter Cup #2 SR 2WD", "2026-11-15", "sr_2wd"]]); assert.deepEqual(rounds.map((event) => [event.raceConfig.qualifyingRounds, event.raceConfig.finalLegs, event.raceConfig.carsPerHeat]), [[2, 1, 2], [2, 1, 2]], "the chosen format is applied to every new round"); console.log("PASS a series is created with club, class and count rule, and its rounds are created from dates"); // The series is the hub for each round: drivers, a late entry, copy to the next round, // format per round and qualifying - heats split by how many take part. const roundOne = rounds.find((event) => event.date === "2026-11-01"); const roundTwo = rounds.find((event) => event.date === "2026-11-15"); const inRound = (eventId, js) => evaluate(`(() => { const row = document.querySelector('tr[data-round="${eventId}"]'); ${js} })()`); await inRound(roundOne.id, `row.querySelector('[data-round-action="toggle"]').click();`); await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-round-participants="${roundOne.id}"]')`)), "round 1 drivers open"); await evaluate(`(() => { const box = document.querySelector('[data-round-driver="sr_bo"]'); box.checked = false; box.dispatchEvent(new Event("change")); })()`); await waitFor(async () => { const event = (await savedState()).events.find((item) => item.id === roundOne.id); return event.raceConfig.participantsConfigured && !event.raceConfig.driverIds.includes("sr_bo"); }, "Bo unticked for round 1"); await evaluate(`(() => { const form = document.querySelector('[data-round-add-driver]'); form.elements.name.value = "SR Cia"; form.elements.transponder.value = "8660004"; form.requestSubmit(); })()`); await waitFor(async () => (await savedState()).drivers.some((driver) => driver.name === "SR Cia"), "late entry registered"); const cia = (await savedState()).drivers.find((driver) => driver.name === "SR Cia"); assert.deepEqual([cia.classId, cia.club, cia.transponder], ["sr_2wd", "Östersunds RC", "8660004"], "in the round's class and the series' club"); const roundOneIds = (await savedState()).events.find((item) => item.id === roundOne.id).raceConfig.driverIds; assert.ok(roundOneIds.includes(cia.id) && !roundOneIds.includes("sr_bo"), "the late entry is ticked for this round"); await inRound(roundTwo.id, `row.querySelector('[data-round-action="toggle"]').click();`); await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-round-participants="${roundTwo.id}"]')`)), "round 2 drivers open"); await evaluate(`document.querySelector('[data-round-participants="${roundTwo.id}"] [data-round-action="copy"]').click()`); await waitFor(async () => JSON.stringify([...(await savedState()).events.find((item) => item.id === roundTwo.id).raceConfig.driverIds].sort()) === JSON.stringify([...roundOneIds].sort()), "round 2 copied from round 1"); // Round 1 gets three qualifying rounds (round 2 keeps two). Three drivers with max two per // heat give two heats - of two and one - in each round. await inRound(roundOne.id, `const input = row.querySelector('input[name="qualifyingRounds"]'); input.value = "3"; input.dispatchEvent(new Event("change"));`); await waitFor(async () => (await savedState()).events.find((item) => item.id === roundOne.id).raceConfig.qualifyingRounds === 3, "format changed for round 1 only"); assert.equal((await savedState()).events.find((item) => item.id === roundTwo.id).raceConfig.qualifyingRounds, 2, "round 2 keeps its own format"); await inRound(roundOne.id, `row.querySelector('[data-round-action="qualifying"]').click();`); await waitFor(async () => (await savedState()).sessions.filter((session) => session.eventId === roundOne.id && session.type === "qualification").length === 6, "3 rounds x 2 heats created"); const quals = (await savedState()).sessions.filter((session) => session.eventId === roundOne.id && session.type === "qualification"); assert.deepEqual([...new Set(quals.map((session) => session.driverIds.length))].sort(), [1, 2], "3 drivers, max 2 per heat: heats of 2 and 1"); await waitFor(async () => (await inRound(roundOne.id, `return row.querySelector('.pill').textContent.trim() + "|" + row.querySelector('input[name="qualifyingRounds"]').disabled;`)) === "Qualifying created|true", "status shown and format locked"); console.log("PASS each round is run from the series: drivers, late entry, copy from previous, own format, qualifying split by the field"); // The rest of this scenario seeds a finished final for round 1 - drop these unrun heats first. await patchState({ sessions: (await savedState()).sessions.filter((session) => !(session.eventId === roundOne.id && session.type === "qualification")) }); // Round 1 is run: a finished A-final where the guest wins, Anna 2nd, Bo 3rd. const round1 = rounds.find((event) => event.date === "2026-11-01"); const startedAt = Date.parse("2026-11-01T10:00:00Z"); const finalSession = { id: "sr_final", eventId: round1.id, name: "A Final", type: "final", durationMin: 5, status: "finished", startedAt, endedAt: startedAt + 300000, driverIds: ["sr_guest", "sr_anna", "sr_bo"] }; const competitor = (driverId, laps) => ({ key: `driver_${driverId}`, driverId, driverName: driverId, laps, startTimestamp: startedAt, lastTimestamp: startedAt + laps * 20000, bestLapMs: 20000 }); const passingsFor = (driverId, laps) => Array.from({ length: laps }, (_, index) => ({ driverId, competitorKey: `driver_${driverId}`, lapMs: 20000, timestamp: startedAt + (index + 1) * 20000, validLap: true })); await patchState({ sessions: [...(await savedState()).sessions, finalSession], resultsBySession: { ...(await savedState()).resultsBySession, sr_final: { passings: [...passingsFor("sr_guest", 15), ...passingsFor("sr_anna", 14), ...passingsFor("sr_bo", 13)], competitors: { driver_sr_guest: competitor("sr_guest", 15), driver_sr_anna: competitor("sr_anna", 14), driver_sr_bo: competitor("sr_bo", 13) }, adjustments: [] } }, }); await navigate("series", "seriesNew"); await evaluate(`document.querySelector('[data-series-select="${saved.id}"]')?.click()`); await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-series-class="sr_2wd"] tbody tr')`)), "standings table"); const rows = await evaluate(`[...document.querySelectorAll('[data-series-class="sr_2wd"] tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent.trim()))`); assert.deepEqual(rows, [["1", "SR Anna", "100", "", "1", "100"], ["2", "SR Bo", "90", "", "1", "90"]], "the guest races but doesn't score; Anna is re-ranked 1st; round 2 is empty"); console.log("PASS standings: only club drivers score, re-ranked without guests; unplayed rounds stay empty"); // Switch the same series to "every driver": the guest now scores and wins round 1. await evaluate(`(() => { const form = document.getElementById("seriesForm"); form.elements.scoring.value = "all"; form.elements.scoring.dispatchEvent(new Event("change")); form.requestSubmit(); })()`); await waitFor(async () => (await savedState()).series.find((series) => series.id === saved.id)?.scoring === "all", "open series saved"); await waitFor(async () => (await evaluate(`[...document.querySelectorAll('[data-series-class="sr_2wd"] tbody tr')].map((tr) => tr.children[1].textContent.trim()).join(",")`)) === "SR Guest,SR Anna,SR Bo", "everyone scores in an open series"); assert.equal(await evaluate(`document.querySelector("[data-series-club]").hidden`), true, "the club field is hidden for an open series"); await evaluate(`(() => { const form = document.getElementById("seriesForm"); form.elements.scoring.value = "club"; form.requestSubmit(); })()`); await waitFor(async () => (await savedState()).series.find((series) => series.id === saved.id)?.scoring === "club", "back to members only"); console.log("PASS who scores is a choice: every driver, or club members only"); // The PDF export carries the same table (request captured in the page, not downloaded). const payload = await evaluate(`(async () => { const realFetch = window.fetch; let body = null; window.fetch = async (url, options) => { if (String(url).includes("/api/export/pdf")) { body = options.body; return new Response(new Blob(["%PDF"]), { status: 200 }); } return realFetch(url, options); }; try { document.getElementById("seriesPdf").click(); for (let i = 0; i < 50 && !body; i += 1) await new Promise((r) => setTimeout(r, 20)); return body; } finally { window.fetch = realFetch; } })()`); const pdf = JSON.parse(payload); assert.equal(pdf.title, "SR Winter Cup"); assert.deepEqual(pdf.sections[0].rows[0], ["1", "SR Anna", "100", "", "1", "100"]); console.log("PASS the series PDF export contains the standings"); } finally { dialogs.splice(dialogsAtStart); const current = await savedState(); const seriesEventIds = new Set((current.series || []).filter((series) => series.name === "SR Winter Cup").flatMap((series) => series.eventIds)); await patchState({ series: (current.series || []).filter((series) => series.name !== "SR Winter Cup"), events: current.events.filter((event) => !seriesEventIds.has(event.id)), sessions: current.sessions.filter((session) => session.id !== "sr_final" && !seriesEventIds.has(session.eventId)), drivers: current.drivers.filter((driver) => !driver.id.startsWith("sr_") && !String(driver.name).startsWith("SR ")), classes: current.classes.filter((item) => !item.id.startsWith("sr_")), }); } }