Ctrl K
ComunidadGuías…

bldr/desktop/tray/desktop-tray_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package desktop_tray

import (
	"context"
	"io"
	"testing"
	"time"

	"github.com/aperturerobotics/starpc/srpc"
	"github.com/s4wave/spacewave/bldr/resource"
	resource_client "github.com/s4wave/spacewave/bldr/resource/client"
	resource_server "github.com/s4wave/spacewave/bldr/resource/server"
)

type testResourceClientContext struct {
	ctx context.Context

	nextID   uint32
	values   map[uint32]any
	releases map[uint32]func()
	attached map[uint32]srpc.Client
}

func newTestResourceClientContext(ctx context.Context) *testResourceClientContext {
	return &testResourceClientContext{
		ctx:      ctx,
		values:   make(map[uint32]any),
		releases: make(map[uint32]func()),
		attached: make(map[uint32]srpc.Client),
	}
}

func (c *testResourceClientContext) Context() context.Context {
	return c.ctx
}

func (c *testResourceClientContext) AddResource(mux srpc.Invoker, releaseFn func()) (uint32, error) {
	return c.AddResourceValue(mux, nil, releaseFn)
}

func (c *testResourceClientContext) AddResourceValue(mux srpc.Invoker, value any, releaseFn func()) (uint32, error) {
	// Allocate the next resource ID and record the value and release hook.
	c.nextID++
	resourceID := c.nextID
	c.values[resourceID] = value
	c.releases[resourceID] = releaseFn
	return resourceID, nil
}

func (c *testResourceClientContext) ReleaseResource(resourceID uint32) bool {
	// Invoke and forget the release hook when the resource exists.
	releaseFn := c.releases[resourceID]
	if releaseFn == nil {
		return false
	}
	delete(c.releases, resourceID)
	delete(c.values, resourceID)
	releaseFn()
	return true
}

func (c *testResourceClientContext) GetResourceValue(resourceID uint32) (any, error) {
	value, ok := c.values[resourceID]
	if !ok {
		return nil, resource.ErrResourceNotFound
	}
	return value, nil
}

func (c *testResourceClientContext) GetAttachedResource(id uint32) (srpc.Client, error) {
	client := c.attached[id]
	if client == nil {
		return nil, resource.ErrResourceNotFound
	}
	return client, nil
}

type testActionClient struct {
	requests []*HandleDesktopTrayActionRequest
}

func (c *testActionClient) ExecCall(ctx context.Context, service, method string, in, out srpc.Message) error {
	req := in.(*HandleDesktopTrayActionRequest)
	c.requests = append(c.requests, req.CloneVT())
	return nil
}

func (c *testActionClient) NewStream(
	ctx context.Context,
	service string,
	method string,
	firstMsg srpc.Message,
) (srpc.Stream, error) {
	return nil, resource.ErrResourceNotFound
}

type testActionHandler struct {
	requests []*HandleDesktopTrayActionRequest
}

func (h *testActionHandler) HandleDesktopTrayAction(
	ctx context.Context,
	req *HandleDesktopTrayActionRequest,
) (*HandleDesktopTrayActionResponse, error) {
	h.requests = append(h.requests, req.CloneVT())
	return &HandleDesktopTrayActionResponse{}, nil
}

type testWatchStream struct {
	ctx context.Context

	updates chan *WatchDesktopTrayResponse
}

func newTestWatchStream(ctx context.Context) *testWatchStream {
	return &testWatchStream{
		ctx:     ctx,
		updates: make(chan *WatchDesktopTrayResponse, 8),
	}
}

func (s *testWatchStream) Context() context.Context {
	return s.ctx
}

func (s *testWatchStream) MsgSend(msg srpc.Message) error {
	return nil
}

func (s *testWatchStream) MsgRecv(msg srpc.Message) error {
	return io.EOF
}

func (s *testWatchStream) CloseSend() error {
	return nil
}

func (s *testWatchStream) Close() error {
	return nil
}

func (s *testWatchStream) Send(resp *WatchDesktopTrayResponse) error {
	s.updates <- resp.CloneVT()
	return nil
}

func (s *testWatchStream) SendAndClose(resp *WatchDesktopTrayResponse) error {
	if resp != nil {
		return s.Send(resp)
	}
	return nil
}

func TestDesktopTrayRegistryOrdersEntriesAndUpdatesState(t *testing.T) {
	// Build the request context and the tray under test.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register a status entry and an action entry.
	statusResp, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:        "status",
			Kind:      DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Path:      []string{"Status"},
			Group:     "runtime",
			Order:     20,
			Label:     "Runtime",
			Enabled:   true,
			IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_NORMAL,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	actionResp, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:        "open",
			Kind:      DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Path:      []string{"Actions"},
			Group:     "primary",
			Order:     10,
			Label:     "Open Spacewave",
			Enabled:   true,
			IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE,
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	// Expect the action entry to sort before the status entry with an active icon.
	state := tray.snapshot()
	entries := state.GetEntries()
	if len(entries) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(entries))
	}
	if entries[0].GetId() != "open" || entries[1].GetId() != "status" {
		t.Fatalf("unexpected order: %s, %s", entries[0].GetId(), entries[1].GetId())
	}
	if state.GetIconState() != DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE {
		t.Fatalf("expected active icon state, got %s", state.GetIconState())
	}

	// Toggle the action entry active and disabled through its resource.
	value, err := client.GetResourceValue(actionResp.GetResourceId())
	if err != nil {
		t.Fatal(err)
	}
	entryResource := value.(*DesktopTrayEntryResource)
	if _, err := entryResource.SetDesktopTrayEntryActive(ctx, &SetDesktopTrayEntryActiveRequest{Active: true}); err != nil {
		t.Fatal(err)
	}
	if _, err := entryResource.SetDesktopTrayEntryEnabled(ctx, &SetDesktopTrayEntryEnabledRequest{Enabled: false}); err != nil {
		t.Fatal(err)
	}

	// Expect the snapshot to reflect the active and disabled flags.
	state = tray.snapshot()
	entries = state.GetEntries()
	if !entries[0].GetActive() {
		t.Fatal("expected active entry")
	}
	if entries[0].GetEnabled() {
		t.Fatal("expected disabled entry")
	}

	// Release the status entry and expect only the action entry left.
	if !client.ReleaseResource(statusResp.GetResourceId()) {
		t.Fatal("expected release to unregister entry")
	}
	state = tray.snapshot()
	entries = state.GetEntries()
	if len(entries) != 1 || entries[0].GetId() != "open" {
		t.Fatalf("unexpected entries after release: %#v", entries)
	}
}

func TestDesktopTrayRegistryDerivesStatusTextFromTitleEntry(t *testing.T) {
	// Build the request context and the tray under test.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register a title entry with an active icon state.
	_, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:        "title",
			Kind:      DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label:     "Spacewave: Syncing",
			IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE,
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	// Expect the status text and icon state to derive from the title entry.
	state := tray.snapshot()
	if state.GetStatusText() != "Syncing" {
		t.Fatalf("status text = %q, want Syncing", state.GetStatusText())
	}
	if state.GetIconState() != DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE {
		t.Fatalf("icon state = %v, want active", state.GetIconState())
	}
}

func TestDesktopTrayRegistryWatchStreamsSnapshots(t *testing.T) {
	// Cancel the context when the test finishes.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Start the watch stream in the background.
	client := newTestResourceClientContext(ctx)
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()
	strm := newTestWatchStream(ctx)
	errCh := make(chan error, 1)
	go func() {
		errCh <- tray.WatchDesktopTray(&WatchDesktopTrayRequest{}, strm)
	}()

	// Expect an empty initial snapshot.
	resp := <-strm.updates
	if len(resp.GetState().GetEntries()) != 0 {
		t.Fatalf("expected empty initial snapshot, got %d entries", len(resp.GetState().GetEntries()))
	}

	// Register a runtime entry after the watch started.
	_, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:      "runtime",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label:   "Runtime",
			Enabled: true,
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	// Expect the new entry to appear in the streamed snapshot.
	resp = <-strm.updates
	entries := resp.GetState().GetEntries()
	if len(entries) != 1 || entries[0].GetId() != "runtime" {
		t.Fatalf("unexpected watch snapshot: %#v", entries)
	}

	// Cancel the context and expect the watch to stop with the error.
	cancel()
	if err := <-errCh; err != context.Canceled {
		t.Fatalf("expected canceled watch, got %v", err)
	}
}

func TestDesktopTrayRegistryInvokesAttachedActionHandlers(t *testing.T) {
	// Build the request context with an attached action client.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	actionClient := &testActionClient{}
	client.attached[7] = actionClient
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register an action entry referencing the attached handler resource.
	_, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		AttachedActionResourceId: 7,
		Entry: &DesktopTrayEntry{
			Id:      "open",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "Open",
			Enabled: true,
			Action: &DesktopTrayAction{
				Kind:  DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER,
				Route: "/spaces",
			},
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	// Invoke the entry and expect one request to reach the handler.
	_, err = tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "open"})
	if err != nil {
		t.Fatal(err)
	}
	if len(actionClient.requests) != 1 {
		t.Fatalf("expected 1 action request, got %d", len(actionClient.requests))
	}
	if actionClient.requests[0].GetEntryId() != "open" {
		t.Fatalf("unexpected entry id: %s", actionClient.requests[0].GetEntryId())
	}
	if actionClient.requests[0].GetAction().GetRoute() != "/spaces" {
		t.Fatalf("unexpected route: %s", actionClient.requests[0].GetAction().GetRoute())
	}
}

func TestDesktopTrayRegistryRejectsNonInvokableEntries(t *testing.T) {
	// Build the request context with an attached action client.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	actionClient := &testActionClient{}
	client.attached[7] = actionClient
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register a disabled action entry and a status entry.
	_, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		AttachedActionResourceId: 7,
		Entry: &DesktopTrayEntry{
			Id:      "disabled",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "Disabled",
			Enabled: false,
			Action: &DesktopTrayAction{
				Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER,
			},
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:    "status",
			Kind:  DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label: "Status",
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	// Expect both entries to reject invocation without touching the handler.
	if _, err := tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "disabled"}); err != ErrDesktopTrayEntryNotInvokable {
		t.Fatalf("expected disabled entry to be non-invokable, got %v", err)
	}
	if _, err := tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "status"}); err != ErrDesktopTrayEntryNotInvokable {
		t.Fatalf("expected status entry to be non-invokable, got %v", err)
	}
	if len(actionClient.requests) != 0 {
		t.Fatalf("expected no action requests, got %d", len(actionClient.requests))
	}
}

func TestDesktopTrayRegistryRejectsDuplicateEntryIDs(t *testing.T) {
	// Build the request context and the tray under test.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register an entry and expect a duplicate registration to fail.
	first, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:    "same",
			Kind:  DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label: "First",
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:    "same",
			Kind:  DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label: "Second",
		},
	})
	if err != ErrDesktopTrayEntryDuplicate {
		t.Fatalf("expected duplicate entry error, got %v", err)
	}

	// Rename a separate entry onto the held ID through its resource.
	value, err := client.GetResourceValue(first.GetResourceId())
	if err != nil {
		t.Fatal(err)
	}
	entryResource := value.(*DesktopTrayEntryResource)
	_, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:    "other",
			Kind:  DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label: "Other",
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = entryResource.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:    "other",
			Kind:  DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label: "Renamed",
		},
	})
	if err != ErrDesktopTrayEntryDuplicate {
		t.Fatalf("expected duplicate update error, got %v", err)
	}
}

func TestDesktopTrayRegistrySortsTiesByEntryIDThenResourceID(t *testing.T) {
	// Build the request context and the tray under test.
	ctx := context.Background()
	client := newTestResourceClientContext(ctx)
	reqCtx := resource_server.WithResourceClientContext(ctx, client)
	tray := NewDesktopTray()

	// Register three entries that tie on path, group, and order.
	for _, id := range []string{"zeta", "alpha", "middle"} {
		_, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{
			Entry: &DesktopTrayEntry{
				Id:      id,
				Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
				Path:    []string{"Actions"},
				Group:   "primary",
				Order:   10,
				Label:   id,
				Enabled: true,
			},
		})
		if err != nil {
			t.Fatal(err)
		}
	}

	// Expect the snapshot to order the entries by ID.
	entries := tray.snapshot().GetEntries()
	got := []string{entries[0].GetId(), entries[1].GetId(), entries[2].GetId()}
	want := []string{"alpha", "middle", "zeta"}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("expected order %v, got %v", want, got)
		}
	}
}

func TestReconcileDesktopTrayMirrorsEntriesToTargetResource(t *testing.T) {
	// Cancel the context when the test finishes.
	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()

	// Build the source and target tray resource clients.
	sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t)
	defer sourceRelease()
	targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t)
	defer targetRelease()

	// Start the reconciler in the background.
	errCh := make(chan error, 1)
	go func() {
		errCh <- ReconcileDesktopTray(ctx, source, target, targetClient)
	}()

	// Drain the initial empty target snapshot.
	targetStream, err := target.WatchDesktopTray(ctx, &WatchDesktopTrayRequest{})
	if err != nil {
		t.Fatalf("watch target tray: %v", err)
	}
	if _, err := targetStream.Recv(); err != nil {
		t.Fatalf("recv target initial snapshot: %v", err)
	}

	// Register a status entry on the source tray.
	first, err := source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:      "status",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label:   "Runtime - Running",
			Enabled: false,
		},
	})
	if err != nil {
		t.Fatalf("register source entry: %v", err)
	}

	// Expect the entry to mirror onto the target.
	state := recvTrayState(t, targetStream)
	if len(state.GetEntries()) != 1 {
		t.Fatalf("target entries = %d, want 1", len(state.GetEntries()))
	}
	if state.GetEntries()[0].GetLabel() != "Runtime - Running" {
		t.Fatalf("target label = %q, want mirrored source label", state.GetEntries()[0].GetLabel())
	}

	// Update the source entry label through its resource.
	firstRef := sourceClient.CreateResourceReference(first.GetResourceId())
	defer firstRef.Release()
	firstClient, err := firstRef.GetClient()
	if err != nil {
		t.Fatalf("source entry client: %v", err)
	}
	firstEntry := NewSRPCDesktopTrayEntryResourceServiceClient(firstClient)
	_, err = firstEntry.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:      "status",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS,
			Label:   "Runtime - Busy",
			Enabled: false,
		},
	})
	if err != nil {
		t.Fatalf("update source entry: %v", err)
	}

	// Expect the updated label to mirror onto the target.
	state = recvTrayState(t, targetStream)
	if len(state.GetEntries()) != 1 {
		t.Fatalf("target entries after update = %d, want 1", len(state.GetEntries()))
	}
	if state.GetEntries()[0].GetLabel() != "Runtime - Busy" {
		t.Fatalf("target label after update = %q, want updated source label", state.GetEntries()[0].GetLabel())
	}

	// Release the source entry and expect the target to drop it.
	firstRef.Release()
	state = recvTrayState(t, targetStream)
	if len(state.GetEntries()) != 0 {
		t.Fatalf("target entries after source release = %d, want 0", len(state.GetEntries()))
	}

	// Cancel the context and expect the reconciler to stop.
	cancel()
	if err := <-errCh; err == nil {
		t.Fatalf("expected reconciler to stop on context cancel")
	}
}

func TestReconcileDesktopTrayForwardsAttachedActionHandlers(t *testing.T) {
	// Cancel the context when the test finishes.
	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()

	// Build the source and target tray resource clients.
	sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t)
	defer sourceRelease()
	targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t)
	defer targetRelease()

	// Attach a test action handler to the source client.
	handler := &testActionHandler{}
	handlerMux := srpc.NewMux()
	if err := SRPCRegisterDesktopTrayActionHandlerService(handlerMux, handler); err != nil {
		t.Fatalf("register action handler: %v", err)
	}
	attachedActionResourceID, err := sourceClient.AttachRawInvoker(ctx, "test-tray-action", handlerMux)
	if err != nil {
		t.Fatalf("attach source action handler: %v", err)
	}
	defer func() {
		_ = sourceClient.DetachResource(context.Background(), attachedActionResourceID)
	}()

	// Register an attached-handler action entry on the source tray.
	_, err = source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{
		AttachedActionResourceId: attachedActionResourceID,
		Entry: &DesktopTrayEntry{
			Id:      "copy-diagnostics",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "Copy Diagnostics",
			Enabled: true,
			Action: &DesktopTrayAction{
				Kind:  DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER,
				Value: "diagnostics",
			},
		},
	})
	if err != nil {
		t.Fatalf("register source entry: %v", err)
	}

	// Start the reconciler in the background.
	errCh := make(chan error, 1)
	go func() {
		errCh <- ReconcileDesktopTray(ctx, source, target, targetClient)
	}()

	// Watch the target tray and drain the initial snapshot.
	targetStream, err := target.WatchDesktopTray(ctx, &WatchDesktopTrayRequest{})
	if err != nil {
		t.Fatalf("watch target tray: %v", err)
	}
	state := recvTrayState(t, targetStream)
	if len(state.GetEntries()) == 0 {
		state = recvTrayState(t, targetStream)
	}
	if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetId() != "copy-diagnostics" {
		t.Fatalf("target entries = %#v", state.GetEntries())
	}

	// Invoke the entry on the target and expect the source handler to run.
	_, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{
		EntryId: "copy-diagnostics",
	})
	if err != nil {
		t.Fatalf("invoke target attached entry: %v", err)
	}
	if len(handler.requests) != 1 {
		t.Fatalf("source handler requests = %d, want 1", len(handler.requests))
	}
	if handler.requests[0].GetEntryId() != "copy-diagnostics" {
		t.Fatalf("source handler entry id = %q", handler.requests[0].GetEntryId())
	}

	// Cancel the context and expect the reconciler to stop.
	cancel()
	if err := <-errCh; err == nil {
		t.Fatalf("expected reconciler to stop on context cancel")
	}
}

func TestReconcileDesktopTrayReplacesEntryWhenAttachedHandlerModeChanges(t *testing.T) {
	// Cancel the context when the test finishes.
	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()

	// Build the source and target tray resource clients.
	sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t)
	defer sourceRelease()
	targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t)
	defer targetRelease()

	// Attach a test action handler to the source client.
	handler := &testActionHandler{}
	handlerMux := srpc.NewMux()
	if err := SRPCRegisterDesktopTrayActionHandlerService(handlerMux, handler); err != nil {
		t.Fatalf("register action handler: %v", err)
	}
	attachedActionResourceID, err := sourceClient.AttachRawInvoker(ctx, "test-dynamic-tray-action", handlerMux)
	if err != nil {
		t.Fatalf("attach source action handler: %v", err)
	}
	defer func() {
		_ = sourceClient.DetachResource(context.Background(), attachedActionResourceID)
	}()

	// Register an attached-handler action entry on the source tray.
	first, err := source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{
		AttachedActionResourceId: attachedActionResourceID,
		Entry: &DesktopTrayEntry{
			Id:      "dynamic",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "Run Dynamic Action",
			Enabled: true,
			Action: &DesktopTrayAction{
				Kind:  DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER,
				Value: "dynamic",
			},
		},
	})
	if err != nil {
		t.Fatalf("register source entry: %v", err)
	}
	firstRef := sourceClient.CreateResourceReference(first.GetResourceId())
	defer firstRef.Release()

	// Start the reconciler in the background.
	errCh := make(chan error, 1)
	go func() {
		errCh <- ReconcileDesktopTray(ctx, source, target, targetClient)
	}()

	// Watch the target tray with a timeout.
	watchCtx, watchCancel := context.WithTimeout(ctx, 5*time.Second)
	defer watchCancel()
	targetStream, err := target.WatchDesktopTray(watchCtx, &WatchDesktopTrayRequest{})
	if err != nil {
		t.Fatalf("watch target tray: %v", err)
	}

	// Expect the attached-handler entry to mirror onto the target.
	state := recvTrayStateWithActionKind(
		t,
		targetStream,
		"dynamic",
		DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER,
	)
	if len(state.GetEntries()) != 1 {
		t.Fatalf("target initial entry = %#v", state.GetEntries())
	}

	// Invoke the entry on the target and expect the source handler to run.
	_, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{
		EntryId: "dynamic",
	})
	if err != nil {
		t.Fatalf("invoke initial target entry: %v", err)
	}
	if len(handler.requests) != 1 {
		t.Fatalf("initial source handler requests = %d, want 1", len(handler.requests))
	}

	// Replace the entry action with an open-route action.
	firstClient, err := firstRef.GetClient()
	if err != nil {
		t.Fatalf("source entry client: %v", err)
	}
	firstEntry := NewSRPCDesktopTrayEntryResourceServiceClient(firstClient)
	_, err = firstEntry.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:      "dynamic",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "Open Spacewave",
			Enabled: true,
			Action: &DesktopTrayAction{
				Kind:  DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE,
				Route: "/u/1/",
			},
		},
	})
	if err != nil {
		t.Fatalf("update source entry: %v", err)
	}

	// Expect the replaced entry to mirror onto the target.
	state = recvTrayStateWithActionKind(
		t,
		targetStream,
		"dynamic",
		DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE,
	)
	if len(state.GetEntries()) != 1 {
		t.Fatalf("target updated entry = %#v", state.GetEntries())
	}

	// Expect the replaced route entry to reject attached-handler invocation.
	_, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{
		EntryId: "dynamic",
	})
	if err == nil {
		t.Fatal("expected replaced route entry to be non-invokable through attached handler path")
	}
	if len(handler.requests) != 1 {
		t.Fatalf("source handler requests after replace = %d, want 1", len(handler.requests))
	}

	// Cancel the context and expect the reconciler to stop.
	cancel()
	if err := <-errCh; err == nil {
		t.Fatalf("expected reconciler to stop on context cancel")
	}
}

func TestReconcileDesktopTrayMirrorsExistingEntriesAcrossTargetReconnect(t *testing.T) {
	// Register an open-route entry on the source tray.
	_, source, sourceRelease := newTestDesktopTrayResourceClient(t)
	defer sourceRelease()

	// Register the "space" route entry on the source tray resource.
	_, err := source.RegisterDesktopTrayEntry(t.Context(), &RegisterDesktopTrayEntryRequest{
		Entry: &DesktopTrayEntry{
			Id:      "space",
			Kind:    DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION,
			Label:   "My Drive",
			Enabled: true,
			Action: &DesktopTrayAction{
				Kind:  DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE,
				Route: "/u/1/so/space",
			},
		},
	})
	if err != nil {
		t.Fatalf("register source entry: %v", err)
	}

	// Watch the first target and confirm it starts empty.
	firstTargetClient, firstTarget, firstTargetRelease := newTestDesktopTrayResourceClient(t)
	defer firstTargetRelease()
	firstStream, err := firstTarget.WatchDesktopTray(t.Context(), &WatchDesktopTrayRequest{})
	if err != nil {
		t.Fatalf("watch first target tray: %v", err)
	}
	if state := recvTrayState(t, firstStream); len(state.GetEntries()) != 0 {
		t.Fatalf("first target initial entries = %d, want 0", len(state.GetEntries()))
	}

	// Run the first reconciler and expect the entry to mirror.
	firstCtx, firstCancel := context.WithCancel(t.Context())
	firstErrCh := make(chan error, 1)
	go func() {
		firstErrCh <- ReconcileDesktopTray(firstCtx, source, firstTarget, firstTargetClient)
	}()

	// Receive the mirrored tray state from the first target stream.
	state := recvTrayState(t, firstStream)
	if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetLabel() != "My Drive" {
		t.Fatalf("first target mirrored entries = %#v", state.GetEntries())
	}

	// Stop the first reconciler and expect the target tray to clear.
	firstCancel()
	if err := <-firstErrCh; err == nil {
		t.Fatalf("expected first reconciler to stop on context cancel")
	}
	state = recvTrayState(t, firstStream)
	if len(state.GetEntries()) != 0 {
		t.Fatalf("first target entries after reconciler stop = %d, want 0", len(state.GetEntries()))
	}

	// Watch the second target and confirm it starts empty.
	secondTargetClient, secondTarget, secondTargetRelease := newTestDesktopTrayResourceClient(t)
	defer secondTargetRelease()
	secondStream, err := secondTarget.WatchDesktopTray(t.Context(), &WatchDesktopTrayRequest{})
	if err != nil {
		t.Fatalf("watch second target tray: %v", err)
	}
	if state := recvTrayState(t, secondStream); len(state.GetEntries()) != 0 {
		t.Fatalf("second target initial entries = %d, want 0", len(state.GetEntries()))
	}

	// Run the second reconciler and expect the entry to mirror again.
	secondCtx, secondCancel := context.WithCancel(t.Context())
	secondErrCh := make(chan error, 1)
	go func() {
		secondErrCh <- ReconcileDesktopTray(secondCtx, source, secondTarget, secondTargetClient)
	}()

	// Receive the mirrored tray state from the second target stream.
	state = recvTrayState(t, secondStream)
	if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetLabel() != "My Drive" {
		t.Fatalf("second target mirrored entries = %#v", state.GetEntries())
	}

	// Stop the second reconciler.
	secondCancel()
	if err := <-secondErrCh; err == nil {
		t.Fatalf("expected second reconciler to stop on context cancel")
	}
}

func newTestDesktopTrayResourceClient(
	t *testing.T,
) (*resource_client.Client, SRPCDesktopTrayResourceServiceClient, func()) {
	// Mark the helper so test failures attribute to the caller.
	t.Helper()

	// Serve the tray resource over an in-memory SRPC server.
	tray := NewDesktopTray()
	server := resource_server.NewResourceServer(tray.GetMux())
	serverMux := srpc.NewMux()
	if err := server.Register(serverMux); err != nil {
		t.Fatalf("register resource server: %v", err)
	}

	// Connect a resource client to the server.
	resourceService := resource.NewSRPCResourceServiceClient(
		srpc.NewClient(srpc.NewServerPipe(srpc.NewServer(serverMux))),
	)
	client, err := resource_client.NewClient(t.Context(), resourceService)
	if err != nil {
		t.Fatalf("new resource client: %v", err)
	}

	// Access the root resource and return the tray service client.
	rootRef := client.AccessRootResource()
	rootClient, err := rootRef.GetClient()
	if err != nil {
		rootRef.Release()
		client.Release()
		t.Fatalf("root resource client: %v", err)
	}
	service := NewSRPCDesktopTrayResourceServiceClient(rootClient)
	return client, service, func() {
		rootRef.Release()
		client.Release()
	}
}

func recvTrayState(
	t *testing.T,
	strm SRPCDesktopTrayResourceService_WatchDesktopTrayClient,
) *DesktopTrayState {
	t.Helper()
	resp, err := strm.Recv()
	if err != nil {
		t.Fatalf("recv tray state: %v", err)
	}
	return resp.GetState()
}

func recvTrayStateWithActionKind(
	t *testing.T,
	strm SRPCDesktopTrayResourceService_WatchDesktopTrayClient,
	entryID string,
	kind DesktopTrayActionKind,
) *DesktopTrayState {
	t.Helper()

	for range 16 {
		state := recvTrayState(t, strm)
		for _, entry := range state.GetEntries() {
			if entry.GetId() == entryID && entry.GetAction().GetKind() == kind {
				return state
			}
		}
	}
	t.Fatalf("did not receive entry %q with action kind %v", entryID, kind)
	return nil
}

// _ is a type assertion
var (
	_ resource_server.ResourceClientContext                 = (*testResourceClientContext)(nil)
	_ SRPCDesktopTrayResourceService_WatchDesktopTrayStream = (*testWatchStream)(nil)
	_ srpc.Client                                           = (*testActionClient)(nil)
)