Files
mesh-catalog/modules/power/cmd/power/draw_test.go
T
jochen 76b63744e6 Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)
Modules talk over the bus. The power module's bundle puts each fresh reading of its own
sampler in its state draw, keyed by the machine; the bar's block shows that state, which
the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses
a module naming another module's path under /run or /var/lib.
2026-10-08 16:12:37 +02:00

211 lines
7.8 KiB
Go

package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Defends novox/hq ADR 0255: the power drawn is measured honestly and says what it measured — the
// machine on battery, the processor (and discrete graphics) otherwise — and only a rate leaves root.
func tree(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(c+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return &Machine{Root: root, Run: func(context.Context, string, ...string) (string, error) {
return "", errors.New("nothing may be run here")
}}
}
func noWait(time.Duration) bool { return true }
func TestOnBatteryTheDischargeIsTheMachine(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/power_supply/BAT1/type": "Battery",
"/sys/class/power_supply/BAT1/status": "Discharging",
"/sys/class/power_supply/BAT1/current_now": "-1500000",
"/sys/class/power_supply/BAT1/voltage_now": "16000000",
// A counter is there, and not what a battery reading reports.
"/sys/class/powercap/intel-rapl:0/name": "package-0",
"/sys/class/powercap/intel-rapl:0/energy_uj": "1",
})
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { t.Fatal("a battery reading waited"); return false })
if got := d.Line(); got != "24.0 W machine" {
t.Fatalf("%q", got)
}
}
func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/power_supply/BAT1/type": "Battery",
"/sys/class/power_supply/BAT1/status": "Not charging",
"/sys/class/powercap/intel-rapl:0/name": "package-0",
"/sys/class/powercap/intel-rapl:0/energy_uj": "1000000",
"/sys/class/powercap/intel-rapl:0/max_energy_range_uj": "65532610987",
"/sys/class/powercap/intel-rapl:0:0/name": "core",
"/sys/class/powercap/intel-rapl:0:0/energy_uj": "1",
// An APU's integrated graphics report the whole package: not counted a second time.
"/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000",
})
counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj")
// 1 J over 50 ms: 20 W, well inside what a package draws.
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool {
time.Sleep(50 * time.Millisecond)
return os.WriteFile(counter, []byte("2000000\n"), 0o644) == nil
})
if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 {
t.Fatalf("%+v", d)
}
if !strings.HasSuffix(d.Line(), " W CPU") {
t.Fatalf("%q", d.Line())
}
}
func TestACounterThatWrappedIsCountedAcrossItsRange(t *testing.T) {
before := map[string]counter{"p": {uj: 90, max: 100}}
after := map[string]counter{"p": {uj: 10, max: 100}}
w := cpuWatts(before, after, time.Second)
if w == nil || *w != 20e-6 {
t.Fatalf("%v", w)
}
if cpuWatts(before, map[string]counter{"other": {uj: 1}}, time.Second) != nil {
t.Fatal("a counter read once was taken for a rate")
}
}
func TestADiscreteGraphicsDevicesPowerIsAddedAndASleepingOneIsNotWoken(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung",
"/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000",
"/sys/class/drm/card0-DP-1/status": "connected",
"/proc/driver/nvidia/gpus/0000:01:00.0/information": "x",
"/sys/bus/pci/devices/0000:01:00.0/power/runtime_status": "suspended",
})
if w := m.gpuWatts(context.Background()); w == nil || *w != 30 {
t.Fatalf("%v", w)
}
// Awake, it is asked.
if err := os.WriteFile(filepath.Join(m.Root, "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status"), []byte("active\n"), 0o644); err != nil {
t.Fatal(err)
}
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
if name != "nvidia-smi" || args[len(args)-1] != "0000:01:00.0" {
t.Fatalf("ran %s %v", name, args)
}
return "12.50\n", nil
}
if w := m.gpuWatts(context.Background()); w == nil || *w != 42.5 {
t.Fatalf("%v", w)
}
cpu, gpu := 10.0, 42.5
if got := (Draw{CPU: &cpu, GPU: &gpu}).Line(); got != "52.5 W CPU+GPU" {
t.Fatalf("%q", got)
}
}
func TestNothingToMeasureIsSaid(t *testing.T) {
m := tree(t, map[string]string{"/sys/class/power_supply/ACAD/type": "Mains"})
if got := m.Sample(context.Background(), time.Second, noWait).Line(); got != "power not measured" {
t.Fatalf("%q", got)
}
}
func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) {
m := tree(t, nil)
w := 8.0
if err := m.Publish(Draw{CPU: &w}.Reading(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC))); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(m.Root, DrawFile))
if err != nil || info.Mode().Perm() != 0o644 {
t.Fatalf("%v %v", info, err)
}
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" {
t.Fatalf("%q", b)
}
}
func TestAPackageSeenTwiceIsCountedOnce(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/powercap/intel-rapl:0/name": "package-0",
"/sys/class/powercap/intel-rapl:0/energy_uj": "100",
"/sys/class/powercap/intel-rapl-mmio:0/name": "package-0",
"/sys/class/powercap/intel-rapl-mmio:0/energy_uj": "999",
"/sys/class/powercap/intel-rapl:1/name": "package-1",
"/sys/class/powercap/intel-rapl:1/energy_uj": "5",
})
got := m.packageCounters()
if len(got) != 2 || got["package-0"].uj != 100 || got["package-1"].uj != 5 {
t.Fatalf("%+v", got)
}
}
func TestACounterResetGivesNoReading(t *testing.T) {
// After a resume the counter starts again near nothing: across its range that would be thousands
// of watts, which is no reading at all.
before := map[string]counter{"package-0": {uj: 40_000_000_000, max: 65_532_610_987}}
after := map[string]counter{"package-0": {uj: 1_000_000, max: 65_532_610_987}}
if w := cpuWatts(before, after, 5*time.Second); w != nil {
t.Fatalf("a reset counter read as %.0f W", *w)
}
// A true wrap, near the end of the range, is still counted.
before = map[string]counter{"package-0": {uj: 65_532_610_987 - 10_000_000, max: 65_532_610_987}}
after = map[string]counter{"package-0": {uj: 40_000_000, max: 65_532_610_987}}
if w := cpuWatts(before, after, 5*time.Second); w == nil || *w != 10 {
t.Fatalf("a wrapped counter read as %v", w)
}
}
func TestASleepingAMDCardIsNotRead(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung",
"/sys/class/drm/card0/device/power/runtime_status": "suspended",
"/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000",
})
if w := m.gpuWatts(context.Background()); w != nil {
t.Fatalf("a suspended card was read: %v", *w)
}
}
func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
m := tree(t, map[string]string{
"/sys/class/power_supply/BAT0/type": "Battery",
"/sys/class/power_supply/BAT0/status": "Discharging",
"/sys/class/power_supply/BAT0/power_now": "12000000",
})
ctx, cancel := context.WithCancel(context.Background())
var lines []string
done := make(chan error)
go func() {
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
}()
time.Sleep(210 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("the loop did not stop when cancelled")
}
// One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall.
if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" {
t.Fatalf("%d lines: %v", len(lines), lines)
}
}