From b1b65a9cf98b7b9a4352b32f92c46949d48d3746 Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 10 Sep 2026 18:18:01 +0200 Subject: [PATCH] Add moving a driver between qualifying heats The user asked whether a lone driver left in the last heat (e.g. 7 drivers at 3 per heat) could be moved into another heat manually. Investigated first: neither "Reseed" (redistributes every not-yet-run heat by current standings, not a targeted move) nor the grid editor (reorders start position within one heat, never moves a driver between heats) could do this - there was genuinely no path to it. moveDriversIntoQualifyingHeat (src/event_race_logic.js) adds the same "checking them elsewhere is the move" pattern already used for team rosters: a new driver checklist on the qualification session edit form pulls a checked driver out of whichever other heat they're currently on, scoped to the same round only ("Q2 H2" never touches "Q1"/"Q3", since heat composition can legitimately differ round to round after a reseed). The checklist names the driver's current heat before saving, mirroring the team-roster warning. Also confirmed, per the user's earlier questions, already correct with no changes needed: practice's per-driver "best 3 laps"/"best 5-minute result" stats, and that heats are already seeded fastest- first from practice results. Flagged one real, deliberately unbuilt gap while investigating: a true per-driver rolling "5 minutes from your own first crossing" qualifying *heat* clock isn't supported - only the practice stats work that way today. Verified with new unit tests for moveDriversIntoQualifyingHeat and the extracted parseQualifyingHeatName helper, and a real headless- browser run reproducing the exact reported scenario end to end through the actual UI. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++++ ROADMAP.md | 16 ++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/app.js | 12 +++++++- src/event_manager_controller.js | 11 +++++++ src/event_manager_view.js | 39 +++++++++++++++++++++++++ src/event_race_logic.js | 52 +++++++++++++++++++++++++++++---- tests/race_timing_logic.test.js | 34 +++++++++++++++++++++ 9 files changed, 166 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 592d73b..4a18b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.11.0 - 2026-09-10 + +### Added + +- A driver left alone in the last qualifying heat (e.g. 7 drivers at 3 per heat) can now be moved into another heat directly - edit a heat and tick the driver on, and they're automatically moved out of whichever heat they were on before, with a warning shown before you save. + ## 1.10.3 - 2026-09-10 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 20bcad3..09bb4c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -249,6 +249,22 @@ Not built in this pass, out of scope for a bug-fixing sweep: there is no disqual Verified 2026-09-10: new unit tests cover every fix above - `tests/race_timing_logic.test.js` (numeric heat sort, field-size mismatch reporting, DNS backfill in both qualifying and final standings without ever inventing a fake 0ms "fastest lap", the reserve/non-reserve bump-up paths, the 26-main cap, pause exclusion in `buildLeaderboard`/`getCompetitorElapsedMs`, the frozen finished-session clock), a new `tests/session_control.test.js` (previously zero coverage for this file - pause/resume accumulation, follow-up pause, and the finish-while-paused fold-in), and `tests/judging_logic.test.js` (undoing two penalties in both orders, confirming the net always reaches zero). A full headless-browser run against a real, isolated server confirmed both the judging undo fix (two +5s penalties applied via the real Judging UI, undone out of order through the real undo buttons, correctly netting to +5000ms after the first undo and exactly 0 after both) and the bump-up fix (a real Event Manager "Apply bumps" click, with `reserveBumpSlots` off, actually grew the upper final's grid instead of silently doing nothing) end to end. +Follow-up questions from the user, checked against the actual code and confirmed already correct (no changes needed): +- **Practice "best 3 laps" / "best result within 5 minutes"** (`getBestConsecutiveTrainingLaps`, `getBestTrainingDurationResult` in `src/training_logic.js`) is computed per driver, independently, from that driver's own lap timestamps after the fact - there's no shared clock, so multiple drivers practicing at once each get their own best-window result correctly, exactly as described. +- **Heats are seeded by speed from practice results** (`buildPracticeStandings` sorts fastest-first, `generateQualifyingForRace`/`reseedUpcomingQualifying` chunk that sorted list in order) - Heat 1 is always the fastest group, Heat 2 the next, and so on. Proven with a concrete existing test (7 drivers, 3 per heat -> heat 1 gets the 3 fastest, heat 3 gets the single slowest, alone). +- **`carsPerHeat`/`carsPerFinal` are freely adjustable** (plain number fields, no hidden constraints) - e.g. 12 drivers at `carsPerHeat: 4` cleanly makes 3 heats of 4, and setting `carsPerFinal` to the full field size (or to a fraction of it) is the existing, direct way to choose "just one A final" vs "A + B", since the number of finals is derived from field size ÷ `carsPerFinal` rather than being a separate setting. +- A genuinely per-driver rolling "your own 5 minutes starts at your first crossing" qualifying *heat* clock (as opposed to practice, where this already works) is not fully supported - the "staggered" start mode gives each driver their own reference point for lap-time math, but the heat's own auto-finish timer is still one shared clock from when the admin pressed Start. Flagged to the user as a separate, deliberately unbuilt gap, not fixed here. + +## Moving a driver between qualifying heats + +Requested 2026-09-10 by the user, after confirming heats seed by speed correctly: with an odd field size (e.g. 7 drivers at 3 per heat), the last heat can end up with a single lone driver - and there was no way to manually move them into another heat. Investigated first: neither "Reseed" (redistributes every not-yet-run heat by current standings, not a targeted single-driver move) nor the grid editor (reorders start position within one heat, never moves a driver between heats) could do this - there was genuinely no path to it in the UI. + +- [x] `moveDriversIntoQualifyingHeat` (`src/event_race_logic.js`, new) - the same "checking them elsewhere is the move" pattern already used for team rosters. Editing a qualification session's driver roster (a new checklist added to the existing session edit form, shown only for `type: "qualification"`) now pulls a checked driver out of whichever *other* heat they're currently on. Scoped deliberately to the same round only ("Q2 H2" never touches "Q1"/"Q3"), since heat composition can legitimately differ round to round after a reseed - an admin fixing one round's lone-driver heat doesn't get other rounds silently reshuffled. +- [x] The checklist shows "Redan i {heat} - kryssar du i flyttas den hit" next to a driver already gridded elsewhere in the same round, so the move is visible before saving, not just after (mirrors the team-roster warning). +- [x] Extracted `parseQualifyingHeatName` (parsing a generated session's "Q<round> H<heat>" name) as a shared helper, now used by both the reseed function and this new one, rather than duplicating the regex a second time. + +Verified 2026-09-10: new unit tests (`tests/race_timing_logic.test.js`) cover `parseQualifyingHeatName` and `moveDriversIntoQualifyingHeat` (moves a lone driver out of their old heat, leaves a different round's same-numbered heat untouched, and is a no-op for a session with no parseable heat name). A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end: generated real qualifying heats for 7 drivers at 3 per heat through the actual "Generate qualifying" button, confirmed heat 3 had exactly one lone driver, opened heat 2's edit form and confirmed it named "Q1 H3" as a warning before saving, ticked the driver in and saved, and confirmed heat 2 gained them while heat 3 became empty - no duplicate entry, nobody left alone. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 5fab864..40dcae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index ec5a0cb..46d75c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index bbb4508..40fa5e2 100644 --- a/src/app.js +++ b/src/app.js @@ -11,7 +11,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; -import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; +import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, moveDriversIntoQualifyingHeat as moveDriversIntoQualifyingHeatLogic, parseQualifyingHeatName as parseQualifyingHeatNameLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js"; import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js"; @@ -73,6 +73,8 @@ const getEventDrivers = (event) => getEventDriversLogic(event, { state }); const getEventTeams = (event) => getEventTeamsLogic(event, { normalizeRaceTeam }); const getTeamDriverPool = (event) => getTeamDriverPoolLogic(event, { getEventDrivers, state }); const reassignTeamMembers = (teams, options) => reassignTeamMembersLogic(teams, options); +const moveDriversIntoQualifyingHeat = (targetSessionId, driverIds) => moveDriversIntoQualifyingHeatLogic(state.sessions, targetSessionId, driverIds); +const parseQualifyingHeatName = (name) => parseQualifyingHeatNameLogic(name); const findEventTeamForPassing = (event, driverId, carId) => findEventTeamForPassingLogic(event, driverId, carId, { getEventTeams }); const generateQualifyingForRace = (event) => generateQualifyingForRaceLogic(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }); const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }); @@ -1169,6 +1171,9 @@ const TRANSLATIONS = { "events.team_form_cars": "Markera lagbilar innan du sparar laget.", "events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.", "events.team_member_elsewhere": "Redan i {team} - kryssar du i flyttas den hit", + "events.heat_drivers": "Förare i heatet", + "events.heat_drivers_hint": "Kryssa i/ur för att ändra vilka förare som kör det här heatet. En förare som redan är i ett annat heat i samma omgång flyttas hit automatiskt när du sparar.", + "events.heat_member_elsewhere": "Redan i {heat} - kryssar du i flyttas den hit", "events.no_teams": "Inga lag skapade ännu.", "events.team_standings": "Lagställning", "events.no_team_results": "Inga teamresultat ännu.", @@ -2677,6 +2682,9 @@ const TRANSLATIONS = { "events.team_form_cars": "Select team cars before saving the team.", "events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.", "events.team_member_elsewhere": "Already on {team} - checking this moves them here", + "events.heat_drivers": "Heat drivers", + "events.heat_drivers_hint": "Tick/untick to change who races this heat. A driver already on another heat in the same round is moved here automatically when you save.", + "events.heat_member_elsewhere": "Already on {heat} - checking this moves them here", "events.no_teams": "No teams created yet.", "events.team_standings": "Team standings", "events.no_team_results": "No team results yet.", @@ -5022,6 +5030,8 @@ function renderEventManager(eventId) { getTeamDriverPool, getEventTeams, reassignTeamMembers, + moveDriversIntoQualifyingHeat, + parseQualifyingHeatName, normalizeBrandingConfig, getSessionTypeChoices, getRaceFormatPresets, diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js index 23da0c1..4391d11 100644 --- a/src/event_manager_controller.js +++ b/src/event_manager_controller.js @@ -19,6 +19,8 @@ export function renderEventManagerView(context) { getTeamDriverPool, getEventTeams, reassignTeamMembers, + moveDriversIntoQualifyingHeat, + parseQualifyingHeatName, normalizeBrandingConfig, getSessionTypeChoices, getRaceFormatPresets, @@ -168,6 +170,7 @@ export function renderEventManagerView(context) { raceDrivers, teamDriverPool, showTeamsSection, + parseQualifyingHeatName, state, getDriverDisplayById, raceFormatAdvanced, @@ -411,6 +414,14 @@ export function renderEventManagerView(context) { : "best_sum"; editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0); editingSession.maxCars = Number(form.get("maxCars") || 0) || null; + if (selectedType === "qualification") { + const heatDriverIds = Array.from(formNode.querySelectorAll('[name="heatDriverIds"]:checked')) + .map((node) => String(node.value)) + .filter(Boolean); + moveDriversIntoQualifyingHeat(editingSession.id, heatDriverIds); + editingSession.driverIds = heatDriverIds; + editingSession.manualGridIds = [...heatDriverIds]; + } setSelectedSessionEditId(null); saveState(); refreshManager(); diff --git a/src/event_manager_view.js b/src/event_manager_view.js index 14088ed..ad8700b 100644 --- a/src/event_manager_view.js +++ b/src/event_manager_view.js @@ -21,6 +21,7 @@ export function renderEventManagerMarkup(context) { raceDrivers, teamDriverPool, showTeamsSection, + parseQualifyingHeatName, state, getDriverDisplayById, raceFormatAdvanced, @@ -54,6 +55,21 @@ export function renderEventManagerMarkup(context) { raceTeams.find((team) => team.id !== excludeTeamId && team.driverIds.includes(driverId))?.name || ""; const carTeamElsewhere = (carId, excludeTeamId = "") => raceTeams.find((team) => team.id !== excludeTeamId && team.carIds.includes(carId))?.name || ""; + // Same idea as driverTeamElsewhere above, but for qualifying heats: checking a driver + // onto this heat moves them out of whichever other heat in the SAME round they're + // currently on (see moveDriversIntoQualifyingHeat) - name that heat so the admin sees the + // move coming before saving, not after. + const driverHeatElsewhere = (driverId, targetSession) => { + const targetParsed = parseQualifyingHeatName(targetSession?.name); + if (!targetParsed) return ""; + const other = sessions.find((session) => + session.id !== targetSession.id + && session.type === "qualification" + && (session.driverIds || []).includes(driverId) + && parseQualifyingHeatName(session.name)?.round === targetParsed.round + ); + return other?.name || ""; + }; const sessionChannelMarkup = sessions .map((session) => ({ session, markup: renderSessionChannelAnalysis(session) })) .filter((entry) => entry.markup) @@ -864,6 +880,29 @@ export function renderEventManagerMarkup(context) { ${t("events.session_max_cars_hint")} + ${ + event.mode === "race" && editingSession.type === "qualification" + ? ` +
+

${t("events.heat_drivers")}

+

${t("events.heat_drivers_hint")}

+
+ ${raceDrivers + .map((driver) => { + const elsewhere = driverHeatElsewhere(driver.id, editingSession); + return ` + + `; + }) + .join("")} +
+
+ ` + : "" + }
diff --git a/src/event_race_logic.js b/src/event_race_logic.js index ecfe8b3..0d8e45b 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -672,6 +672,16 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv +// Generated qualifying sessions are named "Q H" (see generateQualifyingForRace) +// with no separate round/heat fields of their own - shared here so every place that needs +// to know which round/heat a session belongs to parses the same way. +export function parseQualifyingHeatName(name) { + const match = String(name || "").match(/^Q(\d+)\s+H(\d+)/i); + return match ? { round: Number(match[1]), heat: Number(match[2]) } : null; +} + + + export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }) { const standings = buildQualifyingStandings(event); const sourceRows = @@ -695,12 +705,12 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil getSessionsForEvent(event.id) .filter((session) => session.type === "qualification" && session.generated && session.status === "ready") .forEach((session) => { - const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i); - const round = match ? Number(match[1]) : 0; - // The regex above already captures the heat number - sorting the full session name - // as a string instead ("H10" < "H2" lexicographically) used to scramble the grid - // for any round with 10+ heats. - const heatNumber = match ? Number(match[2]) : 0; + const parsed = parseQualifyingHeatName(session.name); + const round = parsed?.round ?? 0; + // parseQualifyingHeatName already captures the heat number - sorting the full + // session name as a string instead ("H10" < "H2" lexicographically) used to scramble + // the grid for any round with 10+ heats. + const heatNumber = parsed?.heat ?? 0; if (!sessionsByRound.has(round)) { sessionsByRound.set(round, []); } @@ -737,6 +747,36 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil +// Moving a driver onto a heat's roster (there is no separate "move" action - ticking them +// on the target heat's edit form IS the move) must pull them out of whichever other heat +// they're currently gridded in, or they end up double-entered - the same class of bug +// already fixed for team rosters. Scoped to the target heat's own round only ("Q2 H2" never +// touches "Q1"/"Q3"), since heat composition can legitimately differ round to round after a +// reseed, and an admin fixing one round's lone-driver heat should not silently reshuffle +// rounds they didn't ask to change. +export function moveDriversIntoQualifyingHeat(sessions, targetSessionId, driverIds) { + const list = Array.isArray(sessions) ? sessions : []; + const target = list.find((session) => session.id === targetSessionId); + const parsedTarget = target ? parseQualifyingHeatName(target.name) : null; + if (!parsedTarget) { + return; + } + const driverSet = new Set(driverIds); + list.forEach((session) => { + if (session.id === targetSessionId || session.type !== "qualification") { + return; + } + const parsed = parseQualifyingHeatName(session.name); + if (!parsed || parsed.round !== parsedTarget.round) { + return; + } + session.driverIds = (session.driverIds || []).filter((id) => !driverSet.has(id)); + session.manualGridIds = (session.manualGridIds || []).filter((id) => !driverSet.has(id)); + }); +} + + + export function generateFinalsForRace(event, { buildPracticeStandings, buildQualifyingStandings, clearGeneratedFinals, state, normalizeStartMode, normalizeSession, uid, chunkArray, t }) { const sourceRows = event.raceConfig?.finalsSource === "practice" diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index dee40d3..e1eb3e9 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -9,7 +9,9 @@ import { generateFinalsForRace, generateQualifyingForRace, getTeamDriverPool, + moveDriversIntoQualifyingHeat, normalizeEvent, + parseQualifyingHeatName, reassignTeamMembers, reseedUpcomingQualifying, } from "../src/event_race_logic.js"; @@ -330,6 +332,38 @@ test("qualifying reseed reports a field-size mismatch instead of silently droppi assert.equal(result.mismatched, 1, "the round's ready-heat count no longer matches the participant list"); }); +test("parseQualifyingHeatName extracts the round and heat number, or null for anything else", () => { + assert.deepEqual(parseQualifyingHeatName("Q2 H3"), { round: 2, heat: 3 }); + assert.deepEqual(parseQualifyingHeatName("Q10 H11"), { round: 10, heat: 11 }); + assert.equal(parseQualifyingHeatName("A Final 1"), null); + assert.equal(parseQualifyingHeatName(""), null); +}); + +test("moveDriversIntoQualifyingHeat moves a lone driver out of their old heat into a new one, in the same round only", () => { + const sessions = [ + { id: "q1h1", name: "Q1 H1", type: "qualification", driverIds: ["d1", "d2"], manualGridIds: ["d1", "d2"] }, + { id: "q1h2", name: "Q1 H2", type: "qualification", driverIds: ["d3"], manualGridIds: ["d3"] }, + // A different round's H1 must never be touched by an edit to Q1's heats. + { id: "q2h1", name: "Q2 H1", type: "qualification", driverIds: ["d1", "d2"], manualGridIds: ["d1", "d2"] }, + ]; + moveDriversIntoQualifyingHeat(sessions, "q1h1", ["d1", "d2", "d3"]); + + const q1h1 = sessions.find((s) => s.id === "q1h1"); + const q1h2 = sessions.find((s) => s.id === "q1h2"); + const q2h1 = sessions.find((s) => s.id === "q2h1"); + assert.deepEqual(q1h2.driverIds, [], "d3 was pulled out of Q1 H2, the heat they were lonely in"); + assert.deepEqual(q2h1.driverIds, ["d1", "d2"], "Q2's heats are untouched - only Q1's heats are affected by editing a Q1 heat"); + // moveDriversIntoQualifyingHeat only clears the OTHER sessions - the caller (the real + // sessionEditForm handler) is the one that sets the target session's own driverIds. + assert.equal(q1h1.id, "q1h1"); +}); + +test("moveDriversIntoQualifyingHeat does nothing for a session with no parseable heat name", () => { + const sessions = [{ id: "s1", name: "Open Practice", type: "practice", driverIds: ["d1"] }]; + moveDriversIntoQualifyingHeat(sessions, "s1", ["d1"]); + assert.deepEqual(sessions[0].driverIds, ["d1"], "left untouched - not a qualifying heat, nothing to move"); +}); + test("final generation reserves bump slots and keeps seeded grid order", () => { const state = { sessions: [] }; const sourceRows = Array.from({ length: 7 }, (_, index) => ({ driverId: `driver_${index + 1}` }));