Route icon results through UI events (T-607)

This commit is contained in:
ila
2026-07-17 10:21:44 +08:00
parent fba672381e
commit 0945fe93dc
24 changed files with 2068 additions and 31 deletions
+179
View File
@@ -0,0 +1,179 @@
package catalog
import (
"context"
"errors"
"fmt"
"reflect"
"softbox.local/core/application"
)
var (
ErrIconDeliveryInvalid = errors.New("icon event delivery is invalid")
ErrIconEventPublish = errors.New("publish icon application event")
)
// IconLoader is the narrow cache contract used by background delivery.
type IconLoader interface {
Load(context.Context, IconRequest) (IconResult, error)
}
// IconLoaderFunc adapts a function to IconLoader.
type IconLoaderFunc func(context.Context, IconRequest) (IconResult, error)
func (function IconLoaderFunc) Load(
ctx context.Context,
request IconRequest,
) (IconResult, error) {
return function(ctx, request)
}
// IconEventPublisher queues application events for UI adapters.
type IconEventPublisher interface {
Publish(context.Context, application.Event) error
}
// IconEventPublisherFunc adapts a function to IconEventPublisher.
type IconEventPublisherFunc func(context.Context, application.Event) error
func (function IconEventPublisherFunc) Publish(
ctx context.Context,
event application.Event,
) error {
return function(ctx, event)
}
// IconEventDelivery loads and decodes an icon in a caller-owned background task.
// It never creates goroutines and never imports or mutates Gio state.
type IconEventDelivery struct {
Loader IconLoader
Publisher IconEventPublisher
}
// LoadAndPublish emits one ready or failed application event.
// Cancellation ends silently so an obsolete request cannot publish a stale failure.
func (delivery IconEventDelivery) LoadAndPublish(
ctx context.Context,
identity application.IconEventIdentity,
) error {
validated, err := application.NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, err)
}
if isNilIconDeliveryDependency(delivery.Loader) ||
isNilIconDeliveryDependency(delivery.Publisher) {
return fmt.Errorf(
"%w: loader and publisher are required",
ErrIconDeliveryInvalid,
)
}
if err := ctx.Err(); err != nil {
return err
}
result, loadErr := delivery.Loader.Load(ctx, IconRequest{
Reference: validated.Reference,
DPI: validated.DPI,
})
if loadErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if isIconDeliveryCancellation(loadErr) {
return loadErr
}
return delivery.publishFailure(ctx, validated, loadErr)
}
if err := ctx.Err(); err != nil {
return err
}
icon, decodeErr := DecodeIcon(result.Bytes)
if decodeErr != nil {
return delivery.publishFailure(ctx, validated, decodeErr)
}
if err := ctx.Err(); err != nil {
return err
}
ready, eventErr := application.NewIconReadyEvent(validated, icon)
if eventErr != nil {
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr)
}
if publishErr := delivery.Publisher.Publish(ctx, ready); publishErr != nil {
if isIconDeliveryCancellation(publishErr) {
return publishErr
}
return errors.Join(ErrIconEventPublish, publishErr)
}
return result.Warning
}
func (delivery IconEventDelivery) publishFailure(
ctx context.Context,
identity application.IconEventIdentity,
cause error,
) error {
if err := ctx.Err(); err != nil {
return err
}
failed, eventErr := application.NewIconFailedEvent(
identity,
classifyIconFailure(cause),
)
if eventErr != nil {
return errors.Join(
fmt.Errorf("load or decode icon: %w", cause),
fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr),
)
}
publishErr := delivery.Publisher.Publish(ctx, failed)
operationErr := fmt.Errorf("load or decode icon: %w", cause)
if publishErr != nil {
if isIconDeliveryCancellation(publishErr) {
return publishErr
}
return errors.Join(operationErr, ErrIconEventPublish, publishErr)
}
return operationErr
}
func classifyIconFailure(err error) application.IconFailureCode {
switch {
case errors.Is(err, ErrIconCacheUnsafe):
return application.IconFailureUnsafe
case errors.Is(err, ErrIconReferenceInvalid),
errors.Is(err, ErrIconDPIInvalid),
errors.Is(err, ErrIconHashMismatch),
errors.Is(err, ErrIconTooLarge),
errors.Is(err, ErrIconImageInvalid),
errors.Is(err, ErrIconResponseInvalid):
return application.IconFailureInvalid
default:
return application.IconFailureUnavailable
}
}
func isIconDeliveryCancellation(err error) bool {
return errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded)
}
func isNilIconDeliveryDependency(dependency any) bool {
if dependency == nil {
return true
}
value := reflect.ValueOf(dependency)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
+236
View File
@@ -0,0 +1,236 @@
package catalog
import (
"context"
"errors"
"strings"
"testing"
"softbox.local/core/application"
)
func TestIconEventDeliveryPublishesDecodedReadyEvent(t *testing.T) {
document := testPNG(t, 24, 24)
identity := iconEventIdentity(t, document, "request-ready")
var gotRequest IconRequest
var events []application.Event
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
_ context.Context,
request IconRequest,
) (IconResult, error) {
gotRequest = request
return IconResult{Bytes: document, Source: IconSourceMemory}, nil
}),
Publisher: IconEventPublisherFunc(func(
_ context.Context,
event application.Event,
) error {
events = append(events, event)
return nil
}),
}
if err := delivery.LoadAndPublish(context.Background(), identity); err != nil {
t.Fatalf("LoadAndPublish() error = %v", err)
}
if gotRequest.Reference != identity.Reference || gotRequest.DPI != identity.DPI {
t.Fatalf("loader request = %+v", gotRequest)
}
if len(events) != 1 {
t.Fatalf("published events = %d", len(events))
}
parsed, handled, err := application.ParseIconEvent(events[0])
if err != nil || !handled {
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != application.EventIconReady || parsed.Identity != identity {
t.Fatalf("ready event = %+v", parsed)
}
if bounds := parsed.Image.Bounds(); bounds.Dx() != 24 || bounds.Dy() != 24 {
t.Fatalf("decoded bounds = %v", bounds)
}
}
func TestIconEventDeliveryClassifiesFailuresWithoutRawErrorPayload(t *testing.T) {
document := testPNG(t, 8, 8)
identity := iconEventIdentity(t, document, "request-failed")
tests := []struct {
name string
loadErr error
bytes []byte
wantCode application.IconFailureCode
}{
{name: "unsafe", loadErr: ErrIconCacheUnsafe, wantCode: application.IconFailureUnsafe},
{name: "invalid", loadErr: ErrIconHashMismatch, wantCode: application.IconFailureInvalid},
{
name: "unavailable",
loadErr: errors.New("GET https://secret.invalid/icon?token=hidden failed"),
wantCode: application.IconFailureUnavailable,
},
{name: "decode", bytes: []byte("not an image"), wantCode: application.IconFailureInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var published application.Event
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: test.bytes}, test.loadErr
}),
Publisher: IconEventPublisherFunc(func(
_ context.Context,
event application.Event,
) error {
published = event
return nil
}),
}
err := delivery.LoadAndPublish(context.Background(), identity)
if err == nil {
t.Fatal("LoadAndPublish() unexpectedly succeeded")
}
parsed, handled, parseErr := application.ParseIconEvent(published)
if parseErr != nil || !handled {
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr)
}
if parsed.Type != application.EventIconFailed || parsed.ErrorCode != test.wantCode {
t.Fatalf("failed event = %+v", parsed)
}
payload := published.Payload.(application.IconFailedPayload)
if strings.Contains(string(payload.ErrorCode), "secret") ||
strings.Contains(string(payload.ErrorCode), "token") {
t.Fatalf("failure payload leaked raw error: %+v", payload)
}
})
}
}
func TestIconEventDeliveryReportsPublishFailureAndCacheWarning(t *testing.T) {
document := testPNG(t, 12, 12)
identity := iconEventIdentity(t, document, "request-publish")
publishErr := errors.New("event queue closed")
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: document}, nil
}),
Publisher: IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
return publishErr
}),
}
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconEventPublish) || !errors.Is(err, publishErr) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
warning := errors.New("disk store warning")
delivery.Loader = IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
return IconResult{Bytes: document, Warning: warning}, nil
})
delivery.Publisher = IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
return nil
})
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, warning) {
t.Fatalf("LoadAndPublish() warning = %v", err)
}
}
func TestIconEventDeliveryCancellationPublishesNothing(t *testing.T) {
document := testPNG(t, 10, 10)
identity := iconEventIdentity(t, document, "request-canceled")
ctx, cancel := context.WithCancel(context.Background())
cancel()
loaderCalled := false
publisherCalled := false
delivery := IconEventDelivery{
Loader: IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
loaderCalled = true
return IconResult{}, nil
}),
Publisher: IconEventPublisherFunc(func(
context.Context,
application.Event,
) error {
publisherCalled = true
return nil
}),
}
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
if loaderCalled || publisherCalled {
t.Fatalf("canceled delivery called loader=%t publisher=%t", loaderCalled, publisherCalled)
}
ctx, cancel = context.WithCancel(context.Background())
loaderCalled = false
publisherCalled = false
delivery.Loader = IconLoaderFunc(func(
context.Context,
IconRequest,
) (IconResult, error) {
loaderCalled = true
cancel()
return IconResult{Bytes: document}, nil
})
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
t.Fatalf("LoadAndPublish(cancel during load) error = %v", err)
}
if !loaderCalled || publisherCalled {
t.Fatalf(
"cancel-during-load called loader=%t publisher=%t",
loaderCalled,
publisherCalled,
)
}
}
func TestIconEventDeliveryRejectsIncompleteDependencies(t *testing.T) {
document := testPNG(t, 4, 4)
identity := iconEventIdentity(t, document, "request-invalid")
tests := []IconEventDelivery{
{},
{Loader: IconLoaderFunc(nil), Publisher: IconEventPublisherFunc(nil)},
}
for _, delivery := range tests {
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconDeliveryInvalid) {
t.Fatalf("LoadAndPublish() error = %v", err)
}
}
}
func iconEventIdentity(
t *testing.T,
document []byte,
requestID string,
) application.IconEventIdentity {
t.Helper()
request := iconRequest(document, 96)
identity, err := application.NewIconEventIdentity(
requestID,
"app-one",
request.Reference,
request.DPI,
)
if err != nil {
t.Fatal(err)
}
return identity
}