Add adapter interaction contracts (T-608)
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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(1024, 680)
|
||||
adapterContractCompactViewport = image.Pt(1024, 380)
|
||||
)
|
||||
|
||||
const adapterContractEdition = "Legacy"
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user