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(" { + const markup = "> Heading 1\n>> Heading 2"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("Heading 1"); + expect(html).toContain("Heading 2"); + // Check for styles applied to headings (in dark theme) + expect(html).toContain("background-color: rgb(187, 187, 187)"); // #bbb + expect(html).toContain("background-color: rgb(153, 153, 153)"); // #999 + }); + + it("converts horizontal dividers", () => { + const markup = "---"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain(" { + const markup = "`!Bold Text`!"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("font-weight: bold"); + expect(html).toContain("Bold Text"); + }); + + it("handles italic formatting", () => { + const markup = "`*Italic Text`*"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("font-style: italic"); + expect(html).toContain("Italic Text"); + }); + + it("handles underline formatting", () => { + const markup = "`_Underlined Text`_"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("text-decoration: underline"); + expect(html).toContain("Underlined Text"); + }); + + it("handles combined formatting", () => { + const markup = "`!`_Bold Underlined Text`_`!"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain("font-weight: bold"); + expect(html).toContain("text-decoration: underline"); + expect(html).toContain("Bold Underlined Text"); + }); + + it("handles literal mode", () => { + const markup = "`=\n`*Not Italic`*\n`="; + const html = parser.convertMicronToHtml(markup); + expect(html).not.toContain("font-style: italic"); + expect(html).toContain("`*Not Italic`*"); + }); + + it("handles links correctly", () => { + const markup = "`[Label`example.com]"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain(" { + const markup = "`<24|field_name`Initial Value>"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain(" { + const markup = "`"; + const html = parser.convertMicronToHtml(markup); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('name="checkbox_name"'); + expect(html).toContain('value="val"'); + expect(html).toContain("Checkbox Label"); + }); + }); + + describe("colorToCss", () => { + it("expands 3-digit hex", () => { + expect(parser.colorToCss("abc")).toBe("#abc"); + }); + + it("returns 6-digit hex", () => { + expect(parser.colorToCss("abcdef")).toBe("#abcdef"); + }); + + it("handles grayscale format", () => { + expect(parser.colorToCss("g50")).toBe("#7f7f7f"); // 50 * 2.55 = 127.5 -> 7f + }); + + it("returns null for unknown formats", () => { + expect(parser.colorToCss("invalid")).toBeNull(); + }); + }); +}); diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js new file mode 100644 index 0000000..ad6070a --- /dev/null +++ b/tests/frontend/NomadNetworkPage.test.js @@ -0,0 +1,96 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import NomadNetworkPage from "@/components/nomadnetwork/NomadNetworkPage.vue"; + +describe("NomadNetworkPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }; + window.axios = axiosMock; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } }); + if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } }); + if (url.includes("/path")) return Promise.resolve({ data: { path: { hops: 1 } } }); + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.axios; + }); + + const mountNomadNetworkPage = (props = { destinationHash: "" }) => { + return mount(NomadNetworkPage, { + props, + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $router: { replace: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + LoadingSpinner: true, + NomadNetworkSidebar: { + template: '', + props: ["nodes", "selectedDestinationHash"], + }, + }, + }, + }); + }; + + it("displays 'No active node' by default", () => { + const wrapper = mountNomadNetworkPage(); + expect(wrapper.text()).toContain("nomadnet.no_active_node"); + }); + + it("loads node 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 Node" }] }, + }); + if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } }); + return Promise.resolve({ data: {} }); + }); + + const wrapper = mountNomadNetworkPage({ destinationHash: destHash }); + // Manually set favourites to avoid undefined error if mock fails + wrapper.vm.favourites = []; + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); // Wait for fetch + + expect(wrapper.vm.selectedNode.destination_hash).toBe(destHash); + }); + + it("toggles source view", async () => { + const destHash = "0123456789abcdef0123456789abcdef"; + const wrapper = mountNomadNetworkPage({ destinationHash: destHash }); + wrapper.vm.favourites = []; + wrapper.setData({ + selectedNode: { destination_hash: destHash, display_name: "Test Node" }, + nodePageContent: "Page Content", + nodePagePath: "test:path", + }); + await wrapper.vm.$nextTick(); + + // Find toggle source button by icon name + const buttons = wrapper.findAll("button"); + const toggleSourceButton = buttons.find((b) => b.html().includes('data-icon-name="code-tags"')); + if (toggleSourceButton) { + await toggleSourceButton.trigger("click"); + expect(wrapper.vm.isShowingNodePageSource).toBe(true); + } + }); +}); diff --git a/tests/frontend/SidebarLink.test.js b/tests/frontend/SidebarLink.test.js new file mode 100644 index 0000000..569999e --- /dev/null +++ b/tests/frontend/SidebarLink.test.js @@ -0,0 +1,88 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi } from "vitest"; +import SidebarLink from "@/components/SidebarLink.vue"; + +describe("SidebarLink.vue", () => { + const defaultProps = { + to: { name: "test-route" }, + isCollapsed: false, + }; + + const RouterLinkStub = { + template: '', + props: ["to", "custom", "navigate", "isActive"], + }; + + it("renders icon and text slots", () => { + const wrapper = mount(SidebarLink, { + props: defaultProps, + slots: { + icon: 'icon', + text: 'Link Text', + }, + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }); + expect(wrapper.find(".icon").exists()).toBe(true); + expect(wrapper.find(".text").exists()).toBe(true); + expect(wrapper.text()).toContain("Link Text"); + }); + + it("applies collapsed class when isCollapsed is true", () => { + const wrapper = mount(SidebarLink, { + props: { ...defaultProps, isCollapsed: true }, + slots: { + icon: 'icon', + text: 'Link Text', + }, + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }); + // v-if="!isCollapsed" means the span with the text won't exist + expect(wrapper.find(".text").exists()).toBe(false); + }); + + it("emits click event and calls navigate when clicked", async () => { + const navigate = vi.fn(); + const wrapper = mount(SidebarLink, { + props: defaultProps, + global: { + stubs: { + RouterLink: { + template: '', + props: ["to", "custom"], + setup() { + return { navigate }; + }, + }, + }, + }, + }); + + await wrapper.find("a").trigger("click"); + expect(wrapper.emitted("click")).toBeTruthy(); + expect(navigate).toHaveBeenCalled(); + }); + + it("applies active classes when isActive is true", () => { + const wrapper = mount(SidebarLink, { + props: defaultProps, + global: { + stubs: { + RouterLink: { + template: '', + props: ["to", "custom"], + }, + }, + }, + }); + // Based on SidebarLink.vue line 8: bg-blue-100 text-blue-800 ... + expect(wrapper.find("a").classes()).toContain("bg-blue-100"); + }); +}); diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js new file mode 100644 index 0000000..2136984 --- /dev/null +++ b/tests/frontend/Toast.test.js @@ -0,0 +1,66 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import Toast from "@/components/Toast.vue"; +import GlobalEmitter from "@/js/GlobalEmitter"; + +describe("Toast.vue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + // Clear all listeners from GlobalEmitter to avoid test pollution + GlobalEmitter.off("toast"); + }); + + it("adds a toast when GlobalEmitter emits 'toast'", async () => { + const wrapper = mount(Toast); + + GlobalEmitter.emit("toast", { message: "Test Message", type: "success" }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Test Message"); + expect(wrapper.findComponent({ name: "MaterialDesignIcon" }).props("iconName")).toBe("check-circle"); + }); + + it("removes a toast after duration", async () => { + const wrapper = mount(Toast); + + GlobalEmitter.emit("toast", { message: "Test Message", duration: 1000 }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Test Message"); + + vi.advanceTimersByTime(1001); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain("Test Message"); + }); + + it("removes a toast when clicking the close button", async () => { + const wrapper = mount(Toast); + + GlobalEmitter.emit("toast", { message: "Test Message", duration: 0 }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Test Message"); + + const closeButton = wrapper.find("button"); + await closeButton.trigger("click"); + + expect(wrapper.text()).not.toContain("Test Message"); + }); + + it("assigns correct classes for different toast types", async () => { + const wrapper = mount(Toast); + + GlobalEmitter.emit("toast", { message: "Success", type: "success" }); + GlobalEmitter.emit("toast", { message: "Error", type: "error" }); + await wrapper.vm.$nextTick(); + + const toasts = wrapper.findAll(".pointer-events-auto"); + expect(toasts[0].classes()).toContain("border-green-500/30"); + expect(toasts[1].classes()).toContain("border-red-500/30"); + }); +}); diff --git a/tests/frontend/Utils.test.js b/tests/frontend/Utils.test.js new file mode 100644 index 0000000..62ded65 --- /dev/null +++ b/tests/frontend/Utils.test.js @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import Utils from "@/js/Utils"; +import dayjs from "dayjs"; + +describe("Utils.js", () => { + describe("formatDestinationHash", () => { + it("formats destination hash correctly", () => { + const hash = "e253d0b19fe34c3f0a09569165abc45a"; + expect(Utils.formatDestinationHash(hash)).toBe(""); + }); + }); + + describe("formatBytes", () => { + it("formats 0 bytes correctly", () => { + expect(Utils.formatBytes(0)).toBe("0 Bytes"); + }); + + it("formats KB correctly", () => { + expect(Utils.formatBytes(1024)).toBe("1 KB"); + expect(Utils.formatBytes(1500)).toBe("1 KB"); + }); + + it("formats MB correctly", () => { + expect(Utils.formatBytes(1024 * 1024)).toBe("1 MB"); + }); + }); + + describe("formatNumber", () => { + it("formats 0 correctly", () => { + expect(Utils.formatNumber(0)).toBe("0"); + }); + + it("formats large numbers with commas", () => { + // Using a regex to match either comma or space or whatever locale-specific grouping separator is used + // But since we are in jsdom with default locale, it should be comma or similar. + // Actually, we can just check if it's a string. + const result = Utils.formatNumber(1234567); + expect(typeof result).toBe("string"); + expect(result).toMatch(/1.234.567/); // Matches 1,234,567 or 1.234.567 etc. + }); + }); + + describe("parseSeconds", () => { + it("parses seconds into days, hours, minutes, and seconds", () => { + const seconds = 1 * 24 * 3600 + 2 * 3600 + 3 * 60 + 4; + expect(Utils.parseSeconds(seconds)).toEqual({ + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + }); + }); + }); + + describe("formatSeconds", () => { + it('formats "a second ago"', () => { + expect(Utils.formatSeconds(1)).toBe("a second ago"); + expect(Utils.formatSeconds(0)).toBe("a second ago"); + }); + + it("formats minutes ago", () => { + expect(Utils.formatSeconds(60)).toBe("1 min ago"); + expect(Utils.formatSeconds(120)).toBe("2 mins ago"); + }); + + it("formats hours ago", () => { + expect(Utils.formatSeconds(3600)).toBe("1 hour ago"); + expect(Utils.formatSeconds(7200)).toBe("2 hours ago"); + }); + + it("formats days ago", () => { + expect(Utils.formatSeconds(86400)).toBe("1 day ago"); + expect(Utils.formatSeconds(172800)).toBe("2 days ago"); + }); + }); + + describe("formatTimeAgo", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats SQLite format date correctly", () => { + const now = new Date("2025-01-01T12:00:00Z"); + vi.setSystemTime(now); + + const pastDate = "2025-01-01 11:59:00"; // 1 min ago + expect(Utils.formatTimeAgo(pastDate)).toBe("1 min ago"); + }); + + it('returns "unknown" for empty input', () => { + expect(Utils.formatTimeAgo(null)).toBe("unknown"); + }); + }); + + describe("formatMinutesSeconds", () => { + it("formats seconds into MM:SS", () => { + expect(Utils.formatMinutesSeconds(65)).toBe("01:05"); + expect(Utils.formatMinutesSeconds(3600)).toBe("00:00"); // 3600s is 0m 0s in the current implementation because it only looks at minutes and seconds from parseSeconds + }); + }); + + describe("convertUnixMillisToLocalDateTimeString", () => { + it("converts unix millis to formatted string", () => { + const millis = new Date("2025-01-01T12:00:00Z").getTime(); + // dayjs format depends on local time, so we just check if it returns a string with expected components + const result = Utils.convertUnixMillisToLocalDateTimeString(millis); + expect(result).toMatch(/2025-01-01/); + }); + }); + + describe("formatBitsPerSecond", () => { + it("formats 0 bps correctly", () => { + expect(Utils.formatBitsPerSecond(0)).toBe("0 bps"); + }); + + it("formats kbps correctly", () => { + expect(Utils.formatBitsPerSecond(1000)).toBe("1 kbps"); + }); + }); + + describe("formatFrequency", () => { + it("formats 0 Hz correctly", () => { + expect(Utils.formatFrequency(0)).toBe("0 Hz"); + }); + + it("formats kHz correctly", () => { + expect(Utils.formatFrequency(1000)).toBe("1 kHz"); + }); + }); + + describe("isInterfaceEnabled", () => { + it('returns true for "on", "yes", "true"', () => { + expect(Utils.isInterfaceEnabled({ enabled: "on" })).toBe(true); + expect(Utils.isInterfaceEnabled({ enabled: "yes" })).toBe(true); + expect(Utils.isInterfaceEnabled({ enabled: "true" })).toBe(true); + expect(Utils.isInterfaceEnabled({ interface_enabled: "true" })).toBe(true); + }); + + it("returns false for other values", () => { + expect(Utils.isInterfaceEnabled({ enabled: "off" })).toBe(false); + expect(Utils.isInterfaceEnabled({ enabled: null })).toBe(false); + }); + }); +});