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 TestDiffletDashboardIsCenteredBesideMetadata(t *testing.T) { app := NewAppWithSettings( &recordingService{}, "owner", "repository", false, 10, 10, AppSettings{Mascot: true}, ) app.screen = dashboardScreen app.loading = false app.details = PRDetails{ PullRequest: PullRequest{ RepoWithOwner: "owner/repository", Number: 42, Title: "A useful title", Author: "alice", }, HeadRef: "feature", BaseRef: "main", } updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) app = updated.(App) rendered := strings.Split(app.View(), "\n") headerHeight := len(app.dashboardHeaderLines()) if strings.TrimSpace(ansi.Strip(rendered[headerHeight])) == "" { t.Fatal("dashboard left a blank row between its title and metadata") } for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() { plain := ansi.Strip(rendered[headerHeight+row]) mascotText := strings.TrimRight(mascotLine, " ") mascotIndex := strings.Index(plain, mascotText) left := -1 if mascotIndex >= 0 { left = lipgloss.Width(plain[:mascotIndex]) } if left != (app.width-diffletWidth)/2 { t.Fatalf("row %d mascot starts at %d, want centered position %d: %q", row, left, (app.width-diffletWidth)/2, plain) } } for row, label := range []string{"author", "branches", "review"} { if !strings.Contains(ansi.Strip(rendered[headerHeight+row]), label) { t.Fatalf("dashboard row %d does not place %q beside mascot: %q", row, label, ansi.Strip(rendered[headerHeight+row])) } } } func TestDiffletIsHiddenInEditorAndPopups(t *testing.T) { app := NewAppWithSettings( &recordingService{}, "owner", "repository", false, 10, 10, AppSettings{Mascot: true}, ) app.screen = dashboardScreen app.loading = false app.details = PRDetails{PullRequest: PullRequest{ RepoWithOwner: "owner/repository", Number: 42, Title: "Title", }} updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) app = updated.(App) for _, mode := range []writeMode{writePREdit, writeReplyConfirm} { app.writeMode = mode if got, want := app.View(), app.viewContent(); got != want { t.Fatalf("write mode %d changed by enabled mascot:\ngot:\n%q\nwant:\n%q", mode, got, want) } if strings.Contains(ansi.Strip(app.View()), "▄███████▄") { t.Fatalf("write mode %d displayed the mascot", mode) } } app.writeMode = writeNone app.helpVisible = true if got, want := app.View(), app.viewContent(); got != want { t.Fatalf("help popup changed by enabled mascot:\ngot:\n%q\nwant:\n%q", got, want) } } func TestDiffletEnabledEditorCanRevealLastDescriptionRow(t *testing.T) { app := NewAppWithSettings( &recordingPRService{}, "owner", "repository", false, 10, 10, AppSettings{Mascot: true}, ) app.screen = dashboardScreen app.loading = false app.width, app.height = 50, 12 app.details = PRDetails{ PullRequest: PullRequest{ ID: "pr", RepoWithOwner: "owner/repository", Number: 42, Title: "Title", }, BaseRef: "main", Body: strings.Repeat("description row\n", 20) + "LAST DESCRIPTION ROW", Permissions: ViewerPermissions{ CanUpdatePR: true, }, } app.startPREdit() app.prEditEditors[prEditBodyField].Cursor = len([]rune(app.prEditEditors[prEditBodyField].Text)) app.ensurePREditCursorVisible() rendered := ansi.Strip(app.View()) if !strings.Contains(rendered, "LAST DESCRIPTION ROW") { t.Fatalf("last description row is outside the editor viewport:\n%s", rendered) } if strings.Contains(rendered, "▄███████▄") { t.Fatal("editor displayed the mascot instead of using its full height") } } func TestDashboardDiffletRemainsCenteredAtNarrowWidths(t *testing.T) { mascot := (DiffletFrame{Expression: DiffletIdle}).lines() metadata := []string{"author", "branches", "review", "checks"} for width := diffletWidth; width < 20; width++ { rendered := renderDashboardMetadataWithDifflet(metadata, mascot, width) for row, mascotLine := range mascot { mascotText := strings.TrimRight(mascotLine, " ") var mascotRow string for _, line := range rendered { if strings.Contains(line, mascotText) { mascotRow = line break } } index := strings.Index(mascotRow, mascotText) left := -1 if index >= 0 { left = lipgloss.Width(mascotRow[:index]) } if want := max(0, (width-diffletWidth)/2); left != want { t.Fatalf("width %d row %d mascot starts at %d, want %d: %q", width, row, left, want, mascotRow) } } } } func TestDashboardLoadingDiffletIsCentered(t *testing.T) { app := NewAppWithSettings( &recordingService{}, "owner", "repository", false, 10, 10, AppSettings{Mascot: true}, ) app.screen = dashboardScreen app.loading = true app.details = PRDetails{PullRequest: PullRequest{ RepoWithOwner: "owner/repository", Number: 42, Title: "Title", }} updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) app = updated.(App) rendered := strings.Split(app.View(), "\n") mascotLine := strings.TrimRight((DiffletFrame{Expression: DiffletIdle}).lines()[0], " ") for row, line := range rendered { plain := ansi.Strip(line) index := strings.Index(plain, mascotLine) if index < 0 { continue } if left := lipgloss.Width(plain[:index]); left != (app.width-diffletWidth)/2 { t.Fatalf("loading mascot starts at %d, want %d: %q", left, (app.width-diffletWidth)/2, plain) } if row != len(app.dashboardHeaderLines()) { t.Fatalf("loading mascot begins on row %d, want %d", row, len(app.dashboardHeaderLines())) } return } t.Fatal("loading dashboard did not display the mascot") } 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) } }