Skip to content

Commit 0df0909

Browse files
LessUpCopilot
andauthored
refactor: scope dialogue runtime to services (#3)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dbaa645 commit 0df0909

20 files changed

Lines changed: 393 additions & 71 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# 架构深化:对话运行时下沉到服务容器
2+
3+
## 变更摘要
4+
5+
1. **移除应用路径对全局对话单例的依赖**
6+
- `Services` 新增 `dialogue: DialogueOrchestrator`
7+
- 新增 `useDialogue()` Hook
8+
- `useChatStream``useSessionManager``ASRService` 改为使用 Provider 作用域内的对话运行时
9+
10+
2. **修复 `DigitalHumanEngine` 事件接口不一致**
11+
- 非法 expression / emotion / behavior 输入现在统一归一化为 `neutral``idle`
12+
- 事件 payload 与实际写入 store 的值保持一致,避免下游监听者收到不可能状态
13+
14+
3. **收紧 UI store 订阅面**
15+
- `ControlPanel` 改为使用精确 selector
16+
- `DigitalHumanPage` 改为按字段订阅,减少无关状态变化引发的重渲染
17+
18+
4. **补齐回归测试**
19+
- 新增 `src/__tests__/audioService.test.ts`
20+
- 新增 `src/__tests__/ControlPanel.test.tsx`
21+
- 新增 `src/__tests__/useSessionManager.test.tsx`
22+
- 扩展服务层 / Hook / Engine 相关测试
23+
24+
## 影响范围
25+
26+
- `src/core/services*.ts`
27+
- `src/core/audio/audioService.ts`
28+
- `src/core/avatar/DigitalHumanEngine.ts`
29+
- `src/hooks/useChatStream.ts`
30+
- `src/hooks/useSessionManager.ts`
31+
- `src/components/ControlPanel.tsx`
32+
- `src/pages/DigitalHumanPage.tsx`
33+
- `src/__tests__/`
34+
35+
## 测试结果
36+
37+
- TypeScript 类型检查:通过
38+
- ESLint:通过
39+
- Vitest:175 passed
40+
- Build:通过
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Profiler } from 'react';
2+
import { act, render } from '@testing-library/react';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import ControlPanel from '@/components/ControlPanel';
5+
import { useDigitalHumanStore } from '@/store/digitalHumanStore';
6+
import { useSystemStore } from '@/store/systemStore';
7+
8+
describe('ControlPanel', () => {
9+
beforeEach(() => {
10+
useDigitalHumanStore.getState().reset();
11+
useDigitalHumanStore.setState({
12+
isSpeaking: false,
13+
currentBehavior: 'idle',
14+
currentEmotion: 'neutral',
15+
});
16+
useSystemStore.setState({
17+
connectionStatus: 'connected',
18+
isConnected: true,
19+
});
20+
});
21+
22+
it('does not rerender for unrelated digital human store updates', async () => {
23+
const onRender = vi.fn();
24+
25+
render(
26+
<Profiler id="control-panel" onRender={onRender}>
27+
<ControlPanel
28+
isPlaying={false}
29+
isRecording={false}
30+
isMuted={false}
31+
autoRotate={false}
32+
onPlayPause={vi.fn()}
33+
onReset={vi.fn()}
34+
onToggleRecording={vi.fn()}
35+
onToggleMute={vi.fn()}
36+
onToggleAutoRotate={vi.fn()}
37+
onVoiceCommand={vi.fn()}
38+
/>
39+
</Profiler>,
40+
);
41+
42+
expect(onRender).toHaveBeenCalledTimes(1);
43+
44+
await act(async () => {
45+
useDigitalHumanStore.getState().setEmotion('happy');
46+
});
47+
48+
expect(onRender).toHaveBeenCalledTimes(1);
49+
});
50+
});

src/__tests__/ServicesProvider.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,43 @@ describe('ServicesProvider', () => {
4444
expect(resetMock).toHaveBeenCalledTimes(1);
4545
expect(disposeMock).toHaveBeenCalledTimes(3);
4646
});
47+
48+
it('exposes provider-owned dialogue runtime through service hooks', async () => {
49+
const dialogue = {
50+
abortPendingTurn: vi.fn(),
51+
isTurnPending: vi.fn(() => false),
52+
reset: resetMock,
53+
runDialogueTurn: vi.fn(),
54+
runDialogueTurnStream: vi.fn(),
55+
};
56+
57+
createServicesMock.mockReturnValue({
58+
engine: {
59+
dispose: disposeMock,
60+
},
61+
tts: {
62+
dispose: disposeMock,
63+
},
64+
asr: {
65+
dispose: disposeMock,
66+
},
67+
dialogue,
68+
});
69+
70+
const { ServicesProvider, useDialogue } = await import('@/core/services');
71+
const captured = { current: null as unknown };
72+
73+
function Consumer() {
74+
captured.current = useDialogue();
75+
return null;
76+
}
77+
78+
render(
79+
<ServicesProvider>
80+
<Consumer />
81+
</ServicesProvider>,
82+
);
83+
84+
expect(captured.current).toBe(dialogue);
85+
});
4786
});

src/__tests__/audioService.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
const moduleRunDialogueTurnMock = vi.fn((_: string, _options: unknown) => {
4+
throw new Error('ASRService should use injected dialogue runtime');
5+
});
6+
7+
vi.mock('@/core/dialogue/dialogueOrchestrator', () => ({
8+
runDialogueTurn: (text: string, options: unknown) => moduleRunDialogueTurnMock(text, options),
9+
}));
10+
11+
import { ASRService } from '@/core/audio/audioService';
12+
13+
describe('ASRService dialogue runtime', () => {
14+
it('routes backend dialogue through injected runtime', async () => {
15+
const dialogue = {
16+
runDialogueTurn: vi.fn().mockResolvedValue(undefined),
17+
};
18+
const state = {
19+
setRecording: vi.fn(),
20+
setBehavior: vi.fn(),
21+
setSpeaking: vi.fn(),
22+
setError: vi.fn(),
23+
setEmotion: vi.fn(),
24+
setExpression: vi.fn(),
25+
setAnimation: vi.fn(),
26+
play: vi.fn(),
27+
pause: vi.fn(),
28+
reset: vi.fn(),
29+
setMuted: vi.fn(),
30+
isMuted: false,
31+
sessionId: 'session_test',
32+
currentBehavior: 'idle',
33+
addChatMessage: vi.fn(),
34+
};
35+
const tts = {
36+
speak: vi.fn().mockResolvedValue(undefined),
37+
};
38+
39+
const TestableASRService = ASRService as unknown as new (...args: any[]) => ASRService;
40+
const asr = new TestableASRService({}, state, tts, dialogue);
41+
42+
await (
43+
asr as unknown as { sendToDialogueService(text: string): Promise<void> }
44+
).sendToDialogueService('你好');
45+
46+
expect(dialogue.runDialogueTurn).toHaveBeenCalledWith(
47+
'你好',
48+
expect.objectContaining({
49+
sessionId: 'session_test',
50+
isMuted: false,
51+
}),
52+
);
53+
expect(moduleRunDialogueTurnMock).not.toHaveBeenCalled();
54+
});
55+
});

src/__tests__/digitalHuman.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,9 @@ describe('ASRService', () => {
441441
let asrService: ASRService;
442442
let localTts: TTSService;
443443
let mockSpeechRecognition: any;
444+
const mockDialogue = {
445+
runDialogueTurn: vi.fn(),
446+
};
444447

445448
beforeEach(() => {
446449
// Create a proper constructor function for SpeechRecognition
@@ -472,6 +475,7 @@ describe('ASRService', () => {
472475
let mockState: any;
473476

474477
beforeEach(() => {
478+
mockDialogue.runDialogueTurn.mockReset();
475479
mockState = {
476480
setRecording: vi.fn(),
477481
setBehavior: vi.fn(),
@@ -498,19 +502,19 @@ describe('ASRService', () => {
498502
});
499503

500504
it('initializes correctly when supported', () => {
501-
asrService = new ASRService({}, mockState, localTts);
505+
asrService = new ASRService({}, mockState, localTts, mockDialogue);
502506
expect(asrService).toBeDefined();
503507
});
504508

505509
it('starts recognition', () => {
506-
asrService = new ASRService({}, mockState, localTts);
510+
asrService = new ASRService({}, mockState, localTts, mockDialogue);
507511
asrService.start();
508512
// Since we can't directly access the mock, we verify the service is created
509513
expect(asrService).toBeDefined();
510514
});
511515

512516
it('stops recognition', () => {
513-
asrService = new ASRService({}, mockState, localTts);
517+
asrService = new ASRService({}, mockState, localTts, mockDialogue);
514518
asrService.stop();
515519
// Verify no errors are thrown
516520
expect(asrService).toBeDefined();

src/__tests__/digitalHumanEngine.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ describe('DigitalHumanEngine', () => {
165165
expect(handler).toHaveBeenCalledWith({ type: 'expression:change', value: 'smile' });
166166
});
167167

168+
it('emits normalized expression:change event for invalid expressions', () => {
169+
const handler = vi.fn();
170+
engine.on('expression:change', handler);
171+
172+
engine.setExpression('invalid_expr');
173+
174+
expect(handler).toHaveBeenCalledWith({ type: 'expression:change', value: 'neutral' });
175+
});
176+
168177
it('emits emotion:change event', () => {
169178
const handler = vi.fn();
170179
engine.on('emotion:change', handler);
@@ -173,6 +182,15 @@ describe('DigitalHumanEngine', () => {
173182
expect(handler).toHaveBeenCalledWith({ type: 'emotion:change', value: 'happy' });
174183
});
175184

185+
it('emits normalized emotion:change event for invalid emotions', () => {
186+
const handler = vi.fn();
187+
engine.on('emotion:change', handler);
188+
189+
engine.setEmotion('confused');
190+
191+
expect(handler).toHaveBeenCalledWith({ type: 'emotion:change', value: 'neutral' });
192+
});
193+
176194
it('emits behavior:change event', () => {
177195
const handler = vi.fn();
178196
engine.on('behavior:change', handler);
@@ -181,6 +199,15 @@ describe('DigitalHumanEngine', () => {
181199
expect(handler).toHaveBeenCalledWith({ type: 'behavior:change', value: 'thinking' });
182200
});
183201

202+
it('emits normalized behavior:change event for invalid behaviors', () => {
203+
const handler = vi.fn();
204+
engine.on('behavior:change', handler);
205+
206+
engine.setBehavior('flying');
207+
208+
expect(handler).toHaveBeenCalledWith({ type: 'behavior:change', value: 'idle' });
209+
});
210+
184211
it('emits animation:start and animation:end events', () => {
185212
vi.useFakeTimers();
186213
const startHandler = vi.fn();

src/__tests__/useAdvancedDigitalHumanController.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
1111
reconnectMock: vi.fn(),
1212
asrStartMock: vi.fn(),
1313
asrStopMock: vi.fn(),
14+
dialogueAbortPendingTurnMock: vi.fn(),
1415
asrPerformGreetingMock: vi.fn(),
1516
asrPerformDanceMock: vi.fn(),
1617
clearRemoteSessionMock: vi.fn(),
@@ -73,6 +74,9 @@ vi.mock('../core/services', () => ({
7374
performGreeting: mocks.asrPerformGreetingMock,
7475
performDance: mocks.asrPerformDanceMock,
7576
}),
77+
useDialogue: () => ({
78+
abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
79+
}),
7680
useTTS: () => ({
7781
speak: vi.fn(),
7882
}),
@@ -92,6 +96,9 @@ vi.mock('../core/services', () => ({
9296
performGreeting: mocks.asrPerformGreetingMock,
9397
performDance: mocks.asrPerformDanceMock,
9498
},
99+
dialogue: {
100+
abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
101+
},
95102
tts: {},
96103
}),
97104
}));
@@ -115,6 +122,7 @@ describe('useAdvancedDigitalHumanController', () => {
115122
mocks.reconnectMock.mockReset();
116123
mocks.asrStartMock.mockReset();
117124
mocks.asrStopMock.mockReset();
125+
mocks.dialogueAbortPendingTurnMock.mockReset();
118126
mocks.asrPerformGreetingMock.mockReset();
119127
mocks.asrPerformDanceMock.mockReset();
120128
mocks.clearRemoteSessionMock.mockReset();

src/__tests__/useChatStream.test.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,35 @@ import { useSystemStore } from '../store/systemStore';
77

88
const runDialogueTurnStreamMock = vi.fn();
99
const abortPendingTurnMock = vi.fn();
10+
const moduleRunDialogueTurnStreamMock = vi.fn((_: string, _options: unknown) => {
11+
throw new Error('useChatStream should use useDialogue().runDialogueTurnStream');
12+
});
13+
const moduleAbortPendingTurnMock = vi.fn(() => {
14+
throw new Error('useChatStream should use useDialogue().abortPendingTurn');
15+
});
1016

1117
vi.mock('@/core/services', () => ({
1218
useTTS: () => ({ speak: vi.fn() }),
1319
useEngine: () => ({ setBehavior: vi.fn() }),
20+
useDialogue: () => ({
21+
abortPendingTurn: () => abortPendingTurnMock(),
22+
runDialogueTurnStream: (text: string, options: unknown) =>
23+
runDialogueTurnStreamMock(text, options),
24+
}),
1425
}));
1526

1627
vi.mock('../core/dialogue/dialogueOrchestrator', () => ({
17-
runDialogueTurnStream: (...args: unknown[]) => runDialogueTurnStreamMock(...args),
18-
abortPendingTurn: () => abortPendingTurnMock(),
28+
runDialogueTurnStream: (text: string, options: unknown) =>
29+
moduleRunDialogueTurnStreamMock(text, options),
30+
abortPendingTurn: () => moduleAbortPendingTurnMock(),
1931
}));
2032

2133
describe('useChatStream', () => {
2234
beforeEach(() => {
2335
runDialogueTurnStreamMock.mockReset();
2436
abortPendingTurnMock.mockReset();
37+
moduleRunDialogueTurnStreamMock.mockClear();
38+
moduleAbortPendingTurnMock.mockClear();
2539
useDigitalHumanStore.setState({
2640
currentBehavior: 'idle',
2741
});

0 commit comments

Comments
 (0)