Finish the D&D module cleanup

This commit is contained in:
2026-07-25 13:30:23 +00:00
parent aeaaf44ae0
commit 29ee68824d
14 changed files with 76 additions and 366 deletions

View File

@@ -334,6 +334,9 @@ func TestDocumentIndexSnapshotsIdentityAndUnitPositions(t *testing.T) {
doc.ID = "changed"
doc.Units[0].ID = 99
if documentID, ok := index.DocumentID(); !ok || documentID != "source-1" {
t.Fatalf("DocumentID() = %q, %t, want source-1, true", documentID, ok)
}
if position, ok := index.Position(30); !ok || position != 0 {
t.Fatalf("Position(30) = %d, %t, want 0, true", position, ok)
}
@@ -348,6 +351,9 @@ func TestDocumentIndexSnapshotsIdentityAndUnitPositions(t *testing.T) {
func TestZeroDocumentIndexIsSafe(t *testing.T) {
var index DocumentIndex
if documentID, ok := index.DocumentID(); ok || documentID != "" {
t.Fatalf("DocumentID() = %q, %t, want empty, false", documentID, ok)
}
if position, ok := index.Position(1); ok || position != 0 {
t.Fatalf("Position(1) = %d, %t, want 0, false", position, ok)
}

View File

@@ -31,6 +31,14 @@ func NewDocumentIndex(doc *SourceDocument) DocumentIndex {
}
}
// DocumentID returns the indexed document identity.
func (i DocumentIndex) DocumentID() (string, bool) {
if !i.hasDocument {
return "", false
}
return i.documentID, true
}
// Position returns the indexed document position for unitID.
func (i DocumentIndex) Position(unitID int) (int, bool) {
position, ok := i.positions[unitID]

View File

@@ -111,7 +111,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
}

View File

@@ -119,7 +119,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
}
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
}

View File

@@ -98,7 +98,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
}
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
warnings = append(warnings, duplicateWarnings...)

View File

@@ -1,6 +1,8 @@
package register
import (
"slices"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
@@ -110,32 +112,28 @@ func appendSceneDescriptionLists(values []dnd.SceneDescriptionList) (dnd.SceneDe
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone
}
func cloneSpellCast(value dnd.SpellCast) dnd.SpellCast {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone
}
func cloneNPC(value dnd.NPC) dnd.NPC {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone
}
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone
}
func cloneSourceRefs(refs []source.SourceRef) []source.SourceRef {
return slices.Clone(refs)
}

View File

@@ -409,6 +409,28 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
}
}
func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
spells, err := appendSpellLists([]dnd.SpellList{{SpellCasts: []dnd.SpellCast{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || spells.SpellCasts[0].SourceRefs == nil {
t.Fatalf("appendSpellLists() = %#v, %v; want present-empty source refs", spells, err)
}
npcs, err := appendNPCLists([]dnd.NPCList{{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || npcs.NPCs[0].SourceRefs == nil {
t.Fatalf("appendNPCLists() = %#v, %v; want present-empty source refs", npcs, err)
}
turns, err := appendCombatTurnLists([]dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || turns.CombatTurns[0].SourceRefs == nil {
t.Fatalf("appendCombatTurnLists() = %#v, %v; want present-empty source refs", turns, err)
}
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || interactions.Interactions[0].SourceRefs == nil {
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
}
}
func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) {
tests := []struct {
name string

View File

@@ -15,16 +15,17 @@ type SourceRefOrder struct {
// NewSourceRefOrder captures the source identity and unit positions from doc.
func NewSourceRefOrder(doc *source.SourceDocument) SourceRefOrder {
return NewSourceRefOrderWithIndex(doc, source.NewDocumentIndex(doc))
return NewSourceRefOrderFromIndex(source.NewDocumentIndex(doc))
}
// NewSourceRefOrderWithIndex captures source identity with a supplied
// document index for source-reference comparison and canonicalization.
func NewSourceRefOrderWithIndex(doc *source.SourceDocument, index source.DocumentIndex) SourceRefOrder {
if doc == nil {
// NewSourceRefOrderFromIndex captures source identity and unit positions from
// one document index for source-reference comparison and canonicalization.
func NewSourceRefOrderFromIndex(index source.DocumentIndex) SourceRefOrder {
sourceID, ok := index.DocumentID()
if !ok {
return SourceRefOrder{}
}
return SourceRefOrder{sourceID: doc.ID, index: index}
return SourceRefOrder{sourceID: sourceID, index: index}
}
// Less orders references by source identity, then document positions when

View File

@@ -60,19 +60,22 @@ func TestSourceRefOrderCanonicalizePreservesRepresentationAndOwnership(t *testin
func TestSourceRefOrderSnapshotAndEarliestValid(t *testing.T) {
doc := unitRefSourceDocument(30, 10, 20)
order := NewSourceRefOrder(doc)
sourceID := doc.ID
index := source.NewDocumentIndex(doc)
order := NewSourceRefOrderFromIndex(index)
doc.ID = "changed"
doc.Units[0].ID, doc.Units[1].ID = 10, 30
refs := []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20},
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: sourceID, StartUnitID: 20, EndUnitID: 20},
{SourceID: sourceID, StartUnitID: 10, EndUnitID: 10},
{SourceID: "other", StartUnitID: 1, EndUnitID: 1},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30},
{SourceID: sourceID, StartUnitID: 999, EndUnitID: 999},
{SourceID: sourceID, StartUnitID: 20, EndUnitID: 30},
}
if position, ok := order.EarliestValid(refs); !ok || position != 1 {
t.Fatalf("EarliestValid() = %d, %t, want 1, true", position, ok)
}
if position, ok := order.EarliestValid([]source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30}}); ok || position != 0 {
if position, ok := order.EarliestValid([]source.SourceRef{{SourceID: sourceID, StartUnitID: 20, EndUnitID: 30}}); ok || position != 0 {
t.Fatalf("EarliestValid(invalid) = %d, %t, want 0, false", position, ok)
}
if position, ok := (SourceRefOrder{}).EarliestValid(refs); ok || position != 0 {

View File

@@ -55,7 +55,7 @@ func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
if !sourceRefsValid(index, value) {
return nil
}
issues := issuesFor(shared.NewSourceRefOrderWithIndex(doc, index), value)
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), value)
if len(issues) == 0 {
return nil
}

View File

@@ -94,7 +94,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if !npcRegistry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil
}
issues := issuesFor(shared.NewSourceRefOrderWithIndex(req.Source, index), req.Value, npcRegistry)
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, npcRegistry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -45,7 +45,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
if shape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
@@ -72,15 +72,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func sourceRefsValid(doc *source.SourceDocument, value dnd.SceneDescriptionList) bool {
for _, scene := range value.Scenes {
if source.ValidateRef(doc, scene.SourceRef) != nil {
return false
}
}
return true
}
func tokenSet(value string) map[string]struct{} {
tokens := make(map[string]struct{})
for _, token := range shared.NormalizedTokens(value) {

View File

@@ -54,6 +54,14 @@ func TestValidatorUsesTranscriptOnlyAndDefersInvalidInputs(t *testing.T) {
if err != nil || !malformed.Approved || len(malformed.Warnings) != 0 {
t.Fatalf("malformed Validate() = %#v, %v; want deferral", malformed, err)
}
invalidScene := scene("invalid", "Greencloak", "Greencloak arrives.")
invalidScene.SourceRef.StartUnitID = 99
invalidSource := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{invalidScene}}
deferred, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SceneDescriptionList]{Source: doc, Value: invalidSource})
if err != nil || !deferred.Approved || len(deferred.Warnings) != 0 {
t.Fatalf("invalid-source Validate() = %#v, %v; want deferral", deferred, err)
}
}
func TestValidatorRegistrationAndPolicyFingerprint(t *testing.T) {