Files
zzet--gortex/internal/progress/tracker_test.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

328 lines
9.9 KiB
Go

package progress
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
)
// forceUnicode pins the glyph set so assertions are stable regardless of the
// host environment.
func forceUnicode(t *testing.T) {
t.Helper()
t.Setenv("GORTEX_ASCII", "")
t.Setenv("GORTEX_UNICODE", "1")
}
// newAnimatedTestTracker returns a tracker that renders the animated protocol
// into buf: animation forced (no TTY in tests), the frame ticker frozen so
// paints happen only at Start / Logf / finish, and a fixed terminal size.
func newAnimatedTestTracker(t *testing.T, buf *bytes.Buffer, w, h int, opts ...TrackerOption) *Tracker {
t.Helper()
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
tr := NewTracker(buf, opts...)
tr.fps = time.Hour
tr.sizeFn = func() (int, int) { return w, h }
return tr
}
func TestTrackerPlainExplicitStepsLifecycle(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
tr.Start("gortex init")
scan := tr.AddStep("scan repository")
parse := tr.AddStep("parse sources")
analyze := tr.AddStep("analyze codebase")
broken := tr.AddStep("configure adapters")
if strings.Contains(buf.String(), "scan repository") {
t.Fatalf("pending steps must stay silent in plain mode, got:\n%s", buf.String())
}
tr.StartStep("scan repository")
scan.Progress(1338, 0)
scan.SetUnit("files")
scan.DoneAs("files discovered")
tr.StartStep("parse sources")
parse.Progress(48210, 0)
parse.SetUnit("symbols")
parse.Done()
analyze.Skip("disabled by flag")
tr.StartStep("configure adapters")
broken.Fail(errors.New("no adapters selected"))
tr.Done("ready", "indexed 1,338 files · 48,210 symbols")
out := buf.String()
wants := []string{
" gortex init",
" scan repository ...",
" ✓ files discovered · 1,338 files",
" parse sources ...",
" ✓ parse sources · 48,210 symbols",
" · analyze codebase — skipped: disabled by flag",
" configure adapters ...",
" ✗ configure adapters: no adapters selected",
" ✓ ready — indexed 1,338 files · 48,210 symbols",
}
for _, w := range wants {
if !strings.Contains(out, w) {
t.Errorf("plain output missing %q\n--- got ---\n%s", w, out)
}
}
}
func TestTrackerPlainHeartbeatThrottled(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
tr.now = func() time.Time { return clock }
tr.Start("indexing")
s := tr.StartStep("resolving references")
s.Progress(1, 10)
s.Progress(2, 10) // within the throttle window — silent
if got := strings.Count(buf.String(), "resolving references"); got != 1 {
t.Fatalf("ticks inside the throttle window must not print, got %d occurrences:\n%s", got, buf.String())
}
clock = clock.Add(11 * time.Second)
s.Progress(5, 10)
if !strings.Contains(buf.String(), " resolving references · 5 / 10 (11s)") {
t.Errorf("expected a heartbeat line after the throttle window, got:\n%s", buf.String())
}
s.Progress(6, 10) // window reset — silent again
if got := strings.Count(buf.String(), "resolving references"); got != 2 {
t.Errorf("heartbeat must rearm the throttle, got %d occurrences:\n%s", got, buf.String())
}
}
func TestTrackerAnimatedFrameProtocol(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 24)
tr.Start("gortex enrich")
s := tr.StartStep("stamping blame")
s.Progress(120, 400)
s.SetUnit("nodes")
s.Done()
tr.Done("", "400 nodes stamped")
out := buf.String()
if !strings.HasPrefix(out, ansiHideCursor) {
t.Errorf("animated stream must open by hiding the cursor, got prefix %q", out[:min(20, len(out))])
}
if !strings.Contains(out, ansiSyncStart) || !strings.Contains(out, ansiSyncEnd) {
t.Error("frames must be wrapped in synchronized-update brackets")
}
if strings.Count(out, ansiSyncStart) != strings.Count(out, ansiSyncEnd) {
t.Error("unbalanced synchronized-update brackets")
}
if !strings.Contains(out, ansiShowCursor) {
t.Error("final frame must re-show the cursor")
}
if !strings.Contains(out, ansiReset) {
t.Error("final frame must reset SGR state")
}
// The final frame carries the completed checklist and the summary.
for _, w := range []string{"✓ stamping blame", "400 nodes stamped", "gortex enrich"} {
if !strings.Contains(ansi.Strip(out), w) {
t.Errorf("final frame missing %q\n--- stripped ---\n%s", w, ansi.Strip(out))
}
}
// Every frame line terminates with clear-to-EOL + CRLF so repaints can
// never leave residue and the cursor arithmetic stays exact.
frames := strings.Split(out, ansiSyncStart)
for _, fr := range frames[1:] {
body := strings.TrimSuffix(fr, ansiSyncEnd)
for _, ln := range strings.Split(body, "\r\n") {
if strings.Contains(ln, "\n") {
t.Errorf("bare LF inside a frame line: %q", ln)
}
}
}
}
func TestTrackerAnimatedLinesNeverExceedWidth(t *testing.T) {
forceUnicode(t)
const width = 34
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, width, 24)
tr.Start("gortex enrich with an extremely long command title that cannot fit")
s := tr.StartStep("a step label that is much longer than the terminal width for sure")
s.Progress(123456, 654321)
s.SetUnit("symbols")
s.Note("annotation that also overflows the width")
tr.Done("", "")
for _, raw := range strings.Split(buf.String(), "\r\n") {
if w := ansi.StringWidth(raw); w > width-1 {
t.Errorf("frame line exceeds clamp width %d (got %d): %q", width-1, w, ansi.Strip(raw))
}
}
}
func TestTrackerOverflowRetiresFinishedRows(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 8) // maxRegion = 6
tr.Start("bulk run")
for i := 0; i < 10; i++ {
s := tr.StartStep(fmt.Sprintf("phase-%02d", i))
s.Done()
}
tr.Done("", "")
stripped := ansi.Strip(buf.String())
for i := 0; i < 10; i++ {
label := fmt.Sprintf("phase-%02d", i)
if !strings.Contains(stripped, label) {
t.Errorf("finished step %s lost during overflow handling\n--- got ---\n%s", label, stripped)
}
}
// The final live region must fit the clamped height. Region lines are the
// ones terminated by clear-to-EOL; retired rows above the region are
// written without it.
frames := strings.Split(buf.String(), ansiSyncStart)
last := frames[len(frames)-1]
if lines := strings.Count(last, ansiClearEOL); lines > 6 {
t.Errorf("final live region paints %d lines, exceeding the clamp of 6", lines)
}
}
func TestTrackerLogfWritesAboveRegion(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 24)
tr.Start("watching")
tr.Logf("warning: adapter %s misbehaved", "codex")
tr.Done("", "")
out := ansi.Strip(buf.String())
warnIdx := strings.Index(out, "warning: adapter codex misbehaved")
if warnIdx < 0 {
t.Fatalf("log line missing from animated stream:\n%s", out)
}
// The region is repainted after the log line, so the title must appear
// again later in the stream.
if !strings.Contains(out[warnIdx:], "watching") {
t.Errorf("live region not repainted after log line:\n%s", out)
}
}
func TestTrackerReporterBuildsChecklistAnimated(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 100, 24)
tr.Start("indexing repository")
tr.Report("discovering files", 0, 0)
tr.Report("discovering files", 900, 0)
tr.Report("parsing", 10, 900)
tr.Report("parsing", 900, 900)
tr.Done("", "")
out := ansi.Strip(buf.String())
if !strings.Contains(out, "✓ discovering files · 900") {
t.Errorf("previous stage must complete with its final counter:\n%s", out)
}
if !strings.Contains(out, "✓ parsing · 900") {
t.Errorf("last stage must be completed by Done:\n%s", out)
}
}
func TestTrackerBannerPresetRendersLogoAndStatus(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 100, 30, WithLogo())
tr.Start("gortex init")
tr.AddStep("index repository")
tr.AddStep("configure adapters")
tr.StartStep("index repository").Done()
tr.SetStatus("configuring")
tr.StartStep("configure adapters").Done()
tr.Done("ready", "indexed everything")
out := ansi.Strip(buf.String())
if got := strings.Count(out, "●"); got < 10 {
t.Errorf("banner preset must render the dot-matrix mark, found %d dots:\n%s", got, out)
}
for _, w := range []string{"gortex init", "ready", "indexed everything", "✓ index repository", "✓ configure adapters"} {
if !strings.Contains(out, w) {
t.Errorf("banner output missing %q:\n%s", w, out)
}
}
}
func TestTrackerConcurrentReportsAreSafe(t *testing.T) {
forceUnicode(t)
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
var buf bytes.Buffer
tr := NewTracker(&buf)
tr.fps = 2 * time.Millisecond
tr.sizeFn = func() (int, int) { return 80, 24 }
tr.Start("stress")
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 200; i++ {
tr.Report(fmt.Sprintf("stage-%d", g%3), i, 200)
}
}(g)
}
wg.Wait()
time.Sleep(10 * time.Millisecond) // let the ticker paint at least once
tr.Done("", "")
if !strings.Contains(ansi.Strip(buf.String()), "stress") {
t.Error("expected output from the concurrent run")
}
}
func TestTrackerFinishIdempotent(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
tr.Start("run")
tr.Done("", "")
tr.Done("", "")
tr.Fail(errors.New("late"))
out := buf.String()
if got := strings.Count(out, "✓"); got != 1 {
t.Errorf("expected exactly one ✓, got %d:\n%s", got, out)
}
if strings.Contains(out, "✗") {
t.Errorf("Fail after Done must be ignored:\n%s", out)
}
}
func TestSpinnerCompatStatusRidesFinishLine(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Disable()
sp.Start("Enriched via daemon")
sp.Set("", "42 nodes stamped")
sp.Done()
if !strings.Contains(buf.String(), "✓ Enriched via daemon — 42 nodes stamped") {
t.Errorf("finish line must carry the last status, got:\n%s", buf.String())
}
}