Files
makelore/tests/unit/teacher-discussion.test.ts

282 lines
15 KiB
TypeScript

// @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.each([
'[项目说明](https://example.com/readme)\n\n先看已有设计。',
'[1, 2, 3]',
'[{"reply":"普通数组示例"}]',
'```json\nnot valid json\n```',
'{"scripts":{"dev":"vite"},"name":"flybot"}',
'项目配置可以这样写:\n\n```json\n{"name":"flybot","scripts":{"dev":"vite"}}\n```',
'```json\n[{"id":1,"label":"入口"}]\n```',
'{"example":{"reply":"这是数据字段,不是回答协议"}}',
'```js\nconst example = {"reply":"代码示例"};\n```',
])('preserves Markdown links and JSON examples as readable content: %s', raw => {
expect(parseTeacherDiscussionReply(raw)).toEqual({ reply: raw, 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"}}',
'```makelore-teacher-discussion\nnot valid json\n```',
'{"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);
});
});