feat: integrate pinned teacher discussion workspace
This commit is contained in:
@@ -9,6 +9,7 @@ const api = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
read: vi.fn(),
|
||||
send: vi.fn(),
|
||||
updateDiscussion: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
events: vi.fn(),
|
||||
@@ -448,3 +449,105 @@ describe('teacher side chat', () => {
|
||||
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('pinned discussion workspace', () => {
|
||||
const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: {
|
||||
kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }],
|
||||
} };
|
||||
async function openDiscussion(status: 'active' | 'offered' = 'active') {
|
||||
const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } };
|
||||
api.read.mockResolvedValue(current);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
|
||||
await screen.findByText('一起想我的宠物游戏');
|
||||
await waitFor(() => expect(streams.has('first')).toBe(true));
|
||||
return current;
|
||||
}
|
||||
it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => {
|
||||
const current = await openDiscussion();
|
||||
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true }));
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('接着聊:养只小狗')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我希望是柯基' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' })));
|
||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
||||
});
|
||||
it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => {
|
||||
const current = await openDiscussion();
|
||||
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } });
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在想的下一句' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true }));
|
||||
await screen.findByText('先留在这里');
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在想的下一句');
|
||||
expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true');
|
||||
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||
await screen.findByText('我们慢慢聊。');
|
||||
expect(api.send.mock.calls[0][2].discussion).toBeUndefined();
|
||||
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true }));
|
||||
await screen.findByRole('button', { name: '养只小狗', exact: true });
|
||||
expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' });
|
||||
});
|
||||
it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => {
|
||||
const current = await openDiscussion('offered');
|
||||
api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } });
|
||||
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true }));
|
||||
await screen.findByRole('button', { name: '把想法理一理', exact: true });
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '不要丢掉的草稿' } });
|
||||
api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
||||
expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' });
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('不要丢掉的草稿');
|
||||
});
|
||||
it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => {
|
||||
const current = await openDiscussion();
|
||||
const stream = streams.get('first')!;
|
||||
const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] };
|
||||
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在梳理');
|
||||
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '下一轮草稿' } });
|
||||
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
|
||||
await screen.findByText('这次先停在这里。想继续时可以再说说。');
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('下一轮草稿');
|
||||
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) })));
|
||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
||||
expect(screen.getByText('一起想小狗和花园')).toBeVisible();
|
||||
});
|
||||
it('sends a normal suggested question directly without changing the composer draft', async () => {
|
||||
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
|
||||
api.read.mockResolvedValue(current);
|
||||
api.send.mockResolvedValue({ ...current, revision: 2 });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
|
||||
await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己的草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '怎样知道小狗喜欢我?' }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
||||
expect(api.send.mock.calls[0][2].text).toBe('怎样知道小狗喜欢我?');
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('自己的草稿');
|
||||
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => {
|
||||
const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: {
|
||||
kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }],
|
||||
} } };
|
||||
api.read.mockResolvedValue(current);
|
||||
api.send.mockResolvedValue({ ...current, revision: 2 });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
|
||||
await screen.findByText('回到家时');
|
||||
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己还没说完的话' } });
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]);
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
||||
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } });
|
||||
expect(screen.getByLabelText('向老师提问')).toHaveValue('自己还没说完的话');
|
||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1156,3 +1156,44 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
describe('structured teacher service integration', () => {
|
||||
it('buffers model JSON, offers once, locks updates, persists state, and never applies a cancelled update', async () => {
|
||||
const f = await fixture();
|
||||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||||
const initial = await f.service.create(scope);
|
||||
const tool = { kind: 'ideas', title: '宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] };
|
||||
f.replyWith(JSON.stringify({ reply: '我们可以先把想法放在一起。', quickReplies: [], tool }));
|
||||
const send = (text: string, discussion?: { toolId: string; revision: number }) => f.service.send(scope, initial.id, {
|
||||
requestId: crypto.randomUUID(), text, presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId, discussion,
|
||||
});
|
||||
const pending = await send('想养只小狗');
|
||||
expect(pending.requests[0].response).toBe('');
|
||||
expect(pending.discussion).toBeUndefined();
|
||||
f.finish();
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests[0].status).toBe('completed'));
|
||||
const offered = await f.service.read(scope, initial.id);
|
||||
expect(offered.discussion?.status).toBe('offered');
|
||||
expect(offered.requests[0].response).toBe('我们可以先把想法放在一起。');
|
||||
const active = await f.service.updateDiscussion(scope, initial.id, { toolId: offered.discussion!.id, revision: 1, action: 'enter' });
|
||||
const context = { toolId: active.discussion!.id, revision: 2 };
|
||||
f.replyWith(JSON.stringify({ reply: '把花园也放进来了。', tool: { ...tool, title: '小狗和花园' } }));
|
||||
const running = await send('还想种花', context);
|
||||
expect(running.discussion?.content.title).toBe('宠物游戏');
|
||||
await expect(f.service.updateDiscussion(scope, initial.id, { ...context, action: 'finish' })).rejects.toMatchObject({ code: 'teacher_topic_busy' });
|
||||
await f.service.cancel(scope, initial.id, running.requests.at(-1)!.id);
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('cancelled'));
|
||||
expect((await f.service.read(scope, initial.id)).discussion?.content.title).toBe('宠物游戏');
|
||||
await send('再把花园加进来', context);
|
||||
f.finish();
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('completed'));
|
||||
const completed = await f.service.read(scope, initial.id);
|
||||
expect(completed.discussion?.content.title).toBe('小狗和花园');
|
||||
expect(completed.discussion?.revision).toBe(3);
|
||||
expect(completed.requests[0].discussionSnapshot?.title).toBe('宠物游戏');
|
||||
await expect(send('旧版本', context)).rejects.toMatchObject({ code: 'teacher_discussion_changed' });
|
||||
const restarted = await f.restart();
|
||||
expect((await restarted.read(scope, initial.id)).discussion).toEqual(completed.discussion);
|
||||
});
|
||||
});
|
||||
|
||||
315
tests/unit/teacher-discussion-state.test.ts
Normal file
315
tests/unit/teacher-discussion-state.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyDiscussionReply,
|
||||
discussionInstructions,
|
||||
editDiscussion,
|
||||
validateDiscussionContext,
|
||||
} from '../../electron/coding-teacher/discussion';
|
||||
import type {
|
||||
TeacherDefinition,
|
||||
TeacherDiscussionAction,
|
||||
TeacherDiscussionContext,
|
||||
TeacherRequest,
|
||||
TeacherTopic,
|
||||
} from '../../shared/coding-teacher';
|
||||
import type { TeacherDiscussion, TeacherDiscussionContent } from '../../shared/teacher-discussion';
|
||||
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1, teacher_id: 'coding-teacher', name: '老师', description: '', avatar_id: 'avatar-01',
|
||||
welcome_message: '一起想一想', suggested_questions: [], system_prompt: '帮助孩子理解自己的想法。',
|
||||
skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
|
||||
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
|
||||
};
|
||||
const ideas: Extract<TeacherDiscussionContent, { kind: 'ideas' }> = {
|
||||
kind: 'ideas', title: '小狗游戏', items: [
|
||||
{ id: 'dog', text: '养一只自己的小狗', state: 'kept' },
|
||||
{ id: 'breed', text: '柯基', parentId: 'dog', state: 'kept' },
|
||||
{ id: 'adventure', text: '带它去冒险', state: 'suggested' },
|
||||
], firstItemId: 'dog',
|
||||
};
|
||||
const structure: Extract<TeacherDiscussionContent, { kind: 'structure' }> = {
|
||||
kind: 'structure', title: '这些想法的关系', nodes: [
|
||||
{ id: 'dog', label: '自己的小狗' },
|
||||
{ id: 'breed', label: '柯基', parentId: 'dog', relation: '品种' },
|
||||
],
|
||||
};
|
||||
const flow: Extract<TeacherDiscussionContent, { kind: 'flow' }> = {
|
||||
kind: 'flow', title: '回家时的回应', nodes: [
|
||||
{ id: 'door', label: '打开门', kind: 'event' },
|
||||
{ id: 'awake', label: '醒着吗', kind: 'condition' },
|
||||
{ id: 'sleep', label: '继续睡', kind: 'outcome' },
|
||||
], edges: [{ id: 'start', from: 'door', to: 'awake' }, { id: 'no', from: 'awake', to: 'sleep', label: '睡着' }],
|
||||
};
|
||||
const comparison: Extract<TeacherDiscussionContent, { kind: 'comparison' }> = {
|
||||
kind: 'comparison', title: '两种提示',
|
||||
columns: [{ id: 'direct', label: '直接说' }, { id: 'hint', label: '给线索' }],
|
||||
rows: [{ id: 'new', label: '新玩家', cells: [{ columnId: 'direct', text: '马上知道做什么' }, { columnId: 'hint', text: '还需要判断' }] }],
|
||||
};
|
||||
|
||||
function topic(content?: TeacherDiscussionContent, status: TeacherDiscussion['status'] = 'active'): TeacherTopic {
|
||||
return {
|
||||
schemaVersion: 1, revision: 1, id: 'topic', accountId: 'account', projectId: 'project',
|
||||
sourceConversationId: 'source', version: 1, definition, createdAt: 'now', updatedAt: 'now', requests: [],
|
||||
...(content ? { discussion: { id: 'tool-1', revision: 7, status, content: structuredClone(content) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function request(owner: TeacherTopic, context?: TeacherDiscussionContext): TeacherRequest {
|
||||
const value: TeacherRequest = {
|
||||
id: 'request-' + owner.requests.length, text: '我的补充', references: [], createdAt: 'now',
|
||||
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [],
|
||||
omittedMessages: 0, status: 'running', response: '', presentation: 'discussion-v1',
|
||||
...(context ? { discussionContext: structuredClone(context) } : {}),
|
||||
};
|
||||
owner.requests.push(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function context(owner: TeacherTopic, extra: Partial<TeacherDiscussionContext> = {}): TeacherDiscussionContext {
|
||||
return { toolId: owner.discussion!.id, revision: owner.discussion!.revision, ...extra };
|
||||
}
|
||||
|
||||
function act(owner: TeacherTopic, action: TeacherDiscussionAction['action'], itemId?: string): void {
|
||||
editDiscussion(owner, { ...context(owner), action, ...(itemId ? { itemId } : {}) });
|
||||
}
|
||||
|
||||
function reply(content?: TeacherDiscussionContent | null, text = '我把你的想法接上了。'): string {
|
||||
return JSON.stringify({ reply: text, quickReplies: ['接着说'], ...(content === undefined ? {} : { tool: content }) });
|
||||
}
|
||||
|
||||
describe('teacher discussion lifecycle', () => {
|
||||
it('offers a new component without activating it and keeps an independent history snapshot', () => {
|
||||
const owner = topic();
|
||||
const turn = request(owner);
|
||||
applyDiscussionReply(owner, turn, reply(ideas));
|
||||
expect(owner.discussion).toMatchObject({ revision: 1, status: 'offered', content: ideas });
|
||||
expect(owner.discussion?.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(turn.response).toBe('我把你的想法接上了。');
|
||||
expect(turn.suggestedQuestions).toEqual(['接着说']);
|
||||
expect(turn.discussionSnapshot).toEqual(ideas);
|
||||
expect(turn.discussionSnapshot).not.toBe(owner.discussion?.content);
|
||||
act(owner, 'enter');
|
||||
act(owner, 'aside-idea', 'breed');
|
||||
expect(turn.discussionSnapshot).toEqual(ideas);
|
||||
});
|
||||
|
||||
it('enters, pauses, resumes, finishes and reopens the same component without losing content', () => {
|
||||
const owner = topic(ideas, 'offered');
|
||||
const original = structuredClone(owner.discussion!);
|
||||
for (const [action, status] of [
|
||||
['enter', 'active'], ['pause', 'paused'], ['resume', 'active'], ['finish', 'finished'], ['resume', 'active'],
|
||||
] as const) {
|
||||
const priorRevision = owner.discussion!.revision;
|
||||
act(owner, action);
|
||||
expect(owner.discussion).toEqual({ ...original, status, revision: priorRevision + 1 });
|
||||
}
|
||||
expect(owner.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows a first offer to be declined while retaining it for later use', () => {
|
||||
const owner = topic(flow, 'offered');
|
||||
act(owner, 'pause');
|
||||
expect(owner.discussion).toMatchObject({ id: 'tool-1', status: 'paused', content: flow });
|
||||
act(owner, 'resume');
|
||||
expect(owner.discussion).toMatchObject({ id: 'tool-1', status: 'active', content: flow });
|
||||
});
|
||||
|
||||
it('keeps and shelves ideas explicitly and removes a shelved first-item marker', () => {
|
||||
const owner = topic(ideas);
|
||||
act(owner, 'keep-idea', 'adventure');
|
||||
act(owner, 'first-idea', 'adventure');
|
||||
expect(owner.discussion!.content).toMatchObject({ firstItemId: 'adventure' });
|
||||
act(owner, 'aside-idea', 'adventure');
|
||||
expect(owner.discussion!.content).not.toHaveProperty('firstItemId');
|
||||
expect(owner.discussion!.content).toMatchObject({ items: [ideas.items[0], ideas.items[1], { ...ideas.items[2], state: 'aside' }] });
|
||||
const before = structuredClone(owner.discussion);
|
||||
expect(() => act(owner, 'first-idea', 'adventure')).toThrowError();
|
||||
expect(owner.discussion).toEqual(before);
|
||||
});
|
||||
|
||||
it.each(['enter', 'pause', 'finish', 'resume', 'keep-idea', 'aside-idea', 'first-idea', 'back-ideas'] as const)(
|
||||
'rejects stale %s actions without mutating the current component', action => {
|
||||
const owner = topic(ideas);
|
||||
const before = structuredClone(owner);
|
||||
expect(() => editDiscussion(owner, { toolId: 'tool-1', revision: 6, action, itemId: 'dog' }))
|
||||
.toThrowError(expect.objectContaining({ code: 'teacher_discussion_changed', status: 409 }));
|
||||
expect(owner).toEqual(before);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects actions against another tool or a missing item without a partial edit', () => {
|
||||
const owner = topic(ideas);
|
||||
const before = structuredClone(owner);
|
||||
expect(() => editDiscussion(owner, { toolId: 'other', revision: 7, action: 'pause' })).toThrowError();
|
||||
expect(() => act(owner, 'keep-idea', 'missing')).toThrowError();
|
||||
expect(() => act(owner, 'back-ideas')).toThrowError();
|
||||
expect(owner).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discussion request context and focus', () => {
|
||||
it('permits ordinary chat without context even when a component exists', () => {
|
||||
expect(validateDiscussionContext(topic(flow))).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ideas, 'breed'], [structure, 'breed'], [flow, 'sleep'], [comparison, 'new:hint'],
|
||||
] as const)('validates a stable focus id inside $0.kind', (content, focusId) => {
|
||||
const owner = topic(content);
|
||||
const expected = context(owner, { focusId });
|
||||
expect(validateDiscussionContext(owner, { ...expected, clientOnly: 'drop me' } as TeacherDiscussionContext)).toEqual(expected);
|
||||
expect(() => validateDiscussionContext(owner, context(owner, { focusId: 'missing' })))
|
||||
.toThrowError(expect.objectContaining({ code: 'teacher_discussion_changed' }));
|
||||
});
|
||||
|
||||
it.each(['offered', 'paused', 'finished'] as const)('rejects tool-maintenance context while %s', status => {
|
||||
const owner = topic(flow, status);
|
||||
expect(() => validateDiscussionContext(owner, context(owner))).toThrowError();
|
||||
});
|
||||
|
||||
it('rejects stale revisions, foreign identities, invalid types and non-cell comparison focus', () => {
|
||||
const owner = topic(comparison);
|
||||
for (const input of [
|
||||
context(owner, { revision: 6 }), context(owner, { toolId: 'other' }),
|
||||
context(owner, { revision: 1.5 }), context(owner, { focusId: 'new' }),
|
||||
{ ...context(owner), focusId: 3 }, { ...context(owner), transition: 'flow' },
|
||||
]) {
|
||||
expect(() => validateDiscussionContext(owner, input as TeacherDiscussionContext)).toThrowError();
|
||||
}
|
||||
const withFlow = topic(flow);
|
||||
expect(() => validateDiscussionContext(withFlow, context(withFlow, { focusId: 'no' }))).toThrowError();
|
||||
});
|
||||
|
||||
it('allows an explicit structure transition only from active ideas', () => {
|
||||
const owner = topic(ideas);
|
||||
const transition = context(owner, { transition: 'structure' });
|
||||
expect(validateDiscussionContext(owner, transition)).toEqual(transition);
|
||||
for (const content of [structure, flow, comparison]) {
|
||||
const other = topic(content);
|
||||
expect(() => validateDiscussionContext(other, context(other, { transition: 'structure' }))).toThrowError();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applying completed teacher replies', () => {
|
||||
it('updates the same active component and preserves all earlier turn snapshots', () => {
|
||||
const owner = topic(flow);
|
||||
const oldTurn = request(owner);
|
||||
oldTurn.status = 'completed';
|
||||
oldTurn.discussionSnapshot = structuredClone(flow);
|
||||
const turn = request(owner, context(owner, { focusId: 'sleep' }));
|
||||
const updated = structuredClone(flow);
|
||||
updated.nodes[2].label = '动动耳朵,继续睡';
|
||||
applyDiscussionReply(owner, turn, reply(updated));
|
||||
expect(owner.discussion).toEqual({ id: 'tool-1', revision: 8, status: 'active', content: updated });
|
||||
expect(turn.discussionSnapshot).toEqual(updated);
|
||||
expect(oldTurn.discussionSnapshot).toEqual(flow);
|
||||
expect(turn.discussionSnapshot).not.toBe(owner.discussion!.content);
|
||||
updated.nodes[2].label = '改变调用方数据';
|
||||
expect(turn.discussionSnapshot).toEqual(owner.discussion!.content);
|
||||
});
|
||||
|
||||
it.each([ideas, structure, comparison])('rejects changing an active flow into $kind', content => {
|
||||
const owner = topic(flow);
|
||||
const before = structuredClone(owner.discussion);
|
||||
const turn = request(owner, context(owner));
|
||||
applyDiscussionReply(owner, turn, reply(content));
|
||||
expect(owner.discussion).toEqual(before);
|
||||
expect(turn.discussionError).toBeTruthy();
|
||||
expect(turn.response).toBe('我把你的想法接上了。');
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires an explicit ideas-to-structure transition and preserves both directions for later discussion', () => {
|
||||
const owner = topic(ideas);
|
||||
const unconfirmed = request(owner, context(owner));
|
||||
applyDiscussionReply(owner, unconfirmed, reply(structure));
|
||||
expect(owner.discussion!.content).toEqual(ideas);
|
||||
expect(unconfirmed.discussionError).toBeTruthy();
|
||||
const transitionContext = validateDiscussionContext(owner, context(owner, { transition: 'structure' }));
|
||||
const converted = request(owner, transitionContext);
|
||||
applyDiscussionReply(owner, converted, reply(structure));
|
||||
expect(owner.discussion).toMatchObject({ id: 'tool-1', revision: 8, status: 'active', content: structure, previousIdeas: ideas });
|
||||
const expanded = structuredClone(structure);
|
||||
expanded.nodes.push({ id: 'personality', label: '胆小但好奇', relation: '性格', parentId: 'dog' });
|
||||
const supplement = request(owner, context(owner));
|
||||
applyDiscussionReply(owner, supplement, reply(expanded));
|
||||
act(owner, 'back-ideas');
|
||||
expect(owner.discussion).toMatchObject({ id: 'tool-1', revision: 10, status: 'active', content: ideas, previousStructure: expanded });
|
||||
const nextContext = context(owner, { transition: 'structure' });
|
||||
const instructions = discussionInstructions(owner, nextContext);
|
||||
expect(JSON.parse(instructions.split('\n').at(-1)!)).toMatchObject({ previousStructure: expanded });
|
||||
act(owner, 'aside-idea', 'breed');
|
||||
expect(owner.discussion!.previousIdeas).toEqual(ideas);
|
||||
expect(owner.discussion!.previousStructure).toEqual(expanded);
|
||||
expect(converted.discussionSnapshot).toEqual(structure);
|
||||
expect(supplement.discussionSnapshot).toEqual(expanded);
|
||||
});
|
||||
|
||||
it.each(['active', 'offered', 'paused', 'finished'] as const)('does not modify a %s component during ordinary chat', status => {
|
||||
const owner = topic(flow, status);
|
||||
const before = structuredClone(owner.discussion);
|
||||
const turn = request(owner);
|
||||
applyDiscussionReply(owner, turn, reply(ideas, '我们先聊这个想法。'));
|
||||
expect(owner.discussion).toEqual(before);
|
||||
expect(turn.response).toBe('我们先聊这个想法。');
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a late reply after revision/status changed while retaining its readable response', () => {
|
||||
const owner = topic(flow);
|
||||
const turn = request(owner, context(owner));
|
||||
act(owner, 'pause');
|
||||
const before = structuredClone(owner.discussion);
|
||||
const update = structuredClone(flow);
|
||||
update.nodes[2].label = '轻轻摇尾巴';
|
||||
applyDiscussionReply(owner, turn, reply(update));
|
||||
expect(owner.discussion).toEqual(before);
|
||||
expect(turn.response).toBe('我把你的想法接上了。');
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
act(owner, 'resume');
|
||||
const resumed = structuredClone(owner.discussion);
|
||||
const stillStale = request(owner, turn.discussionContext);
|
||||
applyDiscussionReply(owner, stillStale, reply(update));
|
||||
expect(owner.discussion).toEqual(resumed);
|
||||
});
|
||||
|
||||
it.each([null, undefined, flow])('keeps revision and snapshots unchanged when there is no component change: %s', content => {
|
||||
const owner = topic(flow);
|
||||
const before = structuredClone(owner.discussion);
|
||||
const turn = request(owner, context(owner));
|
||||
applyDiscussionReply(owner, turn, reply(content, '同一个动作,在不同情况下可以有不同反应。'));
|
||||
expect(owner.discussion).toEqual(before);
|
||||
expect(turn.response).toBe('同一个动作,在不同情况下可以有不同反应。');
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
expect(turn.discussionError).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
JSON.stringify({ reply: '你的补充收到啦。', tool: { ...flow, edges: [{ id: 'bad', from: 'door', to: 'missing' }] } }),
|
||||
'{"reply":"你的补充收到啦。","tool":{"kind":"flow",',
|
||||
])('keeps the old component and historical snapshot when a model update is invalid', raw => {
|
||||
const owner = topic(flow);
|
||||
const oldTurn = request(owner);
|
||||
oldTurn.status = 'completed';
|
||||
oldTurn.discussionSnapshot = structuredClone(flow);
|
||||
const before = structuredClone(owner.discussion);
|
||||
const turn = request(owner, context(owner));
|
||||
applyDiscussionReply(owner, turn, raw);
|
||||
expect(owner.discussion).toEqual(before);
|
||||
expect(oldTurn.discussionSnapshot).toEqual(flow);
|
||||
expect(turn.response).toBe('你的补充收到啦。');
|
||||
expect(turn.discussionError).toBeTruthy();
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps legacy text as a normal answer without creating a component', () => {
|
||||
const owner = topic();
|
||||
const turn = request(owner);
|
||||
applyDiscussionReply(owner, turn, '先听听朋友玩的时候怎么想。');
|
||||
expect(turn.response).toBe('先听听朋友玩的时候怎么想。');
|
||||
expect(owner.discussion).toBeUndefined();
|
||||
expect(turn.discussionSnapshot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
268
tests/unit/teacher-discussion.test.ts
Normal file
268
tests/unit/teacher-discussion.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
parseTeacherDiscussionContent,
|
||||
parseTeacherDiscussionReply,
|
||||
type TeacherDiscussionContent,
|
||||
} from '../../shared/teacher-discussion';
|
||||
|
||||
const ideas: TeacherDiscussionContent = {
|
||||
kind: 'ideas', title: '我想做的小狗游戏',
|
||||
items: [
|
||||
{ id: 'dog', text: '养一只小狗', state: 'kept' },
|
||||
{ id: 'breed', text: '柯基', parentId: 'dog', state: 'kept' },
|
||||
{ id: 'adventure', text: '一起冒险', state: 'suggested' },
|
||||
{ id: 'garden', text: '种菜', state: 'aside' },
|
||||
],
|
||||
firstItemId: 'dog',
|
||||
};
|
||||
const structure: TeacherDiscussionContent = {
|
||||
kind: 'structure', title: '这些想法有什么关系',
|
||||
nodes: [
|
||||
{ id: 'dog', label: '我的小狗' },
|
||||
{ id: 'breed', label: '柯基', parentId: 'dog', relation: '品种' },
|
||||
{ id: 'personality', label: '胆小但好奇', parentId: 'dog', relation: '性格' },
|
||||
],
|
||||
};
|
||||
const flow: TeacherDiscussionContent = {
|
||||
kind: 'flow', title: '回家时怎么欢迎我',
|
||||
nodes: [
|
||||
{ id: 'door', label: '打开家门', kind: 'event' },
|
||||
{ id: 'awake', label: '小狗醒着吗?', kind: 'condition' },
|
||||
{ id: 'welcome', label: '跑过来欢迎', kind: 'outcome' },
|
||||
{ id: 'sleep', label: '动动耳朵,继续睡', kind: 'outcome' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start', from: 'door', to: 'awake' },
|
||||
{ id: 'yes', from: 'awake', to: 'welcome', label: '醒着' },
|
||||
{ id: 'no', from: 'awake', to: 'sleep', label: '睡着' },
|
||||
],
|
||||
};
|
||||
const comparison: TeacherDiscussionContent = {
|
||||
kind: 'comparison', title: '没打中时怎样回应',
|
||||
columns: [{ id: 'penalty', label: '直接扣分' }, { id: 'hint', label: '先给提示' }],
|
||||
rows: [{
|
||||
id: 'new', label: '第一次玩的人',
|
||||
cells: [
|
||||
{ columnId: 'penalty', text: '可能不知道错在哪' },
|
||||
{ columnId: 'hint', text: '更容易知道如何调整' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
describe('teacher discussion content contract', () => {
|
||||
it.each([ideas, structure, flow, comparison])('preserves Agent-provided $kind content in an independent safe projection', content => {
|
||||
const parsed = parseTeacherDiscussionContent(content);
|
||||
expect(parsed).toEqual(content);
|
||||
expect(parsed).not.toBe(content);
|
||||
expect(parseTeacherDiscussionContent(structuredClone(content))).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('preserves arbitrary structural relationships without inventing fixed character/place/item categories', () => {
|
||||
expect(parseTeacherDiscussionContent({
|
||||
kind: 'structure', title: '音乐盒', nodes: [
|
||||
{ id: 'melody', label: '自己录的旋律' },
|
||||
{ id: 'tempo', label: '慢慢加快', relation: '播放速度', parentId: 'melody' },
|
||||
],
|
||||
})).toEqual({
|
||||
kind: 'structure', title: '音乐盒', nodes: [
|
||||
{ id: 'melody', label: '自己录的旋律' },
|
||||
{ id: 'tempo', label: '慢慢加快', relation: '播放速度', parentId: 'melody' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts explicit game/retry loops while validating every edge target', () => {
|
||||
const looping = {
|
||||
...flow,
|
||||
edges: [...flow.edges, { id: 'retry', from: 'sleep', to: 'door', label: '明天再来' }],
|
||||
};
|
||||
expect(parseTeacherDiscussionContent(looping)).toEqual(looping);
|
||||
expect(() => parseTeacherDiscussionContent({
|
||||
...looping, edges: [...looping.edges, { id: 'missing', from: 'welcome', to: 'outside' }],
|
||||
})).toThrow(Error);
|
||||
});
|
||||
|
||||
it('aligns comparison cells by their explicit column ids rather than their array position', () => {
|
||||
const reversed = { ...comparison, rows: [{ ...comparison.rows[0], cells: [...comparison.rows[0].cells].reverse() }] };
|
||||
expect(parseTeacherDiscussionContent(reversed)).toEqual(comparison);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['duplicate item', { ...ideas, items: [ideas.items[0], ideas.items[0]] }],
|
||||
['unknown parent', { ...structure, nodes: [{ id: 'child', label: '柯基', parentId: 'missing' }] }],
|
||||
['self parent', { ...structure, nodes: [{ id: 'dog', label: '小狗', parentId: 'dog' }] }],
|
||||
['parent cycle', { ...structure, nodes: [{ id: 'a', label: '甲', parentId: 'b' }, { id: 'b', label: '乙', parentId: 'a' }] }],
|
||||
['idea cycle', { ...ideas, items: [{ id: 'a', text: '甲', state: 'kept', parentId: 'b' }, { id: 'b', text: '乙', state: 'kept', parentId: 'a' }] }],
|
||||
['missing first item', { ...ideas, firstItemId: 'missing' }],
|
||||
['aside first item', { ...ideas, firstItemId: 'garden' }],
|
||||
['duplicate flow nodes', { ...flow, nodes: [...flow.nodes, flow.nodes[0]] }],
|
||||
['duplicate edge', { ...flow, edges: [flow.edges[0], flow.edges[0]] }],
|
||||
['unknown edge source', { ...flow, edges: [{ id: 'bad', from: 'missing', to: 'door' }] }],
|
||||
['unknown node kind', { ...flow, nodes: [{ id: 'a', label: '运行脚本', kind: 'execute' }], edges: [] }],
|
||||
['missing comparison cell', { ...comparison, rows: [{ ...comparison.rows[0], cells: comparison.rows[0].cells.slice(0, 1) }] }],
|
||||
['duplicate comparison cell', { ...comparison, rows: [{ ...comparison.rows[0], cells: [comparison.rows[0].cells[0], comparison.rows[0].cells[0]] }] }],
|
||||
['unknown comparison column', { ...comparison, rows: [{ ...comparison.rows[0], cells: [{ columnId: 'other', text: '其他' }, comparison.rows[0].cells[1]] }] }],
|
||||
['duplicate comparison columns', { ...comparison, columns: [comparison.columns[0], comparison.columns[0]] }],
|
||||
['duplicate comparison rows', { ...comparison, rows: [comparison.rows[0], comparison.rows[0]] }],
|
||||
])('rejects broken graph/table identity: %s', (_name, content) => {
|
||||
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty title', { ...structure, title: ' ' }],
|
||||
['long title', { ...structure, title: '字'.repeat(121) }],
|
||||
['long label', { ...structure, nodes: [{ id: 'a', label: '字'.repeat(601) }] }],
|
||||
['unsafe id', { ...structure, nodes: [{ id: 'node a', label: '甲' }] }],
|
||||
['long id', { ...structure, nodes: [{ id: 'x'.repeat(65), label: '甲' }] }],
|
||||
['empty nodes', { ...structure, nodes: [] }],
|
||||
['too many nodes', { ...structure, nodes: Array.from({ length: 25 }, (_, i) => ({ id: 'n' + i, label: '节点' })) }],
|
||||
['too many edges', { ...flow, edges: Array.from({ length: 41 }, (_, i) => ({ id: 'e' + i, from: 'door', to: 'awake' })) }],
|
||||
['too many rows', { ...comparison, rows: Array.from({ length: 13 }, (_, i) => ({ ...comparison.rows[0], id: 'r' + i })) }],
|
||||
['one comparison column', { ...comparison, columns: [comparison.columns[0]] }],
|
||||
['unknown content kind', { kind: 'html', title: '网页', html: '<script>run()</script>' }],
|
||||
['missing field', { kind: 'ideas', title: '想法' }],
|
||||
])('rejects unbounded or unsupported content: %s', (_name, content) => {
|
||||
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
|
||||
});
|
||||
|
||||
it('does not accept sparse arrays or invoke object accessors', () => {
|
||||
expect(() => parseTeacherDiscussionContent({ ...structure, nodes: new Array(2) })).toThrow(Error);
|
||||
const getter = vi.fn(() => '偷偷读取');
|
||||
const content = { ...structure };
|
||||
Object.defineProperty(content, 'title', { get: getter });
|
||||
expect(() => parseTeacherDiscussionContent(content)).toThrow(Error);
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
const arrayGetter = vi.fn(() => structure.nodes[0]);
|
||||
const nodes = [structure.nodes[0]];
|
||||
Object.defineProperty(nodes, 0, { get: arrayGetter });
|
||||
expect(() => parseTeacherDiscussionContent({ ...structure, nodes })).toThrow(Error);
|
||||
expect(arrayGetter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops extra display/execution fields and rejects dangerous object keys without polluting prototypes', () => {
|
||||
const parsed = parseTeacherDiscussionContent({
|
||||
...structure, html: '<svg onload="execute()"/>', action: 'run',
|
||||
nodes: structure.nodes.map(node => ({ ...node, style: { color: 'red' }, onClick: 'execute()' })),
|
||||
});
|
||||
expect(parsed).toEqual(structure);
|
||||
const polluted = JSON.parse('{"kind":"structure","title":"图","nodes":[{"id":"a","label":"节点","__proto__":{"polluted":true}}]}');
|
||||
expect(() => parseTeacherDiscussionContent(polluted)).toThrow(Error);
|
||||
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
|
||||
expect(() => parseTeacherDiscussionContent(Object.assign(Object.create({ injected: true }), structure))).toThrow(Error);
|
||||
expect(parseTeacherDiscussionContent(Object.assign(Object.create(null), structure))).toEqual(structure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teacher discussion model envelope', () => {
|
||||
const envelope = { reply: '我把睡着的分支补好了。', quickReplies: ['还能怎样回应?'], tool: flow };
|
||||
|
||||
it.each([
|
||||
(json: string) => json,
|
||||
(json: string) => '```json\n' + json + '\n```',
|
||||
(json: string) => '```JSON\r\n' + json + '\r\n```',
|
||||
(json: string) => '```\n' + json + '\n```',
|
||||
(json: string) => '```makelore-teacher-discussion\n' + json + '\n```',
|
||||
(json: string) => '下面是整理。\n```json\n' + json + '\n```',
|
||||
(json: string) => '下面是整理。\n' + json,
|
||||
])('accepts structured JSON transport without exposing its envelope', wrap => {
|
||||
expect(parseTeacherDiscussionReply(wrap(JSON.stringify(envelope)))).toEqual(envelope);
|
||||
});
|
||||
|
||||
it('keeps legacy prose and ordinary programming examples without selecting a tool from keywords', () => {
|
||||
const prose = '我们可以用流程图看看。\n```js\nif (awake) welcome();\n```';
|
||||
expect(parseTeacherDiscussionReply(prose)).toEqual({ reply: prose, quickReplies: [] });
|
||||
expect(parseTeacherDiscussionReply('柯基、冒险和菜园都是好点子。'))
|
||||
.toEqual({ reply: '柯基、冒险和菜园都是好点子。', quickReplies: [] });
|
||||
});
|
||||
|
||||
it('distinguishes an absent tool from an explicit null and supports a tool-only envelope', () => {
|
||||
expect(parseTeacherDiscussionReply('{"reply":"这轮只解释一下。"}'))
|
||||
.toEqual({ reply: '这轮只解释一下。', quickReplies: [] });
|
||||
expect(parseTeacherDiscussionReply('{"reply":"先聊聊。","tool":null}'))
|
||||
.toEqual({ reply: '先聊聊。', quickReplies: [], tool: null });
|
||||
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '', tool: ideas })))
|
||||
.toEqual({ reply: '', quickReplies: [], tool: ideas });
|
||||
});
|
||||
|
||||
it('preserves the valid reply but omits an invalid tool so the service can keep the current component', () => {
|
||||
const parsed = parseTeacherDiscussionReply(JSON.stringify({
|
||||
reply: '你的补充我收到了。', quickReplies: ['接着聊'],
|
||||
tool: { ...flow, edges: [{ id: 'broken', from: 'door', to: 'missing' }] },
|
||||
}));
|
||||
expect(parsed.reply).toBe('你的补充我收到了。');
|
||||
expect(parsed.quickReplies).toEqual(['接着聊']);
|
||||
expect(parsed.tool).toBeUndefined();
|
||||
expect(parsed.toolError).toBeTruthy();
|
||||
expect(JSON.stringify(parsed)).not.toContain('broken');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"reply":"说明已经完整。","tool":{"kind":"flow",',
|
||||
'```json\n{"reply":"说明已经完整。","tool":',
|
||||
'整理结果:\n{"reply":"说明已经完整。","tool":',
|
||||
'{"quickReplies":[],"tool":{"nodes":[]},"reply":"说明已经完整。",',
|
||||
])('recovers a complete top-level reply from truncated JSON: %s', raw => {
|
||||
const parsed = parseTeacherDiscussionReply(raw);
|
||||
expect(parsed.reply).toBe('说明已经完整。');
|
||||
expect(parsed.tool).toBeUndefined();
|
||||
expect(parsed.toolError).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"reply":"还没有说完',
|
||||
'{"tool":{"reply":"不能拿嵌套结构充当正文"},',
|
||||
'```json\n{"tool":{"secret":"raw-data"}}',
|
||||
'```json\nnot valid json\n```',
|
||||
'[{"reply":"不是合法 envelope"}]',
|
||||
'{"reply":13,"tool":{"kind":"html"}}',
|
||||
'```json\n{"reply":"结束"}\n```\n```json\n{"reply":"第二个"}\n```',
|
||||
])('never exposes malformed JSON as the student reply: %s', raw => {
|
||||
const parsed = parseTeacherDiscussionReply(raw);
|
||||
expect(parsed.reply).not.toContain('{');
|
||||
expect(parsed.reply).not.toContain('```');
|
||||
expect(parsed.reply).not.toContain('raw-data');
|
||||
expect(parsed.reply).not.toContain('不能拿嵌套结构');
|
||||
expect(parsed.reply).not.toContain('不是合法 envelope');
|
||||
expect(parsed.reply).toBeTruthy();
|
||||
expect(parsed.tool).toBeUndefined();
|
||||
expect(parsed.toolError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('recovers JSON-escaped reply strings without copying following malformed data', () => {
|
||||
const reply = '你说:“先别叫醒它。”\n这个想法可以留下。';
|
||||
const parsed = parseTeacherDiscussionReply('{"reply":' + JSON.stringify(reply) + ',"tool":{');
|
||||
expect(parsed.reply).toBe(reply);
|
||||
expect(parsed.toolError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('bounds transport by UTF-8 bytes and keeps only a recovered readable reply', () => {
|
||||
const raw = JSON.stringify({ reply: '简短回复保留。', tool: flow, ignored: '中'.repeat(22000) });
|
||||
expect(raw.length).toBeLessThan(64000);
|
||||
expect(new TextEncoder().encode(raw).length).toBeGreaterThan(64000);
|
||||
const parsed = parseTeacherDiscussionReply(raw);
|
||||
expect(parsed.reply).toBe('简短回复保留。');
|
||||
expect(parsed.tool).toBeUndefined();
|
||||
expect(parsed.toolError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('bounds quick replies independently while retaining a valid component', () => {
|
||||
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '想一想。', tool: structure, quickReplies: [' 再解释 ', '再解释'] })))
|
||||
.toEqual({ reply: '想一想。', tool: structure, quickReplies: ['再解释'] });
|
||||
for (const invalid of [['一', '二', '三', '四'], ['x'.repeat(121)], [13], ['']]) {
|
||||
expect(parseTeacherDiscussionReply(JSON.stringify({ reply: '想一想。', tool: structure, quickReplies: invalid })))
|
||||
.toEqual({ reply: '想一想。', tool: structure, quickReplies: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds plain and structured reply text and does not propagate hostile envelope keys', () => {
|
||||
expect(parseTeacherDiscussionReply('字'.repeat(12001)).reply).toHaveLength(12000);
|
||||
const longReply = parseTeacherDiscussionReply(JSON.stringify({ reply: '字'.repeat(12001) }));
|
||||
expect(longReply.reply.length).toBeLessThan(100);
|
||||
expect(longReply.toolError).toBeTruthy();
|
||||
const hostile = parseTeacherDiscussionReply('{"reply":"说明保留。","__proto__":{"polluted":true}}');
|
||||
expect(hostile.reply).toBe('说明保留。');
|
||||
expect(hostile.toolError).toBeTruthy();
|
||||
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user