From 7eb22a6e1e6bc794edb09efa4c92dc842dbd9487 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 Apr 2026 19:50:51 +0000 Subject: [PATCH 1/6] Remove auto-transition from Interact to Solve step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses item 4 of issue #2885: users reported the implicit jump from Repeat (Interact) into Solve (Task) was confusing and hid the moment when the step actually changed. The Listen → Interact auto-transition is kept. Solve is now entered only when the user clicks the Solve button; it lights up in the "next" style as soon as every option has been heard, and a title tooltip hints that the step can be started when ready. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/exercise-steps/index.gts | 4 +++- frontend/app/components/task-player/index.gts | 7 ++----- .../components/task-player/heard-words-test.js | 18 +++++++----------- frontend/translations/en-us.yaml | 1 + frontend/translations/ru-ru.yaml | 1 + 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/frontend/app/components/exercise-steps/index.gts b/frontend/app/components/exercise-steps/index.gts index 3bf5a6087..f8966a2f5 100644 --- a/frontend/app/components/exercise-steps/index.gts +++ b/frontend/app/components/exercise-steps/index.gts @@ -22,6 +22,7 @@ interface ExerciseStepsSignature { Args: { activeStep: Mode; visible: boolean; + interactReady?: boolean; onClick: (key: string) => unknown; }; Element: HTMLElement; @@ -86,7 +87,7 @@ export default class ExerciseStepsComponent extends Component diff --git a/frontend/app/components/task-player/index.gts b/frontend/app/components/task-player/index.gts index 555cbbbf9..84bd04780 100644 --- a/frontend/app/components/task-player/index.gts +++ b/frontend/app/components/task-player/index.gts @@ -126,11 +126,7 @@ export default class TaskPlayerComponent extends Component } catch (_e) { // Interact was interrupted } - try { - await this.setMode(MODES.TASK); - } catch (_e) { - // Task mode interrupted - } + // Solve (TASK) is entered manually — users found the auto-jump abrupt. }); @action @@ -439,6 +435,7 @@ export default class TaskPlayerComponent extends Component diff --git a/frontend/tests/unit/components/task-player/heard-words-test.js b/frontend/tests/unit/components/task-player/heard-words-test.js index 3a02443a6..a58eeae86 100644 --- a/frontend/tests/unit/components/task-player/heard-words-test.js +++ b/frontend/tests/unit/components/task-player/heard-words-test.js @@ -182,9 +182,10 @@ module('Unit | Component | task-player | interactModeTask heardWords accumulatio }); }); -module('Unit | Component | task-player | exerciseSequenceTask with auto-transition', function () { - // Test that the exercise sequence flows through listen -> interact -> task, - // and that interactModeTask returns (completing interact) when allOptionsHeard is true. +module('Unit | Component | task-player | exerciseSequenceTask auto-transition', function () { + // The auto-sequence stops after INTERACT — users opt into TASK (Solve) + // manually, so the body of exerciseSequenceTask only calls setMode for + // LISTEN and INTERACT. async function runExerciseSequence(setMode) { try { @@ -197,23 +198,18 @@ module('Unit | Component | task-player | exerciseSequenceTask with auto-transiti } catch (_e) { // Interact was interrupted } - try { - await setMode(MODES.TASK); - } catch (_e) { - // Task mode interrupted - } } - test('transitions through listen -> interact -> task in full sequence', async function (assert) { + test('transitions through listen -> interact and stops before task', async function (assert) { const calls = []; await runExerciseSequence(async (mode) => { calls.push(mode); }); - assert.strictEqual(calls.length, 3, 'setMode called three times'); + assert.strictEqual(calls.length, 2, 'setMode called twice'); assert.strictEqual(calls[0], MODES.LISTEN, 'first call is LISTEN'); assert.strictEqual(calls[1], MODES.INTERACT, 'second call is INTERACT'); - assert.strictEqual(calls[2], MODES.TASK, 'third call is TASK'); + assert.false(calls.includes(MODES.TASK), 'TASK is not auto-entered'); }); test('allOptionsHeard check causes loop exit after all options played (simulated logic)', async function (assert) { diff --git a/frontend/translations/en-us.yaml b/frontend/translations/en-us.yaml index de6ebe1bd..beef70d02 100644 --- a/frontend/translations/en-us.yaml +++ b/frontend/translations/en-us.yaml @@ -112,6 +112,7 @@ service_message: control_exercises: solve: Solve + solve_hint: Click when you are ready to start solving interact: Interact listen: Listen diff --git a/frontend/translations/ru-ru.yaml b/frontend/translations/ru-ru.yaml index dcb8e3213..d572d7212 100644 --- a/frontend/translations/ru-ru.yaml +++ b/frontend/translations/ru-ru.yaml @@ -112,6 +112,7 @@ service_message: control_exercises: solve: Решать + solve_hint: Нажмите, когда будете готовы перейти к решению interact: Повторить listen: Слушать From c4c5b432670f848a5f76a6904b3ed2c45d1faddc Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 18 Apr 2026 00:06:22 +0000 Subject: [PATCH 2/6] Fix silent words and freeze in exercise audio playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the audio bug in issue #2885: users reported that after 3+ words, audio "freezes" until the mouse moves, and individual words can go silent. Root cause: the playback loop used a wall-clock setTimeout to wait for each word to finish. Browsers (Safari, and Chrome under timer throttling) suspend idle AudioContexts. When that happens, source.start(0) queues playback but never produces sound until a user gesture resumes the context — yet the wall-clock timer keeps firing, so the loop advances right over the silent clips. Fix: - Resume a suspended AudioContext before starting each native source, so queued playback does not pile up. - Await the AudioBufferSourceNode onended event (with duration+1s as a safety fallback) instead of trusting setTimeout to match real playback duration. The Tone-based path (signal exercises, no onended) still uses timeout; this path was never reported as affected. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/app/services/audio.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/app/services/audio.ts b/frontend/app/services/audio.ts index 45e0bdef5..6bd9f7c21 100644 --- a/frontend/app/services/audio.ts +++ b/frontend/app/services/audio.ts @@ -422,10 +422,31 @@ export default class AudioService extends Service { index++; if (item) { if (item.source.buffer) { + // Browsers (Safari, and Chrome under throttling) suspend idle + // AudioContexts. source.start(0) on a suspended context queues + // playback instead of playing it — without this resume, later + // words fall silent until a user gesture wakes the context. + if (this.context.state === 'suspended' && !isTesting()) { + await this.context.resume(); + } const duration = toMilliseconds(item.source.buffer.duration); + // Prefer onended over wall-clock timeout: setTimeout keeps + // ticking when the context suspends mid-clip, so a timer-only + // loop would advance over silent words instead of waiting + // for real playback to finish. + const rawSource = item.source as unknown; + const ended = rawSource instanceof AudioBufferSourceNode + ? new Promise((resolve) => { + rawSource.onended = () => resolve(); + }) + : null; item.source.start(0); startedSources.push(item); - await timeout(duration); + if (ended) { + await Promise.race([ended, timeout(duration + 1000)]); + } else { + await timeout(duration); + } } else { console.error('there is no buffer for source'); } From 7776ff4c88994276251370c842bb74bc1d564387 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 18 Apr 2026 00:08:54 +0000 Subject: [PATCH 3/6] Persist exercise completion across navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the "fix update page after task done" item of issue #2885: after completing an exercise, the green check disappeared if the user navigated away and came back. Root cause: enableNextExercise only mutated isManuallyCompleted on the in-memory record. tasksManager.completedExerciseIds was the durable source used by the subgroup's exerciseAvailabilityCalculationTask, but it was populated solely from loadTodayCompletedExercises on app boot and login — never when the user finished an exercise. Fix: add the just-completed exercise's id to completedExerciseIds as part of enableNextExercise. A fresh Set is assigned so @tracked picks up the change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/controllers/group/series/subgroup/exercise.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/app/controllers/group/series/subgroup/exercise.ts b/frontend/app/controllers/group/series/subgroup/exercise.ts index 5d6b7bed2..6d02e0022 100644 --- a/frontend/app/controllers/group/series/subgroup/exercise.ts +++ b/frontend/app/controllers/group/series/subgroup/exercise.ts @@ -94,6 +94,17 @@ export default class GroupSeriesSubgroupExerciseController extends Controller { const nextIndex = index + 1; model.isManuallyCompleted = true; + // Persist the completion in tasksManager so the subgroup view still + // shows this exercise as completed after navigating away and back — + // the server history is only re-read on login/app start, so without + // this the green check disappears on the next visit. + if (model.id != null) { + this.tasksManager.completedExerciseIds = new Set([ + ...this.tasksManager.completedExerciseIds, + String(model.id), + ]); + } + if (children[nextIndex]) { children[nextIndex].available = true; } From b8588a15d629d7c2263bac07fb121459962112ed Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 18 Apr 2026 00:14:03 +0000 Subject: [PATCH 4/6] Add tests for exercise UX fixes Tests cover the three behavior changes in the previous commits: - enableNextExercise: marks current completed, enables next sibling, adds id to tasksManager.completedExerciseIds (new Set, coerced to string), preserves prior ids, skips update when id is null. - playTask: awaits the source onended event for AudioBufferSourceNode (so it advances in real time, not on wall-clock); falls back to duration+1s timeout when onended never fires. - ExerciseSteps: Solve button gets the "next" style when @interactReady is true during Interact, stays default otherwise, exposes the ready-when-you-are hint via title attribute. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../exercise-steps/component-test.gjs | 55 ++++++- .../group/series/subgroup/exercise-test.js | 141 +++++++++++++++++- frontend/tests/unit/services/audio-test.js | 88 +++++++++++ 3 files changed, 279 insertions(+), 5 deletions(-) diff --git a/frontend/tests/integration/components/exercise-steps/component-test.gjs b/frontend/tests/integration/components/exercise-steps/component-test.gjs index 9c6a8ae01..a6b13b62f 100644 --- a/frontend/tests/integration/components/exercise-steps/component-test.gjs +++ b/frontend/tests/integration/components/exercise-steps/component-test.gjs @@ -8,12 +8,59 @@ module('Integration | Component | exercise-steps', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - + test('it renders three step buttons', async function (assert) { await render(); assert.dom('button').exists({ count: 3 }); }); + + test('solve button gets the "next" style when interactReady is true while in Interact', async function (assert) { + await render( + , + ); + + assert.dom('button:nth-of-type(3)').hasClass( + 'exercise-step-btn--next', + 'solve button lights up as next step', + ); + }); + + test('solve button stays default when interactReady is false during Interact', async function (assert) { + await render( + , + ); + + assert.dom('button:nth-of-type(3)').doesNotHaveClass( + 'exercise-step-btn--next', + 'solve button is not highlighted yet', + ); + }); + + test('solve button exposes a hint via title attribute', async function (assert) { + await render( + , + ); + + assert + .dom('button:nth-of-type(3)') + .hasAttribute( + 'title', + 'Click when you are ready to start solving', + 'solve button shows the ready-when-you-are hint', + ); + }); }); diff --git a/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js b/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js index f93adc093..18d624594 100644 --- a/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js +++ b/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js @@ -1,14 +1,153 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; module('Unit | Controller | group/series/subgroup/exercise', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup( 'controller:group/series/subgroup/exercise', ); assert.ok(controller); }); + + module('enableNextExercise', function (innerHooks) { + innerHooks.beforeEach(function () { + class MockTasksManager extends Service { + @tracked completedExerciseIds = new Set(); + } + this.owner.register('service:tasks-manager', MockTasksManager); + }); + + function makeModel(id, siblings = []) { + const model = { id, isManuallyCompleted: false }; + const exercises = [model, ...siblings]; + model.parent = { exercises }; + return model; + } + + test('marks current exercise as manually completed', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const model = makeModel('10'); + controller.model = model; + + controller.enableNextExercise(model); + + assert.true( + model.isManuallyCompleted, + 'current model is marked manually completed', + ); + }); + + test('enables the next sibling exercise', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const next = { id: '11', available: false }; + const model = makeModel('10', [next]); + controller.model = model; + + controller.enableNextExercise(model); + + assert.true(next.available, 'next sibling is marked available'); + }); + + test('adds completed exercise id to tasksManager.completedExerciseIds', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const model = makeModel('42'); + controller.model = model; + + assert.false( + tasksManager.completedExerciseIds.has('42'), + 'not present before call', + ); + + controller.enableNextExercise(model); + + assert.true( + tasksManager.completedExerciseIds.has('42'), + 'id is added after enableNextExercise', + ); + }); + + test('assigns a fresh Set so @tracked reactivity fires', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const before = tasksManager.completedExerciseIds; + const model = makeModel('7'); + controller.model = model; + + controller.enableNextExercise(model); + + assert.notStrictEqual( + tasksManager.completedExerciseIds, + before, + 'completedExerciseIds is a new Set instance', + ); + }); + + test('preserves previously completed exercise ids', function (assert) { + class MockTasksManagerWithHistory extends Service { + @tracked completedExerciseIds = new Set(['1', '2']); + } + this.owner.register('service:tasks-manager', MockTasksManagerWithHistory); + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const model = makeModel('3'); + controller.model = model; + + controller.enableNextExercise(model); + + assert.deepEqual( + [...tasksManager.completedExerciseIds].sort(), + ['1', '2', '3'], + 'earlier ids are kept alongside the new one', + ); + }); + + test('coerces numeric ids to strings for consistency with server lookup', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const model = makeModel(42); + controller.model = model; + + controller.enableNextExercise(model); + + assert.true( + tasksManager.completedExerciseIds.has('42'), + 'stored as string even when id was a number', + ); + }); + + test('skips tasksManager update when model.id is null', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const before = tasksManager.completedExerciseIds; + const model = makeModel(null); + controller.model = model; + + controller.enableNextExercise(model); + + assert.strictEqual( + tasksManager.completedExerciseIds, + before, + 'completedExerciseIds untouched for null id', + ); + }); + }); }); diff --git a/frontend/tests/unit/services/audio-test.js b/frontend/tests/unit/services/audio-test.js index 0c5c7635f..628d55aa7 100644 --- a/frontend/tests/unit/services/audio-test.js +++ b/frontend/tests/unit/services/audio-test.js @@ -181,4 +181,92 @@ module('Unit | Service | audio', function (hooks) { assert.strictEqual(ctx.state, 'closed', 'context is closed after destroy'); }); + + module('playTask onended handling', function () { + test('awaits the source onended event for AudioBufferSourceNode', async function (assert) { + const service = this.owner.lookup('service:audio'); + const AudioContextCtor = + window.AudioContext || window.webkitAudioContext; + if (!AudioContextCtor) { + assert.ok(true, 'no AudioContext in this env — skipping'); + return; + } + + const ctx = new AudioContextCtor(); + service.context = ctx; + + // A long silent buffer — wall-clock timeout would wait ~5s here. + const buffer = ctx.createBuffer(1, ctx.sampleRate * 5, ctx.sampleRate); + service.buffers = [buffer]; + + const t0 = Date.now(); + const taskInstance = service.playTask.perform(); + + // Stop the source early; this fires onended and should let the + // loop advance immediately instead of waiting out the 5s duration. + await new Promise((r) => setTimeout(r, 20)); + const source = service.sources[0]?.source; + if (source) { + source.stop(0); + } + + await taskInstance; + const elapsed = Date.now() - t0; + + assert.ok(source instanceof AudioBufferSourceNode, 'native source is used'); + assert.true( + elapsed < 2000, + `playTask returns on onended, not wall-clock (${elapsed}ms)`, + ); + assert.false(service.isPlaying, 'isPlaying is reset after completion'); + }); + + test('falls back to timeout when onended never fires (safety net)', async function (assert) { + const service = this.owner.lookup('service:audio'); + const AudioContextCtor = + window.AudioContext || window.webkitAudioContext; + if (!AudioContextCtor) { + assert.ok(true, 'no AudioContext in this env — skipping'); + return; + } + + const ctx = new AudioContextCtor(); + service.context = ctx; + + // Very short buffer (50ms). If we never manually trigger onended and + // the native playback doesn't end (stub), the duration+1s timeout + // fallback should release us inside ~1.1s. + const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate); + service.buffers = [buffer]; + + // Patch createBufferSource to return a source that never ends so we + // exercise the fallback path specifically. + const originalCreate = ctx.createBufferSource.bind(ctx); + ctx.createBufferSource = () => { + const s = originalCreate(); + // swallow the onended assignment so the promise never resolves + Object.defineProperty(s, 'onended', { + configurable: true, + set() {}, + get() { + return null; + }, + }); + return s; + }; + + const t0 = Date.now(); + await service.playTask.perform(); + const elapsed = Date.now() - t0; + + assert.true( + elapsed >= 1000, + `fallback timeout waited at least duration+1s (${elapsed}ms)`, + ); + assert.true( + elapsed < 3000, + `fallback timeout did not hang (${elapsed}ms)`, + ); + }); + }); }); From d6a32646c00f573bf87725c353c3d4b30b047b92 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 18 Apr 2026 00:21:08 +0000 Subject: [PATCH 5/6] Address code review on exercise UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audio.ts: wrap source.start(0) in try/catch so a synchronous throw (closed context, re-started source) bails out of the word-loop instead of stranding it in the duration+1s safety net. - audio-test.js: replace real-AudioContext probes with prototype-injected fake sources to avoid CI flake on headless Chrome suspended contexts. Tighten fallback lower bound from >=1000ms to >=1050ms so the extra second of margin is actually pinned. Add a test for the sync-start error path. - heard-words-test.js: remove the tautological exerciseSequenceTask module — it re-defined and asserted the same fixture; the real body is already covered in component-test.js:272-347. - exercise-test.js: combine null and undefined into one id-guard test so `!= null` semantics are pinned, not the accidental choice of one literal. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/app/services/audio.ts | 16 +- .../task-player/heard-words-test.js | 32 +--- .../group/series/subgroup/exercise-test.js | 22 ++- frontend/tests/unit/services/audio-test.js | 140 ++++++++++-------- 4 files changed, 108 insertions(+), 102 deletions(-) diff --git a/frontend/app/services/audio.ts b/frontend/app/services/audio.ts index 6bd9f7c21..13b8f08d6 100644 --- a/frontend/app/services/audio.ts +++ b/frontend/app/services/audio.ts @@ -440,9 +440,19 @@ export default class AudioService extends Service { rawSource.onended = () => resolve(); }) : null; - item.source.start(0); - startedSources.push(item); - if (ended) { + let startFailed = false; + try { + item.source.start(0); + startedSources.push(item); + } catch (e) { + // A sync throw (closed context, source already started, etc.) + // would otherwise strand the loop in the safety-net timeout. + startFailed = true; + console.error('source.start failed', e); + } + if (startFailed) { + // nothing playing — move on immediately + } else if (ended) { await Promise.race([ended, timeout(duration + 1000)]); } else { await timeout(duration); diff --git a/frontend/tests/unit/components/task-player/heard-words-test.js b/frontend/tests/unit/components/task-player/heard-words-test.js index a58eeae86..7c275469c 100644 --- a/frontend/tests/unit/components/task-player/heard-words-test.js +++ b/frontend/tests/unit/components/task-player/heard-words-test.js @@ -1,5 +1,4 @@ import { module, test } from 'qunit'; -import { MODES } from 'brn/utils/task-modes'; module('Unit | Component | task-player | heardWords tracking', function () { // Test the heardWords and allOptionsHeard logic by replicating the @@ -182,36 +181,7 @@ module('Unit | Component | task-player | interactModeTask heardWords accumulatio }); }); -module('Unit | Component | task-player | exerciseSequenceTask auto-transition', function () { - // The auto-sequence stops after INTERACT — users opt into TASK (Solve) - // manually, so the body of exerciseSequenceTask only calls setMode for - // LISTEN and INTERACT. - - async function runExerciseSequence(setMode) { - try { - await setMode(MODES.LISTEN); - } catch (_e) { - return; - } - try { - await setMode(MODES.INTERACT); - } catch (_e) { - // Interact was interrupted - } - } - - test('transitions through listen -> interact and stops before task', async function (assert) { - const calls = []; - await runExerciseSequence(async (mode) => { - calls.push(mode); - }); - - assert.strictEqual(calls.length, 2, 'setMode called twice'); - assert.strictEqual(calls[0], MODES.LISTEN, 'first call is LISTEN'); - assert.strictEqual(calls[1], MODES.INTERACT, 'second call is INTERACT'); - assert.false(calls.includes(MODES.TASK), 'TASK is not auto-entered'); - }); - +module('Unit | Component | task-player | allOptionsHeard break condition', function () { test('allOptionsHeard check causes loop exit after all options played (simulated logic)', async function (assert) { // Simulate the interactModeTask loop logic (sync) to verify the break condition let heardWords = new Set(); diff --git a/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js b/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js index 18d624594..b40898f31 100644 --- a/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js +++ b/frontend/tests/unit/controllers/group/series/subgroup/exercise-test.js @@ -132,22 +132,30 @@ module('Unit | Controller | group/series/subgroup/exercise', function (hooks) { ); }); - test('skips tasksManager update when model.id is null', function (assert) { + test('skips tasksManager update when model.id is null or undefined', function (assert) { const controller = this.owner.lookup( 'controller:group/series/subgroup/exercise', ); const tasksManager = this.owner.lookup('service:tasks-manager'); - const before = tasksManager.completedExerciseIds; - const model = makeModel(null); - controller.model = model; - - controller.enableNextExercise(model); + const initial = tasksManager.completedExerciseIds; + const nullModel = makeModel(null); + controller.model = nullModel; + controller.enableNextExercise(nullModel); assert.strictEqual( tasksManager.completedExerciseIds, - before, + initial, 'completedExerciseIds untouched for null id', ); + + const undefinedModel = makeModel(undefined); + controller.model = undefinedModel; + controller.enableNextExercise(undefinedModel); + assert.strictEqual( + tasksManager.completedExerciseIds, + initial, + 'completedExerciseIds untouched for undefined id', + ); }); }); }); diff --git a/frontend/tests/unit/services/audio-test.js b/frontend/tests/unit/services/audio-test.js index 628d55aa7..e37965f43 100644 --- a/frontend/tests/unit/services/audio-test.js +++ b/frontend/tests/unit/services/audio-test.js @@ -183,89 +183,107 @@ module('Unit | Service | audio', function (hooks) { }); module('playTask onended handling', function () { - test('awaits the source onended event for AudioBufferSourceNode', async function (assert) { - const service = this.owner.lookup('service:audio'); - const AudioContextCtor = - window.AudioContext || window.webkitAudioContext; - if (!AudioContextCtor) { - assert.ok(true, 'no AudioContext in this env — skipping'); - return; - } + // Build a plain object that satisfies `rawSource instanceof + // AudioBufferSourceNode` via prototype injection. Avoids real + // AudioContext, which in headless CI is created in the suspended + // state and makes stop()/onended timing unreliable. + function fakeNativeSource(durationSec, options = {}) { + const { autoEndAfterMs = null, suppressOnended = false } = options; + const source = { + buffer: { duration: durationSec }, + _onended: null, + get onended() { + return this._onended; + }, + set onended(fn) { + if (suppressOnended) return; + this._onended = fn; + }, + start() { + if (autoEndAfterMs !== null) { + setTimeout(() => this._onended && this._onended(), autoEndAfterMs); + } + }, + stop() { + if (this._onended) this._onended(); + }, + }; + Object.setPrototypeOf(source, AudioBufferSourceNode.prototype); + return source; + } - const ctx = new AudioContextCtor(); - service.context = ctx; + function stubContext(service) { + service.context = { + state: 'running', + resume: () => Promise.resolve(), + close: () => Promise.resolve(), + }; + } - // A long silent buffer — wall-clock timeout would wait ~5s here. - const buffer = ctx.createBuffer(1, ctx.sampleRate * 5, ctx.sampleRate); - service.buffers = [buffer]; + test('awaits onended and advances on the event, not a wall-clock timer', async function (assert) { + const service = this.owner.lookup('service:audio'); + stubContext(service); + // 5-second nominal duration, onended fires after 50ms. A timer-only + // loop would have waited ~5s; onended should release it in ~50ms. + const source = fakeNativeSource(5, { autoEndAfterMs: 50 }); + service.createSources = async () => [{ source, gainNode: {} }]; + service.buffers = [{}]; const t0 = Date.now(); - const taskInstance = service.playTask.perform(); - - // Stop the source early; this fires onended and should let the - // loop advance immediately instead of waiting out the 5s duration. - await new Promise((r) => setTimeout(r, 20)); - const source = service.sources[0]?.source; - if (source) { - source.stop(0); - } - - await taskInstance; + await service.playTask.perform(); const elapsed = Date.now() - t0; - assert.ok(source instanceof AudioBufferSourceNode, 'native source is used'); + assert.ok(source instanceof AudioBufferSourceNode, 'prototype satisfies instanceof'); assert.true( elapsed < 2000, - `playTask returns on onended, not wall-clock (${elapsed}ms)`, + `advanced on onended at ~50ms, not wall-clock 5s (${elapsed}ms)`, ); assert.false(service.isPlaying, 'isPlaying is reset after completion'); }); - test('falls back to timeout when onended never fires (safety net)', async function (assert) { + test('falls back to duration + 1s when onended never fires', async function (assert) { const service = this.owner.lookup('service:audio'); - const AudioContextCtor = - window.AudioContext || window.webkitAudioContext; - if (!AudioContextCtor) { - assert.ok(true, 'no AudioContext in this env — skipping'); - return; - } - - const ctx = new AudioContextCtor(); - service.context = ctx; - - // Very short buffer (50ms). If we never manually trigger onended and - // the native playback doesn't end (stub), the duration+1s timeout - // fallback should release us inside ~1.1s. - const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate); - service.buffers = [buffer]; - - // Patch createBufferSource to return a source that never ends so we - // exercise the fallback path specifically. - const originalCreate = ctx.createBufferSource.bind(ctx); - ctx.createBufferSource = () => { - const s = originalCreate(); - // swallow the onended assignment so the promise never resolves - Object.defineProperty(s, 'onended', { - configurable: true, - set() {}, - get() { - return null; - }, - }); - return s; - }; + stubContext(service); + // 50ms duration, onended assignment is swallowed → safety net must + // release after duration + 1000ms. Assert against the full 1050 so + // shrinking the margin in the future is caught. + const source = fakeNativeSource(0.05, { suppressOnended: true }); + service.createSources = async () => [{ source, gainNode: {} }]; + service.buffers = [{}]; const t0 = Date.now(); await service.playTask.perform(); const elapsed = Date.now() - t0; assert.true( - elapsed >= 1000, - `fallback timeout waited at least duration+1s (${elapsed}ms)`, + elapsed >= 1050, + `safety net waited at least duration + 1s (${elapsed}ms)`, ); assert.true( elapsed < 3000, - `fallback timeout did not hang (${elapsed}ms)`, + `safety net did not hang (${elapsed}ms)`, + ); + }); + + test('swallowed synchronous source.start error does not strand the loop', async function (assert) { + const service = this.owner.lookup('service:audio'); + stubContext(service); + const source = fakeNativeSource(5); + source.start = () => { + throw new Error('closed context'); + }; + service.createSources = async () => [{ source, gainNode: {} }]; + service.buffers = [{}]; + + const t0 = Date.now(); + await service.playTask.perform(); + const elapsed = Date.now() - t0; + + // If the start-failure branch were absent, the loop would hang on + // the safety-net timeout (≥1050ms). It should exit nearly instantly. + assert.true( + elapsed < 500, + `loop bails out on sync start throw (${elapsed}ms)`, ); }); }); From ae0d611a5956751636a63f5bb6aab03a79803b1f Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 18 Apr 2026 00:28:27 +0000 Subject: [PATCH 6/6] Fix exercise-steps integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solve-NEXT test: walk through LISTEN first via a @tracked state so setLastMode populates `modes` with both LISTEN and INTERACT. Without this, modeForTask stays DISABLED and taskBtnClass returns STATE_LOCKED regardless of interactReady. - Title test: match the repo's existing ember-intl test pattern — setupIntl in this env returns the `t:` placeholder when translations aren't loaded (see doctor-feedback/component-test.gjs). Verified locally: tests 50-53 in the exercise-steps module all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../exercise-steps/component-test.gjs | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/frontend/tests/integration/components/exercise-steps/component-test.gjs b/frontend/tests/integration/components/exercise-steps/component-test.gjs index a6b13b62f..191dbf4a4 100644 --- a/frontend/tests/integration/components/exercise-steps/component-test.gjs +++ b/frontend/tests/integration/components/exercise-steps/component-test.gjs @@ -1,9 +1,25 @@ import { module, test } from 'qunit'; import { setupIntl } from 'ember-intl/test-support'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, settled } from '@ember/test-helpers'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { tracked } from '@glimmer/tracking'; import ExerciseSteps from 'brn/components/exercise-steps'; +// Helper: walk through LISTEN → INTERACT so `setLastMode` populates +// the component's internal `modes` array with both entries. Without +// this, modeForTask stays DISABLED regardless of interactReady and +// STATE_LOCKED wins in taskBtnClass. +class StepState { + @tracked step = 'listen'; + @tracked ready = false; +} + +async function walkToInteract(state) { + state.step = 'interact'; + await settled(); +} + module('Integration | Component | exercise-steps', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); @@ -14,41 +30,53 @@ module('Integration | Component | exercise-steps', function (hooks) { assert.dom('button').exists({ count: 3 }); }); - test('solve button gets the "next" style when interactReady is true while in Interact', async function (assert) { + test('solve button gets the "next" style once interactReady is true during Interact', async function (assert) { + const state = new StepState(); await render( , ); - assert.dom('button:nth-of-type(3)').hasClass( - 'exercise-step-btn--next', - 'solve button lights up as next step', - ); + await walkToInteract(state); + state.ready = true; + await settled(); + + assert + .dom('button:nth-of-type(3)') + .hasClass('exercise-step-btn--next', 'solve button lights up as next step'); }); test('solve button stays default when interactReady is false during Interact', async function (assert) { + const state = new StepState(); await render( , ); - assert.dom('button:nth-of-type(3)').doesNotHaveClass( - 'exercise-step-btn--next', - 'solve button is not highlighted yet', - ); + await walkToInteract(state); + + assert + .dom('button:nth-of-type(3)') + .doesNotHaveClass( + 'exercise-step-btn--next', + 'solve button is not highlighted yet', + ); }); - test('solve button exposes a hint via title attribute', async function (assert) { + test('solve button exposes the ready-when-you-are hint via title', async function (assert) { + // ember-intl in this test env returns the `t:` placeholder when + // the translation is not loaded (see doctor-feedback/component-test.gjs + // for the same pattern). We just verify the hint key is wired up. await render(