Vectrum
Sign In
175 lines · 6.3 KB · Go
package tui

import (
	"strings"
	"testing"
)

// The lines a selection is made over are the transcript's own rendered rows,
// padded to the width by the viewport, which is why trailing space is never
// part of what a person meant to take.
var selLines = []string{
	"the quick brown fox      ",
	"jumps over                ",
	"the lazy dog             ",
}

func TestSelectedTextTakesExactlyTheMarkedCells(t *testing.T) {
	cases := []struct {
		name     string
		from, to cell
		want     string
	}{
		{"a word inside one line", cell{0, 4}, cell{0, 9}, "quick"},
		{"to the end of a line drops the padding", cell{0, 16}, cell{0, 25}, "fox"},
		{"across three lines keeps the middle whole", cell{0, 16}, cell{2, 8},
			"fox\njumps over\nthe lazy"},
		{"a single cell", cell{2, 4}, cell{2, 5}, "l"},
		{"an empty span takes nothing", cell{1, 3}, cell{1, 3}, ""},
	}
	for _, tc := range cases {
		if got := selectedText(selLines, tc.from, tc.to); got != tc.want {
			t.Errorf("%s: selectedText = %q, want %q", tc.name, got, tc.want)
		}
	}
}

// What reaches the clipboard is text, not the escape sequences that coloured
// it on screen.
func TestSelectedTextCarriesNoStyling(t *testing.T) {
	forceColour(t)

	lines := []string{styCoral.Render("ReadFile") + styDim.Render("(path=x)")}
	got := selectedText(lines, cell{0, 0}, cell{0, 16})
	if got != "ReadFile(path=x)" {
		t.Fatalf("selectedText = %q, want %q", got, "ReadFile(path=x)")
	}
	if strings.ContainsRune(got, 0x1b) {
		t.Errorf("copied text still carries escape sequences: %q", got)
	}
}

// A drag runs in whichever direction the hand moves, and the cell the drag
// started on belongs to the selection either way.
func TestSpanNormalisesTheDirectionOfADrag(t *testing.T) {
	forward := selection{anchor: cell{0, 4}, cursor: cell{1, 2}, on: true}
	from, to, ok := forward.span(selLines)
	if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
		t.Errorf("dragging down: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
	}

	backward := selection{anchor: cell{1, 2}, cursor: cell{0, 4}, on: true}
	from, to, ok = backward.span(selLines)
	if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
		t.Errorf("dragging up: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
	}

	var idle selection
	if _, _, ok := idle.span(selLines); ok {
		t.Error("a selection nobody made reports a span")
	}
}

func TestSpanExpandsToWordsAndLines(t *testing.T) {
	word := selection{anchor: cell{0, 6}, cursor: cell{0, 6}, mode: selWord, on: true}
	from, to, _ := word.span(selLines)
	if got := selectedText(selLines, from, to); got != "quick" {
		t.Errorf("double-click inside a word took %q, want %q", got, "quick")
	}

	line := selection{anchor: cell{1, 3}, cursor: cell{1, 3}, mode: selLine, on: true}
	from, to, _ = line.span(selLines)
	if got := selectedText(selLines, from, to); got != "jumps over" {
		t.Errorf("triple-click took %q, want %q", got, "jumps over")
	}
}

// Double-clicking a path is how a person picks one out of a wall of tool
// output, so the run of characters a path is made of counts as one word.
func TestWordSelectionKeepsAPathWhole(t *testing.T) {
	lines := []string{"edited internal/tui/model.go:222 for you"}
	sel := selection{anchor: cell{0, 12}, cursor: cell{0, 12}, mode: selWord, on: true}
	from, to, _ := sel.span(lines)
	if got := selectedText(lines, from, to); got != "internal/tui/model.go:222" {
		t.Errorf("double-click on a path took %q, want the whole path", got)
	}
}

// A press alone is a click: it puts the caret somewhere and clears whatever
// was selected. Only movement with the button down makes a selection.
func TestAClickWithoutMovementSelectsNothing(t *testing.T) {
	var s selection
	s.press(cell{0, 4}, false)
	if s.on {
		t.Error("a press with no movement already counts as a selection")
	}
	s.to(cell{0, 9})
	if !s.on {
		t.Error("moving with the button down did not start a selection")
	}
}

// A column is a display cell, not a rune: two of them go by for every wide
// character to the left of the pointer.
func TestWordSelectionCountsCellsNotRunes(t *testing.T) {
	lines := []string{"日本語 fox"}
	sel := selection{anchor: cell{0, 7}, cursor: cell{0, 7}, mode: selWord, on: true}
	from, to, _ := sel.span(lines)
	if got := selectedText(lines, from, to); got != "fox" {
		t.Errorf("double-click past a wide rune took %q, want %q", got, "fox")
	}
}

func TestPaintMarksExactlyTheSelectedCells(t *testing.T) {
	forceColour(t)

	view := strings.Join(selLines, "\n")
	rows := strings.Split(paintSelection(view, 0, cell{0, 4}, cell{0, 9}), "\n")
	if got := backedCells(rows[0]); got != "quick" {
		t.Errorf("the highlight covers %q, want %q", got, "quick")
	}
	for _, row := range rows[1:] {
		if got := backedCells(row); got != "" {
			t.Errorf("a row outside the selection was painted: %q", got)
		}
	}
}

// The viewport hands over the rows it is showing, so which content line a row
// holds depends on how far the transcript has been scrolled.
func TestPaintFollowsTheScrollOffset(t *testing.T) {
	forceColour(t)

	view := strings.Join(selLines, "\n")
	rows := strings.Split(paintSelection(view, 5, cell{6, 0}, cell{6, 5}), "\n")
	if got := backedCells(rows[1]); got != "jumps" {
		t.Errorf("row 1 with the view scrolled to line 5 shows %q, want %q", got, "jumps")
	}
	if got := backedCells(rows[0]) + backedCells(rows[2]); got != "" {
		t.Errorf("the wrong rows were painted: %q", got)
	}
}

// The text around a selection keeps the colour it had. The marked text does
// not: a style nested inside a background ends with a reset, which would drop
// the background for the rest of the span and tear a hole in it.
func TestPaintLeavesTheSurroundingColoursAloneAndHasNoHoles(t *testing.T) {
	forceColour(t)

	view := styToolName.Render("ReadFile") + styToolArgs.Render("(path=x)")
	got := paintSelection(view, 0, cell{0, 0}, cell{0, 8})

	if marked := backedCells(got); marked != "ReadFile" {
		t.Errorf("the highlight covers %q, want %q", marked, "ReadFile")
	}
	if rest := unbackedCells(got); rest != "(path=x)" {
		t.Errorf("outside the selection reads %q, want %q", rest, "(path=x)")
	}

	// Selecting the whole of a line built from several styles must come back
	// as one unbroken run of marked cells.
	whole := paintSelection(view, 0, cell{0, 0}, cell{0, 16})
	if marked := backedCells(whole); marked != "ReadFile(path=x)" {
		t.Errorf("a fully selected line is torn: marked %q", marked)
	}
}