Build virtualized software list (T-203)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
// CatalogView is one primary software-list scope.
|
||||
type CatalogView string
|
||||
|
||||
const (
|
||||
CatalogViewAll CatalogView = "all"
|
||||
CatalogViewInstalled CatalogView = "installed"
|
||||
CatalogViewUpdates CatalogView = "updates"
|
||||
)
|
||||
|
||||
// CatalogListItem is the IO-free data consumed by Gio list adapters.
|
||||
type CatalogListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version string
|
||||
Category string
|
||||
Tags []string
|
||||
Status domain.AppStatus
|
||||
Installed bool
|
||||
Installable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CatalogListModel owns source items and composable list filters.
|
||||
type CatalogListModel struct {
|
||||
items []CatalogListItem
|
||||
visible []CatalogListItem
|
||||
categories []string
|
||||
query string
|
||||
category string
|
||||
view CatalogView
|
||||
selectedID string
|
||||
}
|
||||
|
||||
// NewCatalogListModel copies items and initializes the all view.
|
||||
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
|
||||
model := &CatalogListModel{view: CatalogViewAll}
|
||||
model.SetItems(items)
|
||||
return model
|
||||
}
|
||||
|
||||
// SetItems replaces the source snapshot and recomputes categories/visibility.
|
||||
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
|
||||
model.items = cloneCatalogItems(items)
|
||||
model.categories = collectCategories(model.items)
|
||||
if model.category != "" && !containsString(model.categories, model.category) {
|
||||
model.category = ""
|
||||
}
|
||||
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
|
||||
model.selectedID = ""
|
||||
}
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetQuery applies a case-insensitive name/ID/tag search.
|
||||
func (model *CatalogListModel) SetQuery(query string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
if model.query == normalized {
|
||||
return
|
||||
}
|
||||
model.query = normalized
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetCategory selects one exact category. Empty means every category.
|
||||
func (model *CatalogListModel) SetCategory(category string) {
|
||||
if category != "" && !containsString(model.categories, category) {
|
||||
return
|
||||
}
|
||||
if model.category == category {
|
||||
return
|
||||
}
|
||||
model.category = category
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetView selects the all, installed or updates scope.
|
||||
func (model *CatalogListModel) SetView(view CatalogView) {
|
||||
if !view.Valid() || model.view == view {
|
||||
return
|
||||
}
|
||||
model.view = view
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// Select records a stable app ID for row interaction state.
|
||||
func (model *CatalogListModel) Select(appID string) {
|
||||
if appID == "" || containsItemID(model.items, appID) {
|
||||
model.selectedID = appID
|
||||
}
|
||||
}
|
||||
|
||||
// ResetFilters restores the full list while retaining source data.
|
||||
func (model *CatalogListModel) ResetFilters() {
|
||||
model.query = ""
|
||||
model.category = ""
|
||||
model.view = CatalogViewAll
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// VisibleItems returns the current immutable-by-convention snapshot.
|
||||
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
|
||||
return model.visible
|
||||
}
|
||||
|
||||
// Categories returns the stable first-seen category order.
|
||||
func (model *CatalogListModel) Categories() []string {
|
||||
return model.categories
|
||||
}
|
||||
|
||||
// TotalCount returns the unfiltered source count.
|
||||
func (model *CatalogListModel) TotalCount() int {
|
||||
return len(model.items)
|
||||
}
|
||||
|
||||
// Query returns the normalized active query.
|
||||
func (model *CatalogListModel) Query() string {
|
||||
return model.query
|
||||
}
|
||||
|
||||
// Category returns the active exact category, or empty for all.
|
||||
func (model *CatalogListModel) Category() string {
|
||||
return model.category
|
||||
}
|
||||
|
||||
// View returns the active primary scope.
|
||||
func (model *CatalogListModel) View() CatalogView {
|
||||
return model.view
|
||||
}
|
||||
|
||||
// SelectedID returns the selected stable software ID.
|
||||
func (model *CatalogListModel) SelectedID() string {
|
||||
return model.selectedID
|
||||
}
|
||||
|
||||
// Valid reports whether view is supported by the MVP list.
|
||||
func (view CatalogView) Valid() bool {
|
||||
return view == CatalogViewAll ||
|
||||
view == CatalogViewInstalled ||
|
||||
view == CatalogViewUpdates
|
||||
}
|
||||
|
||||
func (model *CatalogListModel) refilter() {
|
||||
model.visible = model.visible[:0]
|
||||
for _, item := range model.items {
|
||||
if model.category != "" && item.Category != model.category {
|
||||
continue
|
||||
}
|
||||
if !matchesView(item, model.view) {
|
||||
continue
|
||||
}
|
||||
if model.query != "" && !matchesQuery(item, model.query) {
|
||||
continue
|
||||
}
|
||||
model.visible = append(model.visible, item)
|
||||
}
|
||||
}
|
||||
|
||||
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||
switch view {
|
||||
case CatalogViewAll:
|
||||
return true
|
||||
case CatalogViewInstalled:
|
||||
return item.Installed
|
||||
case CatalogViewUpdates:
|
||||
return item.Status == domain.StatusUpdateAvailable
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchesQuery(item CatalogListItem, query string) bool {
|
||||
if strings.Contains(strings.ToLower(item.Name), query) ||
|
||||
strings.Contains(strings.ToLower(item.ID), query) {
|
||||
return true
|
||||
}
|
||||
for _, tag := range item.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectCategories(items []CatalogListItem) []string {
|
||||
seen := make(map[string]struct{})
|
||||
categories := make([]string, 0)
|
||||
for _, item := range items {
|
||||
if item.Category == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.Category]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.Category] = struct{}{}
|
||||
categories = append(categories, item.Category)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
func cloneCatalogItems(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 containsString(values []string, value string) bool {
|
||||
for _, candidate := range values {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsItemID(items []CatalogListItem, appID string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == appID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"JSON", "格式化"},
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "image-tool",
|
||||
Name: "Image Tool",
|
||||
Category: "图像",
|
||||
Tags: []string{"PNG", "压缩"},
|
||||
Status: domain.StatusUpdateAvailable,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "log-viewer",
|
||||
Name: "日志查看器",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"LOG", "诊断"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
},
|
||||
})
|
||||
|
||||
model.SetQuery(" png ")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("")
|
||||
model.SetCategory("开发工具")
|
||||
assertVisibleIDs(t, model, "json-parser", "log-viewer")
|
||||
|
||||
model.SetView(CatalogViewInstalled)
|
||||
assertVisibleIDs(t, model, "json-parser")
|
||||
|
||||
model.SetCategory("")
|
||||
model.SetView(CatalogViewUpdates)
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("IMAGE-")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
}
|
||||
|
||||
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||
items := []CatalogListItem{
|
||||
{ID: "app-b", Name: "B", Category: "工具"},
|
||||
{ID: "app-a", Name: "A", Category: "工具"},
|
||||
}
|
||||
model := NewCatalogListModel(items)
|
||||
model.Select("app-a")
|
||||
model.SetQuery("app")
|
||||
|
||||
assertVisibleIDs(t, model, "app-b", "app-a")
|
||||
if model.SelectedID() != "app-a" {
|
||||
t.Fatalf("SelectedID = %q", model.SelectedID())
|
||||
}
|
||||
|
||||
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
|
||||
if model.SelectedID() != "" {
|
||||
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{ID: "one", Category: "开发"},
|
||||
{ID: "two", Category: "图像"},
|
||||
{ID: "three", Category: "开发"},
|
||||
})
|
||||
categories := model.Categories()
|
||||
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
|
||||
t.Fatalf("Categories = %#v", categories)
|
||||
}
|
||||
|
||||
model.SetQuery("missing")
|
||||
model.SetCategory("开发")
|
||||
model.SetView(CatalogViewInstalled)
|
||||
model.ResetFilters()
|
||||
if model.Query() != "" ||
|
||||
model.Category() != "" ||
|
||||
model.View() != CatalogViewAll ||
|
||||
len(model.VisibleItems()) != 3 {
|
||||
t.Fatalf("model was not reset: %#v", model)
|
||||
}
|
||||
}
|
||||
|
||||
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||
t.Helper()
|
||||
visible := model.VisibleItems()
|
||||
if len(visible) != len(want) {
|
||||
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
|
||||
}
|
||||
for index, item := range visible {
|
||||
if item.ID != want[index] {
|
||||
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user