package agentapi import "testing" // The v0.215.0 severity ladder, verdict half. The event half (emission, damping, cooldown, // persistence) lives in internal/web — this file pins ONLY what the pure function decides. // // Every value used here is taken from the committed evidence: // felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json // (ST3000VX010-2E3166, S/N Z6A07P2G, /dev/sdg on DooPlex). // realDrive is the failing drive AS CAPTURED on 2026-08-14: PASSED, 352 pending, 352 offline // uncorrectable, 0 reallocated, 40 °C. The whole point of the fixture is that Health is PASSED. func realDrive() *SmartSummary { return &SmartSummary{ Health: SmartPassed, PendingSectors: ip(352), OfflineUncorrectable: ip(352), ReallocatedSectors: ip(0), TemperatureC: ip(40), } } // Group A (verdict half) — Scenario A. The real drive on its SECOND observation reaches Hiba, and // the chip label is exactly "Hiba". // // Red-proof: delete truth-table row 6 (the `prior.SawUncorrectable` case) from DiskVerdictFor → // the drive still reaches Fail via row 8 (352 >= 64), so this test alone does NOT prove row 6. // TestLadder_SustainIsWhatFires below is the one that isolates it. func TestLadder_RealDrive_ReachesHiba(t *testing.T) { got := DiskVerdictFor(realDrive(), DiskPrior{SawUncorrectable: true}) if got != DiskVerdictFail { t.Fatalf("real failing drive verdict = %d (%s), want Fail/Hiba", got, got.Label()) } if got.Label() != "Hiba" { t.Errorf("label = %q, want %q", got.Label(), "Hiba") } // The trap this whole change exists for: the drive's own verdict says everything is fine. if realDrive().Health != SmartPassed { t.Fatal("fixture drift: the real drive's Health must be PASSED — that IS the defect") } } // Groups B + C (verdict half) — Scenarios B and C. The SAME SmartSummary yields Figyelmeztetés on a // first sighting and Hiba once it is sustained. This is the pair that isolates row 6: the counters // are identical and only `prior` differs, so nothing else in the table can be producing the change. // // The values are the 11 August excursion (8 sectors), which cleared completely within an hour — a // count deliberately far below the 64 backstop so row 8 cannot mask row 6. // // Red-proof: remove the `prior.SawUncorrectable` clause from row 6 → the sustained case stays Warn. func TestLadder_SustainIsWhatFires(t *testing.T) { excursion := func() *SmartSummary { return &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), OfflineUncorrectable: ip(8), ReallocatedSectors: ip(0)} } if got := DiskVerdictFor(excursion(), DiskPrior{}); got != DiskVerdictWarn { t.Errorf("first sighting of 8 sectors = %d (%s), want Warn/Figyelmeztetés — a single "+ "excursion that clears by itself is normal and must NOT reach Hiba", got, got.Label()) } if got := DiskVerdictFor(excursion(), DiskPrior{SawUncorrectable: true}); got != DiskVerdictFail { t.Errorf("SAME 8 sectors, now sustained = %d (%s), want Fail/Hiba", got, got.Label()) } if got := DiskVerdictFor(excursion(), DiskPrior{}).Label(); got != "Figyelmeztetés" { t.Errorf("first-sighting label = %q, want Figyelmeztetés", got) } } // Row 8, the backstop — for a box that was powered off or restarted across the sustain window and so // has NO prior. 63 stays Warn, 64 reaches Hiba. The boundary is inclusive, which is what // `uncorrectableFailCount` claims and what the real drive did at 13 Aug 11:28 (exactly 64). // // Red-proof: change `>=` to `>` in row 8 → the "exactly 64" case reads Warn. func TestLadder_CountBackstopBoundary(t *testing.T) { cases := []struct { pending int want DiskVerdict }{ {63, DiskVerdictWarn}, {64, DiskVerdictFail}, {352, DiskVerdictFail}, } for _, c := range cases { s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(c.pending)} if got := DiskVerdictFor(s, DiskPrior{}); got != c.want { t.Errorf("%d pending sectors, no prior = %d (%s), want %d", c.pending, got, got.Label(), c.want) } } // Row 7 — unreadable AND remapping together is Hiba even at a low count with no prior. s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), ReallocatedSectors: ip(1)} if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictFail { t.Errorf("row 7 (unreadable + reallocated) = %d, want Fail", got) } } // Group I — Scenario I, heat. 61 → Hiba, 56 → Figyelmeztetés, 54 → Rendben, with all counters clean. // // Red-proof: remove rows 3 and 13 → all three read Rendben. func TestLadder_Temperature(t *testing.T) { cases := []struct { temp int want DiskVerdict }{ {54, DiskVerdictOK}, {55, DiskVerdictWarn}, // inclusive boundary {56, DiskVerdictWarn}, {59, DiskVerdictWarn}, {60, DiskVerdictFail}, // inclusive boundary {61, DiskVerdictFail}, } for _, c := range cases { s := &SmartSummary{Health: SmartPassed, TemperatureC: ip(c.temp), PendingSectors: ip(0), ReallocatedSectors: ip(0)} if got := DiskVerdictFor(s, DiskPrior{}); got != c.want { t.Errorf("%d °C = %d (%s), want %d", c.temp, got, got.Label(), c.want) } } } // Group J (verdict half) — Scenario J. No data never alarms, and a prior must not manufacture one: // a nil/UNKNOWN SmartSummary reads Nincs adat EVEN WITH SawUncorrectable set. Row 1 is first in the // table for exactly this reason. // // Red-proof: move row 1 below row 6 → the UNKNOWN-with-prior case reads Hiba, i.e. a disk whose // SMART briefly became unreadable would be reported as failing. func TestLadder_UnknownNeverAlarms(t *testing.T) { for _, s := range []*SmartSummary{nil, {Health: ""}, {Health: SmartUnknown}} { if got := DiskVerdictFor(s, DiskPrior{SawUncorrectable: true}); got != DiskVerdictUnknown { t.Errorf("no-data disk with a prior = %d (%s), want Unknown/Nincs adat", got, got.Label()) } } if got := DiskVerdictFor(&SmartSummary{Health: SmartUnknown}, DiskPrior{}).Label(); got != "Nincs adat" { t.Errorf("label = %q, want Nincs adat", got) } } // The zero DiskPrior must be the SAFE default: a caller that forgets to load history can only // under-report (Figyelmeztetés), never over-report (Hiba) on a first sighting. This pins the // fail-safe direction the persisted-state loader relies on when its file is missing or corrupt. func TestLadder_ZeroPriorIsFailSafe(t *testing.T) { s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8)} if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictWarn { t.Fatalf("zero prior must degrade to Warn, not Fail; got %d (%s)", got, got.Label()) } } // UncorrectableSectors is max(pending, offline) — the number the alert copy quotes and the persisted // state remembers. A wrong answer here puts a wrong count in a customer's email. func TestUncorrectableSectors(t *testing.T) { cases := []struct { name string in *SmartSummary want int }{ {"nil summary", nil, 0}, {"neither reported (old agent)", &SmartSummary{Health: SmartPassed}, 0}, {"both zero", &SmartSummary{PendingSectors: ip(0), OfflineUncorrectable: ip(0)}, 0}, {"pending only", &SmartSummary{PendingSectors: ip(8)}, 8}, {"offline only", &SmartSummary{OfflineUncorrectable: ip(24)}, 24}, {"pending larger", &SmartSummary{PendingSectors: ip(40), OfflineUncorrectable: ip(24)}, 40}, {"offline larger", &SmartSummary{PendingSectors: ip(24), OfflineUncorrectable: ip(40)}, 40}, {"the real drive", realDrive(), 352}, } for _, c := range cases { if got := UncorrectableSectors(c.in); got != c.want { t.Errorf("%s: UncorrectableSectors = %d, want %d", c.name, got, c.want) } } } // DegradedAttributes must NAME the counters behind a Hiba reached from counters (v0.215.0) — the // alert body is built from this and an empty list produces a message that says nothing is wrong. // It still returns nil for row 2 (drive-reported FAILING), which has no single triggering counter. // // Red-proof: restore the pre-v0.215.0 body (nil for anything at Fail) → the real-drive case returns // an empty list. func TestDegradedAttributes_NamesFailCounters(t *testing.T) { got := DegradedAttributes(realDrive()) if len(got) == 0 { t.Fatal("a Hiba reached from counters must name its attributes, got none") } found := map[string]bool{} for _, a := range got { found[a] = true } for _, want := range []string{"függőben lévő szektorok", "javíthatatlan szektorok"} { if !found[want] { t.Errorf("missing attribute %q in %v", want, got) } } // Row 2 — the drive self-reports FAILING: no single triggering counter, so nil. if a := DegradedAttributes(&SmartSummary{Health: SmartFailing, PendingSectors: ip(5)}); a != nil { t.Errorf("FAILING (row 2) must return nil attributes, got %v", a) } // Nincs adat must never produce attribute names either. if a := DegradedAttributes(&SmartSummary{Health: SmartUnknown}); a != nil { t.Errorf("UNKNOWN must return nil attributes, got %v", a) } // Temperature is newly able to trigger on its own, so it must be nameable. hot := DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(61)}) if len(hot) != 1 || hot[0] != "hőmérséklet" { t.Errorf("hot disk attributes = %v, want [hőmérséklet]", hot) } }