Files
thu-maic--openmaic/tests/pbl/v2/eval-tail-parser.test.ts
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

279 lines
10 KiB
TypeScript

/**
* Tests for the evaluator JSON-tail parser + normalizers.
*
* Heavy on the failure-mode coverage — these are the same modes the
* v1 Python repo's evaluator iteratively fixed. Encoding them all
* here means a regression shows up as a red test, not as a learner
* staring at a star rating that says "good".
*/
import { describe, expect, it } from 'vitest';
import {
normalizeOptionalString,
normalizeScore,
normalizeStars,
normalizeStringList,
parseEvaluationTail,
sanitizeMilestoneEvaluationFeedback,
stripEvaluationTail,
stripTemplatePlaceholders,
} from '@/lib/pbl/v2/operations/eval-tail-parser';
describe('parseEvaluationTail', () => {
it('returns null on empty / blank input', () => {
expect(parseEvaluationTail('')).toBeNull();
expect(parseEvaluationTail(' ')).toBeNull();
});
it('returns null when no JSON tail is present', () => {
expect(parseEvaluationTail('Great work today!')).toBeNull();
});
it('parses a single fenced ```json block', () => {
const text =
'反馈段落...\n\n```json\n{"strengths": ["a"], "improvements": ["b"], "score": 80}\n```\n';
const out = parseEvaluationTail(text);
expect(out).toEqual({ strengths: ['a'], improvements: ['b'], score: 80 });
});
it('parses a JSON-only task evaluation object with feedback', () => {
const text =
'{"feedback":"这次提交展示了可运行的输入输出,已经达到继续要求。","strengths":["输出清楚"],"improvements":["补充边界样例"],"score":72}';
expect(parseEvaluationTail(text)).toEqual({
feedback: '这次提交展示了可运行的输入输出,已经达到继续要求。',
strengths: ['输出清楚'],
improvements: ['补充边界样例'],
score: 72,
});
});
it('parses fenced JSON even when the closing fence is on the JSON line', () => {
const text =
'反馈段落...\n\n```json\n{"strengths": ["a"], "improvements": ["b"], "score": 80}```';
expect(parseEvaluationTail(text)).toEqual({
strengths: ['a'],
improvements: ['b'],
score: 80,
});
});
it('takes the LAST fenced block when multiple exist (narrative example + real tail)', () => {
const text =
'Example shape:\n```json\n{"sample": "ignore"}\n```\n\n' +
'实际反馈...\n\n```json\n{"strengths": ["x"], "improvements": ["y"]}\n```\n';
expect(parseEvaluationTail(text)).toEqual({
strengths: ['x'],
improvements: ['y'],
});
});
it('falls back to a trailing balanced {...} when no fence', () => {
const text = '反馈段落...\n\n{"learned": ["a", "b"], "performance": "p", "stars": 4.5}';
expect(parseEvaluationTail(text)).toEqual({
learned: ['a', 'b'],
performance: 'p',
stars: 4.5,
});
});
it('uses the shared JSON repair parser for malformed fenced blocks', () => {
const text = '反馈\n```json\n{ this is not json\n```';
expect(parseEvaluationTail(text)).toEqual({ 'this is not json': null });
});
it('rejects a top-level array (only object payloads are accepted)', () => {
const text = 'narrative\n```json\n["a", "b"]\n```';
expect(parseEvaluationTail(text)).toBeNull();
});
it('survives a UTF-8 / Chinese narrative + fenced tail', () => {
const text =
'恭喜你完成了这一阶段!你在调试的时候非常有耐心。\n\n' +
'```json\n{"learned": ["读懂报错信息", "用 print 排查"], "performance": "面对错误冷静、自己定位问题", "stars": 4}\n```';
const out = parseEvaluationTail(text);
expect(out).toMatchObject({
stars: 4,
learned: ['读懂报错信息', '用 print 排查'],
});
});
});
describe('stripEvaluationTail', () => {
it('returns input untouched when no fenced tail', () => {
expect(stripEvaluationTail('Good narrative.')).toBe('Good narrative.');
});
it('strips only the LAST fenced block, leaving earlier ones intact', () => {
const text =
'Example block:\n```json\n{"sample": 1}\n```\n\nReal narrative.\n\n```json\n{"x": 1}\n```\n';
const stripped = stripEvaluationTail(text);
expect(stripped).toContain('Example block');
expect(stripped).toContain('{"sample": 1}');
expect(stripped).not.toContain('{"x": 1}');
});
it('trims trailing whitespace after stripping', () => {
const text = 'narrative\n\n```json\n{"x":1}\n```\n\n \n';
expect(stripEvaluationTail(text)).toBe('narrative');
});
it('strips a naked trailing JSON object that parseEvaluationTail accepts', () => {
const text =
'这一阶段你已经完成了删除功能,并用两种情况验证了结果。\n' +
'{"learned":["用 in 判断商品是否在列表里"],"performance":"能用输出检查逻辑。","stars":4.0}';
expect(stripEvaluationTail(text)).toBe(
'这一阶段你已经完成了删除功能,并用两种情况验证了结果。',
);
});
it('does not strip prose that only contains inline JSON-like examples', () => {
const text = '可以把配置写成 {"mode":"demo"},这只是正文示例。';
expect(stripEvaluationTail(text)).toBe(text);
});
});
describe('template placeholder cleanup', () => {
it('removes unresolved template placeholders from structured strings', () => {
expect(stripTemplatePlaceholders('恭喜 {{NAME}} 完成项目')).toBe('恭喜 完成项目');
expect(normalizeStringList(['你完成了 {{PROJECT}}', '{{NAME}}'], 4)).toEqual(['你完成了']);
expect(normalizeOptionalString('下一步可以扩展 {{FEATURE}}')).toBe('下一步可以扩展');
});
});
describe('sanitizeMilestoneEvaluationFeedback', () => {
it('removes next-stage setup sections but keeps the structured JSON tail', () => {
const text = [
'你完成了搭建清单骨架这一阶段,也跑通了添加和展示功能。',
'你遇到变量名问题时能自己回头检查,这是很好的项目调试习惯。',
'',
'当前阶段目标',
'我们接下来要实现删除商品功能。',
'',
'你目前的代码应该长这样',
'```python',
'shopping = []',
'```',
'',
'第一个微任务:编写删除商品函数',
'',
'```json',
'{"learned":["创建列表","封装添加函数"],"performance":"能用运行结果检查功能。","stars":4}',
'```',
].join('\n');
const sanitized = sanitizeMilestoneEvaluationFeedback(text);
expect(sanitized).toContain('你完成了搭建清单骨架这一阶段');
expect(sanitized).not.toContain('当前阶段目标');
expect(sanitized).not.toContain('第一个微任务');
expect(parseEvaluationTail(sanitized)).toEqual({
learned: ['创建列表', '封装添加函数'],
performance: '能用运行结果检查功能。',
stars: 4,
});
});
it('allows a short Continue handover sentence in the reflection card', () => {
const text =
'这个阶段完成了。下一阶段是实现删除功能,点击 Continue 按钮后再继续。\n\n```json\n{"learned":["a"],"performance":"p","stars":4}\n```';
const sanitized = sanitizeMilestoneEvaluationFeedback(text);
expect(sanitized).toContain('下一阶段是实现删除功能');
expect(sanitized).toContain('Continue 按钮');
});
});
describe('normalizeStars', () => {
it('returns null for null / undefined / non-numeric strings / objects', () => {
expect(normalizeStars(null)).toBeNull();
expect(normalizeStars(undefined)).toBeNull();
expect(normalizeStars('good')).toBeNull();
expect(normalizeStars('4 stars')).toBeNull();
expect(normalizeStars({})).toBeNull();
expect(normalizeStars([])).toBeNull();
});
it('returns null for NaN / Infinity', () => {
expect(normalizeStars(Number.NaN)).toBeNull();
expect(normalizeStars(Number.POSITIVE_INFINITY)).toBeNull();
expect(normalizeStars(Number.NEGATIVE_INFINITY)).toBeNull();
});
it('clamps to [0, 5]', () => {
expect(normalizeStars(8.7)).toBe(5);
expect(normalizeStars(-2)).toBe(0);
expect(normalizeStars(5)).toBe(5);
expect(normalizeStars(0)).toBe(0);
});
it('snaps to the nearest 0.5', () => {
expect(normalizeStars(4.5)).toBe(4.5);
expect(normalizeStars(4.3)).toBe(4.5);
expect(normalizeStars(4.7)).toBe(4.5);
expect(normalizeStars(4.8)).toBe(5);
expect(normalizeStars(4.2)).toBe(4);
});
it('parses "n/5" and "n / 5" strings', () => {
expect(normalizeStars('4/5')).toBe(4);
expect(normalizeStars('4.5 / 5')).toBe(4.5);
expect(normalizeStars('3.0/5')).toBe(3);
});
it('parses bare numeric strings', () => {
expect(normalizeStars('4')).toBe(4);
expect(normalizeStars('4.5')).toBe(4.5);
});
});
describe('normalizeScore', () => {
it('rejects non-numeric and NaN', () => {
expect(normalizeScore(null)).toBeNull();
expect(normalizeScore('great')).toBeNull();
expect(normalizeScore(Number.NaN)).toBeNull();
});
it('rounds to integer and clamps to [0, 100]', () => {
expect(normalizeScore(85.7)).toBe(86);
expect(normalizeScore(150)).toBe(100);
expect(normalizeScore(-10)).toBe(0);
});
it('parses "n/100" strings', () => {
expect(normalizeScore('72/100')).toBe(72);
expect(normalizeScore('72 / 100')).toBe(72);
});
});
describe('normalizeStringList', () => {
it('returns [] for non-array', () => {
expect(normalizeStringList(null)).toEqual([]);
expect(normalizeStringList('a,b')).toEqual([]);
expect(normalizeStringList({})).toEqual([]);
});
it('filters non-strings and empties, trims whitespace', () => {
expect(normalizeStringList(['a', ' b ', '', null, 3, 'c'])).toEqual(['a', 'b', 'c']);
});
it('caps at max length', () => {
expect(normalizeStringList(['a', 'b', 'c', 'd'], 2)).toEqual(['a', 'b']);
});
});
describe('normalizeOptionalString', () => {
it('returns null for non-strings / empties', () => {
expect(normalizeOptionalString(null)).toBeNull();
expect(normalizeOptionalString(123)).toBeNull();
expect(normalizeOptionalString('')).toBeNull();
expect(normalizeOptionalString(' ')).toBeNull();
});
it('returns trimmed string', () => {
expect(normalizeOptionalString(' hi ')).toBe('hi');
});
});