Extends the existing per-tick nvidia-smi query with temperature.gpu and fan.speed (no extra call); N/A values (cards without fan telemetry) are simply omitted from the GPU line.
161 lines
4.3 KiB
Go
161 lines
4.3 KiB
Go
package game
|
|
|
|
import (
|
|
"runtime"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormName(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"Cyberpunk2077.exe": "cyberpunk2077",
|
|
"ollama": "ollama",
|
|
"OLLAMA APP.EXE": "ollama app",
|
|
" python.exe ": "python",
|
|
"hl2": "hl2",
|
|
} {
|
|
if got := normName(in); got != want {
|
|
t.Errorf("normName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseComputeApps(t *testing.T) {
|
|
apps, err := parseComputeApps("1234, 512\n 42 , 8192 \n\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []computeApp{{PID: 1234, UsedMB: 512}, {PID: 42, UsedMB: 8192}}
|
|
if !slices.Equal(apps, want) {
|
|
t.Errorf("got %+v, want %+v", apps, want)
|
|
}
|
|
|
|
// WDDM reports "N/A" for memory; those rows are skipped, not fatal.
|
|
apps, err = parseComputeApps("1234, N/A\n42, 1024\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(apps, []computeApp{{PID: 42, UsedMB: 1024}}) {
|
|
t.Errorf("got %+v", apps)
|
|
}
|
|
|
|
if _, err := parseComputeApps("garbage\n"); err == nil {
|
|
t.Error("expected an error for a malformed line")
|
|
}
|
|
}
|
|
|
|
func TestDetect(t *testing.T) {
|
|
d := New([]string{"Cyberpunk2077.exe", "hl2"}, 1024, 0,
|
|
[]string{"ollama", "python", "pythonw"}, nil)
|
|
ps := []Process{
|
|
{PID: 10, Name: "ollama.exe"},
|
|
{PID: 20, Name: "python.exe"},
|
|
{PID: 30, Name: "cyberpunk2077.exe"},
|
|
}
|
|
apps := []computeApp{
|
|
{PID: 10, UsedMB: 8192}, // ignored: ollama
|
|
{PID: 20, UsedMB: 4096}, // ignored: python (ComfyUI)
|
|
{PID: 40, UsedMB: 2048}, // foreign, above threshold
|
|
{PID: 50, UsedMB: 100}, // foreign but below threshold
|
|
}
|
|
holders := d.detect(ps, apps, nil)
|
|
if len(holders) != 2 {
|
|
t.Fatalf("got %v, want 2 holders", holders)
|
|
}
|
|
if holders[0] != "cyberpunk2077.exe (pid 30)" {
|
|
t.Errorf("holders[0] = %q", holders[0])
|
|
}
|
|
if holders[1] != "unknown process (pid 40) using 2048 MiB VRAM" {
|
|
t.Errorf("holders[1] = %q", holders[1])
|
|
}
|
|
}
|
|
|
|
func TestDetectEngineUtil(t *testing.T) {
|
|
d := New(nil, 0, 30, []string{"dwm", "python"}, nil)
|
|
ps := []Process{
|
|
{PID: 10, Name: "dwm.exe"},
|
|
{PID: 20, Name: "game.exe"},
|
|
{PID: 40, Name: "browser.exe"},
|
|
}
|
|
utils := map[int]float64{
|
|
10: 45, // ignored: dwm
|
|
20: 61, // foreign, above threshold
|
|
30: 82, // foreign, unknown name
|
|
40: 5, // below threshold
|
|
}
|
|
holders := d.detect(ps, nil, utils)
|
|
want := []string{
|
|
"game.exe (pid 20) using 61% GPU",
|
|
"unknown process (pid 30) using 82% GPU",
|
|
}
|
|
if !slices.Equal(holders, want) {
|
|
t.Errorf("got %v, want %v", holders, want)
|
|
}
|
|
}
|
|
|
|
func TestDetectNothingConfigured(t *testing.T) {
|
|
d := New(nil, 0, 0, nil, nil)
|
|
if got := d.detect([]Process{{PID: 1, Name: "game.exe"}}, nil, nil); len(got) != 0 {
|
|
t.Errorf("got %v, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestParseGPUEngineInstance(t *testing.T) {
|
|
pid, eng, ok := parseGPUEngineInstance("pid_1234_luid_0x00000000_0x00011A2B_phys_0_eng_0_engtype_3D")
|
|
if !ok || pid != 1234 || eng != "3D" {
|
|
t.Errorf("got %d, %q, %v", pid, eng, ok)
|
|
}
|
|
pid, eng, ok = parseGPUEngineInstance("pid_42_luid_0x0_0x0_phys_0_eng_1_engtype_Copy")
|
|
if !ok || pid != 42 || eng != "Copy" {
|
|
t.Errorf("got %d, %q, %v", pid, eng, ok)
|
|
}
|
|
for _, bad := range []string{"", "something", "pid_", "pid_x_luid", "pid_-1_luid_0"} {
|
|
if _, _, ok := parseGPUEngineInstance(bad); ok {
|
|
t.Errorf("%q parsed, want failure", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseGPUStats(t *testing.T) {
|
|
st, err := parseGPUStats("4300, 16384, 55, 42\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.UsedMB != 4300 || st.TotalMB != 16384 || st.TempC != 55 || st.FanPct != 42 {
|
|
t.Errorf("got %+v", st)
|
|
}
|
|
|
|
// Cards that don't expose temperature/fan report N/A.
|
|
st, err = parseGPUStats("1024, 16384, N/A, N/A")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.TempC != -1 || st.FanPct != -1 {
|
|
t.Errorf("got %+v, want -1 for N/A fields", st)
|
|
}
|
|
|
|
for _, bad := range []string{"", "1, 2", "x, 16384, 55, 42", "1024, x, 55, 42", "1, 2, 3, 4, 5"} {
|
|
if _, err := parseGPUStats(bad); err == nil {
|
|
t.Errorf("%q parsed, want failure", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProcessesLive(t *testing.T) {
|
|
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
|
|
t.Skip("no process listing on this platform")
|
|
}
|
|
ps, err := processes()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ps) == 0 {
|
|
t.Fatal("no processes listed")
|
|
}
|
|
for _, p := range ps {
|
|
if p.Name == "" {
|
|
t.Errorf("pid %d has an empty name", p.PID)
|
|
}
|
|
}
|
|
}
|