76 lines
5.1 KiB
TypeScript
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()}
|
|
});
|