Files
paliad/internal/services/projection_anchor_test.go
mAi 293e612582
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(projection): IsConditional for uncertain-anchor rules (t-paliad-289)
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.

Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
  main loop, so order-of-evaluation in sequence_order no longer matters
  for the parent-court-set check. Fixes R.109(1) "Antrag auf
  Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
  sequence_order=50): the timing='before' backward arithmetic was
  computing 1 month before the trigger date because the court-set
  parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
  R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
  rules whose data-model parent is the trigger anchor (covers R.262(2)
  confidentiality_response: the data anchors on SoC, but the real
  trigger is the opposing party's confidentiality motion which may
  never happen). Suppressed by IsOverridden so user anchors win.

Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
  DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
  carries the "abhängig von <parent>" payload even when the parent
  has no computed date for annotateDependsOn to discover.

Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
  click-to-edit affordance in place of the date column when
  isConditional=true. IsConditional wins over IsCourtSet for the
  date column (they overlap; "abhängig von <parent>" names the
  specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
  dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
  escape so the module is testable in bun test without jsdom (the
  old form forced fixtures to leave several fields empty just to
  avoid the DOM dependency).

Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
  that conditional rows pass through applyLookaheadCap untouched
  (don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
  asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
  IsConditional=true with empty DueDate + populated ParentRule*; SoD
  stays non-conditional; override on the oral hearing flips R.109(1)
  back to concrete date.
- 4 new bun tests for the conditional rendering branches in
  deadlineCardHtml.

UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.

go build / go test / bun test / bun run build all clean.
2026-05-26 09:56:15 +02:00

383 lines
12 KiB
Go

package services
// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
// no DB required. Validates lookahead cap behaviour, anchor-kind
// dispatch, and extractMetadataString. The live integration test in
// projection_service_test.go covers SQL paths; this file covers the
// pure helpers a future refactor most likely to break.
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestApplyLookaheadDefault(t *testing.T) {
cases := []struct {
in, want int
}{
{0, DefaultLookaheadCap},
{-5, DefaultLookaheadCap},
{1, 1},
{7, 7},
{50, 50},
{51, MaxLookaheadCap},
{1000, MaxLookaheadCap},
}
for _, c := range cases {
if got := applyLookaheadDefault(c.in); got != c.want {
t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
}
}
}
func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Two predicted_overdue (past) — must survive uncapped.
{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
// One court_set future — exempt from cap.
{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 2 {
t.Errorf("PredictedOverdue = %d, want 2", overdue)
}
// kept must include both overdue + court_set + first 2 predicted = 5 rows.
if len(kept) != 5 {
t.Errorf("kept rows = %d, want 5", len(kept))
}
// Past + court_set must remain.
pastTitles := map[string]bool{}
for _, r := range kept {
pastTitles[r.Title] = true
}
for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
if !pastTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if pastTitles["Fut 3"] {
t.Errorf("Fut 3 should have been dropped (cap=2)")
}
}
func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
}
kept, total, shown, _ := applyLookaheadCap(rows, 7)
if total != 1 || shown != 1 {
t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
}
if len(kept) != 1 {
t.Errorf("kept = %d, want 1", len(kept))
}
}
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
// pass through applyLookaheadCap untouched — they're not "future
// predicted" rows by either Status or Date semantics, so they belong in
// the pass-through bucket alongside court_set / undated rows. The cap
// must NOT consume one of its slots for a conditional row, and the
// row must survive even when projTotal exceeds the cap.
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
// Two conditional — must survive uncapped, must NOT count
// against projTotal / projShown.
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 0 {
t.Errorf("PredictedOverdue = %d, want 0", overdue)
}
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
if len(kept) != 4 {
t.Errorf("kept rows = %d, want 4", len(kept))
}
keptTitles := map[string]bool{}
for _, r := range kept {
keptTitles[r.Title] = true
}
for _, want := range []string{"F1", "F2", "C1", "C2"} {
if !keptTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if keptTitles["F3"] {
t.Errorf("F3 should have been dropped (cap=2)")
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"
order := "order"
filing := "filing"
cases := []struct {
name string
rule *models.DeadlineRule
want string
}{
{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
{"nil rule → deadline", nil, "deadline"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ruleAnchorKind(c.rule); got != c.want {
t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
}
})
}
}
func TestExtractMetadataString(t *testing.T) {
cases := []struct {
name string
raw string
key string
want string
}{
{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
{"missing key", `{"foo":"bar"}`, "rule_code", ""},
{"empty json", `{}`, "rule_code", ""},
{"empty raw", ``, "rule_code", ""},
{"non-string value", `{"rule_code":123}`, "rule_code", ""},
{"malformed", `{`, "rule_code", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractMetadataString(json.RawMessage(c.raw), c.key)
if got != c.want {
t.Errorf("extractMetadataString = %q, want %q", got, c.want)
}
})
}
}
func TestLang(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "de"},
{"de", "de"},
{"DE", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"},
}
for _, c := range cases {
if got := lang(c.in); got != c.want {
t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestRuleNameInLang(t *testing.T) {
r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
t.Errorf("de = %q", got)
}
if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
t.Errorf("en = %q", got)
}
rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
t.Errorf("missing EN should fall back to DE, got %q", got)
}
}
func TestCrossProceedingAnchorError(t *testing.T) {
parentID := uuid.New()
cpe := &CrossProceedingAnchorError{
RequestedRuleCode: "upc.inf.cfi.soc",
RequestedRuleNameDE: "Klageschrift",
RequestedRuleNameEN: "Statement of Claim",
ParentProjectID: parentID,
ParentProjectTitle: "UPC-CFI München — Klage",
}
got, ok := IsCrossProceedingAnchor(cpe)
if !ok {
t.Fatal("IsCrossProceedingAnchor on direct error should return ok")
}
if got != cpe {
t.Errorf("unwrapped pointer mismatch")
}
wrapped := wrap(cpe, "context")
got2, ok2 := IsCrossProceedingAnchor(wrapped)
if !ok2 {
t.Fatal("IsCrossProceedingAnchor on wrapped error should return ok")
}
if got2 != cpe {
t.Errorf("unwrapped wrapped pointer mismatch")
}
if _, ok := IsCrossProceedingAnchor(errOther{}); ok {
t.Error("non-CPE should not unwrap as CPE")
}
// And the inverse: a PredecessorMissingError must NOT match the
// cross-proceeding helper (the two coexist on the 409 response and a
// mistaken unwrap would render the wrong UI).
pme := &PredecessorMissingError{RequestedRuleCode: "x"}
if _, ok := IsCrossProceedingAnchor(pme); ok {
t.Error("PredecessorMissingError must not unwrap as CrossProceedingAnchorError")
}
}
func TestPredecessorMissingError(t *testing.T) {
pme := &PredecessorMissingError{
MissingRuleCode: "upc.inf.cfi.soc",
MissingRuleNameDE: "Klageschrift",
MissingRuleNameEN: "Statement of Claim",
RequestedRuleCode: "upc.inf.cfi.sod",
RequestedRuleNameDE: "Klageerwiderung",
RequestedRuleNameEN: "Statement of Defence",
}
got, ok := IsPredecessorMissing(pme)
if !ok {
t.Fatal("IsPredecessorMissing on direct error should return ok")
}
if got != pme {
t.Errorf("unwrapped pointer mismatch")
}
// Wrapping with errors.Errorf-style fmt should still unwrap.
wrapped := wrap(pme, "context")
got2, ok2 := IsPredecessorMissing(wrapped)
if !ok2 {
t.Fatal("IsPredecessorMissing on wrapped error should return ok")
}
if got2 != pme {
t.Errorf("unwrapped wrapped pointer mismatch")
}
// Random other error must not unwrap.
if _, ok := IsPredecessorMissing(errOther{}); ok {
t.Error("non-PME should not unwrap as PME")
}
}
// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
func wrap(err error, msg string) error {
return wrappedErr{msg: msg, inner: err}
}
type wrappedErr struct {
msg string
inner error
}
func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
func (w wrappedErr) Unwrap() error { return w.inner }
type errOther struct{}
func (errOther) Error() string { return "other" }
func TestAnnotateDependsOn(t *testing.T) {
socID := uuid.New()
sodID := uuid.New()
replyID := uuid.New()
socCode := "upc.inf.cfi.soc"
sodCode := "upc.inf.cfi.sod"
replyCode := "upc.inf.cfi.reply"
rules := []models.DeadlineRule{
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
}
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// SoC actual.
{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
DeadlineRuleID: ptrUUID(socID)},
// SoD projected.
{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
DeadlineRuleID: ptrUUID(sodID)},
// Reply projected — depends on SoD.
{Kind: "projected", Status: "predicted", RuleCode: replyCode,
DeadlineRuleID: ptrUUID(replyID)},
}
svc := &ProjectionService{}
svc.annotateDependsOn(rows, rules, "de")
// SoC has no parent — depends_on stays empty.
if rows[0].DependsOnRuleCode != "" {
t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
}
// SoD's depends_on is SoC, dated.
if rows[1].DependsOnRuleCode != socCode {
t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
}
if rows[1].DependsOnRuleName != "Klageschrift" {
t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
}
if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
}
// Reply's depends_on is SoD, dated (from SoD's projected date).
if rows[2].DependsOnRuleCode != sodCode {
t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
}
if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
rows[2].DependsOnDate, sodDate)
}
// English mode flips the name.
svc.annotateDependsOn(rows, rules, "en")
if rows[1].DependsOnRuleName != "Statement of Claim" {
t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
}
}
func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }