From 72d83ad183844c9e1f8e70861699398d99c615f0 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 11 Oct 2026 20:13:10 +0200 Subject: [PATCH] feat(overlay): show places gained since the start in the OBS tower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heats and finals started together show ▲/▼ against the start order (new OBS option, on by default). In practice the best lap is no longer printed twice as both result and best. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++++++++ ROADMAP.md | 20 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/app.js | 10 ++++++++- src/misc_views.js | 4 ++-- src/overlays.js | 32 +++++++++++++++++++++++++-- src/settings.js | 2 ++ src/styles.css | 5 +++++ tests/browser_overlays.js | 25 +++++++++++++++++++++ tests/overlay_position_change.test.js | 18 +++++++++++++++ tests/run.js | 1 + 12 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 tests/overlay_position_change.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 374f98b..0fb6cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.39.0 - 2026-10-11 + +### Added + +- OBS overlay: **places since the start** in heats and finals started together (mass or position start). A driver who started 5th and is now 2nd shows ▲3 in green; one who dropped back shows ▼ in red; no arrow when the place is unchanged. The column is a new option, "Places since the start (▲/▼) in races", on by default. It is not shown in practice, qualifying, or staggered and flying starts, where there is no common start order to compare with. + +### Fixed + +- OBS overlay in Open/Free practice showed the best lap twice, as "result" and as "best", because practice is ranked on the best lap. It is now shown once. + ## 1.38.0 - 2026-10-11 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 9b13dd0..adc28ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -904,6 +904,26 @@ Checked first: `settings.publicBaseUrl` already existed and was used by the prof Verified 2026-10-11: the browser test refuses `ftp://nope`, saves `https://klubben.dyndns.org:8443/some/path` as `https://klubben.dyndns.org:8443`, shows it as in use and in a driver's share panel, follows "Change in Settings" to the System tab, and clears it again. 264 unit and 72 browser scenarios pass. +## OBS overlay: places since the start, no duplicate best lap + +Reported by the user 2026-10-11 with a screenshot of the OBS tower during Open Practice: the fastest lap was shown twice, laps were missing, and in a race you want to see how you are doing, e.g. started 5th and now 2nd. + +Checked: +- That overlay URL had `showLaps=0`, an overlay setting. +- In races the result column is already laps/time, and gap updates on every passing. +- In practice the result is the best lap, so result and best printed the same time. + +- [x] `getPositionChange(session, startOrder, driverId, position)` in `overlays.js`: start position (`getSessionGridOrder`) minus current position. Only for running/finished heats and finals with a mass/position start; `null` for practice, qualifying, staggered/flying and drivers not in the start order. Rendered as ▲n (green) / ▼n (red) first in the OBS trail. +- [x] New OBS option `showPositionChange` (default on) in all places the OBS options live: defaults, normalize, URL params, URL writer, Settings checkbox, save mapping. The start order reaches the renderer through `misc_views.js`. +- [x] `obsShowsResult`: in Open/Free practice with "best" shown, "result" is skipped (it was the same time). + +Verified 2026-10-11: +- 265 unit tests pass. A new test covers +3/-2/0, a driver not in the order, mass heat, ready, practice, qualifying, flying and no session. +- The browser test renders the OBS tower and checks: + - in a final, the leader who started 5th shows "▲4 14L 14/5:02.312 0:20.100 -", the driver who started 1st shows "▼1 …", and an unchanged place shows no arrow; + - Open Practice shows "23L 0:15.833 -" with the best lap once. +- 73 browser scenarios pass. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 332fb12..ea7fd09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.38.0", + "version": "1.39.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index f3947b9..86857ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.38.0", + "version": "1.39.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 c02e558..2f377dd 100644 --- a/src/app.js +++ b/src/app.js @@ -435,7 +435,7 @@ const renderRaceDay = () => renderRaceDayView({ navigateTo: navigateToView, getStartLightState: () => raceAudioEngine.getStartLightState(), }); -const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); +const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, getSessionGridOrder, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); const refreshOverlayDynamics = () => { const active = getActiveSession(); if (!active) return; @@ -794,6 +794,8 @@ const TRANSLATIONS = { "series.step3": "3 · Deltävlingar ({count})", "series.step4": "4 · Ställning", "series.classes_all": "Alla", + "overlay.obs_show_position_change": "Placering sedan start (▲/▼) i race", + "overlay.position_change_title": "Placeringar vunna (▲) eller förlorade (▼) sedan starten", "settings.public_address_title": "Publik adress (domän / DynDNS)", "settings.public_address_hint": "Gäller alla förares mobilprofiler: länkar och QR-koder byggs med den här adressen, t.ex. https://klubben.dyndns.org eller https://klubben.se:8443. Lämna tomt för att använda datorns adress i det lokala nätverket (fungerar bara på samma wifi).", "settings.public_address_in_use": "Används nu: {url}", @@ -2598,6 +2600,8 @@ const TRANSLATIONS = { "series.step3": "3 · Rounds ({count})", "series.step4": "4 · Standings", "series.classes_all": "All", + "overlay.obs_show_position_change": "Places since the start (▲/▼) in races", + "overlay.position_change_title": "Places gained (▲) or lost (▼) since the start", "settings.public_address_title": "Public address (domain / DynDNS)", "settings.public_address_hint": "Used for every driver's mobile profile: links and QR codes are built with this address, e.g. https://club.dyndns.org or https://club.example:8443. Leave empty to use this computer's local network address (works on the same wifi only).", "settings.public_address_in_use": "In use: {url}", @@ -4384,6 +4388,7 @@ function createDefaultObsOverlaySettings() { showResult: true, showBest: true, showGap: true, + showPositionChange: true, layout: "leaderboard", theme: "panel", publicToken: "", @@ -4409,6 +4414,7 @@ function normalizeObsOverlaySettings(raw = {}) { showResult: raw?.showResult !== false, showBest: raw?.showBest !== false, showGap: raw?.showGap !== false, + showPositionChange: raw?.showPositionChange !== false, layout, theme, publicToken: String(raw?.publicToken || "").trim(), @@ -4510,6 +4516,7 @@ function getObsOverlayConfig() { showResult: parseOverlayBooleanParam("showResult", base.showResult), showBest: parseOverlayBooleanParam("showBest", base.showBest), showGap: parseOverlayBooleanParam("showGap", base.showGap), + showPositionChange: parseOverlayBooleanParam("showPositionChange", base.showPositionChange), layout: getObsLayouts().includes(String(urlParams.get("layout") || "").toLowerCase()) ? String(urlParams.get("layout")).toLowerCase() : base.layout, theme: getObsThemes().includes(String(urlParams.get("obsTheme") || "").toLowerCase()) ? String(urlParams.get("obsTheme")).toLowerCase() : base.theme, publicToken: String(urlParams.get("token") || base.publicToken || "").trim(), @@ -4526,6 +4533,7 @@ function writeObsOverlayParams(url, config) { url.searchParams.set("showResult", normalized.showResult ? "1" : "0"); url.searchParams.set("showBest", normalized.showBest ? "1" : "0"); url.searchParams.set("showGap", normalized.showGap ? "1" : "0"); + url.searchParams.set("showPositionChange", normalized.showPositionChange ? "1" : "0"); url.searchParams.set("layout", normalized.layout); url.searchParams.set("obsTheme", normalized.theme); if (normalized.publicToken) { diff --git a/src/misc_views.js b/src/misc_views.js index c4627b6..b6b4ff4 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -173,7 +173,7 @@ export function renderGuideView({ dom, t }) { } export function renderOverlayPageView(deps) { - const { state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl } = deps; + const { state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, getSessionGridOrder, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl } = deps; const active = getActiveSession(); const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : []; @@ -287,7 +287,7 @@ export function renderOverlayPageView(deps) { ${renderRaceStandingsTableView(finalRows, t("events.no_final_results"))} - ` : overlayViewMode === "team" ? renderTeamOverlay(leaderboard, result, sessionTiming, { t, escapeHtml, formatLap, formatPredictedLapDelta, formatTeamActiveMemberLabel, getVisiblePassings, renderOverlayLeaderboard }) : overlayViewMode === "obs" ? renderObsOverlay(active, leaderboard, result, sessionTiming, branding, { t, escapeHtml, formatLap, getVisiblePassings, getSessionTypeLabel, getStartModeLabel, getStatusLabel, getEventName, getObsOverlayConfig, normalizeStartMode, renderPositionGrid, getSessionGridEntries, formatTeamActiveMemberLabel, renderOverlayLeaderboard }) : overlayViewMode === "tv" ? ` + ` : overlayViewMode === "team" ? renderTeamOverlay(leaderboard, result, sessionTiming, { t, escapeHtml, formatLap, formatPredictedLapDelta, formatTeamActiveMemberLabel, getVisiblePassings, renderOverlayLeaderboard }) : overlayViewMode === "obs" ? renderObsOverlay(active, leaderboard, result, sessionTiming, branding, { t, escapeHtml, formatLap, getVisiblePassings, getSessionTypeLabel, getStartModeLabel, getStatusLabel, getEventName, getObsOverlayConfig, normalizeStartMode, renderPositionGrid, getSessionGridEntries, getSessionGridOrder, formatTeamActiveMemberLabel, renderOverlayLeaderboard }) : overlayViewMode === "tv" ? `
diff --git a/src/overlays.js b/src/overlays.js index 905aa73..d20354e 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -180,6 +180,26 @@ export function renderTeamOverlay(rows, result, sessionTiming, deps) { `; } +const PRACTICE_TYPES = new Set(["open_practice", "free_practice", "practice"]); + +// Places gained (positive) or lost (negative) since the start, for heats and finals started +// together from a start order (mass/position). null when it doesn't apply: practice and +// qualifying rank on best laps or individual clocks, and a staggered or flying start has no +// common start order to compare with. +export function getPositionChange(session, startOrder, driverId, currentPosition) { + if (!session || PRACTICE_TYPES.has(String(session.type || "")) || session.type === "qualification") return null; + if (!["mass", "position"].includes(String(session.startMode || "mass"))) return null; + if (session.status === "ready") return null; + const startPosition = (startOrder || []).indexOf(driverId) + 1; + return startPosition > 0 ? startPosition - currentPosition : null; +} + +// In practice the ranking value *is* the best lap, so "result" and "best" would print the +// same time twice - show it once. +function obsShowsResult(session, obsConfig) { + return obsConfig.showResult && !(obsConfig.showBest && ["open_practice", "free_practice"].includes(String(session?.type || ""))); +} + export function renderObsOverlay(active, leaderboard, result, sessionTiming, branding, deps) { const { t, @@ -194,9 +214,16 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra normalizeStartMode, renderPositionGrid, getSessionGridEntries, + getSessionGridOrder = () => [], formatTeamActiveMemberLabel, } = deps; const obsConfig = getObsOverlayConfig(); + const startOrder = active && obsConfig.showPositionChange ? getSessionGridOrder(active) : []; + const positionChangeMarkup = (row, position) => { + const change = obsConfig.showPositionChange ? getPositionChange(active, startOrder, row.driverId, position) : null; + if (!change) return ""; + return `${change > 0 ? "▲" : "▼"}${Math.abs(change)}`; + }; const compactRows = leaderboard.slice(0, obsConfig.rows); const readyPositionGrid = active && active.status === "ready" && normalizeStartMode(active.startMode) === "position"; const showStartGrid = obsConfig.showGrid && readyPositionGrid; @@ -263,7 +290,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra ${idx + 1} ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""}${row.suspectPending ? ` ⚠` : ""} ${obsConfig.showLaps ? `${t("table.laps")}: ${row.laps ?? 0}` : ""} - ${obsConfig.showResult ? `${t("table.result")}: ${escapeHtml(row.resultDisplay)}` : ""} + ${obsShowsResult(active, obsConfig) ? `${t("table.result")}: ${escapeHtml(row.resultDisplay)}` : ""} ${obsConfig.showGap ? `${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}` : ""} `; @@ -293,8 +320,9 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra .map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; const trail = [ + positionChangeMarkup(row, idx + 1), obsConfig.showLaps ? `${row.laps ?? 0}L` : "", - obsConfig.showResult ? `${escapeHtml(row.resultDisplay)}` : "", + obsShowsResult(active, obsConfig) ? `${escapeHtml(row.resultDisplay)}` : "", obsConfig.showBest ? `${formatLap(row.bestLapMs)}` : "", obsConfig.showGap ? `${escapeHtml(row.gapDisplay || row.gapAhead || "-")}` : "", ] diff --git a/src/settings.js b/src/settings.js index 8f69fd4..048ebcb 100644 --- a/src/settings.js +++ b/src/settings.js @@ -377,6 +377,7 @@ export function renderSettings(deps) { +
@@ -741,6 +742,7 @@ Erik Eriksson;654321;4WD;Associated;; ["obsShowResult", "showResult", (value, el) => Boolean(el?.checked)], ["obsShowBest", "showBest", (value, el) => Boolean(el?.checked)], ["obsShowGap", "showGap", (value, el) => Boolean(el?.checked)], + ["obsShowPositionChange", "showPositionChange", (value, el) => Boolean(el?.checked)], ].forEach(([id, key, mapper]) => { const el = document.getElementById(id); if (!el) { diff --git a/src/styles.css b/src/styles.css index afa9a8e..a938073 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4626,3 +4626,8 @@ tr.is-removed td.lap-actions { .settings-public-address { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .settings-public-address input { flex: 1 1 320px; max-width: 520px; } .training-public-address { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +/* OBS overlay: places gained/lost since the start */ +.overlay-position-change { font-weight: 800; font-variant-numeric: tabular-nums; } +.overlay-position-change.is-up { color: #3ddc84; } +.overlay-position-change.is-down { color: #ff6b6b; } diff --git a/tests/browser_overlays.js b/tests/browser_overlays.js index 051e308..9d7bd7d 100644 --- a/tests/browser_overlays.js +++ b/tests/browser_overlays.js @@ -62,4 +62,29 @@ export async function runOverlaySmoke({ evaluate }) { } } console.log("PASS overlay heading alignment at 390/900/1600px, sorting preserves race position, OBS has no sort controls"); + + // OBS tower: in a final, places gained/lost since the start; in practice the best lap is + // shown once (result and best were the same time twice). + const obs = await evaluate(`(async () => { + const { renderObsOverlay } = await import('/src/overlays.js'); + const { escapeHtml } = await import('/src/html_escape.js'); + const config = { layout: 'leaderboard', rows: 10, showClock: true, showLaps: true, showResult: true, showBest: true, showGap: true, showPositionChange: true }; + const deps = { t: (key) => key, escapeHtml, formatLap: (ms) => '0:' + (ms / 1000).toFixed(3), formatTeamActiveMemberLabel: () => '', getVisiblePassings: () => [], getEventName: () => 'Club', getSessionTypeLabel: () => 'Final', getStartModeLabel: () => 'Position', getStatusLabel: () => 'Running', getObsOverlayConfig: () => config, normalizeStartMode: (v) => v, renderPositionGrid: () => '', getSessionGridEntries: () => [], getSessionGridOrder: () => ['a', 'b', 'c', 'd', 'e'] }; + const rows = [ + { key: 'e', driverId: 'e', displayName: 'Eve', laps: 14, resultDisplay: '14/5:02.312', bestLapMs: 20100, gapDisplay: '-' }, + { key: 'a', driverId: 'a', displayName: 'Ann', laps: 14, resultDisplay: '14/5:05.000', bestLapMs: 20300, gapDisplay: '+2.688' }, + { key: 'c', driverId: 'c', displayName: 'Cid', laps: 13, resultDisplay: '13/5:01.000', bestLapMs: 21000, gapDisplay: '+1L' }, + ]; + const box = document.createElement('div'); + box.innerHTML = renderObsOverlay({ type: 'final', startMode: 'position', status: 'running' }, rows, {}, { remainingMs: 1000 }, {}, deps); + const finalRows = [...box.querySelectorAll('.overlay-obs-tower-row')].map((row) => [...row.querySelector('.overlay-obs-tower-trail').children].map((span) => span.textContent.trim()).join(' ')); + box.innerHTML = renderObsOverlay({ type: 'open_practice', startMode: 'mass', status: 'running' }, [{ key: 'p', driverId: 'p', displayName: 'Pia', laps: 23, resultDisplay: '0:15.833', bestLapMs: 15833, gapDisplay: '-' }], {}, { untimed: true, elapsedMs: 1000 }, {}, deps); + const practiceRow = [...box.querySelector('.overlay-obs-tower-trail').children].map((span) => span.textContent.trim()).join(' '); + return { finalRows, practiceRow }; + })()`); + assert.equal(obs.finalRows[0], "▲4 14L 14/5:02.312 0:20.100 -", "started 5th, leads: ▲4, laps and result"); + assert.equal(obs.finalRows[1], "▼1 14L 14/5:05.000 0:20.300 +2.688", "started 1st, now 2nd: ▼1"); + assert.equal(obs.finalRows[2], "13L 13/5:01.000 0:21.000 +1L", "same place as at the start: no arrow"); + assert.equal(obs.practiceRow, "23L 0:15.833 -", "practice: the best lap once, not twice"); + console.log("PASS OBS overlay shows places gained/lost since the start in finals, and the best lap once in practice"); } diff --git a/tests/overlay_position_change.test.js b/tests/overlay_position_change.test.js new file mode 100644 index 0000000..f90f1b4 --- /dev/null +++ b/tests/overlay_position_change.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getPositionChange } from "../src/overlays.js"; + +test("places gained or lost since the start - only for heats and finals started together", () => { + const order = ["a", "b", "c", "d", "e"]; + const final = { type: "final", startMode: "position", status: "running" }; + assert.equal(getPositionChange(final, order, "e", 2), 3, "started 5th, now 2nd: +3"); + assert.equal(getPositionChange(final, order, "a", 3), -2, "started 1st, now 3rd: -2"); + assert.equal(getPositionChange(final, order, "c", 3), 0); + assert.equal(getPositionChange(final, order, "late_entry", 1), null, "not in the start order"); + assert.equal(getPositionChange({ ...final, startMode: "mass", type: "heat" }, order, "e", 1), 4); + assert.equal(getPositionChange({ ...final, status: "ready" }, order, "e", 2), null, "not before the start"); + assert.equal(getPositionChange({ ...final, type: "open_practice" }, order, "e", 2), null, "practice ranks on best lap"); + assert.equal(getPositionChange({ ...final, type: "qualification" }, order, "e", 2), null, "qualifying has individual clocks"); + assert.equal(getPositionChange({ ...final, startMode: "flying" }, order, "e", 2), null, "no common start order"); + assert.equal(getPositionChange(null, order, "e", 2), null); +}); diff --git a/tests/run.js b/tests/run.js index 78cedd5..173538e 100644 --- a/tests/run.js +++ b/tests/run.js @@ -2,6 +2,7 @@ import "./auth.test.js"; import "./prologue_logic.test.js"; import "./suspect_laps_logic.test.js"; import "./series_logic.test.js"; +import "./overlay_position_change.test.js"; import "./tts.test.js"; import "./piper_speech.test.js"; import "./driver_profile_logic.test.js";