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 }