Add spell catalog overlay resolution

This commit is contained in:
2026-07-20 19:10:44 +00:00
parent 7806dba509
commit 3bfe05ab56
4 changed files with 781 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
package catalog
import (
"encoding/json"
"reflect"
"sort"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestResolveEffectiveCatalogBaseAndImmutability(t *testing.T) {
effective, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
if effective.BaseID() != SRD5E2014ID || effective.Ruleset() != SRD5E2014Ruleset {
t.Fatalf("identity = %q/%q", effective.BaseID(), effective.Ruleset())
}
if got := effective.OverlayIDs(); len(got) != 0 {
t.Fatalf("overlay IDs = %#v", got)
}
names := effective.CanonicalNames()
if len(names) != 319 || !sort.StringsAreSorted(names) {
t.Fatalf("canonical names count/order = %d/%t", len(names), sort.StringsAreSorted(names))
}
if got, ok := effective.Lookup(" HUNTER'S MARK "); !ok || got != "Hunters Mark" {
t.Fatalf("Hunter's Mark lookup = %q, present=%t", got, ok)
}
if got, ok := effective.Lookup("Definitely Not A Spell"); ok || got != "" {
t.Fatalf("unknown lookup = %q, present=%t", got, ok)
}
if !strings.HasPrefix(effective.Digest(), "sha256:") {
t.Fatalf("digest = %q", effective.Digest())
}
names[0] = "changed"
ids := effective.OverlayIDs()
ids = append(ids, "changed")
again, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(names, again.CanonicalNames()) || len(again.OverlayIDs()) != 0 {
t.Fatal("effective catalog exposed mutable result storage")
}
}
func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) {
overlay := testOverlayJSON(t, testOverlayCatalog(
"campaign.example",
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
testOverlaySpell("Cure Wounds", "Healing Touch"),
))
effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json"))
if err != nil {
t.Fatal(err)
}
if got := effective.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) {
t.Fatalf("overlay IDs = %#v", got)
}
if len(effective.CanonicalNames()) != 320 {
t.Fatalf("canonical name count = %d, want 320", len(effective.CanonicalNames()))
}
if got, ok := effective.Lookup("aegis of emberfall"); !ok || got != "Aegis of Emberfall" {
t.Fatalf("new spell lookup = %q, present=%t", got, ok)
}
if got, ok := effective.Lookup("Emberfall Aegis"); !ok || got != "Aegis of Emberfall" {
t.Fatalf("new alias lookup = %q, present=%t", got, ok)
}
if got, ok := effective.Lookup("Healing Touch"); !ok || got != "Cure Wounds" {
t.Fatalf("augmentation alias lookup = %q, present=%t", got, ok)
}
for _, name := range effective.CanonicalNames() {
if name == "Emberfall Aegis" || name == "Healing Touch" {
t.Fatalf("alias %q was exposed as a canonical name", name)
}
}
}
func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) {
single := testOverlayJSON(t, testOverlayCatalog(
"campaign.example",
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
))
repeated := testOverlayJSON(t, testOverlayCatalog(
"campaign.example",
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
testOverlaySpell("Aegis of Emberfall", "emberfall aegis"),
))
first, err := ResolveEffectiveCatalog(overlayReference([]byte(single), "application/json"))
if err != nil {
t.Fatal(err)
}
second, err := ResolveEffectiveCatalog(overlayReference([]byte(repeated), "application/json"))
if err != nil {
t.Fatal(err)
}
if first.Digest() != second.Digest() || !reflect.DeepEqual(first.CanonicalNames(), second.CanonicalNames()) {
t.Fatalf("repeated alias changed effective result: digest=%q/%q", first.Digest(), second.Digest())
}
}
func TestResolveEffectiveCatalogDigestIgnoresOrderingAndFormatting(t *testing.T) {
first := `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.z","ruleset":"dnd-5e-2014","source":{"title":"Z spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Z Alias","Another Alias"]}]},{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"A spells","version":"1","url":"","license":""},"spells":[{"name":"Cure Wounds","aliases":["Healing Touch","Cure Mend"]}]}]}`
second := `{
"catalogs": [
{"source":{"license":"","url":"","version":"1","title":"A spells"},"spells":[{"aliases":["Cure Mend","Healing Touch"],"name":"Cure Wounds"}],"ruleset":"dnd-5e-2014","id":"campaign.a"},
{"spells":[{"aliases":["Another Alias","Z Alias"],"name":"Aegis of Emberfall"}],"source":{"title":"Z spells","version":"1","url":"","license":""},"ruleset":"dnd-5e-2014","id":"campaign.z"}
],
"schema_version":"notarius.dnd.spell-catalog-overlay.v1"
}`
firstCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(first), "application/json"))
if err != nil {
t.Fatal(err)
}
secondCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(second), "application/json"))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()) || firstCatalog.Digest() != secondCatalog.Digest() {
t.Fatalf("reordered overlays changed effective result: names=%t digest=%q/%q", reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()), firstCatalog.Digest(), secondCatalog.Digest())
}
}
func TestResolveEffectiveCatalogRejectsInvalidOverlays(t *testing.T) {
valid := validOverlayJSON()
tests := []struct {
name string
content string
}{
{name: "wrong schema version", content: strings.Replace(valid, "notarius.dnd.spell-catalog-overlay.v1", "notarius.dnd.spell-catalog-overlay.v2", 1)},
{name: "empty catalogs", content: `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[]}`},
{name: "duplicate IDs", content: testOverlayJSON(t, testOverlayCatalog("campaign.example", testOverlaySpell("Aegis of Emberfall")), testOverlayCatalog("campaign.example", testOverlaySpell("Another Spell")))},
{name: "wrong ruleset", content: strings.Replace(valid, `"ruleset":"dnd-5e-2014"`, `"ruleset":"dnd-5e-other"`, 1)},
{name: "empty ID", content: strings.Replace(valid, `"id":"campaign.example"`, `"id":""`, 1)},
{name: "empty source title", content: strings.Replace(valid, `"title":"Example campaign spells"`, `"title":""`, 1)},
{name: "empty spells", content: testOverlayJSON(t, testOverlayCatalog("campaign.example"))},
{name: "empty spell name", content: strings.Replace(valid, `"name":"Aegis of Emberfall"`, `"name":""`, 1)},
{name: "null aliases", content: strings.Replace(valid, `"aliases":["Emberfall Aegis"]`, `"aliases":null`, 1)},
{name: "unknown bundle field", content: strings.Replace(valid, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs"`, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","unexpected":true,"catalogs"`, 1)},
{name: "unknown catalog field", content: strings.Replace(valid, `"id":"campaign.example","ruleset"`, `"id":"campaign.example","unexpected":true,"ruleset"`, 1)},
{name: "unknown source field", content: strings.Replace(valid, `"title":"Example campaign spells","version"`, `"title":"Example campaign spells","unexpected":true,"version"`, 1)},
{name: "unknown spell field", content: strings.Replace(valid, `"name":"Aegis of Emberfall","aliases"`, `"name":"Aegis of Emberfall","unexpected":true,"aliases"`, 1)},
{name: "trailing JSON", content: valid + `{}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := ResolveEffectiveCatalog(overlayReference([]byte(test.content), "application/json")); err == nil {
t.Fatal("invalid overlay was accepted")
}
})
}
}
func TestResolveEffectiveCatalogRejectsReferenceMultiplicityAndMediaType(t *testing.T) {
valid := []byte(validOverlayJSON())
tests := []struct {
name string
refs contracts.ReferenceSet
}{
{
name: "multiple items",
refs: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
SpellCatalogReferenceSlot: {Items: []contracts.ReferenceItem{
{MediaType: "application/json", Content: valid},
{MediaType: "application/json", Content: valid},
}},
}},
},
{
name: "non JSON item",
refs: overlayReference(valid, "text/plain"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := ResolveEffectiveCatalog(test.refs); err == nil {
t.Fatal("invalid reference set was accepted")
}
})
}
for _, refs := range []contracts.ReferenceSet{
{}, {Slots: map[string]contracts.ResolvedReferenceSlot{SpellCatalogReferenceSlot: {}}},
} {
if effective, err := ResolveEffectiveCatalog(refs); err != nil || len(effective.CanonicalNames()) != 319 {
t.Fatalf("missing overlay should use base catalog: effective=%#v err=%v", effective, err)
}
}
}
func TestResolveEffectiveCatalogRejectsCrossSpellCollisions(t *testing.T) {
tests := []struct {
name string
spells []overlaySpell
content string
}{
{
name: "same key display conflict with embedded canonical",
spells: []overlaySpell{testOverlaySpell("cure wounds")},
},
{
name: "same key display conflict between canonical names",
spells: []overlaySpell{testOverlaySpell("Moon Beam"), testOverlaySpell("moon beam")},
},
{
name: "canonical versus alias",
spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Beta Spell"), testOverlaySpell("Beta Spell")},
},
{
name: "alias versus canonical",
spells: []overlaySpell{testOverlaySpell("Alpha Spell"), testOverlaySpell("Beta Spell", "Alpha Spell")},
},
{
name: "alias versus alias",
spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Shared Name"), testOverlaySpell("Beta Spell", "Shared Name")},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
content := test.content
if content == "" {
content = testOverlayJSON(t, testOverlayCatalog("campaign.example", test.spells...))
}
if _, err := ResolveEffectiveCatalog(overlayReference([]byte(content), "application/json")); err == nil {
t.Fatal("cross-spell collision was accepted")
}
})
}
}
func overlayReference(content []byte, mediaType string) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
SpellCatalogReferenceSlot: {
Items: []contracts.ReferenceItem{{
SlotName: SpellCatalogReferenceSlot,
MediaType: mediaType,
Content: append([]byte(nil), content...),
}},
},
}}
}
func testOverlaySpell(name string, aliases ...string) overlaySpell {
content, err := json.Marshal(aliases)
if err != nil {
panic(err)
}
return overlaySpell{Name: name, Aliases: content}
}
func testOverlayCatalog(id string, spells ...overlaySpell) overlayCatalog {
return overlayCatalog{
ID: id,
Ruleset: SRD5E2014Ruleset,
Source: overlaySource{
Title: id + " spells",
Version: json.RawMessage(`"1"`),
URL: json.RawMessage(`""`),
License: json.RawMessage(`""`),
},
Spells: spells,
}
}
func testOverlayJSON(t *testing.T, catalogs ...overlayCatalog) string {
t.Helper()
content, err := json.Marshal(overlayBundle{SchemaVersion: overlaySchemaVersion, Catalogs: catalogs})
if err != nil {
t.Fatal(err)
}
return string(content)
}
func validOverlayJSON() string {
return `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Example campaign spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`
}