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

76 lines
5.1 KiB
TypeScript

import 'fake-indexeddb/auto';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {newDb} from 'pg-mem';
import {Storage} from '../server/storage.mjs';
import {newCharacter,characterSchema,configureSpellSlotPool,changeSpellSlotPool,spellSlotRestPreview,applyRest} from '../src/model.ts';
import {CharacterDB,saveCharacter,loadCharacters} from '../src/db.ts';
import {characterMcp} from '../server/mcp.mjs';
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
test('slot usage is bounded, rest behavior distinguishes warlock pact from normal slots',()=>{
const c=newCharacter();configureSpellSlotPool(c,'standard',1,4);configureSpellSlotPool(c,'standard',3,2);configureSpellSlotPool(c,'pact',2,2);
changeSpellSlotPool(c,'standard',1,-3);changeSpellSlotPool(c,'standard',3,-1);changeSpellSlotPool(c,'pact',2,-2);
assert.throws(()=>changeSpellSlotPool(c,'pact',2,-1),/No spell slots/);
assert.throws(()=>changeSpellSlotPool(c,'standard',8,-1),/Configure/);
assert.deepEqual(spellSlotRestPreview(c,'short-rest').map(s=>s.name),['Pact Magic (level 2)']);
applyRest(c,'short-rest');
assert.equal(c.spellSlots?.pact?.remaining,2);
assert.equal(c.spellSlots?.levels[0].remaining,1);
applyRest(c,'long-rest');
assert.deepEqual(c.spellSlots?.levels.map(s=>s.remaining),[4,2]);
assert.throws(()=>changeSpellSlotPool(c,'standard',1,1),/already full/);
assert.equal(spellSlotRestPreview(c,'long-rest').length,0);
});
test('reconfigure and old saves preserve uses; bad totals and duplicate levels reject',()=>{
const c=newCharacter();configureSpellSlotPool(c,'standard',1,4);changeSpellSlotPool(c,'standard',1,-2);
configureSpellSlotPool(c,'standard',1,3);
assert.equal(c.spellSlots?.levels[0].remaining,2);
configureSpellSlotPool(c,'standard',1,1);
assert.equal(c.spellSlots?.levels[0].remaining,1);
configureSpellSlotPool(c,'standard',1,0);
assert.deepEqual(c.spellSlots?.levels,[]);
configureSpellSlotPool(c,'pact',5,4);assert.throws(()=>configureSpellSlotPool(c,'pact',6,1));
assert.throws(()=>configureSpellSlotPool(c,'standard',2,21));
assert.throws(()=>configureSpellSlotPool(c,'standard',2,-1));
assert.throws(()=>changeSpellSlotPool(c,'pact',1,-1));
const old=newCharacter();delete old.spellSlots;assert.equal(characterSchema.parse(old).spellSlots,undefined);
const broken={...c,spellSlots:{levels:[{level:1,max:2,remaining:2},{level:1,max:3,remaining:3}],pact:null}};
assert.equal(characterSchema.safeParse(broken).success,false);
});
test('slot pools round trip in Dexie and server preserves omitted slots from older clients',async()=>{
const database=new CharacterDB('slots-'+crypto.randomUUID()),c=newCharacter();
configureSpellSlotPool(c,'standard',2,3);changeSpellSlotPool(c,'standard',2,-1);
try{await saveCharacter('alice',c,database);assert.deepEqual((await loadCharacters('alice',database))[0].spellSlots,c.spellSlots)}finally{await database.delete()}
const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();
await storage.put('alice',c.id,c,0);
const legacy={...c};delete legacy.spellSlots;
await storage.put('alice',c.id,legacy,1);
assert.deepEqual((await storage.get('alice',c.id)).character.spellSlots,c.spellSlots);
await storage.put('alice',c.id,{...legacy,spellSlots:{levels:[],pact:null}},2);
assert.deepEqual((await storage.get('alice',c.id)).character.spellSlots,{levels:[],pact:null});
});
test('MCP spell slot tools enforce revision and read/write scope',async()=>{
const pg=newDb().adapters.createPg(),storage=new Storage(new pg.Pool());await storage.init();
const c=newCharacter();await storage.put('alice',c.id,c,0);
async function connected(scopes:string[]){
const server=characterMcp({storage,sub:'alice',scopes}),client=new Client({name:'slot-test',version:'1'});
const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);
return {client,server};
}
const writer=await connected(['characters:read','characters:write']),reader=await connected(['characters:read']);
try{
const request=(client:Client,name:string,args:Record<string,unknown>)=>client.callTool({name,arguments:{id:c.id,...args}});
const read:any=await request(reader.client,'list_spell_slots',{});
assert.deepEqual(read.structuredContent.spellSlots,{levels:[],pact:null});
assert.equal((await request(reader.client,'configure_spell_slots',{expected_revision:1,kind:'standard',level:1,max:2})).isError,true);
assert.equal((await request(writer.client,'configure_spell_slots',{expected_revision:1,kind:'standard',level:1,max:2})).isError,undefined);
assert.equal((await request(writer.client,'change_spell_slots',{expected_revision:2,kind:'standard',level:1,amount:-1})).isError,undefined);
assert.equal((await request(writer.client,'change_spell_slots',{expected_revision:2,kind:'standard',level:1,amount:-1})).isError,true);
const result:any=await request(reader.client,'list_spell_slots',{});
assert.equal(result.structuredContent.spellSlots.levels[0].remaining,1);
}finally{await writer.client.close();await reader.client.close()}
});