Vectrum
Sign In
229 lines · 6.8 KB · Go
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("codey", colFaint, colCoral, 1, false)
	want := termenv.TrueColor.Color(colCoral.Dark).Sequence(false)
	if n := strings.Count(lit, want); n != len("codey") {
		t.Errorf("%d of %d letters reached the target colour at t=1", n, len("codey"))
	}

	if got := stripANSI(lit); got != "codey" {
		t.Errorf("ignite changed the text to %q", got)
	}
	if got := stripANSI(ignite("codey", colFaint, colCoral, 0, false)); got != "codey" {
		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
}