feat(tests): add frontend test coverage with new map components, geodesy functions, and audio handling tests

This commit is contained in:
Ivan 2026-04-23 02:28:39 -05:00
parent 15f60a1b4b
commit 54de21e265
No known key found for this signature in database
GPG key ID: B84314E2D9332AE0
13 changed files with 1086 additions and 21 deletions

View file

@ -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();

View file

@ -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;
}
});
});

View file

@ -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,

View file

@ -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(),

View file

@ -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", () => {

View file

@ -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);

View file

@ -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);
});
});

View 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);
});
});

View 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);
});
});

View 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/>");
});
});

View 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");
});
});

View 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;
});
});
});

View 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]);
});
});