Integrate project classroom with teacher and friend consultations
This commit is contained in:
@@ -53,6 +53,11 @@ function body(init?: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function latestPresentation() {
|
||||
const request = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/present').at(-1);
|
||||
return request ? body(request[1] as RequestInit) : null;
|
||||
}
|
||||
|
||||
function Harness({ initialOpen = false }: { initialOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
return (
|
||||
@@ -164,4 +169,71 @@ describe('AgentBrowserPanel', () => {
|
||||
([path]) => String(path).startsWith('/api/agent-browser/state?'),
|
||||
)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('hides an embedded work page between tabs and reopens it without destroying or navigating it', async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const view = render(<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ project_id: 'project-a', visible: true }));
|
||||
expect(screen.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
|
||||
|
||||
view.rerender(<AgentBrowserPanel projectId="project-a" open={false} embedded onOpenChange={onOpenChange} />);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false);
|
||||
|
||||
view.rerender(<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(screen.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
|
||||
expect(screen.getByRole('button', { name: '刷新网页' })).toBeEnabled();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => ['/api/agent-browser/close', '/api/agent-browser/open', '/api/agent-browser/navigate'].includes(String(path)))).toBe(false);
|
||||
|
||||
view.unmount();
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(['absolute', 'fixed'])('hides the native work page under a %s consultation and restores it beside a relative sidebar', async (position) => {
|
||||
const onOpenChange = vi.fn();
|
||||
const content = (dockPosition: string) => <>
|
||||
<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />
|
||||
<aside id="coding-consultation-dock" style={{ position: dockPosition as 'relative' | 'absolute' | 'fixed' }}>老师咨询</aside>
|
||||
</>;
|
||||
const view = render(content('relative'));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
|
||||
view.rerender(content(position));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
expect(screen.getByText('老师咨询')).toBeVisible();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false);
|
||||
|
||||
view.rerender(content('relative'));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false);
|
||||
});
|
||||
|
||||
it('rechecks consultation occlusion after a responsive resize without needing a DOM change', async () => {
|
||||
const originalGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
let consultationPosition = 'relative';
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudoElement) => {
|
||||
const computed = originalGetComputedStyle(element, pseudoElement);
|
||||
if (element.id !== 'coding-consultation-dock') return computed;
|
||||
// jsdom does not evaluate responsive media queries; simulate their computed position.
|
||||
return new Proxy(computed, {
|
||||
get: (target, property) => property === 'position' ? consultationPosition : Reflect.get(target, property),
|
||||
});
|
||||
});
|
||||
render(<>
|
||||
<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={vi.fn()} />
|
||||
<aside id="coding-consultation-dock">朋友咨询</aside>
|
||||
</>);
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
|
||||
consultationPosition = 'absolute';
|
||||
act(() => window.dispatchEvent(new Event('resize')));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
|
||||
consultationPosition = 'relative';
|
||||
act(() => window.dispatchEvent(new Event('resize')));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,70 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
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 });
|
||||
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 { teacherApi } = await import('@/lib/coding-teacher');
|
||||
vi.spyOn(teacherApi, 'config').mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: null });
|
||||
vi.spyOn(teacherApi, 'list').mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
const consultationSend = vi.spyOn(teacherApi, 'send');
|
||||
const consultationCreate = vi.spyOn(teacherApi, 'create');
|
||||
const browserApi = await import('@/lib/agent-browser');
|
||||
const closedBrowser = {
|
||||
browserId: null, projectId: project.id, projectPath: null, state: 'closed' as const,
|
||||
generation: 0, url: '', title: '', visible: false, bounds: null,
|
||||
canGoBack: false, canGoForward: false, eventCursor: 0,
|
||||
};
|
||||
vi.spyOn(browserApi, 'getAgentBrowserState').mockResolvedValue(closedBrowser);
|
||||
vi.spyOn(browserApi, 'closeAgentBrowser').mockResolvedValue(closedBrowser);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
render(<CodingChatPanel />);
|
||||
const composer = await screen.findByTestId('coding-message-composer');
|
||||
const operationInput = within(composer).getByRole('textbox');
|
||||
fireEvent.change(operationInput, { target: { value: '我想先自己试一试' } });
|
||||
|
||||
const chatTab = screen.getByRole('tab', { name: '操作对话' });
|
||||
const workTab = screen.getByRole('tab', { name: '作品', exact: true });
|
||||
expect(chatTab).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.keyDown(chatTab, { key: 'ArrowRight' });
|
||||
expect(workTab).toHaveFocus();
|
||||
expect(workTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tabpanel', { name: '作品', exact: true })).toBeVisible();
|
||||
expect(operationInput).not.toBeVisible();
|
||||
fireEvent.keyDown(workTab, { key: 'ArrowLeft' });
|
||||
expect(chatTab).toHaveFocus();
|
||||
expect(operationInput).toBeVisible();
|
||||
expect(operationInput).toHaveValue('我想先自己试一试');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '老师', exact: true }));
|
||||
const teacherInput = await screen.findByRole('textbox', { name: '向老师提问' });
|
||||
await waitFor(() => expect(teacherInput).toBeEnabled());
|
||||
fireEvent.change(teacherInput, { target: { value: '怎样判断规则清不清楚?' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '朋友', exact: true }));
|
||||
const friendInput = await screen.findByRole('textbox', { name: '向朋友提问' });
|
||||
await waitFor(() => expect(friendInput).toBeEnabled());
|
||||
expect(friendInput).toHaveValue('');
|
||||
fireEvent.change(friendInput, { target: { value: '第一次玩的感受是什么?' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭朋友' }));
|
||||
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '朋友', exact: true }));
|
||||
expect(screen.getByRole('textbox', { name: '向朋友提问' })).toHaveValue('第一次玩的感受是什么?');
|
||||
fireEvent.click(screen.getByRole('button', { name: '老师', exact: 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');
|
||||
expect(operationInput).toHaveValue('我想先自己试一试');
|
||||
expect(screen.queryByText(/记一下|项目共识|记入共识/)).not.toBeInTheDocument();
|
||||
expect(consultationCreate).not.toHaveBeenCalled();
|
||||
expect(consultationSend).not.toHaveBeenCalled();
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('archives the last conversation without creating another, then restores the same running conversation', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
|
||||
@@ -15,7 +15,7 @@ const api = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('@/lib/coding-teacher', () => ({
|
||||
teacherApi: api,
|
||||
teacherTopicsPath: (p: string, s: string) => p + '/' + s,
|
||||
teacherTopicsPath: (p: string, s: string, role?: string) => p + '/' + (role ?? s),
|
||||
}));
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1,
|
||||
@@ -50,6 +50,7 @@ const first = topic('first'),
|
||||
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
localStorage.clear();
|
||||
streams = new Map();
|
||||
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
|
||||
api.list.mockResolvedValue({
|
||||
@@ -142,4 +143,113 @@ describe('teacher side chat', () => {
|
||||
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
||||
expect(api.read).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['teacher', '老师', '我还没想好下一步做什么', '提问'],
|
||||
['friend', '朋友', '想听听你对作品的第一印象', '发送给朋友'],
|
||||
] as const)('only sends a %s suggestion after the student submits it', async (role, label, suggestion, sendLabel) => {
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.send.mockImplementation(async (_base, _id, input) => ({ ...first, requests: [{
|
||||
id: input.requestId, text: input.text, references: [], createdAt: 'now',
|
||||
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
|
||||
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '一起再想一想。',
|
||||
}] }));
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role={role} />);
|
||||
const sendButton = screen.getByRole('button', { name: sendLabel, exact: true });
|
||||
await waitFor(() => expect(screen.getByLabelText(`向${label}提问`)).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: suggestion }));
|
||||
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue(suggestion);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(sendButton);
|
||||
await screen.findByText('一起再想一想。');
|
||||
expect(api.config).toHaveBeenCalledWith(role);
|
||||
expect(api.send).toHaveBeenCalledWith(`p/${role}`, 'first', expect.objectContaining({
|
||||
text: suggestion, sourceConversationId: 'c', references: [],
|
||||
}));
|
||||
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
|
||||
view.unmount();
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role={role} />);
|
||||
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('restores a closed draft and keeps teacher, friend and project drafts separate', async () => {
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
const closed = vi.fn();
|
||||
const view = render(<TeacherChatPanel key="p-teacher" projectId="p" sourceId="c" role="teacher" onClose={closed} />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled());
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还没想清楚规则' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭老师' }));
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
view.rerender(<></>);
|
||||
view.rerender(<TeacherChatPanel key="p-teacher" projectId="p" sourceId="another-operation-chat" role="teacher" />);
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没想清楚规则');
|
||||
|
||||
view.rerender(<TeacherChatPanel key="p-friend" projectId="p" sourceId="c" role="friend" />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向朋友提问')).toBeEnabled());
|
||||
expect(screen.getByLabelText('向朋友提问')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('向朋友提问'), { target: { value: '想听听你的感受' } });
|
||||
view.rerender(<TeacherChatPanel key="another-teacher" projectId="another-project" sourceId="c" role="teacher" />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled());
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '这个项目的另一个问题' } });
|
||||
|
||||
view.rerender(<TeacherChatPanel key="p-teacher" projectId="p" sourceId="c" role="teacher" />);
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没想清楚规则');
|
||||
view.rerender(<TeacherChatPanel key="p-friend" projectId="p" sourceId="c" role="friend" />);
|
||||
expect(screen.getByLabelText('向朋友提问')).toHaveValue('想听听你的感受');
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(api.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a failed question and its request id when the consultation is reopened', async () => {
|
||||
api.send.mockRejectedValue(new Error('网络暂不可用'));
|
||||
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
|
||||
await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled());
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我该怎样观察?' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('网络暂不可用');
|
||||
const originalRequest = api.send.mock.calls[0][2];
|
||||
view.unmount();
|
||||
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('我该怎样观察?');
|
||||
expect(api.send).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('网络暂不可用');
|
||||
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
|
||||
});
|
||||
|
||||
it('returns to the operation chat without importing advice or offering a notes workflow', async () => {
|
||||
const returnToWork = vi.fn();
|
||||
const bringBack = vi.fn();
|
||||
api.read.mockResolvedValue({ ...first, requests: [{
|
||||
id: 'reply', text: '游戏不好玩', references: [], createdAt: 'now',
|
||||
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
|
||||
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '先观察别人在哪儿停下来。',
|
||||
}] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" onReturnToWork={returnToWork} onBringBack={bringBack} />);
|
||||
await screen.findByText('先观察别人在哪儿停下来。');
|
||||
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/记一下|项目共识|记入共识|待办/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '我去试一试' }));
|
||||
expect(returnToWork).toHaveBeenCalledOnce();
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps operations preview available without a consultation role', async () => {
|
||||
api.preview.mockResolvedValue({ payload: definition, draft_revision: 7 });
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.create.mockResolvedValue({ ...first, draftRevision: 7 });
|
||||
render(<TeacherChatPanel projectId="preview" sourceId="sample" draftRevision={7} sampleContext="学生的练习代码" />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '老师新话题' })).toBeEnabled());
|
||||
expect(screen.getByText('运营草稿试聊')).toBeInTheDocument();
|
||||
expect(api.preview).toHaveBeenCalledWith(7);
|
||||
expect(api.config).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '老师新话题' }));
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CodingTeacherService } from '../../electron/coding-teacher/service';
|
||||
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
|
||||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||||
import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context';
|
||||
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
|
||||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||||
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createMemoryCodingProjectStorage,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
createCodingProjectConfigV2,
|
||||
} from '../../electron/coding-projects/project-config';
|
||||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||||
import type { TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
|
||||
import type { ConsultationRole, TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
|
||||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||||
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
|
||||
|
||||
@@ -85,10 +86,12 @@ async function fixture() {
|
||||
});
|
||||
return { inputTokens: 20, outputTokens: 10 };
|
||||
});
|
||||
const account = {
|
||||
let account = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
binding: { accountKey: 'test', epoch: 1 },
|
||||
};
|
||||
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(context));
|
||||
const prepareModel = vi.fn(async () => ({ inputLimit: 8000, run }));
|
||||
const service = new CodingTeacherService({
|
||||
projects,
|
||||
runtime: new InMemoryConversationRuntime(),
|
||||
@@ -106,8 +109,8 @@ async function fixture() {
|
||||
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
|
||||
return { draft_revision: revision, payload: definition };
|
||||
},
|
||||
readSource: async () => structuredClone(context),
|
||||
prepareModel: async () => ({ inputLimit: 8000, run }),
|
||||
readSource,
|
||||
prepareModel,
|
||||
});
|
||||
services.push(service);
|
||||
return {
|
||||
@@ -117,6 +120,8 @@ async function fixture() {
|
||||
scope,
|
||||
service,
|
||||
run,
|
||||
readSource,
|
||||
prepareModel,
|
||||
finish: () => finish(),
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
@@ -127,6 +132,12 @@ async function fixture() {
|
||||
switchAccount: () => {
|
||||
accountCurrent = false;
|
||||
},
|
||||
useOtherAccount: () => {
|
||||
account = {
|
||||
id: '99999999-9999-4999-8999-999999999999',
|
||||
binding: { accountKey: 'another-account', epoch: 2 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
describe('cloud coding teacher', () => {
|
||||
@@ -270,6 +281,183 @@ describe('cloud coding teacher', () => {
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('project teacher and friend consultations', () => {
|
||||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||||
const nextRequestId = '33333333-3333-4333-8333-333333333333';
|
||||
const roles: ConsultationRole[] = ['teacher', 'friend'];
|
||||
|
||||
it('keeps teacher and friend histories and personas separate without running on open', async () => {
|
||||
const f = await fixture();
|
||||
const teacherScope = { projectId: f.scope.projectId, sourceId: 'project', role: 'teacher' as const };
|
||||
const friendScope = { ...teacherScope, role: 'friend' as const };
|
||||
const teacher = await f.service.create(teacherScope);
|
||||
const friend = await f.service.create(friendScope);
|
||||
expect((await f.service.list(teacherScope)).items.map((item) => item.id)).toEqual([teacher.id]);
|
||||
expect((await f.service.list(friendScope)).items.map((item) => item.id)).toEqual([friend.id]);
|
||||
expect((await f.service.read(teacherScope, teacher.id)).role).toBe('teacher');
|
||||
const onSnapshot = vi.fn();
|
||||
const unsubscribe = await f.service.subscribe(friendScope, friend.id, onSnapshot);
|
||||
expect(onSnapshot).toHaveBeenCalledOnce();
|
||||
unsubscribe();
|
||||
expect(f.readSource).not.toHaveBeenCalled();
|
||||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
await expect(f.service.read(friendScope, teacher.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||||
await expect(f.service.read(teacherScope, friend.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||||
|
||||
await f.service.send(teacherScope, teacher.id, { requestId, text: '我应该怎么想?' });
|
||||
await f.service.send(friendScope, friend.id, { requestId, text: '你有什么感受?' });
|
||||
const teacherPrompt = f.run.mock.calls[0][0][0].content;
|
||||
const friendPrompt = f.run.mock.calls[1][0][0].content;
|
||||
expect(teacherPrompt).toContain('引导思考');
|
||||
expect(teacherPrompt).toContain('使用具体的小例子');
|
||||
expect(friendPrompt).toContain('数字朋友');
|
||||
expect(friendPrompt).toContain('不要假装运行、试玩');
|
||||
expect(friendPrompt).not.toContain('使用具体的小例子');
|
||||
expect(friendPrompt).not.toContain('你是编程老师');
|
||||
expect(friend.definition.model).toEqual(teacher.definition.model);
|
||||
expect(friend.version).toBe(teacher.version);
|
||||
expect((await f.service.read(teacherScope, teacher.id)).requests[0].text).toBe('我应该怎么想?');
|
||||
expect((await f.service.read(friendScope, friend.id)).requests[0].text).toBe('你有什么感受?');
|
||||
});
|
||||
|
||||
it('selects the current operation conversation per request and deduplicates retries by source', async () => {
|
||||
const f = await fixture();
|
||||
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
|
||||
const other = await f.projects.conversationStore(f.created.project.path).create({
|
||||
agentId: f.created.config.defaultAgentId!, title: '另一个操作对话', model: null, modelResolution: 'required',
|
||||
});
|
||||
f.readSource.mockImplementation(async (selected) => ({
|
||||
...context,
|
||||
messages: [{ id: selected.sourceId, role: 'user', text: selected.sourceId === f.scope.sourceId ? '第一段操作' : '第二段操作' }],
|
||||
}));
|
||||
const topic = await f.service.create(scope);
|
||||
const input = { requestId, text: '我这样理解对吗?', sourceConversationId: f.scope.sourceId };
|
||||
await Promise.all([f.service.send(scope, topic.id, input), f.service.send(scope, topic.id, input)]);
|
||||
expect(f.run).toHaveBeenCalledOnce();
|
||||
expect(f.readSource).toHaveBeenCalledWith({ ...scope, sourceId: f.scope.sourceId });
|
||||
await expect(f.service.send(scope, topic.id, { ...input, sourceConversationId: other.id }))
|
||||
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
|
||||
let complete!: () => void;
|
||||
const completed = new Promise<void>((resolve) => { complete = resolve; });
|
||||
const unsubscribe = await f.service.subscribe(scope, topic.id, (snapshot) => {
|
||||
if (snapshot.requests[0].status === 'completed') complete();
|
||||
});
|
||||
f.finish();
|
||||
await completed;
|
||||
unsubscribe();
|
||||
await f.service.send(scope, topic.id, { ...input, requestId: nextRequestId, sourceConversationId: other.id });
|
||||
const latest = await f.service.read(scope, topic.id);
|
||||
expect(latest.requests.map((request) => request.sourceConversationId)).toEqual([f.scope.sourceId, other.id]);
|
||||
expect(latest.requests[1].includedSourceMessageIds).toEqual([other.id]);
|
||||
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('第二段操作');
|
||||
expect(JSON.stringify(f.run.mock.calls[1][0])).not.toContain('第一段操作');
|
||||
expect(latest.id).toBe(topic.id);
|
||||
});
|
||||
|
||||
it.each(roles)('rejects cross-project sources and isolates %s topics by project and account', async (role) => {
|
||||
const f = await fixture();
|
||||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||||
const topic = await f.service.create(scope);
|
||||
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'other-project'), identity: { kind: 'create' } });
|
||||
const foreign = await f.projects.conversationStore(other.project.path).create({
|
||||
agentId: other.config.defaultAgentId!, title: '外部对话', model: null, modelResolution: 'required',
|
||||
});
|
||||
await expect(f.service.send(scope, topic.id, { requestId, text: '帮我理解', sourceConversationId: foreign.id }))
|
||||
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||||
expect(f.readSource).not.toHaveBeenCalled();
|
||||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||||
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
|
||||
const otherScope = { ...scope, projectId: other.project.id };
|
||||
expect((await f.service.list(otherScope)).items).toEqual([]);
|
||||
await expect(f.service.read(otherScope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||||
f.useOtherAccount();
|
||||
expect((await f.service.list(scope)).items).toEqual([]);
|
||||
await expect(f.service.read(scope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(roles)('cancels an active %s reply when its operation source is deleted, preserving project history', async (role) => {
|
||||
const f = await fixture();
|
||||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||||
const topic = await f.service.create(scope);
|
||||
await f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||||
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||||
const saved = await f.service.read(scope, topic.id);
|
||||
expect(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '计数器保存一个数字。' });
|
||||
expect((await f.service.list(scope)).items.map((item) => item.id)).toEqual([topic.id]);
|
||||
await expect(f.service.send(scope, topic.id, { requestId: nextRequestId, text: '再问一次', sourceConversationId: f.scope.sourceId }))
|
||||
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||||
expect(f.run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(roles)('does not dispatch a preparing %s request after its source is deleted', async (role) => {
|
||||
const f = await fixture();
|
||||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
|
||||
const topic = await f.service.create(scope);
|
||||
let releasePreparation!: () => void;
|
||||
f.prepareModel.mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => { releasePreparation = resolve; });
|
||||
return { inputLimit: 8000, run: f.run };
|
||||
});
|
||||
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||||
const rejected = expect(sending).rejects.toMatchObject({ code: 'teacher_source_not_found' });
|
||||
await vi.waitFor(() => expect(f.prepareModel).toHaveBeenCalledOnce());
|
||||
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||||
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
|
||||
releasePreparation();
|
||||
await rejected;
|
||||
await deleting;
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('settles a persisted project request as cancelled if deletion races its initial save', async () => {
|
||||
const f = await fixture();
|
||||
const scope = { projectId: f.scope.projectId, sourceId: 'project', role: 'friend' as const };
|
||||
const topic = await f.service.create(scope);
|
||||
let saved!: () => void;
|
||||
let releaseSave!: () => void;
|
||||
const savedToDisk = new Promise<void>((resolve) => { saved = resolve; });
|
||||
const allowSaveReturn = new Promise<void>((resolve) => { releaseSave = resolve; });
|
||||
const originalSave = TeacherTopicStore.prototype.save;
|
||||
const spy = vi.spyOn(TeacherTopicStore.prototype, 'save').mockImplementation(async function (value) {
|
||||
await originalSave.call(this, value);
|
||||
if (value.id === topic.id && value.requests[0]?.status === 'preparing') {
|
||||
saved();
|
||||
await allowSaveReturn;
|
||||
}
|
||||
});
|
||||
try {
|
||||
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
|
||||
await savedToDisk;
|
||||
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
|
||||
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
|
||||
releaseSave();
|
||||
await sending;
|
||||
await deleting;
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('cancelled');
|
||||
const stored = JSON.parse(await readFile(path.join(
|
||||
f.created.project.path, '.makelore/friend-conversations', topic.accountId, 'project', topic.id + '.json'
|
||||
), 'utf8'));
|
||||
expect(stored.requests[0].status).toBe('cancelled');
|
||||
} finally {
|
||||
releaseSave();
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('derives friend configuration without mutating published teacher prompts or skills', () => {
|
||||
const original = structuredClone(definition);
|
||||
const friend = consultationDefinition(definition, 'friend');
|
||||
expect(friend.teacher_id).toBe('coding-friend');
|
||||
expect(friend.skills).toEqual([]);
|
||||
expect(definition).toEqual(original);
|
||||
expect(consultationDefinition(definition, 'teacher')).toBe(definition);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teacher context and wire contract', () => {
|
||||
it('takes only complete user/assistant text and preserves the read cursor', () => {
|
||||
const snapshot = {
|
||||
@@ -435,3 +623,46 @@ it('serves topic acceptance and SSE snapshots without cancelling on stream close
|
||||
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
|
||||
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
|
||||
});
|
||||
|
||||
it.each(['teacher', 'friend'] as const)('routes project-level %s config and topic messages without a source-scoped URL', async (role) => {
|
||||
const f = await fixture();
|
||||
const server = createServer((req, res) => {
|
||||
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
|
||||
codingProducts: { teacher: f.service },
|
||||
} as HostApiContext);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('no address');
|
||||
const origin = 'http://127.0.0.1:' + address.port;
|
||||
const base = `${origin}/api/coding/projects/${f.scope.projectId}/${role}-topics`;
|
||||
const post = (url: string, body = {}) => fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
const config = await fetch(`${origin}/api/coding/${role}/config`);
|
||||
expect(config.status).toBe(200);
|
||||
expect((await config.json()).definition.teacher_id).toBe('coding-' + role);
|
||||
expect((await post(`${origin}/api/coding/${role}/config`)).status).toBe(405);
|
||||
const created = await post(base);
|
||||
expect(created.status).toBe(201);
|
||||
const topic = await created.json();
|
||||
expect(topic).toMatchObject({ role, sourceConversationId: 'project', projectId: f.scope.projectId });
|
||||
const listed = await fetch(base);
|
||||
expect((await listed.json()).items.map((item: { id: string }) => item.id)).toEqual([topic.id]);
|
||||
expect((await fetch(`${base}/${topic.id}`)).status).toBe(200);
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
const requestId = '22222222-2222-4222-8222-222222222222';
|
||||
const input = { requestId, text: '我想聊聊', sourceConversationId: f.scope.sourceId };
|
||||
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
|
||||
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
|
||||
expect(f.run).toHaveBeenCalledOnce();
|
||||
const accepted = await fetch(`${base}/${topic.id}`);
|
||||
expect((await accepted.json()).requests[0].sourceConversationId).toBe(f.scope.sourceId);
|
||||
expect((await post(`${base}/${topic.id}/requests/${requestId}/cancel`)).status).toBe(200);
|
||||
await vi.waitFor(async () => expect((await f.service.read({ projectId: f.scope.projectId, sourceId: 'project', role }, topic.id)).requests[0].status).toBe('cancelled'));
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -120,10 +120,11 @@ describe('MainLayout module isolation', () => {
|
||||
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the programming workspace padding compensation separate from painting', () => {
|
||||
it('lets the classroom workspace reach the content edges at every breakpoint', () => {
|
||||
renderLayout('/chat');
|
||||
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0', 'sm:p-6');
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0');
|
||||
expect(screen.getByTestId('main-content')).not.toHaveClass('sm:p-6');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('TitleBar platform behavior', () => {
|
||||
expect(invokeIpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aligns the chat workspace title rail with the four-column layout', () => {
|
||||
it('aligns the classroom title bar with one project sidebar and no duplicate branding', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
|
||||
render(<TitleBar integrated workspaceLayout />);
|
||||
@@ -89,9 +89,11 @@ describe('TitleBar platform behavior', () => {
|
||||
'basis-[256px]',
|
||||
);
|
||||
expect(screen.getByTestId('titlebar-main-surface')).toHaveClass('border-b-0');
|
||||
expect(screen.getByTestId('titlebar-conversation-surface')).toHaveClass('w-[256px]', 'bg-surface-tertiary', 'border-r');
|
||||
expect(screen.getByTestId('titlebar-conversation-surface')).toHaveStyle({ left: '0px' });
|
||||
expect(screen.queryByTestId('titlebar-conversation-surface')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('titlebar-project-context')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '折叠侧栏' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders the painting title bar as a transparent overlay with the logo at the window edge', () => {
|
||||
@@ -174,18 +176,28 @@ describe('TitleBar platform behavior', () => {
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the chat titlebar rail fixed while the collapsed sidebar previews', () => {
|
||||
it('keeps the classroom sidebar safe area fixed during preview and lets the student pin it', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
|
||||
const { rerender } = render(<TitleBar integrated workspaceLayout sidebarPeekOpen={false} />);
|
||||
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveClass('w-[132px]');
|
||||
expect(screen.getByTestId('titlebar-conversation-surface')).toHaveStyle({ left: '-132px' });
|
||||
expect(screen.queryByTestId('titlebar-conversation-surface')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '展开侧栏' })).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
rerender(<TitleBar integrated workspaceLayout sidebarPeekOpen />);
|
||||
|
||||
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveClass('w-[132px]');
|
||||
expect(screen.getByTestId('titlebar-conversation-surface')).toHaveStyle({ left: '-132px' });
|
||||
expect(screen.queryByTestId('titlebar-conversation-surface')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
|
||||
const pinButton = screen.getByRole('button', { name: '固定侧栏' });
|
||||
expect(pinButton).toBeEnabled();
|
||||
expect(pinButton).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(pinButton);
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(false);
|
||||
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveClass('w-[256px]', 'min-w-[256px]', 'basis-[256px]');
|
||||
expect(screen.getByRole('button', { name: '折叠侧栏' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('extends the ordinary titlebar sidebar surface during a hover preview', () => {
|
||||
|
||||
Reference in New Issue
Block a user