Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/app/components/exercise-steps/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface ExerciseStepsSignature {
Args: {
activeStep: Mode;
visible: boolean;
interactReady?: boolean;
onClick: (key: string) => unknown;
};
Element: HTMLElement;
Expand Down Expand Up @@ -86,7 +87,7 @@ export default class ExerciseStepsComponent extends Component<ExerciseStepsSigna
const base = ExerciseStepsComponent.BASE_BTN;
if (this.modeForTask === BUTTONS.ACTIVE) return `${base} ${ExerciseStepsComponent.STATE_ACTIVE}`;
if (this.modeForTask === BUTTONS.DISABLED) return `${base} ${ExerciseStepsComponent.STATE_LOCKED}`;
if (this.isInteractCompleted) return `${base} ${ExerciseStepsComponent.STATE_NEXT}`;
if (this.isInteractCompleted || this.args.interactReady) return `${base} ${ExerciseStepsComponent.STATE_NEXT}`;
return `${base} ${ExerciseStepsComponent.STATE_DEFAULT}`;
}

Expand Down Expand Up @@ -171,6 +172,7 @@ export default class ExerciseStepsComponent extends Component<ExerciseStepsSigna
type="button"
class={{this.taskBtnClass}}
aria-label={{t "control_exercises.solve"}}
title={{t "control_exercises.solve_hint"}}
disabled={{eq this.modeForTask "disabled"}}
{{on "click" (fn this.onClick this.MODES.TASK this.modeForTask)}}
>
Expand Down
7 changes: 2 additions & 5 deletions frontend/app/components/task-player/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ export default class TaskPlayerComponent extends Component<TaskPlayerSignature>
} 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
Expand Down Expand Up @@ -439,6 +435,7 @@ export default class TaskPlayerComponent extends Component<TaskPlayerSignature>
<ExerciseSteps
@visible={{not this.justEnteredTask}}
@activeStep={{this.mode}}
@interactReady={{this.allOptionsHeard}}
@onClick={{this.onModeChange}}
class="sm:ml-2 flex mb-3 mr-2"
/>
Expand Down
11 changes: 11 additions & 0 deletions frontend/app/controllers/group/series/subgroup/exercise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 34 additions & 3 deletions frontend/app/services/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<template><ExerciseSteps /></template>);

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(
<template>
<ExerciseSteps
@activeStep={{state.step}}
@visible={{true}}
@interactReady={{state.ready}}
/>
</template>,
);

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(
<template>
<ExerciseSteps
@activeStep={{state.step}}
@visible={{true}}
@interactReady={{state.ready}}
/>
</template>,
);

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:<key>` 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(
<template>
<ExerciseSteps @activeStep="interact" @visible={{true}} />
</template>,
);

assert
.dom('button:nth-of-type(3)')
.hasAttribute(
'title',
't:control_exercises.solve_hint',
'solve button binds the hint translation key',
);
});
});
36 changes: 1 addition & 35 deletions frontend/tests/unit/components/task-player/heard-words-test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading