package service import ( "errors" "strings" "testing" "time" complaint "wucher/internal/domain/complaint" fleetstatus "wucher/internal/domain/fleet_status" "wucher/internal/domain/helicopter" helicopterusage "wucher/internal/domain/helicopter_usage" "wucher/internal/shared/pkg/nextdue" ) func ptr[T any](v T) *T { return &v } func TestDecideAutoAOG(t *testing.T) { now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC) reasonFn := func(c complaint.Complaint) string { return "complaint:" + c.Description } noComplaints := func() ([]complaint.Complaint, error) { return nil, nil } t.Run("next due grounds; complaints not consulted", func(t *testing.T) { loaderCalled := false loader := func() ([]complaint.Complaint, error) { loaderCalled = true; return nil, nil } evals := []NextDueEvaluation{{InspectionType: "A/F", AOG: true, Reason: "A/F due hours reached"}} grounded, reason, source, err := decideAutoAOG(evals, now, loader, reasonFn) if err != nil || !grounded { t.Fatalf("expected grounded, got grounded=%v err=%v", grounded, err) } if source != helicopter.AOGSourceAuto { t.Fatalf("source = %q, want auto", source) } if !strings.HasPrefix(reason, autoAOGReasonPrefixNextDue) { t.Fatalf("reason %q missing next-due prefix", reason) } if loaderCalled { t.Fatal("next-due grounded -> complaints must not be loaded (short-circuit)") } }) t.Run("complaint grounds when no next due", func(t *testing.T) { classified := now.Add(-time.Hour) loader := func() ([]complaint.Complaint, error) { return []complaint.Complaint{{Description: "blade crack", MELSeverity: complaint.MELSeverityNonMEL, ReportedAt: now.Add(-time.Hour), MELClassifiedAt: &classified}}, nil } grounded, reason, source, err := decideAutoAOG(nil, now, loader, reasonFn) if err != nil || !grounded { t.Fatalf("expected grounded, got grounded=%v err=%v", grounded, err) } if source != helicopter.AOGSourceComplaint { t.Fatalf("source = %q, want complaint", source) } if reason != "complaint:blade crack" { t.Fatalf("reason = %q, want injected complaint reason", reason) } }) t.Run("nothing grounds", func(t *testing.T) { grounded, _, _, err := decideAutoAOG(nil, now, noComplaints, reasonFn) if err != nil || grounded { t.Fatalf("expected not grounded, got grounded=%v err=%v", grounded, err) } }) t.Run("loader error propagates", func(t *testing.T) { loader := func() ([]complaint.Complaint, error) { return nil, errors.New("db down") } if _, _, _, err := decideAutoAOG(nil, now, loader, reasonFn); err == nil { t.Fatal("expected loader error to propagate") } }) } func assertFloatPtr(t *testing.T, label string, got, want *float64) { t.Helper() if want == nil { if got != nil { t.Errorf("%s = %v, want nil", label, *got) } return } if got == nil { t.Errorf("%s = nil, want %v", label, *want) return } const eps = 1e-9 if diff := *got - *want; diff < -eps || diff > eps { t.Errorf("%s = %v, want %v", label, *got, *want) } } func TestBuildFleetStatusSummaryEngineGating(t *testing.T) { u := &helicopterusage.HelicopterUsage{TotalAirframeHours: 10} got := buildFleetStatusSummary(u, false, false) assertFloatPtr(t, "airframe.hours", got.Airframe.Hours, ptr(10.0)) if got.Engine1.Hours != nil || got.Engine2.Hours != nil { t.Errorf("expected empty engines, got %+v / %+v", got.Engine1, got.Engine2) } } func TestBuildFleetStatusSummaryFromUsage(t *testing.T) { u := &helicopterusage.HelicopterUsage{ TotalLanding: 10, TotalAirframeHours: 3, TotalAirframeCycles: 20, TotalEngine1Hours: 8011, TotalEngine1GpcNgN1: 101, TotalEngine1PtcNfN2: 102, TotalEngine1Ccc: 7, TotalEngine2Hours: 5981, TotalRotorBrakeCycle: 4, TotalHookRelease: 2, } got := buildFleetStatusSummary(u, true, false) assertFloatPtr(t, "airframe.hours", got.Airframe.Hours, ptr(3.0)) assertFloatPtr(t, "airframe.cycles", got.Airframe.Cycles, ptr(20.0)) assertFloatPtr(t, "engine1.hours", got.Engine1.Hours, ptr(8011.0)) assertFloatPtr(t, "engine1.gpc_ng_n1", got.Engine1.GpcNgN1, ptr(101.0)) assertFloatPtr(t, "engine1.ptc_nf_n2", got.Engine1.PtcNfN2, ptr(102.0)) assertFloatPtr(t, "engine1.ccc", got.Engine1.Ccc, ptr(7.0)) if got.Engine2.Hours != nil { t.Errorf("engine 2 absent (NR2 false) -> hours must be nil, got %v", *got.Engine2.Hours) } assertFloatPtr(t, "landings", got.Landings, ptr(10.0)) assertFloatPtr(t, "rotor_brake", got.RotorBrakeCycles, ptr(4.0)) assertFloatPtr(t, "hook_releases", got.HookReleases, ptr(2.0)) } func TestDerefFloat(t *testing.T) { if derefFloat(nil) != 0 { t.Fatal("nil should deref to 0") } if derefFloat(ptr(12.5)) != 12.5 { t.Fatal("deref value mismatch") } } func TestEvaluateNextDueSchedulesPerComponent(t *testing.T) { now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC) schedules := []fleetstatus.MaintenanceSchedule{ {InspectionType: fleetstatus.InspectionTypeAF, Due: "100"}, {InspectionType: fleetstatus.InspectionTypeENG1, Due: "100", Ext: "120"}, {InspectionType: fleetstatus.InspectionTypeENG2, Due: "100"}, } hours := ComponentHours{Airframe: 150, Engine1: 110, Engine2: 90} // Engine 2 present: ENG2 (90 < 100) not overdue; A/F (150>100) overdue; ENG1 (110<120 ext) not overdue. evals, err := EvaluateNextDueSchedules(schedules, hours, true, true, true, now) if err != nil { t.Fatalf("err: %v", err) } got := map[string]bool{} for _, e := range evals { got[e.InspectionType] = e.AOG } if !got[fleetstatus.InspectionTypeAF] { t.Error("A/F should be overdue (150 > 100)") } if got[fleetstatus.InspectionTypeENG1] { t.Error("ENG1 should NOT be overdue (110 < 120 ext)") } if got[fleetstatus.InspectionTypeENG2] { t.Error("ENG2 should NOT be overdue (90 < 100)") } // Engine 2 absent: an ENG2 schedule must be skipped even if hours exceed the limit. hours.Engine2 = 999 evals2, err := EvaluateNextDueSchedules(schedules, hours, true, true, false, now) if err != nil { t.Fatalf("err: %v", err) } for _, e := range evals2 { if e.InspectionType == fleetstatus.InspectionTypeENG2 && e.AOG { t.Error("absent engine 2 must not ground the aircraft") } } } func TestEvaluateInspectionDueHours(t *testing.T) { now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC) p := nextdue.NewParser() cases := []struct { name string due string ext string current float64 wantAOG bool }{ {name: "below due", due: "100", ext: "", current: 99, wantAOG: false}, {name: "exactly at due not overdue", due: "100", ext: "", current: 100, wantAOG: false}, {name: "past due", due: "100", ext: "", current: 101, wantAOG: true}, {name: "ext extends beyond due", due: "100", ext: "120", current: 110, wantAOG: false}, {name: "past ext", due: "100", ext: "120", current: 121, wantAOG: true}, {name: "ext zero treated as unset", due: "100", ext: "0", current: 101, wantAOG: true}, {name: "unset", due: "", ext: "", current: 9999, wantAOG: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ev, err := evaluateInspectionDue(p, "A/F", tc.due, tc.ext, tc.current, true, now) if err != nil { t.Fatalf("unexpected err: %v", err) } if ev.AOG != tc.wantAOG { t.Fatalf("AOG = %v (reason %q), want %v", ev.AOG, ev.Reason, tc.wantAOG) } }) } } func TestEvaluateInspectionDueDate(t *testing.T) { now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC) p := nextdue.NewParser() cases := []struct { name string due string ext string wantAOG bool }{ {name: "due date passed", due: "2026-06-10", ext: "", wantAOG: true}, {name: "due date today not yet overdue", due: "2026-06-17", ext: "", wantAOG: false}, {name: "due date future", due: "2026-06-20", ext: "", wantAOG: false}, {name: "ext date extends beyond passed due", due: "2026-06-10", ext: "2026-06-25", wantAOG: false}, {name: "past ext date", due: "2026-06-10", ext: "2026-06-12", wantAOG: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ev, err := evaluateInspectionDue(p, "MAIN1", tc.due, tc.ext, 0, true, now) if err != nil { t.Fatalf("unexpected err: %v", err) } if ev.AOG != tc.wantAOG { t.Fatalf("AOG = %v (reason %q), want %v", ev.AOG, ev.Reason, tc.wantAOG) } }) } } func TestEvaluateInspectionDueInvalid(t *testing.T) { p := nextdue.NewParser() if _, err := evaluateInspectionDue(p, "A/F", "100 FH", "", 0, true, time.Now()); err == nil { t.Fatal("expected parse error for due '100 FH'") } if _, err := evaluateInspectionDue(p, "A/F", "100", "bad ext", 0, true, time.Now()); err == nil { t.Fatal("expected parse error for ext 'bad ext'") } } func TestEvaluateInspectionDueNoUsageData(t *testing.T) { p := nextdue.NewParser() now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC) ev, err := evaluateInspectionDue(p, "A/F", "100", "", 150, false, now) if err != nil { t.Fatalf("err: %v", err) } if ev.AOG { t.Fatal("hours due must not ground when there is no usage data yet") } evDate, err := evaluateInspectionDue(p, "MAIN1", "2026-06-10", "", 0, false, now) if err != nil { t.Fatalf("err: %v", err) } if !evDate.AOG { t.Fatal("a passed date due should still ground regardless of usage data") } }