Implement catalog startup diagnostics (T-616)

This commit is contained in:
ila
2026-07-19 22:22:46 +08:00
parent 06234e11bf
commit 2c561894fd
28 changed files with 715 additions and 23 deletions
@@ -0,0 +1,22 @@
package main
import (
"context"
"testing"
"softbox.local/core/application"
)
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
runtime := application.NewRuntime(1)
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
}))
if err := <-done; err != nil {
t.Fatalf("bootstrap error = %v", err)
}
event := <-runtime.Events()
if event.Type != application.EventCatalogRefreshed {
t.Fatalf("event type = %q", event.Type)
}
}
+24
View File
@@ -35,6 +35,10 @@ func main() {
}
func run() error {
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
}
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
platform := windows.New()
window := new(app.Window)
window.Option(
@@ -59,10 +63,18 @@ func run() error {
window.Invalidate,
)
}()
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
defer func() {
cancelEvents()
runtime.Close()
relay.Close()
if bootstrapErr := <-catalogDone; bootstrapErr != nil &&
!errors.Is(bootstrapErr, context.Canceled) &&
!errors.Is(bootstrapErr, application.ErrCatalogSourceUnconfigured) &&
!errors.Is(bootstrapErr, application.ErrRuntimeClosed) &&
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
log.Printf("%s catalog bootstrap failed", core.ProductName)
}
if pumpErr := <-pumpDone; pumpErr != nil &&
!errors.Is(pumpErr, context.Canceled) &&
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
@@ -85,3 +97,15 @@ func run() error {
}
}
}
func startCatalogBootstrap(
ctx context.Context,
runtime *application.Runtime,
loader application.CatalogSnapshotLoader,
) <-chan error {
done := make(chan error, 1)
go func() {
done <- application.NewCatalogBootstrap(loader, runtime).Run(ctx)
}()
return done
}
+2 -2
View File
@@ -174,8 +174,8 @@ func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
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 did not render the catalog-loading state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
+46
View File
@@ -0,0 +1,46 @@
package gio
import "softbox.local/core/application"
type catalogPresentationState string
const (
catalogStateLoading catalogPresentationState = "loading"
catalogStateReady catalogPresentationState = "ready"
catalogStateUnconfigured catalogPresentationState = "unconfigured"
catalogStateLoadFailed catalogPresentationState = "load_failed"
)
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
payload, handled, err := application.ParseCatalogEvent(event)
if err != nil || !handled {
return handled, err
}
switch payload.Type {
case application.EventCatalogRefreshed:
shell.SetItems(payload.Items)
case application.EventCatalogRejected:
switch payload.FailureCode {
case application.CatalogFailureSourceUnconfigured:
shell.catalogState = catalogStateUnconfigured
case application.CatalogFailureLoadFailed:
shell.catalogState = catalogStateLoadFailed
}
}
return true, nil
}
func (shell *AppShell) catalogStatusText() string {
switch shell.catalogState {
case catalogStateLoading:
return "目录状态:正在加载已验证 Catalog"
case catalogStateUnconfigured:
return "目录状态:Catalog 来源尚未配置"
case catalogStateLoadFailed:
return "目录状态:Catalog 加载失败"
case catalogStateReady:
return "目录状态:已加载已验证 Catalog"
default:
return "目录状态:未知"
}
}
+72
View File
@@ -0,0 +1,72 @@
package gio
import (
"errors"
"testing"
"softbox.local/core/application"
)
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
shell := NewAppShell("Legacy")
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
}); err != nil {
t.Fatalf("ApplyEvent(refresh) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(reject) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
}
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
shell := NewAppShell("Legacy")
if shell.catalogState != catalogStateLoading {
t.Fatalf("initial state = %q, want loading", shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
}); err != nil {
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
}
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
t.Fatal("unconfigured state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(load failed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
t.Fatal("load-failed state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
}); err != nil {
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
t.Fatal("loaded-empty state was not visible")
}
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
if !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("bad payload error = %v", err)
}
}
+3
View File
@@ -56,6 +56,9 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
// ApplyEvent validates and applies an application event on the UI goroutine.
func (shell *AppShell) ApplyEvent(event application.Event) error {
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
return err
}
iconEvent, handled, err := application.ParseIconEvent(event)
if err != nil || !handled {
return err
+2 -2
View File
@@ -213,8 +213,8 @@ func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
+6 -1
View File
@@ -34,6 +34,7 @@ type AppShell struct {
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]iconFailureState
catalogState catalogPresentationState
lastRendered int
detailRendered bool
}
@@ -58,6 +59,9 @@ func NewAppShell(
}
shell.search.SingleLine = true
shell.SetItems(items)
if len(items) == 0 {
shell.catalogState = catalogStateLoading
}
return shell
}
@@ -76,6 +80,7 @@ func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
shell.catalogState = catalogStateReady
nextRows := make(map[string]*rowControls, len(items))
nextIcons := make(map[string]paint.ImageOp, len(items))
@@ -146,7 +151,7 @@ func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
shell.edition+" · Legacy · Windows 7 SP1 x64",
shell.catalogStatusText()+" · "+shell.edition+" · Legacy · Windows 7 SP1 x64",
)
label.Color = shellColors.secondary
return label.Layout(gtx)
+14
View File
@@ -270,6 +270,20 @@ func (shell *AppShell) layoutEmptyState(
if shell.model.TotalCount() == 0 {
title = "软件目录尚未加载"
body = "联网刷新或读取已验证缓存后会显示软件。"
switch shell.catalogState {
case catalogStateLoading:
title = "正在加载软件目录"
body = "正在等待已验证 Catalog 快照。"
case catalogStateUnconfigured:
title = "Catalog 来源尚未配置"
body = "此构建未装配可信发布配置,因此未显示任何软件。"
case catalogStateLoadFailed:
title = "Catalog 加载失败"
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
case catalogStateReady:
title = "Catalog 暂无可显示软件"
body = "已验证 Catalog 没有适用于当前目标的软件。"
}
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
gtx,