21 lines
3.9 KiB
TypeScript
21 lines
3.9 KiB
TypeScript
import 'fake-indexeddb/auto';import {test} from 'node:test';import assert from 'node:assert/strict';
|
|
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';import {syncCharacters} from '../src/sync.ts';import {newCharacter} from '../src/model.ts';
|
|
function remote(){const rows=new Map<string,any>();const request=(async(url:string,options:any={})=>{if(options.method==='PUT'){const id=decodeURIComponent(url.split('/').pop()!),body=JSON.parse(options.body),old=rows.get(id);if((old?.revision||0)!==body.revision)return new Response('{}',{status:409});const sheet={id,character:body.character,revision:body.revision+1,deleted:body.deleted};rows.set(id,sheet);return Response.json({sheet})}return Response.json({sheets:[...rows.values()]})}) as typeof fetch;return {rows,request}}
|
|
test('existing device saves upload once, pull MCP edits, and delete using account revisions',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,name:'MCP edit'}});await syncCharacters('alice',database,r.request);assert.equal((await loadCharacters('alice',database))[0].name,'MCP edit');assert.equal(await saveCharacter('alice',(await loadCharacters('alice',database))[0],database),false);await deleteCharacter('alice',c.id,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).deleted,true);assert.equal((await loadCharacters('alice',database)).length,0);await database.delete()});
|
|
test('offline conflict preserves local edits as a new copy and pulls remote sheet',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);await saveCharacter('alice',{...c,notes:'Offline edits'},database);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,notes:'MCP edits'}});const result=await syncCharacters('alice',database,r.request);assert.equal(result.conflicts,1);assert.equal(result.characters.length,2);assert.equal(result.characters.find(x=>x.id===c.id)?.notes,'MCP edits');assert.equal(result.characters.find(x=>x.id!==c.id)?.notes,'Offline edits');await syncCharacters('alice',database,r.request);assert.equal(r.rows.size,2);await database.delete()});
|
|
test('network failure retains pending saves and does not touch another local account',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),c=newCharacter();await saveCharacter('alice',c,database);await saveCharacter('bob',{...c,name:'Bob'},database);await assert.rejects(syncCharacters('alice',database,(async()=>{throw new TypeError('offline')}) as typeof fetch));assert.equal((await loadCharacters('alice',database)).length,1);assert.equal((await loadCharacters('bob',database))[0].name,'Bob');await database.delete()});
|
|
|
|
test('portrait remains after sync and later edits, and explicit removal syncs',async()=>{
|
|
const database=new CharacterDB('portrait-sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();
|
|
c.portrait='data:image/webp;base64,'+'a'.repeat(72);
|
|
await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);
|
|
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
|
|
const edited=(await loadCharacters('alice',database))[0];edited.name='Updated bard';
|
|
await saveCharacter('alice',edited,database);await syncCharacters('alice',database,r.request);
|
|
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
|
|
edited.portrait=null;await saveCharacter('alice',edited,database);
|
|
await syncCharacters('alice',database,r.request);
|
|
assert.equal(r.rows.get(c.id).character.portrait,null);
|
|
await database.delete();
|
|
});
|