Files
gogobee/internal/plugin/dnd_spells_migration_test.go
prosolis b53e516bf0 Adv 2.0 D&D Phase 9 SP4: mage spellbook cap + level-up nudge
- Enforce per-level Mage known-spell cap in handleSpellsLearn; surface
  spellbook budget in renderSpellsList.
- Mage level-up DM now nudges with "Spells available to learn: N" via
  extracted buildLevelUpMessage.
- Extract halveSavedDamage and enemySpellSaveMod for clarity; document
  single-target AoE limitation in applySpellDamageSave.
- Add tests: mage learn cap, prepare flow, AoE behavior, spell save
  rounding, spells migration.
2026-05-09 14:25:21 -07:00

189 lines
5.5 KiB
Go

package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Phase 9 SP4 — ensureSpellsForCharacter migration / idempotency tests.
func mageCharacter(uid id.UserID, level int) *DnDCharacter {
return &DnDCharacter{
UserID: uid, Race: RaceElf, Class: ClassMage, Level: level,
STR: 8, DEX: 14, CON: 12, INT: 17, WIS: 12, CHA: 10,
HPMax: 10, HPCurrent: 10, ArmorClass: 12,
}
}
// TestEnsureSpells_Idempotent — second call leaves the known list unchanged
// and does not duplicate rows.
func TestEnsureSpells_Idempotent(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@migrate_idem:example")
c := mageCharacter(uid, 5)
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
first, _ := listKnownSpells(uid)
if len(first) == 0 {
t.Fatal("first ensureSpells: no known spells granted")
}
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
second, _ := listKnownSpells(uid)
if len(second) != len(first) {
t.Errorf("idempotency: first=%d, second=%d", len(first), len(second))
}
// Spell IDs must match exactly (set equality).
wantIDs := map[string]bool{}
for _, r := range first {
wantIDs[r.SpellID] = true
}
for _, r := range second {
if !wantIDs[r.SpellID] {
t.Errorf("second pass introduced new spell: %s", r.SpellID)
}
}
}
// TestEnsureSpells_RefreshesSlotPoolOnLevelChange — when level shifts between
// sessions, the slot pool re-syncs to the new level even if spells already
// exist (the early-exit branch still re-runs setSpellSlotsForLevel).
func TestEnsureSpells_RefreshesSlotPoolOnLevelChange(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@migrate_level:example")
c := mageCharacter(uid, 1)
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
got, _ := getSpellSlots(uid)
if got[1][0] != 2 || got[2][0] != 0 {
t.Errorf("L1 Mage slot pool: %v, want only L1=2", got)
}
// Level up between sessions.
c.Level = 5
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
got, _ = getSpellSlots(uid)
want := slotsForClassLevel(ClassMage, 5)
for lvl, total := range want {
if got[lvl][0] != total {
t.Errorf("post-level-up L%d slots: got total=%d, want %d", lvl, got[lvl][0], total)
}
}
}
// TestEnsureSpells_NonCasterIsNoOp — Fighter call writes nothing.
func TestEnsureSpells_NonCasterIsNoOp(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@migrate_fighter:example")
c := &DnDCharacter{UserID: uid, Class: ClassFighter, Race: RaceHuman, Level: 5}
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
rows, _ := listKnownSpells(uid)
if len(rows) != 0 {
t.Errorf("non-caster got known spells: %+v", rows)
}
slots, _ := getSpellSlots(uid)
if len(slots) != 0 {
t.Errorf("non-caster got slot pool: %+v", slots)
}
}
// TestEnsureSpells_RangerL1CantripsOnly — Ranger L1 has no slots, and the
// granted spells are all cantrips (Level 0).
func TestEnsureSpells_RangerL1CantripsOnly(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@migrate_ranger:example")
c := &DnDCharacter{
UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 1,
STR: 12, DEX: 16, CON: 13, INT: 10, WIS: 14, CHA: 8,
}
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
rows, _ := listKnownSpells(uid)
if len(rows) == 0 {
t.Fatal("ranger L1 got nothing — expected at least cantrips")
}
for _, r := range rows {
s, ok := lookupSpell(r.SpellID)
if !ok {
t.Errorf("unknown spell %q in known list", r.SpellID)
continue
}
if s.Level != 0 {
t.Errorf("ranger L1 got non-cantrip %s (L%d); expected cantrips only",
s.ID, s.Level)
}
}
slots, _ := getSpellSlots(uid)
if len(slots) != 0 {
t.Errorf("ranger L1 has slot pool: %+v, want empty", slots)
}
}
// TestEnsureSpells_LevelAppropriateDefaultsExist — every default spell at
// representative levels is present in the registry and within the player's
// max slot level (or a cantrip).
func TestEnsureSpells_LevelAppropriateDefaultsExist(t *testing.T) {
for _, class := range []DnDClass{ClassMage, ClassCleric, ClassRanger} {
for _, lvl := range []int{1, 2, 5, 9, 13, 20} {
maxSlot := highestAvailableSlot(class, lvl)
for _, sid := range defaultKnownSpells(class, lvl) {
s, ok := lookupSpell(sid)
if !ok {
t.Errorf("%s L%d default %q missing from registry", class, lvl, sid)
continue
}
if s.Level > maxSlot && s.Level > 0 {
t.Errorf("%s L%d default %s requires L%d slot but max is L%d",
class, lvl, sid, s.Level, maxSlot)
}
// Class-list sanity.
ok = false
for _, cl := range s.Classes {
if cl == class {
ok = true
break
}
}
if !ok {
t.Errorf("%s L%d default %s is not on the %s class list",
class, lvl, sid, class)
}
}
}
}
}
// TestEnsureSpells_ClericDefaultsAutoPrepared — auto-migrated Cleric defaults
// are flagged prepared so !cast works immediately. (Pre-SP4 contract; SP4
// keeps it for migration backward-compat — players can later !prepare clear.)
func TestEnsureSpells_ClericDefaultsAutoPrepared(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@migrate_cleric:example")
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: 5,
STR: 12, DEX: 12, CON: 14, INT: 10, WIS: 16, CHA: 12,
}
if err := ensureSpellsForCharacter(c); err != nil {
t.Fatal(err)
}
rows, _ := listKnownSpells(uid)
if len(rows) == 0 {
t.Fatal("cleric got no defaults")
}
for _, r := range rows {
if !r.Prepared {
t.Errorf("cleric default %s not prepared after migration", r.SpellID)
}
}
}