Files
soft_quay/app-modern/ui/gio/adapter_contract_test.go
T
ila 1b7f72e658
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Add adapter interaction contracts (T-608)
2026-07-17 10:41:40 +08:00

271 lines
9.4 KiB
Go

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-unavailable 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
}