feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
Q1 composition: primary + spawned (v1); multi-proceeding peer
compose is the v2 goal (spec.proceedings[] array)
Q2 scope: per-project + abstract (project_id NULL = abstract)
Q3 trigger: per-anchor overrides over one base date
Q4 storage: NEW paliad.scenarios table with jsonb spec
(NOT a project_event_choices column extension)
Migration 145 — additive only. Pre-flight coordination check:
- On-disk max: 138 (Berufung backfill, just merged).
- Live DB tracker: 106 (significantly behind — many migs pending
deploy).
- curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
as buffer; claimed 145 as the safe minimum that won't collide.
- paliad.scenarios has audit_reason NOT applicable (no audit
trigger on the table); updated_at trigger added on the table
itself.
- paliad.projects gains active_scenario_id uuid NULL FK with ON
DELETE SET NULL (mig 134 lesson — no updated_at clauses on
proceeding_types-style assumptions).
Schema:
paliad.scenarios (
id uuid pk,
project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
name text NOT NULL CHECK char_length > 0,
description text NULL,
spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
created_by uuid NULL FK → users(id) ON DELETE SET NULL,
created_at + updated_at timestamptz,
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
);
paliad.projects.active_scenario_id uuid NULL FK;
RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
Trigger: scenarios_touch_updated_at_trg.
pkg/litigationplanner additions:
- Scenario struct (db + json tags)
- ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
view of the jsonb (version-1 today, v2 multi-peer-ready)
- ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
- ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
- CalculateFromScenario(scenario, catalog, holidays, courts) — high-
level engine entry: parses spec → builds CalcOptions → delegates
to Calculate
- Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
ErrScenarioNoPrimary
paliadCatalog impl:
- LoadScenarios with progressively-built WHERE clauses (project-id
filter, abstract-for-user filter, or all)
- MatchScenario by id — returns ErrUnknownScenario on not-found
- Services connection bypasses RLS; ScenarioService enforces
visibility at the application layer (mirrors EventChoiceService
pattern from t-paliad-265)
SnapshotCatalog impl (embedded/upc):
- LoadScenarios returns empty slice (no scenarios in the snapshot)
- MatchScenario returns ErrUnknownScenario
internal/services/scenario_service.go:
- Create / Get / ListForProject / ListAbstractForUser / Patch /
SetActive / Delete with visibility checks
- validateSpec checks version, base_trigger_date format, every
proceedings[*].code resolves to an active paliad.proceeding_types
row, every appeal_target is valid, every anchor_overrides date
parses, every role ∈ {primary, peer}
- SetActive validates the scenario belongs to the requested project
(a scenario from a different project can't be active here)
- Returns ErrScenarioNotVisible for failed visibility checks
REST endpoints (registered in handlers.go):
GET /api/scenarios?project=<id> — list project's
GET /api/scenarios?abstract=true — list user's abstract
GET /api/scenarios/{id} — one
POST /api/scenarios — create
PATCH /api/scenarios/{id} — partial update
DELETE /api/scenarios/{id} — remove
PUT /api/projects/{id}/active-scenario — set / clear active
Handler error mapping:
- ErrUnknownScenario / ErrScenarioNotVisible → 404
- ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
- everything else → 500
Tests:
- pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
(well-formed + unknown version + malformed json),
PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
unpack, trigger_date_override path, no-base-trigger safety check.
8 cases total, all DB-free.
Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).
Acceptance:
- go build ./... clean
- go test ./... all green (incl. new scenarios tests)
- Pre-flight audit confirmed mig 145 number is safe vs curie's
pending B.2-B.6 range
This commit is contained in:
@@ -67,6 +67,12 @@ func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[
|
||||
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
|
||||
return nil, ErrUnknownScenario
|
||||
}
|
||||
|
||||
// noOpHolidays never adjusts dates — the test fixture doesn't care about
|
||||
// weekends or holidays, only about which base date the engine resolves.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package litigationplanner
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
@@ -59,4 +63,17 @@ type Catalog interface {
|
||||
// (proceeding_type_id, sequence_order) so the frontend can render
|
||||
// without re-sorting.
|
||||
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||
|
||||
// LoadScenarios lists scenarios visible to the caller, narrowed by
|
||||
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
|
||||
// (NOT an error) when no scenarios match. paliad-side impl applies
|
||||
// RLS (paliad.can_see_project for project-scoped, created_by for
|
||||
// abstract); snapshot-backed catalogs return an empty list.
|
||||
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
|
||||
|
||||
// MatchScenario returns the scenario with the given id, or
|
||||
// ErrUnknownScenario if not found / not visible. The engine adapter
|
||||
// (CalculateFromScenario) calls this to fetch a scenario by id and
|
||||
// then unpacks its spec via ParseSpec.
|
||||
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
|
||||
}
|
||||
|
||||
@@ -292,6 +292,20 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadScenarios returns an empty slice. The snapshot catalog has no
|
||||
// scenarios — youpc.org (the consumer today) doesn't carry a project /
|
||||
// user model. Future snapshot variants could ship demo scenarios, but
|
||||
// v1 returns nothing.
|
||||
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
|
||||
return []lp.Scenario{}, nil
|
||||
}
|
||||
|
||||
// MatchScenario always returns ErrUnknownScenario — the snapshot has
|
||||
// no scenarios to match against.
|
||||
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
|
||||
var _ lp.Catalog = (*SnapshotCatalog)(nil)
|
||||
|
||||
|
||||
215
pkg/litigationplanner/scenarios.go
Normal file
215
pkg/litigationplanner/scenarios.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
|
||||
//
|
||||
// A Scenario is a named composition of existing proceedings + flags +
|
||||
// per-card choices + anchor dates. v1 ships with one primary proceeding
|
||||
// per scenario; the spec.proceedings[] array is architected to absorb
|
||||
// multi-peer compose (v2) without a schema migration.
|
||||
//
|
||||
// "users should not add their own rules" (m, t-paliad-301) — the spec
|
||||
// references existing rules by submission_code; it never creates new
|
||||
// ones. ValidateSpec checks every code/submission resolves against the
|
||||
// current catalog before a save is accepted.
|
||||
|
||||
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
|
||||
// API request/response payload for /api/scenarios.
|
||||
type Scenario struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
// Spec carries the jsonb composition. Stored raw so we can ship
|
||||
// shape evolutions without schema churn; ParseSpec gives the
|
||||
// structured view.
|
||||
Spec NullableJSON `db:"spec" json:"spec"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
|
||||
// Future shape changes bump the version; ParseSpec rejects unknown
|
||||
// versions so an old client doesn't silently misread a future-shape
|
||||
// scenario.
|
||||
type ScenarioSpec struct {
|
||||
Version int `json:"version"`
|
||||
BaseTriggerDate string `json:"base_trigger_date"`
|
||||
Proceedings []ScenarioProceeding `json:"proceedings"`
|
||||
}
|
||||
|
||||
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
|
||||
// exactly one with role="primary" (additional entries with role="peer"
|
||||
// are reserved for v2 multi-proceeding compose and silently ignored
|
||||
// by the engine today).
|
||||
type ScenarioProceeding struct {
|
||||
Code string `json:"code"`
|
||||
Role string `json:"role"` // "primary" | "peer" (v2)
|
||||
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
|
||||
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
|
||||
SkipRules []string `json:"skip_rules,omitempty"`
|
||||
AppealTarget string `json:"appeal_target,omitempty"`
|
||||
}
|
||||
|
||||
// ScenarioCardChoice is one entry under
|
||||
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
|
||||
// kinds; not every kind is populated on every card.
|
||||
type ScenarioCardChoice struct {
|
||||
Appellant string `json:"appellant,omitempty"`
|
||||
IncludeCCR *bool `json:"include_ccr,omitempty"`
|
||||
Skip *bool `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
// Spec version constant.
|
||||
const ScenarioSpecVersion = 1
|
||||
|
||||
// Sentinel errors for scenarios.
|
||||
var (
|
||||
ErrUnknownScenario = errors.New("unknown scenario")
|
||||
ErrInvalidScenario = errors.New("invalid scenario spec")
|
||||
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
|
||||
)
|
||||
|
||||
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
|
||||
const (
|
||||
ScenarioRolePrimary = "primary"
|
||||
ScenarioRolePeer = "peer"
|
||||
)
|
||||
|
||||
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
|
||||
// by the engine adapter + the rule-editor preview. Surfaces a friendly
|
||||
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
|
||||
// so the handler can map to a 400.
|
||||
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
|
||||
}
|
||||
var s ScenarioSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
|
||||
}
|
||||
if s.Version != ScenarioSpecVersion {
|
||||
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
|
||||
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// PrimaryProceeding returns the entry from spec.proceedings[] with
|
||||
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
|
||||
// must carry exactly one primary entry. (Multiple primaries are also
|
||||
// rejected: the engine consumes one.)
|
||||
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
|
||||
var primary *ScenarioProceeding
|
||||
for i := range s.Proceedings {
|
||||
if s.Proceedings[i].Role == ScenarioRolePrimary {
|
||||
if primary != nil {
|
||||
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
|
||||
}
|
||||
primary = &s.Proceedings[i]
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, ErrScenarioNoPrimary
|
||||
}
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
|
||||
// entry. The caller still needs the proceeding code + the trigger date,
|
||||
// both returned alongside.
|
||||
//
|
||||
// v1: only the primary entry is honoured. v2 will iterate over peer
|
||||
// entries; the multi-peer merge lives in the paliad-side
|
||||
// ProjectionService (one Calculate call per entry, merged + sorted by
|
||||
// date).
|
||||
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
|
||||
primary, err := s.PrimaryProceeding()
|
||||
if err != nil {
|
||||
return "", "", CalcOptions{}, err
|
||||
}
|
||||
td := s.BaseTriggerDate
|
||||
if primary.TriggerDateOverride != "" {
|
||||
td = primary.TriggerDateOverride
|
||||
}
|
||||
if td == "" {
|
||||
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
|
||||
}
|
||||
|
||||
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
|
||||
skipRules := make(map[string]struct{}, len(primary.SkipRules))
|
||||
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
|
||||
for code, choice := range primary.PerCardChoices {
|
||||
if choice.Appellant != "" {
|
||||
perCardAppellant[code] = choice.Appellant
|
||||
}
|
||||
if choice.IncludeCCR != nil && *choice.IncludeCCR {
|
||||
includeCCRFor[code] = struct{}{}
|
||||
}
|
||||
if choice.Skip != nil && *choice.Skip {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, code := range primary.SkipRules {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
|
||||
return primary.Code, td, CalcOptions{
|
||||
Flags: primary.Flags,
|
||||
AnchorOverrides: primary.AnchorOverrides,
|
||||
AppealTarget: primary.AppealTarget,
|
||||
PerCardAppellant: perCardAppellant,
|
||||
SkipRules: skipRules,
|
||||
IncludeCCRFor: includeCCRFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
|
||||
//
|
||||
// - ProjectID non-nil: only scenarios attached to that project
|
||||
// (project_id = filter.ProjectID).
|
||||
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
|
||||
// NULL) created by that user.
|
||||
// - Both nil: list every scenario the caller can see (RLS-gated).
|
||||
type ScenarioFilter struct {
|
||||
ProjectID *uuid.UUID
|
||||
AbstractForUser *uuid.UUID
|
||||
}
|
||||
|
||||
// CalculateFromScenario is the high-level engine entry for scenario-
|
||||
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
|
||||
// to Calculate.
|
||||
//
|
||||
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
|
||||
// expansion lives on the paliad-side ProjectionService (per-entry
|
||||
// Calculate + client-side merge); the package doesn't own that
|
||||
// orchestration.
|
||||
func CalculateFromScenario(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
spec, err := ParseSpec(scenario.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
|
||||
}
|
||||
207
pkg/litigationplanner/scenarios_test.go
Normal file
207
pkg/litigationplanner/scenarios_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user