269 lines
14 KiB
TypeScript
269 lines
14 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('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);
|
|
});
|
|
});
|