Skip to content

Commit e91ff7c

Browse files
committed
feat(window-recognition): 自动选中三规则与记忆课程名回填
1 parent 369aaa1 commit e91ff7c

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* 窗口监听 hook 测试:自动选中三规则与课程名回填
3+
* @ai-context: mock window.electronAPI;仅验证自动选中评估逻辑。
4+
*
5+
* 时序说明:挂载时 refreshWindows 会异步 setWindows([]),若与测试触发的
6+
* 窗口更新同处一个 act 微任务队列,后落定者会覆盖前者。故 invoke mock 按
7+
* channel 区分返回值,且 setup 内先 flush 挂载期异步更新,再触发事件。
8+
*/
9+
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
import { renderHook, act, waitFor } from '@testing-library/react';
11+
import { useWindowWatcher } from './useWindowWatcher';
12+
13+
const invokeMock = vi.fn();
14+
// 显式声明参数类型:channel + 回调,使 mock.calls 可安全索引
15+
const onMock = vi.fn((_channel: string, _cb: (...args: unknown[]) => void) => () => {});
16+
const notifyMock = vi.fn();
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
(window as unknown as { electronAPI: unknown }).electronAPI = {
21+
invoke: invokeMock,
22+
on: onMock,
23+
};
24+
// screen_list_windows 返回空列表;其余 channel(start/stop/memory_record)返回 undefined
25+
invokeMock.mockImplementation((channel: string) =>
26+
Promise.resolve(channel === 'screen_list_windows' ? [] : undefined),
27+
);
28+
});
29+
30+
function makeWindow(id: string, overrides: Record<string, unknown> = {}) {
31+
return { id, title: `win-${id}`, score: 0, ...overrides };
32+
}
33+
34+
async function setup() {
35+
const hook = renderHook(() =>
36+
useWindowWatcher({
37+
courseMeta: { courseName: undefined as string | undefined },
38+
setCourseMeta: vi.fn(),
39+
onNotify: notifyMock,
40+
}),
41+
);
42+
// 冲刷挂载期 refreshWindows 的异步 setWindows([]),避免其覆盖后续事件触发的窗口更新
43+
await act(async () => {});
44+
return hook;
45+
}
46+
47+
describe('useWindowWatcher — 自动选中', () => {
48+
it('high 置信度 top1 自动选中并 toast', async () => {
49+
const { result } = await setup();
50+
const high = makeWindow('w1', { score: 150, confidence: 'high', processName: 'wemeet.exe', reasons: ['学习意图'] });
51+
52+
await act(async () => {
53+
onMock.mock.calls.forEach(([channel, cb]) => {
54+
if (channel === 'screen_windows_changed') cb(null, [high, makeWindow('w2', { score: 20 })]);
55+
});
56+
});
57+
58+
await waitFor(() => {
59+
expect(result.current.selectedWindow?.id).toBe('w1');
60+
});
61+
expect(notifyMock).toHaveBeenCalledWith('success', expect.stringContaining('w1'));
62+
});
63+
64+
it('前台窗口 + 唯一候选自动选中(无记忆)', async () => {
65+
const { result } = await setup();
66+
const fg = makeWindow('w1', { score: 90, confidence: 'medium', isForeground: true, learningScore: 80, processName: 'chrome.exe' });
67+
68+
await act(async () => {
69+
onMock.mock.calls.forEach(([, cb]) => cb(null, [fg, makeWindow('w2', { score: 10, learningScore: 5 })]));
70+
});
71+
72+
await waitFor(() => {
73+
expect(result.current.selectedWindow?.id).toBe('w1');
74+
});
75+
});
76+
77+
it('medium 且无记忆且非唯一候选 → 不自动选中', async () => {
78+
const { result } = await setup();
79+
const med = makeWindow('w1', { score: 80, confidence: 'medium', learningScore: 75 });
80+
const med2 = makeWindow('w2', { score: 75, confidence: 'medium', learningScore: 70 });
81+
82+
await act(async () => {
83+
onMock.mock.calls.forEach(([, cb]) => cb(null, [med, med2]));
84+
});
85+
86+
await waitFor(() => {
87+
expect(result.current.selectedWindow).toBeNull();
88+
});
89+
});
90+
91+
it('已有选中时不覆盖(不重复 toast)', async () => {
92+
const { result } = await setup();
93+
const high = makeWindow('w1', { score: 150, confidence: 'high' });
94+
95+
await act(async () => {
96+
onMock.mock.calls.forEach(([, cb]) => cb(null, [high]));
97+
});
98+
await act(async () => {
99+
result.current.setSelectedWindow(makeWindow('w2', { score: 5 }));
100+
});
101+
102+
await act(async () => {
103+
onMock.mock.calls.forEach(([, cb]) => cb(null, [high, makeWindow('w3', { score: 5 })]));
104+
});
105+
106+
expect(result.current.selectedWindow?.id).toBe('w2');
107+
});
108+
109+
it('记忆课程名回填 courseName(detectedBy=memory)', async () => {
110+
const setCourseMeta = vi.fn();
111+
const hook = renderHook(() =>
112+
useWindowWatcher({
113+
courseMeta: { courseName: undefined as string | undefined },
114+
setCourseMeta,
115+
onNotify: notifyMock,
116+
}),
117+
);
118+
await act(async () => {});
119+
const mem = makeWindow('w1', { score: 150, confidence: 'high', memoryCourseName: '高等数学' });
120+
121+
await act(async () => {
122+
onMock.mock.calls.forEach(([, cb]) => cb(null, [mem]));
123+
});
124+
125+
await waitFor(() => {
126+
// setCourseMeta 以函数式更新调用(与正则提取 effect 一致,避免闭包过期)
127+
expect(setCourseMeta).toHaveBeenCalledTimes(1);
128+
});
129+
const updater = setCourseMeta.mock.calls[0][0] as (prev: { courseName?: string }) => Record<string, unknown>;
130+
expect(updater({ courseName: undefined })).toEqual(
131+
expect.objectContaining({ courseName: '高等数学', detectedBy: 'memory' }),
132+
);
133+
hook.unmount();
134+
});
135+
});

‎client/src/features/classroom/hooks/useWindowWatcher.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,45 @@ export function useWindowWatcher({ courseMeta, setCourseMeta, onNotify }: UseWin
101101
};
102102
}, [onNotify]);
103103

104+
// 自动选中评估(三规则):仅在未选中窗口时评估,避免覆盖用户选择与重复 toast
105+
useEffect(() => {
106+
if (selectedWindow || windows.length === 0) return;
107+
const top = windows[0];
108+
if (!top) return;
109+
110+
// 唯一视频类候选:learningScore >= 60 的窗口仅此一个
111+
const uniqueCandidates = windows.filter((w) => (w.learningScore ?? 0) >= 60);
112+
const isUnique = uniqueCandidates.length === 1 && uniqueCandidates[0].id === top.id;
113+
114+
const rule1 = top.confidence === 'high';
115+
const rule2 =
116+
!!top.isForeground &&
117+
(top.score ?? 0) >= 60 &&
118+
(!!top.memoryCourseName || isUnique);
119+
const rule3 =
120+
top.confidence === 'medium' &&
121+
!!top.memoryCourseName &&
122+
isUnique;
123+
124+
if (!(rule1 || rule2 || rule3)) return;
125+
126+
setSelectedWindow(top);
127+
onNotify('success', `已自动选择:${top.title}`);
128+
129+
// 记录记忆(自动选中同样累加 useCount——用户接受默认即正反馈)
130+
if (top.processName) {
131+
window.electronAPI?.invoke('window_memory_record', {
132+
processName: top.processName,
133+
title: top.title,
134+
courseName: courseMeta.courseName,
135+
}).catch(() => {});
136+
}
137+
138+
// 记忆课程名回填(优先级最高;无记忆时由 AI 首帧/正则兜底)
139+
if (top.memoryCourseName && !courseMeta.courseName) {
140+
setCourseMeta((prev) => ({ ...prev, courseName: top.memoryCourseName, detectedBy: 'memory' }));
141+
}
142+
}, [windows, selectedWindow, courseMeta.courseName, onNotify, setCourseMeta]); // eslint-disable-line react-hooks/exhaustive-deps
143+
104144
return { windows, windowsLoading, selectedWindow, setSelectedWindow, refreshWindows };
105145
}

0 commit comments

Comments
 (0)