Files
zero/internal/tui/doctor_command_test.go
Vasanth T efe48ade0c feat(tui): syntax-highlighted code, word-level diffs, reduced-motion gate (#289)
* feat(tui): syntax-highlighted code, word-level diffs, and a reduced-motion gate

The remaining three items from the reference-TUI comparison, focused on
"real content rendering" + motion accessibility.

1. Syntax-highlight fenced code blocks. renderMarkdownCodeBlock passed code
   through plain; now the fence language is captured and the block is tokenized
   with chroma (new dep, pure Go) and colored with Zero's OWN audited palette —
   keyword=accent, string=green, number=amber, comment=faint, function=blue,
   punctuation=muted — so it stays on-brand and degrades through the same
   lipgloss profile path (truecolor→256→16→plain). Wrapping is token-level so a
   color never splits across a wrap. Unknown/missing language falls back to the
   old plain rendering, so it is never worse than today.

2. Word-level diff highlighting. diffCardBody tinted whole add/del lines; now an
   isolated 1:1 replacement (one "-" followed by one "+") highlights only the
   changed span on each side via a brighter changed-span bg (new addBgWord/
   delBgWord tokens), after trimming the common prefix/suffix. Gated to <=60%
   change so a near-rewrite is not confettied, and block changes keep whole-line
   tint. Changed-span colors validated by WCAG ratio (fg >=4.6:1, clear
   separation from the base band).

3. One reduced-motion gate. ZERO_REDUCED_MOTION (and no-TTY) now swaps the
   animated spinner for a steady dot via m.spinnerGlyph() at every render site
   (working line, plan, tool/specialist cards) and forces the fade off — one
   switch for all motion, where ZERO_NO_FADE only governed the fade. Liveness is
   preserved by the advancing elapsed timer, so a static frame never reads as
   frozen.

Tests cover the reduced-motion resolver + static glyph, changedSpan/word-diff
pairing + the near-rewrite fallback, and the highlighter's unknown-language
fallback + wrapping. Full internal/tui suite green; gofmt/vet/build clean.

* fix(tui): address render-polish review — highlight committed rows only

Adversarial review found a real interactive-jank regression and three smaller
issues; all fixed:

- PERF (blocker): the live streaming block re-tokenised the whole accumulated
  code via chroma on every frame. renderAssistantMarkdownText now takes
  allowHighlight; the interim/streaming path (model.interimBlock) and reasoning
  bodies pass false (plain, cheap), and only committed/cached rows
  (renderAssistantRow, selectable rows) highlight — so chroma runs once per row,
  never per frame.
- PERF: cache language->lexer lookups (including nil) and short-circuit an empty
  language before chroma's slow registry Match scan.
- Fences with metadata: take the first whitespace token as the language
  ("```go title=x" -> "go") instead of the whole info string.
- Reduced motion: freeze the compact and doctor braille rings too (their frame
  counters no longer advance under ZERO_REDUCED_MOTION), honoring the one-switch
  contract.

Tests: streaming code renders verbatim/plain; diff word-span colors AA on both
themes. Full internal/tui suite green; gofmt/vet/build clean.

* test(tui): opt the doctor/compact animation tests into motion

The reduced-motion gate auto-enables under no-TTY (colorprofile.NoTTY ->
reducedMotion), which is exactly the CI environment. That froze the compact and
doctor status rings, so two existing tests asserting "animates on tick" failed on
the Linux/macOS runners (Windows CI detects a color profile, so it passed there —
the platform split). Both tests exercise the animation mechanism, which is only
active when motion is on, so set m.reducedMotion = false in their setup to test it
deterministically regardless of the runner's detected profile.
2026-06-21 18:21:31 +05:30

370 lines
12 KiB
Go

package tui
import (
"context"
"reflect"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/providerhealth"
)
func TestParseDoctorCommandArgsRejectsFixWithConnectivity(t *testing.T) {
_, _, _, err := parseDoctorCommandArgs("fix --connectivity")
if err == nil {
t.Fatal("parseDoctorCommandArgs accepted both fix and --connectivity")
}
if !strings.Contains(err.Error(), "choose either") {
t.Fatalf("error = %q, want mutually exclusive flag guidance", err)
}
}
func TestDoctorOptionsIncludeConfigPaths(t *testing.T) {
m := newModel(context.Background(), Options{
UserConfigPath: "C:/zero/user.json",
ProjectConfigPath: "C:/repo/.zero/config.json",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4.1",
},
})
options := m.doctorOptions(false)
if options.UserConfig != "C:/zero/user.json" {
t.Fatalf("UserConfig = %q, want %q", options.UserConfig, "C:/zero/user.json")
}
if options.ProjectConfig != "C:/repo/.zero/config.json" {
t.Fatalf("ProjectConfig = %q, want %q", options.ProjectConfig, "C:/repo/.zero/config.json")
}
if options.Connectivity {
t.Fatal("Connectivity = true, want false")
}
if options.ProviderHealth != nil {
t.Fatalf("ProviderHealth = %#v, want nil without connectivity", options.ProviderHealth)
}
}
func TestDoctorOptionsConnectivityInvokesConfiguredProviderHealthProbe(t *testing.T) {
profile := config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "custom-model",
}
var called int
var gotOptions providerhealth.Options
m := newModel(context.Background(), Options{
ProviderProfile: profile,
UserAgent: "zero-test",
ProbeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result {
called++
gotOptions = options
return providerhealth.Result{
Status: providerhealth.StatusPass,
Checks: []providerhealth.Check{{
ID: "provider.connectivity",
Status: providerhealth.StatusPass,
Message: "reachable",
}},
}
},
})
options := m.doctorOptions(true)
report := doctor.Run(options)
if called != 1 {
t.Fatalf("probe called %d time(s), want 1", called)
}
if !gotOptions.Connectivity {
t.Fatal("probe Connectivity = false, want true")
}
if gotOptions.UserAgent != "zero-test" {
t.Fatalf("probe UserAgent = %q, want %q", gotOptions.UserAgent, "zero-test")
}
if !reflect.DeepEqual(gotOptions.Profile, profile) {
t.Fatalf("probe Profile = %#v, want %#v", gotOptions.Profile, profile)
}
check := report.Check("provider.connectivity")
if options.ProviderHealth == nil || check == nil || check.Status != doctor.StatusPass {
t.Fatalf("connectivity check = %#v, ProviderHealth = %#v; want passing injected health", check, options.ProviderHealth)
}
}
func TestDoctorConnectivityCommandRunsProbeAsynchronously(t *testing.T) {
profile := config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "custom-model",
}
called := false
m := newModel(context.Background(), Options{
ProviderProfile: profile,
ProbeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
called = true
return providerhealth.Result{
Status: providerhealth.StatusPass,
Checks: []providerhealth.Check{{
ID: "provider.connectivity",
Status: providerhealth.StatusPass,
Message: "reachable",
}},
}
},
})
m.input.SetValue("/doctor --connectivity")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor --connectivity to return an async command")
}
if called {
t.Fatal("provider probe ran synchronously before the returned command executed")
}
if !transcriptContains(next.transcript, "checking provider connectivity") {
t.Fatalf("expected running doctor status, got %#v", next.transcript)
}
msg := execCmd(cmd)
if !called {
t.Fatal("provider probe did not run when the async command executed")
}
updated, _ = next.Update(msg)
final := updated.(model)
for _, want := range []string{"Diagnostics", "[pass] provider.connectivity", "reachable"} {
if !transcriptContains(final.transcript, want) {
t.Fatalf("expected final doctor transcript to contain %q, got %#v", want, final.transcript)
}
}
}
func TestDoctorCommandUsesDiagnosticCenterRow(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderProfile: config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "custom-model",
},
})
m.input.SetValue("/doctor")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected plain /doctor to render synchronously")
}
row := newestDoctorStatusRow(next.transcript)
if row == nil {
t.Fatalf("expected /doctor to render a doctor status row, got %#v", next.transcript)
}
if row.id != doctorStatusRowID {
t.Fatalf("expected /doctor row id %q, got %q", doctorStatusRowID, row.id)
}
text := row.text
for _, want := range []string{"Diagnostics", "checks need attention", "Actions"} {
if !strings.Contains(text, want) {
t.Fatalf("doctor row missing %q:\n%s", want, text)
}
}
for _, unwanted := range []string{"Generated", "Checks", "[pass]"} {
if strings.Contains(text, unwanted) {
t.Fatalf("doctor row should hide %q:\n%s", unwanted, text)
}
}
}
func TestDoctorConnectivityCommandAnimatesAndReplacesStatusRow(t *testing.T) {
profile := config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "custom-model",
}
m := newModel(context.Background(), Options{
ProviderProfile: profile,
ProbeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
return providerhealth.Result{
Status: providerhealth.StatusPass,
Checks: []providerhealth.Check{{
ID: "provider.connectivity",
Status: providerhealth.StatusPass,
Message: "reachable",
}},
}
},
})
// This test exercises the tick-driven status animation, which is gated off
// under reduced motion. CI runs with no TTY (reducedMotion auto-on), so force
// it off to test the animation mechanism deterministically.
m.reducedMotion = false
m.input.SetValue("/doctor --connectivity")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor --connectivity to start an async command")
}
beforeRows := len(next.transcript)
before := newestDoctorStatusText(next.transcript)
updated, _ = next.Update(next.spinner.Tick())
ticked := updated.(model)
after := newestDoctorStatusText(ticked.transcript)
if before == "" || after == "" {
t.Fatalf("expected doctor status row before=%q after=%q", before, after)
}
if before == after {
t.Fatalf("expected doctor status to animate on tick, still %q", after)
}
updated, _ = ticked.Update(execCmd(cmd))
final := updated.(model)
if len(final.transcript) != beforeRows {
t.Fatalf("expected final doctor report to replace the running row, rows before=%d after=%d: %#v", beforeRows, len(final.transcript), final.transcript)
}
if got := countDoctorDiagnosticRows(final.transcript); got != 1 {
t.Fatalf("expected one doctor diagnostic row after completion, got %d: %#v", got, final.transcript)
}
if transcriptContains(final.transcript, "checking provider connectivity") {
t.Fatalf("expected stale running copy to be replaced, got %#v", final.transcript)
}
if !transcriptContains(final.transcript, "[pass] provider.connectivity") {
t.Fatalf("expected completed diagnostics result, got %#v", final.transcript)
}
}
func TestDoctorFixOpensProviderWizardWhenProviderMissing(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/doctor fix")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /doctor fix provider setup path to be handled synchronously")
}
if next.providerWizard == nil {
t.Fatalf("expected /doctor fix to open provider setup wizard, transcript %#v", next.transcript)
}
if !transcriptContains(next.transcript, "Opening provider setup") {
t.Fatalf("expected doctor fix transcript to explain provider setup, got %#v", next.transcript)
}
}
func TestDoctorFixLinesShowNoAutomaticFixForUnmappedFailure(t *testing.T) {
lines := doctorFixLines(doctor.Report{Checks: []doctor.Check{{
ID: "provider.auth",
Status: doctor.StatusFail,
Message: "API key rejected.",
}}})
text := strings.Join(lines, "\n")
if strings.Contains(text, "already clean") {
t.Fatalf("doctorFixLines reported clean diagnostics for an unmapped failure:\n%s", text)
}
if !strings.Contains(text, "No automatic fixes are available") {
t.Fatalf("doctorFixLines = %q, want no automatic fix guidance", text)
}
}
func TestDoctorFixLinesUseSandboxRemedy(t *testing.T) {
lines := doctorFixLines(doctor.Report{Checks: []doctor.Check{{
ID: "sandbox.backend",
Status: doctor.StatusWarn,
Message: "Native sandbox backend unavailable on windows: Windows sandbox setup helper is not available.",
Details: map[string]any{
"remedy": "install the Windows sandbox command runner and setup helper together, then run `zero sandbox setup`",
},
}}})
text := strings.Join(lines, "\n")
if !strings.Contains(text, "zero sandbox setup") {
t.Fatalf("doctorFixLines missing sandbox setup remedy:\n%s", text)
}
if strings.Contains(text, "WSL2") || strings.Contains(text, "Linux container") {
t.Fatalf("doctorFixLines used stale Windows sandbox guidance:\n%s", text)
}
}
func newestDoctorStatusText(rows []transcriptRow) string {
if row := newestDoctorStatusRow(rows); row != nil {
return row.text
}
return ""
}
func newestDoctorStatusRow(rows []transcriptRow) *transcriptRow {
for i := len(rows) - 1; i >= 0; i-- {
row := rows[i]
if row.kind == rowSystem && row.id == doctorStatusRowID {
return &rows[i]
}
}
return nil
}
func countDoctorDiagnosticRows(rows []transcriptRow) int {
count := 0
for _, row := range rows {
if row.kind == rowSystem && row.id == doctorStatusRowID {
count++
}
}
return count
}
func TestDoctorFixRunsConnectivityWhenProviderConfigured(t *testing.T) {
profile := config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "custom-model",
APIKey: "sk-test", // credentialed so provider.config passes and /doctor fix reaches connectivity
}
called := false
m := newModel(context.Background(), Options{
ProviderProfile: profile,
ProbeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
called = true
return providerhealth.Result{
Status: providerhealth.StatusPass,
Checks: []providerhealth.Check{{
ID: "provider.connectivity",
Status: providerhealth.StatusPass,
Message: "reachable",
}},
}
},
})
m.input.SetValue("/doctor fix")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor fix to run provider connectivity asynchronously")
}
if called {
t.Fatal("provider probe ran synchronously before the returned command executed")
}
if !transcriptContains(next.transcript, "checking provider connectivity") {
t.Fatalf("expected running doctor status, got %#v", next.transcript)
}
updated, _ = next.Update(execCmd(cmd))
final := updated.(model)
if !called {
t.Fatal("provider probe did not run when /doctor fix command executed")
}
if !transcriptContains(final.transcript, "[pass] provider.connectivity") {
t.Fatalf("expected connectivity result in transcript, got %#v", final.transcript)
}
}