2026-08-05 18:38:22 +00:00
package npcregistry
2026-07-21 02:36:59 +00:00
import (
"context"
2026-07-26 01:36:32 +00:00
"encoding/json"
2026-08-26 23:24:20 +00:00
"fmt"
2026-07-21 02:36:59 +00:00
"reflect"
"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"
2026-08-09 16:38:43 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
2026-07-21 02:36:59 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
2026-07-22 18:51:26 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
2026-07-21 02:36:59 +00:00
)
func TestModuleContractAndIdentity ( t * testing . T ) {
if _ , err := DecodeOptions ( nil ) ; err != nil {
t . Fatalf ( "DecodeOptions(nil) error = %v, want nil" , err )
}
2026-07-22 18:51:26 +00:00
if _ , err := DecodeOptions ( map [ string ] any { "unexpected" : true } ) ; err == nil {
t . Fatal ( "DecodeOptions() accepted unknown option" )
2026-07-21 02:36:59 +00:00
}
2026-08-27 00:08:05 +00:00
want := pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , ExecutionClass : contracts . ExecutionClassLLMBacked , CorrectionProtocol : contracts . CorrectionProtocolSingleResponseV1 , Requires : [ ] string { "merged" } , Provides : [ ] string { "normalized" } , ArtifactKind : dnd . NPCRegistryKind }
2026-07-21 02:36:59 +00:00
if got := ModuleSpec ( ) ; ! reflect . DeepEqual ( got , want ) {
t . Fatalf ( "ModuleSpec() = %#v, want %#v" , got , want )
}
registry := pipeline . NewNormalizerRegistry ( )
if err := Register ( registry ) ; err != nil {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "Register() error = %v" , err )
2026-07-21 02:36:59 +00:00
}
2026-07-26 01:36:32 +00:00
if _ , err := New ( nil , Options { } ) ; err == nil {
t . Fatal ( "New(nil, Options{}) error = nil, want nil client rejection" )
}
normalizer := newNormalizer ( t , & recordingNPCNormalizerClient { } )
2026-08-09 16:38:43 +00:00
metadata := normalizer . ManifestMetadata ( )
limits , ok := metadata [ "semantic_reconciliation_limits" ] . ( map [ string ] any )
if ! ok || metadata [ "identity_policy" ] != identity . Policy || metadata [ "normalization_policy" ] != normalizationPolicy || metadata [ "prompt_id" ] != PromptID || metadata [ "prompt_version" ] != PromptVersion || metadata [ "response_schema_key" ] != string ( semanticreconcile . ResponseSchemaKey ) || metadata [ "response_schema_id" ] != semanticreconcile . ResponseSchemaID || metadata [ "response_schema_name" ] != semanticreconcile . ResponseSchemaName || metadata [ "semantic_reconciliation_policy" ] != semanticreconcile . Policy || len ( limits ) != 3 {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "metadata = %#v" , metadata )
2026-07-21 02:36:59 +00:00
}
2026-08-09 16:38:43 +00:00
fingerprints := normalizer . CheckpointFingerprints ( )
for _ , name := range [ ] string { "prompt" , "response_schema" , "semantic_reconciliation_policy" , "semantic_reconciliation_limits" , "identity_policy" , "normalization_policy" } {
if ! hasFingerprint ( fingerprints , name ) {
t . Fatalf ( "fingerprints = %#v, want %q" , fingerprints , name )
}
2026-07-21 02:36:59 +00:00
}
}
2026-07-22 18:51:26 +00:00
func TestNormalizeNamesEvidenceAndIDs ( t * testing . T ) {
2026-08-05 18:25:04 +00:00
input := dnd . NPCRegistry { NPCs : [ ] dnd . NPC { {
2026-07-22 18:51:26 +00:00
ID : "wrong" , Name : " Lady\tAsh " , SourceRefs : [ ] source . SourceRef {
2026-07-21 02:36:59 +00:00
{ SourceID : "b" , StartUnitID : 2 , EndUnitID : 3 } ,
{ SourceID : "a" , StartUnitID : 4 , EndUnitID : 4 } ,
{ SourceID : "b" , StartUnitID : 2 , EndUnitID : 3 } ,
} ,
} } }
2026-07-26 01:36:32 +00:00
result , err := newNormalizer ( t , & recordingNPCNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
2026-07-21 02:36:59 +00:00
if err != nil {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "Normalize() error = %v" , err )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
want := dnd . NPC { ID : identity . DeriveID ( "Lady Ash" ) , Name : "Lady Ash" , SourceRefs : [ ] source . SourceRef { { SourceID : "a" , StartUnitID : 4 , EndUnitID : 4 } , { SourceID : "b" , StartUnitID : 2 , EndUnitID : 3 } } }
if ! reflect . DeepEqual ( result . Value . NPCs [ 0 ] , want ) {
t . Fatalf ( "NPC = %#v, want %#v" , result . Value . NPCs [ 0 ] , want )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
for _ , reason := range [ ] string { ReasonCodeNPCFieldsNormalized , ReasonCodeSourceReferencesNormalized , ReasonCodeNPCIDRecomputed } {
2026-08-27 15:57:29 +00:00
if ! hasDiagnostic ( result . Diagnostics , reason , "npcs[0]" , contracts . DiagnosticDispositionObservation ) {
t . Fatalf ( "diagnostics = %#v, want %s" , result . Diagnostics , reason )
2026-07-22 18:51:26 +00:00
}
2026-07-21 02:36:59 +00:00
}
}
2026-07-24 14:20:10 +00:00
func TestNormalizeOrdersEvidenceBySourceDocumentPosition ( t * testing . T ) {
doc := & source . SourceDocument { ID : "source" , Units : [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } } }
2026-08-05 18:25:04 +00:00
input := dnd . NPCRegistry { NPCs : [ ] dnd . NPC { { Name : "Lady Ash" , SourceRefs : [ ] source . SourceRef {
2026-07-24 14:20:10 +00:00
{ SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } ,
{ SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } ,
{ SourceID : doc . ID , StartUnitID : 999 , EndUnitID : 999 } ,
} } } }
2026-07-26 01:36:32 +00:00
result , err := newNormalizer ( t , & recordingNPCNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
2026-07-24 14:20:10 +00:00
if err != nil {
t . Fatalf ( "Normalize() error = %v" , err )
}
got := result . Value . NPCs [ 0 ] . SourceRefs
if got [ 0 ] . StartUnitID != 30 || got [ 1 ] . StartUnitID != 10 || got [ 2 ] . StartUnitID != 999 {
t . Fatalf ( "source refs = %#v, want source-document order followed by invalid reference" , got )
}
}
2026-07-22 18:51:26 +00:00
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence ( t * testing . T ) {
2026-08-05 18:25:04 +00:00
input := dnd . NPCRegistry { NPCs : [ ] dnd . NPC {
2026-07-22 18:51:26 +00:00
{ Name : " Captain Vale " , SourceRefs : [ ] source . SourceRef { { SourceID : "a" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "captain vale" , SourceRefs : [ ] source . SourceRef { { SourceID : "b" , StartUnitID : 2 , EndUnitID : 2 } } } ,
{ Name : "The Captain" , SourceRefs : [ ] source . SourceRef { { SourceID : "c" , StartUnitID : 3 , EndUnitID : 3 } } } ,
2026-07-21 02:36:59 +00:00
} }
2026-07-26 01:36:32 +00:00
result , err := newNormalizer ( t , & recordingNPCNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
2026-07-21 02:36:59 +00:00
if err != nil {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "Normalize() error = %v" , err )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
if len ( result . Value . NPCs ) != 2 || result . Value . NPCs [ 0 ] . Name != "Captain Vale" || result . Value . NPCs [ 1 ] . Name != "The Captain" {
t . Fatalf ( "NPCs = %#v, want name-only stable consolidation" , result . Value . NPCs )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
if refs := result . Value . NPCs [ 0 ] . SourceRefs ; len ( refs ) != 2 || refs [ 0 ] . SourceID != "a" || refs [ 1 ] . SourceID != "b" {
t . Fatalf ( "source refs = %#v, want evidence union" , refs )
2026-07-21 02:36:59 +00:00
}
2026-08-27 15:57:29 +00:00
if ! hasDiagnostic ( result . Diagnostics , ReasonCodeDuplicateNPCCollapsed , "npcs[0]" , contracts . DiagnosticDispositionObservation ) {
t . Fatalf ( "diagnostics = %#v, want duplicate collapse" , result . Diagnostics )
2026-07-21 02:36:59 +00:00
}
}
2026-07-22 18:51:26 +00:00
func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput ( t * testing . T ) {
2026-08-05 18:25:04 +00:00
input := dnd . NPCRegistry { NPCs : [ ] dnd . NPC { { Name : " " , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 0 , EndUnitID : - 1 } } } } }
before := dnd . NPCRegistry { NPCs : [ ] dnd . NPC { { Name : " " , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 0 , EndUnitID : - 1 } } } } }
2026-07-26 01:36:32 +00:00
result , err := newNormalizer ( t , & recordingNPCNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
2026-07-22 18:51:26 +00:00
if err != nil || ! reflect . DeepEqual ( input , before ) {
t . Fatalf ( "Normalize() = %#v, %v; input mutated to %#v" , result , err , input )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
if result . Value . NPCs [ 0 ] . Name != "" || result . Value . NPCs [ 0 ] . SourceRefs [ 0 ] . EndUnitID != - 1 {
t . Fatalf ( "candidate = %#v, want invalid semantics preserved" , result . Value . NPCs [ 0 ] )
2026-07-21 02:36:59 +00:00
}
result . Value . NPCs [ 0 ] . SourceRefs [ 0 ] . SourceID = "changed"
2026-07-22 18:51:26 +00:00
if input . NPCs [ 0 ] . SourceRefs [ 0 ] . SourceID != "source" {
t . Fatal ( "output aliases input evidence" )
2026-07-21 02:36:59 +00:00
}
}
func TestNormalizeHandlesNilAndCanceledCalls ( t * testing . T ) {
2026-07-26 01:36:32 +00:00
normalizer := newNormalizer ( t , & recordingNPCNormalizerClient { } )
2026-08-09 18:30:32 +00:00
if _ , err := normalizer . Normalize ( context . Background ( ) , contracts . TypedNormalizeRequest [ dnd . NPCRegistry ] { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "source document must not be nil" ) {
t . Fatalf ( "nil source Normalize() error = %v" , err )
}
2026-08-05 18:25:04 +00:00
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequest ( dnd . NPCRegistry { NPCs : nil } ) )
2026-07-21 02:36:59 +00:00
if err != nil || result . Value . NPCs != nil {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "nil list result = %#v, error = %v" , result . Value , err )
2026-07-21 02:36:59 +00:00
}
canceled , cancel := context . WithCancel ( context . Background ( ) )
cancel ( )
2026-08-05 18:25:04 +00:00
if _ , err := normalizer . Normalize ( canceled , normalizeRequest ( dnd . NPCRegistry { } ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , "context error" ) {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "canceled Normalize() error = %v" , err )
2026-07-21 02:50:37 +00:00
}
}
2026-07-26 01:36:32 +00:00
type recordingNPCNormalizerClient struct {
response string
responses [ ] string
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( c * recordingNPCNormalizerClient ) CompleteStructured ( _ context . Context , request contracts . StructuredCompletionRequest , output any ) ( contracts . StructuredCompletionResponse , error ) {
2026-08-26 23:24:20 +00:00
snapshot , err := contracts . CloneStructuredCompletionRequest ( request )
if err != nil {
return contracts . StructuredCompletionResponse { } , fmt . Errorf ( "clone recording request: %w" , err )
}
c . requests = append ( c . requests , snapshot )
2026-07-26 01:36:32 +00:00
if c . err != nil {
return contracts . StructuredCompletionResponse { } , c . err
}
response := c . response
if len ( c . responses ) > 0 {
responseIndex := len ( c . requests ) - 1
if responseIndex >= len ( c . responses ) {
responseIndex = len ( c . responses ) - 1
}
response = c . responses [ responseIndex ]
}
if response == "" {
response = ` { "duplicate_groups":[]} `
}
2026-08-09 16:38:43 +00:00
content := [ ] byte ( response )
2026-08-08 15:05:35 +00:00
if err := json . Unmarshal ( content , output ) ; err != nil {
2026-07-26 01:36:32 +00:00
return contracts . StructuredCompletionResponse { } , err
}
2026-08-08 15:05:35 +00:00
return contracts . StructuredCompletionResponse { Content : content } , nil
}
2026-07-26 01:36:32 +00:00
func newNormalizer ( t * testing . T , client contracts . StructuredLLMClient ) * Normalizer {
t . Helper ( )
normalizer , err := New ( client , Options { } )
if err != nil {
t . Fatalf ( "New() error = %v" , err )
}
return normalizer
}
2026-08-05 18:25:04 +00:00
func normalizeRequest ( value dnd . NPCRegistry ) contracts . TypedNormalizeRequest [ dnd . NPCRegistry ] {
2026-08-09 18:30:32 +00:00
return contracts . TypedNormalizeRequest [ dnd . NPCRegistry ] {
Source : & source . SourceDocument { } ,
MergeOutput : contracts . MergeArtifact [ dnd . NPCRegistry ] { Value : value } ,
}
2026-07-21 02:36:59 +00:00
}
2026-08-05 18:25:04 +00:00
func normalizeRequestWithSource ( value dnd . NPCRegistry , doc * source . SourceDocument ) contracts . TypedNormalizeRequest [ dnd . NPCRegistry ] {
2026-07-24 14:20:10 +00:00
request := normalizeRequest ( value )
request . Source = doc
return request
}
2026-08-27 15:57:29 +00:00
func hasDiagnostic ( diagnostics [ ] contracts . ProducerDiagnostic , reason , scope string , disposition contracts . DiagnosticDisposition ) bool {
for _ , diagnostic := range diagnostics {
if diagnostic . ReasonCode != reason || diagnostic . Disposition != disposition {
continue
}
for _ , sample := range diagnostic . Samples {
if sample . Scope == scope {
return true
}
2026-07-21 02:36:59 +00:00
}
}
return false
}
2026-08-09 16:38:43 +00:00
func hasFingerprint ( fingerprints [ ] pipeline . CheckpointFingerprint , name string ) bool {
for _ , fingerprint := range fingerprints {
if fingerprint . Name == name && fingerprint . Value != "" {
return true
}
}
return false
}