init push
This commit is contained in:
267
internal/service/fleet_status_service_test.go
Normal file
267
internal/service/fleet_status_service_test.go
Normal file
@@ -0,0 +1,267 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user