diff --git a/internal/cli/assembled_enemy_event_codec_contract_test.go b/internal/cli/assembled_enemy_event_codec_contract_test.go new file mode 100644 index 0000000..b1416d4 --- /dev/null +++ b/internal/cli/assembled_enemy_event_codec_contract_test.go @@ -0,0 +1,81 @@ +package cli + +import ( + "context" + "errors" + "strings" + "testing" + + "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" +) + +const invalidEnemyEventExtractorKey = "test/dnd/invalid-enemy-events" + +func TestAssembledEnemyEventLaneRejectsInvalidFinalArtifactDespiteValidatorOverrides(t *testing.T) { + components := productionTestComponents(t) + if err := pipeline.RegisterExtractor[dnd.EnemyEventList](components.registries.Extractors, pipeline.ModuleSpec{ + Key: invalidEnemyEventExtractorKey, + Stage: pipeline.StageExtract, + ExecutionClass: contracts.ExecutionClassDeterministic, + Requires: []string{"chunks", "source.transcript"}, + Provides: []string{"dnd.enemy_events"}, + ArtifactKind: dnd.EnemyEventListKind, + }, func() (contracts.Extractor[dnd.EnemyEventList], error) { + return invalidEnemyEventExtractor{}, nil + }); err != nil { + t.Fatalf("register extractor: %v", err) + } + + accept := pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}} + resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{ + ID: "assembled-invalid-enemy-events", + Input: pipeline.Binding("seriatim"), + Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, + Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "enemy-events": { + Extract: pipeline.ModuleBinding{Module: invalidEnemyEventExtractorKey, Validators: accept}, + Normalize: pipeline.ModuleBinding{Module: pipeline.DefaultNormalizeModule, Validators: accept}, + }, + }, + Output: pipeline.Binding("json"), + }, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries)) + if err != nil { + t.Fatalf("ResolvePipeline() error = %v", err) + } + + prepared, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{}) + if err != nil { + t.Fatalf("Prepare() error = %v", err) + } + _, err = pipeline.New().Run(context.Background(), pipeline.RunInput{ + Prepared: prepared, + RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"), + ChunkCacheMode: pipeline.ChunkCacheBypass, + }) + if err == nil || !strings.Contains(err.Error(), "serialize accepted extract output") || !strings.Contains(err.Error(), "must not exceed") { + t.Fatalf("Run() error = %v, want final durable range rejection", err) + } +} + +type invalidEnemyEventExtractor struct{} + +func (invalidEnemyEventExtractor) Key() string { return invalidEnemyEventExtractorKey } + +func (invalidEnemyEventExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } + +func (invalidEnemyEventExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) { + if err := ctx.Err(); err != nil { + return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, err + } + if req.Source == nil { + return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, errors.New("assembled extractor requires source") + } + return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{ + Name: "Ashfang", + Kind: dnd.EnemyEventKindEngaged, + SourceRefs: []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 1}}, + }}}}, nil +} diff --git a/internal/modules/dnd/codec/combatturns/codec.go b/internal/modules/dnd/codec/combatturns/codec.go index c448a2a..56ed761 100644 --- a/internal/modules/dnd/codec/combatturns/codec.go +++ b/internal/modules/dnd/codec/combatturns/codec.go @@ -8,6 +8,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" ) const ( @@ -98,11 +99,8 @@ func validate(value dnd.CombatTurnList) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/combatturns/codec_test.go b/internal/modules/dnd/codec/combatturns/codec_test.go index c42aabe..d93761e 100644 --- a/internal/modules/dnd/codec/combatturns/codec_test.go +++ b/internal/modules/dnd/codec/combatturns/codec_test.go @@ -106,6 +106,41 @@ func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *tes } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.CombatTurnList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.CombatTurnList) { + value.CombatTurns[0].SourceRefs[0].StartUnitID = 2 + value.CombatTurns[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) { tests := []struct { name string diff --git a/internal/modules/dnd/codec/enemyevents/codec.go b/internal/modules/dnd/codec/enemyevents/codec.go index a5e28bc..dbd6325 100644 --- a/internal/modules/dnd/codec/enemyevents/codec.go +++ b/internal/modules/dnd/codec/enemyevents/codec.go @@ -5,11 +5,14 @@ import ( "embed" "encoding/json" "fmt" + "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" + enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents" ) const ( @@ -51,7 +54,7 @@ func (c *Codec) Metadata(value dnd.EnemyEventList) map[string]any { } func (c *Codec) Encode(value dnd.EnemyEventList) ([]byte, error) { - if err := validateRequiredValueFields(value); err != nil { + if err := validate(value); err != nil { return nil, fmt.Errorf("encode dnd enemy event list: %w", err) } return c.EncodeCandidate(value) @@ -71,6 +74,9 @@ func (c *Codec) Decode(content []byte) (dnd.EnemyEventList, error) { if err := validateRequiredJSONFields(content); err != nil { return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err) } + if err := validate(value); err != nil { + return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err) + } return value, nil } @@ -84,13 +90,29 @@ func (c *Codec) DecodeCandidate(content []byte) (dnd.EnemyEventList, error) { return cloneList(value), nil } -func validateRequiredValueFields(value dnd.EnemyEventList) error { +func validate(value dnd.EnemyEventList) error { if value.Events == nil { return fmt.Errorf("events must be present") } for index, event := range value.Events { - if event.SourceRefs == nil { - return fmt.Errorf("events[%d].source_refs must be present", index) + prefix := fmt.Sprintf("events[%d]", index) + if strings.TrimSpace(event.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if !enemyeventmodel.SupportedKind(event.Kind) { + return fmt.Errorf("%s.kind must be supported", prefix) + } + if len(event.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for refIndex, ref := range event.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) + } } } return nil @@ -176,7 +198,8 @@ func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList { for index, event := range value.Events { cloned.Events[index] = event if event.SourceRefs != nil { - cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...) + cloned.Events[index].SourceRefs = make([]source.SourceRef, len(event.SourceRefs)) + copy(cloned.Events[index].SourceRefs, event.SourceRefs) } } return cloned diff --git a/internal/modules/dnd/codec/enemyevents/codec_test.go b/internal/modules/dnd/codec/enemyevents/codec_test.go index f0988e2..738d0c0 100644 --- a/internal/modules/dnd/codec/enemyevents/codec_test.go +++ b/internal/modules/dnd/codec/enemyevents/codec_test.go @@ -62,6 +62,8 @@ func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) { {"missing kind", strings.Replace(validJSON, `"kind":"engaged",`, "", 1), "events[0].kind must be present"}, {"missing refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "events[0].source_refs must be present"}, {"missing source ID", strings.Replace(validJSON, `"source_id":"session",`, "", 1), "source_refs[0].source_id must be present"}, + {"missing source start", strings.Replace(validJSON, `"start_unit_id":1,`, "", 1), "source_refs[0].start_unit_id must be present"}, + {"missing source end", strings.Replace(validJSON, `,"end_unit_id":1`, "", 1), "source_refs[0].end_unit_id must be present"}, {"trailing JSON", `{"events":[]} {}`, "multiple JSON values"}, } for _, test := range tests { @@ -73,7 +75,7 @@ func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) { } } -func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) { +func TestCodecDefensivelyOwnsCandidateValues(t *testing.T) { codec := New() candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{ Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}}, @@ -90,8 +92,8 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) { if candidate.Events[0].SourceRefs[0].SourceID != "" { t.Fatal("DecodeCandidate() retained caller-owned source references") } - if decoded, err := codec.Decode(content); err != nil || !reflect.DeepEqual(decoded, candidate) { - t.Fatalf("Decode() = %#v, %v; want durable decode behavior", decoded, err) + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), "name must not be empty") { + t.Fatalf("Decode() error = %v, want durable value validation", err) } first := codec.Schema() @@ -100,3 +102,57 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) { t.Fatal("Schema() returned shared bytes") } } + +func TestCodecRejectsInvalidDurableValuesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.EnemyEventList) + want string + }{ + {name: "blank name", mutate: func(value *dnd.EnemyEventList) { value.Events[0].Name = " " }, want: "name must not be empty"}, + {name: "unsupported kind", mutate: func(value *dnd.EnemyEventList) { value.Events[0].Kind = "unsupported" }, want: "kind must be supported"}, + {name: "empty source refs", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs = []source.SourceRef{} }, want: "source_refs must contain"}, + {name: "blank source ID", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].SourceID = " " }, want: "source_id must not be empty"}, + {name: "nonpositive start", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.EnemyEventList) { + value.Events[0].SourceRefs[0].StartUnitID = 2 + value.Events[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{ + Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecAcceptsEmptyDurableEventList(t *testing.T) { + value := dnd.EnemyEventList{Events: []dnd.EnemyEvent{}} + content, err := New().Encode(value) + if err != nil || string(content) != `{"events":[]}` { + t.Fatalf("Encode() = %s, %v", content, err) + } + decoded, err := New().Decode(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value) + } +} diff --git a/internal/modules/dnd/codec/scenedescriptions/codec.go b/internal/modules/dnd/codec/scenedescriptions/codec.go index 4cc3ee5..052ef0c 100644 --- a/internal/modules/dnd/codec/scenedescriptions/codec.go +++ b/internal/modules/dnd/codec/scenedescriptions/codec.go @@ -8,6 +8,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" ) const ( @@ -102,11 +103,8 @@ func validate(value dnd.SceneDescriptionList) error { if strings.TrimSpace(scene.SourceRef.SourceID) == "" { return fmt.Errorf("%s.source_ref.source_id must not be empty", prefix) } - if scene.SourceRef.StartUnitID <= 0 { - return fmt.Errorf("%s.source_ref.start_unit_id must be positive", prefix) - } - if scene.SourceRef.EndUnitID <= 0 { - return fmt.Errorf("%s.source_ref.end_unit_id must be positive", prefix) + if err := sourcerange.Validate(scene.SourceRef); err != nil { + return fmt.Errorf("%s.source_ref: %w", prefix, err) } } return nil diff --git a/internal/modules/dnd/codec/scenedescriptions/codec_test.go b/internal/modules/dnd/codec/scenedescriptions/codec_test.go index d0bfb66..e1c9453 100644 --- a/internal/modules/dnd/codec/scenedescriptions/codec_test.go +++ b/internal/modules/dnd/codec/scenedescriptions/codec_test.go @@ -168,6 +168,41 @@ func TestCodecCandidatePreservesValidatorOwnedValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.SceneDescriptionList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.SceneDescriptionList) { + value.Scenes[0].SourceRef.StartUnitID = 2 + value.Scenes[0].SourceRef.EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecRejectsRequiredApprovedValues(t *testing.T) { tests := []struct { name string diff --git a/internal/modules/dnd/codec/spells/codec.go b/internal/modules/dnd/codec/spells/codec.go index 5c6a25c..f74f702 100644 --- a/internal/modules/dnd/codec/spells/codec.go +++ b/internal/modules/dnd/codec/spells/codec.go @@ -8,6 +8,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" ) const ( @@ -91,11 +92,8 @@ func validate(value dnd.SpellList) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("spell_casts[%d].source_refs[%d].source_id must not be empty", index, refIndex) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("spell_casts[%d].source_refs[%d].start_unit_id must be positive", index, refIndex) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("spell_casts[%d].source_refs[%d].end_unit_id must be positive", index, refIndex) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("spell_casts[%d].source_refs[%d]: %w", index, refIndex, err) } } } diff --git a/internal/modules/dnd/codec/spells/codec_test.go b/internal/modules/dnd/codec/spells/codec_test.go index f6700c9..96cc03e 100644 --- a/internal/modules/dnd/codec/spells/codec_test.go +++ b/internal/modules/dnd/codec/spells/codec_test.go @@ -130,6 +130,41 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.SpellList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.SpellList) { value.SpellCasts[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.SpellList) { value.SpellCasts[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.SpellList) { + value.SpellCasts[0].SourceRefs[0].StartUnitID = 2 + value.SpellCasts[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecSchemaIsMutationSafe(t *testing.T) { first := New().Schema() first.JSONSchema[0] = '[' diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go index 9ecf849..e6b4e2a 100644 --- a/internal/modules/dnd/extract/combatturns/extractor_test.go +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -97,7 +97,7 @@ func TestExtractUsesDocumentOrderForReferencesAndTurns(t *testing.T) { req := extractionRequest(t) req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}} req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) - req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10} + req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 10, EndUnitID: 30} req.References = sceneReferences(t, req.Chunk, dnd.SceneKindCombat) result, err := newExtractor(t, client).Extract(context.Background(), req) @@ -265,7 +265,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) { name: "start mismatch", scenes: []dnd.SceneDescription{func() dnd.SceneDescription { value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat) - value.SourceRef.StartUnitID = 2 + value.SourceRef.StartUnitID = 3 return value }()}, wantWarning: "scene_classification_unavailable", @@ -275,7 +275,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) { name: "end mismatch", scenes: []dnd.SceneDescription{func() dnd.SceneDescription { value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat) - value.SourceRef.EndUnitID = 10 + value.SourceRef.EndUnitID = 9 return value }()}, wantWarning: "scene_classification_unavailable", @@ -340,7 +340,7 @@ func TestSceneEligibilityMetadataAndFingerprintsTrackGatingValues(t *testing.T) change func(*dnd.SceneDescription) }{ {"kind", func(value *dnd.SceneDescription) { value.Kind = dnd.SceneKindNarrative }}, - {"range", func(value *dnd.SceneDescription) { value.SourceRef.StartUnitID = 2 }}, + {"range", func(value *dnd.SceneDescription) { value.SourceRef.StartUnitID = 3 }}, } { t.Run(test.name, func(t *testing.T) { changed := base @@ -477,7 +477,7 @@ func extractionRequest(t *testing.T) contracts.TypedExtractionRequest { ID: "session-alpha:chunk:0", SourceID: doc.ID, Index: 0, - Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 2}, + Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 10}, Content: []byte(`{"units":[10,2]}`), MediaType: "application/json", Units: append([]source.SourceUnit(nil), doc.Units...), diff --git a/internal/modules/dnd/extract/enemyevents/grounding_test.go b/internal/modules/dnd/extract/enemyevents/grounding_test.go index a20ffa3..0a1ce13 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding_test.go +++ b/internal/modules/dnd/extract/enemyevents/grounding_test.go @@ -179,6 +179,7 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) { {"malformed durable content", replaceItem(valid, NPCOccurrenceReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: []byte(`{}`)})}, {"wrong media type", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "text/plain", Content: valid.Slots[CombatTurnReferenceSlot].Items[0].Content})}, {"oversize", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: make([]byte, ReferenceMaxBytes+1)})}, + {"reversed combat-turn evidence", reversedCombatTurnReference(t, valid)}, } resolver, err := newGroundingResolver(valid) if err != nil { @@ -193,6 +194,19 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) { } } +func reversedCombatTurnReference(t *testing.T, references contracts.ReferenceSet) contracts.ReferenceSet { + t.Helper() + value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Ashfang", TurnKind: dnd.CombatTurnKindTurn, + SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 1}}, + }}} + content, err := combatturncodec.New().EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + return replaceItem(references, CombatTurnReferenceSlot, newReferenceItem(CombatTurnReferenceSlot, content)) +} + func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet { t.Helper() npcContent, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{ diff --git a/internal/modules/dnd/scenedescriptions/registry/registry_test.go b/internal/modules/dnd/scenedescriptions/registry/registry_test.go index 2ad4a76..a3ab054 100644 --- a/internal/modules/dnd/scenedescriptions/registry/registry_test.go +++ b/internal/modules/dnd/scenedescriptions/registry/registry_test.go @@ -2,6 +2,7 @@ package registry import ( "fmt" + "strings" "sync" "testing" @@ -60,6 +61,17 @@ func TestResolveRejectsInvalidSceneReferences(t *testing.T) { } } +func TestResolveRejectsReversedEvidenceInDirectSceneReference(t *testing.T) { + value := sceneList(scene("chunk-1", "session-alpha", 2, 1, dnd.SceneKindNarrative, "Arrival", "The party arrives.")) + content, err := scenecodec.New().EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + if _, err := Resolve(referenceSet(referenceItem(content))); err == nil || !strings.Contains(err.Error(), "invalid approved") { + t.Fatalf("Resolve() error = %v, want direct-reference rejection", err) + } +} + func TestEligibilityDigestTracksOnlyGatingFields(t *testing.T) { base := sceneList( scene("chunk-a", "session-alpha", 1, 2, dnd.SceneKindNarrative, "Arrival", "The party arrives."),