diff --git a/src/native/tray.test.ts b/src/native/tray.test.ts new file mode 100644 index 0000000..6815068 --- /dev/null +++ b/src/native/tray.test.ts @@ -0,0 +1,325 @@ +/// + +import { Menu, Tray, nativeImage } from "electron"; + +import { initTray, updateTrayMenu } from "./tray"; +import { mainWindow, quitApp } from "./window"; + +// Mock electron +jest.mock("electron", () => ({ + Menu: { + buildFromTemplate: jest.fn((template: unknown[]) => template), + }, + Tray: jest.fn().mockImplementation(() => ({ + setToolTip: jest.fn(), + setImage: jest.fn(), + on: jest.fn(), + setContextMenu: jest.fn(), + })), + nativeImage: { + createFromDataURL: jest.fn().mockReturnValue({ + resize: jest.fn().mockReturnValue({ + setTemplateImage: jest.fn(), + }), + }), + }, +})); + +// Mock the asset import +jest.mock("../../assets/desktop/icon.png?asset", () => "mock-icon-path", { + virtual: true, +}); + +// Mock package.json +jest.mock("../../package.json", () => ({ + version: "1.1.11", +}), { virtual: true }); + +// Mock window +jest.mock("./window", () => ({ + mainWindow: { + show: jest.fn(), + focus: jest.fn(), + isVisible: jest.fn().mockReturnValue(false), + hide: jest.fn(), + }, + quitApp: jest.fn(), +})); + +describe("tray", () => { + let mockTray: Record; + let capturedClickHandler: Function; + let capturedMenu: unknown[]; + + beforeEach(() => { + jest.clearAllMocks(); + + capturedClickHandler = jest.fn(); + capturedMenu = []; + + mockTray = { + setToolTip: jest.fn(), + setImage: jest.fn(), + on: jest.fn((event: string, handler: Function) => { + if (event === "click") { + capturedClickHandler = handler; + } + }), + setContextMenu: jest.fn((menu: unknown[]) => { + capturedMenu = menu; + }), + }; + + (Tray as unknown as jest.Mock).mockImplementation(() => mockTray); + }); + + describe("initTray", () => { + it("should create a tray icon from the asset", () => { + initTray(); + + expect(nativeImage.createFromDataURL).toHaveBeenCalledWith( + "mock-icon-path", + ); + }); + + it("should resize the tray icon to 20x20", () => { + const mockResize = jest.fn().mockReturnValue({ + setTemplateImage: jest.fn(), + }); + (nativeImage.createFromDataURL as jest.Mock).mockReturnValue({ + resize: mockResize, + }); + + initTray(); + + expect(mockResize).toHaveBeenCalledWith({ width: 20, height: 20 }); + }); + + it("should set the resized icon as a template image", () => { + const mockSetTemplateImage = jest.fn(); + const mockResized = { + resize: jest.fn().mockReturnValue({ + setTemplateImage: mockSetTemplateImage, + }), + }; + (nativeImage.createFromDataURL as jest.Mock).mockReturnValue(mockResized); + + initTray(); + + expect(mockSetTemplateImage).toHaveBeenCalledWith(true); + }); + + it("should create a new Tray instance with the resized icon", () => { + initTray(); + + expect(Tray).toHaveBeenCalled(); + }); + + it("should set the tray tooltip", () => { + initTray(); + + expect(mockTray.setToolTip).toHaveBeenCalledWith("Stoat for Desktop"); + }); + + it("should set the tray image", () => { + initTray(); + + expect(mockTray.setImage).toHaveBeenCalled(); + }); + + it("should call updateTrayMenu during initialization", () => { + initTray(); + + expect(mockTray.setContextMenu).toHaveBeenCalled(); + }); + + it("should register a click handler on the tray", () => { + initTray(); + + expect(mockTray.on).toHaveBeenCalledWith("click", expect.any(Function)); + }); + + it("should show and focus main window when tray is clicked", () => { + initTray(); + + capturedClickHandler(); + + expect(mainWindow.show).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); + }); + }); + + describe("updateTrayMenu", () => { + it("should build a context menu from a template", () => { + initTray(); + updateTrayMenu(); + + expect(Menu.buildFromTemplate).toHaveBeenCalled(); + }); + + it("should set the context menu on the tray", () => { + initTray(); + updateTrayMenu(); + + expect(mockTray.setContextMenu).toHaveBeenCalled(); + }); + + it("should include a disabled label item for the app name", () => { + initTray(); + updateTrayMenu(); + + const appNameItem = capturedMenu.find( + (item: { label: string }) => item.label === "Stoat for Desktop", + ); + + expect(appNameItem).toBeDefined(); + expect(appNameItem.enabled).toBe(false); + expect(appNameItem.type).toBe("normal"); + }); + + it("should include a version submenu with the current version", () => { + initTray(); + updateTrayMenu(); + + const versionItem = capturedMenu.find( + (item: { label: string }) => item.label === "Version", + ); + + expect(versionItem).toBeDefined(); + expect(versionItem.type).toBe("submenu"); + expect(versionItem.submenu).toEqual([ + { + label: "1.1.11", + type: "normal", + enabled: false, + }, + ]); + }); + + it("should include a separator", () => { + initTray(); + updateTrayMenu(); + + const separatorItem = capturedMenu.find( + (item: { type: string }) => item.type === "separator", + ); + + expect(separatorItem).toBeDefined(); + }); + + it("should show 'Show App' when window is not visible", () => { + (mainWindow.isVisible as jest.Mock).mockReturnValue(false); + initTray(); + updateTrayMenu(); + + const toggleItem = capturedMenu.find( + (item: { label: string }) => + item.label === "Show App" || item.label === "Hide App", + ); + + expect(toggleItem.label).toBe("Show App"); + expect(toggleItem.type).toBe("normal"); + }); + + it("should show 'Hide App' when window is visible", () => { + (mainWindow.isVisible as jest.Mock).mockReturnValue(true); + initTray(); + updateTrayMenu(); + + const toggleItem = capturedMenu.find( + (item: { label: string }) => + item.label === "Show App" || item.label === "Hide App", + ); + + expect(toggleItem.label).toBe("Hide App"); + expect(toggleItem.type).toBe("normal"); + }); + + it("should hide the app when 'Hide App' menu item is clicked", () => { + (mainWindow.isVisible as jest.Mock).mockReturnValue(true); + initTray(); + updateTrayMenu(); + + const toggleItem = capturedMenu.find( + (item: { label: string }) => item.label === "Hide App", + ); + + toggleItem.click(); + + expect(mainWindow.hide).toHaveBeenCalled(); + expect(mainWindow.show).not.toHaveBeenCalled(); + }); + + it("should show the app when 'Show App' menu item is clicked", () => { + (mainWindow.isVisible as jest.Mock).mockReturnValue(false); + initTray(); + updateTrayMenu(); + + const toggleItem = capturedMenu.find( + (item: { label: string }) => item.label === "Show App", + ); + + toggleItem.click(); + + expect(mainWindow.show).toHaveBeenCalled(); + expect(mainWindow.hide).not.toHaveBeenCalled(); + }); + + it("should include a 'Quit App' menu item", () => { + initTray(); + updateTrayMenu(); + + const quitItem = capturedMenu.find( + (item: { label: string }) => item.label === "Quit App", + ); + + expect(quitItem).toBeDefined(); + expect(quitItem.type).toBe("normal"); + }); + + it("should call quitApp when 'Quit App' menu item is clicked", () => { + initTray(); + updateTrayMenu(); + + const quitItem = capturedMenu.find( + (item: { label: string }) => item.label === "Quit App", + ); + + quitItem.click(); + + expect(quitApp).toHaveBeenCalled(); + }); + + it("should have the correct menu structure with all items in order", () => { + initTray(); + updateTrayMenu(); + + expect(capturedMenu).toHaveLength(5); + expect(capturedMenu[0]).toEqual( + expect.objectContaining({ + label: "Stoat for Desktop", + type: "normal", + enabled: false, + }), + ); + expect(capturedMenu[1]).toEqual( + expect.objectContaining({ + label: "Version", + type: "submenu", + }), + ); + expect(capturedMenu[2]).toEqual({ type: "separator" }); + expect(capturedMenu[3]).toEqual( + expect.objectContaining({ + type: "normal", + }), + ); + expect(capturedMenu[4]).toEqual( + expect.objectContaining({ + label: "Quit App", + type: "normal", + }), + ); + }); + }); +});