Backend: mig 110/111 (will be renumbered after merging main), validators + helpers widened, BuildProjectCode helper + projection populator wired into List/GetByID/ListAncestors/GetTree/CCR. All internal Go tests pass. Frontend: ProjectFormFields conditional render — opponent_code on litigation, our_side renamed to Client Role on case with grouped optgroups. i18n keys for both DE and EN. fristenrechner perspective mapping widened. project-form.ts payload reader/writer + showFieldsForType toggle for new litigation block. Migration slots about to be bumped (mig 110 was claimed by euler's project_type_other on main).
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package services
|
|
|
|
// Pure-function tests for ProjectionService — no DB required, runs by
|
|
// default. Validates the deterministic sort order and status-mapping
|
|
// behaviour; the live integration test in projection_service_test.go
|
|
// covers the SQL paths.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
|
|
d1 := uuid.New()
|
|
d2 := uuid.New()
|
|
a1 := uuid.New()
|
|
pe1 := uuid.New()
|
|
|
|
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
|
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
|
|
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
|
|
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
|
|
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
|
|
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
|
|
}
|
|
|
|
sortTimeline(rows)
|
|
|
|
// Date ASC (Mar1, Mar5, Mar10), undated last.
|
|
if rows[0].Title != "Mar1 deadline" {
|
|
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
|
|
}
|
|
if rows[1].Title != "Mar5 appointment" {
|
|
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
|
|
}
|
|
if rows[2].Title != "Mar10 deadline" {
|
|
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
|
|
}
|
|
if rows[3].Title != "Undated milestone" {
|
|
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
|
|
}
|
|
}
|
|
|
|
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
|
|
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
|
d1 := uuid.New()
|
|
a1 := uuid.New()
|
|
pe1 := uuid.New()
|
|
|
|
rows := []TimelineEvent{
|
|
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
|
|
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
|
|
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
|
|
}
|
|
|
|
sortTimeline(rows)
|
|
|
|
// Tiebreak: deadline > appointment > milestone (kindOrder).
|
|
if rows[0].Kind != "deadline" {
|
|
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
|
|
}
|
|
if rows[1].Kind != "appointment" {
|
|
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
|
|
}
|
|
if rows[2].Kind != "milestone" {
|
|
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
|
|
}
|
|
}
|
|
|
|
func TestDeadlineStatus(t *testing.T) {
|
|
today := time.Now().UTC()
|
|
yesterday := today.AddDate(0, 0, -1)
|
|
tomorrow := today.AddDate(0, 0, 1)
|
|
|
|
cases := []struct {
|
|
name string
|
|
status string
|
|
due time.Time
|
|
want string
|
|
}{
|
|
{"completed regardless of date", "completed", yesterday, "done"},
|
|
{"completed even if future", "completed", tomorrow, "done"},
|
|
{"pending past = overdue", "pending", yesterday, "overdue"},
|
|
{"pending today = open", "pending", today, "open"},
|
|
{"pending future = open", "pending", tomorrow, "open"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := deadlineStatus(c.status, c.due)
|
|
if got != c.want {
|
|
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
|
|
c.status, c.due, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppointmentStatus(t *testing.T) {
|
|
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
|
past := now.Add(-1 * time.Hour)
|
|
future := now.Add(1 * time.Hour)
|
|
|
|
if got := appointmentStatus(past, now); got != "done" {
|
|
t.Errorf("past appointment status = %q, want done", got)
|
|
}
|
|
if got := appointmentStatus(future, now); got != "open" {
|
|
t.Errorf("future appointment status = %q, want open", got)
|
|
}
|
|
}
|
|
|
|
func TestMilestoneStatus(t *testing.T) {
|
|
custom := "custom_milestone"
|
|
other := "counterclaim_filed"
|
|
|
|
cases := []struct {
|
|
name string
|
|
timelineKind *string
|
|
eventType *string
|
|
want string
|
|
}{
|
|
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
|
|
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
|
|
{"structural milestone = done", nil, &other, "done"},
|
|
{"both nil = done", nil, nil, "done"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := milestoneStatus(c.timelineKind, c.eventType)
|
|
if got != c.want {
|
|
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKindOrder(t *testing.T) {
|
|
// Lock the exact ordering — frontend assumes deadline before
|
|
// appointment before milestone before projected on same-date ties.
|
|
if kindOrder("deadline") >= kindOrder("appointment") {
|
|
t.Error("deadline should sort before appointment")
|
|
}
|
|
if kindOrder("appointment") >= kindOrder("milestone") {
|
|
t.Error("appointment should sort before milestone")
|
|
}
|
|
if kindOrder("milestone") >= kindOrder("projected") {
|
|
t.Error("milestone should sort before projected")
|
|
}
|
|
}
|
|
|
|
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
|
|
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
|
|
// These are user-visible policy decisions — locked here to catch
|
|
// accidental shifts during refactors.
|
|
func TestLevelPolicy(t *testing.T) {
|
|
cases := []struct {
|
|
projectType string
|
|
kinds []string
|
|
statuses []string
|
|
laneAxis string
|
|
}{
|
|
{"case", nil, nil, "self_plus_ccr"},
|
|
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
|
|
{"unknown", nil, nil, "self_plus_ccr"},
|
|
{
|
|
"patent",
|
|
[]string{"deadline", "milestone"},
|
|
[]string{"done", "open", "overdue"},
|
|
"child_case",
|
|
},
|
|
{
|
|
"litigation",
|
|
[]string{"milestone"},
|
|
[]string{"done"},
|
|
"child_patent",
|
|
},
|
|
{
|
|
"client",
|
|
[]string{"milestone"},
|
|
[]string{"done"},
|
|
"child_litigation",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.projectType, func(t *testing.T) {
|
|
got := levelPolicy(c.projectType)
|
|
if got.LaneAxis != c.laneAxis {
|
|
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
|
|
}
|
|
if !sliceEqual(got.Kinds, c.kinds) {
|
|
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
|
|
}
|
|
if !sliceEqual(got.Statuses, c.statuses) {
|
|
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func sliceEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
|
|
// a project_event milestone with bubble_up=true survives the level
|
|
// policy's kind/status filter at higher levels (design §5.3 + Q5).
|
|
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
|
|
allowKind := stringSet([]string{"deadline"}) // milestones excluded
|
|
allowStatus := stringSet([]string{"done"}) // off_script excluded
|
|
bubbledMilestone := TimelineEvent{
|
|
Kind: "milestone",
|
|
Status: "off_script",
|
|
BubbleUp: true,
|
|
}
|
|
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
|
|
t.Error("bubble_up=true row should survive both kind and status filters")
|
|
}
|
|
|
|
regularMilestone := TimelineEvent{
|
|
Kind: "milestone",
|
|
Status: "off_script",
|
|
}
|
|
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
|
|
t.Error("regular milestone should be filtered when kind/status both excluded")
|
|
}
|
|
|
|
// kind allowed, status excluded → drop.
|
|
allowedKindBadStatus := TimelineEvent{
|
|
Kind: "deadline",
|
|
Status: "open",
|
|
}
|
|
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
|
|
t.Error("excluded status should drop a row even when kind allowed")
|
|
}
|
|
|
|
// kind excluded, status allowed → drop.
|
|
badKindGoodStatus := TimelineEvent{
|
|
Kind: "appointment",
|
|
Status: "done",
|
|
}
|
|
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
|
|
t.Error("excluded kind should drop a row even when status allowed")
|
|
}
|
|
|
|
// Empty filters = pass-through.
|
|
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
|
|
t.Error("empty filters should pass everything")
|
|
}
|
|
}
|
|
|
|
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
|
|
// - counterclaim_created / third_party_intervention / scope_change
|
|
// default to true.
|
|
// - custom_milestone defaults to false.
|
|
// - Explicit metadata.bubble_up always wins.
|
|
func TestExtractBubbleUp(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
eventType *string
|
|
timelineKind *string
|
|
want bool
|
|
}{
|
|
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
|
|
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
|
|
{"scope_change defaults true", "", str("scope_change"), nil, true},
|
|
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
|
|
{"unknown defaults false", "{}", str("note_created"), nil, false},
|
|
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
|
|
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
|
|
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
|
|
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
|
|
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
|
|
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
|
|
{"empty metadata + nil event_type = false", "", nil, nil, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
|
|
if got != c.want {
|
|
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChildTypeForAxis pins the axis → project type map.
|
|
func TestChildTypeForAxis(t *testing.T) {
|
|
cases := map[string]string{
|
|
"child_case": "case",
|
|
"child_patent": "patent",
|
|
"child_litigation": "litigation",
|
|
"self_plus_ccr": "",
|
|
"": "",
|
|
"bogus": "",
|
|
}
|
|
for axis, want := range cases {
|
|
if got := childTypeForAxis(axis); got != want {
|
|
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
|
|
// (t-paliad-174 §11 Q2, widened in t-paliad-222 / m/paliad#47):
|
|
// - Default (override nil): flip across the active / reactive axis —
|
|
// claimant ↔ defendant, applicant ↔ respondent, appellant →
|
|
// respondent. third_party / other / NULL pass through.
|
|
// - Override true: same default-flip semantics.
|
|
// - Override false (R.49.2.b CCI edge case): keep parent's side.
|
|
// - NULL parent_side yields empty string (no flip without a starting side).
|
|
func TestDerivedCounterclaimOurSide(t *testing.T) {
|
|
tru := true
|
|
fal := false
|
|
str := func(s string) *string { return &s }
|
|
|
|
cases := []struct {
|
|
name string
|
|
parent *string
|
|
override *bool
|
|
want string
|
|
}{
|
|
{"nil parent → empty", nil, nil, ""},
|
|
{"nil parent + override → empty", nil, &tru, ""},
|
|
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
|
|
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
|
|
{"applicant → respondent (default)", str("applicant"), nil, "respondent"},
|
|
{"respondent → applicant (default)", str("respondent"), nil, "applicant"},
|
|
{"appellant → respondent (default)", str("appellant"), nil, "respondent"},
|
|
{"third_party passes through", str("third_party"), nil, "third_party"},
|
|
{"other passes through", str("other"), nil, "other"},
|
|
{"explicit flip=true", str("claimant"), &tru, "defendant"},
|
|
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
|
|
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
|
|
{"flip=false on applicant keeps applicant", str("applicant"), &fal, "applicant"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := derivedCounterclaimOurSide(c.parent, c.override)
|
|
if got != c.want {
|
|
t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
|
|
c.parent, c.override, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|