This commit is contained in:
33333-33333 2026-07-30 15:13:53 +09:00
commit 0a343ecbc5
326 changed files with 15584 additions and 0 deletions

View file

@ -0,0 +1,74 @@
'use strict';
const path=require('path');
const vm=require('vm');
const {assert,root,read}=require('./helpers/app-source');
delete global.BendFieldPersistence;
require(path.join(root,'field-persistence.js'));
const persistence=global.BendFieldPersistence;
assert(persistence&&persistence.ARCHIVE_VERSION===2,'Archive persistence module did not initialize');
const manifest={
saveSchema:31,gameplayVersion:3,worldGeneration:'test-world',appVersion:'47.36',generatorVersion:1,
exportedAt:'2026-07-28T00:00:00.000Z',boardCount:2,estimatedRawBytes:4096,encoding:'ndjson',compression:'identity'
};
const globalState={gameplayVersion:3,bonusEvents:{},fieldEffects:[],specialMechanicsSeen:[],clockFloor:0,lastSolveAt:0,timeAttack:null,timeAttackCooldowns:{3:0,5:0,10:0},lastTimeAttack:null};
const boardRows=[
{id:'B0',meta:{id:'B0',x:0,y:0,chunks:[[0,0]],puzzle:{g:[[0,0,0],[0,1,2]],n:[],valid:[[0,0],[0,1]],solution:[]}},state:{paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0}},
{id:'B1',meta:{id:'B1',x:1,y:0,chunks:[[0,0]],puzzle:{g:[[0,0,0],[0,1,2]],n:[],valid:[[0,0],[0,1]],solution:[]}},state:{paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0}}
];
async function* boards(){for(const row of boardRows)yield structuredClone(row)}
async function encode(compression,module=persistence){
const chunks=[];
const writable=new WritableStream({write(chunk){chunks.push((chunk instanceof Uint8Array?chunk:new Uint8Array(chunk)).slice())}});
const result=await module.writeArchive({writable,manifest:{...manifest,compression},globalState:structuredClone(globalState),boards:boards()});
return{result,blob:new Blob(chunks,{type:'application/x-bend-field-save'}),bytes:Buffer.concat(chunks.map(chunk=>Buffer.from(chunk)))};
}
async function decode(blob,module=persistence){
const seen=[];
const result=await module.readArchive(blob,{onBoard:record=>seen.push(record)});
return{result,seen};
}
(async()=>{
for(const compression of ['identity','gzip']){
if(compression==='gzip'&&typeof CompressionStream!=='function')continue;
const first=await encode(compression),second=await encode(compression),decoded=await decode(first.blob);
assert(first.result.boardCount===2&&decoded.result.boardCount===2&&decoded.seen.map(row=>row.id).join(',')==='B0,B1',`${compression} archive did not round-trip`);
assert(first.result.crc32===decoded.result.footer.crc32,`${compression} archive checksum changed during round-trip`);
assert(first.bytes.equals(second.bytes),`${compression} archive output is not deterministic for identical records`);
}
const identity=await encode('identity'),text=identity.bytes.toString('utf8'),modified=text.replace('"x":1','"x":2');
assert(modified!==text,'Archive corruption fixture did not modify a board record');
let checksumRejected=false;
try{await decode(new Blob([modified]))}catch(error){checksumRejected=error?.code==='CHECKSUM_MISMATCH'}
assert(checksumRejected,'Archive checksum corruption was not rejected');
let truncationRejected=false;
try{await decode(new Blob([text.split('\n').slice(0,-2).join('\n')+'\n']))}catch(error){truncationRejected=/footer|incomplete/i.test(error?.message||'')}
assert(truncationRejected,'Truncated archive was not rejected');
let manifestRejected=false;
try{await persistence.inspectArchive(new Blob([text.replace('\"encoding\":\"ndjson\"','\"encoding\":\"json\"')]))}catch(error){manifestRejected=error?.code==='UNSUPPORTED_ARCHIVE'}
assert(manifestRejected,'Unsupported archive encoding was not rejected at inspection time');
const lines=text.trimEnd().split('\n');
const expectArchiveFailure=async(candidate,pattern,label)=>{let rejected=false;try{await decode(new Blob([candidate]))}catch(error){rejected=pattern.test(`${error?.code||''} ${error?.message||''}`)}assert(rejected,label)};
const reordered=[...lines];[reordered[2],reordered[3]]=[reordered[3],reordered[2]];await expectArchiveFailure(`${reordered.join('\n')}\n`,/order|B0/i,'Reordered archive boards were accepted');
const duplicate=[...lines];duplicate[3]=duplicate[2];await expectArchiveFailure(`${duplicate.join('\n')}\n`,/duplicated|order/i,'Duplicate archive board IDs were accepted');
const unknown=[...lines],unknownRecord=JSON.parse(unknown[3]);unknownRecord.type='mystery';unknown[3]=JSON.stringify(unknownRecord);await expectArchiveFailure(`${unknown.join('\n')}\n`,/unknown|misplaced/i,'Unknown archive records were accepted');
const wrongFooter=[...lines],footer=JSON.parse(wrongFooter[4]);footer.rawBytes++;wrongFooter[4]=JSON.stringify(footer);await expectArchiveFailure(`${wrongFooter.join('\n')}\n`,/CHECKSUM|integrity/i,'Incorrect footer byte counts were accepted');
const wrongCount=[...lines],declared=JSON.parse(wrongCount[0]);declared.boardCount=1;wrongCount[0]=JSON.stringify(declared);await expectArchiveFailure(`${wrongCount.join('\n')}\n`,/more boards|count/i,'Incorrect manifest board counts were accepted');
const aborted=new AbortController();aborted.abort();let canceled=false;try{await persistence.readArchive(identity.blob,{signal:aborted.signal})}catch(error){canceled=error?.name==='AbortError'}assert(canceled,'Archive cancellation was not acknowledged');
let writeFailed=false;try{await persistence.writeArchive({writable:new WritableStream({write(){throw new Error('injected destination failure')}}),manifest:{...manifest},globalState:structuredClone(globalState),boards:boards()})}catch(_){writeFailed=true}assert(writeFailed,'Destination write failure did not abort export');
if(typeof CompressionStream==='function'&&typeof DecompressionStream==='function'){const compressed=await encode('gzip'),damaged=Buffer.from(compressed.bytes);damaged[Math.max(10,Math.floor(damaged.length/2))]^=0xff;let gzipRejected=false;try{await decode(new Blob([damaged]))}catch(_){gzipRejected=true}assert(gzipRejected,'Corrupt gzip archive was accepted')}
const worker=read('field-persistence-worker.js'),moduleSource=read('field-persistence.js');
class FakeWorker{
constructor(){this.onmessage=null;this.onerror=null;const owner=this,context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,self:{postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
postMessage(data){queueMicrotask(()=>{try{this.workerSelf.onmessage({data:structuredClone(data)})}catch(error){this.onerror?.({message:error.message})}})}
terminate(){}
}
global.Worker=FakeWorker;delete global.BendFieldPersistence;delete require.cache[require.resolve(path.join(root,'field-persistence.js'))];require(path.join(root,'field-persistence.js'));const workerPersistence=global.BendFieldPersistence;
const workerEncoded=await encode('identity',workerPersistence),workerDecoded=await decode(workerEncoded.blob,workerPersistence);
assert(workerEncoded.result.worker===true&&workerDecoded.result.worker===true&&workerDecoded.seen.length===2,'Archive worker execution path did not round-trip records');
delete global.Worker;global.BendFieldPersistence=persistence;
assert(worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks)'),'Archive worker does not enforce transferable 1 MiB output chunks');
assert(moduleSource.includes("new Worker('field-persistence-worker.js?v=47.77')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
console.log('Field archive round-trip and corruption checks passed');
})().catch(error=>{console.error(error);process.exitCode=1});