init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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")
}
}