package templates import ( "context" "strings" "testing" "time" "vetting/internal/model" ) 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_NoStageStrip: the tile no longer carries the Phase 3 // per-stage mini run-view — the runs-table on /hosts/{id} owns the // stage-strip now. Guards against the regression that would bring // `tile-steplist` / `tile-step-name` / `tile-run-duration` back. func TestHostTile_NoStageStrip(t *testing.T) { now := time.Now() latest := &model.Run{ ID: 17, State: model.StateSMART, StartedAt: now.Add(-3 * time.Minute), } data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, Latest: latest, 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 _, dropped := range []string{ `tile-steplist`, `tile-step-name`, `tile-step-dot`, `tile-run-id`, `tile-run-duration`, `tile-meta-row`, } { if strings.Contains(html, dropped) { t.Errorf("host tile leaked dropped class %q: %s", dropped, html) } } } // TestTileStatusCancelledFromHold: a run that entered FailedHolding and // was later Cancelled by the operator should read as a failure, not a // plain cancel. The discriminator is FailedStage being set — mid-stage // cancels leave it empty. func TestTileStatusCancelledFromHold(t *testing.T) { cases := []struct { name string run *model.Run wantStatus string wantMood string }{ { name: "cancelled from hold shows failed", run: &model.Run{State: model.StateCancelled, FailedStage: "Storage"}, wantStatus: "Failed (cancelled)", wantMood: "fail", }, { name: "mid-stage cancel stays plain cancelled", run: &model.Run{State: model.StateCancelled}, wantStatus: "Cancelled", wantMood: "idle", }, { name: "failed-holding itself still reads as FailedHolding", run: &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"}, wantStatus: string(model.StateFailedHolding), wantMood: "fail", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tileStatus(tc.run); got != tc.wantStatus { t.Errorf("tileStatus = %q, want %q", got, tc.wantStatus) } if got := tileMood(tc.run); got != tc.wantMood { t.Errorf("tileMood = %q, want %q", got, tc.wantMood) } }) } } 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) } }