Integrate project classroom with teacher and friend consultations

This commit is contained in:
鲨鱼辣椒
2026-09-22 15:50:18 +08:00
parent e5d271bc45
commit 68e676cb62
28 changed files with 957 additions and 314 deletions

View File

@@ -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);
});
});

View File

@@ -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 });

View File

@@ -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, '学生的练习代码'));
});
});

View File

@@ -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()));
}
});

View File

@@ -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');
});
});

View File

@@ -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', () => {