diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df394a..1e2361b 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.33.0 - 2026-10-08 + +### Added + +- **🔊 Test pronunciation** on the Drivers page, in the add and edit forms and per driver in the list. It plays the driver's passing sound and then two real announcements with the spoken name (personal best and winner) through race audio, so you hear the voice you actually use (browser or local Piper). In the forms it uses what is typed right now, so a nickname or a spelling can be tried before saving. + +### Fixed + +- The search field on the Drivers and Cars pages lost focus after the first letter, so a name like "Anders" couldn't be typed straight through. Every keystroke re-rendered the page; focus and the cursor are now put back, as the archive search already did. + ## 1.32.0 - 2026-10-07 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 556e034..16731de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -760,6 +760,20 @@ Verified 2026-10-07: - `tests/browser_corrections_scope.js` checks a running heat: Timing has no penalty or lap-removal buttons and the lap list has no actions, "Open in Judging" lands on Judging with the penalty buttons and the driver. It then checks a running Open Practice: Timing has Remove per lap and no corrections block, and Judging has invalidate but no penalty or reset buttons. - 60 browser scenarios pass. +## Drivers page: search focus, pronunciation test + +Reported by the user 2026-10-08: +- Searching on Drivers jumped out of the field after the first letter. +- It should be possible to hear how a driver's spoken name or nickname sounds in announcements. + +- [x] The Drivers and Cars search inputs re-rendered the whole page on every `input` event, which replaced the field. Focus and the caret are now restored, the same pattern `results_archive_view.js` already used. +- [x] `testDriverVoice` in `app.js` plays the passing sound (`passSound`), then `personal_best` and `race_winner` with `driverName` set to the spoken name, through race audio with `force`. It goes through the same voice path as live announcements. Buttons are in the add and edit forms (reading the unsaved form values) and per row in the list. + +Verified 2026-10-08: +- `tests/browser_drivers_page.js` types "DP And" letter by letter and checks focus after every keystroke. +- It captures `speechSynthesis.speak` in the page and checks that the row button reads the saved spoken name, and that the add-form button reads an unsaved nickname without saving the driver. +- 253 unit and 62 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 13eca9c..ad66cfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.32.0", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.32.0", + "version": "1.33.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index ef6d013..dd3716f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.32.0", + "version": "1.33.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 9f71b25..2239d65 100644 --- a/src/app.js +++ b/src/app.js @@ -400,7 +400,17 @@ const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessa }, decoderRole); const renderDashboard = () => renderDashboardView({ addTrackLayout, state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell }); -const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName }); +// Driver page "Test pronunciation": the passing sound, then two real announcements with the +// spoken name, through race audio (so the chosen voice / Piper is what you hear). +const testDriverVoice = ({ spokenName, passSound }) => { + if (!spokenName) return; + ensureAudioContext(); + triggerRaceAudio("transponder_pass", { force: true, effectPath: passSound || "" }); + const variables = { driverName: spokenName, lapTime: formatLap(16420), podium: spokenName }; + triggerRaceAudio("personal_best", { force: true, interrupt: true, variables }); + triggerRaceAudio("race_winner", { force: true, variables }); +}; +const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName , testDriverVoice }); const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar }); const renderGuide = () => renderGuideView({ dom, t }); const renderRaceDay = () => renderRaceDayView({ @@ -1030,8 +1040,9 @@ const TRANSLATIONS = { "drivers.transponder_placeholder": "Personlig transponder (valfritt)", "drivers.transponder_conflict_confirm": "Transpondern {tp} anvĂ€nds redan av {name} ({class}). Spara Ă€ndĂ„?", "drivers.speech_name": "Uttalsnamn", + "drivers.voice_test": "🔊 Testa uttal", "drivers.speech_name_placeholder": "Uttal för upplĂ€sning (valfritt)", - "drivers.speech_name_hint": "Skriv namnet som webblĂ€sarrösten ska uttala det. LĂ€mna tomt för vanligt förarnamn.", + "drivers.speech_name_hint": "Skriv namnet som rösten ska uttala det, eller ett smeknamn. LĂ€mna tomt för vanligt förarnamn. Testa med 🔊.", "drivers.training_profile": "TrĂ€ningsprofil", "drivers.training_profile_hint": "LĂ€nka flera klass- och transponderposter för samma person till en gemensam trĂ€ningsprofil.", "drivers.training_profile_auto": "Automatiskt: samma namn eller ny profil", @@ -2694,8 +2705,9 @@ const TRANSLATIONS = { "drivers.transponder_placeholder": "Personal transponder (optional)", "drivers.transponder_conflict_confirm": "Transponder {tp} is already used by {name} ({class}). Save anyway?", "drivers.speech_name": "Spoken name", + "drivers.voice_test": "🔊 Test pronunciation", "drivers.speech_name_placeholder": "Pronunciation for announcements (optional)", - "drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.", + "drivers.speech_name_hint": "Enter the name as the voice should pronounce it, or a nickname. Leave blank to use the regular driver name. Try it with 🔊.", "drivers.training_profile": "Practice profile", "drivers.training_profile_hint": "Link multiple class and transponder entries for the same person to one shared practice profile.", "drivers.training_profile_auto": "Automatic: same name or new profile", diff --git a/src/core_views.js b/src/core_views.js index d4349ae..0f3ef9e 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -375,6 +375,7 @@ export function renderDriversView(deps) { bindModalShell, normalizeDriver, getClassName, + testDriverVoice = () => {}, } = deps; const classOptions = state.classes.map((c) => ``).join(""); const driverSearch = driverBrandFilter().trim().toLowerCase(); @@ -408,6 +409,7 @@ export function renderDriversView(deps) { + @@ -444,6 +446,7 @@ export function renderDriversView(deps) { ${escapeHtml(profile?.representative?.name || d.name)}${profile?.drivers.length > 1 ? ` · ${profile.drivers.length}` : ""} ${escapeHtml(d.speechName || d.name)} + @@ -474,6 +477,7 @@ export function renderDriversView(deps) { + @@ -536,13 +540,33 @@ export function renderDriversView(deps) { renderView(); }); + // Hear the spoken name as race audio will say it, before saving: the form's current + // spoken name (or name) and passing sound. + dom.view.querySelectorAll("[data-driver-voice-test]").forEach((button) => button.addEventListener("click", () => { + const form = button.closest("form"); + const value = (name) => String(form?.elements.namedItem(name)?.value || "").trim(); + testDriverVoice({ spokenName: value("speechName") || value("name"), passSound: value("passSound") }); + })); + dom.view.querySelectorAll("[data-driver-voice-row]").forEach((button) => button.addEventListener("click", () => { + const driver = state.drivers.find((item) => item.id === button.dataset.driverVoiceRow); + if (driver) testDriverVoice({ spokenName: driver.speechName || driver.name, passSound: driver.passSound || "" }); + })); + document.getElementById("driverBrandFilter")?.addEventListener("input", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) { return; } + // A full re-render replaces this input, which dropped focus after the first letter - + // put focus and the cursor back so a whole name can be typed (same as the archive search). + const caret = input.selectionStart; setDriverBrandFilter(input.value); renderDriversView(deps); + const restored = document.getElementById("driverBrandFilter"); + if (restored instanceof HTMLInputElement) { + restored.focus(); + restored.setSelectionRange(caret, caret); + } }); const bindProfileMetadataFill = (formId) => { @@ -785,8 +809,16 @@ export function renderCarsView(deps) { if (!(input instanceof HTMLInputElement)) { return; } + // A full re-render replaces this input, which dropped focus after the first letter - + // put focus and the cursor back so a whole name can be typed (same as the archive search). + const caret = input.selectionStart; setCarBrandFilter(input.value); renderCarsView(deps); + const restored = document.getElementById("carBrandFilter"); + if (restored instanceof HTMLInputElement) { + restored.focus(); + restored.setSelectionRange(caret, caret); + } }); state.cars.forEach((c) => { diff --git a/tests/browser_drivers_page.js b/tests/browser_drivers_page.js new file mode 100644 index 0000000..c07d57b --- /dev/null +++ b/tests/browser_drivers_page.js @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; + +// Drivers page: the search field keeps focus while a whole name is typed (it used to lose it +// after the first letter, because every keystroke re-renders the page), and "Test +// pronunciation" speaks the spoken name - the unsaved one in the form - through race audio. +export async function runDriversPageSmoke({ evaluate, navigate, savedState, waitFor, base }) { + 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 seeded = await savedState(); + await patchState({ + drivers: [...seeded.drivers, { id: "dp_anders", name: "DP Anders", speechName: "Anders the Rocket", transponder: "8550001" }], + settings: { ...seeded.settings, ttsEngine: "browser", raceAudio: { ...seeded.settings.raceAudio, enabled: true, muted: false, language: "sv", events: {} } }, + }); + + try { + await navigate("drivers", "driverForm"); + // Type letter by letter, like a person. + for (const letter of "DP And") { + const ok = await evaluate(`(() => { + const input = document.getElementById("driverBrandFilter"); + if (document.activeElement !== input) input.focus(); + input.value += ${JSON.stringify(letter)}; + input.setSelectionRange(input.value.length, input.value.length); + input.dispatchEvent(new Event("input", { bubbles: true })); + return document.activeElement?.id === "driverBrandFilter"; + })()`); + assert.equal(ok, true, `focus kept after typing "${letter}"`); + } + assert.equal(await evaluate(`document.getElementById("driverBrandFilter").value`), "DP And"); + assert.match(await evaluate(`document.querySelector(".panel table, table")?.innerText || document.body.innerText`), /DP Anders/); + console.log("PASS the driver search keeps focus so a whole name can be typed"); + + // Capture what the browser voice is asked to say. + await evaluate(`(() => { + window.__spoken = []; + window.speechSynthesis.speak = (utterance) => { window.__spoken.push(utterance.text); setTimeout(() => utterance.onend?.(), 10); }; + })()`); + await evaluate(`document.querySelector('[data-driver-voice-row="dp_anders"]').click()`); + await waitFor(async () => (await evaluate(`window.__spoken.join(" | ")`)).includes("Anders the Rocket"), "the saved spoken name is read out"); + + // In the add form: an unsaved nickname is what you hear. + await evaluate(`(() => { + window.__spoken = []; + const form = document.getElementById("driverForm"); + form.elements.name.value = "New Kid"; + form.elements.speechName.value = "Kiddo"; + form.querySelector("[data-driver-voice-test]").click(); + })()`); + await waitFor(async () => (await evaluate(`window.__spoken.join(" | ")`)).includes("Kiddo"), "the unsaved nickname is read out"); + assert.equal((await savedState()).drivers.some((driver) => driver.name === "New Kid"), false, "testing doesn't save the driver"); + console.log("PASS Test pronunciation reads the spoken name (also an unsaved one) through race audio"); + } finally { + const current = await savedState(); + await patchState({ drivers: current.drivers.filter((driver) => driver.id !== "dp_anders"), settings: { ...current.settings, raceAudio: seeded.settings.raceAudio, ttsEngine: seeded.settings.ttsEngine } }); + } +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index c7d0390..ef6046b 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -14,6 +14,7 @@ import { runPrologueSmoke } from "./browser_prologue.js"; import { runPracticeResetSmoke } from "./browser_practice_reset.js"; import { runSuspectLapsSmoke } from "./browser_suspect_laps.js"; import { runCorrectionsScopeSmoke } from "./browser_corrections_scope.js"; +import { runDriversPageSmoke } from "./browser_drivers_page.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -158,6 +159,7 @@ try { await runPracticeResetSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runSuspectLapsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runCorrectionsScopeSmoke({ evaluate, navigate, savedState, waitFor, base }); + await runDriversPageSmoke({ evaluate, navigate, savedState, waitFor, 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 });