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/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; } diff --git a/frontend/app/services/audio.ts b/frontend/app/services/audio.ts index 45e0bdef5..13b8f08d6 100644 --- a/frontend/app/services/audio.ts +++ b/frontend/app/services/audio.ts @@ -422,10 +422,41 @@ 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); - item.source.start(0); - startedSources.push(item); - await timeout(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; + 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); + } } else { console.error('there is no buffer for source'); } diff --git a/frontend/tests/integration/components/exercise-steps/component-test.gjs b/frontend/tests/integration/components/exercise-steps/component-test.gjs index 9c6a8ae01..191dbf4a4 100644 --- a/frontend/tests/integration/components/exercise-steps/component-test.gjs +++ b/frontend/tests/integration/components/exercise-steps/component-test.gjs @@ -1,19 +1,94 @@ 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'); - 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 once interactReady is true during Interact', async function (assert) { + const state = new StepState(); + await render( + , + ); + + 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( + , + ); + + 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 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( + , + ); + + assert + .dom('button:nth-of-type(3)') + .hasAttribute( + 'title', + 't:control_exercises.solve_hint', + 'solve button binds the hint translation key', + ); + }); }); 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..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,40 +181,7 @@ 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. - - async function runExerciseSequence(setMode) { - try { - await setMode(MODES.LISTEN); - } catch (_e) { - return; - } - try { - await setMode(MODES.INTERACT); - } 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) { - const calls = []; - await runExerciseSequence(async (mode) => { - calls.push(mode); - }); - - assert.strictEqual(calls.length, 3, 'setMode called three times'); - 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'); - }); - +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 f93adc093..b40898f31 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,161 @@ 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 or undefined', function (assert) { + const controller = this.owner.lookup( + 'controller:group/series/subgroup/exercise', + ); + const tasksManager = this.owner.lookup('service:tasks-manager'); + const initial = tasksManager.completedExerciseIds; + + const nullModel = makeModel(null); + controller.model = nullModel; + controller.enableNextExercise(nullModel); + assert.strictEqual( + tasksManager.completedExerciseIds, + 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 0c5c7635f..e37965f43 100644 --- a/frontend/tests/unit/services/audio-test.js +++ b/frontend/tests/unit/services/audio-test.js @@ -181,4 +181,110 @@ module('Unit | Service | audio', function (hooks) { assert.strictEqual(ctx.state, 'closed', 'context is closed after destroy'); }); + + module('playTask onended handling', function () { + // 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; + } + + function stubContext(service) { + service.context = { + state: 'running', + resume: () => Promise.resolve(), + close: () => Promise.resolve(), + }; + } + + 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(); + await service.playTask.perform(); + const elapsed = Date.now() - t0; + + assert.ok(source instanceof AudioBufferSourceNode, 'prototype satisfies instanceof'); + assert.true( + elapsed < 2000, + `advanced on onended at ~50ms, not wall-clock 5s (${elapsed}ms)`, + ); + assert.false(service.isPlaying, 'isPlaying is reset after completion'); + }); + + test('falls back to duration + 1s when onended never fires', async function (assert) { + const service = this.owner.lookup('service:audio'); + 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 >= 1050, + `safety net waited at least duration + 1s (${elapsed}ms)`, + ); + assert.true( + elapsed < 3000, + `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)`, + ); + }); + }); }); 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: Слушать