dnd-character-deploy/tests/character.test.ts

27 lines
3.6 KiB
TypeScript

import 'fake-indexeddb/auto';
import {test} from 'node:test';import assert from 'node:assert/strict';
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema,portraitSchema} from '../src/model.ts';
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});
test('character portrait accepts compact JPEG/WebP image data, rejects unsafe and huge inputs',()=>{
const c=newCharacter(),portrait='data:image/webp;base64,'+'A'.repeat(24);
c.portrait=portrait;assert.equal(characterSchema.parse(c).portrait,portrait);
c.portrait=null;assert.equal(characterSchema.parse(c).portrait,null);
delete c.portrait;assert.equal(characterSchema.parse(c).portrait,undefined);
for(const invalid of ['https://another.site/image.png','data:image/svg+xml;base64,AAAA','data:image/png;base64,AAAA','data:image/webp;base64,@@@','data:image/jpeg;base64,'+'A'.repeat(320001)])
assert.equal(portraitSchema.safeParse(invalid).success,false);
});
test('portrait round trips through offline saves and JSON backup',async()=>{
const db=new CharacterDB('portrait-'+crypto.randomUUID()),c=newCharacter();
c.portrait='data:image/jpeg;base64,'+'a'.repeat(64);
await saveCharacter('alice',c,db);assert.equal((await loadCharacters('alice',db))[0].portrait,c.portrait);
const backup=JSON.stringify((await loadCharacters('alice',db))[0]);
assert.equal(characterSchema.parse(JSON.parse(backup)).portrait,c.portrait);
await db.delete();
});