Files
makelore/tests/unit/teacher-discussion-state.test.ts
2026-09-23 19:13:51 +08:00

316 lines
16 KiB
TypeScript

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