package gio import ( "fmt" "image" "testing" "gioui.org/io/input" "gioui.org/layout" "gioui.org/op" "gioui.org/unit" "softbox.local/core/application" "softbox.local/core/domain" ) var ( adapterContractViewport = image.Pt(1080, 720) adapterContractCompactViewport = image.Pt(1080, 420) ) const adapterContractEdition = "Modern" func TestAdapterContractInputEventsUpdateModel(t *testing.T) { shell := NewAppShell(adapterContractEdition, adapterContractItems()...) shell.search.SetText("APP-TWO") adapterContractLayout(shell, adapterContractViewport) if got := shell.model.Query(); got != "app-two" { t.Fatalf("query after editor update = %q, want app-two", got) } visible := shell.model.VisibleItems() if len(visible) != 1 || visible[0].ID != "app-two" { t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible)) } shell.search.SetText("") adapterContractLayout(shell, adapterContractViewport) shell.categoryControls["图像"].Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.Category(); got != "图像" { t.Fatalf("category after click = %q, want 图像", got) } shell.viewUpdates.Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.View(); got != application.CatalogViewUpdates { t.Fatalf("view after updates click = %q", got) } shell.viewInstalled.Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.View(); got != application.CatalogViewInstalled { t.Fatalf("view after installed click = %q", got) } shell.viewAll.Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.View(); got != application.CatalogViewAll { t.Fatalf("view after all click = %q", got) } shell.search.SetText("missing-app") nodes := adapterContractLayout(shell, adapterContractViewport) if !adapterContractHasSemantic(nodes, "没有匹配的软件") || !adapterContractHasSemantic(nodes, "显示全部软件") { t.Fatal("filtered empty state did not expose its recovery action") } shell.resetFilters.Click() adapterContractLayout(shell, adapterContractViewport) if shell.search.Text() != "" || shell.model.Query() != "" || shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll { t.Fatalf( "reset state = editor %q, query %q, category %q, view %q", shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(), ) } if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) { t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems())) } } func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) { items := adapterContractLargeCatalog(80) shell := NewAppShell(adapterContractEdition, items...) targetID := "app-037" targetControl := shell.rows[targetID] reordered := append([]application.CatalogListItem(nil), items...) for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 { reordered[left], reordered[right] = reordered[right], reordered[left] } shell.SetItems(reordered) if shell.rows[targetID] != targetControl { t.Fatal("row control was recreated after catalog reorder") } shell.search.SetText("app-") adapterContractLayout(shell, adapterContractViewport) shell.categoryControls["图像"].Click() adapterContractLayout(shell, adapterContractViewport) shell.viewInstalled.Click() adapterContractLayout(shell, adapterContractViewport) shell.appList.ScrollTo(12) adapterContractLayout(shell, adapterContractViewport) targetControl.open.Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.SelectedID(); got != targetID { t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID) } if !shell.detailRendered { t.Fatal("row click selected the model but did not render detail") } queryBefore := shell.model.Query() categoryBefore := shell.model.Category() viewBefore := shell.model.View() positionBefore := shell.appList.Position shell.closeDetail.Click() adapterContractLayout(shell, adapterContractViewport) if got := shell.model.SelectedID(); got != "" { t.Fatalf("selected ID after close = %q, want empty", got) } if shell.detailRendered { t.Fatal("detail remained rendered after close click") } if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore || shell.model.View() != viewBefore { t.Fatal("closing detail changed the active list filters") } positionAfter := shell.appList.Position if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset { t.Fatalf( "list position after close = first %d offset %d, want first %d offset %d", positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset, ) } } func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) { items := adapterContractLargeCatalog(500) shell := NewAppShell(adapterContractEdition, items...) retainedRow := shell.rows["app-001"] retainedCategory := shell.categoryControls["图像"] adapterContractLayout(shell, adapterContractCompactViewport) if shell.lastRendered <= 0 || shell.lastRendered >= len(items) { t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items)) } if count := shell.appList.Position.Count; count <= 0 || count >= len(items) { t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items)) } shell.categoryControls["图像"].Click() adapterContractLayout(shell, adapterContractCompactViewport) if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory { t.Fatal("filtering recreated stable app or category controls") } shell.SetItems([]application.CatalogListItem{items[3], items[1]}) if shell.rows["app-001"] != retainedRow { t.Fatal("retained app lost its row control after snapshot update") } if shell.categoryControls["图像"] != retainedCategory { t.Fatal("retained category lost its control after snapshot update") } if _, exists := shell.rows["app-000"]; exists { t.Fatal("removed app retained its row control") } if _, exists := shell.categoryControls["工具"]; exists { t.Fatal("removed category retained its control") } } func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) { emptyShell := NewAppShell(adapterContractEdition) emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport) if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") { t.Fatal("empty catalog did not render the catalog-loading state") } if adapterContractHasSemantic(emptyNodes, "显示全部软件") { t.Fatal("empty catalog rendered a filter recovery action") } filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...) filteredShell.search.SetText("missing-app") filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport) if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") { t.Fatal("filtered catalog did not render the no-matches state") } if !adapterContractHasSemantic(filteredNodes, "显示全部软件") { t.Fatal("filtered catalog did not render its recovery action") } filteredShell.resetFilters.Click() adapterContractLayout(filteredShell, adapterContractViewport) if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" { t.Fatal("filter recovery did not clear editor and model query") } if len(filteredShell.model.VisibleItems()) == 0 { t.Fatal("filter recovery did not restore catalog rows") } } func adapterContractItems() []application.CatalogListItem { return []application.CatalogListItem{ { ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具", Status: domain.StatusNotInstalled, Installable: true, }, { ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像", Status: domain.StatusInstalled, Installed: true, Installable: true, }, { ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像", Status: domain.StatusUpdateAvailable, Installed: true, Installable: true, }, } } func adapterContractLargeCatalog(count int) []application.CatalogListItem { items := make([]application.CatalogListItem, count) for index := range items { category := "工具" if index%2 == 1 { category = "图像" } items[index] = application.CatalogListItem{ ID: fmt.Sprintf("app-%03d", index), Name: fmt.Sprintf("App %03d", index), Version: "1.0.0", Category: category, Status: domain.StatusInstalled, Installed: true, Installable: true, } } return items } func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode { var operations op.Ops var router input.Router context := layout.Context{ Ops: &operations, Source: router.Source(), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Exact(size), } shell.Layout(context, NewTheme()) router.Frame(&operations) return router.AppendSemantics(nil) } func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool { for _, node := range nodes { if node.Desc.Label == want || node.Desc.Description == want || adapterContractHasSemantic(node.Children, want) { return true } } return false } func adapterContractIDs(items []application.CatalogListItem) []string { ids := make([]string, len(items)) for index, item := range items { ids[index] = item.ID } return ids }