Files
diple/difflet_test.go
2026-07-29 22:02:30 +02:00

371 lines
12 KiB
Go

package main
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
)
func TestDiffletExpressionsAndFramesHaveStableCellDimensions(t *testing.T) {
expressions := []DiffletExpression{
DiffletIdle, DiffletFocused, DiffletHappy, DiffletApproved,
DiffletSurprised, DiffletConcerned, DiffletConfused, DiffletSad,
DiffletAnnoyed, DiffletError, DiffletSleeping, DiffletCurious,
}
for _, expression := range expressions {
face := diffletFaces[expression]
if width := lipgloss.Width(face); width != 3 {
t.Errorf("%s face width = %d, want 3: %q", expression, width, face)
}
lines := (DiffletFrame{Expression: expression}).lines()
if len(lines) != diffletHeight {
t.Fatalf("%s has %d lines, want %d", expression, len(lines), diffletHeight)
}
for row, line := range lines {
if width := lipgloss.Width(line); width != diffletWidth {
t.Errorf("%s row %d width = %d, want %d: %q",
expression, row, width, diffletWidth, line)
}
}
}
}
func TestDiffletCanonicalFramePreservesGlyphsAndSpacing(t *testing.T) {
got := (DiffletFrame{Expression: DiffletIdle}).lines()
want := []string{
"▄███████▄",
"█+ •_• -█",
"▀███████▀",
" ▝ ▘ ",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("canonical frame:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletStylesOnlyDiffSigns(t *testing.T) {
previousProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) })
previousOK, previousBad := okStyle, badStyle
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
t.Cleanup(func() { okStyle, badStyle = previousOK, previousBad })
rendered := (DiffletFrame{Expression: DiffletIdle}).render()
wantMiddle := "█" + okStyle.Render("+") + " •_• " + badStyle.Render("-") + "█"
if rendered[1] != wantMiddle {
t.Fatalf("middle row = %q, want %q", rendered[1], wantMiddle)
}
if ansi.Strip(strings.Join(rendered, "\n")) !=
strings.Join((DiffletFrame{Expression: DiffletIdle}).lines(), "\n") {
t.Fatal("styling changed Difflet glyphs or spacing")
}
for _, row := range []int{0, 2, 3} {
if strings.Contains(rendered[row], "\x1b[") {
t.Fatalf("row %d unexpectedly styled: %q", row, rendered[row])
}
}
}
func TestDiffletEnabledSitsRightOfWrappingHeader(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 24, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
if len(rendered) < diffletHeight+2 {
t.Fatalf("rendered rows = %d, want at least %d", len(rendered), diffletHeight+2)
}
headerWidth := diffletHeaderWidth(app.width)
content := app
content.headerWidth = headerWidth
_, _, ok := splitHeader(content.viewContent())
if !ok {
t.Fatal("test view did not expose a header")
}
wantMascotLeft := headerWidth + diffletGap
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
plain := ansi.Strip(rendered[row])
if !strings.Contains(plain, strings.TrimRight(mascotLine, " ")) {
t.Fatalf("mascot row %d is not visible: %q", row, plain)
}
left := strings.Index(plain, strings.TrimRight(mascotLine, " "))
if left != wantMascotLeft {
t.Fatalf("mascot row %d starts at %d, want adjacent position %d",
row, left, wantMascotLeft)
}
}
blankRow := -1
var headerText strings.Builder
for row := 0; row < len(rendered); row++ {
line := rendered[row]
plain := ansi.Strip(line)
if strings.TrimSpace(plain) == "" {
blankRow = row
break
}
headerText.WriteString(strings.TrimSpace(
ansi.Cut(plain, 0, wantMascotLeft-diffletGap),
))
}
normalizedHeader := strings.ReplaceAll(headerText.String(), " ", "")
for _, information := range []string{"diple", "owner/repository", "assignedtoyou"} {
if !strings.Contains(normalizedHeader, information) {
t.Fatalf("wrapped header does not contain %q: %s", information, normalizedHeader)
}
}
if blankRow < diffletHeight {
t.Fatalf("blank row = %d, want at or below mascot row %d",
blankRow, diffletHeight)
}
}
func TestDiffletIsCenteredAtNormalWidths(t *testing.T) {
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
const width = 40
headerWidth := diffletHeaderWidth(width)
rendered := strings.Split(
renderHeaderWithDifflet([]string{"diple"}, mascot, width, headerWidth, diffletGap),
"\n",
)
for row, line := range rendered[:diffletHeight] {
mascotText := strings.TrimRight(mascot[row], " ")
wantLeft := (width - diffletWidth) / 2
if left := strings.Index(ansi.Strip(line), mascotText); left != wantLeft {
t.Fatalf("row %d mascot starts at %d, want %d: %q",
row, left, wantLeft, line)
}
}
}
func TestDiffletLongHeaderKeepsMascotCentered(t *testing.T) {
const width = 80
headerWidth := diffletHeaderWidth(width)
if headerWidth != 33 {
t.Fatalf("header width = %d, want 33", headerWidth)
}
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
rendered := strings.Split(
renderHeaderWithDifflet(
[]string{strings.Repeat("x", headerWidth)},
mascot,
width,
headerWidth,
diffletGap,
),
"\n",
)
mascotLeft := strings.Index(ansi.Strip(rendered[0]), strings.TrimRight(mascot[0], " "))
if mascotLeft != headerWidth+diffletGap {
t.Fatalf("mascot starts at %d, want %d", mascotLeft, headerWidth+diffletGap)
}
if mascotLeft != (width-diffletWidth)/2 {
t.Fatalf("mascot starts at %d, want centered position %d",
mascotLeft, (width-diffletWidth)/2)
}
}
func TestDiffletFallsBackToSmallRightPadding(t *testing.T) {
const width = 24
headerWidth := diffletHeaderWidth(width)
mascotLeft := headerWidth + diffletGap
if rightPadding := width - mascotLeft - diffletWidth; rightPadding != 1 {
t.Fatalf("fallback right padding = %d, want 1", rightPadding)
}
}
func TestDiffletDisabledPreservesViewExactly(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{MascotExpressive: true, MascotAnimated: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 12})
app = updated.(App)
got := app.View()
want := app.viewContent()
if got != want {
t.Fatalf("disabled mascot changed rendered view:\ngot:\n%q\nwant:\n%q", got, want)
}
}
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
tests := []struct {
name string
screen screen
}{
{name: "picker", screen: prScreen},
{name: "dashboard", screen: dashboardScreen},
{name: "threads", screen: threadScreen},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "owner", "repository", false, 10, 10,
AppSettings{Mascot: true},
)
app.screen = test.screen
app.details = PRDetails{PullRequest: PullRequest{
Owner: "owner", Repository: "repository",
RepoWithOwner: "owner/repository", Number: 42,
Title: "A pull request with a useful title",
}}
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
app = updated.(App)
app.headerWidth = diffletHeaderWidth(app.width)
measured, ok := app.diffletHeaderLineCount()
if !ok {
t.Fatal("normal screen did not expose measurable header lines")
}
renderedHeader, _, ok := splitHeader(app.viewContent())
if !ok {
t.Fatal("rendered normal screen did not expose a header")
}
if measured != len(renderedHeader) {
t.Fatalf("measured header lines = %d, rendered = %d",
measured, len(renderedHeader))
}
})
}
}
func TestDiffletKeepsCompleteFrameAtNarrowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true},
)
updated, _ := app.Update(tea.WindowSizeMsg{Width: diffletWidth, Height: 12})
app = updated.(App)
rendered := strings.Split(app.View(), "\n")
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
if got := ansi.Strip(rendered[row]); got != mascotLine {
t.Fatalf("mascot row %d = %q, want %q", row, got, mascotLine)
}
}
headerBelowMascot := ansi.Strip(strings.Join(rendered[diffletHeight:], "\n"))
normalizedHeader := strings.NewReplacer("\n", "", " ", "").Replace(headerBelowMascot)
if !strings.Contains(normalizedHeader, "diple") {
t.Fatalf("header did not wrap below complete mascot: %q", headerBelowMascot)
}
}
func TestDiffletStopsTicksBelowPhysicalMinimum(t *testing.T) {
app := NewAppWithSettings(
&recordingService{}, "", "", false, 10, 10,
AppSettings{Mascot: true, MascotAnimated: true},
)
if command := app.difflet.start(); command != nil {
t.Fatal("hidden startup mascot scheduled a tick")
}
updated, command := app.Update(tea.WindowSizeMsg{Width: 12, Height: 12})
app = updated.(App)
if command == nil {
t.Fatal("visible animated mascot did not schedule a tick")
}
generation := app.difflet.generation
updated, command = app.Update(tea.WindowSizeMsg{Width: 8, Height: 12})
app = updated.(App)
if command != nil {
t.Fatal("mascot below its nine-cell minimum scheduled a tick")
}
if app.difflet.visible {
t.Fatal("mascot below its nine-cell minimum remained visible")
}
updated, command = app.Update(diffletTickMsg{generation: generation})
app = updated.(App)
if command != nil || app.difflet.visible {
t.Fatal("stale tick restarted hidden mascot")
}
}
func TestDiffletStaticOptionsSelectFinalSafeExpression(t *testing.T) {
model := newDifflet(true, false, false)
if command := model.setState(diffletSuccess); command != nil {
t.Fatal("animation-disabled Difflet scheduled a tick")
}
if model.expression != DiffletHappy {
t.Fatalf("static success = %s, want %s", model.expression, DiffletHappy)
}
if command := model.setState(diffletRecoverableError); command != nil {
t.Fatal("non-expressive Difflet scheduled an error animation")
}
if model.expression != DiffletConcerned {
t.Fatalf("non-expressive error = %s, want %s", model.expression, DiffletConcerned)
}
model.setState(diffletFatalError)
if model.expression != DiffletError {
t.Fatalf("non-expressive fatal error = %s, want %s", model.expression, DiffletError)
}
}
func TestDiffletFocusedStateDoesNotLoop(t *testing.T) {
model := newDifflet(true, true, true)
if command := model.setState(diffletFocused); command != nil {
t.Fatal("focused state scheduled continuous animation")
}
if model.expression != DiffletFocused || len(model.animation.frames) != 0 {
t.Fatalf("focused state = expression %s, animation %#v",
model.expression, model.animation)
}
}
func TestDiffletLoadingFootShuffleSequence(t *testing.T) {
model := newDifflet(true, false, true)
if command := model.setState(diffletLoading); command == nil {
t.Fatal("loading animation did not schedule a tick")
}
want := []string{" ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ ", " ▝ ▘ "}
for index, feet := range want {
if got := model.frameLines()[3]; got != feet {
t.Fatalf("loading frame %d feet = %q, want %q", index, got, feet)
}
if index < len(want)-1 {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("loading frame %d stopped looping", index)
}
}
}
}
func TestDiffletOneShotStopsAndSettles(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletSuccess)
for index := 0; index < 3; index++ {
if command := model.update(diffletTickMsg{generation: model.generation}); command == nil {
t.Fatalf("success animation stopped at frame %d", index)
}
}
if command := model.update(diffletTickMsg{generation: model.generation}); command != nil {
t.Fatal("completed success animation scheduled another tick")
}
if len(model.animation.frames) != 0 || model.expression != DiffletHappy {
t.Fatalf("success settled with animation=%#v expression=%s",
model.animation, model.expression)
}
}
func TestDiffletLoopStopsOnStateChangeAndRejectsStaleTick(t *testing.T) {
model := newDifflet(true, true, true)
model.setState(diffletLoading)
oldGeneration := model.generation
if command := model.update(diffletTickMsg{generation: oldGeneration}); command == nil {
t.Fatal("loading animation did not continue")
}
model.setState(diffletConcerned)
if command := model.update(diffletTickMsg{generation: oldGeneration}); command != nil {
t.Fatal("stale tick scheduled another tick")
}
if model.expression != DiffletConcerned || model.frame != 0 {
t.Fatalf("stale tick changed newer state: expression=%s frame=%d",
model.expression, model.frame)
}
}