git/intake/main
internal/tui/view_test.go
282 lines8 KB last change raw · history
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")
	}
}