2026-08-29 18:27:44 +00:00
package manifest
import (
"bytes"
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
)
func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically ( t * testing . T ) {
now := time . Date ( 2026 , 5 , 3 , 10 , 0 , 0 , 0 , time . UTC )
records := map [ string ] AnalyzeArtifactRecord {
"session_recap" : currentAnalyzeArtifactRecord ( "session_recap" , now ) ,
"quest_log" : {
Key : "quest_log" , Status : AnalyzeArtifactStale ,
FingerprintVersion : AnalyzeFingerprintContractVersion ,
Fingerprint : strings . Repeat ( "2" , 64 ) ,
Dependencies : [ ] string { "session_recap" } , ProducerRunID : "run-stale" , UpdatedAt : now ,
} ,
"player_handout" : {
Key : "player_handout" , Status : AnalyzeArtifactMissing ,
Dependencies : [ ] string { "session_recap" } , ProducerRunID : "run-missing" , UpdatedAt : now ,
} ,
"npc_digest" : {
Key : "npc_digest" , Status : AnalyzeArtifactFailed ,
ProducerRunID : "run-failed" , UpdatedAt : now , Error : "scriptorium validation failed" ,
Logs : [ ] string { "runs/run-failed/logs/npc-digest.stderr.log" } ,
} ,
"gm_notes" : {
Key : "gm_notes" , Status : AnalyzeArtifactUnselected ,
ProducerRunID : "run-unselected" , UpdatedAt : now ,
} ,
}
records [ "session_recap" ] = func ( ) AnalyzeArtifactRecord {
record := records [ "session_recap" ]
record . Dependencies = [ ] string { "quest_log" , "gm_notes" }
2026-08-30 14:27:42 +00:00
record . Family = "character_meta"
record . CharacterID = "arannis"
2026-08-29 18:27:44 +00:00
return record
} ( )
m := New ( "session" , now )
m . Stages [ "analyze" ] = & StageRecord {
Name : "analyze" ,
Status : StatusSucceeded ,
CreatedAt : now ,
UpdatedAt : now ,
AnalyzeStateVersion : AnalyzeStateContractVersion ,
AnalyzeArtifacts : records ,
}
path := filepath . Join ( t . TempDir ( ) , "manifest.json" )
store := & LocalStore { }
if err := store . Save ( context . Background ( ) , path , m ) ; err != nil {
t . Fatalf ( "Save() error = %v" , err )
}
loaded , err := store . Load ( context . Background ( ) , path )
if err != nil {
t . Fatalf ( "Load() error = %v" , err )
}
analyze := loaded . Stages [ "analyze" ]
if ! analyze . HasVersionedAnalyzeState ( ) {
t . Fatal ( "round-tripped analyze record lacks versioned state" )
}
for key , want := range records {
got , ok := analyze . AnalyzeArtifacts [ key ]
if ! ok || got . Status != want . Status {
t . Fatalf ( "artifact %q = %#v, want status %q" , key , got , want . Status )
}
}
if got := analyze . AnalyzeArtifacts [ "session_recap" ] . Dependencies ; ! reflect . DeepEqual ( got , [ ] string { "gm_notes" , "quest_log" } ) {
t . Fatalf ( "canonical dependencies = %#v" , got )
}
2026-08-30 14:27:42 +00:00
if got := analyze . AnalyzeArtifacts [ "session_recap" ] ; got . Family != "character_meta" || got . CharacterID != "arannis" {
t . Fatalf ( "family provenance = %#v" , got )
}
2026-08-29 18:27:44 +00:00
data , err := os . ReadFile ( path )
if err != nil {
t . Fatalf ( "ReadFile() error = %v" , err )
}
orderedKeys := [ ] string { "gm_notes" , "npc_digest" , "player_handout" , "quest_log" , "session_recap" }
previous := - 1
for _ , key := range orderedKeys {
position := bytes . Index ( data , [ ] byte ( ` " ` + key + ` ": { ` ) )
if position <= previous {
t . Fatalf ( "map key %q position = %d after %d; JSON is not canonical:\n%s" , key , position , previous , data )
}
previous = position
}
}
func TestValidateAnalyzeArtifactCollectionRejectsMalformedState ( t * testing . T ) {
now := time . Date ( 2026 , 5 , 3 , 10 , 0 , 0 , 0 , time . UTC )
valid := currentAnalyzeArtifactRecord ( "session_recap" , now )
tests := [ ] struct {
name string
key string
mutate func ( * AnalyzeArtifactRecord )
want string
} {
{ name : "malformed map key" , key : "Session Recap" , want : "map key must match" } ,
{ name : "record key mismatch" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Key = "quest_log" } , want : "does not match map key" } ,
{ name : "unsupported status" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Status = "ready" } , want : "unsupported status" } ,
{ name : "unsupported fingerprint version" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . FingerprintVersion = 99 } , want : "unsupported fingerprint version" } ,
{ name : "bad fingerprint" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Fingerprint = "not-a-digest" } , want : "fingerprint must be" } ,
{ name : "duplicate dependency" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Dependencies = [ ] string { "quest_log" , "quest_log" } } , want : "duplicate dependency" } ,
{ name : "malformed dependency" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Dependencies = [ ] string { "Quest Log" } } , want : "dependencies[0]" } ,
{ name : "missing current output" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output = nil } , want : "requires output" } ,
{ name : "bad current size" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . OutputSize = 0 } , want : "positive output_size" } ,
{ name : "bad current source" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output . SourceID = "narratio.artifact.other" } , want : "source_id" } ,
{ name : "unsafe current path" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output . LocalPath = "../recap.md" } , want : "local_path is unsafe" } ,
{ name : "missing current contract" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output . Contract = nil } , want : "output contract" } ,
{ name : "bad current checksum" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output . Checksum = strings . Repeat ( "G" , 64 ) } , want : "checksum must be" } ,
{ name : "producer mismatch" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Output . ProducerRunID = "other-run" } , want : "does not match record" } ,
{ name : "non-current output" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Status = AnalyzeArtifactStale } , want : "forbids output" } ,
{ name : "failed without error" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Status = AnalyzeArtifactFailed ; r . Output = nil ; r . OutputSize = 0 } , want : "requires error" } ,
{ name : "current with error" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Error = "unexpected" } , want : "forbids error" } ,
{ name : "oversized error" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) {
r . Status = AnalyzeArtifactFailed
r . Output = nil
r . OutputSize = 0
r . Error = strings . Repeat ( "x" , maxAnalyzeArtifactErrorLength + 1 )
} , want : "error exceeds" } ,
{ name : "bad producer run" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . ProducerRunID = "bad/run" } , want : "producer_run_id" } ,
{ name : "missing update time" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . UpdatedAt = time . Time { } } , want : "updated_at" } ,
{ name : "oversized log collection" , key : "session_recap" , mutate : func ( r * AnalyzeArtifactRecord ) { r . Logs = make ( [ ] string , maxAnalyzeArtifactListEntries + 1 ) } , want : "logs exceeds" } ,
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
record := valid
record . Output = cloneArtifactRecord ( valid . Output )
if test . mutate != nil {
test . mutate ( & record )
}
err := ValidateAnalyzeArtifactCollection ( AnalyzeStateContractVersion , map [ string ] AnalyzeArtifactRecord { test . key : record } )
if err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "error = %v, want %q" , err , test . want )
}
} )
}
if err := ValidateAnalyzeArtifactCollection ( 99 , nil ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unsupported analyze state version" ) {
t . Fatalf ( "unsupported version error = %v" , err )
}
}
2026-09-02 11:45:40 +00:00
func TestAnalyzeStageRequiresCurrentPerArtifactAuthority ( t * testing . T ) {
2026-08-29 18:27:44 +00:00
now := time . Date ( 2026 , 5 , 3 , 10 , 0 , 0 , 0 , time . UTC )
store := & LocalStore { }
2026-09-02 11:45:40 +00:00
aggregateOnly := ` {
"schema_version" : "narratio.session-manifest.v1" ,
2026-08-29 18:27:44 +00:00
"session_id" : "session" ,
"created_at" : "2026-05-03T10:00:00Z" ,
"updated_at" : "2026-05-03T10:01:00Z" ,
"stages" : {
"analyze" : {
"name" : "analyze" ,
"status" : "succeeded" ,
"created_at" : "2026-05-03T10:00:00Z" ,
"updated_at" : "2026-05-03T10:01:00Z" ,
"outputs" : [ { "kind" : "session_recap" , "local_path" : "artifacts/session_recap.md" } ]
}
}
} `
2026-09-02 11:45:40 +00:00
if _ , err := store . LoadReader ( context . Background ( ) , strings . NewReader ( aggregateOnly ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unsupported analyze state version 0" ) {
t . Fatalf ( "LoadReader(aggregate-only) error = %v" , err )
2026-08-29 18:27:44 +00:00
}
empty := New ( "session" , now )
empty . Stages [ "analyze" ] = & StageRecord {
Name : "analyze" , Status : StatusSucceeded , CreatedAt : now , UpdatedAt : now ,
AnalyzeStateVersion : AnalyzeStateContractVersion ,
}
emptyPath := filepath . Join ( t . TempDir ( ) , "empty.json" )
if err := store . Save ( context . Background ( ) , emptyPath , empty ) ; err != nil {
t . Fatalf ( "Save(versioned empty state) error = %v" , err )
}
emptyLoaded , err := store . Load ( context . Background ( ) , emptyPath )
if err != nil {
t . Fatalf ( "Load(versioned empty state) error = %v" , err )
}
if ! emptyLoaded . Stages [ "analyze" ] . HasVersionedAnalyzeState ( ) || len ( emptyLoaded . Stages [ "analyze" ] . AnalyzeArtifacts ) != 0 {
t . Fatalf ( "versioned empty state = %#v" , emptyLoaded . Stages [ "analyze" ] )
}
m := New ( "session" , now )
m . Stages [ "render" ] = & StageRecord {
Name : "render" , Status : StatusSucceeded , CreatedAt : now , UpdatedAt : now ,
AnalyzeStateVersion : AnalyzeStateContractVersion ,
}
if err := store . Save ( context . Background ( ) , filepath . Join ( t . TempDir ( ) , "bad-owner.json" ) , m ) ; err == nil || ! strings . Contains ( err . Error ( ) , "cannot contain analyze-owned state" ) {
t . Fatalf ( "non-analyze ownership error = %v" , err )
}
m . Stages = map [ string ] * StageRecord { "analyze" : {
Name : "analyze" , Status : StatusSucceeded , CreatedAt : now , UpdatedAt : now ,
AnalyzeArtifacts : map [ string ] AnalyzeArtifactRecord { } ,
} }
2026-09-02 11:45:40 +00:00
if err := store . Save ( context . Background ( ) , filepath . Join ( t . TempDir ( ) , "missing-version.json" ) , m ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unsupported analyze state version 0" ) {
2026-08-29 18:27:44 +00:00
t . Fatalf ( "missing version error = %v" , err )
}
}
func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing ( t * testing . T ) {
now := time . Date ( 2026 , 5 , 3 , 10 , 0 , 0 , 0 , time . UTC )
for _ , transition := range [ ] struct {
name string
apply func ( * Manifest )
} {
{ name : "running" , apply : func ( m * Manifest ) { m . MarkStageRunning ( "analyze" , now . Add ( time . Minute ) ) } } ,
{ name : "failed" , apply : func ( m * Manifest ) { m . MarkStageFailed ( "analyze" , now . Add ( time . Minute ) , "aggregate failed" ) } } ,
{ name : "skipped" , apply : func ( m * Manifest ) { m . MarkStageSkipped ( "analyze" , now . Add ( time . Minute ) , "disabled" ) } } ,
} {
t . Run ( transition . name , func ( t * testing . T ) {
m := New ( "session" , now )
m . Stages [ "analyze" ] = & StageRecord {
Name : "analyze" , Status : StatusSucceeded , CreatedAt : now , UpdatedAt : now ,
Outputs : [ ] ArtifactRecord { { Kind : "legacy" , LocalPath : "artifacts/legacy.md" } } ,
Logs : [ ] string { "aggregate.log" } , GeneratedConfigs : [ ] string { "aggregate.yml" } , Metadata : map [ string ] any { "aggregate" : true } ,
AnalyzeStateVersion : AnalyzeStateContractVersion ,
AnalyzeArtifacts : map [ string ] AnalyzeArtifactRecord {
"session_recap" : currentAnalyzeArtifactRecord ( "session_recap" , now ) ,
} ,
}
transition . apply ( m )
stage := m . Stages [ "analyze" ]
if len ( stage . Outputs ) != 0 || len ( stage . Logs ) != 0 || len ( stage . GeneratedConfigs ) != 0 || len ( stage . Metadata ) != 0 {
t . Fatalf ( "aggregate details survived %s: %#v" , transition . name , stage )
}
if ! stage . HasVersionedAnalyzeState ( ) || stage . AnalyzeArtifacts [ "session_recap" ] . Status != AnalyzeArtifactCurrent {
t . Fatalf ( "per-artifact state was cleared by %s: %#v" , transition . name , stage )
}
} )
}
}
func currentAnalyzeArtifactRecord ( key string , now time . Time ) AnalyzeArtifactRecord {
runID := "run-" + strings . ReplaceAll ( key , "_" , "-" )
return AnalyzeArtifactRecord {
Key : key ,
Status : AnalyzeArtifactCurrent ,
FingerprintVersion : AnalyzeFingerprintContractVersion ,
Fingerprint : strings . Repeat ( "1" , 64 ) ,
Output : & ArtifactRecord {
Kind : key ,
SourceID : "narratio.artifact." + key ,
LocalPath : "artifacts/" + strings . ReplaceAll ( key , "_" , "-" ) + ".md" ,
ProducerRunID : runID ,
Checksum : strings . Repeat ( "a" , 64 ) ,
Contract : & artifactmodel . ContractMetadata {
MediaType : "text/markdown" , SchemaID : "narratio." + key , SchemaVersion : "1" ,
} ,
ExternalProvenance : & artifactmodel . ExternalProvenance {
System : "scriptorium" , PipelineID : "campaign" , ArtifactID : key ,
} ,
} ,
OutputSize : 42 ,
ProducerRunID : runID ,
UpdatedAt : now ,
Scriptorium : & AnalyzeArtifactProvenance {
PromptID : key , ProfileID : "default" , CommandMode : "artifact" ,
} ,
Logs : [ ] string { "runs/" + runID + "/logs/" + key + ".log" } ,
GeneratedConfigs : [ ] string { "runs/" + runID + "/config/" + key + ".yml" } ,
}
}