diff --git a/controller/internal/agentapi/diskverdict.go b/controller/internal/agentapi/diskverdict.go index 185824a..d5551cf 100644 --- a/controller/internal/agentapi/diskverdict.go +++ b/controller/internal/agentapi/diskverdict.go @@ -1,7 +1,7 @@ package agentapi // DiskVerdict is the customer-facing disk-health verdict derived from a SmartSummary (v0.169.0). -// It is the SHARED source of truth for both the "Lemezek állapota" dashboard card and the 6-hourly +// It is the SHARED source of truth for both the "Lemezek állapota" dashboard card and the periodic // degradation check — one pure function so the chip and the alert can never disagree. type DiskVerdict int @@ -9,38 +9,144 @@ const ( // DiskVerdictUnknown — no SMART data (nil / UNKNOWN / old agent). Renders "Nincs adat"; NEVER // alarms and NEVER participates in degradation transitions (excluded both directions). DiskVerdictUnknown DiskVerdict = iota - DiskVerdictOK // "Rendben" — PASSED, all counters clean - DiskVerdictWarn // "Figyelmeztetés" — PASSED but a wear/relocation counter is non-zero (or NVMe ≥90%) - DiskVerdictFail // "Hiba" — FAILING + DiskVerdictOK // "Rendben" — clean + DiskVerdictWarn // "Figyelmeztetés" — a wear/relocation counter is non-zero, below the Hiba bar + DiskVerdictFail // "Hiba" — FAILING, or failing-but-not-self-reported (v0.215.0) ) -// percentageUsedWarn is the NVMe wear threshold (inclusive) at which a still-PASSED disk warns. -const percentageUsedWarn = 90 +// Thresholds. A number without a reason becomes permanent by default, so each carries its provenance. +// The evidence is committed at felhom.eu/documentation/audits/DIAG-smart-passed-trap-2026-08-14.md +// and its two fixtures (ST3000VX010 S/N Z6A07P2G, /dev/sdg on DooPlex, 11-13 Aug 2026). +const ( + // percentageUsedWarn / percentageUsedFail — NVMe wear (%). 100 means the vendor's rated endurance + // is spent; that is a declaration, not a trend, so it is Hiba. + percentageUsedWarn = 90 + percentageUsedFail = 100 -// DiskVerdictFor maps a SmartSummary to a verdict per the v0.169.0 rules: + // uncorrectableFailCount — unreadable sectors too numerous to be a blip. + // + // PROVENANCE: on the one real failing drive observed, the benign excursion peaked at 16 and + // cleared COMPLETELY within an hour (11 Aug 12:28 -> 13:28); the terminal run passed 64 at + // 13 Aug 11:28 and never came back below it. 64 sits above the one observed transient and below + // the observed terminal run. This is a judgement from ONE drive: it is a static BACKSTOP behind + // the sustain rule, not the primary signal, and Phase 3 is expected to replace it with + // growth-rate detection once the box keeps history. + uncorrectableFailCount = 64 + + // temperatureWarnC / TemperatureFailC — adopted UNCHANGED from the operator's existing Prometheus + // bands on DooPlex, so the two systems cannot disagree about the same drive. + temperatureWarnC = 55 + // TemperatureFailC is exported because the alert-copy layer must pick the "overheated" message + // shape from the SAME number the verdict fired on. A second literal elsewhere would be free to + // drift, and the drift would show up as a customer told the wrong reason. + TemperatureFailC = 60 +) + +// DiskPrior is what the previous check observed for THIS SAME disk. It is the only history the +// verdict consults, and it is passed in rather than read so the function stays pure — the caller +// (internal/web) owns loading it from the persisted per-disk state. // -// FAILING → Hiba -// PASSED + any(reallocated>0, pending>0, offline_unc>0, -// critical_warning>0, media_errors>0, -// percentage_used >= 90) → Figyelmeztetés -// PASSED otherwise → Rendben -// nil / UNKNOWN / empty → Nincs adat -func DiskVerdictFor(s *SmartSummary) DiskVerdict { +// Plain value type: no methods, no I/O. A zero DiskPrior means "nothing known", which is the correct +// fail-safe — a first-ever observation can only reach Figyelmeztetés from counters, never Hiba. +type DiskPrior struct { + // SawUncorrectable reports whether unreadable sectors (pending OR offline-uncorrectable) were + // present at the previous check. It is what turns a one-off excursion into a sustained fault. + SawUncorrectable bool +} + +// DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated +// TOP-DOWN and the FIRST match wins (v0.215.0): +// +// 1. nil / "" / UNKNOWN -> Nincs adat +// 2. Health == FAILING -> Hiba (drive self-reports) +// 3. temperature_c >= 60 -> Hiba +// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba +// 5. percentage_used >= 100 -> Hiba +// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) +// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping) +// 8. unreadable >= 64 -> Hiba (too large to be a blip) +// 9. unreadable > 0 -> Figyelmeztetés (first sighting) +// 10. reallocated > 0 -> Figyelmeztetés +// 11. media_errors > 0 -> Figyelmeztetés +// 12. percentage_used >= 90 -> Figyelmeztetés +// 13. temperature_c >= 55 -> Figyelmeztetés +// 14. otherwise -> Rendben +// +// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187, +// 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to +// or below the threshold. The real drive stayed PASSED at 352 pending sectors with 1001 reported +// uncorrectable reads. A verdict built on the drive's own self-assessment is blind to this whole +// class of failure, which is why rows 3-8 read the raw counters instead. +// +// WHY row 6 sits ABOVE row 8: sustain is the PRIMARY rule and the count is the backstop. On the real +// drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for +// a box that was powered off or restarted across the sustain window and so has no prior. +// +// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument. +func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { + // 1 — no data. Never alarms. if s == nil || s.Health == "" || s.Health == SmartUnknown { return DiskVerdictUnknown } + // 2 — the drive admits failure. if s.Health == SmartFailing { return DiskVerdictFail } - // Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters. - if positive(s.ReallocatedSectors) || positive(s.PendingSectors) || positive(s.OfflineUncorrectable) || - positive(s.CriticalWarning) || positive(s.MediaErrors) || atLeast(s.PercentageUsed, percentageUsedWarn) { + // Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters, + // because the overall verdict is structurally unable to report this class of fault. + switch { + case atLeast(s.TemperatureC, TemperatureFailC): // 3 + return DiskVerdictFail + case positive(s.CriticalWarning): // 4 + return DiskVerdictFail + case atLeast(s.PercentageUsed, percentageUsedFail): // 5 + return DiskVerdictFail + } + unreadable := UncorrectableSectors(s) + switch { + case unreadable > 0 && prior.SawUncorrectable: // 6 — sustained across two consecutive checks + return DiskVerdictFail + case unreadable > 0 && positive(s.ReallocatedSectors): // 7 — accumulating and remapping together + return DiskVerdictFail + case unreadable >= uncorrectableFailCount: // 8 — too large to be a blip + return DiskVerdictFail + case unreadable > 0: // 9 — first sighting, below the bar + return DiskVerdictWarn + case positive(s.ReallocatedSectors): // 10 + return DiskVerdictWarn + case positive(s.MediaErrors): // 11 + return DiskVerdictWarn + case atLeast(s.PercentageUsed, percentageUsedWarn): // 12 + return DiskVerdictWarn + case atLeast(s.TemperatureC, temperatureWarnC): // 13 return DiskVerdictWarn } - return DiskVerdictOK + return DiskVerdictOK // 14 +} + +// UncorrectableSectors is the disk's unreadable-sector count: max(pending, offline_uncorrectable). +// The two attributes track the same physical defect and on the real drive moved in lockstep, so the +// larger is the honest figure. 0 when neither is reported (an old agent or a device without them). +// Exported because the alert copy quotes this number and the persisted state remembers it. +func UncorrectableSectors(s *SmartSummary) int { + if s == nil { + return 0 + } + n := 0 + if s.PendingSectors != nil && *s.PendingSectors > n { + n = *s.PendingSectors + } + if s.OfflineUncorrectable != nil && *s.OfflineUncorrectable > n { + n = *s.OfflineUncorrectable + } + return n } // Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email). +// +// There are FOUR labels and there will not be a fifth: a predicted failure is "Hiba", the same word a +// self-reported failure gets. A fourth word sharing a root with "Figyelmeztetés" would make the MORE +// severe state read as the milder one (settled operator decision, v0.215.0). func (v DiskVerdict) Label() string { switch v { case DiskVerdictOK: @@ -54,11 +160,15 @@ func (v DiskVerdict) Label() string { } } -// DegradedAttributes returns the human-readable Hungarian names of the attribute(s) that pushed a -// PASSED disk to Figyelmeztetés (empty for OK/Fail/Unknown) — for the alert body. FAILING is a -// whole-disk verdict with no single triggering counter, so it returns nil there. +// DegradedAttributes returns the human-readable Hungarian names of the attribute(s) behind a +// degraded verdict, for the alert body. +// +// v0.215.0: this now also names the attributes behind a Hiba REACHED FROM COUNTERS (truth-table rows +// 3 and 6-8), not only a Figyelmeztetés — the alert message needs to say what is wrong, and those +// rows do have a triggering counter. It returns nil ONLY for row 2 (the drive self-reports FAILING, +// a whole-disk verdict with no single triggering counter) and, naturally, for Nincs adat / Rendben. func DegradedAttributes(s *SmartSummary) []string { - if s == nil { + if s == nil || s.Health == "" || s.Health == SmartUnknown || s.Health == SmartFailing { return nil } var out []string @@ -80,6 +190,10 @@ func DegradedAttributes(s *SmartSummary) []string { if atLeast(s.PercentageUsed, percentageUsedWarn) { out = append(out, "elhasználódás") } + // Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. + if atLeast(s.TemperatureC, temperatureWarnC) { + out = append(out, "hőmérséklet") + } return out } diff --git a/controller/internal/agentapi/diskverdict_ladder_test.go b/controller/internal/agentapi/diskverdict_ladder_test.go new file mode 100644 index 0000000..c3d6c0b --- /dev/null +++ b/controller/internal/agentapi/diskverdict_ladder_test.go @@ -0,0 +1,202 @@ +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) + } +} diff --git a/controller/internal/agentapi/diskverdict_test.go b/controller/internal/agentapi/diskverdict_test.go index a78f1b3..60baa55 100644 --- a/controller/internal/agentapi/diskverdict_test.go +++ b/controller/internal/agentapi/diskverdict_test.go @@ -4,32 +4,40 @@ import "testing" func ip(v int) *int { return &v } -// Verdict table (Part 2). Red-proof: change the PercentageUsed boundary from `>= 90` to `> 90` in -// DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case fails. +// Verdict table (Part 2, extended v0.215.0). Red-proof: change the PercentageUsed boundary from +// `>= 90` to `> 90` in DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case +// fails. +// +// v0.215.0 moved ONE pre-existing case deliberately: critical_warning>0 was Figyelmeztetés and is +// now Hiba (truth-table row 4). It is NVMe's own critical flag — a declaration by the device, not a +// counter that might drift back — so it belongs with the self-reported failures, not below them. func TestDiskVerdictFor(t *testing.T) { + noPrior := DiskPrior{} cases := []struct { - name string - in *SmartSummary - want DiskVerdict + name string + in *SmartSummary + prior DiskPrior + want DiskVerdict }{ - {"nil → unknown", nil, DiskVerdictUnknown}, - {"empty health → unknown", &SmartSummary{Health: ""}, DiskVerdictUnknown}, - {"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, DiskVerdictUnknown}, - {"FAILING → fail", &SmartSummary{Health: SmartFailing}, DiskVerdictFail}, - {"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, DiskVerdictFail}, - {"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, DiskVerdictOK}, - {"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, DiskVerdictOK}, - {"reallocated>0 → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, DiskVerdictWarn}, - {"pending>0 → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, DiskVerdictWarn}, - {"offline_unc>0 → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, DiskVerdictWarn}, - {"critical_warning>0 → warn", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, DiskVerdictWarn}, - {"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, DiskVerdictWarn}, - {"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, DiskVerdictOK}, - {"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, DiskVerdictWarn}, - {"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, DiskVerdictWarn}, + {"nil → unknown", nil, noPrior, DiskVerdictUnknown}, + {"empty health → unknown", &SmartSummary{Health: ""}, noPrior, DiskVerdictUnknown}, + {"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, noPrior, DiskVerdictUnknown}, + {"FAILING → fail", &SmartSummary{Health: SmartFailing}, noPrior, DiskVerdictFail}, + {"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, noPrior, DiskVerdictFail}, + {"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, noPrior, DiskVerdictOK}, + {"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, noPrior, DiskVerdictOK}, + {"reallocated>0 alone → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, noPrior, DiskVerdictWarn}, + {"pending>0 first sighting → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, noPrior, DiskVerdictWarn}, + {"offline_unc>0 first sighting → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, noPrior, DiskVerdictWarn}, + {"critical_warning>0 → fail (row 4)", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, noPrior, DiskVerdictFail}, + {"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, noPrior, DiskVerdictWarn}, + {"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, noPrior, DiskVerdictOK}, + {"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, noPrior, DiskVerdictWarn}, + {"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, noPrior, DiskVerdictWarn}, + {"percentage_used exactly 100 → fail (row 5)", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(100)}, noPrior, DiskVerdictFail}, } for _, c := range cases { - if got := DiskVerdictFor(c.in); got != c.want { + if got := DiskVerdictFor(c.in, c.prior); got != c.want { t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want) } } diff --git a/controller/internal/notify/disk_health_test.go b/controller/internal/notify/disk_health_test.go index 2b873f1..913ac3c 100644 --- a/controller/internal/notify/disk_health_test.go +++ b/controller/internal/notify/disk_health_test.go @@ -5,24 +5,81 @@ import ( "testing" ) -// NotifyDiskHealthDegraded emits the right event type + severity + a message carrying the required -// "Lemez állapot romlás: