mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(tests): add frontend test coverage with new map components, geodesy functions, and audio handling tests
This commit is contained in:
parent
15f60a1b4b
commit
54de21e265
13 changed files with 1086 additions and 21 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
351
tests/frontend/mapComponents.test.js
Normal file
351
tests/frontend/mapComponents.test.js
Normal file
|
|
@ -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: "<div class='mini-chat-stub'></div>" } },
|
||||
},
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
179
tests/frontend/mapExchange.test.js
Normal file
179
tests/frontend/mapExchange.test.js
Normal file
|
|
@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>P</name>
|
||||
<Style><IconStyle><scale>0.5</scale><Icon><href>${TINY_PNG}</href></Icon></IconStyle></Style>
|
||||
<Point><coordinates>-122.1,37.2,0</coordinates></Point>
|
||||
</Placemark></Document></kml>`;
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>X</name>
|
||||
<Point><coordinates>5,10,0</coordinates></Point></Placemark></Document></kml>`;
|
||||
const features = readKmlToFeatures(kml, "EPSG:3857");
|
||||
const out = writeFeaturesToKml(features, "EPSG:3857");
|
||||
expect(out.includes("<kml")).toBe(true);
|
||||
expect(out.includes("coordinates")).toBe(true);
|
||||
const again = readKmlToFeatures(out, "EPSG:3857");
|
||||
expect(again.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapExchange KMZ", () => {
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>KMZ Point</name>
|
||||
<Style><IconStyle><scale>0.5</scale><Icon><href>files/icon.png</href></Icon></IconStyle></Style>
|
||||
<Point><coordinates>-122.1,37.2,0</coordinates></Point>
|
||||
</Placemark></Document></kml>`;
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>Round</name>
|
||||
<Style><IconStyle><Icon><href>${TINY_PNG}</href></Icon></IconStyle></Style>
|
||||
<Point><coordinates>10,20,0</coordinates></Point>
|
||||
</Placemark></Document></kml>`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
73
tests/frontend/mapGeodesy.test.js
Normal file
73
tests/frontend/mapGeodesy.test.js
Normal file
|
|
@ -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<script>evil()</script>" : k
|
||||
);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("Geodesic");
|
||||
expect(html).toContain("map.bearing_forward");
|
||||
});
|
||||
|
||||
it("buildBearingLiveTooltipHtml produces compact string", () => {
|
||||
const m = computeSegmentMetrics(0, 0, 0, 0.5);
|
||||
const h = buildBearingLiveTooltipHtml(m, (k) => k);
|
||||
expect(h).toContain("map.bearing_geodesic");
|
||||
expect(h).toContain("<br/>");
|
||||
});
|
||||
});
|
||||
61
tests/frontend/mapLinkUtils.test.js
Normal file
61
tests/frontend/mapLinkUtils.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildMeshchatMapUri,
|
||||
buildWebHashMapUrl,
|
||||
findMapUriInContent,
|
||||
mapLinkKindFromMessage,
|
||||
parseMeshchatMapUri,
|
||||
} from "@/js/mapLinkUtils.js";
|
||||
|
||||
describe("mapLinkUtils", () => {
|
||||
it("builds and parses meshchatx map URIs", () => {
|
||||
const uri = buildMeshchatMapUri({
|
||||
lat: 1.5,
|
||||
lon: -2.25,
|
||||
zoom: 7,
|
||||
layers: "discovered",
|
||||
label: "Test",
|
||||
});
|
||||
expect(uri.startsWith("meshchatx://map?")).toBe(true);
|
||||
const p = parseMeshchatMapUri(uri);
|
||||
expect(p).not.toBeNull();
|
||||
expect(p.lat).toBeCloseTo(1.5);
|
||||
expect(p.lon).toBeCloseTo(-2.25);
|
||||
expect(p.zoom).toBe(7);
|
||||
expect(p.layers).toBe("discovered");
|
||||
expect(p.label).toBe("Test");
|
||||
});
|
||||
|
||||
it("accepts meshchat:// alias", () => {
|
||||
const p = parseMeshchatMapUri("meshchat://map?lat=10&lon=20&z=5");
|
||||
expect(p).not.toBeNull();
|
||||
expect(p.zoom).toBe(5);
|
||||
});
|
||||
|
||||
it("reads z or zoom query", () => {
|
||||
expect(parseMeshchatMapUri("meshchatx://map?lat=0&lon=0&zoom=12").zoom).toBe(12);
|
||||
expect(parseMeshchatMapUri("meshchatx://map?lat=0&lon=0&z=9").zoom).toBe(9);
|
||||
});
|
||||
|
||||
it("finds first map URI in text", () => {
|
||||
const text = "See meshchatx://map?lat=1&lon=2&z=3 end";
|
||||
expect(findMapUriInContent(text)).toBe("meshchatx://map?lat=1&lon=2&z=3");
|
||||
});
|
||||
|
||||
it("classifies ping vs view from message", () => {
|
||||
expect(mapLinkKindFromMessage("MeshChatX map ping: meshchatx://map?lat=0&lon=0&z=3", null)).toBe("ping");
|
||||
expect(mapLinkKindFromMessage("hello", { label: "Ping" })).toBe("ping");
|
||||
expect(mapLinkKindFromMessage("hello", { label: "Here" })).toBe("view");
|
||||
});
|
||||
|
||||
it("buildWebHashMapUrl includes hash route", () => {
|
||||
const u = buildWebHashMapUrl({ lat: 3, lon: 4, zoom: 8, layers: "discovered" });
|
||||
expect(u).toContain("#/map?");
|
||||
expect(u).toContain("lat=3");
|
||||
expect(u).toContain("lon=4");
|
||||
expect(u).toContain("zoom=8");
|
||||
expect(u).toContain("layers=discovered");
|
||||
});
|
||||
});
|
||||
103
tests/frontend/mapTileNetwork.test.js
Normal file
103
tests/frontend/mapTileNetwork.test.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildNominatimSearchUrl,
|
||||
normalizeHttpBaseUrl,
|
||||
fetchTileBlobWithRetry,
|
||||
fetchJsonWithRetry,
|
||||
TILE_FETCH_TIMEOUT_MS,
|
||||
} from "@/js/mapTileNetwork";
|
||||
|
||||
describe("mapTileNetwork", () => {
|
||||
describe("normalizeHttpBaseUrl", () => {
|
||||
it("strips trailing slash", () => {
|
||||
expect(normalizeHttpBaseUrl("http://127.0.0.1:8080/nominatim/")).toBe("http://127.0.0.1:8080/nominatim");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNominatimSearchUrl", () => {
|
||||
it("builds search URL for public nominatim", () => {
|
||||
const u = buildNominatimSearchUrl("https://nominatim.openstreetmap.org", "Paris", 5);
|
||||
expect(u).toBe("https://nominatim.openstreetmap.org/search?format=json&q=Paris&limit=5&addressdetails=1");
|
||||
});
|
||||
|
||||
it("supports custom local mesh nominatim base", () => {
|
||||
const u = buildNominatimSearchUrl("http://192.168.44.1:3000/", "camp", 10);
|
||||
expect(u.startsWith("http://192.168.44.1:3000/search?")).toBe(true);
|
||||
expect(u).toContain(encodeURIComponent("camp"));
|
||||
});
|
||||
|
||||
it("encodes query parameters safely", () => {
|
||||
const u = buildNominatimSearchUrl("http://localhost/nom", "a&b=c", 10);
|
||||
expect(u).toContain(encodeURIComponent("a&b=c"));
|
||||
expect(u).not.toContain("a&b=c&");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchTileBlobWithRetry", () => {
|
||||
it("returns blob on first successful response", async () => {
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
const r = await fetchTileBlobWithRetry("https://tile.example/0/0/0.png", {}, { retries: 0 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.blob).toBe(blob);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it("retries after failure then succeeds", async () => {
|
||||
const blob = new Blob([], { type: "image/png" });
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
const r = await fetchTileBlobWithRetry(
|
||||
"https://tile.example/1/1/1.png",
|
||||
{},
|
||||
{ retries: 2, retryBaseDelayMs: 0 }
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it("returns ok false after exhausting retries", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new TypeError("network"));
|
||||
const r = await fetchTileBlobWithRetry(
|
||||
"https://tile.example/z/x/y.png",
|
||||
{},
|
||||
{ retries: 1, retryBaseDelayMs: 0, timeoutMs: TILE_FETCH_TIMEOUT_MS }
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toBeDefined();
|
||||
delete global.fetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchJsonWithRetry", () => {
|
||||
it("returns response on HTTP 200", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
});
|
||||
const r = await fetchJsonWithRetry("http://127.0.0.1:9999/search?q=x", {}, { retries: 0 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.response.ok).toBe(true);
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it("does not retry on HTTP 404", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||
const r = await fetchJsonWithRetry("http://localhost/n/search", {}, { retries: 2, retryBaseDelayMs: 0 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.status).toBe(404);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
delete global.fetch;
|
||||
});
|
||||
});
|
||||
});
|
||||
63
tests/frontend/telemetryBatteryChartSpec.test.js
Normal file
63
tests/frontend/telemetryBatteryChartSpec.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
batteryHistoryFromTelemetryItems,
|
||||
buildTelemetryBatteryChartSpec,
|
||||
interpolateBatteryByTime,
|
||||
} from "../../meshchatx/src/frontend/js/telemetryBatteryChartSpec.js";
|
||||
|
||||
describe("telemetryBatteryChartSpec", () => {
|
||||
it("buildTelemetryBatteryChartSpec returns null for short history", () => {
|
||||
expect(buildTelemetryBatteryChartSpec([{ x: 1, y: 50 }], "a")).toBeNull();
|
||||
expect(buildTelemetryBatteryChartSpec([], "a")).toBeNull();
|
||||
});
|
||||
|
||||
it("buildTelemetryBatteryChartSpec returns paths and stable ids", () => {
|
||||
const spec = buildTelemetryBatteryChartSpec(
|
||||
[
|
||||
{ x: 1000, y: 80 },
|
||||
{ x: 2000, y: 60 },
|
||||
{ x: 3000, y: 90 },
|
||||
],
|
||||
"ab12"
|
||||
);
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec.linePath).toMatch(/^M /);
|
||||
expect(spec.areaPath).toContain("Z");
|
||||
expect(spec.gradientId).toBe("tb-fill-ab12");
|
||||
expect(spec.strokeGradientId).toBe("tb-stroke-ab12");
|
||||
expect(spec.first.x).toBeLessThan(spec.last.x);
|
||||
expect(spec.layout.minX).toBe(1000);
|
||||
expect(spec.layout.maxX).toBe(3000);
|
||||
expect(spec.viewBox).toBe("0 0 100 46");
|
||||
});
|
||||
|
||||
it("interpolateBatteryByTime clamps and interpolates", () => {
|
||||
const h = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
];
|
||||
expect(interpolateBatteryByTime(h, 50).y).toBe(50);
|
||||
expect(interpolateBatteryByTime(h, -1).y).toBe(0);
|
||||
expect(interpolateBatteryByTime(h, 200).y).toBe(100);
|
||||
});
|
||||
|
||||
it("batteryHistoryFromTelemetryItems sorts by timestamp", () => {
|
||||
const items = [
|
||||
{
|
||||
lxmf_message: {
|
||||
timestamp: 300,
|
||||
fields: { telemetry: { battery: { charge_percent: 10 } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
lxmf_message: {
|
||||
timestamp: 100,
|
||||
fields: { telemetry: { battery: { charge_percent: 50 } } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const h = batteryHistoryFromTelemetryItems(items);
|
||||
expect(h.map((p) => p.x)).toEqual([100, 300]);
|
||||
expect(h.map((p) => p.y)).toEqual([50, 10]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue