package main import ( "errors" "sync" "testing" "time" "go.uber.org/zap" ) type fakeTrayController struct { run func(onReady func(), onExit func()) quitCh chan struct{} quitOnce sync.Once icon []byte tooltip string menuItems []*fakeTrayMenuItem } func newFakeTrayController() *fakeTrayController { return &fakeTrayController{quitCh: make(chan struct{})} } func (c *fakeTrayController) Run(onReady func(), onExit func()) { if c.run != nil { c.run(onReady, onExit) return } onReady() <-c.quitCh if onExit != nil { onExit() } } func (c *fakeTrayController) Quit() { c.quitOnce.Do(func() { close(c.quitCh) }) } func (c *fakeTrayController) SetIcon(icon []byte) { c.icon = append([]byte(nil), icon...) } func (c *fakeTrayController) SetTooltip(tooltip string) { c.tooltip = tooltip } func (c *fakeTrayController) AddMenuItem(title, tooltip string) trayMenuItem { item := &fakeTrayMenuItem{clicked: make(chan struct{}), title: title, tooltip: tooltip} c.menuItems = append(c.menuItems, item) return item } func (c *fakeTrayController) AddSeparator() {} type fakeTrayMenuItem struct { clicked chan struct{} title string tooltip string disabled bool } func (m *fakeTrayMenuItem) Disable() { m.disabled = true } func (m *fakeTrayMenuItem) Clicked() <-chan struct{} { return m.clicked } func TestRunSystrayReadyOpensBrowser(t *testing.T) { controller := newFakeTrayController() opened := make(chan string, 1) err := runSystray(19826, trayOptions{ controller: controller, readyTimeout: time.Second, iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, openBrowser: func(url string) error { opened <- url controller.Quit() return nil }, notify: func(string, string) {}, logger: zap.NewNop(), }) if err != nil { t.Fatalf("托盘 ready 成功不应返回错误: %v", err) } if got := <-opened; got != "http://localhost:19826" { t.Fatalf("浏览器 URL = %s", got) } if string(controller.icon) != "icon" { t.Fatalf("应设置托盘图标") } if controller.tooltip != appTooltip { t.Fatalf("tooltip = %q, want %q", controller.tooltip, appTooltip) } } func TestRunSystrayReadyTimeoutReturnsTrayStartupError(t *testing.T) { controller := newFakeTrayController() controller.run = func(_ func(), _ func()) { <-controller.quitCh } err := runSystray(19826, trayOptions{ controller: controller, readyTimeout: 10 * time.Millisecond, iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, openBrowser: func(string) error { return nil }, notify: func(string, string) {}, logger: zap.NewNop(), }) if err == nil { t.Fatal("托盘 ready timeout 应返回错误") } var startupErr *startupError if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" { t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err) } } func TestRunSystrayIconLoadFailureReturnsTrayStartupError(t *testing.T) { controller := newFakeTrayController() err := runSystray(19826, trayOptions{ controller: controller, readyTimeout: time.Second, iconLoader: func() ([]byte, error) { return nil, errors.New("missing icon") }, openBrowser: func(string) error { return nil }, notify: func(string, string) {}, logger: zap.NewNop(), }) if err == nil { t.Fatal("托盘图标加载失败应返回错误") } var startupErr *startupError if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" { t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err) } } func TestRunSystrayBrowserOpenFailureIsNonFatal(t *testing.T) { controller := newFakeTrayController() notified := make(chan string, 1) err := runSystray(19826, trayOptions{ controller: controller, readyTimeout: time.Second, iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, openBrowser: func(string) error { return errors.New("no browser") }, notify: func(_, message string) { notified <- message controller.Quit() }, logger: zap.NewNop(), }) if err != nil { t.Fatalf("浏览器打开失败不应成为 fatal: %v", err) } if got := <-notified; got == "" { t.Fatal("浏览器打开失败应提示用户") } }