// @ts-nocheck -- Node's native TypeScript runner resolves explicit .ts imports. import assert from "node:assert/strict"; import test from "node:test"; import { createAnnotation, exportAnnotations, exportTranscripts, formatTime, getHistory, parseAnnotations, parseTranscripts, reasonOptions, validateAnnotation, } from "../lib/annotations.ts"; const transcriptHeader = "call_id,role,start_ms,end_ms,transcript"; const annotationHeader = "call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator"; const withoutId = (row) => Object.fromEntries(Object.entries(row).filter(([key]) => key !== "id")); test("zero is a labeled negative example, and empty remains undecided", () => { const rows = parseAnnotations(`\uFEFF${annotationHeader}\r\ndemo,5200,我明天下午,0,high,mid_turn_pause,A\r\ndemo,9400,听不清,,,audio_issue,A\r\n`); assert.equal(rows[0].can_reply_now, "0"); assert.equal(rows[1].can_reply_now, ""); assert.equal(rows[1].confidence, ""); assert.notEqual(rows[0].id, rows[1].id); assert.deepEqual(parseAnnotations(exportAnnotations(rows)).map(withoutId), rows.map(withoutId)); assert.equal(exportAnnotations(rows).split("\r\n")[0], annotationHeader); assert.ok(!exportAnnotations(rows).includes(rows[0].id)); }); test("commas, doubled quotes and multiline UTF-8 text survive CSV round trips", () => { const csv = `${transcriptHeader}\r\ndemo,user,100,800,"明天,\"\"下午\"\"\r\n都可以"\r\n`; const rows = parseTranscripts(csv); assert.equal(rows[0].transcript, '明天,"下午"\r\n都可以'); assert.deepEqual(parseTranscripts(exportTranscripts(rows)), rows); const annotation = { ...createAnnotation("demo", 500), user_text_at_decision: '明天,"下午"\n先看看', annotator: 'A,"B"' }; assert.deepEqual(parseAnnotations(exportAnnotations([annotation])).map(withoutId), [withoutId(annotation)]); }); test("required headers are validated while reordered columns remain usable", () => { assert.equal(parseTranscripts("transcript,end_ms,role,call_id,start_ms\n可以,40,user,demo,20")[0].transcript, "可以"); assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms\ndemo,user,0,10"), /缺少字段.*transcript/); assert.throws(() => parseTranscripts(`${transcriptHeader},extra\ndemo,user,0,10,可以,x`), /未知字段.*extra/); assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms,transcript,role"), /重复字段/); assert.throws(() => parseAnnotations(""), /为空/); assert.deepEqual(parseAnnotations(annotationHeader), []); }); test("malformed CSV and incorrect column counts give useful line diagnostics", () => { assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"未闭合`), /第 2 行.*没有闭合/); assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"可以"额外`), /结束引号后/); assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,可以"`), /双引号.*转义/); assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,多,一列`), /第 2 行.*实际有 6 列/); }); test("invalid times and roles are rejected instead of being silently coerced", () => { for (const start of ["", "-1", "1.5", "1e3", "9007199254740992"]) { assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,${start},2000,可以`), /非负整数毫秒/); } assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,assistant,0,100,可以`), /agent 或 user/); assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,100,100,可以`), /小于 end_ms/); assert.throws(() => parseTranscripts(`${transcriptHeader}\n,user,0,100,可以`), /call_id 不能为空/); assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,-1,可以,1,high,completed_answer,A`), /非负整数毫秒/); }); test("labels, confidence, known reasons and reason-label consistency are checked", () => { assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,2,high,completed_answer,A`), /can_reply_now 只能/); assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,strong,completed_answer,A`), /confidence 只能/); assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,freeform,A`), /未知的 reason/); assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,0,high,completed_answer,A`), /不一致/); assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,uncertain,A`), /不一致/); for (const option of reasonOptions) { const row = { ...createAnnotation("demo", 100), can_reply_now: option.can_reply_now, reason: option.value }; assert.equal(validateAnnotation(row), null); } }); test("duplicate decision positions are rejected within a call and allowed across calls", () => { assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,completed_answer,A\ndemo,100,等等,0,high,hesitation,B`), /已有标注/); const first = createAnnotation("demo", 100); const duplicate = createAnnotation("demo", 100); assert.match(validateAnnotation(duplicate, [first]), /已有标注/); assert.equal(validateAnnotation(first, [first]), null); assert.throws(() => exportAnnotations([first, first]), /重复/); assert.equal(parseAnnotations(`${annotationHeader}\ndemo,100,,,,,\nother,100,,,,,`).length, 2); }); test("history uses all earlier completed records, excludes crossing and future records, and preserves tied order", () => { const rows = parseTranscripts(`${transcriptHeader}\ndemo,user,300,440,我明天下午\ndemo,agent,100,240,什么时候方便\ndemo,user,400,700,不行后天可以\nother,user,0,5,其他通话\ndemo,user,300,400,同时出现的第二行\ndemo,agent,800,900,后天上午可以吗`); const before = rows.slice(); assert.deepEqual(getHistory(rows, "demo", 520).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]); assert.deepEqual(getHistory(rows, "demo", 440).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]); assert.deepEqual(getHistory(rows, "demo", 100), []); assert.deepEqual(rows, before); }); test("new samples start undecided and time formatting preserves millisecond precision", () => { const row = createAnnotation("demo", 0); assert.equal(row.can_reply_now, ""); assert.equal(row.reason, ""); assert.equal(validateAnnotation(row), null); assert.equal(formatTime(0), "00:00.000"); assert.equal(formatTime(5_200), "00:05.200"); assert.equal(formatTime(62_345), "01:02.345"); assert.equal(formatTime(3_600_000), "60:00.000"); assert.throws(() => createAnnotation("demo", -1), /非负整数/); });