// @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: '' }], ['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: '', 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); }); });