package templates import ( "context" "fmt" "strings" "testing" "time" "vetting/internal/model" "vetting/internal/store" ) func TestHumanAgoFrom(t *testing.T) { now := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC) cases := []struct { name string ago time.Duration want string }{ {"just now", 5 * time.Second, "online"}, {"edge-just-under-minute", 59 * time.Second, "online"}, {"one minute", 60 * time.Second, "1m ago"}, {"five minutes", 5 * time.Minute, "5m ago"}, {"fifty-nine minutes", 59 * time.Minute, "59m ago"}, {"one hour", 1 * time.Hour, "1h ago"}, {"eight hours", 8 * time.Hour, "8h ago"}, {"one day", 24 * time.Hour, "1d ago"}, {"three days", 72 * time.Hour, "3d ago"}, // Clock skew: "future" heartbeat clamps to "online" rather than // printing "-3m ago" or panicking. {"future clamps to online", -5 * time.Second, "online"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := humanAgoFrom(now, now.Add(-tc.ago)) if got != tc.want { t.Fatalf("humanAgoFrom(%v) = %q, want %q", tc.ago, got, tc.want) } }) } } // TestHostTile_OverlayLink asserts the tile includes the tile-link // that makes the whole card clickable. The action button stays a // sibling element, so CSS (z-index) keeps it on top of the overlay. // // Heartbeat must be fresh because canStart now gates on LastSeenAt — // an offline host renders a disabled button (no form), which is // covered by TestHostTile_DisabledStartWhenOffline below. func TestHostTile_OverlayLink(t *testing.T) { now := time.Now() data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, LastSeenAt: &now, } var buf strings.Builder if err := HostTile(data).Render(context.Background(), &buf); err != nil { t.Fatalf("render: %v", err) } html := buf.String() if !strings.Contains(html, `href="/hosts/42"`) { t.Fatalf("tile missing overlay href: %s", html) } if !strings.Contains(html, `class="tile-link"`) { t.Fatalf("tile missing tile-link class: %s", html) } // Fresh heartbeat + no run → Start form must render. if !strings.Contains(html, `/hosts/42/start`) { t.Fatalf("expected Start vetting form in tile: %s", html) } // Dropped content that used to live on the tile — confirm it has // actually moved off so the slim-down is real. for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} { if strings.Contains(html, dropped) { t.Errorf("slim tile still contains dropped class %q", dropped) } } } // TestHostTile_DisabledStartWhenOffline: no heartbeat → disabled button // with the quick.sh tooltip, not a submittable form. Mirrors the // server-side StartRun 409 so the UI matches the handler. func TestHostTile_DisabledStartWhenOffline(t *testing.T) { data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, } var buf strings.Builder if err := HostTile(data).Render(context.Background(), &buf); err != nil { t.Fatalf("render: %v", err) } html := buf.String() if strings.Contains(html, `/hosts/42/start`) { t.Fatalf("offline host should not expose a Start form: %s", html) } if !strings.Contains(html, `disabled`) || !strings.Contains(html, `quick.sh`) { t.Fatalf("expected disabled Start button with quick.sh tooltip: %s", html) } } // TestHostTile_MiniRunView asserts the tile renders a step-list entry // for every canonical stage, colours the dots according to the mixed // stage states in the fixture, and surfaces the run id + duration in // the meta row. This is the contract the dashboard leans on: the // operator should be able to read run health across all tiles without // drilling into any of them. func TestHostTile_MiniRunView(t *testing.T) { now := time.Now() started := now.Add(-3 * time.Minute) latest := &model.Run{ ID: 17, State: model.StateSMART, StartedAt: started, } // Mixed states: first two stages passed, SMART running, rest pending. stages := []model.Stage{ {Name: "Inventory", State: model.StagePassed}, {Name: "SpecValidate", State: model.StagePassed}, {Name: "SMART", State: model.StageRunning}, } data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, Latest: latest, Stages: stages, LastSeenAt: &now, } var buf strings.Builder if err := HostTile(data).Render(context.Background(), &buf); err != nil { t.Fatalf("render: %v", err) } html := buf.String() // Step list exists and contains every canonical stage name so the // operator reads a full 9-dot strip regardless of how far the run got. if !strings.Contains(html, `
    `) { t.Fatalf("tile missing step list: %s", html) } for _, s := range store.DefaultStageOrder { want := fmt.Sprintf(`%s`, s) if !strings.Contains(html, want) { t.Fatalf("tile missing step name %q: %s", s, html) } } // Colours: the two passed stages got passed dots; SMART got a running // dot; CPUStress (no fixture row) falls back to pending. mustContain := []string{ `stage-dot stage-dot-sm stage-dot-passed`, `stage-dot stage-dot-sm stage-dot-running`, `stage-dot stage-dot-sm stage-dot-pending`, } for _, c := range mustContain { if !strings.Contains(html, c) { t.Fatalf("tile missing expected dot classes %q: %s", c, html) } } // Meta row: run id + a duration string (minutes for a 3m-old run). if !strings.Contains(html, `#17`) { t.Fatalf("tile missing run id #17: %s", html) } if !strings.Contains(html, `class="tile-run-duration"`) { t.Fatalf("tile missing duration element: %s", html) } } // TestHostTile_GhostSteplist: a never-run host still gets a 9-dot // ghost strip (all pending). Keeps the tile height stable so the // dashboard grid doesn't reflow as hosts gain their first run. func TestHostTile_GhostSteplist(t *testing.T) { now := time.Now() data := TileData{ Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"}, LastSeenAt: &now, } var buf strings.Builder if err := HostTile(data).Render(context.Background(), &buf); err != nil { t.Fatalf("render: %v", err) } html := buf.String() for _, s := range store.DefaultStageOrder { want := fmt.Sprintf(`%s`, s) if !strings.Contains(html, want) { t.Fatalf("ghost tile missing stage %q: %s", s, html) } } if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) { t.Fatalf("ghost tile should have only pending dots: %s", html) } // No run → no meta row (suppresses "#0 · 0s" when no run exists). if strings.Contains(html, `class="tile-run-id"`) { t.Fatalf("ghost tile should omit run id: %s", html) } } func TestLastSeenLabelAndClass(t *testing.T) { if got := lastSeenLabel(nil); got != "never" { t.Fatalf("label nil = %q, want never", got) } if got := lastSeenClass(nil); got != "offline" { t.Fatalf("class nil = %q, want offline", got) } recent := time.Now().Add(-5 * time.Second) if got := lastSeenClass(&recent); got != "online" { t.Fatalf("class recent = %q, want online", got) } stale := time.Now().Add(-10 * time.Minute) if got := lastSeenClass(&stale); got != "stale" { t.Fatalf("class stale = %q, want stale", got) } }