package textfile import ( "os" "path/filepath" "strings" "testing" "time" "git.mial.net/mokhtar/x1208-exporter/internal/sensor" ) func sample() sensor.Reading { return sensor.Reading{ ACPresent: true, Volts: 4.0625, SOC: 97.65625, At: time.Unix(1754661600, 0), } } // The four original metrics must render exactly as the oneshot exporter did. // Grafana alerts and the dashboard key on these names and this formatting. // // The sample values are exact binary fractions that land on a rounding tie, so // this also locks the rounding mode: Go rounds half to even, which turns // 4.0625 into 4.062 and 97.65625 into 97.66. const goldenHealthy = `# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run. # TYPE rpi_ups_last_update_seconds gauge rpi_ups_last_update_seconds 1754661600 # HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery). # TYPE rpi_ups_ac_power gauge rpi_ups_ac_power 1 # HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge. # TYPE rpi_ups_voltage_volts gauge rpi_ups_voltage_volts 4.062 # HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge. # TYPE rpi_ups_battery_percent gauge rpi_ups_battery_percent 97.66 # HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no). # TYPE rpi_ups_sensor_healthy gauge rpi_ups_sensor_healthy 1 # HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start. # TYPE rpi_ups_read_failures_total counter rpi_ups_read_failures_total 0 ` func TestRenderGolden(t *testing.T) { got := Render(sample(), true, 0) if got != goldenHealthy { t.Errorf("render mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, goldenHealthy) } } // A failed read must not advance the timestamp. That frozen value is exactly // what RpiUpsExporterStale measures. func TestRenderUnhealthyFreezesTimestamp(t *testing.T) { last := sample() got := Render(last, false, 3) if !strings.Contains(got, "rpi_ups_last_update_seconds 1754661600\n") { t.Error("timestamp must stay at the last complete sample") } if !strings.Contains(got, "rpi_ups_sensor_healthy 0\n") { t.Error("unhealthy sample must report rpi_ups_sensor_healthy 0") } if !strings.Contains(got, "rpi_ups_read_failures_total 3\n") { t.Error("failure count must be visible while the sensor is failing") } } func TestRenderACAbsent(t *testing.T) { s := sample() s.ACPresent = false if !strings.Contains(Render(s, true, 0), "rpi_ups_ac_power 0\n") { t.Error("AC absent must render rpi_ups_ac_power 0") } } func TestWriteAtomicReplacesContent(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sub", "x1208.prom") if err := WriteAtomic(path, "first\n"); err != nil { t.Fatalf("first write: %v", err) } if err := WriteAtomic(path, "second\n"); err != nil { t.Fatalf("second write: %v", err) } got, err := os.ReadFile(path) if err != nil { t.Fatalf("read back: %v", err) } if string(got) != "second\n" { t.Errorf("content = %q, want %q", got, "second\n") } info, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } if info.Mode().Perm() != 0o644 { t.Errorf("mode = %v, want 0644 (node-exporter must be able to read it)", info.Mode().Perm()) } } // A rename-based write must never leave temporary files behind for the // collector to trip over. func TestWriteAtomicLeavesNoTempFiles(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "x1208.prom") for i := 0; i < 5; i++ { if err := WriteAtomic(path, "x\n"); err != nil { t.Fatalf("write %d: %v", i, err) } } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Errorf("directory holds %d entries, want 1", len(entries)) } }