Skip to content

Commit 86489e5

Browse files
fix(headphones): recover from stalled check with Try Again + two-strike fail (#1241)
* fix(headphones): recover from stalled check instead of looping If the user picks "I did not hear anything" the troubleshooting panel now includes a Try Again button that resets the radio selection so they can replay. A second "none" fails the check with "Could not hear test sound." so the equipment flow restarts rather than stalling indefinitely (seen on Firefox where the audio `playing` event sometimes doesn't fire). Also drop the `soundPlayed &&` gate on the pass branch — Firefox occasionally misses the `playing` event, which was blocking legitimate passes. Add `[HeadphonesCheck]` console logs on play/end/pause and a `noneSelectedCount` field in the setupSteps debug payload for future diagnosis. Covered by HC-005 (retry button appears, no fail on first "none") and new HC-005b (second "none" fails with error message). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address Copilot review: fix off-by-one, reset counter, poll in tests Component: - Increment noneSelectedCountRef *before* logging so the setupSteps debug payload matches the strike number that caused it (first "none" logs 1, second logs 2 and fails). Previously the log was off by one. - Reset the counter inside resetProgress() so that switching output device via "Choose a different device" starts a fresh two-strike window (prevents the next "none" from being mistakenly counted as strike #2). - Troubleshooting heading: h2 → h3 to preserve heading hierarchy under the existing step h2, and fix "Lets" → "Let's". Tests: - HC-005 / HC-005b: replace synchronous asserts on the statuses/errors arrays with expect.poll. setHeadphonesStatus fires from a useEffect (async to the radio click), so the prior asserts could race. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e857de commit 86489e5

2 files changed

Lines changed: 83 additions & 12 deletions

File tree

client/src/intro-exit/setup/HeadphonesCheck.jsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
2929
const [speakerIteration, setSpeakerIteration] = useState(0);
3030
const [noDevicesTimeout, setNoDevicesTimeout] = useState(false);
3131
const [isPlaying, setIsPlaying] = useState(false);
32+
const noneSelectedCountRef = useRef(0);
3233
const audioRef = useRef(null);
3334

3435
useEffect(() => {
@@ -46,22 +47,30 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
4647

4748
useEffect(() => {
4849
if (soundPlayed && soundSelected) {
50+
if (soundSelected === "none") {
51+
noneSelectedCountRef.current += 1;
52+
}
4953
const logEntry = {
5054
step: "headphonesCheck",
5155
event: "soundSelected",
5256
value: soundSelected,
5357
errors: [],
54-
debug: {},
58+
debug: { noneSelectedCount: noneSelectedCountRef.current },
5559
timestamp: new Date().toISOString(),
5660
};
5761

5862
player.append("setupSteps", logEntry);
59-
console.log("Sound played successfully", logEntry);
60-
if (soundPlayed && soundSelected === "clock") {
63+
console.log("[HeadphonesCheck] Sound selected", logEntry);
64+
if (soundSelected === "clock") {
6165
setHeadphonesStatus("pass");
66+
} else if (soundSelected === "none" && noneSelectedCountRef.current >= 2) {
67+
// User said "I did not hear anything" twice — fail so the equipment
68+
// check restarts rather than stalling indefinitely.
69+
if (setErrorMessage) setErrorMessage("Could not hear test sound.");
70+
setHeadphonesStatus("fail");
6271
}
6372
}
64-
}, [soundPlayed, soundSelected, setHeadphonesStatus, player]);
73+
}, [soundPlayed, soundSelected, setHeadphonesStatus, setErrorMessage, player]);
6574

6675
const devices = useDevices();
6776

@@ -102,6 +111,8 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
102111
setSoundPlayed(false);
103112
setSoundSelected("");
104113
setHeadphonesStatus("waiting");
114+
// Fresh attempt after a device switch — don't let earlier strikes carry over.
115+
noneSelectedCountRef.current = 0;
105116
};
106117

107118
const handleSpeakerSelected = async (speaker) => {
@@ -139,6 +150,7 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
139150
const audio = audioRef.current;
140151
if (!audio) return undefined;
141152
const onPlaying = () => {
153+
console.log("[HeadphonesCheck] Audio playing event fired");
142154
setIsPlaying(true);
143155
player.append("setupSteps", {
144156
step: "headphonesCheck",
@@ -148,8 +160,14 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
148160
timestamp: new Date().toISOString(),
149161
});
150162
};
151-
const onEnded = () => setIsPlaying(false);
152-
const onPause = () => setIsPlaying(false);
163+
const onEnded = () => {
164+
console.log("[HeadphonesCheck] Audio ended");
165+
setIsPlaying(false);
166+
};
167+
const onPause = () => {
168+
console.log("[HeadphonesCheck] Audio paused");
169+
setIsPlaying(false);
170+
};
153171
audio.addEventListener("playing", onPlaying);
154172
audio.addEventListener("ended", onEnded);
155173
audio.addEventListener("pause", onPause);
@@ -275,17 +293,27 @@ export function HeadphonesCheck({ setHeadphonesStatus, setErrorMessage }) {
275293
)}
276294

277295
{soundSelected === "none" && (
278-
<>
279-
<h2>🤔 Lets troubleshoot:</h2>
296+
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
297+
<h3>🤔 Let&apos;s troubleshoot:</h3>
280298
<ul>
281299
<li>Are your headphones connected or paired?</li>
282300
<li>Is the volume turned up?</li>
283301
{canSelectSpeaker && (
284302
<li>Is this device selected as the output above?</li>
285303
)}
286304
</ul>
287-
<p>After checking these, please play the sound again.</p>
288-
</>
305+
<p className="mt-2">After checking these, try again:</p>
306+
<Button
307+
className="mt-2"
308+
testId="retrySound"
309+
handleClick={() => {
310+
setSoundSelected("");
311+
setSoundPlayed(false);
312+
}}
313+
>
314+
Try Again
315+
</Button>
316+
</div>
289317
)}
290318
</section>
291319
)}

playwright/component-tests/equipment-check/HeadphonesCheck.ct.jsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,14 @@ test('HC-004: wrong sound identification does not pass', async ({ mount, page })
220220
expect(latestStatus).not.toBe('pass');
221221
});
222222

223-
/** HC-005: "I did not hear anything" shows troubleshooting */
223+
/** HC-005: "I did not hear anything" shows troubleshooting and Try Again button */
224224
test('HC-005: no-sound option shows troubleshooting', async ({ mount, page }) => {
225225
await installAudioMocks(page);
226226

227+
const statuses = [];
227228
await mount(
228229
<HeadphonesCheck
229-
setHeadphonesStatus={() => {}}
230+
setHeadphonesStatus={(s) => statuses.push(s)}
230231
setErrorMessage={() => {}}
231232
/>,
232233
{ hooksConfig: hooksConfig() },
@@ -236,10 +237,52 @@ test('HC-005: no-sound option shows troubleshooting', async ({ mount, page }) =>
236237
await page.locator('[data-test="playSound"]').click();
237238
await expect(page.locator('[data-test="soundSelect"]')).toBeVisible();
238239

240+
// First "none" — shows troubleshooting + Try Again, does NOT fail
239241
await page.locator('[data-test="soundSelect"] input[value="none"]').check();
240242

241243
await expect(page.locator('text=Are your headphones connected')).toBeVisible();
242244
await expect(page.locator('text=Is the volume turned up')).toBeVisible();
245+
await expect(page.locator('[data-test="retrySound"]')).toBeVisible();
246+
// `setHeadphonesStatus` fires from a useEffect (async to the click), so a
247+
// sync assertion could miss a late "fail" update. Poll briefly to confirm
248+
// fail never arrives.
249+
await expect.poll(() => statuses.includes('fail'), { timeout: 300 }).toBe(false);
250+
});
251+
252+
/** HC-005b: second "none" selection fails the check */
253+
test('HC-005b: second no-sound fails the check', async ({ mount, page }) => {
254+
await installAudioMocks(page);
255+
256+
const statuses = [];
257+
const errors = [];
258+
await mount(
259+
<HeadphonesCheck
260+
setHeadphonesStatus={(s) => statuses.push(s)}
261+
setErrorMessage={(m) => errors.push(m)}
262+
/>,
263+
{ hooksConfig: hooksConfig() },
264+
);
265+
266+
await advanceToStep3(page);
267+
268+
// First attempt: play, select "none", see troubleshooting
269+
await page.locator('[data-test="playSound"]').click();
270+
await page.locator('[data-test="soundSelect"] input[value="none"]').check();
271+
await expect(page.locator('[data-test="retrySound"]')).toBeVisible();
272+
273+
// Click Try Again — resets radio selection
274+
await page.locator('[data-test="retrySound"]').click();
275+
await expect(page.locator('[data-test="soundSelect"]')).not.toBeVisible();
276+
277+
// Second attempt: play again, select "none" again
278+
await page.locator('[data-test="playSound"]').click();
279+
await expect(page.locator('[data-test="soundSelect"]')).toBeVisible();
280+
await page.locator('[data-test="soundSelect"] input[value="none"]').check();
281+
282+
// Should now fail. `setHeadphonesStatus` / `setErrorMessage` fire from a
283+
// useEffect, so poll rather than asserting synchronously.
284+
await expect.poll(() => statuses).toContain('fail');
285+
await expect.poll(() => errors).toContain('Could not hear test sound.');
243286
});
244287

245288
/** HC-006: "Choose a different device" resets to speaker selection */

0 commit comments

Comments
 (0)