package litigationplanner import ( "strings" "testing" ) // TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed // jsonb with version=1 parses; unknown versions and malformed JSON // surface ErrInvalidScenario. func TestParseSpec_Roundtrip(t *testing.T) { cases := []struct { name string spec string wantErr bool }{ { "v1 primary-only", `{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`, false, }, { "v1 with full primary entry", `{"version":1,"base_trigger_date":"2026-05-26","proceedings":[ {"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"], "anchor_overrides":{"inf.reply":"2026-08-15"}, "skip_rules":["inf.r30_amend"]} ]}`, false, }, { "v2 spec rejected — unknown version", `{"version":2,"proceedings":[]}`, true, }, { "empty spec", ``, true, }, { "malformed json", `{"version":1,"proceedings":[}`, true, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := ParseSpec(NullableJSON(c.spec)) if c.wantErr && err == nil { t.Errorf("ParseSpec(%s): want error, got nil", c.spec) } if !c.wantErr && err != nil { t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err) } }) } } // TestScenarioSpec_PrimaryProceeding pins the "exactly one primary" // invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario. func TestScenarioSpec_PrimaryProceeding(t *testing.T) { t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) { s := &ScenarioSpec{ Version: 1, Proceedings: []ScenarioProceeding{ {Code: "upc.inf.cfi", Role: ScenarioRolePeer}, }, } _, err := s.PrimaryProceeding() if err != ErrScenarioNoPrimary { t.Errorf("want ErrScenarioNoPrimary, got %v", err) } }) t.Run("two primaries rejected", func(t *testing.T) { s := &ScenarioSpec{ Version: 1, Proceedings: []ScenarioProceeding{ {Code: "upc.inf.cfi", Role: ScenarioRolePrimary}, {Code: "upc.rev.cfi", Role: ScenarioRolePrimary}, }, } _, err := s.PrimaryProceeding() if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") { t.Errorf("want multi-primary error, got %v", err) } }) t.Run("single primary picked", func(t *testing.T) { s := &ScenarioSpec{ Version: 1, Proceedings: []ScenarioProceeding{ {Code: "upc.inf.cfi", Role: ScenarioRolePeer}, {Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}}, }, } p, err := s.PrimaryProceeding() if err != nil { t.Fatalf("PrimaryProceeding: %v", err) } if p.Code != "upc.rev.cfi" { t.Errorf("primary code = %q, want upc.rev.cfi", p.Code) } if len(p.Flags) != 1 || p.Flags[0] != "with_amend" { t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags) } }) } // TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec // jsonb into the CalcOptions the engine consumes. Pins: // - base_trigger_date used when no per-proceeding override // - trigger_date_override wins when set // - flags + anchor_overrides + appeal_target passed through verbatim // - per_card_choices unpacked into PerCardAppellant / SkipRules / // IncludeCCRFor maps func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) { includeTrue := true skipTrue := true s := &ScenarioSpec{ Version: 1, BaseTriggerDate: "2026-05-26", Proceedings: []ScenarioProceeding{{ Code: "upc.inf.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_ccr"}, AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"}, AppealTarget: "endentscheidung", SkipRules: []string{"explicit_skip_code"}, PerCardChoices: map[string]ScenarioCardChoice{ "inf.r30_amend": {Appellant: "claimant"}, "inf.rejoin": {IncludeCCR: &includeTrue}, "inf.amend_other": {Skip: &skipTrue}, }, }}, } code, td, opts, err := s.CalcOptionsFromSpec() if err != nil { t.Fatalf("CalcOptionsFromSpec: %v", err) } if code != "upc.inf.cfi" { t.Errorf("code = %q, want upc.inf.cfi", code) } if td != "2026-05-26" { t.Errorf("triggerDate = %q, want 2026-05-26", td) } if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" { t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags) } if opts.AppealTarget != "endentscheidung" { t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget) } if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" { t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got) } if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" { t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got) } if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok { t.Error("opts.IncludeCCRFor missing inf.rejoin") } if _, ok := opts.SkipRules["inf.amend_other"]; !ok { t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)") } if _, ok := opts.SkipRules["explicit_skip_code"]; !ok { t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])") } } // TestScenarioSpec_TriggerDateOverride pins the per-proceeding override // path (v2-ready — primary entry honours trigger_date_override too). func TestScenarioSpec_TriggerDateOverride(t *testing.T) { s := &ScenarioSpec{ Version: 1, BaseTriggerDate: "2026-05-26", Proceedings: []ScenarioProceeding{{ Code: "upc.inf.cfi", Role: ScenarioRolePrimary, TriggerDateOverride: "2026-12-01", }}, } _, td, _, err := s.CalcOptionsFromSpec() if err != nil { t.Fatalf("CalcOptionsFromSpec: %v", err) } if td != "2026-12-01" { t.Errorf("triggerDate = %q, want override 2026-12-01", td) } } // TestScenarioSpec_NoBaseTrigger pins the safety check that a spec // without base_trigger_date AND without per-proceeding override // surfaces ErrInvalidScenario (the engine can't render without a date). func TestScenarioSpec_NoBaseTrigger(t *testing.T) { s := &ScenarioSpec{ Version: 1, Proceedings: []ScenarioProceeding{{ Code: "upc.inf.cfi", Role: ScenarioRolePrimary, }}, } _, _, _, err := s.CalcOptionsFromSpec() if err == nil { t.Fatal("want ErrInvalidScenario, got nil") } }