144 lines
14 KiB
JavaScript
144 lines
14 KiB
JavaScript
'use strict';
|
|
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
|
for(const marker of ['GATE_ARROW','legacyStorageKeys','saveFailed','minimapTransform','generatorMode','hydrateRemaining','boardAtGlobalCell','areaDifficultyShift','plannedExpansionFrontiers','metaIsVisible','mergeLatestFromStorage','shopBtn','shopCountEl']){
|
|
assert(!app.includes(marker),`Dead marker remains: ${marker}`);
|
|
}
|
|
const saveSource=functionSource('save'),persistSource=functionSource('persistDirtyToDb'),
|
|
pullCloudSource=functionSource('pullCloudWorld'),pushCloudSource=functionSource('pushCloudPending');
|
|
assert(!saveSource.includes('writeCompactMirror'),'save() still writes the full mirror before persistence');
|
|
assert((persistSource.match(/snapshotForStorage\(/g)||[]).length===1&&(persistSource.match(/writeCompactMirror\(/g)||[]).length===1,'IndexedDB persistence still builds or writes a full mirror');
|
|
assert(persistSource.includes('scheduleMirrorCheckpoint()'),'Successful IndexedDB persistence does not schedule an idle recovery checkpoint');
|
|
assert(!app.includes("pagehide',()=>{try{writeCompactMirror")&&!app.includes("visibilitychange',()=>{if(document.visibilityState==='hidden')try{writeCompactMirror"),'Lifecycle handlers still rebuild the mirror directly');
|
|
assert(pullCloudSource.indexOf('cloudProfileIdentity()!==profileIdentity')<pullCloudSource.indexOf('data.cloudRevision='),'Cloud pull mutates revision before confirming the active profile');
|
|
assert(pushCloudSource.includes('mergeCloudPending(cloudPushPending,pending)')&&pushCloudSource.indexOf('cloudProfileIdentity()!==profileIdentity')<pushCloudSource.indexOf('acknowledgeCloudPending('),'Cloud push acknowledges work before confirming the active profile');
|
|
{
|
|
const batchContext={};vm.createContext(batchContext);vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('takeCloudPendingBatch')}\nthis.takeCloudPendingBatch=takeCloudPendingBatch;`,batchContext);
|
|
const source={metaIds:new Set(Array.from({length:600},(_,index)=>`B${index}`)),stateIds:new Set(['B0']),deleted:new Set(['B999']),globalChanged:true},
|
|
{batch,remainder}=batchContext.takeCloudPendingBatch(source,512);
|
|
assert(batch.metaIds.size===512&&remainder.metaIds.size===88&&remainder.stateIds.has('B0')&&remainder.deleted.has('B999')&&batch.globalChanged,'Cloud change batching lost or exceeded work');
|
|
}
|
|
|
|
const writes={meta:0,state:0,global:0,outbox:0,recovery:0,mirror:0,snapshot:0,checkpoint:0,revision:0,journalClear:0,signal:0,cloud:0};
|
|
const recoveryWrites=[],journalClears=[];
|
|
const request=result=>({result});
|
|
const stores={
|
|
control:{get:()=>request({key:'active',activeFormat:2,activeEpoch:'world:test-epoch'})},
|
|
worlds:{get:()=>request({epoch:'world:test-epoch',status:'active',createdAt:1,source:{kind:'fresh'},global:null}),put:()=>writes.global++},
|
|
boardIndex:{put:()=>{},delete:()=>{},get:()=>request(null)},boardPuzzles:{put:()=>writes.meta++,delete:()=>{},get:()=>request(null)},boardStates:{put:()=>writes.state++,delete:()=>{},get:()=>request(null)},
|
|
outboxV2:{put:()=>writes.outbox++,delete:()=>{}},recoveryV2:{put:row=>{writes.recovery++;recoveryWrites.push(row)},delete:()=>{}},tombstonesV2:{put:()=>{},delete:()=>{},get:()=>request(null)}
|
|
};
|
|
const context={
|
|
console,sessionId:'test-session',lastRevision:100,
|
|
data:{worldEpoch:'world:test-epoch',globalRev:10,globalRevAuthor:'test-session',metas:{B0:{id:'B0',rev:7,revAuthor:'test-session'}},states:{B0:{rev:9,revAuthor:'test-session'}},updatedAt:0,clockFloor:0},
|
|
dirtyMetaIds:new Set(['B0']),dirtyStateIds:new Set(['B0']),deletedBoardIds:new Set(),deletedBoardRevisions:new Map(),
|
|
deletedBoardAuthors:new Map(),
|
|
recoveryJournalsToCover:[{sessionId:'prior-session',seq:6,_storageKey:'journal:prior'}],recoveryJournalSeq:3,
|
|
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),cloudJournalDeletedIds:new Set(),cloudApiEnabled:false,
|
|
globalDirty:true,globalChangeSeq:3,cloudJournalGlobalChanged:true,idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,storageKey:'save',
|
|
pruneAndCount:()=>{},hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
|
|
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test-epoch',rememberWorldEpoch:()=>true,
|
|
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),newerRevisionValue:(a,b)=>((a?.rev||0)>=(b?.rev||0)?a:b),
|
|
trustedNow:()=>123456,
|
|
globalForStorage:(source,updatedAt)=>({worldEpoch:source.worldEpoch,globalRev:source.globalRev,globalRevAuthor:source.globalRevAuthor,updatedAt,clockFloor:updatedAt}),
|
|
mergeGlobalRecords:(_stored,incoming)=>incoming,applyGlobalRecordToData:record=>Object.assign(context.data,record),staleWorldEpochError:()=>Object.assign(new Error('stale'),{code:'STALE_WORLD_EPOCH'}),
|
|
metaForStorage:value=>({...value}),stateForStorage:value=>({...value}),
|
|
metaRowsForStorage:ids=>ids.map(id=>context.data.metas[id]&&({...context.data.metas[id]})).filter(Boolean),
|
|
stateRowsForStorage:ids=>ids.map(id=>context.data.states[id]&&({id,value:{...context.data.states[id]}})).filter(Boolean),
|
|
snapshotForStorage:updatedAt=>{writes.snapshot++;return{updatedAt}},
|
|
openWorldDb:async()=>({transaction:()=>({objectStore:name=>stores[name],abort(){}})}),transactionDone:async()=>{},requestValue:async req=>req.result,
|
|
recoveryWalPromise:Promise.resolve(),normalizeMeta:(_id,value)=>value,normalizeState:value=>value,normalizedStateObjects:new WeakSet(),
|
|
boardIndexSummaries:new Map(),
|
|
metaFromV2Records:()=>null,stateFromV2Record:()=>null,puzzleRecordV2:value=>value,stateRecordV2:(_id,value)=>value,summarizeBoardV2:value=>value,
|
|
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
|
writeCompactMirror:snapshot=>{assert(snapshot.updatedAt===123456,'Mirror did not use the captured persistence snapshot');writes.mirror++;return true},safeLocalSet:()=>{writes.mirror++;return true},
|
|
scheduleMirrorCheckpoint:()=>writes.checkpoint++,
|
|
updateStorageRevision:()=>writes.revision++,clearRecoveryJournalIfCovered:(seq,covered)=>{writes.journalClear++;journalClears.push({seq,covered})},
|
|
scheduleCloudPush:()=>writes.cloud++,
|
|
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
|
|
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{},deepClone:value=>JSON.parse(JSON.stringify(value)),resetHistory:[],invalidateStoreEffectCache:()=>{},statsDirty:false
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,context);
|
|
(async()=>{
|
|
const first=await context.persistDirtyToDb();
|
|
assert(!first.skipped,'Dirty save was skipped');
|
|
assert(writes.meta===1&&writes.state===1&&writes.global===1,'Incremental database stores were not written once');
|
|
assert(writes.snapshot===0&&writes.mirror===0,'Ordinary IndexedDB persistence built or synchronously wrote a full mirror');
|
|
assert(writes.checkpoint===1,'Ordinary IndexedDB persistence did not schedule one idle mirror checkpoint');
|
|
assert(recoveryWrites.some(row=>row.key==='covered:test-session'&&row.value.seq===3)&&recoveryWrites.some(row=>row.key==='covered:prior-session'&&row.value.seq===6),'Persistence did not atomically write current and recovered-session coverage markers');
|
|
assert(journalClears.length===1&&journalClears[0].seq===3&&journalClears[0].covered[0]._storageKey==='journal:prior','Committed recovery journals were not passed to cleanup after persistence');
|
|
assert(context.dirtyMetaIds.size===0&&context.dirtyStateIds.size===0&&!context.globalDirty,'Dirty state was not cleared after persistence');
|
|
const second=await context.persistDirtyToDb();
|
|
assert(second.skipped,'Unchanged persistence did not exit early');
|
|
assert(writes.snapshot===0&&writes.mirror===0&&writes.checkpoint===1&&writes.global===1,'Unchanged persistence performed additional work');
|
|
|
|
context.deletedBoardIds.add('B9');context.deletedBoardRevisions.set('B9',10);
|
|
context.transactionDone=async()=>{context.deletedBoardRevisions.set('B9',11)};
|
|
const deletionCommit=await context.persistDirtyToDb();
|
|
assert(!deletionCommit.skipped&&context.deletedBoardIds.has('B9')&&context.deletedBoardRevisions.get('B9')===11,'A newer deletion tombstone was cleared by an older in-flight commit');
|
|
|
|
context.dirtyMetaIds.add('B0');context.dirtyStateIds.add('B0');context.globalDirty=true;context.globalChangeSeq++;
|
|
const beforeFailure={mirror:writes.mirror,snapshot:writes.snapshot,checkpoint:writes.checkpoint,signal:writes.signal,cloud:writes.cloud};
|
|
context.transactionDone=async()=>{throw new Error('transaction failed')};
|
|
let failed=false;try{await context.persistDirtyToDb()}catch(_){failed=true}
|
|
assert(failed,'Database failure was swallowed');
|
|
assert(context.dirtyMetaIds.has('B0')&&context.dirtyStateIds.has('B0')&&context.globalDirty,'Database failure cleared dirty state');
|
|
assert(writes.mirror===beforeFailure.mirror&&writes.snapshot===beforeFailure.snapshot&&writes.checkpoint===beforeFailure.checkpoint&&writes.signal===beforeFailure.signal&&writes.cloud===beforeFailure.cloud,'Database failure published or checkpointed an uncommitted save');
|
|
|
|
context.idbAvailable=false;context.transactionDone=async()=>{};context.writeCompactMirror=()=>false;context.storageAccessError=new Error('mirror failed');
|
|
failed=false;try{await context.persistDirtyToDb()}catch(_){failed=true}
|
|
assert(failed,'Mirror-only storage failure was swallowed');
|
|
assert(writes.snapshot===1,'Mirror-only storage did not build its required fallback snapshot');
|
|
assert(context.dirtyMetaIds.has('B0')&&context.dirtyStateIds.has('B0')&&context.globalDirty,'Mirror failure cleared dirty state');
|
|
|
|
let scheduled=0,immediate=0;
|
|
const saveContext={
|
|
Promise,SAVE_DELAY:180,saveTimer:null,lifecyclePersistenceSuppressed:false,
|
|
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},runDeferredSave:()=>{},
|
|
setSaveStatus:()=>{},clearTimeout:()=>{},setTimeout:()=>{scheduled++;return 1}
|
|
};
|
|
vm.createContext(saveContext);vm.runInContext(`${saveSource}\nthis.save=save;`,saveContext);
|
|
assert(saveContext.save()===true&&scheduled===0,'No-op save scheduled work');
|
|
await saveContext.save(true);assert(immediate===0,'No-op immediate save invoked persistence');
|
|
saveContext.hasPendingPersistence=()=>true;
|
|
saveContext.save();assert(scheduled===1,'Dirty deferred save was not scheduled');
|
|
await saveContext.save(true);assert(immediate===1,'Dirty immediate save did not persist');
|
|
|
|
let checkpointPersists=0;
|
|
const checkpointTimers=[],checkpointContext={
|
|
cloudCheckpointRetryTimer:0,hasPendingPersistence:()=>true,persistNow:async()=>{checkpointPersists++;return false},
|
|
setTimeout:(callback,delay)=>{checkpointTimers.push({callback,delay});return checkpointTimers.length}
|
|
};
|
|
vm.createContext(checkpointContext);vm.runInContext(`${functionSource('scheduleCloudCheckpointRetry')}\nthis.scheduleCloudCheckpointRetry=scheduleCloudCheckpointRetry;`,checkpointContext);
|
|
checkpointContext.scheduleCloudCheckpointRetry();checkpointContext.scheduleCloudCheckpointRetry();
|
|
assert(checkpointTimers.length===1&&checkpointTimers[0].delay===1000,'Cloud checkpoint retries were not coalesced behind one timer');
|
|
checkpointTimers[0].callback();await Promise.resolve();await Promise.resolve();
|
|
assert(checkpointPersists===1&&checkpointTimers.length===2,'Failed cloud checkpoint was not retried after the guarded persistence attempt');
|
|
|
|
let profileRetrySchedules=0;
|
|
const profileContext={
|
|
data:{cloudProfile:null,cloudRevision:9},serverClockOffset:null,Date,
|
|
fetchJson:async()=>({playerId:'a'.repeat(16),token:'b'.repeat(32),serverTime:Date.now()}),
|
|
markGlobalDirty:()=>{},persistNow:async()=>false,scheduleCloudCheckpointRetry:()=>profileRetrySchedules++
|
|
};
|
|
vm.createContext(profileContext);vm.runInContext(`${functionSource('createCloudProfile')}\nthis.createCloudProfile=createCloudProfile;`,profileContext);
|
|
let profileFailed=false;try{await profileContext.createCloudProfile()}catch(_){profileFailed=true}
|
|
assert(profileFailed&&profileRetrySchedules===1&&profileContext.data.cloudProfile?.playerId==='a'.repeat(16),'Uncommitted cloud profile was treated as a completed checkpoint');
|
|
|
|
let acknowledged=0,armedDelay=null;
|
|
const pending={metaIds:new Set(['B0']),stateIds:new Set(),deleted:new Set(),globalChanged:false};
|
|
const cloudContext={
|
|
data:{cloudProfile:{playerId:'a'.repeat(16),token:'b'.repeat(32)},cloudRevision:5,metas:{B0:{id:'B0',rev:7}},states:{}},
|
|
cloudAvailable:true,cloudSyncing:false,cloudPushTimer:null,cloudPushPending:pending,cloudJournalChangeSeq:1,serverClockOffset:null,
|
|
clearTimeout:()=>{},setCloudStatus:()=>{},sharedWorldGlobalSignature:()=>"world",sharedWorldGlobalForCloud:()=>({}),lastCloudWorldGlobalSignature:"old",applyCloudEnvelope:()=>{},cloudRowsForStorage:async ids=>({metas:[...ids].map(id=>({id,rev:7})),states:[]}),
|
|
cloudAuthHeaders:()=>({}),fetchJson:async()=>{cloudContext.data.cloudProfile={playerId:'c'.repeat(16),token:'d'.repeat(32)};return{revision:6,serverTime:Date.now()}},
|
|
acknowledgeCloudPending:()=>acknowledged++,markGlobalDirty:()=>{},persistNow:async()=>true,scheduleCloudCheckpointRetry:()=>{},
|
|
pullCloudWorld:async()=>true,armCloudPush:delay=>{armedDelay=delay;return true},console:{warn:()=>{}},Date
|
|
};
|
|
vm.createContext(cloudContext);
|
|
vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('mergeCloudPending')}\n${functionSource('takeCloudPendingBatch')}\n${functionSource('cloudPendingHasWork')}\n${functionSource('cloudProfileIdentity')}\n${pushCloudSource}\nthis.pushCloudPending=pushCloudPending;`,cloudContext);
|
|
const profileChangedResult=await cloudContext.pushCloudPending();
|
|
assert(profileChangedResult===false&&acknowledged===0&&cloudContext.data.cloudRevision===5,'Cloud response from an obsolete profile was acknowledged');
|
|
assert(cloudContext.cloudPushPending.metaIds.has('B0')&&armedDelay===5000,'Profile-switch guard did not restore and defer pending cloud work');
|
|
console.log('Save pipeline cleanup test passed');
|
|
})().catch(error=>{console.error(error);process.exitCode=1});
|