package tui
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"intake/internal/scan"
)
func TestFrameIsExactHeight(t *testing.T) {
for _, sz := range []struct{ w, h int }{{80, 24}, {100, 30}, {60, 20}, {200, 50}} {
m := testModule(sz.w, sz.h)
next, _ := m.Update(albumsMsg{albums: albums()})
m = next.(Module)
lines := strings.Split(m.render(), "\n")
if len(lines) > sz.h {
t.Errorf("%dx%d: frame is %d rows, more than the terminal", sz.w, sz.h, len(lines))
}
}
}
func TestFooterIsOnItsOwnRow(t *testing.T) {
m := testModule(100, 30)
next, _ := m.Update(albumsMsg{albums: albums()})
m = next.(Module)
lines := strings.Split(m.render(), "\n")
footer := stripANSI(lines[len(lines)-1])
if !strings.Contains(footer, "filter") {
t.Errorf("last row should be the key hints, got %q", footer)
}
if strings.Contains(footer, "quit14") || strings.Contains(footer, "quit3") {
t.Errorf("status is glued to the hints: %q", footer)
}
}
func TestHeaderHasASeparator(t *testing.T) {
m := testModule(100, 30)
next, _ := m.Update(albumsMsg{albums: albums()})
m = next.(Module)
header := stripANSI(strings.Split(m.render(), "\n")[0])
if !strings.Contains(header, "intake") {
t.Fatalf("header should name the app, got %q", header)
}
if !strings.Contains(header, "│") && !strings.Contains(header, "staging") {
t.Errorf("header should separate the app name from the location, got %q", header)
}
if strings.Contains(header, "staging14") {
t.Errorf("status is glued to the breadcrumb: %q", header)
}
}
func TestAlbumRowsKeepIndentation(t *testing.T) {
m := testModule(100, 30)
next, _ := m.Update(albumsMsg{albums: albums()})
m = next.(Module)
lines := strings.Split(m.render(), "\n")
var found int
for _, l := range lines {
plain := stripANSI(l)
if strings.Contains(plain, "boards-of-canada") {
found++
if !strings.HasPrefix(plain, " ") {
t.Errorf("album row lost its indentation: %q", plain)
}
}
}
if found == 0 {
t.Fatal("expected to find a non-selected album row")
}
}
func TestPanelBordersLineUp(t *testing.T) {
m := testModule(100, 30)
next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
m = next.(Module)
for _, line := range strings.Split(m.render(), "\n") {
plain := stripANSI(line)
if strings.HasPrefix(plain, "╭") || strings.HasPrefix(plain, "│") || strings.HasPrefix(plain, "╰") {
if displayWidth(plain) > m.width {
t.Errorf("panel line wider than the terminal: %q", plain)
}
}
}
}
func TestFieldFitsInsideItsPanel(t *testing.T) {
for _, w := range []int{40, 60, 80, 100} {
m := testModule(w, 30)
line := stripANSI(m.field("album artist", strings.Repeat("x", 200), w))
if displayWidth(line) > w-4 {
t.Errorf("width %d: field is %d cells, wider than the panel inner width", w, displayWidth(line))
}
}
}
func TestCoverPickerEmitsACoherentGrid(t *testing.T) {
m, img := pickerModule(t)
panel := m.coverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
var gridLines []string
for _, l := range strings.Split(panel, "\n") {
if strings.ContainsRune(l, placeholderRune) {
gridLines = append(gridLines, l)
}
}
if len(gridLines) == 0 {
t.Fatalf("no placeholder cells in the preview; panel was:\n%s", panel)
}
want := strings.Count(gridLines[0], string(placeholderRune))
if want < 4 {
t.Fatalf("grid is only %d cells wide", want)
}
for i, l := range gridLines {
if got := strings.Count(l, string(placeholderRune)); got != want {
t.Errorf("grid row %d has %d cells, row 0 has %d", i, got, want)
}
if !strings.Contains(l, "\x1b[38:2:") {
t.Errorf("grid row %d lost the image id colour", i)
}
if !strings.HasPrefix(l, " ") {
t.Errorf("grid row %d is not indented like the rest of the panel", i)
}
if got := displayWidth(l); got != want+1 {
t.Errorf("grid row %d measures %d, want %d (cells plus indent)", i, got, want+1)
}
}
for _, l := range strings.Split(panel, "\n") {
if strings.Contains(l, "PREVIEW") {
if !strings.HasPrefix(l, " ") {
t.Errorf("heading is not indented but the grid is: %q", l)
}
break
}
}
if got := m.coverPreviewWidth(); got < want+1 {
t.Errorf("grid plus indent is %d cells wide but the column is %d", want+1, got)
}
_ = img
}
func TestCoverPickerNeverExceedsTheTerminal(t *testing.T) {
for _, w := range []int{60, 72, 80, 100, 120, 160, 200} {
for _, h := range []int{20, 24, 34, 50} {
m, _ := pickerModule(t)
m.width, m.height = w, h
for _, l := range strings.Split(m.viewCoverPicker(), "\n") {
if got := displayWidth(l); got > w {
t.Errorf("%dx%d: a line measures %d columns, wider than the terminal", w, h, got)
break
}
}
}
}
}
func pickerModule(t *testing.T) (Module, string) {
t.Helper()
dir := t.TempDir()
img := filepath.Join(dir, "cover.png")
writeSolidPNG(t, img, 300)
prev := &ImagePreviewer{mode: imageKitty}
m := Module{
st: StylesFor(makeTheme(true)),
prev: prev,
screen: ScreenCoverPicker,
covers: []CoverOption{{Path: img, Label: "local", Width: 300, Height: 300}},
width: 120,
height: 40,
}
return m, img
}
func writeSolidPNG(t *testing.T, path string, n int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, n, n))
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
img.Set(x, y, color.RGBA{0x20, 0x40, 0x80, 0xff})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
// Opening a second album must not inherit the first one's cover art. It used to:
// the cover, the candidate list and the "already fetched" flag all survived, so
// the next album skipped its own lookup and offered the previous album's art.
func TestSecondAlbumInheritsNoCoverState(t *testing.T) {
m := testModule(100, 30)
next, _ := m.Update(planMsg{album: scan.Album{Name: "first"}, plan: readyPlan()})
m = next.(Module)
next, _ = m.Update(coversMsg{covers: []CoverOption{
{Path: "/covers/first.jpg", Label: "folder", Width: 2000, Height: 2000},
}})
m = next.(Module)
m.coverCursor = 0
next, _ = m.Update(key("enter"))
m = next.(Module)
if !m.hasCover {
t.Fatal("first album should have a cover selected")
}
next, _ = m.Update(planMsg{album: scan.Album{Name: "second"}, plan: readyPlan()})
m = next.(Module)
if m.hasCover {
t.Errorf("second album inherited a cover: %+v", m.cover)
}
if len(m.covers) != 0 {
t.Errorf("second album inherited %d candidate(s)", len(m.covers))
}
if m.coversFetched {
t.Error("second album thinks its covers were already fetched, so it will never look")
}
if m.coverCursor != 0 {
t.Errorf("coverCursor = %d, want 0", m.coverCursor)
}
if m.placed != nil {
t.Error("second album inherited a placed image")
}
}
// An album is minutes of copying and encoding. The log used to render from the
// top, so the lines that mattered -- the ones that just arrived -- were the ones
// pushed off the bottom.
func TestWorkingScreenFollowsTheNewestLine(t *testing.T) {
m := testModule(80, 12)
m.screen = ScreenWorking
m.current = scan.Album{Name: "an-album"}
m.follow = true
for i := 0; i < 100; i++ {
next, _ := m.Update(progressMsg(fmt.Sprintf("line %d", i)))
m = next.(Module)
}
body := m.render()
if !strings.Contains(body, "line 99") {
t.Error("the newest line is not on screen")
}
if strings.Contains(body, "line 0\n") {
t.Error("the oldest line is still on screen; the view is not following")
}
}
// Scrolling up has to stop the follow or the next line yanks the view back down,
// and scrolling down again resumes it.
func TestWorkingScreenScrollingStopsFollowing(t *testing.T) {
m := testModule(80, 12)
m.screen = ScreenWorking
m.current = scan.Album{Name: "an-album"}
m.follow = true
next, _ := m.Update(progressMsg("line 0"))
m = next.(Module)
next, _ = m.Update(key("up"))
m = next.(Module)
if m.follow {
t.Error("scrolling up should stop the follow")
}
next, _ = m.Update(key("down"))
m = next.(Module)
if !m.follow {
t.Error("scrolling down should resume the follow")
}
}