Stabilize visible item snapshots (T-609)
This commit is contained in:
@@ -109,7 +109,10 @@ func (model *CatalogListModel) ResetFilters() {
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// VisibleItems returns the current immutable-by-convention snapshot.
|
||||
// VisibleItems returns the current read-only snapshot generation without copying.
|
||||
// The snapshot remains stable after later model changes. Callers must not modify
|
||||
// its elements, nested Tags, or capacity; CatalogListModel is single-owner and
|
||||
// does not support concurrent reads and writes.
|
||||
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
|
||||
return model.visible
|
||||
}
|
||||
@@ -162,7 +165,7 @@ func (view CatalogView) Valid() bool {
|
||||
}
|
||||
|
||||
func (model *CatalogListModel) refilter() {
|
||||
model.visible = model.visible[:0]
|
||||
visible := make([]CatalogListItem, 0, len(model.items))
|
||||
for _, item := range model.items {
|
||||
if model.category != "" && item.Category != model.category {
|
||||
continue
|
||||
@@ -173,8 +176,9 @@ func (model *CatalogListModel) refilter() {
|
||||
if model.query != "" && !matchesQuery(item, model.query) {
|
||||
continue
|
||||
}
|
||||
model.visible = append(model.visible, item)
|
||||
visible = append(visible, item)
|
||||
}
|
||||
model.visible = visible
|
||||
}
|
||||
|
||||
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var visibleSnapshotSink []CatalogListItem
|
||||
|
||||
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{
|
||||
@@ -98,6 +101,151 @@ func TestCatalogListModelCategoriesAndReset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(*CatalogListModel)
|
||||
mutate func(*CatalogListModel)
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
mutate: func(model *CatalogListModel) { model.SetQuery("image") },
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
mutate: func(model *CatalogListModel) { model.SetCategory("图像") },
|
||||
},
|
||||
{
|
||||
name: "view",
|
||||
mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) },
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
prepare: func(model *CatalogListModel) {
|
||||
model.SetQuery("image")
|
||||
},
|
||||
mutate: func(model *CatalogListModel) { model.ResetFilters() },
|
||||
},
|
||||
{
|
||||
name: "items",
|
||||
mutate: func(model *CatalogListModel) {
|
||||
model.SetItems([]CatalogListItem{
|
||||
{ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||
if test.prepare != nil {
|
||||
test.prepare(model)
|
||||
}
|
||||
previous := model.VisibleItems()
|
||||
if len(previous) == 0 {
|
||||
t.Fatal("test setup produced an empty previous generation")
|
||||
}
|
||||
wantPrevious := cloneSnapshotForTest(previous)
|
||||
|
||||
test.mutate(model)
|
||||
|
||||
if !reflect.DeepEqual(previous, wantPrevious) {
|
||||
t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious)
|
||||
}
|
||||
current := model.VisibleItems()
|
||||
if len(current) == 0 {
|
||||
t.Fatal("test mutation produced an empty current generation")
|
||||
}
|
||||
if &previous[0] == ¤t[0] {
|
||||
t.Fatal("current generation reused the previous backing array")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) {
|
||||
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||
first := model.VisibleItems()
|
||||
second := model.VisibleItems()
|
||||
if len(first) == 0 || len(second) == 0 {
|
||||
t.Fatal("test setup produced an empty generation")
|
||||
}
|
||||
if &first[0] != &second[0] {
|
||||
t.Fatal("repeated VisibleItems calls copied the current generation")
|
||||
}
|
||||
|
||||
model.SetQuery(" ")
|
||||
unchanged := model.VisibleItems()
|
||||
if &first[0] != &unchanged[0] {
|
||||
t.Fatal("no-op model update published a new generation")
|
||||
}
|
||||
if allocations := testing.AllocsPerRun(100, func() {
|
||||
visibleSnapshotSink = model.VisibleItems()
|
||||
}); allocations != 0 {
|
||||
t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations)
|
||||
}
|
||||
|
||||
model.SetQuery("image")
|
||||
changed := model.VisibleItems()
|
||||
if len(changed) == 0 {
|
||||
t.Fatal("changed generation is empty")
|
||||
}
|
||||
if &first[0] == &changed[0] {
|
||||
t.Fatal("actual model update did not publish a new generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) {
|
||||
model := NewCatalogListModel(nil)
|
||||
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||
t.Fatalf("empty catalog visible items = %#v", visible)
|
||||
}
|
||||
|
||||
model.SetItems(catalogSnapshotFixture())
|
||||
full := model.VisibleItems()
|
||||
wantFull := cloneSnapshotForTest(full)
|
||||
model.SetQuery("missing-app")
|
||||
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||
t.Fatalf("no-match visible items = %#v", visible)
|
||||
}
|
||||
if !reflect.DeepEqual(full, wantFull) {
|
||||
t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull)
|
||||
}
|
||||
|
||||
model.ResetFilters()
|
||||
assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer")
|
||||
if !reflect.DeepEqual(full, wantFull) {
|
||||
t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull)
|
||||
}
|
||||
}
|
||||
|
||||
func catalogSnapshotFixture() []CatalogListItem {
|
||||
return []CatalogListItem{
|
||||
{
|
||||
ID: "json-parser", Name: "JSON Parser", Category: "开发",
|
||||
Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "image-tool", Name: "Image Tool", Category: "图像",
|
||||
Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "log-viewer", Name: "Log Viewer", Category: "开发",
|
||||
Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSnapshotForTest(items []CatalogListItem) []CatalogListItem {
|
||||
cloned := make([]CatalogListItem, len(items))
|
||||
for index, item := range items {
|
||||
cloned[index] = item
|
||||
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||
t.Helper()
|
||||
visible := model.VisibleItems()
|
||||
|
||||
Reference in New Issue
Block a user