341 lines
14 KiB
Go
341 lines
14 KiB
Go
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 != "Hunter’s 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"]}]}]}`
|
||
}
|