From 54de21e265bc7bee0e0c5d36c52484f9fd36fbc0 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 23 Apr 2026 02:28:39 -0500 Subject: [PATCH] feat(tests): add frontend test coverage with new map components, geodesy functions, and audio handling tests --- tests/frontend/CallPage.test.js | 72 +++- tests/frontend/MapDrawing.test.js | 68 +++- tests/frontend/MapPage.test.js | 80 +++- tests/frontend/MapPageClustering.test.js | 1 + tests/frontend/MicronParser.test.js | 2 +- tests/frontend/MicrophoneRecorder.test.js | 49 ++- tests/frontend/ToolsPage.test.js | 5 +- tests/frontend/mapComponents.test.js | 351 ++++++++++++++++++ tests/frontend/mapExchange.test.js | 179 +++++++++ tests/frontend/mapGeodesy.test.js | 73 ++++ tests/frontend/mapLinkUtils.test.js | 61 +++ tests/frontend/mapTileNetwork.test.js | 103 +++++ .../telemetryBatteryChartSpec.test.js | 63 ++++ 13 files changed, 1086 insertions(+), 21 deletions(-) create mode 100644 tests/frontend/mapComponents.test.js create mode 100644 tests/frontend/mapExchange.test.js create mode 100644 tests/frontend/mapGeodesy.test.js create mode 100644 tests/frontend/mapLinkUtils.test.js create mode 100644 tests/frontend/mapTileNetwork.test.js create mode 100644 tests/frontend/telemetryBatteryChartSpec.test.js diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js index 6f562442..96b73d33 100644 --- a/tests/frontend/CallPage.test.js +++ b/tests/frontend/CallPage.test.js @@ -261,10 +261,53 @@ describe("CallPage.vue", () => { expect(stop).toHaveBeenCalled(); }); + it("onToggleWebAudio enabling without active call skips microphone preflight", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.config = { telephone_web_audio_enabled: false }; + wrapper.vm.activeCall = null; + const permit = vi.spyOn(wrapper.vm, "requestAudioPermission").mockResolvedValue(true); + const patch = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined); + await wrapper.vm.onToggleWebAudio(true); + expect(permit).not.toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith({ telephone_web_audio_enabled: true }); + }); + + it("onToggleWebAudio enabling during active call runs microphone preflight", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.config = { telephone_web_audio_enabled: false }; + wrapper.vm.activeCall = { status: 6 }; + const permit = vi.spyOn(wrapper.vm, "requestAudioPermission").mockResolvedValue(true); + const patch = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined); + const start = vi.spyOn(wrapper.vm, "startWebAudio").mockResolvedValue(undefined); + await wrapper.vm.onToggleWebAudio(true); + expect(permit).toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith({ telephone_web_audio_enabled: true }); + expect(start).toHaveBeenCalled(); + }); + + it("getUserMediaWithMicFallback clears stale device id and retries wide open", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.selectedAudioInputId = "gone"; + wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "gone" }]; + const err = new Error("over"); + err.name = "OverconstrainedError"; + const fakeStream = { getTracks: () => [{ stop: vi.fn() }] }; + const getUserMedia = vi.fn().mockRejectedValueOnce(err).mockResolvedValueOnce(fakeStream); + const mediaDevices = { getUserMedia, enumerateDevices: vi.fn().mockResolvedValue([]) }; + const stream = await wrapper.vm.getUserMediaWithMicFallback(mediaDevices); + expect(getUserMedia).toHaveBeenCalledTimes(2); + expect(wrapper.vm.selectedAudioInputId).toBeNull(); + expect(stream).toBe(fakeStream); + }); + it("startWebAudio disables bridge when media devices API is missing", async () => { const wrapper = mountCallPage(); await flushPromises(); wrapper.vm.config = { telephone_web_audio_enabled: true }; + wrapper.vm.activeCall = { status: 6 }; const updateConfig = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined); const stopWebAudio = vi.spyOn(wrapper.vm, "stopWebAudio"); const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); @@ -307,7 +350,7 @@ describe("CallPage.vue", () => { } }); - it("refreshAudioDevices clears stale devices when media devices API is missing", async () => { + it("refreshAudioDevices uses default placeholders when media devices API is missing", async () => { const wrapper = mountCallPage(); await flushPromises(); wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "old-in" }]; @@ -320,8 +363,22 @@ describe("CallPage.vue", () => { try { await wrapper.vm.refreshAudioDevices(); - expect(wrapper.vm.audioInputDevices).toEqual([]); - expect(wrapper.vm.audioOutputDevices).toEqual([]); + expect(wrapper.vm.audioInputDevices).toEqual([ + { + deviceId: "__meshchat_default_in__", + kind: "audioinput", + label: "Default", + groupId: "", + }, + ]); + expect(wrapper.vm.audioOutputDevices).toEqual([ + { + deviceId: "__meshchat_default_out__", + kind: "audiooutput", + label: "Default", + groupId: "", + }, + ]); } finally { if (mediaDevicesDescriptor) { Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); @@ -346,8 +403,11 @@ describe("CallPage.vue", () => { }; const sourceDisconnect = vi.fn(); wrapper.vm.audioSourceNode = { disconnect: sourceDisconnect }; - const processorDisconnect = vi.fn(); - wrapper.vm.audioProcessor = { disconnect: processorDisconnect }; + const workletDisconnect = vi.fn(); + wrapper.vm.audioWorkletNode = { + port: { onmessage: vi.fn() }, + disconnect: workletDisconnect, + }; const stopTrack = vi.fn(); wrapper.vm.audioStream = { getTracks: () => [{ stop: stopTrack }] }; const ctxClose = vi.fn().mockResolvedValue(undefined); @@ -357,7 +417,7 @@ describe("CallPage.vue", () => { expect(wsClose).toHaveBeenCalledTimes(1); expect(sourceDisconnect).toHaveBeenCalledTimes(1); - expect(processorDisconnect).toHaveBeenCalledTimes(1); + expect(workletDisconnect).toHaveBeenCalledTimes(1); expect(stopTrack).toHaveBeenCalledTimes(1); expect(ctxClose).toHaveBeenCalledTimes(1); expect(wrapper.vm.audioWs).toBeNull(); diff --git a/tests/frontend/MapDrawing.test.js b/tests/frontend/MapDrawing.test.js index eecf10b7..056f46e3 100644 --- a/tests/frontend/MapDrawing.test.js +++ b/tests/frontend/MapDrawing.test.js @@ -1,4 +1,4 @@ -import { mount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; import MapPage from "@/components/map/MapPage.vue"; @@ -28,6 +28,7 @@ vi.mock("ol/Map", () => ({ on: vi.fn(), un: vi.fn(), addLayer: vi.fn(), + addControl: vi.fn(), removeLayer: vi.fn(), addInteraction: vi.fn(), removeInteraction: vi.fn(), @@ -51,6 +52,8 @@ vi.mock("ol/Map", () => ({ getArray: vi.fn().mockReturnValue([]), }), forEachFeatureAtPixel: vi.fn(), + getEventPixel: vi.fn().mockReturnValue([0, 0]), + getTargetElement: vi.fn().mockReturnValue({ style: {} }), setTarget: vi.fn(), updateSize: vi.fn(), getViewport: vi.fn().mockReturnValue({ @@ -60,6 +63,11 @@ vi.mock("ol/Map", () => ({ }; }), })); +vi.mock("ol/control/ScaleLine", () => ({ + default: vi.fn().mockImplementation(function () { + return {}; + }), +})); vi.mock("ol/View", () => ({ default: vi.fn(function () {}) })); vi.mock("ol/layer/Tile", () => ({ default: vi.fn(function () {}) })); @@ -360,4 +368,62 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => { expect(wrapper.text()).toContain("Saved Layer 1"); }); + + it("renders bearing toolbar controls", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('button[title="map.tool_bearing"]').exists()).toBe(true); + expect(wrapper.find('button[title="map.tool_bearing_from_here"]').exists()).toBe(true); + }); + + it("toggles bearing mode", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await wrapper.vm.$nextTick(); + const bearingBtn = wrapper.find('button[title="map.tool_bearing"]'); + await bearingBtn.trigger("click"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isBearingMode).toBe(true); + await bearingBtn.trigger("click"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isBearingMode).toBe(false); + }); + + it("entering bearing mode turns off measurement mode", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await wrapper.vm.$nextTick(); + await wrapper.find('button[title="map.tool_measure"]').trigger("click"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isMeasuring).toBe(true); + await wrapper.find('button[title="map.tool_bearing"]').trigger("click"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isMeasuring).toBe(false); + expect(wrapper.vm.isBearingMode).toBe(true); + }); + + it("bearing from here sets GPS anchor when geolocation succeeds", async () => { + const origNav = global.navigator; + try { + global.navigator = { + geolocation: { + getCurrentPosition: vi.fn((success) => success({ coords: { longitude: -0.1, latitude: 51.5 } })), + }, + }; + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await wrapper.vm.$nextTick(); + await wrapper.find('button[title="map.tool_bearing_from_here"]').trigger("click"); + await flushPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.isBearingMode).toBe(true); + expect(wrapper.vm.bearingFromGps).toBe(true); + expect(wrapper.vm.bearingGpsMapCoord).toEqual([-0.1, 51.5]); + } finally { + global.navigator = origNav; + } + }); }); diff --git a/tests/frontend/MapPage.test.js b/tests/frontend/MapPage.test.js index 5d357e5b..51549676 100644 --- a/tests/frontend/MapPage.test.js +++ b/tests/frontend/MapPage.test.js @@ -1,4 +1,4 @@ -import { mount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; // Mock TileCache BEFORE importing MapPage @@ -26,6 +26,7 @@ vi.mock("ol/Map", () => ({ return { on: vi.fn(), addLayer: vi.fn(), + addControl: vi.fn(), addInteraction: vi.fn(), addOverlay: vi.fn(), removeInteraction: vi.fn(), @@ -90,6 +91,11 @@ vi.mock("ol/proj", () => ({ vi.mock("ol/control", () => ({ defaults: vi.fn().mockReturnValue([]), })); +vi.mock("ol/control/ScaleLine", () => ({ + default: vi.fn().mockImplementation(function () { + return {}; + }), +})); vi.mock("ol/interaction/Draw", () => ({ default: vi.fn().mockImplementation(function () { return { on: vi.fn(), setActive: vi.fn() }; @@ -306,6 +312,40 @@ describe("MapPage.vue", () => { delete global.fetch; }); + it("uses stacked MBTiles only for default or known online tile presets, not local tile URLs", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + wrapper.vm.offlineEnabled = true; + wrapper.vm.tileServerUrl = "http://192.168.1.10/tiles/{z}/{x}/{y}.png"; + expect(wrapper.vm.usesOfflineMbtilesRaster()).toBe(false); + wrapper.vm.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; + expect(wrapper.vm.usesOfflineMbtilesRaster()).toBe(true); + }); + + it("search uses custom local nominatim base URL in request", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + wrapper.vm.nominatimApiUrl = "http://127.0.0.1:18181/nominatim/"; + const searchInput = wrapper.find('input[type="text"]'); + await searchInput.trigger("focus"); + await searchInput.setValue("hq"); + await searchInput.trigger("keydown.enter"); + await flushPromises(); + + expect(global.fetch).toHaveBeenCalled(); + const calledUrl = String(global.fetch.mock.calls[0][0]); + expect(calledUrl).toContain("127.0.0.1:18181"); + expect(calledUrl).toContain("/search?"); + expect(calledUrl).toContain(encodeURIComponent("hq")); + + delete global.fetch; + }); + it("toggles export mode", async () => { const wrapper = mountMapPage(); await wrapper.vm.$nextTick(); @@ -318,6 +358,44 @@ describe("MapPage.vue", () => { } }); + it("serializeFeatures drops bearing preview features", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + const preview = { + get: (k) => (k === "bearingPreview" ? true : undefined), + }; + const normal = { + get: () => undefined, + clone: () => ({ + unset: vi.fn(), + getGeometry: () => null, + setGeometry: vi.fn(), + }), + getStyle: () => null, + }; + const out = wrapper.vm.serializeFeatures([preview, normal]); + expect(out).toHaveLength(1); + }); + + it("toggleBearingMode enables and disables bearing state with a stub map", async () => { + const wrapper = mountMapPage(); + await wrapper.vm.$nextTick(); + wrapper.vm.map = { + addOverlay: vi.fn(), + removeOverlay: vi.fn(), + un: vi.fn(), + }; + wrapper.vm.select = { setActive: vi.fn(), getFeatures: () => ({ clear: vi.fn(), push: vi.fn() }) }; + wrapper.vm.translate = { setActive: vi.fn() }; + wrapper.vm.modify = { setActive: vi.fn() }; + wrapper.vm.toggleBearingMode(); + expect(wrapper.vm.isBearingMode).toBe(true); + expect(wrapper.vm.drawType).toBe("Bearing"); + wrapper.vm.toggleBearingMode(); + expect(wrapper.vm.isBearingMode).toBe(false); + expect(wrapper.vm.select.setActive).toHaveBeenCalledWith(true); + }); + it("handles a large number of search results with overflow", async () => { const manyResults = Array.from({ length: 100 }, (_, i) => ({ place_id: i, diff --git a/tests/frontend/MapPageClustering.test.js b/tests/frontend/MapPageClustering.test.js index 65ae9196..082f3679 100644 --- a/tests/frontend/MapPageClustering.test.js +++ b/tests/frontend/MapPageClustering.test.js @@ -34,6 +34,7 @@ const viewMock = { const mapMock = { on: vi.fn(), addLayer: vi.fn(), + addControl: vi.fn(), addInteraction: vi.fn(), addOverlay: vi.fn(), removeInteraction: vi.fn(), diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js index d2eb4d65..2dd64604 100644 --- a/tests/frontend/MicronParser.test.js +++ b/tests/frontend/MicronParser.test.js @@ -495,7 +495,7 @@ describe("MicronParser.js", () => { const markup = "\n".repeat(10000); const start = Date.now(); parser.convertMicronToHtml(markup); - expect(Date.now() - start).toBeLessThan(1500); + expect(Date.now() - start).toBeLessThan(2500); }); it("handles rapid format toggle (open/close/open/close) quickly", () => { diff --git a/tests/frontend/MicrophoneRecorder.test.js b/tests/frontend/MicrophoneRecorder.test.js index 2fb20f45..0f5421b4 100644 --- a/tests/frontend/MicrophoneRecorder.test.js +++ b/tests/frontend/MicrophoneRecorder.test.js @@ -2,8 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import MicrophoneRecorder from "@/js/MicrophoneRecorder"; function installFakeAudioContext({ sampleRate = 48000 } = {}) { - const processorNode = { - onaudioprocess: null, + const workletNode = { + port: { + onmessage: null, + }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + const gainNode = { + gain: { value: 0 }, connect: vi.fn(), disconnect: vi.fn(), }; @@ -17,15 +24,23 @@ function installFakeAudioContext({ sampleRate = 48000 } = {}) { resume: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), createMediaStreamSource: vi.fn(() => sourceNode), - createScriptProcessor: vi.fn(() => processorNode), + createGain: vi.fn(() => gainNode), + audioWorklet: { + addModule: vi.fn().mockResolvedValue(undefined), + }, }; const original = globalThis.AudioContext; + const originalAWN = globalThis.AudioWorkletNode; globalThis.AudioContext = vi.fn(function FakeAudioContext() { return ctx; }); + globalThis.AudioWorkletNode = vi.fn(function FakeAudioWorkletNode() { + return workletNode; + }); return { ctx, - processorNode, + workletNode, + gainNode, sourceNode, restore() { if (typeof original === "undefined") { @@ -33,6 +48,11 @@ function installFakeAudioContext({ sampleRate = 48000 } = {}) { } else { globalThis.AudioContext = original; } + if (typeof originalAWN === "undefined") { + Reflect.deleteProperty(globalThis, "AudioWorkletNode"); + } else { + globalThis.AudioWorkletNode = originalAWN; + } }, }; } @@ -112,17 +132,26 @@ describe("MicrophoneRecorder", () => { await expect(recorder.start()).resolves.toBe(true); expect(audio.ctx.createMediaStreamSource).toHaveBeenCalledTimes(1); - expect(audio.ctx.createScriptProcessor).toHaveBeenCalledWith(4096, 1, 1); - expect(audio.sourceNode.connect).toHaveBeenCalledWith(audio.processorNode); - expect(audio.processorNode.connect).toHaveBeenCalledWith(audio.ctx.destination); + expect(audio.ctx.audioWorklet.addModule).toHaveBeenCalledTimes(1); + expect(globalThis.AudioWorkletNode).toHaveBeenCalledWith( + audio.ctx, + "microphone-pcm-float", + expect.objectContaining({ + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 1, + }) + ); + expect(audio.sourceNode.connect).toHaveBeenCalledWith(audio.workletNode); + expect(audio.workletNode.connect).toHaveBeenCalledWith(audio.gainNode); + expect(audio.gainNode.connect).toHaveBeenCalledWith(audio.ctx.destination); const frame = new Float32Array(1024); for (let i = 0; i < frame.length; i++) { frame[i] = Math.sin((i / frame.length) * Math.PI * 2) * 0.5; } - audio.processorNode.onaudioprocess({ - inputBuffer: { getChannelData: () => frame }, - }); + expect(typeof audio.workletNode.port.onmessage).toBe("function"); + audio.workletNode.port.onmessage({ data: frame.buffer.slice(0) }); const blob = await recorder.stop(); expect(blob).toBeInstanceOf(Blob); diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js index 76dabaf2..b122f29d 100644 --- a/tests/frontend/ToolsPage.test.js +++ b/tests/frontend/ToolsPage.test.js @@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => { { path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } }, { path: "/debug-logs", name: "debug-logs", component: { template: "div" } }, { path: "/mesh-server", name: "mesh-server", component: { template: "div" } }, + { path: "/tools/repository-server", name: "repository-server", component: { template: "div" } }, { path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } }, ], }); @@ -54,7 +55,7 @@ describe("ToolsPage.vue", () => { it("renders all tool rows", () => { const wrapper = mountToolsPage(); const toolRows = wrapper.findAll(".tool-row"); - expect(toolRows.length).toBe(20); + expect(toolRows.length).toBe(21); }); it("filters tools based on search query", async () => { @@ -79,6 +80,6 @@ describe("ToolsPage.vue", () => { await clearButton.trigger("click"); expect(wrapper.vm.searchQuery).toBe(""); - expect(wrapper.vm.filteredTools.length).toBe(20); + expect(wrapper.vm.filteredTools.length).toBe(21); }); }); diff --git a/tests/frontend/mapComponents.test.js b/tests/frontend/mapComponents.test.js new file mode 100644 index 00000000..420a5e11 --- /dev/null +++ b/tests/frontend/mapComponents.test.js @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: 0BSD + +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import MapDrawingToolbar from "@/components/map/internal/MapDrawingToolbar.vue"; +import MapBearingInstructions from "@/components/map/internal/MapBearingInstructions.vue"; +import MapSearchBar from "@/components/map/internal/MapSearchBar.vue"; +import MapExportInstructions from "@/components/map/internal/MapExportInstructions.vue"; +import MapNoMapWarning from "@/components/map/internal/MapNoMapWarning.vue"; +import MapLoadingOverlay from "@/components/map/internal/MapLoadingOverlay.vue"; +import MapExportConfigPanel from "@/components/map/internal/MapExportConfigPanel.vue"; +import MapExportProgressPanel from "@/components/map/internal/MapExportProgressPanel.vue"; +import MapClusterPanel from "@/components/map/internal/MapClusterPanel.vue"; +import MapMarkerPanel from "@/components/map/internal/MapMarkerPanel.vue"; +import MapVectorExchangePanel from "@/components/map/internal/MapVectorExchangePanel.vue"; + +const DRAWING_TOOLS = [ + { type: "Select", icon: "cursor-default" }, + { type: "Point", icon: "map-marker-plus" }, + { type: "LineString", icon: "vector-line" }, + { type: "Polygon", icon: "vector-polygon" }, + { type: "Circle", icon: "circle-outline" }, + { type: "Export", icon: "crop-free" }, +]; + +function t(key) { + return key; +} + +describe("MapDrawingToolbar", () => { + it("emits toggle-bearing and bearing-from-here", async () => { + const wrapper = mount(MapDrawingToolbar, { + props: { + tools: DRAWING_TOOLS, + drawType: null, + measuring: false, + bearingMode: false, + bearingFromGps: false, + exportMode: false, + selectedFeature: null, + }, + global: { mocks: { $t: t } }, + }); + await wrapper.find('button[title="map.tool_bearing"]').trigger("click"); + expect(wrapper.emitted("toggle-bearing")).toHaveLength(1); + await wrapper.find('button[title="map.tool_bearing_from_here"]').trigger("click"); + expect(wrapper.emitted("bearing-from-here")).toHaveLength(1); + }); + + it("applies bearing highlight classes when bearingMode is true", () => { + const wrapper = mount(MapDrawingToolbar, { + props: { + tools: DRAWING_TOOLS, + bearingMode: true, + bearingFromGps: false, + measuring: false, + exportMode: false, + selectedFeature: null, + }, + global: { mocks: { $t: t } }, + }); + const bearingBtn = wrapper.find('button[title="map.tool_bearing"]'); + expect(bearingBtn.classes().some((c) => c.includes("teal"))).toBe(true); + }); + + it("emits toggle-measure and toggle-draw", async () => { + const wrapper = mount(MapDrawingToolbar, { + props: { + tools: DRAWING_TOOLS, + bearingMode: false, + measuring: false, + exportMode: false, + selectedFeature: null, + }, + global: { mocks: { $t: t } }, + }); + await wrapper.find('button[title="map.tool_measure"]').trigger("click"); + expect(wrapper.emitted("toggle-measure")).toHaveLength(1); + await wrapper.find('button[title="map.tool_point"]').trigger("click"); + expect(wrapper.emitted("toggle-draw")).toEqual([["Point"]]); + }); +}); + +describe("MapBearingInstructions", () => { + it("shows first-hint copy and emits use-my-location", async () => { + const wrapper = mount(MapBearingInstructions, { + props: { fromGpsActive: false, awaitingSecondTap: false }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.bearing_hint_first"); + await wrapper.find("button").trigger("click"); + expect(wrapper.emitted("use-my-location")).toHaveLength(1); + }); + + it("shows destination hint when fromGpsActive", () => { + const wrapper = mount(MapBearingInstructions, { + props: { fromGpsActive: true, awaitingSecondTap: true }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.bearing_hint_destination"); + expect(wrapper.find("button").exists()).toBe(false); + }); + + it("shows second-tap hint for two-point mode", () => { + const wrapper = mount(MapBearingInstructions, { + props: { fromGpsActive: false, awaitingSecondTap: true }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.bearing_hint_second"); + }); +}); + +describe("MapSearchBar", () => { + it("updates modelValue, search, clear, and select", async () => { + const wrapper = mount(MapSearchBar, { + props: { + modelValue: "", + results: [{ display_name: "Somewhere", type: "city" }], + error: null, + searching: false, + showResults: true, + }, + global: { mocks: { $t: t } }, + }); + const input = wrapper.find("input"); + await input.setValue("q"); + expect(wrapper.emitted("update:modelValue")).toEqual([["q"]]); + await wrapper.setProps({ modelValue: "q" }); + await input.trigger("keydown.enter"); + expect(wrapper.emitted("search")).toHaveLength(1); + await wrapper.findAll("button").at(0).trigger("click"); + expect(wrapper.emitted("clear")).toHaveLength(1); + }); + + it("renders error state when error is set", () => { + const wrapper = mount(MapSearchBar, { + props: { + modelValue: "x", + results: [], + error: "map.search_error", + searching: false, + showResults: true, + }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.search_error"); + }); + + it("emits select when a result row is clicked", async () => { + const row = { display_name: "A", type: "town" }; + const wrapper = mount(MapSearchBar, { + props: { + modelValue: "a", + results: [row], + showResults: true, + }, + global: { mocks: { $t: t } }, + }); + await wrapper.find("button.w-full").trigger("click"); + expect(wrapper.emitted("select")).toEqual([[row]]); + }); +}); + +describe("MapExportInstructions", () => { + it("emits select-preset for a preset button", async () => { + const presets = [{ id: "europe" }]; + const wrapper = mount(MapExportInstructions, { + props: { presets }, + global: { mocks: { $t: t } }, + }); + await wrapper.find("button").trigger("click"); + expect(wrapper.emitted("select-preset")).toEqual([[presets[0]]]); + }); +}); + +describe("MapNoMapWarning", () => { + it("emits upload", async () => { + const wrapper = mount(MapNoMapWarning, { + global: { mocks: { $t: t } }, + }); + await wrapper.find("button").trigger("click"); + expect(wrapper.emitted("upload")).toHaveLength(1); + }); +}); + +describe("MapLoadingOverlay", () => { + it("shows custom message when provided", () => { + const wrapper = mount(MapLoadingOverlay, { + props: { message: "custom" }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("custom"); + }); + + it("falls back to map.uploading when message is null", () => { + const wrapper = mount(MapLoadingOverlay, { + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.uploading"); + }); +}); + +describe("MapExportConfigPanel", () => { + it("emits update:minZoom and start", async () => { + const wrapper = mount(MapExportConfigPanel, { + props: { minZoom: 5, maxZoom: 10, estimatedTiles: 100, exporting: false, tileLimitExceeded: false }, + global: { mocks: { $t: t } }, + }); + const inputs = wrapper.findAll('input[type="number"]'); + await inputs.at(0).setValue(6); + expect(wrapper.emitted("update:minZoom")).toEqual([[6]]); + await wrapper + .findAll("button") + .filter((b) => b.text().includes("map.start_export")) + .at(0) + .trigger("click"); + expect(wrapper.emitted("start")).toHaveLength(1); + }); + + it("disables start when tileLimitExceeded", () => { + const wrapper = mount(MapExportConfigPanel, { + props: { + minZoom: 0, + maxZoom: 20, + estimatedTiles: 9999999, + tileLimitExceeded: true, + }, + global: { mocks: { $t: t } }, + }); + const startBtn = wrapper + .findAll("button") + .filter((b) => b.text().includes("map.start_export")) + .at(0); + expect(startBtn.attributes("disabled")).toBeDefined(); + }); +}); + +describe("MapExportProgressPanel", () => { + it("shows progress for running export", () => { + const wrapper = mount(MapExportProgressPanel, { + props: { + status: { status: "running", progress: 40, current: 4, total: 10 }, + exportId: "e1", + }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.exporting"); + expect(wrapper.text()).toContain("40%"); + }); + + it("emits dismiss when completed", async () => { + const wrapper = mount(MapExportProgressPanel, { + props: { + status: { status: "completed", progress: 100, current: 10, total: 10 }, + exportId: "x", + }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("map.download_ready"); + const link = wrapper.find('a[href="/api/v1/map/export/x/download"]'); + expect(link.exists()).toBe(true); + await wrapper.find("button.text-gray-400").trigger("click"); + expect(wrapper.emitted("dismiss")).toHaveLength(1); + }); + + it("emits cancel while running", async () => { + const wrapper = mount(MapExportProgressPanel, { + props: { + status: { status: "running", progress: 1, current: 1, total: 99 }, + }, + global: { mocks: { $t: t } }, + }); + await wrapper.find("button.text-red-500").trigger("click"); + expect(wrapper.emitted("cancel")).toHaveLength(1); + }); +}); + +describe("MapClusterPanel", () => { + it("emits close and select", async () => { + const cluster = { + count: 2, + items: [ + { + kind: "telemetry", + label: "Peer", + identifier: "abc", + peer: { lxmf_user_icon: { icon_name: "account" } }, + }, + ], + }; + const wrapper = mount(MapClusterPanel, { + props: { cluster }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.text()).toContain("2"); + await wrapper.find('button[title="Close"]').trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + await wrapper.find("button.w-full").trigger("click"); + expect(wrapper.emitted("select")).toEqual([[cluster.items[0]]]); + }); +}); + +describe("MapMarkerPanel", () => { + it("renders discovered node and emits close", async () => { + const marker = { + discovered: { + name: "NodeA", + latitude: 1.2, + longitude: 3.4, + interface: "eth0", + }, + }; + const wrapper = mount(MapMarkerPanel, { + props: { marker }, + global: { + mocks: { $t: t }, + stubs: { MiniChat: { template: "
" } }, + }, + }); + expect(wrapper.text()).toContain("NodeA"); + expect(wrapper.text()).toContain("1.200000"); + await wrapper.find("button.text-gray-500").trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + }); +}); + +describe("MapVectorExchangePanel", () => { + it("emits export-geojson when export button is clicked", async () => { + const wrapper = mount(MapVectorExchangePanel, { + props: { disabled: false, hasFeatures: true }, + global: { mocks: { $t: t } }, + }); + const exportBtn = wrapper + .findAll("button") + .filter((b) => b.text().includes("map.vector_export_geojson")) + .at(0); + await exportBtn.trigger("click"); + expect(wrapper.emitted("export-geojson")).toHaveLength(1); + }); + + it("toggle merge checkbox changes mergeImport", async () => { + const wrapper = mount(MapVectorExchangePanel, { + props: { hasFeatures: false }, + global: { mocks: { $t: t } }, + }); + expect(wrapper.vm.mergeImport).toBe(true); + const cb = wrapper.find('input[type="checkbox"]'); + await cb.setValue(false); + expect(wrapper.vm.mergeImport).toBe(false); + }); +}); diff --git a/tests/frontend/mapExchange.test.js b/tests/frontend/mapExchange.test.js new file mode 100644 index 00000000..d800d01f --- /dev/null +++ b/tests/frontend/mapExchange.test.js @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import Feature from "ol/Feature"; +import Point from "ol/geom/Point"; +import { fromLonLat } from "ol/proj"; +import { readGeoJsonToFeatures, writeFeaturesToGeoJson } from "@/js/mapExchange/geoJsonCodec.js"; +import { readKmlToFeatures, writeFeaturesToKml } from "@/js/mapExchange/kmlCodec.js"; +import { readKmzToFeatures, writeFeaturesToKmzBlob, resolveHrefToZipPath } from "@/js/mapExchange/kmzCodec.js"; +import { getDrawFeatureMetadataPayload } from "@/js/mapExchange/metadataUtils.js"; +import { MCX_ICON_DATA_URL, MCX_STROKE_COLOR } from "@/js/mapExchange/constants.js"; + +const TINY_PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +function dataUrlToUint8(dataUrl) { + const base64 = String(dataUrl).split(",")[1] || ""; + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +describe("mapExchange GeoJSON", () => { + it("reads point with mcx_icon_data_url and round-trips properties", () => { + const gj = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { [MCX_ICON_DATA_URL]: TINY_PNG, mcx_icon_scale: 0.5 }, + geometry: { type: "Point", coordinates: [-122, 37] }, + }, + ], + }; + const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857"); + expect(features.length).toBe(1); + const out = writeFeaturesToGeoJson(features, "EPSG:3857"); + const parsed = JSON.parse(out); + expect(parsed.features[0].properties[MCX_ICON_DATA_URL]).toContain("data:image/png"); + }); + + it("reads line with mcx stroke properties", () => { + const gj = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { [MCX_STROKE_COLOR]: "#ff0000", mcx_stroke_width: 3 }, + geometry: { + type: "LineString", + coordinates: [ + [0, 0], + [1, 1], + ], + }, + }, + ], + }; + const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857"); + expect(features.length).toBe(1); + expect(features[0].getGeometry().getType()).toBe("LineString"); + }); + + it("supports simplestyle marker-color on points", () => { + const gj = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { "marker-color": "#00ff00" }, + geometry: { type: "Point", coordinates: [10, 20] }, + }, + ], + }; + const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857"); + expect(features.length).toBe(1); + expect(features[0].getStyle()).not.toBeNull(); + }); +}); + +describe("mapExchange KML", () => { + it("reads placemark with point geometry and name", () => { + const kml = ` +P + +-122.1,37.2,0 +`; + const features = readKmlToFeatures(kml, "EPSG:3857"); + expect(features.length).toBeGreaterThanOrEqual(1); + const f = features[0]; + expect(f.getGeometry().getType()).toBe("Point"); + expect(String(f.get("name") || f.get("Name") || "")).toContain("P"); + const st = f.getStyle(); + expect(st && typeof st.getImage === "function" && st.getImage()).toBeTruthy(); + }); + + it("exports and re-reads a minimal point KML", () => { + const kml = ` +X +5,10,0`; + const features = readKmlToFeatures(kml, "EPSG:3857"); + const out = writeFeaturesToKml(features, "EPSG:3857"); + expect(out.includes(" { + it("resolves relative href paths inside zip", () => { + expect(resolveHrefToZipPath("folder/doc.kml", "files/icon.png")).toBe("folder/files/icon.png"); + expect(resolveHrefToZipPath("doc.kml", "files/icon.png")).toBe("files/icon.png"); + expect(resolveHrefToZipPath("doc.kml", "../secret")).toBe(null); + }); + + it("reads placemark with zip-embedded icon", async () => { + const kml = ` +KMZ Point + +-122.1,37.2,0 +`; + const zip = new JSZip(); + zip.file("doc.kml", kml); + zip.file("files/icon.png", dataUrlToUint8(TINY_PNG)); + const buf = await zip.generateAsync({ type: "arraybuffer" }); + const features = await readKmzToFeatures(buf, "EPSG:3857"); + expect(features.length).toBeGreaterThanOrEqual(1); + const f = features[0]; + expect(f.getGeometry().getType()).toBe("Point"); + expect(String(f.get("name") || "")).toContain("KMZ"); + expect(String(f.get(MCX_ICON_DATA_URL) || "")).toContain("data:image/png"); + }); + + it("exports KMZ with embedded data-URI icon and reads it back", async () => { + const kml = ` +Round + +10,20,0 +`; + const features = readKmlToFeatures(kml, "EPSG:3857"); + const blob = await writeFeaturesToKmzBlob(features, "EPSG:3857"); + const ab = await blob.arrayBuffer(); + const again = await readKmzToFeatures(ab, "EPSG:3857"); + expect(again.length).toBeGreaterThanOrEqual(1); + expect(String(again[0].get("name") || "")).toContain("Round"); + }); +}); + +describe("mapExchange metadata", () => { + it("builds overlay payload with extended properties", () => { + const f = new Feature({ + geometry: new Point(fromLonLat([-122, 37])), + type: "draw", + }); + f.set("name", "Site A"); + f.set("description", "Plain text"); + f.set("ref", "Q123"); + const p = getDrawFeatureMetadataPayload(f); + expect(p).not.toBeNull(); + expect(p.name).toBe("Site A"); + expect(p.description).toBe("Plain text"); + expect(p.extended.some((r) => r.key === "ref" && r.value === "Q123")).toBe(true); + }); + + it("returns geometry_type when no other metadata", () => { + const f = new Feature({ + geometry: new Point(fromLonLat([-122, 37])), + type: "draw", + }); + const p = getDrawFeatureMetadataPayload(f); + expect(p).not.toBeNull(); + expect(p.extended.some((r) => r.key === "geometry_type" && r.value === "Point")).toBe(true); + }); +}); diff --git a/tests/frontend/mapGeodesy.test.js b/tests/frontend/mapGeodesy.test.js new file mode 100644 index 00000000..cd7ed117 --- /dev/null +++ b/tests/frontend/mapGeodesy.test.js @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect } from "vitest"; +import { getDistance } from "ol/sphere"; +import { + sphericalInitialBearingDeg, + rhumbLineMetrics, + computeSegmentMetrics, + formatLengthPairMeters, + buildBearingOverlayHtml, + buildBearingLiveTooltipHtml, +} from "@/js/mapGeodesy.js"; + +describe("mapGeodesy", () => { + it("sphericalInitialBearingDeg: due east on equator", () => { + const b = sphericalInitialBearingDeg(0, 0, 1, 0); + expect(b).toBeCloseTo(90, 0); + }); + + it("sphericalInitialBearingDeg: due north along meridian", () => { + const b = sphericalInitialBearingDeg(0, 0, 0, 1); + expect(b).toBeCloseTo(0, 0); + }); + + it("computeSegmentMetrics: back azimuth equals initial bearing reversed", () => { + const m = computeSegmentMetrics(-0.1276, 51.5074, 2.3522, 48.8566); + const rev = computeSegmentMetrics(2.3522, 48.8566, -0.1276, 51.5074); + expect(m.forwardAzimuthDeg).toBeCloseTo(rev.backAzimuthDeg, 0); + expect(m.backAzimuthDeg).toBeCloseTo(rev.forwardAzimuthDeg, 0); + }); + + it("computeSegmentMetrics: geodesic distance matches ol/sphere getDistance", () => { + const lon1 = -0.1276; + const lat1 = 51.5074; + const lon2 = 2.3522; + const lat2 = 48.8566; + const m = computeSegmentMetrics(lon1, lat1, lon2, lat2); + const d = getDistance([lon1, lat1], [lon2, lat2]); + expect(m.geodesicMeters).toBeCloseTo(d, 4); + expect(m.geodesicMeters).toBeGreaterThan(330_000); + expect(m.geodesicMeters).toBeLessThan(360_000); + }); + + it("rhumbLineMetrics: east on equator has bearing 90", () => { + const r = rhumbLineMetrics(0, 0, 10, 0); + expect(r.bearingDeg).toBeCloseTo(90, 0); + expect(r.distanceMeters).toBeCloseTo(getDistance([0, 0], [10, 0]), -2); + }); + + it("formatLengthPairMeters formats short and long", () => { + const s = formatLengthPairMeters(50); + expect(s.metric).toMatch(/m$/); + const l = formatLengthPairMeters(5000); + expect(l.metric).toMatch(/km/); + }); + + it("buildBearingOverlayHtml escapes and includes labels", () => { + const m = computeSegmentMetrics(0, 0, 0.1, 0); + const html = buildBearingOverlayHtml(m, (k) => + k === "map.bearing_geodesic" ? "Geodesic" : k + ); + expect(html).not.toContain("