Files
bee/audit/internal/webui/api_sat_runall_test.go
T
Mikhail ChusavitinandClaude Sonnet 5 bb2a501a28 feat(sat): fan ceiling check + topology fan tiles
Repurpose the previously-unwired RunFanStressTest into RunFanCheck, a
Load-tier SAT test that drives stressapptest (CPU+memory) and, when a GPU
is present, a GPU burn to 100% simultaneously, then watches every fan
until none has climbed for ~60s. The observed peak RPM per fan is the
"ceiling"; it is persisted through the existing fan-observation store.

MSI G4201 / AMI MegaRAC exposes no host-side fan force (every OEM IPMI
command returns 0xc1; Redfish Thermal is GET-only), so load-driven ramp
is the closest safe equivalent. See
bible-local/decisions/2026-09-04-fan-ceiling-check.md.

- platform.ResolveFanMaxRPM: per-fan max with fallback (persisted peak ->
  peer peak -> current RPM), resolved in platform, not the view.
- platform.ErrTestNotApplicable: no load source or no fan sensors ->
  task lands as cancelled ("not applicable"), never failed, so an
  engineer never sees a false red. executeTaskWithOptions maps the
  sentinel; finalizeTaskForResult honours a pre-set TaskCancelled.
- Verdict FAIL only for a fan at 0 RPM / IPMI cr-nr under load.
- /topo: one small spinning square per fan, sized by RPM / resolved max,
  clickable through to a new "fan" component-detail type; per-fan status
  recorded to the component-status DB from the fan SAT summary.
- Wiring: /api/sat/fan/run route, "fan" task target, Load-page card,
  stress-mode Run All.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
2026-09-04 10:35:34 +03:00

110 lines
3.7 KiB
Go

package webui
import (
"context"
"reflect"
"strings"
"testing"
"time"
"bee/audit/internal/app"
"bee/audit/internal/platform"
"bee/audit/internal/schema"
)
func TestIntersectSortedInts(t *testing.T) {
got := intersectSortedInts([]int{0, 1, 2, 3, 4, 5, 6, 7}, []int{5, 1, 9})
if want := []int{1, 5}; !reflect.DeepEqual(got, want) {
t.Fatalf("intersectSortedInts=%v want %v", got, want)
}
if got := intersectSortedInts([]int{0, 1}, []int{9}); len(got) != 0 {
t.Fatalf("want empty, got %v", got)
}
}
func TestWaitForNvidiaReadyDoesNotTreatLoadedDriverAsEnumeration(t *testing.T) {
oldWait, oldPoll := gpuReadyWait, gpuReadyPollInterval
oldHealth, oldList := apiRuntimeHealthNow, apiListNvidiaGPUs
gpuReadyWait, gpuReadyPollInterval = 5*time.Millisecond, time.Millisecond
apiRuntimeHealthNow = func(*app.App) (schema.RuntimeHealth, error) {
return schema.RuntimeHealth{DriverReady: true}, nil
}
apiListNvidiaGPUs = func(*app.App) ([]platform.NvidiaGPU, error) { return nil, nil }
t.Cleanup(func() {
gpuReadyWait, gpuReadyPollInterval = oldWait, oldPoll
apiRuntimeHealthNow, apiListNvidiaGPUs = oldHealth, oldList
})
_, gpus, ready := (&handler{opts: HandlerOptions{App: app.New(&platform.System{})}}).waitForNvidiaReady(context.Background())
if ready || len(gpus) != 0 {
t.Fatalf("ready=%v gpus=%v; loaded module without enumerated GPUs must not be ready", ready, gpus)
}
}
func TestWaitForNvidiaReadyReturnsFreshEnumeration(t *testing.T) {
oldWait, oldPoll := gpuReadyWait, gpuReadyPollInterval
oldHealth, oldList := apiRuntimeHealthNow, apiListNvidiaGPUs
gpuReadyWait, gpuReadyPollInterval = 20*time.Millisecond, time.Millisecond
apiRuntimeHealthNow = func(*app.App) (schema.RuntimeHealth, error) {
return schema.RuntimeHealth{DriverReady: true, CUDAReady: true}, nil
}
calls := 0
apiListNvidiaGPUs = func(*app.App) ([]platform.NvidiaGPU, error) {
calls++
if calls < 2 {
return nil, nil
}
return []platform.NvidiaGPU{{Index: 3}}, nil
}
t.Cleanup(func() {
gpuReadyWait, gpuReadyPollInterval = oldWait, oldPoll
apiRuntimeHealthNow, apiListNvidiaGPUs = oldHealth, oldList
})
health, gpus, ready := (&handler{opts: HandlerOptions{App: app.New(&platform.System{})}}).waitForNvidiaReady(context.Background())
if !ready || !health.CUDAReady || len(gpus) != 1 || gpus[0].Index != 3 {
t.Fatalf("ready=%v health=%+v gpus=%v", ready, health, gpus)
}
}
// On a host with no GPU and no TPM the plan is the base checks plus a note
// that TPM was skipped, and no GPU tasks are invented.
func TestPlanSATRunAllNoAcceleratorNoTPM(t *testing.T) {
oldWait := gpuReadyWait
gpuReadyWait = 10 * time.Millisecond
t.Cleanup(func() { gpuReadyWait = oldWait })
h := &handler{opts: HandlerOptions{App: app.New(&platform.System{})}}
specs, notes := h.planSATRunAll(context.Background(), satRunAllRequest{})
var targets []string
for _, s := range specs {
targets = append(targets, s.target)
}
want := []string{"cpu", "memory", "storage"}
if !reflect.DeepEqual(targets, want) {
t.Fatalf("targets=%v want %v", targets, want)
}
if len(notes) == 0 {
t.Fatalf("expected a note about TPM being skipped")
}
}
func TestPlanSATRunAllLoadOmitsReadOnlyTPMCheck(t *testing.T) {
h := &handler{opts: HandlerOptions{App: app.New(&platform.System{})}}
specs, notes := h.planSATRunAll(context.Background(), satRunAllRequest{StressMode: true})
var targets []string
for _, s := range specs {
targets = append(targets, s.target)
}
if want := []string{"cpu", "memory", "storage", "fan"}; !reflect.DeepEqual(targets, want) {
t.Fatalf("targets=%v want %v", targets, want)
}
for _, note := range notes {
if strings.Contains(note, "TPM") {
t.Fatalf("load plan must not inspect or report TPM: notes=%v", notes)
}
}
}