diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-18 09:00:35 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-18 09:00:35 +0200 |
| commit | 88f4e239a7521112a4db8c7842e3a05db4446cd4 (patch) | |
| tree | 8c331f9f2e23ad9c9319d6dc8275205b23ce811a /internal/display/tooltip_test.go | |
| parent | 11204092b5ab5dc0f71515adfcaa6f07111363e5 (diff) | |
feat: triple-toggle CPU display mode via 1 key; add tooltip, font, hit-test
CPU display now cycles through three states with each press of 1:
0 = CPUModeAverage – aggregate bar only (default)
1 = CPUModeCores – individual core bars + aggregate
2 = CPUModeOff – all CPU bars hidden
Config file stores cpumode=N (integer); old showcores=0/1 is read for
backward compatibility. CLI flag --showcores replaced by --cpumode.
Other improvements landed in this commit:
- internal/display: add font.go (text rendering), hittest.go (bar hit
testing), tooltip.go (mouse-over tooltip), tooltip_test.go
- internal/display: mouse tracking and drawOverlay hook in display.go
- internal/display: update build tags to //go:build form
- internal/collector: embed remote script via script_embed.go /
scriptdata/loadbars-remote.sh
- internal/collector: CPULine.Total() changed to value receiver
- internal/collector: table test improvements (name field, t.Run)
- internal/constants: BytesPerSec consts promoted from var to const
- Magefile.go: fix error formatting and install path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/display/tooltip_test.go')
| -rw-r--r-- | internal/display/tooltip_test.go | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/internal/display/tooltip_test.go b/internal/display/tooltip_test.go new file mode 100644 index 0000000..e4f6afc --- /dev/null +++ b/internal/display/tooltip_test.go @@ -0,0 +1,587 @@ +package display + +import ( + "testing" + "time" + + "codeberg.org/snonux/loadbars/internal/collector" + "codeberg.org/snonux/loadbars/internal/constants" + "codeberg.org/snonux/loadbars/internal/stats" + "github.com/veandco/go-sdl2/sdl" +) + +// --- font tests --- + +func TestStringWidth(t *testing.T) { + // Single character: 5 pixels wide at scale 1 + if w := stringWidth("A", 1); w != 5 { + t.Errorf("stringWidth(A, 1) = %d, want 5", w) + } + // Two characters at scale 1: 5 + gap(1) + 5 = 11, minus trailing gap = 11 + if w := stringWidth("AB", 1); w != 11 { + t.Errorf("stringWidth(AB, 1) = %d, want 11", w) + } + // Scale 2: single char = 5*2 = 10 + if w := stringWidth("A", 2); w != 10 { + t.Errorf("stringWidth(A, 2) = %d, want 10", w) + } + // Empty string + if w := stringWidth("", 1); w != 0 { + t.Errorf("stringWidth('', 1) = %d, want 0", w) + } +} + +func TestDrawChar_RendersPixels(t *testing.T) { + // Draw the letter 'I' which has a recognizable pattern (center column lit) + renderer, surface, err := createTestRenderer(20, 20) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + renderer.SetDrawColor(255, 255, 255, 255) + drawChar(renderer, 'I', 0, 0, 1) + renderer.Present() + + // 'I' glyph row 0 = 0x70 = 01110000 → pixels at columns 1,2,3 should be lit + r, g, b := getPixelColor(surface, 1, 0) + if r < 200 || g < 200 || b < 200 { + t.Errorf("expected white pixel at (1,0) for 'I' glyph, got RGB(%d,%d,%d)", r, g, b) + } + // Column 0 should be black (not part of 'I' top row) + r, g, b = getPixelColor(surface, 0, 0) + if r > 50 || g > 50 || b > 50 { + t.Errorf("expected black pixel at (0,0) for 'I' glyph, got RGB(%d,%d,%d)", r, g, b) + } +} + +func TestDrawString_MultipleChars(t *testing.T) { + renderer, surface, err := createTestRenderer(40, 10) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + renderer.SetDrawColor(255, 255, 255, 255) + totalW := drawString(renderer, "Hi", 0, 0, 1) + renderer.Present() + + // "Hi" at scale 1: 2 chars × (5+1) = 12 pixels total advance + if totalW != 12 { + t.Errorf("drawString returned width %d, want 12", totalW) + } + // Second character starts at x=6, verify it has some lit pixels + // 'i' glyph row 0 = 0x20 = col 2 → pixel at x=6+2=8 + r, _, _ := getPixelColor(surface, 8, 0) + if r < 200 { + t.Errorf("expected lit pixel from second char at x=8, got R=%d", r) + } +} + +// --- hit test tests --- + +func TestBuildBarMap_SingleHost(t *testing.T) { + snap := map[string]*stats.HostStats{ + "host1": { + CPU: map[string]collector.CPULine{"cpu": {}}, + }, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + + bars := buildBarMap(snap, cfg, state) + if len(bars) != 1 { + t.Fatalf("expected 1 bar, got %d", len(bars)) + } + if bars[0].host != "host1" || bars[0].kind != barCPU || bars[0].cpuName != "cpu" { + t.Errorf("unexpected bar: %+v", bars[0]) + } + // Single bar should fill the whole window + if bars[0].rect.W != 200 || bars[0].rect.H != 100 { + t.Errorf("expected bar to fill window (200x100), got %dx%d", bars[0].rect.W, bars[0].rect.H) + } +} + +func TestBuildBarMap_WithMemAndNet(t *testing.T) { + snap := map[string]*stats.HostStats{ + "alpha": { + CPU: map[string]collector.CPULine{"cpu": {}}, + Mem: map[string]int64{"MemTotal": 1024}, + Net: map[string]stats.NetStamp{"eth0": {}}, + }, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 300, 100) + state.showMem = true + state.showNet = true + + bars := buildBarMap(snap, cfg, state) + if len(bars) != 3 { + t.Fatalf("expected 3 bars (cpu+mem+net), got %d", len(bars)) + } + if bars[0].kind != barCPU { + t.Errorf("bar 0 should be CPU, got %d", bars[0].kind) + } + if bars[1].kind != barMem { + t.Errorf("bar 1 should be Mem, got %d", bars[1].kind) + } + if bars[2].kind != barNet { + t.Errorf("bar 2 should be Net, got %d", bars[2].kind) + } +} + +func TestBuildBarMap_MultiHost(t *testing.T) { + snap := map[string]*stats.HostStats{ + "alpha": {CPU: map[string]collector.CPULine{"cpu": {}}}, + "beta": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + + bars := buildBarMap(snap, cfg, state) + if len(bars) != 2 { + t.Fatalf("expected 2 bars, got %d", len(bars)) + } + // Sorted order: alpha first + if bars[0].host != "alpha" || bars[1].host != "beta" { + t.Errorf("expected alpha then beta, got %s then %s", bars[0].host, bars[1].host) + } +} + +func TestBuildBarMap_WithCores(t *testing.T) { + snap := map[string]*stats.HostStats{ + "host1": { + CPU: map[string]collector.CPULine{ + "cpu": {}, + "cpu0": {}, + "cpu1": {}, + }, + }, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 300, 100) + state.cpuMode = constants.CPUModeCores + + bars := buildBarMap(snap, cfg, state) + if len(bars) != 3 { + t.Fatalf("expected 3 bars (cpu + cpu0 + cpu1), got %d", len(bars)) + } + if bars[0].cpuName != "cpu" { + t.Errorf("first CPU bar should be aggregate 'cpu', got %s", bars[0].cpuName) + } +} + +func TestHitTest_Hit(t *testing.T) { + bars := []barDescriptor{ + {host: "h1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}}, + {host: "h2", kind: barCPU, rect: sdl.Rect{X: 100, Y: 0, W: 100, H: 100}}, + } + hit := hitTest(bars, 50, 50) + if hit == nil || hit.host != "h1" { + t.Errorf("expected hit on h1 at (50,50), got %v", hit) + } + hit = hitTest(bars, 150, 50) + if hit == nil || hit.host != "h2" { + t.Errorf("expected hit on h2 at (150,50), got %v", hit) + } +} + +func TestHitTest_Miss(t *testing.T) { + bars := []barDescriptor{ + {host: "h1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}}, + } + hit := hitTest(bars, -1, -1) + if hit != nil { + t.Errorf("expected nil for off-screen coords, got %v", hit) + } + hit = hitTest(bars, 200, 50) + if hit != nil { + t.Errorf("expected nil for point outside bars, got %v", hit) + } +} + +func TestHitTest_EdgeCases(t *testing.T) { + bars := []barDescriptor{ + {host: "h1", kind: barCPU, rect: sdl.Rect{X: 10, Y: 10, W: 50, H: 50}}, + } + // Top-left corner (inclusive) + hit := hitTest(bars, 10, 10) + if hit == nil { + t.Error("expected hit at top-left corner (10,10)") + } + // Bottom-right edge (exclusive) + hit = hitTest(bars, 60, 60) + if hit != nil { + t.Error("expected miss at bottom-right edge (60,60)") + } +} + +// --- tooltip content tests --- + +func TestTooltipLines_CPU(t *testing.T) { + snap := map[string]*stats.HostStats{ + "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + // Pre-populate smoothed data + state.smoothedCPU["myhost;cpu"] = &[10]float64{10.0, 20.0, 5.0, 60.0, 3.0, 0, 0, 0, 2.0, 0} + + bar := &barDescriptor{host: "myhost", kind: barCPU, cpuName: "cpu"} + lines := tooltipLines(bar, snap, cfg, state) + + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got %d", len(lines)) + } + if lines[0] != "myhost [cpu]" { + t.Errorf("first line = %q, want %q", lines[0], "myhost [cpu]") + } + // Check that sys/usr/idle lines are present + found := map[string]bool{} + for _, l := range lines { + if len(l) >= 3 { + found[l[:3]] = true + } + } + for _, prefix := range []string{"Sys", "Usr", "Nic", "IO:", "Ste", "Idl"} { + if !found[prefix] { + t.Errorf("missing line with prefix %q in tooltip", prefix) + } + } +} + +func TestTooltipLines_Mem(t *testing.T) { + snap := map[string]*stats.HostStats{ + "myhost": { + CPU: map[string]collector.CPULine{"cpu": {}}, + Mem: map[string]int64{ + "MemTotal": 8*1024*1024, // 8 GB in KB + "MemFree": 2*1024*1024, + "SwapTotal": 4*1024*1024, + "SwapFree": 3*1024*1024, + }, + }, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + state.smoothedMem["myhost"] = &struct{ ramUsed, swapUsed float64 }{75.0, 25.0} + + bar := &barDescriptor{host: "myhost", kind: barMem} + lines := tooltipLines(bar, snap, cfg, state) + + if len(lines) < 3 { + t.Fatalf("expected at least 3 lines, got %d", len(lines)) + } + if lines[0] != "myhost [mem]" { + t.Errorf("first line = %q, want %q", lines[0], "myhost [mem]") + } +} + +func TestTooltipLines_Net(t *testing.T) { + snap := map[string]*stats.HostStats{ + "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + cfg.NetLink = "gbit" + state := newRunState(cfg, 200, 100) + state.smoothedNet["myhost"] = &struct{ rxPct, txPct float64 }{12.5, 3.2} + + bar := &barDescriptor{host: "myhost", kind: barNet} + lines := tooltipLines(bar, snap, cfg, state) + + if len(lines) < 3 { + t.Fatalf("expected at least 3 lines, got %d", len(lines)) + } + if lines[0] != "myhost [net]" { + t.Errorf("first line = %q, want %q", lines[0], "myhost [net]") + } +} + +func TestTooltipLines_NoData(t *testing.T) { + snap := map[string]*stats.HostStats{ + "myhost": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 100) + // Don't populate smoothedCPU → should get "No data yet" + + bar := &barDescriptor{host: "myhost", kind: barCPU, cpuName: "cpu"} + lines := tooltipLines(bar, snap, cfg, state) + + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got %d", len(lines)) + } + if lines[1] != "No data yet" { + t.Errorf("expected 'No data yet', got %q", lines[1]) + } +} + +// --- formatKB tests --- + +func TestFormatKB(t *testing.T) { + tests := []struct { + kb int64 + want string + }{ + {500, "500K"}, + {2048, "2.0M"}, + {1024 * 1024, "1.0G"}, + {8 * 1024 * 1024, "8.0G"}, + } + for _, tc := range tests { + got := formatKB(tc.kb) + if got != tc.want { + t.Errorf("formatKB(%d) = %q, want %q", tc.kb, got, tc.want) + } + } +} + +// --- drawTooltip rendering test --- + +func TestDrawTooltip_RendersBox(t *testing.T) { + renderer, surface, err := createTestRenderer(200, 200) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + + lines := []string{"Host: test", "CPU: 50%"} + drawTooltip(renderer, lines, 10, 10, 200, 200) + renderer.Present() + + // The tooltip background (#181818) should be visible near cursor + offset + bx := int32(10 + tooltipOffsetX + 1) + by := int32(10 + tooltipOffsetY + 1) + r, g, b := getPixelColor(surface, bx, by) + // Should be near #181818 (dark grey background) or text color + if r > 0x30 && g > 0x30 && b > 0x30 { + // If it's bright, it might be text — that's also fine + } + // Just verify it's not pure black (meaning something was drawn) + if r == 0 && g == 0 && b == 0 { + t.Errorf("expected tooltip content at (%d,%d), but pixel is pure black", bx, by) + } +} + +func TestDrawTooltip_ClampsToWindow(t *testing.T) { + renderer, surface, err := createTestRenderer(100, 100) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + + // Place cursor near bottom-right corner; tooltip should flip to stay in bounds + lines := []string{"Very long text line here"} + drawTooltip(renderer, lines, 90, 90, 100, 100) + renderer.Present() + + // Tooltip should be positioned to the left/above cursor + // Check that something was drawn in the upper area (not just bottom-right) + foundDrawn := false + for y := int32(0); y < 80; y += 10 { + for x := int32(0); x < 80; x += 10 { + r, g, b := getPixelColor(surface, x, y) + if r != 0 || g != 0 || b != 0 { + foundDrawn = true + break + } + } + if foundDrawn { + break + } + } + if !foundDrawn { + t.Error("expected tooltip to be clamped and visible in upper area, but found nothing drawn") + } +} + +// --- invertHostBars test --- + +func TestInvertHostBars_InvertsCorrectHost(t *testing.T) { + renderer, surface, err := createTestRenderer(200, 100) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + // Draw a blue bar for host1 (left half) and a green bar for host2 (right half) + renderer.SetDrawColor(0, 0, 200, 255) + renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 100, H: 100}) + renderer.SetDrawColor(0, 200, 0, 255) + renderer.FillRect(&sdl.Rect{X: 100, Y: 0, W: 100, H: 100}) + + bars := []barDescriptor{ + {host: "host1", kind: barCPU, rect: sdl.Rect{X: 0, Y: 0, W: 100, H: 100}}, + {host: "host2", kind: barCPU, rect: sdl.Rect{X: 100, Y: 0, W: 100, H: 100}}, + } + + // Invert host1 only + invertHostBars(renderer, bars, "host1") + renderer.Present() + + // host1 area: blue (0,0,200) should become inverted (255, 255, 55) + r1, g1, b1 := getPixelColor(surface, 50, 50) + // host2 area: green (0,200,0) should remain unchanged + r2, g2, b2 := getPixelColor(surface, 150, 50) + + // Host1 blue was inverted: R should jump from 0 to ~255 + if r1 < 200 { + t.Errorf("host1 after inversion: expected R>200 (inverted blue), got R=%d (full: %d,%d,%d)", r1, r1, g1, b1) + } + // Host2 green should stay green (R near 0) + if r2 > 50 { + t.Errorf("host2 should not be inverted: expected R<50, got R=%d (full: %d,%d,%d)", r2, r2, g2, b2) + } + _ = g1 + _ = b1 + _ = g2 + _ = b2 +} + +// --- drawOverlay integration test --- + +func TestDrawOverlay_NoTooltipWhenMouseOffScreen(t *testing.T) { + renderer, surface, err := createTestRenderer(100, 100) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + snap := map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 100, 100) + // mouseX/mouseY default to -1 (off-screen) + + // Draw a solid blue background to detect any overlay changes + renderer.SetDrawColor(0, 0, constants.Blue.B, 255) + renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 100, H: 100}) + + drawOverlay(renderer, snap, cfg, state) + renderer.Present() + + // Center pixel should still be blue (no inversion or tooltip drawn) + r, g, b := getPixelColor(surface, 50, 50) + if r > 10 || g > 10 { + t.Errorf("expected blue pixel (no overlay) at (50,50), got RGB(%d,%d,%d)", r, g, b) + } +} + +func TestDrawOverlay_TooltipWhenMouseOnBar(t *testing.T) { + renderer, surface, err := createTestRenderer(200, 200) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + snap := map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 200) + state.mouseX = 50 + state.mouseY = 50 + state.mouseLastMove = time.Now() // simulate recent mouse activity + state.smoothedCPU["host1;cpu"] = &[10]float64{10, 20, 5, 60, 3, 0, 0, 0, 2, 0} + + // Draw a solid blue background + renderer.SetDrawColor(0, 0, 200, 255) + renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 200, H: 200}) + + drawOverlay(renderer, snap, cfg, state) + renderer.Present() + + // Tooltip area should have tooltip background or text drawn. + // Note: custom blend modes (used for host inversion) may not work with + // software renderers, so we only verify the tooltip box was rendered. + tx := int32(50 + tooltipOffsetX + 2) + ty := int32(50 + tooltipOffsetY + 2) + if tx < 200 && ty < 200 { + tr, tg, tb := getPixelColor(surface, tx, ty) + // Should be tooltip background ~(0x18,0x18,0x18) or text color, not the original blue + isOriginalBlue := tr < 10 && tg < 10 && tb > 150 + if isOriginalBlue { + t.Errorf("tooltip pixel at (%d,%d): still original blue RGB(%d,%d,%d), expected tooltip content", tx, ty, tr, tg, tb) + } + } +} + +// --- mouse idle timeout test --- + +func TestDrawOverlay_HiddenAfterIdleTimeout(t *testing.T) { + renderer, surface, err := createTestRenderer(200, 200) + if err != nil { + t.Fatal(err) + } + defer renderer.Destroy() + defer surface.Free() + + snap := map[string]*stats.HostStats{ + "host1": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + state := newRunState(cfg, 200, 200) + state.mouseX = 50 + state.mouseY = 50 + // Set mouseLastMove to 4 seconds ago (beyond the 3s idle timeout) + state.mouseLastMove = time.Now().Add(-4 * time.Second) + state.smoothedCPU["host1;cpu"] = &[10]float64{10, 20, 5, 60, 3, 0, 0, 0, 2, 0} + + // Draw a solid blue background + renderer.SetDrawColor(0, 0, 200, 255) + renderer.FillRect(&sdl.Rect{X: 0, Y: 0, W: 200, H: 200}) + + drawOverlay(renderer, snap, cfg, state) + renderer.Present() + + // Mouse is idle > 3s, so no tooltip or inversion should be drawn. + // The pixel should remain the original blue. + r, g, b := getPixelColor(surface, 50, 50) + if r > 10 || g > 10 { + t.Errorf("expected original blue at (50,50) after idle timeout, got RGB(%d,%d,%d)", r, g, b) + } +} + +// --- multi-row hit test --- + +func TestBuildBarMap_MultiRow(t *testing.T) { + snap := map[string]*stats.HostStats{ + "a": {CPU: map[string]collector.CPULine{"cpu": {}}}, + "b": {CPU: map[string]collector.CPULine{"cpu": {}}}, + "c": {CPU: map[string]collector.CPULine{"cpu": {}}}, + } + cfg := defaultTestConfig() + cfg.MaxBarsPerRow = 2 + state := newRunState(cfg, 200, 200) + + bars := buildBarMap(snap, cfg, state) + if len(bars) != 3 { + t.Fatalf("expected 3 bars, got %d", len(bars)) + } + // First row: bars 0 and 1 (hosts a, b) should have Y=0 + if bars[0].rect.Y != 0 || bars[1].rect.Y != 0 { + t.Errorf("first row bars should start at Y=0, got Y=%d and Y=%d", bars[0].rect.Y, bars[1].rect.Y) + } + // Second row: bar 2 (host c) should have Y > 0 + if bars[2].rect.Y <= 0 { + t.Errorf("second row bar should have Y>0, got Y=%d", bars[2].rect.Y) + } +} |
