|
|
|
|
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
|
|
|
|
|
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
|
|
|
|
|
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
|
|
|
|
|
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
|
|
|
|
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
|
|
|
|
|
|
|
|
|
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
|
|
|
|
|
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
|
|
|
|
|
@@ -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={companion.selectedAgent?.teacher_id ?? projectId + ':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={companion.selectedAgent?.teacher_id ?? projectId + ':history'} selectedAgent={companion.selectedAgent} catalogState={companion.catalogState} reloadCatalog={companion.reloadCatalog} 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: [] });
|
|
|
|
|
@@ -41,7 +50,171 @@ 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('manual agent refresh', () => {
|
|
|
|
|
it('refreshes while busy and composing, shares one request, and sends through the same agent after publication updates', async () => {
|
|
|
|
|
const original = { teacher_id: 'refresh-first-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-first-method', name: '方法老师', welcome_message: '一起想想办法' } };
|
|
|
|
|
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴', welcome_message: '我们一起试试新办法' } };
|
|
|
|
|
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [original] });
|
|
|
|
|
await mount({ busy: true });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
|
|
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我正在写的问题' } });
|
|
|
|
|
const listCalls = api.list.mock.calls.length;
|
|
|
|
|
const catalogCalls = api.catalog.mock.calls.length;
|
|
|
|
|
let resolve!: (catalog: TeacherCatalog) => void;
|
|
|
|
|
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeDisabled();
|
|
|
|
|
expect(screen.getByText('正在刷新…', { selector: '[role="status"]' })).toBeVisible();
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }));
|
|
|
|
|
expect(api.catalog).toHaveBeenCalledTimes(catalogCalls + 1);
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
|
|
|
|
|
await act(async () => resolve({ items: [updated, added] }));
|
|
|
|
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
|
|
|
|
|
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(updated.definition.welcome_message)).toBeVisible();
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
|
|
|
|
|
expect(api.list).toHaveBeenCalledTimes(listCalls);
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
const created = { ...checkInTopic(), id: 'refresh-first-chat', definition: updated.definition, version: updated.version, requests: [],
|
|
|
|
|
conversation: { agentId: original.teacher_id, segmentTurns: 0, discussions: {} } };
|
|
|
|
|
api.sendConversation.mockResolvedValue(created);
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves the ongoing chat, old turn version and draft across refresh and later removal', async () => {
|
|
|
|
|
const original = { teacher_id: 'refresh-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-method', name: '方法老师' } };
|
|
|
|
|
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴' } };
|
|
|
|
|
const added = { teacher_id: 'refresh-experience', version: 9, is_default: false, definition: { ...definition, config_id: 'refresh-experience', name: '体验朋友' } };
|
|
|
|
|
const history: TeacherTopic = { ...checkInTopic(), version: 1, definition: original.definition,
|
|
|
|
|
requests: [{ ...checkInTopic().requests[0], teacherVersion: 1 }],
|
|
|
|
|
conversation: { agentId: original.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'check-1' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [original] });
|
|
|
|
|
api.conversation.mockImplementation(async (id) => ({ topic: id === original.teacher_id ? history : null, before: null }));
|
|
|
|
|
const view = await mount();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
|
|
|
|
const input = screen.getByLabelText('向智能体提问');
|
|
|
|
|
fireEvent.change(input, { target: { value: '接着刚才说' } });
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [updated, added] });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
|
|
|
|
|
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible();
|
|
|
|
|
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
|
|
|
|
expect(input).toHaveValue('接着刚才说');
|
|
|
|
|
expect(history.requests[0].teacherVersion).toBe(1);
|
|
|
|
|
expect(api.read).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.list).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.sendConversation).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
|
|
|
await act(async () => view.rerender(<Harness projectId="other" />));
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
|
|
|
|
expect(input).toHaveValue('接着刚才说');
|
|
|
|
|
api.sendConversation.mockResolvedValue(history);
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
|
|
|
|
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'other' }));
|
|
|
|
|
fireEvent.change(input, { target: { value: '停用时保留的问题' } });
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [added] });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(input).toHaveValue('停用时保留的问题');
|
|
|
|
|
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
|
|
|
|
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible();
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each(['catalog', 'config'] as const)('keeps the current catalog when %s fails and lets the user retry', async (failedEndpoint) => {
|
|
|
|
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
|
|
|
|
|
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [original] });
|
|
|
|
|
await mount();
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [original, added] });
|
|
|
|
|
api[failedEndpoint].mockRejectedValueOnce(new Error('offline'));
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByText('刷新失败,请重试')).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('方法老师');
|
|
|
|
|
expect(screen.queryByRole('button', { name: '与体验朋友聊天' })).not.toBeInTheDocument();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
|
|
|
|
expect(screen.queryByText('刷新失败,请重试')).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('can fetch the first delivered agent while the previous catalog was unavailable', async () => {
|
|
|
|
|
api.config.mockResolvedValue({ enabled: false, definition: null });
|
|
|
|
|
await mount();
|
|
|
|
|
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [added] });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('体验朋友');
|
|
|
|
|
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps an open project draft in place when the first delivered agent appears', async () => {
|
|
|
|
|
await mount();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
|
|
|
|
const input = screen.getByLabelText('向智能体提问');
|
|
|
|
|
fireEvent.change(input, { target: { value: '还没有选择智能体的问题' } });
|
|
|
|
|
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [added] });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
|
|
|
|
expect(input).toHaveValue('还没有选择智能体的问题');
|
|
|
|
|
expect(screen.queryByText('查看之前的项目草稿')).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(added.definition.name);
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toBe(input);
|
|
|
|
|
expect(input).toHaveValue('还没有选择智能体的问题');
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
|
|
|
|
expect(screen.getByText('查看之前的项目草稿')).toBeVisible();
|
|
|
|
|
expect(localStorage.getItem('makelore-consultation-draft:kid:project:teacher')).toContain('还没有选择智能体的问题');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores a slow refresh from the previous project without changing the new project status', async () => {
|
|
|
|
|
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '旧项目智能体' } };
|
|
|
|
|
const current = { ...original, version: 9, definition: { ...original.definition, name: '最新智能体名称' } };
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [original] });
|
|
|
|
|
const view = await mount();
|
|
|
|
|
let resolve!: (catalog: TeacherCatalog) => void;
|
|
|
|
|
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
|
|
|
|
|
api.catalog.mockResolvedValue({ items: [current] });
|
|
|
|
|
await act(async () => view.rerender(<Harness projectId="other" />));
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
|
|
|
|
|
await act(async () => resolve({ items: [original] }));
|
|
|
|
|
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
|
|
|
|
|
expect(screen.queryByText(original.definition.name)).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText('已更新', { selector: '[role="status"]' })).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText('正在刷新…', { selector: '[role="status"]' })).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
|
|
|
|
|
expect(api.checkIn).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
it('restores the last selected disabled agent as read-only history after a restart', async () => {
|
|
|
|
|
@@ -53,7 +226,7 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
await mount();
|
|
|
|
|
expect(api.conversation).toHaveBeenCalledWith('disabled');
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true})));
|
|
|
|
|
expect(screen.getByRole('heading', {name: '已停用的老师'})).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', {name: '已停用的老师咨询'})).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('button', {name: '提问', exact: true})).toBeDisabled();
|
|
|
|
|
expect(api.sendConversation).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
@@ -77,6 +250,85 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
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('complementary', { 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' } };
|
|
|
|
|
@@ -91,7 +343,7 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
expect(second).toHaveAttribute('src', b.definition.icon_url);
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与另一个智能体聊天' })));
|
|
|
|
|
const images = view.container.querySelectorAll('[data-testid="teacher-chat-panel"] img');
|
|
|
|
|
expect(images).toHaveLength(2);
|
|
|
|
|
expect(images).toHaveLength(1);
|
|
|
|
|
for (const img of images) expect(img).toHaveAttribute('src', b.definition.icon_url);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -106,7 +358,7 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
|
|
|
|
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
|
|
|
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '体验的草稿' } });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
|
|
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
|
|
|
|
|
@@ -138,13 +390,13 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
await mount();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
|
|
|
|
|
expect(api.conversation).toHaveBeenCalledWith('method');
|
|
|
|
|
expect(screen.getByRole('heading', { name: '方法老师' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
|
|
|
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
|
|
|
|
|
expect(api.sendConversation.mock.calls[0][0]).toBe('method');
|
|
|
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
|
|
|
|
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
|
|
|
|
|
expect(api.conversation).toHaveBeenCalledWith('experience');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -160,7 +412,7 @@ describe('teacher presence and periodic guidance', () => {
|
|
|
|
|
await mount();
|
|
|
|
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
|
|
|
|
|
await act(async () => resolve({topic: old, before: null}));
|
|
|
|
|
expect(screen.getByRole('heading', { name: '体验朋友' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
|
|
|
|
|
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
expect(api.conversation).toHaveBeenCalledWith('a');
|
|
|
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
|
|
|
|