feat(chat): show playful teacher introductions on hover
This commit is contained in:
1 parent
b26e25c9ed
commit
772da4f8e4
16 files changed
+433
-62
No files matched your search
@@ -20,8 +20,12 @@ function Harness({ projectId = 'project', sourceId = 'source', busy = false, arc
|
||||
const [open, setOpen] = useState(false);
|
||||
const [teacherComposing, setTeacherComposing] = useState(false);
|
||||
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }} />
|
||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
||||
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
|
||||
onToggle={(id) => {
|
||||
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
|
||||
else { if (id) companion.selectAgent(id); setOpen(true); }
|
||||
}} />
|
||||
{open && <TeacherChatPanel key={projectId + ':' + (companion.selectedAgent?.teacher_id ?? 'history')} selectedAgent={companion.selectedAgent} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
|
||||
}
|
||||
async function mount(props: Parameters<typeof Harness>[0] = {}) {
|
||||
let view!: ReturnType<typeof render>;
|
||||
@@ -33,6 +37,11 @@ async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
});
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ items: [] });
|
||||
@@ -40,9 +49,88 @@ beforeEach(() => {
|
||||
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('teacher presence and periodic guidance', () => {
|
||||
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
|
||||
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
|
||||
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', description: '陪你听听玩家的想法,让作品更有趣。' } };
|
||||
api.catalog.mockResolvedValue({ items: [a, b] });
|
||||
const view = await mount();
|
||||
const method = screen.getByRole('button', { name: '与方法老师聊天' });
|
||||
const experience = screen.getByRole('button', { name: '与体验朋友聊天' });
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
fireEvent.pointerEnter(method, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(a.definition.description);
|
||||
expect(view.container.contains(screen.getByTestId('teacher-introduction'))).toBe(false);
|
||||
fireEvent.pointerLeave(method, { pointerType: 'mouse' });
|
||||
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
|
||||
expect(screen.getByTestId('teacher-introduction')).not.toHaveTextContent(a.definition.description);
|
||||
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
|
||||
expect(experience).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(experience));
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
||||
// The first move may arrive only after the click when crossing a tooltip's
|
||||
// grace area. It must not reopen the introduction beneath the pointer.
|
||||
fireEvent.pointerMove(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
fireEvent.pointerLeave(experience, { pointerType: 'mouse' });
|
||||
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens an introduction on keyboard focus and Escape leaves an unread invitation unacknowledged', async () => {
|
||||
const unread = { ...checkInTopic(), definition: { ...definition, description: '一起发现代码里的小奥秘。' } };
|
||||
api.list.mockResolvedValue({ items: [{ id: unread.id }], lastSelectedTopicId: unread.id });
|
||||
api.read.mockResolvedValue(unread);
|
||||
const view = await mount();
|
||||
const button = screen.getByRole('button', { name: '智能体', exact: true });
|
||||
await act(async () => button.focus());
|
||||
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(unread.definition.description);
|
||||
fireEvent.keyDown(button, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
|
||||
view.unmount();
|
||||
await mount();
|
||||
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.checkIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show an empty introduction and preserves a draft when the same entry closes and reopens the panel', async () => {
|
||||
await mount();
|
||||
const button = screen.getByRole('button', { name: '智能体', exact: true });
|
||||
fireEvent.pointerEnter(button, { pointerType: 'mouse' });
|
||||
await advance(250);
|
||||
await act(async () => button.focus());
|
||||
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(button));
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没想好怎么问' } });
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没想好怎么问');
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
|
||||
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
|
||||
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
|
||||
|
||||
Reference in new issue
Block a user