341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
||
* Tests for the messages + evaluations timeline merge in chat.tsx.
|
||
*
|
||
* The merge is the single place chat rendering depends on for
|
||
* "what shows up in what order" — easy to get wrong, hard to spot
|
||
* in manual testing (a one-second timestamp drift can flip the
|
||
* narrative + card sequence in ways that read as "the evaluator
|
||
* fired before the assistant spoke"). Locking this in saves us.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
buildTimeline,
|
||
displayAgentName,
|
||
handoverForMilestoneEvaluation,
|
||
shouldShowStreamingDraft,
|
||
stripLeakedToolJsonPreview,
|
||
streamingEvaluationPreview,
|
||
streamingMessagePreview,
|
||
} from '@/components/scene-renderers/pbl/v2/chat';
|
||
import { stripEmbeddedDividerMarkers } from '@/components/scene-renderers/pbl/v2/protocol-markers';
|
||
import type { PBLChatMessage, PBLEvaluation, PBLHandover } from '@/lib/pbl/v2/types';
|
||
|
||
function msg(id: string, ts: string, content = 'x'): PBLChatMessage {
|
||
return {
|
||
id,
|
||
roleType: 'instructor',
|
||
content,
|
||
ts,
|
||
};
|
||
}
|
||
|
||
function ev(id: string, createdAt: string, kind: PBLEvaluation['kind'] = 'task'): PBLEvaluation {
|
||
return {
|
||
id,
|
||
kind,
|
||
feedback: 'fb',
|
||
strengths: [],
|
||
improvements: [],
|
||
createdAt,
|
||
};
|
||
}
|
||
|
||
describe('buildTimeline', () => {
|
||
it('returns empty when both inputs empty', () => {
|
||
expect(buildTimeline([], [])).toEqual([]);
|
||
});
|
||
|
||
it('preserves message-only order by ts', () => {
|
||
const items = buildTimeline(
|
||
[msg('m2', '2026-01-01T00:00:02Z'), msg('m1', '2026-01-01T00:00:01Z')],
|
||
[],
|
||
);
|
||
expect(items.map((i) => i.kind === 'message' && i.message.id)).toEqual(['m1', 'm2']);
|
||
});
|
||
|
||
it('preserves evaluation-only order by createdAt', () => {
|
||
const items = buildTimeline(
|
||
[],
|
||
[ev('e2', '2026-01-01T00:00:02Z'), ev('e1', '2026-01-01T00:00:01Z')],
|
||
);
|
||
expect(items.map((i) => i.kind === 'evaluation' && i.evaluation.id)).toEqual(['e1', 'e2']);
|
||
});
|
||
|
||
it('interleaves by timestamp', () => {
|
||
const items = buildTimeline(
|
||
[msg('m1', '2026-01-01T00:00:01Z'), msg('m2', '2026-01-01T00:00:05Z')],
|
||
[ev('e1', '2026-01-01T00:00:03Z')],
|
||
);
|
||
const ids = items.map((i) =>
|
||
i.kind === 'message'
|
||
? i.message.id
|
||
: i.kind === 'evaluation'
|
||
? i.evaluation.id
|
||
: 'roleplay-history',
|
||
);
|
||
expect(ids).toEqual(['m1', 'e1', 'm2']);
|
||
});
|
||
|
||
it('puts message before evaluation when timestamps tie', () => {
|
||
const ts = '2026-01-01T00:00:05Z';
|
||
const items = buildTimeline([msg('m', ts)], [ev('e', ts)]);
|
||
expect(items.map((i) => i.kind)).toEqual(['message', 'evaluation']);
|
||
});
|
||
|
||
it('folds roleplay history into ONE collapsible item, slotted between prep and wrapup by its first ts', () => {
|
||
// Instructor thread = prep message (early) + wrapup message (late); the
|
||
// roleplay conversation happened in between (its own thread).
|
||
const prep = msg('prep', '2026-01-01T00:00:01Z');
|
||
const wrapup = msg('wrap', '2026-01-01T00:00:09Z');
|
||
const roleplay = [msg('rp1', '2026-01-01T00:00:04Z'), msg('rp2', '2026-01-01T00:00:06Z')];
|
||
const items = buildTimeline([prep, wrapup], [], roleplay);
|
||
expect(items.map((i) => i.kind)).toEqual(['message', 'roleplay-history', 'message']);
|
||
const block = items.find((i) => i.kind === 'roleplay-history');
|
||
expect(block && block.kind === 'roleplay-history' && block.messages).toHaveLength(2);
|
||
});
|
||
|
||
it('adds NO roleplay-history item when there is none (ordinary projects / during prep or roleplay)', () => {
|
||
const items = buildTimeline([msg('m', '2026-01-01T00:00:01Z')], []);
|
||
expect(items.some((i) => i.kind === 'roleplay-history')).toBe(false);
|
||
});
|
||
|
||
it('puts the task-complete prompt immediately after the evaluation card when its timestamp is after the evaluation', () => {
|
||
const items = buildTimeline(
|
||
[
|
||
msg('before', '2026-01-01T00:00:01.000Z', 'I submitted my work.'),
|
||
msg(
|
||
'ready',
|
||
'2026-01-01T00:00:05.001Z',
|
||
'这个任务已经完成了。如果你准备好了,也没有其他问题了,请点击左侧当前任务里的「完成」按钮进入下一步。',
|
||
),
|
||
],
|
||
[ev('task-eval', '2026-01-01T00:00:05.000Z', 'task')],
|
||
);
|
||
|
||
const ids = items.map((i) =>
|
||
i.kind === 'message'
|
||
? i.message.id
|
||
: i.kind === 'evaluation'
|
||
? i.evaluation.id
|
||
: 'roleplay-history',
|
||
);
|
||
expect(ids).toEqual(['before', 'task-eval', 'ready']);
|
||
});
|
||
|
||
it('keeps each evaluation kind distinct (task/milestone/final preserved in order)', () => {
|
||
const items = buildTimeline(
|
||
[],
|
||
[
|
||
ev('task1', '2026-01-01T00:00:01Z', 'task'),
|
||
ev('ms1', '2026-01-01T00:00:02Z', 'milestone'),
|
||
ev('final1', '2026-01-01T00:00:03Z', 'final'),
|
||
],
|
||
);
|
||
const kinds = items.map((i) => i.kind === 'evaluation' && i.evaluation.kind);
|
||
expect(kinds).toEqual(['task', 'milestone', 'final']);
|
||
});
|
||
});
|
||
|
||
describe('displayAgentName', () => {
|
||
it('falls back when the agent name is missing or blank', () => {
|
||
expect(displayAgentName(undefined, 'Instructor')).toBe('Instructor');
|
||
expect(displayAgentName('', 'Instructor')).toBe('Instructor');
|
||
expect(displayAgentName(' ', 'Instructor')).toBe('Instructor');
|
||
expect(displayAgentName(' 数据项目导师 ', 'Instructor')).toBe('数据项目导师');
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — divider marker display guard', () => {
|
||
it('strips embedded task divider protocol text from normal instructor prose', () => {
|
||
expect(
|
||
stripEmbeddedDividerMarkers(
|
||
'思路已经到位了:你准备了可手算的测试样例。[TASK_DIVIDER]任务完成:准备手算测试样例 | 开始下一任务:确定双指针起点',
|
||
),
|
||
).toBe('思路已经到位了:你准备了可手算的测试样例。');
|
||
});
|
||
|
||
it('keeps text after a later newline while removing only the marker line', () => {
|
||
expect(
|
||
stripEmbeddedDividerMarkers(
|
||
'上一句收尾。\n[TASK_DIVIDER]任务完成:A | 开始下一任务:B\n下一句仍然保留。',
|
||
),
|
||
).toBe('上一句收尾。\n\n下一句仍然保留。');
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — streaming preview guards', () => {
|
||
it('removes fenced evaluator JSON tail while streaming card prose', () => {
|
||
expect(
|
||
streamingEvaluationPreview(
|
||
'这次做得很好,已经完成核心目标。\n\n```json\n{"strengths":["a"],"score":90}\n',
|
||
),
|
||
).toBe('这次做得很好,已经完成核心目标。');
|
||
});
|
||
|
||
it('removes partial fenced evaluator JSON tail while streaming card prose', () => {
|
||
expect(streamingEvaluationPreview('这次做得很好。\n\n```j')).toBe('这次做得很好。');
|
||
});
|
||
|
||
it('removes naked evaluator JSON tail while streaming card prose', () => {
|
||
expect(
|
||
streamingEvaluationPreview('阶段反馈正文。\n{"learned":["a"],"performance":"ok","stars":4'),
|
||
).toBe('阶段反馈正文。');
|
||
});
|
||
|
||
it('removes embedded divider markers from streaming instructor text', () => {
|
||
expect(
|
||
streamingMessagePreview('任务已经完成。[TASK_DIVIDER]任务完成:A | 开始下一任务:B'),
|
||
).toBe('任务已经完成。');
|
||
});
|
||
|
||
it('removes leaked tool JSON from streaming instructor text', () => {
|
||
expect(
|
||
streamingMessagePreview(
|
||
'{"kind":"concept_unlocked","note":"学习者提交了 print(left_money)","signature":"print_left_money_output"}可以,这一步已经能把剩余金额显示出来了。',
|
||
),
|
||
).toBe('可以,这一步已经能把剩余金额显示出来了。');
|
||
});
|
||
|
||
it('hides partial leaked tool JSON while a stream is still incomplete', () => {
|
||
expect(stripLeakedToolJsonPreview('{"kind":"concept_unlocked","note":"学习者提交了')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('shouldShowStreamingDraft', () => {
|
||
it('shows the initial waiting bubble before the first token arrives', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: true,
|
||
instructorStreaming: true,
|
||
draftAssistant: '',
|
||
streamCommittedOutput: false,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('keeps showing while live draft tokens are present', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: true,
|
||
instructorStreaming: true,
|
||
draftAssistant: '正在讲解',
|
||
streamCommittedOutput: false,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('hides the empty draft after the committed message has landed', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: true,
|
||
instructorStreaming: true,
|
||
draftAssistant: '',
|
||
streamCommittedOutput: true,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('hides the parent-count tail after this stream has committed output', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: false,
|
||
instructorStreaming: true,
|
||
draftAssistant: '',
|
||
streamCommittedOutput: true,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('still shows a background stream indicator when remounted mid-stream', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: false,
|
||
instructorStreaming: true,
|
||
draftAssistant: '',
|
||
streamCommittedOutput: false,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('shows a workspace-owned stream with live draft tokens', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: false,
|
||
instructorStreaming: true,
|
||
draftAssistant: '阶段点评正在生成',
|
||
streamCommittedOutput: false,
|
||
hasExternalDraft: true,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('hides a workspace-owned stream after its committed card lands', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: false,
|
||
instructorStreaming: true,
|
||
draftAssistant: '',
|
||
streamCommittedOutput: true,
|
||
hasExternalDraft: true,
|
||
submissionEvaluationActive: false,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('suppresses the instructor draft while submission evaluation owns the status UI', () => {
|
||
expect(
|
||
shouldShowStreamingDraft({
|
||
streaming: true,
|
||
instructorStreaming: true,
|
||
draftAssistant: 'x',
|
||
streamCommittedOutput: false,
|
||
submissionEvaluationActive: true,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('PBL v2 — milestone handover binding', () => {
|
||
const consumedHandover: PBLHandover = {
|
||
completedMilestoneId: 'ms-1',
|
||
completedMilestoneTitle: '阶段一',
|
||
nextMilestoneId: 'ms-2',
|
||
nextMilestoneTitle: '阶段二',
|
||
consumed: true,
|
||
};
|
||
|
||
it('binds a handover only to the milestone evaluation that created it', () => {
|
||
const ev = {
|
||
id: 'eval-ms-1',
|
||
kind: 'milestone',
|
||
milestoneId: 'ms-1',
|
||
feedback: '阶段一完成。',
|
||
strengths: [],
|
||
improvements: [],
|
||
createdAt: '2026-05-29T00:00:01.000Z',
|
||
} as PBLEvaluation;
|
||
|
||
expect(handoverForMilestoneEvaluation(ev, consumedHandover)).toBe(consumedHandover);
|
||
});
|
||
|
||
it('does not show a stale consumed handover on the final milestone card', () => {
|
||
const finalMilestoneEval = {
|
||
id: 'eval-ms-3',
|
||
kind: 'milestone',
|
||
milestoneId: 'ms-3',
|
||
feedback: '最后阶段完成。',
|
||
strengths: [],
|
||
improvements: [],
|
||
createdAt: '2026-05-29T00:00:02.000Z',
|
||
} as PBLEvaluation;
|
||
|
||
expect(handoverForMilestoneEvaluation(finalMilestoneEval, consumedHandover)).toBeUndefined();
|
||
});
|
||
});
|