package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
// A slide is only correct if every intermediate frame is exactly as wide as
// the screen. One cell out and the terminal reflows mid-animation, which looks
// like the whole UI tearing.
func TestSlideKeepsEveryFrameTheSameSize(t *testing.T) {
forceColour(t)
prev := styCoral.Render("aaaaaaaaaa") + "\n" + styDim.Render("bbbb")
next := styOK.Render("cccc") + "\n" + styErr.Render("dddddddddddddddd")
const width, height = 20, 4
for frame := 0; frame <= slideFrames; frame++ {
got := slide(prev, next, width, height, progress(frame, slideFrames))
lines := strings.Split(got, "\n")
if len(lines) != height {
t.Fatalf("frame %d is %d lines, want %d", frame, len(lines), height)
}
for i, line := range lines {
if w := lipgloss.Width(line); w != width {
t.Errorf("frame %d line %d is %d cells, want %d", frame, i, w, width)
}
}
}
}
// The ends have to be exact: a slide that does not start on the old screen or
// finish on the new one shows a jump at one end or the other.
func TestSlideEndsAreTheTwoPanels(t *testing.T) {
prev, next := "old ", "new "
if got := slide(prev, next, 4, 1, 0); got != prev {
t.Errorf("at t=0 the slide shows %q, want the outgoing panel", got)
}
if got := slide(prev, next, 4, 1, 1); got != next {
t.Errorf("at t=1 the slide shows %q, want the incoming panel", got)
}
}
// A ragged panel — short lines, or more lines than fit — must be squared off
// rather than sheared, or each row slides from a different place.
func TestSlideSquaresOffRaggedPanels(t *testing.T) {
got := slide("a\nbbbbbbbb\n", "x", 4, 2, 0.5)
for i, line := range strings.Split(got, "\n") {
if w := lipgloss.Width(line); w != 4 {
t.Errorf("line %d is %d cells, want 4", i, w)
}
}
}
func TestBlendHexInterpolates(t *testing.T) {
cases := []struct {
a, b string
t float64
want string
}{
{"#000000", "#ffffff", 0, "#000000"},
{"#000000", "#ffffff", 1, "#ffffff"},
{"#000000", "#ffffff", 0.5, "#808080"},
{"#ff0000", "#0000ff", 0.5, "#800080"},
// Out of range is clamped rather than extrapolated into nonsense.
{"#000000", "#ffffff", 2, "#ffffff"},
{"#000000", "#ffffff", -1, "#000000"},
}
for _, c := range cases {
if got := blendHex(c.a, c.b, c.t); got != c.want {
t.Errorf("blendHex(%s, %s, %v) = %s, want %s", c.a, c.b, c.t, got, c.want)
}
}
}
// An unparseable colour must degrade to one of the endpoints. lipgloss renders
// a malformed colour as no colour at all, which would show up as a hole in the
// animation rather than as anything anyone could debug.
func TestBlendHexSurvivesAColourItCannotParse(t *testing.T) {
for _, c := range []struct{ a, b string }{
{"not-a-colour", "#ffffff"},
{"#000000", "13"},
{"", ""},
} {
if got := blendHex(c.a, c.b, 0.2); got != c.a {
t.Errorf("blendHex(%q, %q, 0.2) = %q, want %q", c.a, c.b, got, c.a)
}
if got := blendHex(c.a, c.b, 0.8); got != c.b {
t.Errorf("blendHex(%q, %q, 0.8) = %q, want %q", c.a, c.b, got, c.b)
}
}
}
// progress with no frames to run is already finished. That is the single
// branch every animation's reduced-motion path goes through.
func TestProgressWithNoBudgetIsComplete(t *testing.T) {
if got := progress(0, 0); got != 1 {
t.Errorf("progress(0, 0) = %v, want 1", got)
}
if got := progress(5, 10); got != 0.5 {
t.Errorf("progress(5, 10) = %v, want 0.5", got)
}
if got := progress(99, 10); got != 1 {
t.Errorf("progress past the end = %v, want 1", got)
}
}
func TestEaseHitsItsEndpoints(t *testing.T) {
for _, f := range []func(float64) float64{ease, easeOut} {
if got := f(0); got != 0 {
t.Errorf("ease(0) = %v, want 0", got)
}
if got := f(1); got != 1 {
t.Errorf("ease(1) = %v, want 1", got)
}
}
// Smoothstep is symmetric about the middle; easeOut is not, and is ahead
// of linear there — which is what "decelerates" means.
if got := ease(0.5); got != 0.5 {
t.Errorf("ease(0.5) = %v, want 0.5", got)
}
if easeOut(0.5) <= 0.5 {
t.Errorf("easeOut(0.5) = %v, want more than linear", easeOut(0.5))
}
}
// The triangle wave has to actually reach both ends, or a breathing cursor
// either never lights up or never dims.
func TestTriangleReachesBothEnds(t *testing.T) {
var lo, hi float64 = 1, 0
for f := 0; f < breathPeriod; f++ {
v := triangle(f, breathPeriod)
if v < 0 || v > 1 {
t.Fatalf("triangle(%d) = %v, outside [0,1]", f, v)
}
lo, hi = minFloat(lo, v), maxFloat(hi, v)
}
if lo > 0.01 {
t.Errorf("the wave never dims below %v", lo)
}
if hi < 0.99 {
t.Errorf("the wave never brightens past %v", hi)
}
}
func TestSweepFillsLeftToRight(t *testing.T) {
forceColour(t)
for _, tc := range []struct{ t, wantFilled float64 }{{0, 0}, {0.5, 10}, {1, 20}} {
got := sweep(20, tc.t, colCoral)
if w := lipgloss.Width(got); w != 20 {
t.Errorf("t=%v: the bar is %d cells, want 20", tc.t, w)
}
if n := strings.Count(stripANSI(got), "━"); float64(n) != tc.wantFilled {
t.Errorf("t=%v: %d cells filled, want %v", tc.t, n, tc.wantFilled)
}
}
}
// reveal is the typewriter. It counts cells, so a multi-byte character is
// either drawn or not drawn, never halved.
func TestRevealCountsCellsNotBytes(t *testing.T) {
const s = "héllo wörld"
for n := 0; n <= lipgloss.Width(s); n++ {
got := reveal(s, n)
if w := lipgloss.Width(got); w != n {
t.Errorf("reveal(%d) is %d cells: %q", n, w, got)
}
}
if got := reveal(s, 99); got != s {
t.Errorf("revealing past the end gave %q", got)
}
}
// ignite has to finish: at t=1 every letter is fully in the target colour, or
// the title settles a shade off and stays there.
func TestIgniteFinishesEveryLetter(t *testing.T) {
forceColour(t)
// At t=1 every letter must be painted the target colour exactly: the same
// sequence lipgloss emits for a plain render in it.
lit := ignite("harness", colFaint, colCoral, 1, false)
want := termenv.TrueColor.Color(colCoral.Dark).Sequence(false)
if n := strings.Count(lit, want); n != len("harness") {
t.Errorf("%d of %d letters reached the target colour at t=1", n, len("harness"))
}
if got := stripANSI(lit); got != "harness" {
t.Errorf("ignite changed the text to %q", got)
}
if got := stripANSI(ignite("harness", colFaint, colCoral, 0, false)); got != "harness" {
t.Errorf("at t=0 the text is %q, want it present but unlit", got)
}
}
// stripANSI removes escape sequences, for assertions about what was written
// rather than how it was painted.
func stripANSI(s string) string {
var out strings.Builder
for i := 0; i < len(s); {
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
j := i + 2
for j < len(s) && s[j] != 'm' {
j++
}
i = j + 1
continue
}
out.WriteByte(s[i])
i++
}
return out.String()
}
func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}
func maxFloat(a, b float64) float64 {
if a > b {
return a
}
return b
}