Files
notarius/internal/modules/dnd/spells/catalog/effective_test.go

341 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 TestEffectiveCatalogPromptSpellsIncludeSortedAliasesWithoutProvenance(t *testing.T) {
overlay := testOverlayJSON(t, testOverlayCatalog(
"campaign.example",
testOverlaySpell("Aegis of Emberfall", "Z Emberfall", "Emberfall Aegis", "emberfall aegis"),
testOverlaySpell("Cure Wounds", "Healing Touch"),
))
effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json"))
if err != nil {
t.Fatal(err)
}
spells := effective.PromptSpells()
if len(spells) != len(effective.CanonicalNames()) {
t.Fatalf("prompt spell count = %d, want %d", len(spells), len(effective.CanonicalNames()))
}
for index := 1; index < len(spells); index++ {
if spells[index-1].CanonicalName > spells[index].CanonicalName {
t.Fatalf("prompt spells are not sorted: %q before %q", spells[index-1].CanonicalName, spells[index].CanonicalName)
}
}
aliases := make(map[string][]string, len(spells))
for _, spell := range spells {
aliases[spell.CanonicalName] = spell.Aliases
if !sort.StringsAreSorted(spell.Aliases) {
t.Fatalf("aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases)
}
}
if got := aliases["Aegis of Emberfall"]; !reflect.DeepEqual(got, []string{"Emberfall Aegis", "Z Emberfall"}) {
t.Fatalf("Aegis aliases = %#v, want normalized aliases once", got)
}
if got := aliases["Cure Wounds"]; !reflect.DeepEqual(got, []string{"Healing Touch"}) {
t.Fatalf("Cure Wounds aliases = %#v", got)
}
encoded, err := json.Marshal(spells)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"aliases":null`) {
t.Fatalf("prompt projection encoded missing aliases as null: %s", encoded)
}
for _, forbidden := range []string{"campaign.example", "spells", "source", "license", "ruleset", "provenance"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("prompt projection leaked %q: %s", forbidden, encoded)
}
}
for index := range spells {
if spells[index].CanonicalName == "Aegis of Emberfall" {
spells[index].Aliases[0] = "changed"
break
}
}
for _, spell := range effective.PromptSpells() {
if spell.CanonicalName == "Aegis of Emberfall" && spell.Aliases[0] == "changed" {
t.Fatal("effective catalog exposed mutable prompt projection storage")
}
}
}
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"]}]}]}`
}