274f0081e2
applyTheme mutated ~18 package-global lipgloss styles while every SSH session's tea.Program read them concurrently from its own goroutine. Pressing T or opening a new connection raced other sessions' View and bled themes across users. Styles now live in an immutable per-Model struct built by newStyles; free formatter helpers that consumed the globals became Model methods.
171 lines
4.7 KiB
Go
171 lines
4.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestLatencySparkline_Empty(t *testing.T) {
|
|
got := styledModel.latencySparkline(nil, nil, 10, "")
|
|
if !strings.Contains(got, "··········") {
|
|
t.Errorf("empty sparkline should be dots, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLatencySparkline_SingleValue(t *testing.T) {
|
|
latencies := []time.Duration{100 * time.Millisecond}
|
|
statuses := []bool{true}
|
|
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
|
if len(got) == 0 {
|
|
t.Error("sparkline should not be empty")
|
|
}
|
|
if !strings.Contains(got, "····") {
|
|
t.Errorf("single value with width=5 should have 4 dot padding, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|
latencies := make([]time.Duration, 20)
|
|
statuses := make([]bool, 20)
|
|
for i := range latencies {
|
|
latencies[i] = time.Duration(i*50) * time.Millisecond
|
|
statuses[i] = true
|
|
}
|
|
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
|
if len(got) == 0 {
|
|
t.Error("sparkline should not be empty")
|
|
}
|
|
if strings.Contains(got, "·") {
|
|
t.Errorf("20 samples in width=5 should have no padding, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
|
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
|
statuses := []bool{true, true, true}
|
|
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
|
|
runes := []rune(out)
|
|
if len(runes) < 3 {
|
|
t.Fatalf("expected 3 runes, got %d", len(runes))
|
|
}
|
|
if runes[0] == runes[1] {
|
|
t.Errorf("min and max should have different bar heights, got %c %c %c", runes[0], runes[1], runes[2])
|
|
}
|
|
}
|
|
|
|
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
|
st := newStyles(themeFlexokiDark)
|
|
st.sparkSuccess = "#00ff00"
|
|
st.sparkWarning = "#ffff00"
|
|
st.sparkDanger = "#ff0000"
|
|
m := Model{st: st}
|
|
|
|
green := m.latencyStyle(50, "")
|
|
yellow := m.latencyStyle(300, "")
|
|
red := m.latencyStyle(800, "")
|
|
|
|
gfg := green.GetForeground()
|
|
yfg := yellow.GetForeground()
|
|
rfg := red.GetForeground()
|
|
|
|
if gfg == yfg || yfg == rfg || gfg == rfg {
|
|
t.Errorf("bands should produce distinct foreground colors: green=%v yellow=%v red=%v", gfg, yfg, rfg)
|
|
}
|
|
}
|
|
|
|
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
|
st := newStyles(themeFlexokiDark)
|
|
st.sparkSuccess = "#00ff00"
|
|
m := Model{st: st}
|
|
|
|
dim := m.latencyStyle(10, "")
|
|
bright := m.latencyStyle(190, "")
|
|
|
|
if dim.GetForeground() == bright.GetForeground() {
|
|
t.Error("10ms and 190ms should have different brightness within green band")
|
|
}
|
|
}
|
|
|
|
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
|
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
|
statuses := []bool{true, true, true}
|
|
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
|
count := utf8.RuneCountInString(stripANSI(got))
|
|
if count != 5 {
|
|
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
|
}
|
|
}
|
|
|
|
func stripANSI(s string) string {
|
|
var out strings.Builder
|
|
i := 0
|
|
for i < len(s) {
|
|
if s[i] == '\x1b' {
|
|
for i < len(s) && s[i] != 'm' {
|
|
i++
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
out.WriteByte(s[i])
|
|
i++
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
|
got := styledModel.heartbeatSparkline(nil, 10, "")
|
|
if !strings.Contains(got, "··········") {
|
|
t.Errorf("empty heartbeat should be dots, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
|
statuses := []bool{true, false, true, true, false}
|
|
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
|
if len(got) == 0 {
|
|
t.Error("heartbeat sparkline should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
|
statuses := []bool{true, true}
|
|
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
|
if !strings.Contains(got, "···") {
|
|
t.Errorf("should have dot padding for width > data, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveSparklineIndex(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
x int
|
|
sparkWidth int
|
|
dataLen int
|
|
want int
|
|
}{
|
|
{"exact fit first", 0, 5, 5, 0},
|
|
{"exact fit last", 4, 5, 5, 4},
|
|
{"padding returns -1", 0, 10, 5, -1},
|
|
{"padding boundary", 4, 10, 5, -1},
|
|
{"first data after padding", 5, 10, 5, 0},
|
|
{"last data after padding", 9, 10, 5, 4},
|
|
{"truncated first visible", 0, 5, 20, 15},
|
|
{"truncated last visible", 4, 5, 20, 19},
|
|
{"single data point", 9, 10, 1, 0},
|
|
{"single data point on padding", 0, 10, 1, -1},
|
|
{"zero data", 0, 10, 0, -1},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := resolveSparklineIndex(tt.x, tt.sparkWidth, tt.dataLen)
|
|
if got != tt.want {
|
|
t.Errorf("resolveSparklineIndex(%d, %d, %d) = %d, want %d",
|
|
tt.x, tt.sparkWidth, tt.dataLen, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|