feat: add unit tests for backend collectors and frontend

Backend tests:
- CPU, memory, disk, network collector tests (existing)
- Added temperature, processes, system, AMD GPU collector tests
- All tests use mock filesystem data

Frontend tests:
- Added Vitest with jsdom environment
- Tests for formatters (formatBytes, formatUptime, etc.)
- Tests for theme store

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 05:49:26 +01:00
parent 247b7f2fe6
commit 1e83819318
13 changed files with 3082 additions and 20 deletions

View File

@@ -0,0 +1,166 @@
package collectors
import (
"os"
"path/filepath"
"testing"
)
func TestAMDGPUCollector(t *testing.T) {
tmpDir := t.TempDir()
sysPath := filepath.Join(tmpDir, "sys")
// Create mock AMD GPU sysfs structure
gpuPath := filepath.Join(sysPath, "class/drm/card0/device")
if err := os.MkdirAll(gpuPath, 0755); err != nil {
t.Fatal(err)
}
// Create driver symlink (required for AMD GPU detection)
driverTarget := filepath.Join(tmpDir, "drivers/amdgpu")
if err := os.MkdirAll(driverTarget, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(driverTarget, filepath.Join(gpuPath, "driver")); err != nil {
t.Fatal(err)
}
// GPU utilization
if err := os.WriteFile(filepath.Join(gpuPath, "gpu_busy_percent"), []byte("75\n"), 0644); err != nil {
t.Fatal(err)
}
// VRAM
if err := os.WriteFile(filepath.Join(gpuPath, "mem_info_vram_used"), []byte("4294967296\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gpuPath, "mem_info_vram_total"), []byte("17179869184\n"), 0644); err != nil {
t.Fatal(err)
}
// Clock frequencies (format: "0: 500Mhz\n1: 800Mhz *\n2: 1200Mhz")
sclk := "0: 500Mhz\n1: 800Mhz\n2: 1200Mhz *\n"
if err := os.WriteFile(filepath.Join(gpuPath, "pp_dpm_sclk"), []byte(sclk), 0644); err != nil {
t.Fatal(err)
}
mclk := "0: 400Mhz\n1: 875Mhz *\n"
if err := os.WriteFile(filepath.Join(gpuPath, "pp_dpm_mclk"), []byte(mclk), 0644); err != nil {
t.Fatal(err)
}
// Create hwmon for temperature, power, fan
hwmonPath := filepath.Join(gpuPath, "hwmon/hwmon5")
if err := os.MkdirAll(hwmonPath, 0755); err != nil {
t.Fatal(err)
}
// Temperature (65°C in millidegrees)
if err := os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("65000\n"), 0644); err != nil {
t.Fatal(err)
}
// Power (150W in microwatts)
if err := os.WriteFile(filepath.Join(hwmonPath, "power1_average"), []byte("150000000\n"), 0644); err != nil {
t.Fatal(err)
}
// Fan RPM
if err := os.WriteFile(filepath.Join(hwmonPath, "fan1_input"), []byte("1500\n"), 0644); err != nil {
t.Fatal(err)
}
collector := NewAMDGPUCollector(sysPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if !stats.Available {
t.Error("Expected GPU to be available")
}
if stats.Utilization != 75 {
t.Errorf("Expected utilization 75, got %d", stats.Utilization)
}
if stats.VRAMUsed != 4294967296 {
t.Errorf("Expected VRAM used 4294967296, got %d", stats.VRAMUsed)
}
if stats.VRAMTotal != 17179869184 {
t.Errorf("Expected VRAM total 17179869184, got %d", stats.VRAMTotal)
}
if stats.ClockGPU != 1200 {
t.Errorf("Expected GPU clock 1200, got %d", stats.ClockGPU)
}
if stats.ClockMemory != 875 {
t.Errorf("Expected memory clock 875, got %d", stats.ClockMemory)
}
if stats.Temperature != 65.0 {
t.Errorf("Expected temperature 65.0, got %f", stats.Temperature)
}
if stats.PowerWatts != 150.0 {
t.Errorf("Expected power 150.0W, got %f", stats.PowerWatts)
}
if stats.FanRPM != 1500 {
t.Errorf("Expected fan 1500 RPM, got %d", stats.FanRPM)
}
}
func TestAMDGPUCollector_NoGPU(t *testing.T) {
tmpDir := t.TempDir()
collector := NewAMDGPUCollector(tmpDir)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if stats.Available {
t.Error("Expected GPU to be unavailable")
}
}
func TestAMDGPUCollector_PartialData(t *testing.T) {
tmpDir := t.TempDir()
sysPath := filepath.Join(tmpDir, "sys")
// Create minimal GPU structure (only utilization)
gpuPath := filepath.Join(sysPath, "class/drm/card0/device")
if err := os.MkdirAll(gpuPath, 0755); err != nil {
t.Fatal(err)
}
// Create driver symlink (required for AMD GPU detection)
driverTarget := filepath.Join(tmpDir, "drivers/amdgpu")
if err := os.MkdirAll(driverTarget, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(driverTarget, filepath.Join(gpuPath, "driver")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gpuPath, "gpu_busy_percent"), []byte("50\n"), 0644); err != nil {
t.Fatal(err)
}
collector := NewAMDGPUCollector(sysPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if !stats.Available {
t.Error("Expected GPU to be available with partial data")
}
if stats.Utilization != 50 {
t.Errorf("Expected utilization 50, got %d", stats.Utilization)
}
}

View File

@@ -0,0 +1,71 @@
package collectors
import (
"os"
"path/filepath"
"testing"
)
func TestDiskCollector_IOStats(t *testing.T) {
tmpDir := t.TempDir()
procPath := filepath.Join(tmpDir, "proc")
if err := os.MkdirAll(procPath, 0755); err != nil {
t.Fatal(err)
}
// Create mock /proc/diskstats
diskstatsContent := ` 8 0 sda 12345 6789 1234567 12345 54321 9876 7654321 54321 0 12345 66666 0 0 0 0 0 0
8 1 sda1 12340 6780 1234000 12340 54320 9870 7654000 54320 0 12340 66660 0 0 0 0 0 0
259 0 nvme0n1 98765 4321 9876543 98765 87654 3210 8765432 87654 0 98765 186419 0 0 0 0 0 0
259 1 nvme0n1p1 98760 4320 9876000 98760 87650 3200 8765000 87650 0 98760 186410 0 0 0 0 0 0
`
if err := os.WriteFile(filepath.Join(procPath, "diskstats"), []byte(diskstatsContent), 0644); err != nil {
t.Fatal(err)
}
// Create mock /proc/mounts (will fail statfs but shouldn't crash)
mountsContent := `/dev/sda1 /boot ext4 rw,relatime 0 0
/dev/nvme0n1p2 / ext4 rw,relatime 0 0
tmpfs /tmp tmpfs rw 0 0
`
if err := os.WriteFile(filepath.Join(procPath, "mounts"), []byte(mountsContent), 0644); err != nil {
t.Fatal(err)
}
collector := NewDiskCollector(procPath, filepath.Join(procPath, "mounts"))
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
// Should have IO stats for sda and nvme0n1 (not partitions)
if len(stats.IO) < 2 {
t.Errorf("Expected at least 2 IO entries, got %d", len(stats.IO))
}
// Verify we're not including partitions
for _, io := range stats.IO {
if io.Device == "sda1" || io.Device == "nvme0n1p1" {
t.Errorf("Should not include partition %s", io.Device)
}
}
}
func TestDiskCollector_MissingFiles(t *testing.T) {
tmpDir := t.TempDir()
collector := NewDiskCollector(tmpDir, filepath.Join(tmpDir, "nonexistent"))
stats, err := collector.Collect()
// Should not error, just return empty stats
if err != nil {
t.Logf("Error (acceptable): %v", err)
}
if stats.Mounts == nil {
t.Error("Mounts should not be nil")
}
if stats.IO == nil {
t.Error("IO should not be nil")
}
}

View File

@@ -0,0 +1,100 @@
package collectors
import (
"os"
"path/filepath"
"testing"
)
func TestProcessCollector(t *testing.T) {
tmpDir := t.TempDir()
procPath := filepath.Join(tmpDir, "proc")
// Create mock process directories
pid1 := filepath.Join(procPath, "1")
pid2 := filepath.Join(procPath, "100")
if err := os.MkdirAll(pid1, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(pid2, 0755); err != nil {
t.Fatal(err)
}
// Mock /proc/1/stat
// Format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime ...
stat1 := "1 (systemd) S 0 1 1 0 -1 4194560 12345 67890 100 200 1000 500 0 0 20 0 1 0 1 123456789 2000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"
if err := os.WriteFile(filepath.Join(pid1, "stat"), []byte(stat1), 0644); err != nil {
t.Fatal(err)
}
stat2 := "100 (bash) S 1 100 100 0 -1 4194304 5678 1234 50 100 500 250 0 0 20 0 1 0 100 98765432 1500 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 1 0 0 0 0 0"
if err := os.WriteFile(filepath.Join(pid2, "stat"), []byte(stat2), 0644); err != nil {
t.Fatal(err)
}
// Mock /proc/1/cmdline
if err := os.WriteFile(filepath.Join(pid1, "cmdline"), []byte("/usr/lib/systemd/systemd\x00--system\x00"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pid2, "cmdline"), []byte("/bin/bash\x00"), 0644); err != nil {
t.Fatal(err)
}
// Mock /proc/uptime for CPU calculation
if err := os.WriteFile(filepath.Join(procPath, "uptime"), []byte("12345.67 98765.43\n"), 0644); err != nil {
t.Fatal(err)
}
// Mock /proc/meminfo for memory percentage
meminfo := `MemTotal: 32000000 kB
MemFree: 16000000 kB
MemAvailable: 20000000 kB
`
if err := os.WriteFile(filepath.Join(procPath, "meminfo"), []byte(meminfo), 0644); err != nil {
t.Fatal(err)
}
collector := NewProcessCollector(procPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if stats.Total < 2 {
t.Errorf("Expected at least 2 processes, got %d", stats.Total)
}
// Check that we have some processes in the lists
if len(stats.TopByCPU) == 0 && len(stats.TopByMemory) == 0 {
t.Error("Expected some processes in top lists")
}
}
func TestProcessCollector_EmptyProc(t *testing.T) {
tmpDir := t.TempDir()
procPath := filepath.Join(tmpDir, "proc")
if err := os.MkdirAll(procPath, 0755); err != nil {
t.Fatal(err)
}
// Create meminfo (required)
meminfo := `MemTotal: 32000000 kB
`
if err := os.WriteFile(filepath.Join(procPath, "meminfo"), []byte(meminfo), 0644); err != nil {
t.Fatal(err)
}
collector := NewProcessCollector(procPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if stats.TopByCPU == nil {
t.Error("TopByCPU should not be nil")
}
if stats.TopByMemory == nil {
t.Error("TopByMemory should not be nil")
}
}

View File

@@ -0,0 +1,62 @@
package collectors
import (
"os"
"path/filepath"
"testing"
)
func TestSystemCollector(t *testing.T) {
tmpDir := t.TempDir()
procPath := filepath.Join(tmpDir, "proc")
if err := os.MkdirAll(procPath, 0755); err != nil {
t.Fatal(err)
}
// Mock /proc/version (format: "Linux version 6.1.0-test ...")
versionContent := "Linux version 6.1.0-test (gcc) #1 SMP PREEMPT\n"
if err := os.WriteFile(filepath.Join(procPath, "version"), []byte(versionContent), 0644); err != nil {
t.Fatal(err)
}
// Mock /proc/uptime
if err := os.WriteFile(filepath.Join(procPath, "uptime"), []byte("12345.67 98765.43\n"), 0644); err != nil {
t.Fatal(err)
}
collector := NewSystemCollector(procPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
// Hostname uses os.Hostname(), so we can't mock it easily
// Just check it's not empty
if stats.Hostname == "" {
t.Error("Expected non-empty hostname")
}
if stats.Kernel != "6.1.0-test" {
t.Errorf("Expected kernel '6.1.0-test', got '%s'", stats.Kernel)
}
if stats.Uptime != 12345 {
t.Errorf("Expected uptime 12345, got %d", stats.Uptime)
}
}
func TestSystemCollector_MissingFiles(t *testing.T) {
tmpDir := t.TempDir()
collector := NewSystemCollector(tmpDir)
stats, err := collector.Collect()
// Should not crash, may have errors but returns partial data
if err != nil {
t.Logf("Error (may be acceptable): %v", err)
}
// Should have some default values or empty strings
t.Logf("Stats with missing files: hostname=%s, kernel=%s, uptime=%d",
stats.Hostname, stats.Kernel, stats.Uptime)
}

View File

@@ -0,0 +1,110 @@
package collectors
import (
"os"
"path/filepath"
"testing"
)
func TestTemperatureCollector(t *testing.T) {
tmpDir := t.TempDir()
sysPath := filepath.Join(tmpDir, "sys")
// Create mock hwmon structure
hwmon0 := filepath.Join(sysPath, "class/hwmon/hwmon0")
if err := os.MkdirAll(hwmon0, 0755); err != nil {
t.Fatal(err)
}
// Sensor name
if err := os.WriteFile(filepath.Join(hwmon0, "name"), []byte("k10temp\n"), 0644); err != nil {
t.Fatal(err)
}
// Temperature input (65.5°C in millidegrees)
if err := os.WriteFile(filepath.Join(hwmon0, "temp1_input"), []byte("65500\n"), 0644); err != nil {
t.Fatal(err)
}
// Temperature label
if err := os.WriteFile(filepath.Join(hwmon0, "temp1_label"), []byte("Tctl\n"), 0644); err != nil {
t.Fatal(err)
}
// Critical temperature
if err := os.WriteFile(filepath.Join(hwmon0, "temp1_crit"), []byte("95000\n"), 0644); err != nil {
t.Fatal(err)
}
collector := NewTemperatureCollector(sysPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if len(stats.Sensors) == 0 {
t.Fatal("Expected at least one sensor")
}
sensor := stats.Sensors[0]
if sensor.Name != "k10temp" {
t.Errorf("Expected name 'k10temp', got '%s'", sensor.Name)
}
if sensor.Label != "Tctl" {
t.Errorf("Expected label 'Tctl', got '%s'", sensor.Label)
}
if sensor.Temperature != 65.5 {
t.Errorf("Expected temperature 65.5, got %f", sensor.Temperature)
}
if sensor.Critical != 95.0 {
t.Errorf("Expected critical 95.0, got %f", sensor.Critical)
}
}
func TestTemperatureCollector_MultipleSensors(t *testing.T) {
tmpDir := t.TempDir()
sysPath := filepath.Join(tmpDir, "sys")
// Create two hwmon entries
for i, name := range []string{"hwmon0", "hwmon1"} {
hwmon := filepath.Join(sysPath, "class/hwmon", name)
if err := os.MkdirAll(hwmon, 0755); err != nil {
t.Fatal(err)
}
sensorName := []string{"k10temp", "nvme"}[i]
if err := os.WriteFile(filepath.Join(hwmon, "name"), []byte(sensorName+"\n"), 0644); err != nil {
t.Fatal(err)
}
temp := []string{"55000", "45000"}[i]
if err := os.WriteFile(filepath.Join(hwmon, "temp1_input"), []byte(temp+"\n"), 0644); err != nil {
t.Fatal(err)
}
}
collector := NewTemperatureCollector(sysPath)
stats, err := collector.Collect()
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
if len(stats.Sensors) != 2 {
t.Errorf("Expected 2 sensors, got %d", len(stats.Sensors))
}
}
func TestTemperatureCollector_MissingHwmon(t *testing.T) {
tmpDir := t.TempDir()
collector := NewTemperatureCollector(tmpDir)
stats, err := collector.Collect()
if err != nil {
// Error is acceptable
return
}
if stats.Sensors == nil {
t.Error("Sensors should not be nil")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,24 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/svelte": "^5.2.6",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"svelte": "^5.11.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^2.1.8"
},
"type": "module"
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import { theme } from './theme';
describe('theme store', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('has a valid initial value', () => {
const value = get(theme);
expect(['dark', 'light']).toContain(value);
});
it('can toggle theme', () => {
const initial = get(theme);
theme.toggle();
const toggled = get(theme);
expect(toggled).not.toBe(initial);
// Toggle back
theme.toggle();
expect(get(theme)).toBe(initial);
});
it('can set theme directly', () => {
theme.set('light');
expect(get(theme)).toBe('light');
theme.set('dark');
expect(get(theme)).toBe('dark');
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import {
formatBytes,
formatPercent,
formatUptime,
formatFrequency,
formatTemperature,
formatWatts
} from './formatters';
describe('formatBytes', () => {
it('formats 0 bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});
it('formats bytes', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('formats kilobytes', () => {
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
});
it('formats megabytes', () => {
expect(formatBytes(1024 * 1024)).toBe('1 MB');
expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
it('formats gigabytes', () => {
expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
expect(formatBytes(16 * 1024 * 1024 * 1024)).toBe('16 GB');
});
it('formats terabytes', () => {
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe('1 TB');
});
it('respects decimals parameter', () => {
expect(formatBytes(1536, 2)).toBe('1.5 KB');
expect(formatBytes(1536, 0)).toBe('2 KB');
});
});
describe('formatPercent', () => {
it('formats percentage with default decimals', () => {
expect(formatPercent(50)).toBe('50.0%');
expect(formatPercent(99.9)).toBe('99.9%');
});
it('respects decimals parameter', () => {
expect(formatPercent(33.333, 2)).toBe('33.33%');
expect(formatPercent(33.333, 0)).toBe('33%');
});
});
describe('formatUptime', () => {
it('formats less than a minute', () => {
expect(formatUptime(30)).toBe('< 1m');
expect(formatUptime(0)).toBe('< 1m');
});
it('formats minutes', () => {
expect(formatUptime(60)).toBe('1m');
expect(formatUptime(300)).toBe('5m');
});
it('formats hours and minutes', () => {
expect(formatUptime(3600)).toBe('1h');
expect(formatUptime(3660)).toBe('1h 1m');
expect(formatUptime(7200)).toBe('2h');
});
it('formats days, hours and minutes', () => {
expect(formatUptime(86400)).toBe('1d');
expect(formatUptime(90000)).toBe('1d 1h');
expect(formatUptime(90060)).toBe('1d 1h 1m');
});
});
describe('formatFrequency', () => {
it('formats MHz', () => {
expect(formatFrequency(500)).toBe('500 MHz');
expect(formatFrequency(800)).toBe('800 MHz');
});
it('formats GHz', () => {
expect(formatFrequency(1000)).toBe('1.00 GHz');
expect(formatFrequency(3500)).toBe('3.50 GHz');
expect(formatFrequency(4200)).toBe('4.20 GHz');
});
});
describe('formatTemperature', () => {
it('formats temperature in Celsius', () => {
expect(formatTemperature(65)).toBe('65.0°C');
expect(formatTemperature(45.5)).toBe('45.5°C');
expect(formatTemperature(0)).toBe('0.0°C');
});
});
describe('formatWatts', () => {
it('formats power in Watts', () => {
expect(formatWatts(150)).toBe('150.0 W');
expect(formatWatts(42.5)).toBe('42.5 W');
expect(formatWatts(0)).toBe('0.0 W');
});
});

View File

@@ -0,0 +1,4 @@
export const browser = true;
export const dev = false;
export const building = false;
export const version = 'test';

View File

@@ -0,0 +1,17 @@
import { writable, readable } from 'svelte/store';
export const page = readable({
url: new URL('http://localhost'),
params: {},
route: { id: '/' },
status: 200,
error: null,
data: {},
form: null
});
export const navigating = readable(null);
export const updated = {
subscribe: readable(false).subscribe,
check: async () => false
};

View File

@@ -0,0 +1,37 @@
import '@testing-library/svelte/vitest';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
}
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true
})
});

18
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
setupFiles: ['src/tests/setup.ts']
},
resolve: {
alias: {
$lib: '/src/lib',
$app: '/src/tests/mocks/app'
}
}
});