Files
notarius/internal/modules/dnd/normalize/spells/normalizer_test.go

299 lines
13 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 spells
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)
func TestModuleContractAndStrictOptions(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
}
want := pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
Requires: []string{"merged"},
Provides: []string{"normalized"},
ArtifactKind: dnd.SpellListKind,
ReferenceSlots: []contracts.ReferenceSlot{{
Name: spellcatalog.SpellCatalogReferenceSlot,
Description: "Optional canonical spell-name catalog used for extraction grounding.",
AcceptedMediaTypes: []string{"application/json"},
MaxBytes: 1048576,
}},
}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
}
registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)
}
registered, ok := registry.SpecForArtifact(Key, dnd.SpellListKind)
if !ok || !reflect.DeepEqual(registered, want) {
t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want)
}
if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") {
t.Fatalf("Register(nil) error = %v, want registry error", err)
}
}
func TestNewBuildsEmbeddedAndOverlayCatalogs(t *testing.T) {
base, err := New(Options{})
if err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
if base.effectiveCatalog.BaseID() != spellcatalog.SRD5E2014ID || base.effectiveCatalog.Digest() == "" || len(base.effectiveCatalog.OverlayIDs()) != 0 {
t.Fatalf("base catalog identity = %#v, want embedded catalog identity", base.effectiveCatalog)
}
if canonical, ok := base.effectiveCatalog.Lookup(" cure wounds "); !ok || canonical != "Cure Wounds" {
t.Fatalf("base catalog lookup = %q, %t, want Cure Wounds", canonical, ok)
}
if canonical, ok := base.effectiveCatalog.Lookup("Arcanist's Magic Aura"); !ok || canonical != "Arcanists Magic Aura" {
t.Fatalf("apostrophe lookup = %q, %t, want canonical curly apostrophe spelling", canonical, ok)
}
overlay, err := New(Options{}, overlayReference())
if err != nil {
t.Fatalf("New(overlay) error = %v, want nil", err)
}
if got := overlay.effectiveCatalog.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) {
t.Fatalf("overlay IDs = %#v, want campaign.example", got)
}
if canonical, ok := overlay.effectiveCatalog.Lookup(" emberfall aegis "); !ok || canonical != "Aegis of Emberfall" {
t.Fatalf("overlay alias lookup = %q, %t, want Aegis of Emberfall", canonical, ok)
}
}
func TestNewRejectsInvalidCatalogReferencesDuringConstruction(t *testing.T) {
invalid := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
spellcatalog.SpellCatalogReferenceSlot: {
Items: []contracts.ReferenceItem{{MediaType: "text/plain", Content: []byte("not a catalog")}},
},
}}
if _, err := New(Options{}, invalid); err == nil || !strings.Contains(err.Error(), "application/json") {
t.Fatalf("New(invalid reference) error = %v, want media type failure", err)
}
tooMany := overlayReference()
slot := tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot]
slot.Items = append(slot.Items, slot.Items[0])
tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot] = slot
if _, err := New(Options{}, tooMany); err == nil || !strings.Contains(err.Error(), "zero or one item") {
t.Fatalf("New(duplicated reference) error = %v, want multiplicity failure", err)
}
}
func TestIdentityAndMetadataAreDefensive(t *testing.T) {
normalizer, err := New(Options{}, overlayReference())
if err != nil {
t.Fatal(err)
}
fingerprints := normalizer.CheckpointFingerprints()
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() {
t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints)
}
fingerprints[0].Name = "changed"
fingerprints[0].Value = "changed"
if got := normalizer.CheckpointFingerprints(); len(got) != 1 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() {
t.Fatalf("fingerprints were not defensive: %#v", got)
}
metadata := normalizer.ManifestMetadata()
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() {
t.Fatalf("metadata = %#v, want catalog identity", metadata)
}
metadata["catalog_overlay_ids"].([]string)[0] = "changed"
if got := normalizer.ManifestMetadata()["catalog_overlay_ids"].([]string); !reflect.DeepEqual(got, []string{"campaign.example"}) {
t.Fatalf("metadata overlay IDs were not defensive: %#v", got)
}
}
func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) {
normalizer := newNormalizer(t)
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Spell: " cure wounds "},
{Spell: "Arcanist's Magic Aura"},
{Spell: "Emberfall Aegis"},
{Spell: "Mystery\nSpell\tName"},
{Spell: "Cure Wounds"},
}}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
wantNames := []string{"Cure Wounds", "Arcanists Magic Aura", "Aegis of Emberfall", "Mystery\nSpell\tName", "Cure Wounds"}
for index, want := range wantNames {
if result.Value.SpellCasts[index].Spell != want {
t.Fatalf("spell[%d] = %q, want %q", index, result.Value.SpellCasts[index].Spell, want)
}
}
if len(result.Warnings) != 4 {
t.Fatalf("warnings = %#v, want four name warnings", result.Warnings)
}
if result.Warnings[0].ReasonCode != ReasonCodeSpellNameCanonicalized || result.Warnings[0].Scope != "spell_casts[0]" || !strings.Contains(result.Warnings[0].Message, "input index 0") {
t.Fatalf("first warning = %#v, want canonicalization warning", result.Warnings[0])
}
if result.Warnings[1].ReasonCode != ReasonCodeSpellNameCanonicalized || result.Warnings[2].ReasonCode != ReasonCodeSpellNameCanonicalized {
t.Fatalf("catalog spelling warnings = %#v", result.Warnings[1:3])
}
if result.Warnings[3].ReasonCode != ReasonCodeSpellNameUnresolved || result.Warnings[3].Scope != "spell_casts[3]" || strings.Contains(result.Warnings[3].Message, "Mystery\nSpell") || !strings.Contains(result.Warnings[3].Message, `Mystery\nSpell\tName`) {
t.Fatalf("unresolved warning = %#v, want quoted control characters", result.Warnings[3])
}
}
func TestNormalizeBoundsUnicodeNamesAndQuotesCanonicalReplacement(t *testing.T) {
longName := strings.Repeat("火", 140)
reference := spellCatalogReference(fmt.Sprintf(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.long","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source"},"spells":[{"name":%q,"aliases":["long alias"]}]}]}`, longName))
normalizer, err := New(Options{}, reference)
if err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "long alias"}}}))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSpellNameCanonicalized {
t.Fatalf("warnings = %#v, want one canonicalization warning", result.Warnings)
}
if !utf8.ValidString(result.Warnings[0].Message) || !strings.Contains(result.Warnings[0].Message, "…") || strings.Contains(result.Warnings[0].Message, longName) {
t.Fatalf("warning = %q, want valid bounded Unicode diagnostic", result.Warnings[0].Message)
}
if got := result.Value.SpellCasts[0].Spell; got != longName {
t.Fatalf("canonical value = %q, want full catalog name", got)
}
}
func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
normalizer := newNormalizer(t)
inputRefs := []source.SourceRef{
{SourceID: "source-b", StartUnitID: 4, EndUnitID: 5},
{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
{SourceID: "source-a", StartUnitID: 1, EndUnitID: 2},
{SourceID: "source-a", StartUnitID: 2, EndUnitID: 3},
{SourceID: "source-a", StartUnitID: 1, EndUnitID: 4},
}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds", SourceRefs: inputRefs}}}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
wantRefs := []source.SourceRef{
{SourceID: "source-a", StartUnitID: 1, EndUnitID: 2},
{SourceID: "source-a", StartUnitID: 1, EndUnitID: 4},
{SourceID: "source-a", StartUnitID: 2, EndUnitID: 3},
{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
{SourceID: "source-b", StartUnitID: 4, EndUnitID: 5},
}
if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings)
}
}
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
normalizer := newNormalizer(t)
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Spell: "Cure Wounds", SourceRefs: nil},
{Spell: "Cure Wounds", SourceRefs: []source.SourceRef{}},
{Spell: "Cure Wounds", SourceRefs: []source.SourceRef{
{SourceID: "source", StartUnitID: 3, EndUnitID: 4},
{SourceID: "source", StartUnitID: 1, EndUnitID: 2},
{SourceID: "source", StartUnitID: 2, EndUnitID: 5},
}},
}}
before := input
before.SpellCasts = append([]dnd.SpellCast(nil), input.SpellCasts...)
before.SpellCasts[2].SourceRefs = append([]source.SourceRef(nil), input.SpellCasts[2].SourceRefs...)
result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if result.Value.SpellCasts[0].SourceRefs != nil || result.Value.SpellCasts[1].SourceRefs == nil {
t.Fatalf("nil/empty source refs were not preserved: %#v", result.Value.SpellCasts)
}
if len(result.Value.SpellCasts[2].SourceRefs) != 3 {
t.Fatalf("adjacent/overlapping references = %#v, want all three retained", result.Value.SpellCasts[2].SourceRefs)
}
if !reflect.DeepEqual(input, before) {
t.Fatalf("Normalize() mutated input: got %#v, before %#v", input, before)
}
result.Value.SpellCasts[2].SourceRefs[0].SourceID = "changed"
if input.SpellCasts[2].SourceRefs[0].SourceID == "changed" {
t.Fatal("normalized references share input storage")
}
}
func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
request := normalizeRequest(dnd.SpellList{})
var normalizer *Normalizer
if _, err := normalizer.Normalize(context.Background(), request); err == nil || !strings.Contains(err.Error(), "normalizer must not be nil") {
t.Fatalf("nil receiver error = %v, want nil receiver error", err)
}
normalizer = newNormalizer(t)
if _, err := normalizer.Normalize(nil, request); err == nil || !strings.Contains(err.Error(), "context must not be nil") {
t.Fatalf("nil context error = %v, want nil context error", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := normalizer.Normalize(ctx, request); err == nil || !strings.Contains(err.Error(), "context error before normalize") {
t.Fatalf("canceled context error = %v, want canceled context error", err)
}
}
func newNormalizer(t *testing.T) *Normalizer {
t.Helper()
normalizer, err := New(Options{}, overlayReference())
if err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
return normalizer
}
func normalizeRequest(value dnd.SpellList) contracts.TypedNormalizeRequest[dnd.SpellList] {
return contracts.TypedNormalizeRequest[dnd.SpellList]{
LaneID: "spells",
MergeOutput: contracts.MergeArtifact[dnd.SpellList]{
LaneID: "spells",
MergerKey: "appendorder",
SourceID: "source",
Value: value,
},
}
}
func overlayReference() contracts.ReferenceSet {
return spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`)
}
func spellCatalogReference(content string) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
spellcatalog.SpellCatalogReferenceSlot: {
Items: []contracts.ReferenceItem{{
SlotName: spellcatalog.SpellCatalogReferenceSlot,
MediaType: "application/json",
Content: []byte(content),
}},
},
}}
}