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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StrictMode } from 'react';
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { Pressable, Text, View } from 'react-native';

Expand Down Expand Up @@ -113,6 +114,21 @@ describe('useFreeChatSession', () => {
expect(controller.interrupt).toHaveBeenCalledTimes(1);
});

it('does not end and restart the same controller during StrictMode effect replay', async () => {
const controller = createController();
const screen = await render(
<StrictMode>
<SessionProbe createController={() => controller} />
</StrictMode>,
);

await waitFor(() => expect(controller.start).toHaveBeenCalledTimes(1));
expect(controller.end).not.toHaveBeenCalled();

screen.unmount();
await waitFor(() => expect(controller.end).toHaveBeenCalledTimes(1));
});

it('exposes startup errors and performs idempotent cleanup after ending', async () => {
const controller = createController();
controller.start.mockRejectedValue(new Error('麦克风权限被拒绝'));
Expand Down
16 changes: 14 additions & 2 deletions frontend/mobile/src/features/conversation/useFreeChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ export function useFreeChatSession(
);
const [startupError, setStartupError] = useState<string | null>(null);
const [elapsed, setElapsed] = useState(0);
const startPromise = useRef<Promise<unknown> | null>(null);
const endPromise = useRef<Promise<unknown> | null>(null);
const lifecycleVersion = useRef(0);

const end = useCallback(() => {
if (!endPromise.current) {
Expand All @@ -97,8 +99,12 @@ export function useFreeChatSession(
useEffect(() => controller.subscribe(setSnapshot), [controller]);

useEffect(() => {
lifecycleVersion.current += 1;
let active = true;
void controller.start().catch((error: unknown) => {
if (!startPromise.current) {
startPromise.current = Promise.resolve().then(() => controller.start());
}
void startPromise.current.catch((error: unknown) => {
if (active) {
setStartupError(
error instanceof Error ? error.message : '实时对话启动失败',
Expand All @@ -107,7 +113,13 @@ export function useFreeChatSession(
});
return () => {
active = false;
void end().catch(() => undefined);
lifecycleVersion.current += 1;
const cleanupVersion = lifecycleVersion.current;
queueMicrotask(() => {
if (lifecycleVersion.current === cleanupVersion) {
void end().catch(() => undefined);
}
});
};
}, [controller, end]);

Expand Down
37 changes: 23 additions & 14 deletions frontend/mobile/src/features/scenes/SceneTrainingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export class SceneTrainingController {
}

async next() {
if (this.snapshot.status === 'loading' || this.snapshot.status === 'scoring') {
return false;
}
const scene = this.requireScene();
if (this.snapshot.stage === 'speak') return false;
if (this.snapshot.index < this.snapshot.items.length - 1) {
Expand Down Expand Up @@ -139,21 +142,27 @@ export class SceneTrainingController {
}

if (!this.snapshot.readingResult?.passed) return false;
const flow = await this.service.advanceStage(
scene.sceneId,
'SENTENCE_LEARNING',
);
if (flow.stage !== 'DIALOGUE') {
throw new Error('后端未进入场景对话阶段');
this.update({ ...this.snapshot, status: 'loading', error: null });
try {
const flow = await this.service.advanceStage(
scene.sceneId,
'SENTENCE_LEARNING',
);
if (flow.stage !== 'DIALOGUE') {
throw new Error('后端未进入场景对话阶段');
}
this.update({
...this.snapshot,
status: 'ready',
stage: 'speak',
unlockedStage: 2,
readingResult: null,
});
return true;
} catch (error) {
this.fail(error);
throw error;
}
this.update({
...this.snapshot,
status: 'ready',
stage: 'speak',
unlockedStage: 2,
readingResult: null,
});
return true;
}

previous() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,41 @@ describe('SceneTrainingController', () => {
]);
});

it('ignores a duplicate next action while a stage transition is pending', async () => {
const service = createService();
const controller = new SceneTrainingController(service);
await controller.start(scene);

let resolveAdvance!: (flow: {
sceneId: string;
stage: SceneFlowStage;
completed: boolean;
}) => void;
service.advanceStage.mockImplementationOnce(
() => new Promise((resolve) => {
resolveAdvance = resolve;
}),
);

const first = controller.next();
await expect(controller.next()).resolves.toBe(false);
expect(service.advanceStage).toHaveBeenCalledTimes(1);

resolveAdvance({
sceneId: 'scene-1',
stage: 'PHRASE_LEARNING',
completed: false,
});
await expect(first).resolves.toBe(true);
expect(controller.getSnapshot()).toEqual(
expect.objectContaining({
status: 'ready',
learningGroup: 'phrases',
currentItem: phrase,
}),
);
});

it('scores the current WAV and unlocks dialogue only after a passing result', async () => {
const service = createService();
const controller = new SceneTrainingController(service);
Expand Down
16 changes: 11 additions & 5 deletions frontend/mobile/src/screens/ScenesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ export function Training({ id, scene, trainingController: injectedTrainingContro
: readPassed;
const isLastReadItem = displayedReadIndex >= displayedReadItems.length - 1;
const readingResult = trainingSnapshot?.readingResult;
const trainingTransitioning = trainingSnapshot?.status === 'loading';
const completionMetrics = sceneMetricsForReport(dialogueCompletion?.evaluation);

const toggleDemo = async (text: string) => {
Expand Down Expand Up @@ -442,7 +443,7 @@ export function Training({ id, scene, trainingController: injectedTrainingContro
demoActive.current = false;
setDemoPlaying(false);
if (scene && trainingController) {
void trainingController.next();
void trainingController.next().catch(() => undefined);
return;
}
if (learnIndex < learnItems.length - 1) setLearnIndex((current) => current + 1);
Expand Down Expand Up @@ -591,7 +592,12 @@ export function Training({ id, scene, trainingController: injectedTrainingContro
>
<AppIcon name="arrow-left" size={20} />
</Pressable>
<Pressable accessibilityRole="button" onPress={nextLearn} style={styles.primaryPillButton}>
<Pressable
accessibilityRole="button"
disabled={trainingTransitioning}
onPress={nextLearn}
style={[styles.primaryPillButton, trainingTransitioning && styles.primaryPillDisabled]}
>
<Text style={styles.primaryPillText}>
{displayedIndex < learnItems.length - 1 ? '下一个' : displayedLearningGroup === 'words' ? '进入词组' : '进入朗读'}
</Text>
Expand Down Expand Up @@ -630,14 +636,14 @@ export function Training({ id, scene, trainingController: injectedTrainingContro
</Pressable>
<Pressable
accessibilityRole="button"
disabled={!displayedReadPassed}
disabled={!displayedReadPassed || trainingTransitioning}
onPress={() => {
if (scene && trainingController) void trainingController.next();
if (scene && trainingController) void trainingController.next().catch(() => undefined);
else { setUnlockedStage(2); setStage('speak'); }
setRecording(false);
setDemoPlaying(false);
}}
style={[styles.primaryPillButton, !displayedReadPassed && styles.primaryPillDisabled]}
style={[styles.primaryPillButton, (!displayedReadPassed || trainingTransitioning) && styles.primaryPillDisabled]}
>
<Text style={styles.primaryPillText}>{isLastReadItem ? '进入模拟' : '下一句'}</Text>
<AppIcon name="arrow-right" size={18} color={colors.white} />
Expand Down
Loading