add unit tests for stream decode, mtime fallback, staleness, sorting

This commit is contained in:
2026-08-04 11:05:45 +10:00
parent f0571465b2
commit 19b39de473
2 changed files with 135 additions and 16 deletions

31
main.go
View File

@@ -23,21 +23,21 @@ var (
Foreground(lipgloss.Color("#00d4ff")).
Padding(0, 1)
statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running")
statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle")
statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked")
statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error")
statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed")
statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost")
subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇")
statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running")
statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle")
statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked")
statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error")
statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed")
statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost")
subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇")
detailKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00d4ff")).Bold(true)
detailLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#aaaaaa"))
detailValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#dddddd"))
detailBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).
Padding(0, 1)
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).
Padding(0, 1)
infoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#aaaaaa")).
@@ -50,10 +50,10 @@ var (
// Sort modes
const (
sortSmart = iota // live first, running first, then last activity
sortActivity // most recent activity first
sortStarted // most recently started first
sortFolder // folder name A-Z
sortSmart = iota // live first, running first, then last activity
sortActivity // most recent activity first
sortStarted // most recently started first
sortFolder // folder name A-Z
)
var sortNames = map[int]string{
@@ -795,8 +795,7 @@ Config: ~/.config/pi-dashboard.yaml
remote:
- host: 192.168.20.13
user: sam
path: /home/sam/.pi/agent/dashboard/
`)
path: /home/sam/.pi/agent/dashboard/`)
return
case "--list":
cfg := defaultConfig()

120
main_test.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Simulates the remote `cat *.json` output: several pretty-printed,
// multi-line JSON objects concatenated together.
func TestReadRemoteStream(t *testing.T) {
var sb strings.Builder
for _, name := range []string{"alpha", "beta"} {
s := AgentState{
Session: name,
Machine: "test",
Cwd: "/home/sam/proj/" + name,
AgentStatus: "idle",
SessionState: "open",
LastSeenAt: strptr(time.Now().UTC().Format(time.RFC3339)),
}
b, _ := json.MarshalIndent(s, "", " ")
sb.Write(b)
sb.WriteString("\n")
}
dec := json.NewDecoder(strings.NewReader(sb.String()))
var states []AgentState
for {
var s AgentState
if err := dec.Decode(&s); err != nil {
break
}
states = append(states, s)
}
if len(states) != 2 {
t.Fatalf("expected 2 states, got %d", len(states))
}
if states[1].Session != "beta" {
t.Fatalf("expected beta second, got %s", states[1].Session)
}
}
// A legacy state file (no heartbeat) should use file mtime as liveness.
func TestLegacyMtimeFallback(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.json")
legacy := `{"session":"legacy","status":"running"}`
os.WriteFile(path, []byte(legacy), 0644)
s, err := readStateFile(path)
if err != nil {
t.Fatal(err)
}
if s.LastSeenAt == nil || *s.LastSeenAt == "" {
t.Fatal("expected mtime fallback to fill LastSeenAt")
}
if s.IsClosed(time.Hour) {
t.Fatal("fresh file should not be closed")
}
}
// Stale heartbeat => closed, regardless of graceful state.
func TestStaleness(t *testing.T) {
old := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
fresh := time.Now().UTC().Format(time.RFC3339)
stale := AgentState{SessionState: "open", LastSeenAt: &old}
if !stale.IsClosed(45 * time.Second) {
t.Fatal("old heartbeat should be closed")
}
if !stale.WasKilled(45 * time.Second) {
t.Fatal("no closed_at + stale => killed")
}
graceful := AgentState{SessionState: "closed", ClosedAt: &old}
if !graceful.IsClosed(45 * time.Second) {
t.Fatal("closed state should be closed")
}
if graceful.WasKilled(45 * time.Second) {
t.Fatal("graceful close should not be killed")
}
alive := AgentState{SessionState: "open", LastSeenAt: &fresh}
if alive.IsClosed(45 * time.Second) {
t.Fatal("fresh heartbeat should be live")
}
}
// Sorting: live first, running before idle, then by activity.
func TestSortSmart(t *testing.T) {
now := time.Now().UTC()
mk := func(name, status string, age time.Duration) AgentState {
t := now.Add(-age).Format(time.RFC3339)
return AgentState{Session: name, AgentStatus: status, SessionState: "open", LastSeenAt: &t}
}
agents := []AgentState{
mk("old-idle", "idle", 30*time.Minute),
mk("fresh-running", "running", time.Minute),
mk("old-closed", "idle", 10*time.Hour),
mk("fresh-idle", "idle", 2*time.Minute),
}
sortAgents(agents, sortSmart, 45*time.Second)
got := []string{}
for _, a := range agents {
got = append(got, a.Session)
}
want := []string{"fresh-running", "fresh-idle", "old-idle", "old-closed"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sort mismatch at %d: got %v want %v", i, got, want)
}
}
}
func strptr(s string) *string { return &s }