Files
zero/internal/tui/theme_select_test.go
Anandan d7ac85cbf9 Polish Zero's TUI and harden patch, model, and background workflows (#916)
* Checkpoint UI polish

* Fix structured apply patch handling

* Restore tool previews when resuming sessions

* Improve theme-aware code rendering

* Replace persistent sidebar with run details

* Remove inert transcript interactions

* Cover resumed edit previews

* Smooth active run status feedback

* Preserve semantic colors behind overlays

* Normalize ChatGPT model aliases

* Render staged image attachments in terminal

* Polish transcript and tool feedback

* Resolve rebased diff renderer conflict

* Address UI polish review findings

* Fix smoke test regressions

* Harden patch and transcript edge cases

* Bind structured patches to workspace root

* Use unique structured patch temp files

* Retry structured patch temp allocation

* Reuse rooted patch temp allocator

* Fix Windows CI process checks

* Fix structured patch safety boundaries

Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.

* Resolve plan test seam after rebase

Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.

* Address final review correctness gaps

Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.

* Harden patch transactions and review edges

Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.

* Bind patch rollback to committed versions

Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.

* Align structured patch failure semantics

Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.

* Preserve patch context bytes

Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 22:18:47 +05:30

533 lines
19 KiB
Go

package tui
import (
"fmt"
"image/color"
"math"
"strconv"
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func relLum(t *testing.T, hex string) float64 {
t.Helper()
h := strings.TrimPrefix(hex, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hex)
}
r := float64((v>>16)&0xff) / 255
g := float64((v>>8)&0xff) / 255
b := float64(v&0xff) / 255
return 0.2126*r + 0.7152*g + 0.0722*b
}
// wcagRatio is the true WCAG 2.x contrast ratio (sRGB-linearized luminance), unlike
// relLum which is a cheap perceptual ordering. Used to assert AA (>=4.5) for the
// text-bearing theme tokens.
func wcagRatio(t *testing.T, fg, bg string) float64 {
t.Helper()
rel := func(hex string) float64 {
h := strings.TrimPrefix(hex, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hex)
}
lin := func(c float64) float64 {
c /= 255
if c <= 0.03928 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
return 0.2126*lin(float64((v>>16)&0xff)) + 0.7152*lin(float64((v>>8)&0xff)) + 0.0722*lin(float64(v&0xff))
}
l1, l2 := rel(fg), rel(bg)
if l1 < l2 {
l1, l2 = l2, l1
}
return (l1 + 0.05) / (l2 + 0.05)
}
// The word-level diff's brighter changed-span band must keep its text AA-readable
// and stay clearly distinct from the base add/del band, on both themes.
func TestDiffWordSpanContrast(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
}
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
}
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
}
}
// The highlighted picker/autocomplete row must both stand out from the panel
// AND keep its label readable. Guards the regression this fixes: the light
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
}
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
}
}
}
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
// for light themes). Guards that a newly-added color palette can't ship illegible.
func TestAllThemesContrastAndHierarchy(t *testing.T) {
for _, entry := range themeRegistry {
pal := entry.Palette
for _, tok := range []struct {
name string
fg string
}{
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
{"faintest", pal.faintest}, {"accent", pal.accent},
} {
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
}
}
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
}
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
// rises toward the surface on light themes, falls on dark themes.
chain := []float64{
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
relLum(t, pal.faintest), relLum(t, pal.panel),
}
for i := 1; i < len(chain); i++ {
ok := chain[i] > chain[i-1]
if entry.IsDark {
ok = chain[i] < chain[i-1]
}
if !ok {
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
}
}
}
}
// resolveThemeMode precedence: explicit flag > ZERO_THEME env > system.
func TestResolveThemeModePrecedence(t *testing.T) {
cases := []struct {
flag, env string
want themeMode
}{
{"light", "dark", themeSystem}, // retired preference migrates
{"dark", "light", themeSystem}, // retired preference migrates
{"", "light", themeSystem}, // retired env value migrates
{"", "dark", themeSystem}, // retired env value migrates
{"dracula", "dune", themeMode("dracula")},
{"", "", themeSystem}, // default
{"garbage", "also-bad", themeSystem},
{"AUTO", "", themeSystem},
}
for _, c := range cases {
if got := resolveThemeMode(c.flag, c.env); got != c.want {
t.Errorf("resolveThemeMode(%q,%q) = %q, want %q", c.flag, c.env, got, c.want)
}
}
}
func TestLegacyThemeArgumentsRemainAcceptedOutsidePicker(t *testing.T) {
for _, value := range []string{"dark", "light"} {
if !ValidThemeArg(value) {
t.Errorf("ValidThemeArg(%q) = false, want true for migration compatibility", value)
}
if validThemeMode(value) {
t.Errorf("validThemeMode(%q) = true, retired values must stay out of the picker", value)
}
}
}
// applyTheme keeps system canvas-native and mirrors named palettes when their
// intended surface polarity differs from the terminal.
func TestApplyThemeResolution(t *testing.T) {
defer applyTheme(themeDark, true) // restore the global default
cases := []struct {
mode themeMode
darkBg bool
want themeMode
wantInk string
}{
{themeSystem, true, themeSystem, ""},
{themeAuto, false, themeSystem, ""},
{themeDark, true, themeDark, darkPalette.ink},
{themeDark, false, themeDark, invertPaletteColor(darkPalette.ink)},
{themeLight, false, themeLight, lightPalette.ink},
{themeLight, true, themeLight, invertPaletteColor(lightPalette.ink)},
}
for _, c := range cases {
got := applyTheme(c.mode, c.darkBg)
if got != c.want {
t.Errorf("applyTheme(%q, darkBg=%v) = %q, want %q", c.mode, c.darkBg, got, c.want)
}
if c.wantInk == "" {
if _, ok := zeroTheme.bgPanel.(lipgloss.NoColor); !ok {
t.Errorf("applyTheme(%q,%v): system mode painted canvas with %T", c.mode, c.darkBg, zeroTheme.bgPanel)
}
continue
}
wantR, wantG, wantB, _ := lipgloss.Color(c.wantInk).RGBA()
gotR, gotG, gotB, _ := zeroTheme.inkColor.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB {
t.Errorf("applyTheme(%q,%v): zeroTheme.inkColor not the %q ink", c.mode, c.darkBg, c.want)
}
}
}
func TestInvertedPalettePreservesSemanticHues(t *testing.T) {
inverted := invertedPalette(nordPalette)
for _, test := range []struct {
name string
value string
dominant int
}{
{"success", inverted.green, 1},
{"error", inverted.red, 0},
} {
r, g, b, _ := lipgloss.Color(test.value).RGBA()
channels := []uint32{r, g, b}
for index, channel := range channels {
if index != test.dominant && channels[test.dominant] <= channel {
t.Errorf("inverted %s color %s lost its semantic hue", test.name, test.value)
break
}
}
}
}
// The light palette must be a real dark-on-light set: distinct from dark, ink
// well-contrasted against the panel, accent readable, and the gray hierarchy
// (ink→faintest) ordered toward the surface so it still reads on white.
func TestLightPaletteContrastAndHierarchy(t *testing.T) {
if lightPalette.ink == darkPalette.ink || lightPalette.panel == darkPalette.panel {
t.Fatal("light palette must differ from dark")
}
inkL, panelL := relLum(t, lightPalette.ink), relLum(t, lightPalette.panel)
if panelL-inkL < 0.5 {
t.Errorf("light ink/panel contrast too low: panel=%.2f ink=%.2f", panelL, inkL)
}
// AUDIT-H5/H6/M: text-bearing tokens (incl. faint/faintest, which carry line
// numbers, diff @@/+++/---, help text, placeholders, and the accent prompt glyph)
// must meet WCAG AA (>=4.5) against the worst-case background (the panel) — a real
// contrast ratio, not just a luminance ordering.
for _, tok := range []struct {
name string
fg, bg string
}{
{"dark muted", darkPalette.muted, darkPalette.panel},
{"dark faint", darkPalette.faint, darkPalette.panel},
{"dark faintest", darkPalette.faintest, darkPalette.panel},
{"dark accent", darkPalette.accent, darkPalette.panel},
{"light muted", lightPalette.muted, lightPalette.panel},
{"light faint", lightPalette.faint, lightPalette.panel},
{"light faintest", lightPalette.faintest, lightPalette.panel},
{"light accent", lightPalette.accent, lightPalette.panel},
} {
if r := wcagRatio(t, tok.fg, tok.bg); r < 4.5 {
t.Errorf("%s contrast %.2f < 4.5 (WCAG AA): %s on %s", tok.name, r, tok.fg, tok.bg)
}
}
// Dark-on-light: ink darkest, then progressively lighter toward the surface.
chain := []float64{
relLum(t, lightPalette.ink),
relLum(t, lightPalette.muted),
relLum(t, lightPalette.faint),
relLum(t, lightPalette.faintest),
relLum(t, lightPalette.panel),
}
for i := 1; i < len(chain); i++ {
if !(chain[i] > chain[i-1]) {
t.Errorf("light hierarchy not monotonic toward surface at %d: %v", i, chain)
}
}
// Dark theme keeps the inverse ordering (light-on-dark).
dchain := []float64{
relLum(t, darkPalette.ink),
relLum(t, darkPalette.muted),
relLum(t, darkPalette.faint),
relLum(t, darkPalette.faintest),
relLum(t, darkPalette.panel),
}
for i := 1; i < len(dchain); i++ {
if !(dchain[i] < dchain[i-1]) {
t.Errorf("dark hierarchy not monotonic toward surface at %d: %v", i, dchain)
}
}
}
// /theme switches the active theme live and shows state with no arg.
func TestHandleThemeCommand(t *testing.T) {
defer applyTheme(themeDark, true)
m := model{themeMode: themeSystem, hasDarkBg: true}
m, out := m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
t.Fatalf("after /theme dracula, mode = %q", m.themeMode)
}
if got, want := colorHex(t, zeroTheme.inkColor), draculaPalette.ink; got != want {
t.Errorf("/theme dracula ink = %s, want %s", got, want)
}
if !strings.Contains(out, "dracula") {
t.Errorf("output should confirm dracula: %q", out)
}
before := m.themeMode
m, removed := m.handleThemeCommand("dark")
if m.themeMode != before || !strings.Contains(removed, "Unknown theme") {
t.Fatalf("retired dark theme should not be selectable: mode=%q output=%q", m.themeMode, removed)
}
_, state := m.handleThemeCommand("")
if !strings.Contains(state, "active theme") {
t.Errorf("no-arg /theme should show state: %q", state)
}
if _, bad := m.handleThemeCommand("solarized"); !strings.Contains(bad, "Unknown theme") {
t.Errorf("invalid theme should error: %q", bad)
}
}
func TestNewThemePresetsWired(t *testing.T) {
neon, ok := lookupTheme("neon")
if !ok {
t.Fatal("theme 'neon' is not registered")
}
if !neon.IsDark {
t.Error("theme 'neon' should be marked as dark")
}
dune, ok := lookupTheme("dune")
if !ok {
t.Fatal("theme 'dune' is not registered")
}
if dune.IsDark {
t.Error("theme 'dune' should be marked as light")
}
for _, name := range []string{"neon", "dune"} {
if !validThemeMode(name) {
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
}
}
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
}
}
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
// applyTheme must actually swap zeroTheme to the resolved preset, adapting only
// its contrast direction when the terminal has the opposite polarity.
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
defer applyTheme(themeDark, true)
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
}
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
}
applyTheme(themeMode("dune"), true)
if got, want := colorHex(t, zeroTheme.inkColor), invertPaletteColor(dunePalette.ink); got != want {
t.Errorf("applying dune ink = %s, want %s", got, want)
}
applyTheme(themeMode("neon"), true)
if got, want := colorHex(t, zeroTheme.inkColor), neonPalette.ink; got != want {
t.Errorf("applying neon ink = %s, want %s", got, want)
}
}
func TestExtendedThemeContrastInvariants(t *testing.T) {
// Skip validation for old built-in themes if they have established, non-compliant palettes,
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
for _, entry := range themeRegistry {
if entry.Name != "neon" && entry.Name != "dune" {
continue
}
name, pal := entry.Name, entry.Palette
// Finding 1: Permission and status/success surfaces
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
}
// Finding 2: Selected row secondary text
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
}
// Finding 3: Diff gutter pairings
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
}
}
}
// hexChannels splits a #rrggbb token into its 8-bit channels.
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
t.Helper()
h := strings.TrimPrefix(hexColor, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hexColor)
}
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
}
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
// truecolor support downsamples the palette's hex tokens before rendering.
func xterm256Hex(t *testing.T, hexColor string) string {
t.Helper()
r, g, b := hexChannels(t, hexColor)
levels := []int{0, 95, 135, 175, 215, 255}
bestR, bestG, bestB := 0, 0, 0
bestDistance := math.MaxFloat64
try := func(cr, cg, cb int) {
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
if d < bestDistance {
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
}
}
for _, cr := range levels {
for _, cg := range levels {
for _, cb := range levels {
try(cr, cg, cb)
}
}
}
for i := 0; i < 24; i++ {
gray := 8 + 10*i
try(gray, gray, gray)
}
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
}
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
// terminal, which quantizes every token to its nearest xterm entry first.
// Guard the pairs that regressed: Dune's selected-row affordances (accent
// caret/favorite star and blue local-model dot over selBg via onSel) and
// Neon's diff bands, whose previous values all quantized to the same grays.
func TestExtendedThemeANSI256Contrast(t *testing.T) {
palettes := map[string]palette{}
for _, entry := range themeRegistry {
palettes[entry.Name] = entry.Palette
}
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
dune := palettes["dune"]
for _, pair := range []struct{ name, fg, bg string }{
{"accent on selBg", dune.accent, dune.selBg},
{"blue on selBg", dune.blue, dune.selBg},
{"faintest on selBg", dune.faintest, dune.selBg},
{"ink on selBg", dune.ink, dune.selBg},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
neon := palettes["neon"]
greenish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return g > r && g > b
}
reddish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return r > g && r > b
}
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
}
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
}
if q(neon.addBgWord) == q(neon.addBg) {
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
}
if q(neon.delBgWord) == q(neon.delBg) {
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
}
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
}
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
}
// The two rendered diff-content pairs a 256-color terminal actually
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
// spans (addInk/delInk on their word bands).
for _, pair := range []struct{ name, fg, bg string }{
{"faintest on addBg", neon.faintest, neon.addBg},
{"faintest on delBg", neon.faintest, neon.delBg},
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
// Error-card borders are non-text UI components: they need 3:1 against
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
// and 256-color renderings.
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
}
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
}
}
func colorHex(t *testing.T, value color.Color) string {
t.Helper()
r, g, b, _ := value.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}