Files
Endoleap/tests/headless/import-json-array.test.mjs
T

24 lines
4.3 KiB
JavaScript

/**
* JSON 数组渐进读取测试。按字节游标扫描 UTF-8 与嵌套结构,可保留已接受前缀并将坏尾部作为显式失败行。
* 读取器还需限制单条解码大小,在固定字节工作量或实际耗时达到预算时让出。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
// 每次读取提供独立未取消信号,取消用例显式使用自己的 controller。
const signal=()=>new AbortController().signal;
// 把文本包装成 Blob,绑定 fingerprint,并允许各用例覆盖 chunkBytes、单条上限及调度端口。
function source(text,options={}){return createJsonArrayImportSource({bytes:new Blob([text]),fingerprint:'immutable-source',...options});}
// 以 nextCursor 顺序排空且断言游标递增,最多 100 行,避免测试在读取器不前进时无限循环。
// 收集返回行而非只收 value:错误行也推进游标,调用方才能报告坏记录并继续或结束。
async function all(reader){let cursor=0;const rows=[];for(let i=0;i<100;i++){const row=await reader.read(cursor,signal());if(!row)return rows;assert.ok(row.nextCursor>cursor);rows.push(row);cursor=row.nextCursor;}throw Error('reader did not terminate');}
// 每字节分块处理中文、转义引号和字符串中的括号/逗号,结果与原生 JSON 相同;换块宽重开仍能从旧字节游标继续。
test('array reader handles UTF8, escaped strings and nested delimiters across one-byte chunks and resumes from the same byte cursor',async()=>{const text=' [ {"title":"图\\\"片,]","nested":[1,{"x":"]"}]},\n {"title":"second"} ] ',reader=source(text,{chunkBytes:1}),rows=await all(reader);assert.deepEqual(rows.map(r=>r.value),JSON.parse(text));assert.ok(rows.every(r=>!r.error));const reopened=source(text,{chunkBytes:3});assert.deepEqual(await reopened.read(rows[1].cursor,signal()),rows[1]);assert.equal(await reopened.read(rows[1].nextCursor,signal()),null);});
// 缺失/多余分隔和尾部垃圾保留已接受第一项,随后报告失败;空数组正常,空文件或非法尾逗号明确拒绝。
// “前缀保留”指读取器已返回的行,不涉及 editor 发布、history 或后端同步;本文件未创建这些组件。
test('structurally malformed tails report failure after the already accepted prefix',async()=>{for(const tail of [',]',',','','] garbage']){const rows=await all(source('[{"id":1},{"id":2}'+tail));assert.deepEqual(rows[0].value,{id:1});assert.ok(rows.at(-1).error,tail);}assert.deepEqual(await all(source('[]')),[]);await assert.rejects(all(source('')),/invalid_json_array/);assert.equal((await all(source('[1,]')))[0].error,'invalid_json_array');});
// 超大字符串只扫描不解码,报告单条错误后仍读到后续对象;非法 UTF-8 保留明确错误。
test('oversized records skip bounded payload and allow a later valid record; bad UTF8 remains explicit',async()=>{const rows=await all(source(JSON.stringify(['x'.repeat(200),{ok:true}]),{maxRecordBytes:32,chunkBytes:8}));assert.equal(rows[0].error,'record_too_large');assert.deepEqual(rows[1].value,{ok:true});const reader=createJsonArrayImportSource({bytes:new Blob([new Uint8Array([91,34,255,34,93])]),fingerprint:'bad'});assert.equal((await all(reader))[0].error,'invalid_utf8');});
// 长扫描在工作量边界让出,yield 内取消不能返回已完成记录;负字节游标拒绝。
test('long scans yield and honor abort without publishing a completed record',async()=>{const controller=new AbortController();let yields=0;const reader=source(JSON.stringify(['x'.repeat(40000)]),{now:()=>0,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);await assert.rejects(reader.read(-1,signal()),/invalid_import_cursor/);});
// 即使只扫描 2048 字符,耗时预算先到也应让出,取消错误保持 import_aborted。
test('slow array scans yield by elapsed time before the fixed byte ceiling and abort at that boundary',async()=>{const controller=new AbortController();let clock=0,yields=0;const reader=source(JSON.stringify(['x'.repeat(2048)]),{now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);});