diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js new file mode 100644 index 0000000..a8c4470 --- /dev/null +++ b/tests/frontend/AboutPage.test.js @@ -0,0 +1,142 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import AboutPage from "@/components/about/AboutPage.vue"; + +describe("AboutPage.vue", () => { + let axiosMock; + + beforeEach(() => { + vi.useFakeTimers(); + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + window.URL.createObjectURL = vi.fn(); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete window.axios; + }); + + const mountAboutPage = () => { + return mount(AboutPage, { + global: { + mocks: { + $t: (key, params) => { + if (params) { + return `${key} ${JSON.stringify(params)}`; + } + return key; + }, + }, + stubs: { + MaterialDesignIcon: true, + }, + }, + }); + }; + + it("fetches app info and config on mount", async () => { + const appInfo = { + version: "1.0.0", + rns_version: "0.1.0", + lxmf_version: "0.2.0", + python_version: "3.11.0", + reticulum_config_path: "/path/to/config", + database_path: "/path/to/db", + database_file_size: 1024, + }; + const config = { + identity_hash: "hash1", + lxmf_address_hash: "hash2", + }; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/app/info") return Promise.resolve({ data: { app_info: appInfo } }); + if (url === "/api/v1/config") return Promise.resolve({ data: { config: config } }); + if (url === "/api/v1/database/health") + return Promise.resolve({ + data: { + database: { + quick_check: "ok", + journal_mode: "wal", + page_size: 4096, + page_count: 100, + freelist_pages: 5, + estimated_free_bytes: 20480, + }, + }, + }); + return Promise.reject(new Error("Not found")); + }); + + const wrapper = mountAboutPage(); + await vi.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); // Extra tick for multiple async calls + + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/app/info"); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + + expect(wrapper.text()).toContain("Reticulum MeshChatX"); + expect(wrapper.text()).toContain("hash1"); + expect(wrapper.text()).toContain("hash2"); + }); + + it("updates app info periodically", async () => { + axiosMock.get.mockResolvedValue({ + data: { + app_info: {}, + config: {}, + database: { + quick_check: "ok", + journal_mode: "wal", + page_size: 4096, + page_count: 100, + freelist_pages: 5, + estimated_free_bytes: 20480, + }, + }, + }); + mountAboutPage(); + + expect(axiosMock.get).toHaveBeenCalledTimes(3); // info, config, health + + vi.advanceTimersByTime(5000); + expect(axiosMock.get).toHaveBeenCalledTimes(4); + + vi.advanceTimersByTime(5000); + expect(axiosMock.get).toHaveBeenCalledTimes(5); + }); + + it("handles vacuum database action", async () => { + axiosMock.get.mockResolvedValue({ + data: { + app_info: {}, + config: {}, + database: { + quick_check: "ok", + journal_mode: "wal", + page_size: 4096, + page_count: 100, + freelist_pages: 5, + estimated_free_bytes: 20480, + }, + }, + }); + axiosMock.post.mockResolvedValue({ data: { message: "Vacuum success" } }); + + const wrapper = mountAboutPage(); + await wrapper.vm.$nextTick(); + + // Find vacuum button (it's the second button in the database health section) + // Or we can just call the method directly to be sure + await wrapper.vm.vacuumDatabase(); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/vacuum"); + expect(wrapper.vm.databaseActionMessage).toBe("Vacuum success"); + }); +}); diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js new file mode 100644 index 0000000..abaed1d --- /dev/null +++ b/tests/frontend/CallPage.test.js @@ -0,0 +1,123 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import CallPage from "@/components/call/CallPage.vue"; + +describe("CallPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockImplementation((url) => { + const defaultData = { + config: {}, + calls: [], + call_history: [], + announces: [], + voicemails: [], + active_call: null, + discovery: [], + contacts: [], + profiles: [], + ringtones: [], + voicemail: { unread_count: 0 }, + }; + + if (url.includes("/api/v1/config")) return Promise.resolve({ data: { config: {} } }); + if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } }); + if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } }); + if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } }); + if (url.includes("/api/v1/telephone/voicemail/status")) + return Promise.resolve({ data: { has_espeak: false } }); + if (url.includes("/api/v1/telephone/ringtones/status")) + return Promise.resolve({ data: { enabled: true } }); + if (url.includes("/api/v1/telephone/ringtones")) return Promise.resolve({ data: { ringtones: [] } }); + if (url.includes("/api/v1/telephone/audio-profiles")) + return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } }); + if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } }); + + return Promise.resolve({ data: defaultData }); + }), + post: vi.fn().mockResolvedValue({ data: {} }), + patch: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + }); + + const mountCallPage = () => { + return mount(CallPage, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: true, + LoadingSpinner: true, + LxmfUserIcon: true, + }, + }, + }); + }; + + it("renders tabs correctly", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + + // The tabs are hardcoded strings: Phone, Phonebook, Voicemail, Contacts + expect(wrapper.text()).toContain("Phone"); + expect(wrapper.text()).toContain("Phonebook"); + expect(wrapper.text()).toContain("Voicemail"); + expect(wrapper.text()).toContain("Contacts"); + }); + + it("switches tabs when clicked", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + + // Initial tab should be phone + expect(wrapper.vm.activeTab).toBe("phone"); + + // Click Phonebook tab + const buttons = wrapper.findAll("button"); + const phonebookTab = buttons.find((b) => b.text() === "Phonebook"); + if (phonebookTab) { + await phonebookTab.trigger("click"); + expect(wrapper.vm.activeTab).toBe("phonebook"); + } else { + throw new Error("Phonebook tab not found"); + } + }); + + it("displays 'New Call' UI by default when no active call", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("New Call"); + expect(wrapper.find('input[type="text"]').exists()).toBe(true); + }); + + it("attempts to place a call when 'Call' button is clicked", async () => { + const wrapper = mountCallPage(); + await wrapper.vm.$nextTick(); + + const input = wrapper.find('input[type="text"]'); + await input.setValue("test-destination"); + + // Find Call button - it's hardcoded "Call" + const buttons = wrapper.findAll("button"); + const callButton = buttons.find((b) => b.text() === "Call"); + if (callButton) { + await callButton.trigger("click"); + // CallPage.vue uses window.axios.get(`/api/v1/telephone/call/${hashToCall}`) + expect(axiosMock.get).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/telephone/call/test-destination") + ); + } else { + throw new Error("Call button not found"); + } + }); +}); diff --git a/tests/frontend/FormLabel.test.js b/tests/frontend/FormLabel.test.js new file mode 100644 index 0000000..816754e --- /dev/null +++ b/tests/frontend/FormLabel.test.js @@ -0,0 +1,20 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import FormLabel from "@/components/forms/FormLabel.vue"; + +describe("FormLabel.vue", () => { + it("renders slot content", () => { + const wrapper = mount(FormLabel, { + slots: { + default: "Label Text", + }, + }); + expect(wrapper.text()).toBe("Label Text"); + }); + + it("has correct classes", () => { + const wrapper = mount(FormLabel); + expect(wrapper.classes()).toContain("block"); + expect(wrapper.classes()).toContain("text-sm"); + }); +}); diff --git a/tests/frontend/FormSubLabel.test.js b/tests/frontend/FormSubLabel.test.js new file mode 100644 index 0000000..d1cd184 --- /dev/null +++ b/tests/frontend/FormSubLabel.test.js @@ -0,0 +1,19 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import FormSubLabel from "@/components/forms/FormSubLabel.vue"; + +describe("FormSubLabel.vue", () => { + it("renders slot content", () => { + const wrapper = mount(FormSubLabel, { + slots: { + default: "Sub Label Text", + }, + }); + expect(wrapper.text()).toBe("Sub Label Text"); + }); + + it("has correct classes", () => { + const wrapper = mount(FormSubLabel); + expect(wrapper.classes()).toContain("text-xs"); + }); +}); diff --git a/tests/frontend/GlobalState.test.js b/tests/frontend/GlobalState.test.js new file mode 100644 index 0000000..7706b97 --- /dev/null +++ b/tests/frontend/GlobalState.test.js @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import globalState from "@/js/GlobalState"; + +describe("GlobalState.js", () => { + it("has initial values", () => { + expect(globalState.unreadConversationsCount).toBe(0); + }); + + it("can be updated", () => { + globalState.unreadConversationsCount = 5; + expect(globalState.unreadConversationsCount).toBe(5); + }); +}); diff --git a/tests/frontend/LanguageSelector.test.js b/tests/frontend/LanguageSelector.test.js new file mode 100644 index 0000000..fff5897 --- /dev/null +++ b/tests/frontend/LanguageSelector.test.js @@ -0,0 +1,73 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi } from "vitest"; +import LanguageSelector from "@/components/LanguageSelector.vue"; + +describe("LanguageSelector.vue", () => { + const mountLanguageSelector = (locale = "en") => { + return mount(LanguageSelector, { + global: { + mocks: { + $t: (key) => key, + $i18n: { + locale: locale, + }, + }, + stubs: { + MaterialDesignIcon: true, + }, + }, + }); + }; + + it("renders the language selector button", () => { + const wrapper = mountLanguageSelector(); + expect(wrapper.find("button").exists()).toBe(true); + }); + + it("toggles the dropdown when the button is clicked", async () => { + const wrapper = mountLanguageSelector(); + const button = wrapper.find("button"); + + expect(wrapper.find(".absolute").exists()).toBe(false); + + await button.trigger("click"); + expect(wrapper.find(".absolute").exists()).toBe(true); + + await button.trigger("click"); + expect(wrapper.find(".absolute").exists()).toBe(false); + }); + + it("lists all available languages in the dropdown", async () => { + const wrapper = mountLanguageSelector(); + await wrapper.find("button").trigger("click"); + + const languageButtons = wrapper.findAll(".absolute button"); + expect(languageButtons).toHaveLength(3); + expect(languageButtons[0].text()).toContain("English"); + expect(languageButtons[1].text()).toContain("Deutsch"); + expect(languageButtons[2].text()).toContain("Русский"); + }); + + it("emits language-change when a different language is selected", async () => { + const wrapper = mountLanguageSelector("en"); + await wrapper.find("button").trigger("click"); + + const deButton = wrapper.findAll(".absolute button")[1]; + await deButton.trigger("click"); + + expect(wrapper.emitted("language-change")).toBeTruthy(); + expect(wrapper.emitted("language-change")[0]).toEqual(["de"]); + expect(wrapper.find(".absolute").exists()).toBe(false); + }); + + it("does not emit language-change when the current language is selected", async () => { + const wrapper = mountLanguageSelector("en"); + await wrapper.find("button").trigger("click"); + + const enButton = wrapper.findAll(".absolute button")[0]; + await enButton.trigger("click"); + + expect(wrapper.emitted("language-change")).toBeFalsy(); + expect(wrapper.find(".absolute").exists()).toBe(false); + }); +}); diff --git a/tests/frontend/MapPage.test.js b/tests/frontend/MapPage.test.js new file mode 100644 index 0000000..84618c5 --- /dev/null +++ b/tests/frontend/MapPage.test.js @@ -0,0 +1,198 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock TileCache BEFORE importing MapPage +vi.mock("@/js/TileCache", () => ({ + default: { + getTile: vi.fn(), + setTile: vi.fn(), + clear: vi.fn(), + initPromise: Promise.resolve(), + }, +})); + +// Mock OpenLayers +vi.mock("ol/Map", () => ({ + default: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + addLayer: vi.fn(), + addInteraction: vi.fn(), + getView: vi.fn().mockReturnValue({ + on: vi.fn(), + setCenter: vi.fn(), + setZoom: vi.fn(), + getCenter: vi.fn().mockReturnValue([0, 0]), + getZoom: vi.fn().mockReturnValue(2), + fit: vi.fn(), + animate: vi.fn(), + }), + getLayers: vi.fn().mockReturnValue({ + clear: vi.fn(), + push: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + }), + forEachFeatureAtPixel: vi.fn(), + setTarget: vi.fn(), + updateSize: vi.fn(), + })), +})); + +vi.mock("ol/View", () => ({ default: vi.fn() })); +vi.mock("ol/layer/Tile", () => ({ default: vi.fn() })); +vi.mock("ol/layer/Vector", () => ({ default: vi.fn() })); +vi.mock("ol/source/XYZ", () => ({ + default: vi.fn().mockImplementation(() => ({ + getTileLoadFunction: vi.fn().mockReturnValue(vi.fn()), + setTileLoadFunction: vi.fn(), + })), +})); +vi.mock("ol/source/Vector", () => ({ + default: vi.fn().mockImplementation(() => ({ + clear: vi.fn(), + addFeature: vi.fn(), + })), +})); +vi.mock("ol/proj", () => ({ + fromLonLat: vi.fn((coords) => coords), + toLonLat: vi.fn((coords) => coords), +})); +vi.mock("ol/control", () => ({ + defaults: vi.fn().mockReturnValue([]), +})); +vi.mock("ol/interaction/DragBox", () => ({ + default: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + })), +})); + +import MapPage from "@/components/map/MapPage.vue"; + +describe("MapPage.vue", () => { + let axiosMock; + + beforeEach(() => { + // Mock localStorage on window correctly + const localStorageMock = { + getItem: vi.fn().mockReturnValue("true"), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }; + Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true }); + + axiosMock = { + get: vi.fn().mockImplementation((url) => { + const defaultData = { + config: { map_offline_enabled: false }, + mbtiles: [], + conversations: [], + telemetry: [], + markers: [], + history: [], + announces: [], + }; + if (url.includes("/api/v1/config")) + return Promise.resolve({ data: { config: { map_offline_enabled: false } } }); + if (url.includes("/api/v1/map/mbtiles")) return Promise.resolve({ data: [] }); + if (url.includes("/api/v1/lxmf/conversations")) return Promise.resolve({ data: { conversations: [] } }); + if (url.includes("/api/v1/telemetry/peers")) return Promise.resolve({ data: { telemetry: [] } }); + if (url.includes("/api/v1/telemetry/markers")) return Promise.resolve({ data: { markers: [] } }); + if (url.includes("/api/v1/map/offline")) return Promise.resolve({ data: {} }); + if (url.includes("nominatim")) return Promise.resolve({ data: [] }); + return Promise.resolve({ data: defaultData }); + }), + post: vi.fn().mockResolvedValue({ data: {} }), + patch: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + }); + + const mountMapPage = () => { + return mount(MapPage, { + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $filters: { + formatDestinationHash: (h) => h, + }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + Toggle: { + template: '', + props: ["modelValue", "id"], + }, + LoadingSpinner: true, + }, + }, + }); + }; + + it("renders the map title", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain("map.title"); + }); + + it("toggles settings dropdown", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSettingsOpen).toBe(false); + + // Find settings button by icon name + const settingsButton = wrapper.find('.mdi-stub[data-icon-name="cog"]').element.parentElement; + await settingsButton.click(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSettingsOpen).toBe(true); + }); + + it("performs search and displays results", async () => { + // Mock fetch for search + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ place_id: 1, display_name: "Result 1", type: "city", lat: "0", lon: "0" }]), + }); + + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + + const searchInput = wrapper.find('input[type="text"]'); + await searchInput.trigger("focus"); + await searchInput.setValue("test search"); + + // Trigger search by enter key + await searchInput.trigger("keydown.enter"); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); // Wait for fetch + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Result 1"); + expect(wrapper.text()).toContain("city"); + + delete global.fetch; + }); + + it("toggles export mode", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + + const exportButton = wrapper.find('button[title="map.export_area"]'); + if (exportButton.exists()) { + await exportButton.trigger("click"); + expect(wrapper.vm.isExportMode).toBe(true); + expect(wrapper.text()).toContain("map.export_instructions"); + } + }); +}); diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js new file mode 100644 index 0000000..044a7d8 --- /dev/null +++ b/tests/frontend/MessagesPage.test.js @@ -0,0 +1,96 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import MessagesPage from "@/components/messages/MessagesPage.vue"; + +describe("MessagesPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") + return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } }); + if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } }); + if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } }); + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.axios; + }); + + const mountMessagesPage = (props = { destinationHash: "" }) => { + return mount(MessagesPage, { + props, + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $router: { replace: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: true, + LoadingSpinner: true, + MessagesSidebar: { + template: '', + props: ["conversations", "selectedDestinationHash"], + }, + ConversationViewer: { + template: '', + props: ["selectedPeer", "myLxmfAddressHash"], + }, + Modal: true, + }, + }, + }); + }; + + it("fetches config and conversations on mount", async () => { + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object)); + }); + + it("opens ingest paper message modal", async () => { + const wrapper = mountMessagesPage(); + await wrapper.vm.$nextTick(); + + // Find button to ingest paper message + const buttons = wrapper.findAll("button"); + const ingestButton = buttons.find((b) => b.html().includes('icon-name="note-plus"')); + if (ingestButton) { + await ingestButton.trigger("click"); + expect(wrapper.vm.isShowingIngestPaperMessageModal).toBe(true); + } + }); + + it("composes new message when destinationHash prop is provided", async () => { + const destHash = "0123456789abcdef0123456789abcdef"; + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/announces") + return Promise.resolve({ + data: { announces: [{ destination_hash: destHash, display_name: "Test Peer" }] }, + }); + if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } }); + if (url === "/api/v1/config") + return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } }); + return Promise.resolve({ data: {} }); + }); + + const wrapper = mountMessagesPage({ destinationHash: destHash }); + // Ensure conversations is initialized as array to avoid filter error in watcher + wrapper.vm.conversations = []; + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); // Wait for fetch + + expect(wrapper.vm.selectedPeer.destination_hash).toBe(destHash); + }); +}); diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js new file mode 100644 index 0000000..8776931 --- /dev/null +++ b/tests/frontend/MicronParser.test.js @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import MicronParser from "@/js/MicronParser"; + +describe("MicronParser.js", () => { + let parser; + + beforeEach(() => { + parser = new MicronParser(true); // darkTheme = true + }); + + describe("formatNomadnetworkUrl", () => { + it("formats nomadnetwork URL correctly", () => { + expect(MicronParser.formatNomadnetworkUrl("example.com")).toBe("nomadnetwork://example.com"); + }); + }); + + describe("convertMicronToHtml", () => { + it("converts simple text to HTML", () => { + const markup = "Hello World"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("Hello World"); + expect(html).toContain("