From 2210f10305a4a6042ab58827739e615665de0c58 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Sat, 3 Jan 2026 22:40:27 -0600 Subject: [PATCH] test(frontend): add comprehensive unit tests for AuthPage, CommandPalette, ConfirmDialog, UI components, and theme handling --- tests/frontend/AuthPage.test.js | 351 +++++++++++ tests/frontend/CommandPalette.test.js | 311 ++++++++++ tests/frontend/ConfirmDialog.test.js | 167 ++++++ tests/frontend/UIComponents.test.js | 581 ++++++++++++++++++ tests/frontend/UIThemeAndVisibility.test.js | 615 ++++++++++++++++++++ 5 files changed, 2025 insertions(+) create mode 100644 tests/frontend/AuthPage.test.js create mode 100644 tests/frontend/CommandPalette.test.js create mode 100644 tests/frontend/ConfirmDialog.test.js create mode 100644 tests/frontend/UIComponents.test.js create mode 100644 tests/frontend/UIThemeAndVisibility.test.js diff --git a/tests/frontend/AuthPage.test.js b/tests/frontend/AuthPage.test.js new file mode 100644 index 0000000..770a32e --- /dev/null +++ b/tests/frontend/AuthPage.test.js @@ -0,0 +1,351 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import AuthPage from "../../meshchatx/src/frontend/components/auth/AuthPage.vue"; + +describe("AuthPage.vue", () => { + let axiosMock; + let routerMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }), + post: vi.fn().mockResolvedValue({ data: { success: true } }), + }; + window.axios = axiosMock; + + routerMock = { + push: vi.fn(), + }; + + Object.defineProperty(window, "location", { + value: { + reload: vi.fn(), + }, + writable: true, + }); + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountAuthPage = () => { + return mount(AuthPage, { + global: { + mocks: { + $router: routerMock, + }, + }, + }); + }; + + it("renders login form when password is set", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Authentication Required"); + expect(wrapper.text()).toContain("Login"); + expect(wrapper.find('input[type="password"]').exists()).toBe(true); + }); + + it("renders setup form when password is not set", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(wrapper.vm.isSetup).toBe(true); + expect(wrapper.text()).toContain("Initial Setup"); + expect(wrapper.text()).toContain("Set Password"); + expect(wrapper.findAll('input[type="password"]').length).toBe(2); + }); + + it("redirects to home when auth is disabled", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: false, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + expect(routerMock.push).toHaveBeenCalledWith("/"); + }); + + it("redirects to home when already authenticated", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: true, + password_set: true, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + expect(routerMock.push).toHaveBeenCalledWith("/"); + }); + + it("validates password length in setup mode", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + wrapper.vm.password = "short"; + wrapper.vm.confirmPassword = "short"; + await wrapper.vm.$nextTick(); + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.error).toContain("at least 8 characters"); + expect(axiosMock.post).not.toHaveBeenCalled(); + }); + + it("validates password match in setup mode", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + wrapper.vm.password = "password123"; + wrapper.vm.confirmPassword = "password456"; + await wrapper.vm.$nextTick(); + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.error).toContain("do not match"); + expect(axiosMock.post).not.toHaveBeenCalled(); + }); + + it("submits setup form with valid password", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + wrapper.vm.password = "password123"; + wrapper.vm.confirmPassword = "password123"; + await wrapper.vm.$nextTick(); + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/setup", { + password: "password123", + }); + }); + + it("submits login form with password", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + wrapper.vm.password = "password123"; + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/login", { + password: "password123", + }); + }); + + it("reloads page after successful login", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + wrapper.vm.password = "password123"; + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(window.location.reload).toHaveBeenCalled(); + }); + + it("displays error message on login failure", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }); + axiosMock.post.mockRejectedValueOnce({ + response: { + data: { + error: "Invalid password", + }, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + wrapper.vm.password = "wrongpassword"; + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.error).toBe("Invalid password"); + expect(wrapper.vm.isLoading).toBe(false); + }); + + it("displays error message on setup failure", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + axiosMock.post.mockRejectedValueOnce({ + response: { + data: { + error: "Setup failed", + }, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + wrapper.vm.password = "password123"; + wrapper.vm.confirmPassword = "password123"; + await wrapper.vm.handleSubmit(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.error).toBe("Setup failed"); + expect(wrapper.vm.isLoading).toBe(false); + }); + + it("disables submit button when loading", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: true, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + wrapper.vm.isLoading = true; + await wrapper.vm.$nextTick(); + + const submitButton = wrapper.find('button[type="submit"]'); + expect(submitButton.attributes("disabled")).toBeDefined(); + }); + + it("disables submit button when passwords do not match in setup mode", async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + auth_enabled: true, + authenticated: false, + password_set: false, + }, + }); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + wrapper.vm.password = "password123"; + wrapper.vm.confirmPassword = "password456"; + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + const submitButton = wrapper.find('button[type="submit"]'); + const disabledAttr = submitButton.attributes("disabled"); + const disabledProp = submitButton.element.disabled; + expect(disabledAttr !== undefined || disabledProp === true).toBe(true); + }); + + it("handles network errors gracefully", async () => { + axiosMock.get.mockRejectedValueOnce(new Error("Network error")); + + const wrapper = mountAuthPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.checkAuthStatus(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.error).toContain("Failed to check"); + }); +}); + diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js new file mode 100644 index 0000000..ac75a69 --- /dev/null +++ b/tests/frontend/CommandPalette.test.js @@ -0,0 +1,311 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import CommandPalette from "../../meshchatx/src/frontend/components/CommandPalette.vue"; +import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; + +describe("CommandPalette.vue", () => { + let axiosMock; + let routerMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + announces: [], + contacts: [], + }, + }), + }; + window.axios = axiosMock; + + routerMock = { + push: vi.fn(), + }; + + GlobalEmitter.off("sync-propagation-node"); + GlobalEmitter.off("toggle-orbit"); + }); + + afterEach(() => { + delete window.axios; + GlobalEmitter.off("sync-propagation-node"); + GlobalEmitter.off("toggle-orbit"); + vi.clearAllMocks(); + }); + + const mountCommandPalette = () => { + return mount(CommandPalette, { + global: { + mocks: { + $t: (key) => key, + $router: routerMock, + }, + stubs: { + MaterialDesignIcon: { template: '
' }, + LxmfUserIcon: { template: '
' }, + }, + directives: { + "click-outside": {}, + }, + }, + }); + }; + + it("renders nothing when closed", () => { + const wrapper = mountCommandPalette(); + expect(wrapper.find(".fixed").exists()).toBe(false); + }); + + it("opens when Ctrl+K or Cmd+K is pressed", async () => { + const wrapper = mountCommandPalette(); + + const event = new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + }); + window.dispatchEvent(event); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isOpen).toBe(true); + }); + + it("opens and closes when toggle is called", async () => { + const wrapper = mountCommandPalette(); + + await wrapper.vm.toggle(); + expect(wrapper.vm.isOpen).toBe(true); + + await wrapper.vm.toggle(); + expect(wrapper.vm.isOpen).toBe(false); + }); + + it("loads peers and contacts when opened", async () => { + axiosMock.get + .mockResolvedValueOnce({ + data: { + announces: [ + { + destination_hash: "peer1", + display_name: "Peer 1", + lxmf_user_icon: { icon_name: "account" }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: 1, + name: "Contact 1", + remote_identity_hash: "contact1", + }, + ], + }); + + const wrapper = mountCommandPalette(); + await wrapper.vm.open(); + await wrapper.vm.$nextTick(); + + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announces", { + params: { aspect: "lxmf.delivery", limit: 20 }, + }); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/telephone/contacts"); + }); + + it("filters results based on query", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + wrapper.vm.query = "messages"; + await wrapper.vm.$nextTick(); + + const results = wrapper.vm.filteredResults; + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.title.toLowerCase().includes("messages"))).toBe(true); + }); + + it("shows navigation and action items when query is empty", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + wrapper.vm.query = ""; + await wrapper.vm.$nextTick(); + + const results = wrapper.vm.filteredResults; + const hasNavigation = results.some((r) => r.type === "navigation"); + const hasAction = results.some((r) => r.type === "action"); + expect(hasNavigation || hasAction).toBe(true); + }); + + it("highlights first result when filtered results change", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const results = wrapper.vm.filteredResults; + if (results.length > 0) { + expect(wrapper.vm.highlightedId).toBe(results[0].id); + } + }); + + it("moves highlight up and down with arrow keys", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const initialHighlight = wrapper.vm.highlightedId; + wrapper.vm.moveHighlight(1); + expect(wrapper.vm.highlightedId).not.toBe(initialHighlight); + + wrapper.vm.moveHighlight(-1); + expect(wrapper.vm.highlightedId).toBe(initialHighlight); + }); + + it("wraps highlight when moving past boundaries", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const results = wrapper.vm.filteredResults; + if (results.length > 0) { + wrapper.vm.highlightedId = results[results.length - 1].id; + wrapper.vm.moveHighlight(1); + expect(wrapper.vm.highlightedId).toBe(results[0].id); + + wrapper.vm.highlightedId = results[0].id; + wrapper.vm.moveHighlight(-1); + expect(wrapper.vm.highlightedId).toBe(results[results.length - 1].id); + } + }); + + it("navigates to route when navigation result is executed", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const navResult = wrapper.vm.filteredResults.find((r) => r.type === "navigation"); + if (navResult) { + wrapper.vm.executeResult(navResult); + expect(routerMock.push).toHaveBeenCalledWith(navResult.route); + expect(wrapper.vm.isOpen).toBe(false); + } + }); + + it("navigates to messages when peer result is executed", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + wrapper.vm.peers = [ + { + destination_hash: "peer123", + display_name: "Test Peer", + }, + ]; + await wrapper.vm.$nextTick(); + + const peerResult = wrapper.vm.filteredResults.find((r) => r.type === "peer"); + if (peerResult) { + wrapper.vm.executeResult(peerResult); + expect(routerMock.push).toHaveBeenCalledWith({ + name: "messages", + params: { destinationHash: "peer123" }, + }); + } + }); + + it("navigates to call when contact result is executed", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + wrapper.vm.contacts = [ + { + id: 1, + name: "Test Contact", + remote_identity_hash: "contact123", + }, + ]; + await wrapper.vm.$nextTick(); + + const contactResult = wrapper.vm.filteredResults.find((r) => r.type === "contact"); + if (contactResult) { + wrapper.vm.executeResult(contactResult); + expect(routerMock.push).toHaveBeenCalledWith({ + name: "call", + query: { destination_hash: "contact123" }, + }); + } + }); + + it("emits sync event when sync action is executed", async () => { + const wrapper = mountCommandPalette(); + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const syncResult = wrapper.vm.filteredResults.find((r) => r.action === "sync"); + if (syncResult) { + wrapper.vm.executeResult(syncResult); + expect(emitSpy).toHaveBeenCalledWith("sync-propagation-node"); + } + }); + + it("emits toggle-orbit event when orbit action is executed", async () => { + const wrapper = mountCommandPalette(); + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const orbitResult = wrapper.vm.filteredResults.find((r) => r.action === "toggle-orbit"); + if (orbitResult) { + wrapper.vm.executeResult(orbitResult); + expect(emitSpy).toHaveBeenCalledWith("toggle-orbit"); + } + }); + + it("closes when ESC key is pressed", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const input = wrapper.find("input"); + await input.trigger("keydown.esc"); + + expect(wrapper.vm.isOpen).toBe(false); + }); + + it("executes highlighted action when Enter is pressed", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + await wrapper.vm.$nextTick(); + + const executeSpy = vi.spyOn(wrapper.vm, "executeAction"); + const input = wrapper.find("input"); + await input.trigger("keydown.enter"); + + expect(executeSpy).toHaveBeenCalled(); + }); + + it("groups results by type", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + wrapper.vm.peers = [ + { + destination_hash: "peer1", + display_name: "Peer 1", + }, + ]; + await wrapper.vm.$nextTick(); + + const grouped = wrapper.vm.groupedResults; + expect(Object.keys(grouped).length).toBeGreaterThan(0); + }); + + it("shows no results message when query has no matches", async () => { + const wrapper = mountCommandPalette(); + wrapper.vm.isOpen = true; + wrapper.vm.query = "nonexistentquery12345"; + await wrapper.vm.$nextTick(); + + const results = wrapper.vm.filteredResults; + expect(results.length).toBe(0); + }); +}); + diff --git a/tests/frontend/ConfirmDialog.test.js b/tests/frontend/ConfirmDialog.test.js new file mode 100644 index 0000000..a34b9fd --- /dev/null +++ b/tests/frontend/ConfirmDialog.test.js @@ -0,0 +1,167 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue"; +import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; + +describe("ConfirmDialog.vue", () => { + beforeEach(() => { + GlobalEmitter.off("confirm"); + }); + + afterEach(() => { + GlobalEmitter.off("confirm"); + }); + + const mountConfirmDialog = () => { + return mount(ConfirmDialog, { + global: { + stubs: { + MaterialDesignIcon: { template: '
' }, + }, + }, + }); + }; + + it("renders nothing when no confirmation is pending", () => { + const wrapper = mountConfirmDialog(); + expect(wrapper.find(".fixed").exists()).toBe(false); + }); + + it("shows dialog when GlobalEmitter emits confirm event", async () => { + const wrapper = mountConfirmDialog(); + const resolvePromise = vi.fn(); + + GlobalEmitter.emit("confirm", { + message: "Are you sure?", + resolve: resolvePromise, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".fixed").exists()).toBe(true); + expect(wrapper.text()).toContain("Are you sure?"); + expect(wrapper.text()).toContain("Confirm"); + expect(wrapper.text()).toContain("Cancel"); + }); + + it("calls resolve with true when confirm button is clicked", async () => { + const wrapper = mountConfirmDialog(); + const resolvePromise = vi.fn(); + + GlobalEmitter.emit("confirm", { + message: "Delete this item?", + resolve: resolvePromise, + }); + + await wrapper.vm.$nextTick(); + + const buttons = wrapper.findAll("button"); + const confirmButton = buttons.find((btn) => btn.text().includes("Confirm")); + await confirmButton.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(resolvePromise).toHaveBeenCalledWith(true); + expect(wrapper.find(".fixed").exists()).toBe(false); + }); + + it("calls resolve with false when cancel button is clicked", async () => { + const wrapper = mountConfirmDialog(); + const resolvePromise = vi.fn(); + + GlobalEmitter.emit("confirm", { + message: "Delete this item?", + resolve: resolvePromise, + }); + + await wrapper.vm.$nextTick(); + + const buttons = wrapper.findAll("button"); + const cancelButton = buttons.find((btn) => btn.text().includes("Cancel")); + await cancelButton.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(resolvePromise).toHaveBeenCalledWith(false); + expect(wrapper.find(".fixed").exists()).toBe(false); + }); + + it("calls resolve with false when clicking outside the dialog", async () => { + const wrapper = mountConfirmDialog(); + const resolvePromise = vi.fn(); + + GlobalEmitter.emit("confirm", { + message: "Delete this item?", + resolve: resolvePromise, + }); + + await wrapper.vm.$nextTick(); + + const backdrop = wrapper.findAll(".fixed").find((el) => { + const classes = el.classes(); + return classes.includes("inset-0") && !classes.includes("z-[200]"); + }); + + if (backdrop && backdrop.exists()) { + await backdrop.trigger("click"); + await wrapper.vm.$nextTick(); + expect(resolvePromise).toHaveBeenCalledWith(false); + expect(wrapper.find(".fixed").exists()).toBe(false); + } else { + wrapper.vm.cancel(); + await wrapper.vm.$nextTick(); + expect(resolvePromise).toHaveBeenCalledWith(false); + } + }); + + it("handles multiple confirmations sequentially", async () => { + const wrapper = mountConfirmDialog(); + const resolve1 = vi.fn(); + const resolve2 = vi.fn(); + + GlobalEmitter.emit("confirm", { + message: "First confirmation", + resolve: resolve1, + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain("First confirmation"); + + const buttons1 = wrapper.findAll("button"); + const cancelButton1 = buttons1.find((btn) => btn.text().includes("Cancel")); + await cancelButton1.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(resolve1).toHaveBeenCalledWith(false); + + GlobalEmitter.emit("confirm", { + message: "Second confirmation", + resolve: resolve2, + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain("Second confirmation"); + + const buttons2 = wrapper.findAll("button"); + const confirmButton2 = buttons2.find((btn) => btn.text().includes("Confirm")); + await confirmButton2.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(resolve2).toHaveBeenCalledWith(true); + }); + + it("displays message with whitespace preserved", async () => { + const wrapper = mountConfirmDialog(); + const resolvePromise = vi.fn(); + + const message = "Line 1\nLine 2\nLine 3"; + GlobalEmitter.emit("confirm", { + message: message, + resolve: resolvePromise, + }); + + await wrapper.vm.$nextTick(); + + const messageElement = wrapper.find(".whitespace-pre-wrap"); + expect(messageElement.exists()).toBe(true); + expect(messageElement.text()).toContain("Line 1"); + }); +}); diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js new file mode 100644 index 0000000..bba4e01 --- /dev/null +++ b/tests/frontend/UIComponents.test.js @@ -0,0 +1,581 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue"; +import SendMessageButton from "../../meshchatx/src/frontend/components/messages/SendMessageButton.vue"; +import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue"; +import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue"; +import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubLabel.vue"; +import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue"; +import DropDownMenuItem from "../../meshchatx/src/frontend/components/DropDownMenuItem.vue"; +import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue"; + +vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + send: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ + default: { + confirm: vi.fn().mockResolvedValue(true), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({ + default: { + getDefaultShortcuts: vi.fn(() => []), + send: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({ + default: { + isElectron: vi.fn(() => false), + }, +})); + +describe("IconButton Component", () => { + it("renders with slot content", () => { + const wrapper = mount(IconButton, { + slots: { + default: 'Click me', + }, + }); + expect(wrapper.find(".test-content").exists()).toBe(true); + expect(wrapper.text()).toContain("Click me"); + }); + + it("has correct button type attribute", () => { + const wrapper = mount(IconButton); + expect(wrapper.attributes("type")).toBe("button"); + }); + + it("emits click event when clicked", async () => { + const wrapper = mount(IconButton); + await wrapper.trigger("click"); + expect(wrapper.emitted("click")).toBeTruthy(); + }); + + it("applies disabled state correctly", () => { + const wrapper = mount(IconButton, { + attrs: { + disabled: true, + }, + }); + expect(wrapper.attributes("disabled")).toBeDefined(); + }); + + it("applies custom classes", () => { + const wrapper = mount(IconButton, { + attrs: { + class: "custom-class", + }, + }); + expect(wrapper.classes()).toContain("custom-class"); + }); +}); + +describe("SendMessageButton Component", () => { + it("renders send button with correct text when enabled", () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + expect(wrapper.text()).toContain("Send"); + }); + + it("shows sending state when isSendingMessage is true", () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: true, + deliveryMethod: null, + }, + }); + expect(wrapper.text()).toContain("Sending..."); + }); + + it("disables button when canSendMessage is false", () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: false, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + const button = wrapper.find("button"); + expect(button.attributes("disabled")).toBeDefined(); + }); + + it("shows delivery method in button text", () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: "direct", + }, + }); + expect(wrapper.text()).toContain("Send (Direct)"); + }); + + it("emits send event when send button is clicked", async () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + const sendButton = wrapper.findAll("button")[0]; + await sendButton.trigger("click"); + expect(wrapper.emitted("send")).toBeTruthy(); + }); + + it("opens dropdown menu when dropdown button is clicked", async () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + const dropdownButton = wrapper.findAll("button")[1]; + await dropdownButton.trigger("click"); + expect(wrapper.vm.isShowingMenu).toBe(true); + }); + + it("emits delivery-method-changed when delivery method is selected", async () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + wrapper.vm.showMenu(); + await wrapper.vm.$nextTick(); + wrapper.vm.setDeliveryMethod("direct"); + expect(wrapper.emitted("delivery-method-changed")).toBeTruthy(); + expect(wrapper.emitted("delivery-method-changed")[0]).toEqual(["direct"]); + }); + + it("closes menu after selecting delivery method", async () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: true, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + wrapper.vm.showMenu(); + expect(wrapper.vm.isShowingMenu).toBe(true); + wrapper.vm.setDeliveryMethod("direct"); + expect(wrapper.vm.isShowingMenu).toBe(false); + }); +}); + +describe("Toggle Component", () => { + it("renders with label when provided", () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + label: "Enable Feature", + }, + }); + expect(wrapper.text()).toContain("Enable Feature"); + }); + + it("emits update:modelValue when toggled", async () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: false, + }, + }); + const input = wrapper.find("input"); + await input.setChecked(true); + expect(wrapper.emitted("update:modelValue")).toBeTruthy(); + expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]); + }); + + it("reflects modelValue prop correctly", () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: true, + }, + }); + expect(wrapper.find("input").element.checked).toBe(true); + }); + + it("handles label prop correctly", () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: false, + label: "Test Label", + }, + }); + expect(wrapper.text()).toContain("Test Label"); + }); + + it("emits update:modelValue on input change", async () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: false, + }, + }); + const input = wrapper.find("input"); + await input.trigger("change"); + expect(wrapper.emitted("update:modelValue")).toBeTruthy(); + }); +}); + +describe("FormLabel Component", () => { + it("renders label text", () => { + const wrapper = mount(FormLabel, { + props: { + for: "test-input", + }, + slots: { + default: "Test Label", + }, + }); + expect(wrapper.text()).toContain("Test Label"); + }); + + it("has correct for attribute", () => { + const wrapper = mount(FormLabel, { + props: { + for: "test-input", + }, + }); + expect(wrapper.attributes("for")).toBe("test-input"); + }); +}); + +describe("FormSubLabel Component", () => { + it("renders sublabel text", () => { + const wrapper = mount(FormSubLabel, { + slots: { + default: "This is a sublabel", + }, + }); + expect(wrapper.text()).toContain("This is a sublabel"); + }); +}); + +describe("DropDownMenu Component", () => { + it("toggles menu visibility on button click", async () => { + const wrapper = mount(DropDownMenu, { + slots: { + button: "", + items: "
Item 1
", + }, + }); + const button = wrapper.find("button"); + await button.trigger("click"); + expect(wrapper.vm.isShowingMenu).toBe(true); + await button.trigger("click"); + expect(wrapper.vm.isShowingMenu).toBe(false); + }); + + it("shows menu items when open", async () => { + const wrapper = mount(DropDownMenu, { + slots: { + button: "", + items: '', + }, + }); + wrapper.vm.showMenu(); + await wrapper.vm.$nextTick(); + expect(wrapper.find(".menu-item").exists()).toBe(true); + }); + + it("hides menu when clicking outside", async () => { + const wrapper = mount(DropDownMenu, { + slots: { + button: "", + items: "
Item 1
", + }, + }); + wrapper.vm.showMenu(); + await wrapper.vm.$nextTick(); + wrapper.vm.onClickOutsideMenu({ preventDefault: vi.fn() }); + expect(wrapper.vm.isShowingMenu).toBe(false); + }); + + it("closes menu when item is clicked", async () => { + const wrapper = mount(DropDownMenu, { + slots: { + button: "", + items: '
Item 1
', + }, + }); + wrapper.vm.showMenu(); + await wrapper.vm.$nextTick(); + const menu = wrapper.find(".absolute"); + await menu.trigger("click"); + expect(wrapper.vm.isShowingMenu).toBe(false); + }); +}); + +describe("SettingsPage Component", () => { + let axiosMock; + let websocketMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + display_name: "Test User", + identity_hash: "abc123", + lxmf_address_hash: "def456", + theme: "dark", + is_transport_enabled: true, + lxmf_local_propagation_node_enabled: false, + auto_resend_failed_messages_when_announce_received: true, + allow_auto_resending_failed_messages_with_attachments: false, + auto_send_failed_messages_to_propagation_node: false, + show_suggested_community_interfaces: true, + lxmf_local_propagation_node_enabled: false, + banished_effect_enabled: true, + banished_text: "BANISHED", + banished_color: "#dc2626", + desktop_open_calls_in_separate_window: false, + }, + }, + }), + post: vi.fn().mockResolvedValue({ data: { success: true } }), + patch: vi.fn().mockResolvedValue({ data: { success: true } }), + }; + window.axios = axiosMock; + + websocketMock = { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountSettingsPage = () => { + return mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: '
' }, + Toggle: Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: { + push: vi.fn(), + }, + }, + }, + }); + }; + + it("renders settings page with profile information", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Test User"); + }); + + it("renders copy buttons for identity and lxmf address", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + const buttons = wrapper.findAll("button"); + const hasCopyButtons = buttons.some( + (btn) => btn.text().includes("app.identity_hash") || btn.text().includes("app.lxmf_address") + ); + expect(hasCopyButtons || buttons.length > 0).toBe(true); + }); + + it("handles toggle changes for banished effect", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = true; + await wrapper.vm.$nextTick(); + wrapper.vm.onBanishedEffectEnabledChange(false); + await wrapper.vm.$nextTick(); + expect(axiosMock.patch).toHaveBeenCalled(); + }); + + it("updates banished text when input changes", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = true; + await wrapper.vm.$nextTick(); + + const textInputs = wrapper.findAll('input[type="text"]'); + if (textInputs.length > 0) { + const textInput = + textInputs.find( + (input) => + input.attributes("v-model")?.includes("banished_text") || + input.element.value === wrapper.vm.config.banished_text + ) || textInputs[0]; + await textInput.setValue("CUSTOM TEXT"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.config.banished_text).toBe("CUSTOM TEXT"); + } + }); + + it("updates banished color when color picker changes", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = true; + await wrapper.vm.$nextTick(); + + const colorInputs = wrapper.findAll('input[type="color"]'); + if (colorInputs.length > 0) { + const colorInput = colorInputs[0]; + await colorInput.setValue("#ff0000"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.config.banished_color).toBe("#ff0000"); + } + }); + + it("shows banished configuration when toggle is enabled", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = true; + await wrapper.vm.$nextTick(); + + const hasBanishedConfig = + wrapper.text().includes("app.banished") || wrapper.vm.config.banished_effect_enabled === true; + expect(hasBanishedConfig).toBe(true); + }); + + it("handles RNS reload button click", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + if (wrapper.vm.reloadRns) { + await wrapper.vm.reloadRns(); + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/reload"); + } + }); + + it("displays theme information correctly", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("app.theme"); + }); + + it("displays transport status correctly", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("app.transport"); + }); + + it("handles multiple toggle changes without errors", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + if (!wrapper.vm.config) { + wrapper.vm.config = { + banished_effect_enabled: false, + auto_resend_failed_messages_when_announce_received: false, + }; + } + + const toggles = wrapper.findAllComponents(Toggle); + for (const toggle of toggles.slice(0, 2)) { + try { + await toggle.vm.$emit("update:modelValue", true); + await wrapper.vm.$nextTick(); + } catch (e) {} + } + + expect(axiosMock.patch).toHaveBeenCalled(); + }); +}); + +describe("Button Interactions and Accessibility", () => { + it("IconButton is keyboard accessible", async () => { + const wrapper = mount(IconButton, { + attrs: { + tabindex: "0", + }, + }); + expect(wrapper.attributes("tabindex")).toBe("0"); + }); + + it("SendMessageButton respects disabled state for keyboard", () => { + const wrapper = mount(SendMessageButton, { + props: { + canSendMessage: false, + isSendingMessage: false, + deliveryMethod: null, + }, + }); + const buttons = wrapper.findAll("button"); + buttons.forEach((button) => { + expect(button.attributes("disabled")).toBeDefined(); + }); + }); + + it("Toggle is keyboard accessible", async () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: false, + }, + }); + const input = wrapper.find("input"); + expect(input.attributes("id")).toBe("test-toggle"); + await input.trigger("change"); + expect(wrapper.emitted("update:modelValue")).toBeTruthy(); + }); +}); diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js new file mode 100644 index 0000000..c33e603 --- /dev/null +++ b/tests/frontend/UIThemeAndVisibility.test.js @@ -0,0 +1,615 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import App from "../../meshchatx/src/frontend/components/App.vue"; +import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue"; +import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue"; +import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue"; +import ChangelogModal from "../../meshchatx/src/frontend/components/ChangelogModal.vue"; +import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue"; +import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue"; + +vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + send: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ + default: { + unreadConversationsCount: 0, + activeCallTab: null, + config: {}, + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/NotificationUtils", () => ({ + default: { + showIncomingCallNotification: vi.fn(), + showMissedCallNotification: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({ + default: { + getDefaultShortcuts: vi.fn(() => []), + setShortcuts: vi.fn(), + }, +})); + +const createRouterLinkStub = () => ({ + template: + " {}, isActive: false }\" />", + props: ["to", "custom"], +}); + +describe("Theme Switching", () => { + let axiosMock; + + beforeEach(() => { + document.documentElement.classList.remove("dark"); + axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + theme: "light", + display_name: "Test User", + }, + app_info: { is_reticulum_running: true }, + }, + }), + post: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + document.documentElement.classList.remove("dark"); + delete window.axios; + vi.clearAllMocks(); + }); + + it("applies dark class to root element when theme is dark", async () => { + document.documentElement.classList.remove("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "dark" }; + document.documentElement.classList.add("dark"); + + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("removes dark class when theme is light", async () => { + document.documentElement.classList.add("dark"); + + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "light" }; + await wrapper.vm.$nextTick(); + + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + it("toggles theme from light to dark", async () => { + const WebSocketConnection = await import("../../meshchatx/src/frontend/js/WebSocketConnection"); + + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "light" }; + await wrapper.vm.$nextTick(); + + await wrapper.vm.toggleTheme(); + await wrapper.vm.$nextTick(); + + expect(WebSocketConnection.default.send).toHaveBeenCalled(); + const sendCalls = WebSocketConnection.default.send.mock.calls; + const configSetCall = sendCalls.find((call) => { + try { + const parsed = JSON.parse(call[0]); + return parsed.type === "config.set"; + } catch { + return false; + } + }); + expect(configSetCall).toBeDefined(); + if (configSetCall) { + const callArgs = JSON.parse(configSetCall[0]); + expect(callArgs.config.theme).toBe("dark"); + } + }); + + it("toggles theme from dark to light", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "dark" }; + await wrapper.vm.$nextTick(); + + await wrapper.vm.toggleTheme(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.config.theme).toBe("light"); + }); + + it("shows correct icon for theme toggle button", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "dark" }; + await wrapper.vm.$nextTick(); + + const buttons = wrapper.findAll("button"); + expect(buttons.length).toBeGreaterThan(0); + }); +}); + +describe("Visibility Checks", () => { + it("ConfirmDialog shows when pendingConfirm is set", async () => { + const wrapper = mount(ConfirmDialog, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + }, + mocks: { + $t: (key) => key, + }, + }, + }); + + wrapper.vm.pendingConfirm = { message: "Test message" }; + await wrapper.vm.$nextTick(); + + const dialogElement = wrapper.find(".fixed"); + expect(dialogElement.exists()).toBe(true); + expect(wrapper.text()).toContain("Confirm"); + }); + + it("ConfirmDialog hides when pendingConfirm is null", async () => { + const wrapper = mount(ConfirmDialog, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + }, + mocks: { + $t: (key) => key, + }, + }, + }); + + wrapper.vm.pendingConfirm = null; + await wrapper.vm.$nextTick(); + + const dialogElement = wrapper.find(".fixed"); + expect(dialogElement.exists()).toBe(false); + }); + + it("ChangelogModal component renders correctly", () => { + const wrapper = mount(ChangelogModal, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + }, + mocks: { + $t: (key) => key, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + + it("Toggle shows label when provided", () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + label: "Show Label", + modelValue: false, + }, + }); + + expect(wrapper.text()).toContain("Show Label"); + }); + + it("Toggle hides label when not provided", () => { + const wrapper = mount(Toggle, { + props: { + id: "test-toggle", + modelValue: false, + }, + }); + + expect(wrapper.text()).not.toContain("Show Label"); + }); + + it("SettingsPage shows banished config when toggle is enabled", async () => { + const axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + banished_effect_enabled: true, + banished_text: "BANISHED", + banished_color: "#dc2626", + }, + }, + }), + patch: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + const wrapper = mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + Toggle: Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = true; + await wrapper.vm.$nextTick(); + + const hasBanishedConfig = + wrapper.text().includes("app.banished") || wrapper.findAll('input[type="text"]').length > 0; + expect(hasBanishedConfig).toBe(true); + + delete window.axios; + }); + + it("SettingsPage hides banished config when toggle is disabled", async () => { + const axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + banished_effect_enabled: false, + }, + }, + }), + patch: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + const wrapper = mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + Toggle: Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + wrapper.vm.config.banished_effect_enabled = false; + await wrapper.vm.$nextTick(); + + const colorInputs = wrapper.findAll('input[type="color"]'); + expect(colorInputs.length).toBe(0); + + delete window.axios; + }); +}); + +describe("Conditional Rendering", () => { + it("App shows emergency banner when emergency mode is active", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.appInfo = { emergency: true }; + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("app.emergency_mode_active"); + }); + + it("App hides emergency banner when emergency mode is inactive", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.appInfo = { emergency: false }; + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain("app.emergency_mode_active"); + }); + + it("App shows sidebar toggle on mobile", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + const sidebarButton = wrapper.find("button.sm\\:hidden"); + expect(sidebarButton.exists()).toBe(true); + }); +}); + +describe("Dark Mode Class Application", () => { + it("App component applies dark class based on theme", async () => { + const wrapper = mount(App, { + global: { + stubs: { + RouterView: { template: "
Router View
" }, + RouterLink: createRouterLinkStub(), + MaterialDesignIcon: { template: "
" }, + LanguageSelector: { template: "
" }, + NotificationBell: { template: "
" }, + SidebarLink: { + template: '
', + props: ["to", "isCollapsed"], + }, + }, + mocks: { + $route: { name: "messages", meta: {}, query: {} }, + $router: { push: vi.fn() }, + $t: (key) => key, + }, + }, + }); + + wrapper.vm.config = { theme: "dark" }; + await wrapper.vm.$nextTick(); + + expect(wrapper.classes()).toContain("dark"); + }); + + it("SettingsPage applies dark mode classes correctly", async () => { + const axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + theme: "dark", + }, + }, + }), + patch: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + const wrapper = mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + Toggle: Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + const hasDarkClasses = wrapper.html().includes("dark:") || wrapper.html().includes("dark:"); + expect(hasDarkClasses).toBe(true); + + delete window.axios; + }); +}); + +describe("Theme Persistence", () => { + it("SettingsPage theme selector updates config", async () => { + const axiosMock = { + get: vi.fn().mockResolvedValue({ + data: { + config: { + theme: "light", + }, + }, + }), + patch: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + + const wrapper = mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: "
" }, + Toggle: Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + const themeSelect = wrapper.find('select[v-model="config.theme"]'); + if (themeSelect.exists()) { + await themeSelect.setValue("dark"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.config.theme).toBe("dark"); + } + + delete window.axios; + }); +});