package scenedescriptions import ( "context" "fmt" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) { doc := testDocument() input := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{ scene("later", 3, 3, dnd.SceneKindNarrative, " Return ", " The party returns. "), scene("first-b", 1, 1, dnd.SceneKindMeta, " Rules ", " The table checks rules. "), scene("first-a", 1, 1, dnd.SceneKindMeta, " Rules ", " The table checks rules. "), scene("later", 3, 3, dnd.SceneKindNarrative, " Return ", " The party returns. "), }} before := cloneList(input) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc)) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) } want := []dnd.SceneDescription{ scene("first-a", 1, 1, dnd.SceneKindMeta, "Rules", "The table checks rules."), scene("first-b", 1, 1, dnd.SceneKindMeta, "Rules", "The table checks rules."), scene("later", 3, 3, dnd.SceneKindNarrative, "Return", "The party returns."), } if !reflect.DeepEqual(result.Value.Scenes, want) { t.Fatalf("scenes = %#v, want %#v", result.Value.Scenes, want) } if got := result.Warnings; len(got) != 7 || !reflect.DeepEqual( []string{got[0].ReasonCode, got[1].ReasonCode, got[2].ReasonCode, got[3].ReasonCode, got[4].ReasonCode, got[5].ReasonCode, got[6].ReasonCode}, []string{ReasonCodeProseNormalized, ReasonCodeProseNormalized, ReasonCodeProseNormalized, ReasonCodeProseNormalized, ReasonCodeOrderNormalized, ReasonCodeOrderNormalized, ReasonCodeDuplicateCollapsed}, ) || !reflect.DeepEqual( []string{got[0].Scope, got[1].Scope, got[2].Scope, got[3].Scope, got[4].Scope, got[5].Scope, got[6].Scope}, []string{"scenes[0]", "scenes[1]", "scenes[2]", "scenes[3]", "scenes[2]", "scenes[0]", "scenes[3]"}, ) { t.Fatalf("warnings = %#v, want prose, order, then duplicate mutation diagnostics", got) } if !reflect.DeepEqual(input, before) { t.Fatalf("Normalize() mutated input: %#v", input) } result.Value.Scenes[0].Title = "changed" if input.Scenes[0].Title == "changed" { t.Fatal("normalized output aliases input storage") } second, err := New(Options{}).Normalize(context.Background(), normalizeRequest(dnd.SceneDescriptionList{Scenes: want}, doc)) if err != nil { t.Fatalf("second Normalize() error = %v", err) } if !reflect.DeepEqual(second.Value.Scenes, want) || len(second.Warnings) != 0 { t.Fatalf("second normalization = %#v, want unchanged value without warnings", second) } } func TestNormalizeLimitsCombinedSceneMutationWarnings(t *testing.T) { count := diagnostics.MaxWarnings - 8 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, count)} for index := range input.Scenes { unitID := count - index doc.Units[index] = source.SourceUnit{ID: index + 1} input.Scenes[index] = scene(fmt.Sprintf("scene-%02d", unitID), unitID, unitID, dnd.SceneKindNarrative, " Arrival ", " The party arrives. ") } result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc)) if err != nil { t.Fatalf("Normalize() error = %v", err) } if len(result.Value.Scenes) != count || result.Value.Scenes[0].SourceRef.StartUnitID != 1 { t.Fatalf("normalized scenes = %#v, want unchanged canonical values", result.Value.Scenes) } if len(result.Warnings) != diagnostics.MaxWarnings { t.Fatalf("warning count = %d, want %d", len(result.Warnings), diagnostics.MaxWarnings) } if first := result.Warnings[0]; first.Scope != "scenes[0]" || first.ReasonCode != ReasonCodeProseNormalized { t.Fatalf("first warning = %#v, want first prose warning", first) } summary := result.Warnings[len(result.Warnings)-1] if summary.Scope != "scenes" || summary.ReasonCode != ReasonCodeNormalizationWarningsOmitted || summary.Message != "5 additional warning(s) omitted" { t.Fatalf("warning summary = %#v", summary) } } func TestNormalizeRejectsInvalidCandidatesAndConflicts(t *testing.T) { doc := testDocument() valid := scene("one", 1, 1, dnd.SceneKindNarrative, "Arrival", "The party arrives.") for _, test := range []struct { name string input dnd.SceneDescriptionList }{ {name: "nil list", input: dnd.SceneDescriptionList{}}, {name: "empty list", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}}, {name: "blank ID", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives.", SourceRef: valid.SourceRef}}}}, {name: "invalid kind", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: "other", Title: "Arrival", Summary: "The party arrives.", SourceRef: valid.SourceRef}}}}, {name: "invalid range", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives.", SourceRef: source.SourceRef{SourceID: doc.ID, StartUnitID: 9, EndUnitID: 9}}}}}, {name: "same ID conflict", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{valid, scene("one", 2, 2, dnd.SceneKindNarrative, "Departure", "The party leaves.")}}}, {name: "same range conflict", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{valid, scene("two", 1, 1, dnd.SceneKindCombat, "Ambush", "Bandits strike.")}}}, } { t.Run(test.name, func(t *testing.T) { if _, err := New(Options{}).Normalize(context.Background(), normalizeRequest(test.input, doc)); err == nil { t.Fatal("Normalize() error = nil, want rejection") } }) } } func TestNormalizeUsesDocumentOrderRatherThanNumericUnitID(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ {ID: 20, Text: "The party arrives."}, {ID: 10, Text: "The party departs."}, }} input := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{ scene("later", 10, 10, dnd.SceneKindNarrative, "Departure", "The party departs."), scene("first", 20, 20, dnd.SceneKindNarrative, "Arrival", "The party arrives."), }} result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc)) if err != nil { t.Fatalf("Normalize() error = %v", err) } if got, want := []string{result.Value.Scenes[0].ID, result.Value.Scenes[1].ID}, []string{"first", "later"}; !reflect.DeepEqual(got, want) { t.Fatalf("scene order = %#v, want %#v", got, want) } } func TestNormalizerContractAndCancellation(t *testing.T) { if _, err := DecodeOptions(nil); err != nil { t.Fatalf("DecodeOptions(nil) error = %v", err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown options") } want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.SceneDescriptionListKind} 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", err) } normalizer := New(Options{}) if metadata := normalizer.ManifestMetadata(); metadata["normalizer_policy"] != normalizerPolicy { t.Fatalf("metadata = %#v", metadata) } if got, want := normalizer.CheckpointFingerprints(), []pipeline.CheckpointFingerprint{{Name: "normalizer_policy", Value: normalizerPolicy}}; !reflect.DeepEqual(got, want) { t.Fatalf("fingerprints = %#v, want %#v", got, want) } canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.SceneDescriptionList{}, testDocument())); err == nil || !strings.Contains(err.Error(), "context") { t.Fatalf("canceled Normalize() error = %v", err) } if _, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene("one", 1, 1, dnd.SceneKindNarrative, "Arrival", "The party arrives.")}}, nil)); err == nil || !strings.Contains(err.Error(), "source document") { t.Fatalf("nil source Normalize() error = %v", err) } } func normalizeRequest(value dnd.SceneDescriptionList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.SceneDescriptionList] { return contracts.TypedNormalizeRequest[dnd.SceneDescriptionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.SceneDescriptionList]{Value: value}} } func testDocument() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Rules are discussed."}, {ID: 2, Text: "The party travels."}, {ID: 3, Text: "The party returns."}}} } func scene(id string, start, end int, kind dnd.SceneKind, title, summary string) dnd.SceneDescription { return dnd.SceneDescription{ID: id, SourceRef: source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end}, Kind: kind, Title: title, Summary: summary} } func cloneList(value dnd.SceneDescriptionList) dnd.SceneDescriptionList { if value.Scenes != nil { value.Scenes = append([]dnd.SceneDescription(nil), value.Scenes...) } return value }