feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒
2026-09-23 18:23:53 +08:00
parent 12d800ec51
commit 7951cca700
60 changed files with 3313 additions and 180 deletions

View File

@@ -205,6 +205,57 @@ describe('CodingChatPanel first Conversation', () => {
vi.resetModules();
});
it('continues a failed operation using its history without sending or clearing the current draft', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const failed = createLocalConversationSnapshot(project.id, conversation);
failed.run = { status: 'error', terminalReason: 'failed' };
failed.nodes = [{ kind: 'message', id: 'failed', role: 'assistant', status: 'error', blocks: [] }];
conversationApi.snapshot.mockResolvedValue(failed);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit.mockImplementation(async (input) => ({ accepted: true, conversationId: conversation.id, clientRequestId: input.clientRequestId, runId: 'resume-run', mode: 'prompt' }));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
const composer = within(await screen.findByTestId('coding-message-composer')).getByRole('textbox');
fireEvent.change(composer, { target: { value: '这段还没有写完,不要发送' } });
fireEvent.click(await screen.findByRole('button', { name: '帮我继续' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(1));
expect(conversationApi.recover).toHaveBeenCalledWith(conversation.id);
expect(conversationApi.submit).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('已完成的步骤不要重复执行'), attachments: [] }));
expect(composer).toHaveValue('这段还没有写完,不要发送');
});
it.each([true, false])('only resumes the matching login account and project (same=%s)', async (same) => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { useAuthStore } = await import('@/stores/auth');
const { codingRecoveryAccount } = await import('@/lib/coding-login-recovery');
const user = { username: 'alice', userId: 'a1', tenantId: 't1', deptId: null, authorities: [] };
useAuthStore.setState({ user });
const workApi = await import('@/lib/coding-work-preview');
const ensure = vi.spyOn(workApi, 'ensureWorkPreview').mockResolvedValue({ status: 'ready' });
const onLoginRecoveryConsumed = vi.fn();
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel loginRecovery={{ id: 'return-1', projectId: project.id, conversationId: conversation.id, work: true, account: same ? codingRecoveryAccount(user) : 'another-account' }} onLoginRecoveryConsumed={onLoginRecoveryConsumed} />);
await screen.findByTestId('coding-message-composer');
if (same) {
await waitFor(() => expect(ensure).toHaveBeenCalledTimes(1));
expect(screen.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
expect(onLoginRecoveryConsumed).toHaveBeenCalledTimes(1);
} else {
expect(screen.getByRole('tab', { name: '操作对话' })).toHaveAttribute('aria-selected', 'true');
expect(ensure).not.toHaveBeenCalled();
}
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('keeps the operation draft when switching work tabs and reopening separate teacher and friend drafts', async () => {
localStorage.clear();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
@@ -247,9 +298,16 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(screen.getByRole('button', { name: '老师', exact: true }));
const teacherInput = await screen.findByRole('textbox', { name: '向老师提问' });
expect(workTab).toHaveAttribute('aria-selected', 'true');
expect(operationInput).not.toBeVisible();
expect(operationInput).toHaveValue('我想先自己试一试');
await waitFor(() => expect(teacherInput).toBeEnabled());
fireEvent.change(teacherInput, { target: { value: '怎样判断规则清不清楚?' } });
fireEvent.click(chatTab);
expect(operationInput).toBeVisible();
expect(teacherInput).toHaveValue('怎样判断规则清不清楚?');
fireEvent.click(screen.getByRole('button', { name: '朋友', exact: true }));
expect(chatTab).toHaveAttribute('aria-selected', 'true');
const friendInput = await screen.findByRole('textbox', { name: '向朋友提问' });
await waitFor(() => expect(friendInput).toBeEnabled());
expect(friendInput).toHaveValue('');
@@ -259,6 +317,7 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(screen.getByRole('button', { name: '朋友', exact: true }));
expect(screen.getByRole('textbox', { name: '向朋友提问' })).toHaveValue('第一次玩的感受是什么?');
fireEvent.click(screen.getByRole('button', { name: '老师', exact: true }));
expect(workTab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('textbox', { name: '向老师提问' })).toHaveValue('怎样判断规则清不清楚?');
expect(screen.getByRole('button', { name: '老师', exact: true })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: '朋友', exact: true })).toHaveAttribute('aria-expanded', 'false');
@@ -1297,6 +1356,7 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled());
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument();
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
@@ -1321,7 +1381,7 @@ describe('CodingChatPanel first Conversation', () => {
}));
});
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
it('keeps preparation feedback and preserves Enter versus Shift+Enter behavior', async () => {
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
const onSubmit = vi.fn();
render(
@@ -1336,7 +1396,6 @@ describe('CodingChatPanel first Conversation', () => {
queue={{ items: [] }}
error={null}
recoverableError={false}
acceptedCount={1}
attachments={[]}
submitting={false}
placeholder="Message"
@@ -1349,7 +1408,8 @@ describe('CodingChatPanel first Conversation', () => {
/>,
);
expect(screen.getByText('1 条消息已被本地 Agent 接收。')).toBeInTheDocument();
expect(screen.getByText('正在准备本地 Agent,输入框仍可编辑。')).toBeInTheDocument();
expect(screen.queryByText(/条消息已被本地 Agent 接收/)).not.toBeInTheDocument();
const textbox = screen.getByRole('textbox');
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();