Fix Team Race duplicate-membership bug and two related team-roster gaps
Reported by the user: creating a team, adding the wrong driver, then moving that driver to another team by ticking them on the new team's edit form left the driver on both teams at once. Root cause: there was never a "move" action - the edit form only ever added a driver to the team being saved, never removed them from wherever they already were. Live laps from a driver on two teams were then silently credited to only the first-matching team, with no clear error. reassignTeamMembers (event_race_logic.js) now strips a driver/car being saved onto one team out of every other team in the same event, and the team checkbox list shows which team a driver is already on before you move them. Also fixed two related gaps found while investigating, at the user's request to fix everything: getTeamDriverPool had regressed to ignoring the event's class/participant scoping entirely (showing every driver in the club), and deleting a driver or car left them behind in team rosters forever. Added a duplicate_team_members Race Day preflight check as defense in depth for any already-corrupted saved state. Verified with new unit tests for reassignTeamMembers, getTeamDriverPool and the new preflight check, plus a new permanent browser test reproducing the user's exact reported scenario end to end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm
|
||||
|
||||
No changes recorded yet.
|
||||
|
||||
## 1.10.2 - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Moving a driver from one Team Race team to another (by ticking them on the new team's edit form) used to leave them on both teams at once, so their laps only ever counted for one of them. Team edit/create now shows a warning naming the driver's current team before you tick them elsewhere, and saving genuinely moves them. Also fixed: the team driver picker was showing every driver in the club instead of just the event's own participants, and deleting a driver or car left them behind in team rosters forever.
|
||||
|
||||
## 1.10.1 - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
19
ROADMAP.md
19
ROADMAP.md
@@ -189,6 +189,25 @@ Fixed 2026-09-09, reported while exporting an old race from the new Results arch
|
||||
|
||||
Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) exports a PDF with a Swedish title and a table row containing å/ä/ö/Å/Ä/Ö, asserts the font declares WinAnsiEncoding, asserts the correct single-byte WinAnsi sequence is present in the raw PDF bytes, and explicitly asserts the old UTF-8 byte sequence is *not* present - confirmed this test genuinely fails against the pre-fix code (not a tautology) by re-running it with the fix reverted.
|
||||
|
||||
## Team Race roster duplicate-membership bug
|
||||
|
||||
Reported 2026-09-10 by the user: creating a team, adding a driver to it by mistake, then "moving" that driver to a different team by ticking them on the new team's edit form left the driver on *both* teams at once. Investigated before fixing, per the user's own account of the exact steps that triggered it.
|
||||
|
||||
Root cause: there was never a "move driver to another team" action - the only way to reassign someone was to tick their checkbox on the *new* team's edit form (`event_manager_controller.js`'s `teamForm`/`teamEditForm` submit handlers). That handler only ever wrote the team being saved; it never checked whether the driver/car was already listed on a different team, so ticking them there added them without ever removing them from wherever they already were. Consequence found while investigating: `findEventTeamForPassing` (`event_race_logic.js`) resolves a team by the *first* array match, so a driver on two teams had every one of their live laps silently credited to only one of them - the other team got nothing, with no error surfaced anywhere except a misleading "duplicate transponders" preflight warning (and only when the driver actually had a transponder set).
|
||||
|
||||
Two related bugs surfaced in the same code while investigating, at the user's request to fix everything found:
|
||||
- `getTeamDriverPool` (`event_race_logic.js`) had been reduced, in an earlier change, to unconditionally return every driver in the club regardless of the event's class or configured participants - the class/participant-scoped filtering (with a documented fallback to "show everyone" only when the scoped list is empty) existed in an earlier version of this function and had been silently dropped.
|
||||
- Deleting a driver or car (`core_views.js`) cleaned up session assignments but never removed them from any team's roster, so a deleted driver/car stayed a permanent "member" of whichever teams they'd been added to, inflating team member counts forever.
|
||||
|
||||
Fixed:
|
||||
- [x] `reassignTeamMembers` (`event_race_logic.js`, new) strips a driver/car being saved onto one team out of every *other* team in the same event - both the create-team and edit-team submit handlers now call it before writing, so ticking a driver onto a new team genuinely moves them rather than duplicating them.
|
||||
- [x] The team edit/create checkbox list now shows "Redan i <team> - kryssar du i flyttas den hit" next to a driver/car already on a different team, so the move is visible *before* saving, not just after.
|
||||
- [x] `getTeamDriverPool` restored to scope by the event's class/configured participants first, falling back to every driver only when that scoped list is empty (so a team can still be set up before participants exist, without silently showing the whole club's roster once they do).
|
||||
- [x] Deleting a driver or car now also removes them from every team's `driverIds`/`carIds`, and from a session's `driverIds`/`manualGridIds`, alongside the existing assignment cleanup.
|
||||
- [x] Added a `duplicate_team_members` Race Day preflight check specifically for `team_race` sessions, naming the driver/car by name - defense in depth for any state saved before this fix, so it's diagnosed accurately instead of surfacing as a confusing "duplicate transponders" error (or nothing at all, if the shared driver has no transponder).
|
||||
|
||||
Verified 2026-09-10: new unit tests (`tests/race_timing_logic.test.js`) cover `reassignTeamMembers` (strips from other teams, leaves the team being saved and non-overlapping teams untouched) and `getTeamDriverPool`'s scoped-then-fallback behavior; new tests (`tests/race_day_logic.test.js`) cover the `duplicate_team_members` preflight check both tripping (naming the shared driver) and staying clear when teams don't overlap. A new permanent browser test (`tests/browser_team_membership.js`) reproduces the user's exact reported scenario end to end against a real race-mode event with a team_race session: creates Team A with Driver A, creates Team B with Driver B, opens Team B's edit form and confirms the "already on Team A" warning appears *before* saving, ticks Driver A onto Team B and saves, confirms Driver A is gone from Team A's roster and present on Team B's (the reported bug, now fixed), then deletes Driver B entirely and confirms they're gone from Team B's roster too.
|
||||
|
||||
## In-app update checker and one-click updater
|
||||
|
||||
Implemented 2026-09-09, requested by the user: an easy way to see a new version is available in the GUI, and an easy way to update for someone not comfortable with `git pull` on their own.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.10.1",
|
||||
"version": "1.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rc-racecore",
|
||||
"version": "1.10.1",
|
||||
"version": "1.10.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.10.1",
|
||||
"version": "1.10.2",
|
||||
"private": true,
|
||||
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
|
||||
"engines": {
|
||||
|
||||
@@ -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, 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, 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";
|
||||
@@ -72,6 +72,7 @@ const getRaceWizardSessionPlan = (draft) => getRaceWizardSessionPlanLogic(draft,
|
||||
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 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 });
|
||||
@@ -425,6 +426,7 @@ const TRANSLATIONS = {
|
||||
"race_day.check_participants": "Deltagare",
|
||||
"race_day.check_transponders": "Transpondrar",
|
||||
"race_day.check_duplicate_transponders": "Dubbla transpondrar",
|
||||
"race_day.check_duplicate_team_members": "Förare/bil i flera lag",
|
||||
"race_day.check_grid": "Startgrid",
|
||||
"race_day.check_audio": "Raceljud",
|
||||
"race_day.lap_window_edit": "Justera varvgränser",
|
||||
@@ -1164,6 +1166,7 @@ const TRANSLATIONS = {
|
||||
"events.team_form_drivers": "Markera lagförare innan du sparar laget.",
|
||||
"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.no_teams": "Inga lag skapade ännu.",
|
||||
"events.team_standings": "Lagställning",
|
||||
"events.no_team_results": "Inga teamresultat ännu.",
|
||||
@@ -1929,6 +1932,7 @@ const TRANSLATIONS = {
|
||||
"race_day.check_participants": "Participants",
|
||||
"race_day.check_transponders": "Transponders",
|
||||
"race_day.check_duplicate_transponders": "Duplicate transponders",
|
||||
"race_day.check_duplicate_team_members": "Driver/car on multiple teams",
|
||||
"race_day.check_grid": "Start grid",
|
||||
"race_day.check_audio": "Race audio",
|
||||
"race_day.lap_window_edit": "Adjust lap limits",
|
||||
@@ -2668,6 +2672,7 @@ const TRANSLATIONS = {
|
||||
"events.team_form_drivers": "Select team drivers before saving the team.",
|
||||
"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.no_teams": "No teams created yet.",
|
||||
"events.team_standings": "Team standings",
|
||||
"events.no_team_results": "No team results yet.",
|
||||
@@ -5012,6 +5017,7 @@ function renderEventManager(eventId) {
|
||||
getEventDrivers,
|
||||
getTeamDriverPool,
|
||||
getEventTeams,
|
||||
reassignTeamMembers,
|
||||
normalizeBrandingConfig,
|
||||
getSessionTypeChoices,
|
||||
getRaceFormatPresets,
|
||||
|
||||
@@ -567,6 +567,18 @@ export function renderDriversView(deps) {
|
||||
state.drivers = state.drivers.filter((x) => x.id !== d.id);
|
||||
state.sessions.forEach((s) => {
|
||||
s.assignments = (s.assignments || []).filter((a) => a.driverId !== d.id);
|
||||
s.driverIds = (s.driverIds || []).filter((id) => id !== d.id);
|
||||
s.manualGridIds = (s.manualGridIds || []).filter((id) => id !== d.id);
|
||||
});
|
||||
// Otherwise a deleted driver stays a "member" of every team/participant list they
|
||||
// were ever added to, silently inflating team/participant counts forever.
|
||||
state.events.forEach((event) => {
|
||||
if (!event.raceConfig) return;
|
||||
event.raceConfig.driverIds = (event.raceConfig.driverIds || []).filter((id) => id !== d.id);
|
||||
event.raceConfig.teams = (event.raceConfig.teams || []).map((team) => ({
|
||||
...team,
|
||||
driverIds: (team.driverIds || []).filter((id) => id !== d.id),
|
||||
}));
|
||||
});
|
||||
saveState();
|
||||
renderView();
|
||||
@@ -781,6 +793,13 @@ export function renderCarsView(deps) {
|
||||
state.sessions.forEach((s) => {
|
||||
s.assignments = (s.assignments || []).filter((a) => a.carId !== c.id);
|
||||
});
|
||||
state.events.forEach((event) => {
|
||||
if (!event.raceConfig) return;
|
||||
event.raceConfig.teams = (event.raceConfig.teams || []).map((team) => ({
|
||||
...team,
|
||||
carIds: (team.carIds || []).filter((id) => id !== c.id),
|
||||
}));
|
||||
});
|
||||
saveState();
|
||||
renderView();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export function renderEventManagerView(context) {
|
||||
getEventDrivers,
|
||||
getTeamDriverPool,
|
||||
getEventTeams,
|
||||
reassignTeamMembers,
|
||||
normalizeBrandingConfig,
|
||||
getSessionTypeChoices,
|
||||
getRaceFormatPresets,
|
||||
@@ -555,7 +556,7 @@ export function renderEventManagerView(context) {
|
||||
}
|
||||
setFormError("teamCreateError", "");
|
||||
const createdTeam = normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds });
|
||||
const nextTeams = [...getEventTeams(event), createdTeam];
|
||||
const nextTeams = [...reassignTeamMembers(getEventTeams(event), { excludeTeamId: createdTeam.id, driverIds, carIds }), createdTeam];
|
||||
updateEvent((item) => ({
|
||||
...item,
|
||||
raceConfig: {
|
||||
@@ -639,7 +640,7 @@ export function renderEventManagerView(context) {
|
||||
return;
|
||||
}
|
||||
setFormError("teamEditError", "");
|
||||
const nextTeams = getEventTeams(event).map((team) =>
|
||||
const nextTeams = reassignTeamMembers(getEventTeams(event), { excludeTeamId: editingTeam.id, driverIds, carIds }).map((team) =>
|
||||
team.id === editingTeam.id ? normalizeRaceTeam({ ...team, name, driverIds, carIds }) : team
|
||||
);
|
||||
updateEvent((item) => ({
|
||||
|
||||
@@ -47,6 +47,13 @@ export function renderEventManagerMarkup(context) {
|
||||
getModeLabel,
|
||||
renderTable,
|
||||
} = context;
|
||||
// Checking a driver/car here moves them off whichever other team already has them (see
|
||||
// reassignTeamMembers in event_race_logic.js) - name that team here so the admin sees
|
||||
// the move coming rather than discovering it after saving.
|
||||
const driverTeamElsewhere = (driverId, excludeTeamId = "") =>
|
||||
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 || "";
|
||||
const sessionChannelMarkup = sessions
|
||||
.map((session) => ({ session, markup: renderSessionChannelAnalysis(session) }))
|
||||
.filter((entry) => entry.markup)
|
||||
@@ -324,7 +331,7 @@ export function renderEventManagerMarkup(context) {
|
||||
(driver) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamDriverIds" form="teamForm" value="${driver.id}" />
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}${driverTeamElsewhere(driver.id) ? `<small>${escapeHtml(t("events.team_member_elsewhere", { team: driverTeamElsewhere(driver.id) }))}</small>` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
@@ -344,7 +351,7 @@ export function renderEventManagerMarkup(context) {
|
||||
(car) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamCarIds" form="teamForm" value="${car.id}" />
|
||||
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
|
||||
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})${carTeamElsewhere(car.id) ? `<small>${escapeHtml(t("events.team_member_elsewhere", { team: carTeamElsewhere(car.id) }))}</small>` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
@@ -757,7 +764,7 @@ export function renderEventManagerMarkup(context) {
|
||||
(driver) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamDriverIds" value="${driver.id}" ${editingTeam.driverIds.includes(driver.id) ? "checked" : ""} />
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}${driverTeamElsewhere(driver.id, editingTeam.id) ? `<small>${escapeHtml(t("events.team_member_elsewhere", { team: driverTeamElsewhere(driver.id, editingTeam.id) }))}</small>` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
@@ -772,7 +779,7 @@ export function renderEventManagerMarkup(context) {
|
||||
(car) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamCarIds" value="${car.id}" ${editingTeam.carIds.includes(car.id) ? "checked" : ""} />
|
||||
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
|
||||
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})${carTeamElsewhere(car.id, editingTeam.id) ? `<small>${escapeHtml(t("events.team_member_elsewhere", { team: carTeamElsewhere(car.id, editingTeam.id) }))}</small>` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
|
||||
@@ -560,10 +560,36 @@ export function getEventTeams(event, { normalizeRaceTeam }) {
|
||||
|
||||
|
||||
export function getTeamDriverPool(event, { getEventDrivers, state }) {
|
||||
return {
|
||||
drivers: Array.isArray(state?.drivers) ? [...state.drivers] : [],
|
||||
fallback: false,
|
||||
};
|
||||
const scopedDrivers = getEventDrivers(event);
|
||||
if (scopedDrivers.length) {
|
||||
return { drivers: scopedDrivers, fallback: false };
|
||||
}
|
||||
// No driver matched the event's class or configured participant list (e.g. a team is
|
||||
// being set up before drivers/participants exist yet) - fall back to every driver in
|
||||
// the club rather than leaving the admin with zero checkboxes and no way to proceed.
|
||||
const allDrivers = Array.isArray(state?.drivers) ? [...state.drivers] : [];
|
||||
return { drivers: allDrivers, fallback: allDrivers.length > 0 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// A driver or car can only be a member of one team at a time. There is no separate "move
|
||||
// to another team" action in the UI - ticking a driver's checkbox on a different team's
|
||||
// form IS the move - so saving a team's roster here must pull that driver/car out of
|
||||
// whichever other team currently has them, or the same driver ends up listed on two teams
|
||||
// at once (their live passings then get silently credited to only the first match).
|
||||
export function reassignTeamMembers(teams, { excludeTeamId = "", driverIds = [], carIds = [] } = {}) {
|
||||
const driverSet = new Set(driverIds);
|
||||
const carSet = new Set(carIds);
|
||||
return (Array.isArray(teams) ? teams : []).map((team) => {
|
||||
if (team.id === excludeTeamId) return team;
|
||||
const nextDriverIds = (team.driverIds || []).filter((id) => !driverSet.has(id));
|
||||
const nextCarIds = (team.carIds || []).filter((id) => !carSet.has(id));
|
||||
if (nextDriverIds.length === (team.driverIds || []).length && nextCarIds.length === (team.carIds || []).length) {
|
||||
return team;
|
||||
}
|
||||
return { ...team, driverIds: nextDriverIds, carIds: nextCarIds };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,17 @@ export function buildRaceDayPreflight(state, session, { backendAvailable = false
|
||||
const missingCount = teamTransponders.filter((values) => !values.length).length;
|
||||
transponders = teamTransponders.flat();
|
||||
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount, teamRace: true }));
|
||||
// Named explicitly rather than relying on the transponder-duplicate check below to
|
||||
// catch this indirectly: that check only fires when the shared driver/car has a
|
||||
// transponder set, and reports "duplicate transponders" - a confusing diagnosis for
|
||||
// what is actually "this driver/car is on two teams at once".
|
||||
const duplicateTeamDriverIds = duplicateValues(teams.flatMap((team) => team.driverIds || []));
|
||||
const duplicateTeamCarIds = duplicateValues(teams.flatMap((team) => team.carIds || []));
|
||||
const duplicateTeamMemberNames = [
|
||||
...duplicateTeamDriverIds.map((id) => state.drivers.find((driver) => driver.id === id)?.name).filter(Boolean),
|
||||
...duplicateTeamCarIds.map((id) => state.cars.find((car) => car.id === id)?.name).filter(Boolean),
|
||||
];
|
||||
checks.push(check("duplicate_team_members", duplicateTeamMemberNames.length ? "error" : "ok", { duplicateTeamMemberNames }));
|
||||
} else {
|
||||
entrantIds = getRaceDriverIds(state, session, event);
|
||||
checks.push(check("participants", entrantIds.length ? "ok" : "error", { count: entrantIds.length }));
|
||||
|
||||
@@ -11,7 +11,7 @@ export function getRaceDayCheckAction(item, session) {
|
||||
const view = session?.mode === "track" ? "cars" : "drivers";
|
||||
return { view, label: "nav." + view };
|
||||
}
|
||||
if (["participants", "grid", "transponders", "duplicate_transponders", "lap_window"].includes(item.id)) {
|
||||
if (["participants", "grid", "transponders", "duplicate_transponders", "duplicate_team_members", "lap_window"].includes(item.id)) {
|
||||
const view = session?.mode === "track" ? "events" : "race_setup";
|
||||
return { view, label: "nav." + view };
|
||||
}
|
||||
@@ -32,6 +32,7 @@ function renderPreflightDetail(item, { t, formatLap, escapeHtml }) {
|
||||
: t("race_day.count", { count: item.count || 0 });
|
||||
}
|
||||
if (item.id === "duplicate_transponders" && item.duplicates?.length) return escapeHtml(item.duplicates.join(", "));
|
||||
if (item.id === "duplicate_team_members" && item.duplicateTeamMemberNames?.length) return escapeHtml(item.duplicateTeamMemberNames.join(", "));
|
||||
if (item.id === "grid") return item.notRequired ? t("race_day.not_required") : `${item.count || 0}/${item.expectedCount || 0}`;
|
||||
return t(`race_day.check_${item.level}`);
|
||||
}
|
||||
|
||||
@@ -2506,6 +2506,12 @@ select:focus {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.check-card small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.team-card,
|
||||
.team-standings-block {
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { runOverlaySmoke } from "./browser_overlays.js";
|
||||
import { runRaceSetupSmoke } from "./browser_race_setup.js";
|
||||
import { runRaceDaySmoke } from "./browser_race_day.js";
|
||||
import { runRaceDayStartSequencesSmoke } from "./browser_race_day_start_sequences.js";
|
||||
import { runTeamMembershipSmoke } from "./browser_team_membership.js";
|
||||
import { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -134,6 +135,7 @@ try {
|
||||
console.log("PASS create/edit/reload and draft retention: " + collection);
|
||||
}
|
||||
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
|
||||
await runTeamMembershipSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base });
|
||||
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
|
||||
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
|
||||
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
|
||||
|
||||
78
tests/browser_team_membership.js
Normal file
78
tests/browser_team_membership.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Regression coverage for a real bug the user reported: moving a driver from one team to
|
||||
// another (by ticking them on the new team's edit form - there is no separate "move"
|
||||
// action) used to only ever ADD the driver to the new team, never remove them from the
|
||||
// old one, so the same driver ended up listed on two teams at once. Also covers the two
|
||||
// related findings from the same investigation: getTeamDriverPool used to ignore the
|
||||
// event's class/participant scoping entirely (showing every driver in the club), and
|
||||
// deleting a driver left them behind in any team's roster forever.
|
||||
//
|
||||
// The event/session fixtures are seeded directly rather than driven through the 4-step
|
||||
// race wizard (already covered by browser_race_setup.js) - only team management itself is
|
||||
// under test here, and the Teams section only renders once a team_race session exists
|
||||
// (see showTeamsSection in event_manager_controller.js).
|
||||
export async function runTeamMembershipSmoke({ evaluate, navigate, savedState, waitFor, pause, base }) {
|
||||
const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
|
||||
const submit = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`);
|
||||
const findEvent = async (id) => (await savedState()).events.find((event) => event.id === id);
|
||||
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 driverA = { id: "team_membership_driver_a", name: "Team Membership Driver A", classId: "test_class", transponder: "7810001" };
|
||||
const driverB = { id: "team_membership_driver_b", name: "Team Membership Driver B", classId: "test_class", transponder: "7810002" };
|
||||
const raceEvent = {
|
||||
id: "team_membership_event", name: "Team Membership Event", classId: "test_class", mode: "race",
|
||||
raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id], teams: [] },
|
||||
};
|
||||
const teamRaceSession = { id: "team_membership_session", eventId: raceEvent.id, name: "Team Race Session", type: "team_race", mode: "race", durationMin: 45, status: "ready" };
|
||||
const seeded = await savedState();
|
||||
await patchState({
|
||||
drivers: [...seeded.drivers, driverA, driverB],
|
||||
events: [...seeded.events, raceEvent],
|
||||
sessions: [...seeded.sessions, teamRaceSession],
|
||||
});
|
||||
|
||||
await navigate("race_setup", "raceWizardStepForm");
|
||||
await click("event-manage-" + raceEvent.id);
|
||||
await waitFor(async () => await evaluate(`Boolean(document.getElementById("teamForm"))`), "teams section visible for a race-mode event with a team_race session");
|
||||
|
||||
await evaluate(`(() => { const form = document.getElementById("teamForm"); form.elements.namedItem("teamName").value = "Membership Team A"; })()`);
|
||||
await evaluate(`document.querySelector('[form="teamForm"][name="teamDriverIds"][value="${driverA.id}"]').checked = true`);
|
||||
await submit("teamForm");
|
||||
await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.some((team) => team.name === "Membership Team A"), "team A created");
|
||||
|
||||
await evaluate(`(() => { const form = document.getElementById("teamForm"); form.elements.namedItem("teamName").value = "Membership Team B"; })()`);
|
||||
await evaluate(`document.querySelector('[form="teamForm"][name="teamDriverIds"][value="${driverB.id}"]').checked = true`);
|
||||
await submit("teamForm");
|
||||
await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.some((team) => team.name === "Membership Team B"), "team B created");
|
||||
const teamB = (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B");
|
||||
|
||||
await click("team-edit-" + teamB.id);
|
||||
await waitFor(async () => await evaluate(`Boolean(document.getElementById("teamEditForm"))`), "team B edit form open");
|
||||
const hintText = await evaluate(`document.querySelector('#teamEditForm [name="teamDriverIds"][value="${driverA.id}"]')?.closest("label")?.textContent || ""`);
|
||||
assert.match(hintText, /Membership Team A/, "the edit form names the other team a driver is currently on, before the move happens");
|
||||
|
||||
await evaluate(`document.querySelector('#teamEditForm [name="teamDriverIds"][value="${driverA.id}"]').checked = true`);
|
||||
await pause(5500); // outlast the periodic version poll, matching the existing team-edit browser coverage
|
||||
await click("teamEditSave");
|
||||
await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B").driverIds.includes(driverA.id), "driver A saved onto team B");
|
||||
|
||||
const afterMove = await findEvent(raceEvent.id);
|
||||
const teamA = afterMove.raceConfig.teams.find((team) => team.name === "Membership Team A");
|
||||
const teamBAfter = afterMove.raceConfig.teams.find((team) => team.name === "Membership Team B");
|
||||
assert.deepEqual(teamA.driverIds, [], "driver A was removed from team A when moved to team B - the reported duplicate-membership bug is fixed");
|
||||
assert.deepEqual([...teamBAfter.driverIds].sort(), [driverA.id, driverB.id].sort(), "team B now has both drivers");
|
||||
console.log("PASS moving a driver onto a different team's roster removes them from their previous team, with a warning shown before saving");
|
||||
|
||||
await navigate("drivers", "driverForm");
|
||||
await click("driver-delete-" + driverB.id);
|
||||
await waitFor(async () => !(await savedState()).drivers.some((driver) => driver.id === driverB.id), "driver B deleted");
|
||||
const afterDelete = (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B");
|
||||
assert.deepEqual(afterDelete.driverIds, [driverA.id], "a deleted driver no longer lingers in a team's roster");
|
||||
console.log("PASS deleting a driver purges them from every team's roster too");
|
||||
}
|
||||
@@ -67,6 +67,31 @@ test("open practice allows an unset lap window but still requires the main decod
|
||||
assert.equal(result.ready, false);
|
||||
});
|
||||
|
||||
test("team_race preflight blocks a driver saved onto two teams at once, naming them instead of a generic transponder error", () => {
|
||||
const state = createState();
|
||||
state.sessions.push({ id: "session_team", eventId: "event_1", name: "Team Race", mode: "race", type: "team_race", status: "ready", startMode: "mass" });
|
||||
state.events[0].raceConfig.teams = [
|
||||
{ id: "team_a", name: "Team A", driverIds: ["driver_1"], carIds: [] },
|
||||
{ id: "team_b", name: "Team B", driverIds: ["driver_1", "driver_2"], carIds: [] },
|
||||
];
|
||||
const result = buildRaceDayPreflight(state, state.sessions.find((s) => s.id === "session_team"), { backendAvailable: true });
|
||||
assert.equal(result.ready, false);
|
||||
const duplicateCheck = result.checks.find((item) => item.id === "duplicate_team_members");
|
||||
assert.equal(duplicateCheck.level, "error");
|
||||
assert.deepEqual(duplicateCheck.duplicateTeamMemberNames, ["One"]);
|
||||
});
|
||||
|
||||
test("team_race preflight accepts teams with no members in common", () => {
|
||||
const state = createState();
|
||||
state.sessions.push({ id: "session_team", eventId: "event_1", name: "Team Race", mode: "race", type: "team_race", status: "ready", startMode: "mass" });
|
||||
state.events[0].raceConfig.teams = [
|
||||
{ id: "team_a", name: "Team A", driverIds: ["driver_1"], carIds: [] },
|
||||
{ id: "team_b", name: "Team B", driverIds: ["driver_2"], carIds: [] },
|
||||
];
|
||||
const result = buildRaceDayPreflight(state, state.sessions.find((s) => s.id === "session_team"), { backendAvailable: true });
|
||||
assert.equal(result.checks.find((item) => item.id === "duplicate_team_members").level, "ok");
|
||||
});
|
||||
|
||||
test("preflight presents blockers before warnings without changing the original checks", async () => {
|
||||
const { sortRaceDayChecks } = await import("../src/race_day_view.js");
|
||||
const checks = [{ id: "audio", level: "warning" }, { id: "session", level: "ok" }, { id: "decoder", level: "error" }];
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
buildRaceSessionsFromWizard,
|
||||
generateFinalsForRace,
|
||||
generateQualifyingForRace,
|
||||
getTeamDriverPool,
|
||||
normalizeEvent,
|
||||
reassignTeamMembers,
|
||||
reseedUpcomingQualifying,
|
||||
} from "../src/event_race_logic.js";
|
||||
import {
|
||||
@@ -135,6 +137,43 @@ test("event normalization preserves the practice-only marker", () => {
|
||||
assert.equal(event.raceConfig.presetId, "custom");
|
||||
});
|
||||
|
||||
test("reassignTeamMembers strips a moved driver/car from every other team, leaving the excluded team untouched", () => {
|
||||
const teams = [
|
||||
{ id: "team_a", name: "A", driverIds: ["d1", "d2"], carIds: ["c1"] },
|
||||
{ id: "team_b", name: "B", driverIds: ["d3"], carIds: [] },
|
||||
];
|
||||
const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d1"], carIds: ["c1"] });
|
||||
const teamA = result.find((team) => team.id === "team_a");
|
||||
const teamB = result.find((team) => team.id === "team_b");
|
||||
assert.deepEqual(teamA.driverIds, ["d2"], "d1 was moved out of team A");
|
||||
assert.deepEqual(teamA.carIds, [], "c1 was moved out of team A too");
|
||||
assert.deepEqual(teamB.driverIds, ["d3"], "the team being saved is left for the caller to update, not touched here");
|
||||
});
|
||||
|
||||
test("reassignTeamMembers returns unchanged team objects as-is when nothing overlaps", () => {
|
||||
const teams = [{ id: "team_a", name: "A", driverIds: ["d1"], carIds: [] }];
|
||||
const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d9"], carIds: [] });
|
||||
assert.strictEqual(result[0], teams[0]);
|
||||
});
|
||||
|
||||
test("getTeamDriverPool scopes to the event's class/participants, only falling back to every driver when that list is empty", () => {
|
||||
const state = {
|
||||
drivers: [
|
||||
{ id: "d1", name: "One", classId: "2wd" },
|
||||
{ id: "d2", name: "Two", classId: "4wd" },
|
||||
],
|
||||
};
|
||||
const getEventDrivers = (event) => state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId);
|
||||
|
||||
const scoped = getTeamDriverPool({ classId: "2wd", raceConfig: {} }, { getEventDrivers, state });
|
||||
assert.deepEqual(scoped.drivers.map((driver) => driver.id), ["d1"]);
|
||||
assert.equal(scoped.fallback, false);
|
||||
|
||||
const noMatches = getTeamDriverPool({ classId: "unknown_class", raceConfig: {} }, { getEventDrivers, state });
|
||||
assert.deepEqual(noMatches.drivers.map((driver) => driver.id), ["d1", "d2"], "falls back to every driver rather than leaving zero checkboxes");
|
||||
assert.equal(noMatches.fallback, true);
|
||||
});
|
||||
|
||||
test("race wizard creates configured practice, qualifying, and endurance sessions", () => {
|
||||
const event = {
|
||||
id: "event_1",
|
||||
|
||||
Reference in New Issue
Block a user