Stabilize visible item snapshots (T-609)

This commit is contained in:
ila
2026-07-17 17:31:46 +08:00
parent ed9ded2110
commit 171572973b
9 changed files with 180 additions and 18 deletions
+7 -3
View File
@@ -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 {
+148
View File
@@ -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] == &current[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()