mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
758 lines
23 KiB
Go
758 lines
23 KiB
Go
package tasks
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os/exec"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func getCounter() (func(), func() int) {
|
|
counter := 0
|
|
return func() { counter++ }, func() int { return counter }
|
|
}
|
|
|
|
func TestNewCmdTaskInstantStop(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
beforeStart, getBeforeStartCallCount := getCounter()
|
|
refreshView, getRefreshViewCallCount := getCounter()
|
|
onEndOfInput, getOnEndOfInputCallCount := getCounter()
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
beginRender, getBeginRenderCallCount := getCounter()
|
|
swapInRender, getSwapInRenderCallCount := getCounter()
|
|
onDone, getOnDoneCallCount := getCounter()
|
|
task := gocui.NewFakeTask()
|
|
newTask := func() gocui.Task {
|
|
return task
|
|
}
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
beforeStart,
|
|
refreshView,
|
|
onEndOfInput,
|
|
resetOrigin,
|
|
beginRender,
|
|
swapInRender,
|
|
newTask,
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
stop := make(chan struct{})
|
|
reader := bytes.NewBufferString("test")
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
cmd := exec.Command("blah")
|
|
|
|
close(stop)
|
|
|
|
return ExecCmd{Cmd: cmd}, reader
|
|
}
|
|
|
|
fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone)
|
|
|
|
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
|
|
|
|
callCountExpectations := []struct {
|
|
expected int
|
|
actual int
|
|
name string
|
|
}{
|
|
{0, getBeforeStartCallCount(), "beforeStart"},
|
|
{1, getRefreshViewCallCount(), "refreshView"},
|
|
{0, getOnEndOfInputCallCount(), "onEndOfInput"},
|
|
{0, getResetOriginCallCount(), "resetOrigin"},
|
|
{0, getBeginRenderCallCount(), "beginRender"},
|
|
{0, getSwapInRenderCallCount(), "swapInRender"},
|
|
{1, getOnDoneCallCount(), "onDone"},
|
|
}
|
|
for _, expectation := range callCountExpectations {
|
|
if expectation.actual != expectation.expected {
|
|
t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual)
|
|
}
|
|
}
|
|
|
|
if task.Status() != gocui.TaskStatusDone {
|
|
t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus())
|
|
}
|
|
|
|
expectedContent := ""
|
|
actualContent := writer.String()
|
|
if actualContent != expectedContent {
|
|
t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
|
|
}
|
|
}
|
|
|
|
func TestNewCmdTask(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
beforeStart, getBeforeStartCallCount := getCounter()
|
|
refreshView, getRefreshViewCallCount := getCounter()
|
|
onEndOfInput, getOnEndOfInputCallCount := getCounter()
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
beginRender, getBeginRenderCallCount := getCounter()
|
|
swapInRender, getSwapInRenderCallCount := getCounter()
|
|
onDone, getOnDoneCallCount := getCounter()
|
|
task := gocui.NewFakeTask()
|
|
newTask := func() gocui.Task {
|
|
return task
|
|
}
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
beforeStart,
|
|
refreshView,
|
|
onEndOfInput,
|
|
resetOrigin,
|
|
beginRender,
|
|
swapInRender,
|
|
newTask,
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
stop := make(chan struct{})
|
|
reader := bytes.NewBufferString("test")
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
cmd := exec.Command("blah")
|
|
|
|
return ExecCmd{Cmd: cmd}, reader
|
|
}
|
|
|
|
fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone)
|
|
wg := sync.WaitGroup{}
|
|
wg.Go(func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
close(stop)
|
|
})
|
|
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
|
|
|
|
wg.Wait()
|
|
|
|
callCountExpectations := []struct {
|
|
expected int
|
|
actual int
|
|
name string
|
|
}{
|
|
{0, getBeforeStartCallCount(), "beforeStart"},
|
|
{1, getRefreshViewCallCount(), "refreshView"},
|
|
{1, getOnEndOfInputCallCount(), "onEndOfInput"},
|
|
{0, getResetOriginCallCount(), "resetOrigin"},
|
|
{1, getBeginRenderCallCount(), "beginRender"},
|
|
{1, getSwapInRenderCallCount(), "swapInRender"},
|
|
{1, getOnDoneCallCount(), "onDone"},
|
|
}
|
|
for _, expectation := range callCountExpectations {
|
|
if expectation.actual != expectation.expected {
|
|
t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual)
|
|
}
|
|
}
|
|
|
|
if task.Status() != gocui.TaskStatusDone {
|
|
t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus())
|
|
}
|
|
|
|
expectedContent := "prefix\ntest\n"
|
|
actualContent := writer.String()
|
|
if actualContent != expectedContent {
|
|
t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
|
|
}
|
|
}
|
|
|
|
// A dummy reader that simply yields as many blank lines as requested. The only
|
|
// thing we want to do with the output is count the number of lines.
|
|
type BlankLineReader struct {
|
|
totalLinesToYield int
|
|
linesYielded int
|
|
}
|
|
|
|
func (d *BlankLineReader) Read(p []byte) (n int, err error) {
|
|
if d.totalLinesToYield == d.linesYielded {
|
|
return 0, io.EOF
|
|
}
|
|
|
|
d.linesYielded++
|
|
p[0] = '\n'
|
|
return 1, nil
|
|
}
|
|
|
|
// A dummy reader that yields the given number of blank lines and then blocks
|
|
// until unblock is closed, at which point it reports EOF. This lets a test hold
|
|
// a task in its "still loading" state for as long as it needs to.
|
|
type BlockingLineReader struct {
|
|
linesToYield int
|
|
linesYielded int
|
|
reachedEnd bool
|
|
blocked chan struct{}
|
|
unblock chan struct{}
|
|
}
|
|
|
|
func (d *BlockingLineReader) Read(p []byte) (n int, err error) {
|
|
if d.linesYielded == d.linesToYield {
|
|
if !d.reachedEnd {
|
|
d.reachedEnd = true
|
|
close(d.blocked)
|
|
}
|
|
<-d.unblock
|
|
return 0, io.EOF
|
|
}
|
|
|
|
d.linesYielded++
|
|
p[0] = '\n'
|
|
return 1, nil
|
|
}
|
|
|
|
func TestNewCmdTaskQueuedReadAtEndOfInput(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
task := gocui.NewFakeTask()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() gocui.Task { return task },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
reader := BlockingLineReader{
|
|
linesToYield: 5,
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &reader
|
|
}
|
|
|
|
// The initial request asks for far more lines than the reader has, so the
|
|
// task reaches EOF while that request is still the one being served.
|
|
fn := manager.NewCmdTask(start, "", LinesToRead{100, -1, nil}, func() {})
|
|
|
|
thenCalled := false
|
|
wg := sync.WaitGroup{}
|
|
wg.Go(func() {
|
|
_ = fn(TaskOpts{Stop: make(chan struct{}), InitialContentLoaded: func() { task.Done() }})
|
|
})
|
|
|
|
<-reader.blocked
|
|
manager.ReadToEnd(func() { thenCalled = true })
|
|
// ReadToEnd queues its request from a goroutine; wait for it to land so that
|
|
// it is definitely outstanding by the time we let the task reach EOF.
|
|
for len(*manager.readLines.Load()) == 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
close(reader.unblock)
|
|
|
|
wg.Wait()
|
|
|
|
assert.True(t, thenCalled)
|
|
}
|
|
|
|
// A task rendering content the view wasn't already showing resets the scroll
|
|
// position to the top, at its first paint. If it is stopped and replaced before
|
|
// it ever paints — a background refresh landing just after the user clicked a
|
|
// different item, say — the replacement renders the same content and so decides
|
|
// on no reset of its own; it has to perform the one the stopped task was owed,
|
|
// or the view keeps the scroll position of the content it showed before.
|
|
func TestResetOriginSurvivesTaskReplacement(t *testing.T) {
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
bytes.NewBuffer(nil),
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
resetOrigin,
|
|
func() {},
|
|
func() {},
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
startTask := func(key string, reader io.Reader, onDone func()) {
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, reader
|
|
}
|
|
// The first-paint point is far beyond what any of these readers yield, so
|
|
// only reaching EOF paints.
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
|
|
}
|
|
runTaskToCompletion := func(key string) {
|
|
done := make(chan struct{})
|
|
startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
}
|
|
|
|
// A render of content the view wasn't showing resets the scroll position.
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
// Different content again, but this task stalls before it can paint.
|
|
stalled := BlockingLineReader{
|
|
linesToYield: 3,
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask("cmd2", &stalled, nil)
|
|
<-stalled.blocked
|
|
|
|
// The replacement shows the same content as the stalled task, so it has no
|
|
// reset of its own to do — but it must still do that task's.
|
|
runTaskToCompletion("cmd2")
|
|
assert.Equal(t, 2, getResetOriginCallCount())
|
|
}
|
|
|
|
// A render that takes long enough to start takes the view over to say
|
|
// "loading...", which means blanking whatever it was showing. That is only worth
|
|
// doing when the content coming is different from what's on screen: re-rendering
|
|
// the same content (a background refresh, say) would otherwise blank the view and
|
|
// paint the same thing back, a visible flicker for nothing.
|
|
func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) {
|
|
var beforeStartCount atomic.Int32
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() { beforeStartCount.Add(1) },
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
startTask := func(key string, reader io.Reader, onDone func()) {
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, reader
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
|
|
}
|
|
// Starts a task whose command produces nothing at all, so that it is still
|
|
// waiting for its first line when the loading indicator falls due. Returns
|
|
// the reader so the caller can let it finish.
|
|
startStalledTask := func(key string) *BlockingLineReader {
|
|
reader := &BlockingLineReader{
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
startTask(key, reader, nil)
|
|
<-reader.blocked
|
|
return reader
|
|
}
|
|
|
|
// Get some content on screen first: the indicator is only due when a render
|
|
// is slow, and this one isn't.
|
|
done := make(chan struct{})
|
|
startTask("cmd1", &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
assert.EqualValues(t, 0, beforeStartCount.Load())
|
|
|
|
// A slow re-render of that same content must leave the view alone however
|
|
// long it takes. The indicator is due 200ms in, so give it well past that.
|
|
sameContent := startStalledTask("cmd1")
|
|
defer close(sameContent.unblock)
|
|
time.Sleep(500 * time.Millisecond)
|
|
assert.EqualValues(t, 0, beforeStartCount.Load())
|
|
|
|
// Different content, though, is worth taking the view over for.
|
|
newContent := startStalledTask("cmd2")
|
|
defer close(newContent.unblock)
|
|
assert.Eventually(t,
|
|
func() bool { return beforeStartCount.Load() == 1 },
|
|
2*time.Second, 10*time.Millisecond)
|
|
}
|
|
|
|
// A pending restore takes the first paint over: it says when enough of the new
|
|
// content has arrived to show the position it remembers, and does the swap itself so
|
|
// that it can look for that position while the previous content is still displayed.
|
|
// The scroll reset that new content is owed happens before it runs, so that where it
|
|
// puts the view is where the view stays.
|
|
func TestNewCmdTaskRestore(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
swapped := false
|
|
applyCount := 0
|
|
applyAtLines := -1
|
|
swappedBeforeApply := false
|
|
swappedByApply := false
|
|
resetsBeforeApply := -1
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {}, // beforeStart
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() { swapped = true }, // swapInRender
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
// Ready once five lines have loaded — well before the view is filled (30).
|
|
FirstPaintReady: func() bool { return linesWritten() >= 5 },
|
|
Apply: func(swapIn func()) {
|
|
applyCount++
|
|
applyAtLines = linesWritten()
|
|
swappedBeforeApply = swappedBeforeApply || swapped
|
|
resetsBeforeApply = getResetOriginCallCount()
|
|
swapIn()
|
|
swappedByApply = swapped
|
|
},
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
|
|
<-done
|
|
|
|
assert.Equal(t, 1, applyCount, "Apply should run exactly once")
|
|
assert.False(t, swappedBeforeApply, "the off-screen render should not be swapped in before Apply runs")
|
|
assert.True(t, swappedByApply, "Apply should swap the off-screen render in via swapIn")
|
|
// The first paint was driven by the restore, not by having read enough lines to
|
|
// fill the view.
|
|
assert.GreaterOrEqual(t, applyAtLines, 5)
|
|
assert.Less(t, applyAtLines, 30)
|
|
assert.Equal(t, 1, resetsBeforeApply, "new content should be put at the top before the restore places it")
|
|
assert.Equal(t, 1, getResetOriginCallCount(), "and not reset again afterwards, over the restore")
|
|
}
|
|
|
|
// A restore that never finds what it is looking for keeps the task reading to the
|
|
// end of its input, since the line might have been anywhere in it. Once there is no
|
|
// more content to hope for, the render is revealed with the scroll reset that new
|
|
// content is owed.
|
|
func TestNewCmdTaskRestoreThatFindsNothing(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
applyCount := 0
|
|
swappedAtLines := -1
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {}, // beforeStart
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() { swappedAtLines = linesWritten() },
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
FirstPaintReady: func() bool { return false },
|
|
Apply: func(swapIn func()) {
|
|
applyCount++
|
|
swapIn()
|
|
},
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
|
|
<-done
|
|
|
|
assert.Equal(t, 1, applyCount, "Apply should still run, to swap the render in")
|
|
assert.Equal(t, 50, swappedAtLines, "the whole input should be read before giving up on the restore")
|
|
assert.Equal(t, 1, getResetOriginCallCount(), "new content the restore couldn't place starts at the top")
|
|
}
|
|
|
|
// The task a restore was installed for can be stopped and replaced before it ever
|
|
// paints — a background refresh landing right after the key was pressed. The
|
|
// replacement renders the same content, so it is the one that owes the user their
|
|
// position.
|
|
func TestRestoreSurvivesTaskReplacement(t *testing.T) {
|
|
var applyCount atomic.Int32
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
FirstPaintReady: func() bool { return false },
|
|
Apply: func(swapIn func()) {
|
|
applyCount.Add(1)
|
|
swapIn()
|
|
},
|
|
})
|
|
|
|
startTask := func(reader io.Reader, onDone func()) {
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, reader
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), "cmd")
|
|
}
|
|
|
|
// The task the restore was installed for stalls before it can paint.
|
|
stalled := BlockingLineReader{
|
|
linesToYield: 3,
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask(&stalled, nil)
|
|
<-stalled.blocked
|
|
|
|
done := make(chan struct{})
|
|
startTask(&BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
|
|
assert.EqualValues(t, 1, applyCount.Load(), "the replacement should apply the restore the stopped task couldn't")
|
|
}
|
|
|
|
// A task told to keep the scroll position renders the content the view is showing
|
|
// under another command — the same diff with more context around it, say — so it
|
|
// neither resets the scroll nor blanks the view to say "loading...", both of which are
|
|
// for content the user hasn't seen.
|
|
func TestKeepScrollPositionForNextTask(t *testing.T) {
|
|
var beforeStartCount atomic.Int32
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() { beforeStartCount.Add(1) },
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() {}, // swapInRender
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
startTask := func(key string, reader io.Reader, onDone func()) {
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, reader
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
|
|
}
|
|
runTaskToCompletion := func(key string) {
|
|
done := make(chan struct{})
|
|
startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
}
|
|
|
|
// Content the view wasn't showing, to have something to keep the position in.
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
manager.SetKeepScrollPositionForNextTask()
|
|
runTaskToCompletion("cmd2")
|
|
assert.Equal(t, 1, getResetOriginCallCount(), "the same content under another command keeps its position")
|
|
|
|
// And the request rides one task only: the next different command is a different
|
|
// diff as far as anyone knows.
|
|
runTaskToCompletion("cmd3")
|
|
assert.Equal(t, 2, getResetOriginCallCount())
|
|
|
|
// The loading indicator goes by the same question, so it stays out of the way too.
|
|
manager.SetKeepScrollPositionForNextTask()
|
|
stalled := BlockingLineReader{
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask("cmd4", &stalled, nil)
|
|
<-stalled.blocked
|
|
time.Sleep(500 * time.Millisecond)
|
|
assert.EqualValues(t, 0, beforeStartCount.Load())
|
|
}
|
|
|
|
// A view that has been emptied is showing nothing, so the render it was showing is no
|
|
// longer the one to compare the next task against: running the same command again is
|
|
// putting content into the view that isn't there any more, and starts from the top.
|
|
func TestForgetRenderedContent(t *testing.T) {
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() {}, // beforeStart
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() {}, // swapInRender
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
runTaskToCompletion := func(key string) {
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 3}
|
|
}
|
|
done := make(chan struct{})
|
|
_ = manager.NewTask(
|
|
manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, func() { close(done) }), key)
|
|
<-done
|
|
}
|
|
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
// Rendering the same command's output again leaves the view where it is, that being
|
|
// what it already shows.
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
manager.ForgetRenderedContent()
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 2, getResetOriginCallCount(), "an emptied view is shown its content afresh")
|
|
}
|
|
|
|
func TestNewCmdTaskRefresh(t *testing.T) {
|
|
type scenario struct {
|
|
name string
|
|
totalTaskLines int
|
|
linesToRead LinesToRead
|
|
expectedLineCountsOnRefresh []int
|
|
}
|
|
|
|
scenarios := []scenario{
|
|
{
|
|
"total < initialRefreshAfter",
|
|
150,
|
|
LinesToRead{100, 120, nil},
|
|
[]int{100},
|
|
},
|
|
{
|
|
"total == initialRefreshAfter",
|
|
150,
|
|
LinesToRead{100, 100, nil},
|
|
[]int{100},
|
|
},
|
|
{
|
|
"total > initialRefreshAfter",
|
|
150,
|
|
LinesToRead{100, 50, nil},
|
|
[]int{50, 100},
|
|
},
|
|
{
|
|
"initialRefreshAfter == -1",
|
|
150,
|
|
LinesToRead{100, -1, nil},
|
|
[]int{100},
|
|
},
|
|
{
|
|
"totalTaskLines < initialRefreshAfter",
|
|
25,
|
|
LinesToRead{100, 50, nil},
|
|
[]int{25},
|
|
},
|
|
{
|
|
"totalTaskLines between total and initialRefreshAfter",
|
|
75,
|
|
LinesToRead{100, 50, nil},
|
|
[]int{50, 75},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
writer := bytes.NewBuffer(nil)
|
|
lineCountsOnRefresh := []int{}
|
|
refreshView := func() {
|
|
lineCountsOnRefresh = append(lineCountsOnRefresh, strings.Count(writer.String(), "\n"))
|
|
}
|
|
|
|
task := gocui.NewFakeTask()
|
|
newTask := func() gocui.Task {
|
|
return task
|
|
}
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {},
|
|
refreshView,
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
newTask,
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
stop := make(chan struct{})
|
|
reader := BlankLineReader{totalLinesToYield: s.totalTaskLines}
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
cmd := exec.Command("blah")
|
|
|
|
return ExecCmd{Cmd: cmd}, &reader
|
|
}
|
|
|
|
fn := manager.NewCmdTask(start, "", s.linesToRead, func() {})
|
|
wg := sync.WaitGroup{}
|
|
wg.Go(func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
close(stop)
|
|
})
|
|
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
|
|
|
|
wg.Wait()
|
|
|
|
if !reflect.DeepEqual(lineCountsOnRefresh, s.expectedLineCountsOnRefresh) {
|
|
t.Errorf("%s: expected line counts on refresh: %v, got %v",
|
|
s.name, s.expectedLineCountsOnRefresh, lineCountsOnRefresh)
|
|
}
|
|
}
|
|
}
|