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") } }