mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
The model<->view index conversions were derived from arrays that only renderLines populated. That made them depend on the list having been rendered (so a conversion before the first render ignored the non-model items), and it made them go stale whenever the model changed after a render: converting an index then returned a wrong result, and once the model had grown past the last rendered length the conversion indexed a too-short array and panicked (seen in cherry_pick under -race). The conversion is a pure function of the current list length and the current non-model items, and needs none of the rendered display strings. Compute it directly and drop the cached arrays, so the result is always consistent with the current model and no longer depends on rendering. searchModelCommits converts every commit's index, and building the non-model items can be O(len) mid-rebase, so it would now be quadratic; snapshot the non-model items once via modelToViewIndexConverter instead of rebuilding them per index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
291 lines
7.7 KiB
Go
291 lines
7.7 KiB
Go
package context
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/samber/lo"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// wrapping string in my own type to give it an ID method which is required for list items
|
|
type mystring string
|
|
|
|
func (self mystring) ID() string {
|
|
return string(self)
|
|
}
|
|
|
|
func TestListRenderer_renderLines(t *testing.T) {
|
|
scenarios := []struct {
|
|
name string
|
|
modelStrings []mystring
|
|
nonModelIndices []int
|
|
startIdx int
|
|
endIdx int
|
|
expectedOutput string
|
|
}{
|
|
{
|
|
name: "Render whole list",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
startIdx: 0,
|
|
endIdx: 3,
|
|
expectedOutput: `
|
|
a
|
|
b
|
|
c`,
|
|
},
|
|
{
|
|
name: "Partial list, beginning",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
startIdx: 0,
|
|
endIdx: 2,
|
|
expectedOutput: `
|
|
a
|
|
b`,
|
|
},
|
|
{
|
|
name: "Partial list, end",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
startIdx: 1,
|
|
endIdx: 3,
|
|
expectedOutput: `
|
|
b
|
|
c`,
|
|
},
|
|
{
|
|
name: "Pass an endIdx greater than the model length",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
startIdx: 2,
|
|
endIdx: 5,
|
|
expectedOutput: `
|
|
c`,
|
|
},
|
|
{
|
|
name: "Whole list with section headers",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
nonModelIndices: []int{1, 3},
|
|
startIdx: 0,
|
|
endIdx: 5,
|
|
expectedOutput: `
|
|
a
|
|
--- 1 (0) ---
|
|
b
|
|
c
|
|
--- 3 (1) ---`,
|
|
},
|
|
{
|
|
name: "Multiple consecutive headers",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
nonModelIndices: []int{0, 0, 2, 2, 2},
|
|
startIdx: 0,
|
|
endIdx: 8,
|
|
expectedOutput: `
|
|
--- 0 (0) ---
|
|
--- 0 (1) ---
|
|
a
|
|
b
|
|
--- 2 (2) ---
|
|
--- 2 (3) ---
|
|
--- 2 (4) ---
|
|
c`,
|
|
},
|
|
{
|
|
name: "Partial list with headers, beginning",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
nonModelIndices: []int{1, 3},
|
|
startIdx: 0,
|
|
endIdx: 3,
|
|
expectedOutput: `
|
|
a
|
|
--- 1 (0) ---
|
|
b`,
|
|
},
|
|
{
|
|
name: "Partial list with headers, end (beyond end index)",
|
|
modelStrings: []mystring{"a", "b", "c"},
|
|
nonModelIndices: []int{1, 3},
|
|
startIdx: 2,
|
|
endIdx: 7,
|
|
expectedOutput: `
|
|
b
|
|
c
|
|
--- 3 (1) ---`,
|
|
},
|
|
}
|
|
for _, s := range scenarios {
|
|
t.Run(s.name, func(t *testing.T) {
|
|
viewModel := NewListViewModel(func() []mystring { return s.modelStrings })
|
|
var getNonModelItems func() []*NonModelItem
|
|
if s.nonModelIndices != nil {
|
|
getNonModelItems = func() []*NonModelItem {
|
|
return lo.Map(s.nonModelIndices, func(modelIndex int, nonModelIndex int) *NonModelItem {
|
|
return &NonModelItem{
|
|
Index: modelIndex,
|
|
Content: fmt.Sprintf("--- %d (%d) ---", modelIndex, nonModelIndex),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
self := &ListRenderer{
|
|
list: viewModel,
|
|
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
|
return lo.Map(s.modelStrings[startIdx:endIdx],
|
|
func(s mystring, _ int) []string { return []string{string(s)} })
|
|
},
|
|
getNonModelItems: getNonModelItems,
|
|
}
|
|
|
|
expectedOutput := strings.Join(lo.Map(
|
|
strings.Split(strings.TrimPrefix(s.expectedOutput, "\n"), "\n"),
|
|
func(line string, _ int) string { return strings.TrimSpace(line) }), "\n")
|
|
|
|
assert.Equal(t, expectedOutput, self.renderLines(s.startIdx, s.endIdx))
|
|
})
|
|
}
|
|
}
|
|
|
|
type myint int
|
|
|
|
func (self myint) ID() string {
|
|
return fmt.Sprint(int(self))
|
|
}
|
|
|
|
func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
|
|
scenarios := []struct {
|
|
name string
|
|
numModelItems int
|
|
nonModelIndices []int
|
|
|
|
modelIndices []int
|
|
expectedViewIndices []int
|
|
|
|
viewIndices []int
|
|
expectedModelIndices []int
|
|
}{
|
|
{
|
|
name: "no headers (no getNonModelItems provided)",
|
|
numModelItems: 3,
|
|
nonModelIndices: nil, // no get
|
|
|
|
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
|
|
|
|
viewIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
|
|
},
|
|
{
|
|
name: "no headers (getNonModelItems returns zero items)",
|
|
numModelItems: 3,
|
|
nonModelIndices: []int{},
|
|
|
|
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
|
|
|
|
viewIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
|
|
},
|
|
{
|
|
name: "basic",
|
|
numModelItems: 3,
|
|
nonModelIndices: []int{1, 2},
|
|
|
|
/*
|
|
0: model 0
|
|
1: --- header 0 ---
|
|
2: model 1
|
|
3: --- header 1 ---
|
|
4: model 2
|
|
*/
|
|
|
|
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedViewIndices: []int{0, 0, 2, 4, 5, 5},
|
|
|
|
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6},
|
|
expectedModelIndices: []int{0, 0, 1, 1, 2, 2, 3, 3},
|
|
},
|
|
{
|
|
name: "consecutive section headers",
|
|
numModelItems: 3,
|
|
nonModelIndices: []int{0, 0, 2, 2, 2, 3, 3},
|
|
|
|
/*
|
|
0: --- header 0 ---
|
|
1: --- header 1 ---
|
|
2: model 0
|
|
3: model 1
|
|
4: --- header 2 ---
|
|
5: --- header 3 ---
|
|
6: --- header 4 ---
|
|
7: model 2
|
|
8: --- header 5 ---
|
|
9: --- header 6 ---
|
|
*/
|
|
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
|
expectedViewIndices: []int{2, 2, 3, 7, 10, 10},
|
|
|
|
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
|
|
expectedModelIndices: []int{0, 0, 0, 0, 1, 2, 2, 2, 2, 3, 3, 3, 3},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
t.Run(s.name, func(t *testing.T) {
|
|
// Expect lists of equal length for each test:
|
|
assert.Equal(t, len(s.modelIndices), len(s.expectedViewIndices))
|
|
assert.Equal(t, len(s.viewIndices), len(s.expectedModelIndices))
|
|
|
|
modelInts := lo.Map(lo.Range(s.numModelItems), func(i int, _ int) myint { return myint(i) })
|
|
viewModel := NewListViewModel(func() []myint { return modelInts })
|
|
var getNonModelItems func() []*NonModelItem
|
|
if s.nonModelIndices != nil {
|
|
getNonModelItems = func() []*NonModelItem {
|
|
return lo.Map(s.nonModelIndices, func(modelIndex int, _ int) *NonModelItem {
|
|
return &NonModelItem{Index: modelIndex, Content: ""}
|
|
})
|
|
}
|
|
}
|
|
self := &ListRenderer{
|
|
list: viewModel,
|
|
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
|
return lo.Map(modelInts[startIdx:endIdx],
|
|
func(i myint, _ int) []string { return []string{fmt.Sprint(i)} })
|
|
},
|
|
getNonModelItems: getNonModelItems,
|
|
}
|
|
|
|
for i := range len(s.modelIndices) {
|
|
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
|
|
}
|
|
|
|
for i := range len(s.viewIndices) {
|
|
assert.Equal(t, s.expectedModelIndices[i], self.ViewIndexToModelIndex(s.viewIndices[i]))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The index conversions must not depend on the list having been rendered
|
|
// first. It used to be renderLines that populated the conversion arrays, so
|
|
// converting an index before the first render silently ignored the non-model
|
|
// items (and converting after the model changed used a stale snapshot).
|
|
func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) {
|
|
modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) })
|
|
self := &ListRenderer{
|
|
list: NewListViewModel(func() []myint { return modelInts }),
|
|
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
|
return lo.Map(modelInts[startIdx:endIdx],
|
|
func(i myint, _ int) []string { return []string{fmt.Sprint(i)} })
|
|
},
|
|
// A section header sits at model index 1, so model item 1 is pushed down
|
|
// to view index 2, and view index 2 maps back to model item 1.
|
|
getNonModelItems: func() []*NonModelItem {
|
|
return []*NonModelItem{{Index: 1, Content: "--- header ---"}}
|
|
},
|
|
}
|
|
|
|
// Deliberately convert without rendering first.
|
|
assert.Equal(t, 2, self.ModelIndexToViewIndex(1))
|
|
assert.Equal(t, 1, self.ViewIndexToModelIndex(2))
|
|
}
|