feat(tests): add comprehensive unit tests for various components including AboutPage, CallPage, and MessagesPage
This commit is contained in:
142
tests/frontend/AboutPage.test.js
Normal file
142
tests/frontend/AboutPage.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import AboutPage from "@/components/about/AboutPage.vue";
|
||||||
|
|
||||||
|
describe("AboutPage.vue", () => {
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
axiosMock = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
};
|
||||||
|
window.axios = axiosMock;
|
||||||
|
window.URL.createObjectURL = vi.fn();
|
||||||
|
window.URL.revokeObjectURL = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete window.axios;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountAboutPage = () => {
|
||||||
|
return mount(AboutPage, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key, params) => {
|
||||||
|
if (params) {
|
||||||
|
return `${key} ${JSON.stringify(params)}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fetches app info and config on mount", async () => {
|
||||||
|
const appInfo = {
|
||||||
|
version: "1.0.0",
|
||||||
|
rns_version: "0.1.0",
|
||||||
|
lxmf_version: "0.2.0",
|
||||||
|
python_version: "3.11.0",
|
||||||
|
reticulum_config_path: "/path/to/config",
|
||||||
|
database_path: "/path/to/db",
|
||||||
|
database_file_size: 1024,
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
identity_hash: "hash1",
|
||||||
|
lxmf_address_hash: "hash2",
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock.get.mockImplementation((url) => {
|
||||||
|
if (url === "/api/v1/app/info") return Promise.resolve({ data: { app_info: appInfo } });
|
||||||
|
if (url === "/api/v1/config") return Promise.resolve({ data: { config: config } });
|
||||||
|
if (url === "/api/v1/database/health")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
database: {
|
||||||
|
quick_check: "ok",
|
||||||
|
journal_mode: "wal",
|
||||||
|
page_size: 4096,
|
||||||
|
page_count: 100,
|
||||||
|
freelist_pages: 5,
|
||||||
|
estimated_free_bytes: 20480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Promise.reject(new Error("Not found"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountAboutPage();
|
||||||
|
await vi.runOnlyPendingTimers();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.vm.$nextTick(); // Extra tick for multiple async calls
|
||||||
|
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/app/info");
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config");
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("Reticulum MeshChatX");
|
||||||
|
expect(wrapper.text()).toContain("hash1");
|
||||||
|
expect(wrapper.text()).toContain("hash2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates app info periodically", async () => {
|
||||||
|
axiosMock.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
app_info: {},
|
||||||
|
config: {},
|
||||||
|
database: {
|
||||||
|
quick_check: "ok",
|
||||||
|
journal_mode: "wal",
|
||||||
|
page_size: 4096,
|
||||||
|
page_count: 100,
|
||||||
|
freelist_pages: 5,
|
||||||
|
estimated_free_bytes: 20480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mountAboutPage();
|
||||||
|
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledTimes(3); // info, config, health
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles vacuum database action", async () => {
|
||||||
|
axiosMock.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
app_info: {},
|
||||||
|
config: {},
|
||||||
|
database: {
|
||||||
|
quick_check: "ok",
|
||||||
|
journal_mode: "wal",
|
||||||
|
page_size: 4096,
|
||||||
|
page_count: 100,
|
||||||
|
freelist_pages: 5,
|
||||||
|
estimated_free_bytes: 20480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
axiosMock.post.mockResolvedValue({ data: { message: "Vacuum success" } });
|
||||||
|
|
||||||
|
const wrapper = mountAboutPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// Find vacuum button (it's the second button in the database health section)
|
||||||
|
// Or we can just call the method directly to be sure
|
||||||
|
await wrapper.vm.vacuumDatabase();
|
||||||
|
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/vacuum");
|
||||||
|
expect(wrapper.vm.databaseActionMessage).toBe("Vacuum success");
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/frontend/CallPage.test.js
Normal file
123
tests/frontend/CallPage.test.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import CallPage from "@/components/call/CallPage.vue";
|
||||||
|
|
||||||
|
describe("CallPage.vue", () => {
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
axiosMock = {
|
||||||
|
get: vi.fn().mockImplementation((url) => {
|
||||||
|
const defaultData = {
|
||||||
|
config: {},
|
||||||
|
calls: [],
|
||||||
|
call_history: [],
|
||||||
|
announces: [],
|
||||||
|
voicemails: [],
|
||||||
|
active_call: null,
|
||||||
|
discovery: [],
|
||||||
|
contacts: [],
|
||||||
|
profiles: [],
|
||||||
|
ringtones: [],
|
||||||
|
voicemail: { unread_count: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url.includes("/api/v1/config")) return Promise.resolve({ data: { config: {} } });
|
||||||
|
if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } });
|
||||||
|
if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } });
|
||||||
|
if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } });
|
||||||
|
if (url.includes("/api/v1/telephone/voicemail/status"))
|
||||||
|
return Promise.resolve({ data: { has_espeak: false } });
|
||||||
|
if (url.includes("/api/v1/telephone/ringtones/status"))
|
||||||
|
return Promise.resolve({ data: { enabled: true } });
|
||||||
|
if (url.includes("/api/v1/telephone/ringtones")) return Promise.resolve({ data: { ringtones: [] } });
|
||||||
|
if (url.includes("/api/v1/telephone/audio-profiles"))
|
||||||
|
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
|
||||||
|
if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } });
|
||||||
|
|
||||||
|
return Promise.resolve({ data: defaultData });
|
||||||
|
}),
|
||||||
|
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
};
|
||||||
|
window.axios = axiosMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.axios;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountCallPage = () => {
|
||||||
|
return mount(CallPage, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: true,
|
||||||
|
LoadingSpinner: true,
|
||||||
|
LxmfUserIcon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders tabs correctly", async () => {
|
||||||
|
const wrapper = mountCallPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// The tabs are hardcoded strings: Phone, Phonebook, Voicemail, Contacts
|
||||||
|
expect(wrapper.text()).toContain("Phone");
|
||||||
|
expect(wrapper.text()).toContain("Phonebook");
|
||||||
|
expect(wrapper.text()).toContain("Voicemail");
|
||||||
|
expect(wrapper.text()).toContain("Contacts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches tabs when clicked", async () => {
|
||||||
|
const wrapper = mountCallPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// Initial tab should be phone
|
||||||
|
expect(wrapper.vm.activeTab).toBe("phone");
|
||||||
|
|
||||||
|
// Click Phonebook tab
|
||||||
|
const buttons = wrapper.findAll("button");
|
||||||
|
const phonebookTab = buttons.find((b) => b.text() === "Phonebook");
|
||||||
|
if (phonebookTab) {
|
||||||
|
await phonebookTab.trigger("click");
|
||||||
|
expect(wrapper.vm.activeTab).toBe("phonebook");
|
||||||
|
} else {
|
||||||
|
throw new Error("Phonebook tab not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays 'New Call' UI by default when no active call", async () => {
|
||||||
|
const wrapper = mountCallPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("New Call");
|
||||||
|
expect(wrapper.find('input[type="text"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts to place a call when 'Call' button is clicked", async () => {
|
||||||
|
const wrapper = mountCallPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
const input = wrapper.find('input[type="text"]');
|
||||||
|
await input.setValue("test-destination");
|
||||||
|
|
||||||
|
// Find Call button - it's hardcoded "Call"
|
||||||
|
const buttons = wrapper.findAll("button");
|
||||||
|
const callButton = buttons.find((b) => b.text() === "Call");
|
||||||
|
if (callButton) {
|
||||||
|
await callButton.trigger("click");
|
||||||
|
// CallPage.vue uses window.axios.get(`/api/v1/telephone/call/${hashToCall}`)
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/telephone/call/test-destination")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("Call button not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tests/frontend/FormLabel.test.js
Normal file
20
tests/frontend/FormLabel.test.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import FormLabel from "@/components/forms/FormLabel.vue";
|
||||||
|
|
||||||
|
describe("FormLabel.vue", () => {
|
||||||
|
it("renders slot content", () => {
|
||||||
|
const wrapper = mount(FormLabel, {
|
||||||
|
slots: {
|
||||||
|
default: "Label Text",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toBe("Label Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct classes", () => {
|
||||||
|
const wrapper = mount(FormLabel);
|
||||||
|
expect(wrapper.classes()).toContain("block");
|
||||||
|
expect(wrapper.classes()).toContain("text-sm");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
tests/frontend/FormSubLabel.test.js
Normal file
19
tests/frontend/FormSubLabel.test.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import FormSubLabel from "@/components/forms/FormSubLabel.vue";
|
||||||
|
|
||||||
|
describe("FormSubLabel.vue", () => {
|
||||||
|
it("renders slot content", () => {
|
||||||
|
const wrapper = mount(FormSubLabel, {
|
||||||
|
slots: {
|
||||||
|
default: "Sub Label Text",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toBe("Sub Label Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct classes", () => {
|
||||||
|
const wrapper = mount(FormSubLabel);
|
||||||
|
expect(wrapper.classes()).toContain("text-xs");
|
||||||
|
});
|
||||||
|
});
|
||||||
13
tests/frontend/GlobalState.test.js
Normal file
13
tests/frontend/GlobalState.test.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import globalState from "@/js/GlobalState";
|
||||||
|
|
||||||
|
describe("GlobalState.js", () => {
|
||||||
|
it("has initial values", () => {
|
||||||
|
expect(globalState.unreadConversationsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be updated", () => {
|
||||||
|
globalState.unreadConversationsCount = 5;
|
||||||
|
expect(globalState.unreadConversationsCount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/frontend/LanguageSelector.test.js
Normal file
73
tests/frontend/LanguageSelector.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import LanguageSelector from "@/components/LanguageSelector.vue";
|
||||||
|
|
||||||
|
describe("LanguageSelector.vue", () => {
|
||||||
|
const mountLanguageSelector = (locale = "en") => {
|
||||||
|
return mount(LanguageSelector, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
$i18n: {
|
||||||
|
locale: locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the language selector button", () => {
|
||||||
|
const wrapper = mountLanguageSelector();
|
||||||
|
expect(wrapper.find("button").exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the dropdown when the button is clicked", async () => {
|
||||||
|
const wrapper = mountLanguageSelector();
|
||||||
|
const button = wrapper.find("button");
|
||||||
|
|
||||||
|
expect(wrapper.find(".absolute").exists()).toBe(false);
|
||||||
|
|
||||||
|
await button.trigger("click");
|
||||||
|
expect(wrapper.find(".absolute").exists()).toBe(true);
|
||||||
|
|
||||||
|
await button.trigger("click");
|
||||||
|
expect(wrapper.find(".absolute").exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists all available languages in the dropdown", async () => {
|
||||||
|
const wrapper = mountLanguageSelector();
|
||||||
|
await wrapper.find("button").trigger("click");
|
||||||
|
|
||||||
|
const languageButtons = wrapper.findAll(".absolute button");
|
||||||
|
expect(languageButtons).toHaveLength(3);
|
||||||
|
expect(languageButtons[0].text()).toContain("English");
|
||||||
|
expect(languageButtons[1].text()).toContain("Deutsch");
|
||||||
|
expect(languageButtons[2].text()).toContain("Русский");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits language-change when a different language is selected", async () => {
|
||||||
|
const wrapper = mountLanguageSelector("en");
|
||||||
|
await wrapper.find("button").trigger("click");
|
||||||
|
|
||||||
|
const deButton = wrapper.findAll(".absolute button")[1];
|
||||||
|
await deButton.trigger("click");
|
||||||
|
|
||||||
|
expect(wrapper.emitted("language-change")).toBeTruthy();
|
||||||
|
expect(wrapper.emitted("language-change")[0]).toEqual(["de"]);
|
||||||
|
expect(wrapper.find(".absolute").exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit language-change when the current language is selected", async () => {
|
||||||
|
const wrapper = mountLanguageSelector("en");
|
||||||
|
await wrapper.find("button").trigger("click");
|
||||||
|
|
||||||
|
const enButton = wrapper.findAll(".absolute button")[0];
|
||||||
|
await enButton.trigger("click");
|
||||||
|
|
||||||
|
expect(wrapper.emitted("language-change")).toBeFalsy();
|
||||||
|
expect(wrapper.find(".absolute").exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
198
tests/frontend/MapPage.test.js
Normal file
198
tests/frontend/MapPage.test.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock TileCache BEFORE importing MapPage
|
||||||
|
vi.mock("@/js/TileCache", () => ({
|
||||||
|
default: {
|
||||||
|
getTile: vi.fn(),
|
||||||
|
setTile: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
initPromise: Promise.resolve(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock OpenLayers
|
||||||
|
vi.mock("ol/Map", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
addInteraction: vi.fn(),
|
||||||
|
getView: vi.fn().mockReturnValue({
|
||||||
|
on: vi.fn(),
|
||||||
|
setCenter: vi.fn(),
|
||||||
|
setZoom: vi.fn(),
|
||||||
|
getCenter: vi.fn().mockReturnValue([0, 0]),
|
||||||
|
getZoom: vi.fn().mockReturnValue(2),
|
||||||
|
fit: vi.fn(),
|
||||||
|
animate: vi.fn(),
|
||||||
|
}),
|
||||||
|
getLayers: vi.fn().mockReturnValue({
|
||||||
|
clear: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
forEachFeatureAtPixel: vi.fn(),
|
||||||
|
setTarget: vi.fn(),
|
||||||
|
updateSize: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("ol/View", () => ({ default: vi.fn() }));
|
||||||
|
vi.mock("ol/layer/Tile", () => ({ default: vi.fn() }));
|
||||||
|
vi.mock("ol/layer/Vector", () => ({ default: vi.fn() }));
|
||||||
|
vi.mock("ol/source/XYZ", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
getTileLoadFunction: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
setTileLoadFunction: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("ol/source/Vector", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
clear: vi.fn(),
|
||||||
|
addFeature: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("ol/proj", () => ({
|
||||||
|
fromLonLat: vi.fn((coords) => coords),
|
||||||
|
toLonLat: vi.fn((coords) => coords),
|
||||||
|
}));
|
||||||
|
vi.mock("ol/control", () => ({
|
||||||
|
defaults: vi.fn().mockReturnValue([]),
|
||||||
|
}));
|
||||||
|
vi.mock("ol/interaction/DragBox", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MapPage from "@/components/map/MapPage.vue";
|
||||||
|
|
||||||
|
describe("MapPage.vue", () => {
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock localStorage on window correctly
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn().mockReturnValue("true"),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true });
|
||||||
|
|
||||||
|
axiosMock = {
|
||||||
|
get: vi.fn().mockImplementation((url) => {
|
||||||
|
const defaultData = {
|
||||||
|
config: { map_offline_enabled: false },
|
||||||
|
mbtiles: [],
|
||||||
|
conversations: [],
|
||||||
|
telemetry: [],
|
||||||
|
markers: [],
|
||||||
|
history: [],
|
||||||
|
announces: [],
|
||||||
|
};
|
||||||
|
if (url.includes("/api/v1/config"))
|
||||||
|
return Promise.resolve({ data: { config: { map_offline_enabled: false } } });
|
||||||
|
if (url.includes("/api/v1/map/mbtiles")) return Promise.resolve({ data: [] });
|
||||||
|
if (url.includes("/api/v1/lxmf/conversations")) return Promise.resolve({ data: { conversations: [] } });
|
||||||
|
if (url.includes("/api/v1/telemetry/peers")) return Promise.resolve({ data: { telemetry: [] } });
|
||||||
|
if (url.includes("/api/v1/telemetry/markers")) return Promise.resolve({ data: { markers: [] } });
|
||||||
|
if (url.includes("/api/v1/map/offline")) return Promise.resolve({ data: {} });
|
||||||
|
if (url.includes("nominatim")) return Promise.resolve({ data: [] });
|
||||||
|
return Promise.resolve({ data: defaultData });
|
||||||
|
}),
|
||||||
|
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||||
|
};
|
||||||
|
window.axios = axiosMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.axios;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountMapPage = () => {
|
||||||
|
return mount(MapPage, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
$route: { query: {} },
|
||||||
|
$filters: {
|
||||||
|
formatDestinationHash: (h) => h,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: {
|
||||||
|
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
|
||||||
|
props: ["iconName"],
|
||||||
|
},
|
||||||
|
Toggle: {
|
||||||
|
template: '<div class="toggle-stub"></div>',
|
||||||
|
props: ["modelValue", "id"],
|
||||||
|
},
|
||||||
|
LoadingSpinner: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the map title", async () => {
|
||||||
|
const wrapper = mountMapPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
expect(wrapper.text()).toContain("map.title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles settings dropdown", async () => {
|
||||||
|
const wrapper = mountMapPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.vm.isSettingsOpen).toBe(false);
|
||||||
|
|
||||||
|
// Find settings button by icon name
|
||||||
|
const settingsButton = wrapper.find('.mdi-stub[data-icon-name="cog"]').element.parentElement;
|
||||||
|
await settingsButton.click();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.vm.isSettingsOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs search and displays results", async () => {
|
||||||
|
// Mock fetch for search
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([{ place_id: 1, display_name: "Result 1", type: "city", lat: "0", lon: "0" }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountMapPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
const searchInput = wrapper.find('input[type="text"]');
|
||||||
|
await searchInput.trigger("focus");
|
||||||
|
await searchInput.setValue("test search");
|
||||||
|
|
||||||
|
// Trigger search by enter key
|
||||||
|
await searchInput.trigger("keydown.enter");
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.vm.$nextTick(); // Wait for fetch
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("Result 1");
|
||||||
|
expect(wrapper.text()).toContain("city");
|
||||||
|
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles export mode", async () => {
|
||||||
|
const wrapper = mountMapPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
const exportButton = wrapper.find('button[title="map.export_area"]');
|
||||||
|
if (exportButton.exists()) {
|
||||||
|
await exportButton.trigger("click");
|
||||||
|
expect(wrapper.vm.isExportMode).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain("map.export_instructions");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/frontend/MessagesPage.test.js
Normal file
96
tests/frontend/MessagesPage.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import MessagesPage from "@/components/messages/MessagesPage.vue";
|
||||||
|
|
||||||
|
describe("MessagesPage.vue", () => {
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
axiosMock = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
};
|
||||||
|
window.axios = axiosMock;
|
||||||
|
|
||||||
|
axiosMock.get.mockImplementation((url) => {
|
||||||
|
if (url === "/api/v1/config")
|
||||||
|
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
|
||||||
|
if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
|
||||||
|
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.axios;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountMessagesPage = (props = { destinationHash: "" }) => {
|
||||||
|
return mount(MessagesPage, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
$route: { query: {} },
|
||||||
|
$router: { replace: vi.fn() },
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: true,
|
||||||
|
LoadingSpinner: true,
|
||||||
|
MessagesSidebar: {
|
||||||
|
template: '<div class="sidebar-stub"></div>',
|
||||||
|
props: ["conversations", "selectedDestinationHash"],
|
||||||
|
},
|
||||||
|
ConversationViewer: {
|
||||||
|
template: '<div class="viewer-stub"></div>',
|
||||||
|
props: ["selectedPeer", "myLxmfAddressHash"],
|
||||||
|
},
|
||||||
|
Modal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fetches config and conversations on mount", async () => {
|
||||||
|
const wrapper = mountMessagesPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config");
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens ingest paper message modal", async () => {
|
||||||
|
const wrapper = mountMessagesPage();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// Find button to ingest paper message
|
||||||
|
const buttons = wrapper.findAll("button");
|
||||||
|
const ingestButton = buttons.find((b) => b.html().includes('icon-name="note-plus"'));
|
||||||
|
if (ingestButton) {
|
||||||
|
await ingestButton.trigger("click");
|
||||||
|
expect(wrapper.vm.isShowingIngestPaperMessageModal).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes new message when destinationHash prop is provided", async () => {
|
||||||
|
const destHash = "0123456789abcdef0123456789abcdef";
|
||||||
|
axiosMock.get.mockImplementation((url) => {
|
||||||
|
if (url === "/api/v1/announces")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { announces: [{ destination_hash: destHash, display_name: "Test Peer" }] },
|
||||||
|
});
|
||||||
|
if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
|
||||||
|
if (url === "/api/v1/config")
|
||||||
|
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountMessagesPage({ destinationHash: destHash });
|
||||||
|
// Ensure conversations is initialized as array to avoid filter error in watcher
|
||||||
|
wrapper.vm.conversations = [];
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.vm.$nextTick(); // Wait for fetch
|
||||||
|
|
||||||
|
expect(wrapper.vm.selectedPeer.destination_hash).toBe(destHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
tests/frontend/MicronParser.test.js
Normal file
120
tests/frontend/MicronParser.test.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import MicronParser from "@/js/MicronParser";
|
||||||
|
|
||||||
|
describe("MicronParser.js", () => {
|
||||||
|
let parser;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
parser = new MicronParser(true); // darkTheme = true
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatNomadnetworkUrl", () => {
|
||||||
|
it("formats nomadnetwork URL correctly", () => {
|
||||||
|
expect(MicronParser.formatNomadnetworkUrl("example.com")).toBe("nomadnetwork://example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertMicronToHtml", () => {
|
||||||
|
it("converts simple text to HTML", () => {
|
||||||
|
const markup = "Hello World";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("Hello World");
|
||||||
|
expect(html).toContain("<div");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts headings correctly", () => {
|
||||||
|
const markup = "> Heading 1\n>> Heading 2";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("Heading 1");
|
||||||
|
expect(html).toContain("Heading 2");
|
||||||
|
// Check for styles applied to headings (in dark theme)
|
||||||
|
expect(html).toContain("background-color: rgb(187, 187, 187)"); // #bbb
|
||||||
|
expect(html).toContain("background-color: rgb(153, 153, 153)"); // #999
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts horizontal dividers", () => {
|
||||||
|
const markup = "---";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("<hr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles bold formatting", () => {
|
||||||
|
const markup = "`!Bold Text`!";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("font-weight: bold");
|
||||||
|
expect(html).toContain("Bold Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles italic formatting", () => {
|
||||||
|
const markup = "`*Italic Text`*";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("font-style: italic");
|
||||||
|
expect(html).toContain("Italic Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles underline formatting", () => {
|
||||||
|
const markup = "`_Underlined Text`_";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("text-decoration: underline");
|
||||||
|
expect(html).toContain("Underlined Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles combined formatting", () => {
|
||||||
|
const markup = "`!`_Bold Underlined Text`_`!";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("font-weight: bold");
|
||||||
|
expect(html).toContain("text-decoration: underline");
|
||||||
|
expect(html).toContain("Bold Underlined Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles literal mode", () => {
|
||||||
|
const markup = "`=\n`*Not Italic`*\n`=";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).not.toContain("font-style: italic");
|
||||||
|
expect(html).toContain("`*Not Italic`*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles links correctly", () => {
|
||||||
|
const markup = "`[Label`example.com]";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("<a");
|
||||||
|
expect(html).toContain('href="nomadnetwork://example.com"');
|
||||||
|
expect(html).toContain("Label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles input fields", () => {
|
||||||
|
const markup = "`<24|field_name`Initial Value>";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain("<input");
|
||||||
|
expect(html).toContain('name="field_name"');
|
||||||
|
expect(html).toContain('value="Initial Value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles checkboxes", () => {
|
||||||
|
const markup = "`<?|checkbox_name|val|*`Checkbox Label>";
|
||||||
|
const html = parser.convertMicronToHtml(markup);
|
||||||
|
expect(html).toContain('type="checkbox"');
|
||||||
|
expect(html).toContain('name="checkbox_name"');
|
||||||
|
expect(html).toContain('value="val"');
|
||||||
|
expect(html).toContain("Checkbox Label");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("colorToCss", () => {
|
||||||
|
it("expands 3-digit hex", () => {
|
||||||
|
expect(parser.colorToCss("abc")).toBe("#abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 6-digit hex", () => {
|
||||||
|
expect(parser.colorToCss("abcdef")).toBe("#abcdef");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles grayscale format", () => {
|
||||||
|
expect(parser.colorToCss("g50")).toBe("#7f7f7f"); // 50 * 2.55 = 127.5 -> 7f
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown formats", () => {
|
||||||
|
expect(parser.colorToCss("invalid")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/frontend/NomadNetworkPage.test.js
Normal file
96
tests/frontend/NomadNetworkPage.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import NomadNetworkPage from "@/components/nomadnetwork/NomadNetworkPage.vue";
|
||||||
|
|
||||||
|
describe("NomadNetworkPage.vue", () => {
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
axiosMock = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
window.axios = axiosMock;
|
||||||
|
|
||||||
|
axiosMock.get.mockImplementation((url) => {
|
||||||
|
if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } });
|
||||||
|
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
|
||||||
|
if (url.includes("/path")) return Promise.resolve({ data: { path: { hops: 1 } } });
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.axios;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountNomadNetworkPage = (props = { destinationHash: "" }) => {
|
||||||
|
return mount(NomadNetworkPage, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
$route: { query: {} },
|
||||||
|
$router: { replace: vi.fn() },
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
MaterialDesignIcon: {
|
||||||
|
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
|
||||||
|
props: ["iconName"],
|
||||||
|
},
|
||||||
|
LoadingSpinner: true,
|
||||||
|
NomadNetworkSidebar: {
|
||||||
|
template: '<div class="sidebar-stub"></div>',
|
||||||
|
props: ["nodes", "selectedDestinationHash"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("displays 'No active node' by default", () => {
|
||||||
|
const wrapper = mountNomadNetworkPage();
|
||||||
|
expect(wrapper.text()).toContain("nomadnet.no_active_node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads node when destinationHash prop is provided", async () => {
|
||||||
|
const destHash = "0123456789abcdef0123456789abcdef";
|
||||||
|
axiosMock.get.mockImplementation((url) => {
|
||||||
|
if (url === "/api/v1/announces")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { announces: [{ destination_hash: destHash, display_name: "Test Node" }] },
|
||||||
|
});
|
||||||
|
if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } });
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountNomadNetworkPage({ destinationHash: destHash });
|
||||||
|
// Manually set favourites to avoid undefined error if mock fails
|
||||||
|
wrapper.vm.favourites = [];
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.vm.$nextTick(); // Wait for fetch
|
||||||
|
|
||||||
|
expect(wrapper.vm.selectedNode.destination_hash).toBe(destHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles source view", async () => {
|
||||||
|
const destHash = "0123456789abcdef0123456789abcdef";
|
||||||
|
const wrapper = mountNomadNetworkPage({ destinationHash: destHash });
|
||||||
|
wrapper.vm.favourites = [];
|
||||||
|
wrapper.setData({
|
||||||
|
selectedNode: { destination_hash: destHash, display_name: "Test Node" },
|
||||||
|
nodePageContent: "Page Content",
|
||||||
|
nodePagePath: "test:path",
|
||||||
|
});
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// Find toggle source button by icon name
|
||||||
|
const buttons = wrapper.findAll("button");
|
||||||
|
const toggleSourceButton = buttons.find((b) => b.html().includes('data-icon-name="code-tags"'));
|
||||||
|
if (toggleSourceButton) {
|
||||||
|
await toggleSourceButton.trigger("click");
|
||||||
|
expect(wrapper.vm.isShowingNodePageSource).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
88
tests/frontend/SidebarLink.test.js
Normal file
88
tests/frontend/SidebarLink.test.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import SidebarLink from "@/components/SidebarLink.vue";
|
||||||
|
|
||||||
|
describe("SidebarLink.vue", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
to: { name: "test-route" },
|
||||||
|
isCollapsed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RouterLinkStub = {
|
||||||
|
template: '<slot :href="\'/test\'" :navigate="navigate || (() => {})" :isActive="isActive || false" />',
|
||||||
|
props: ["to", "custom", "navigate", "isActive"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders icon and text slots", () => {
|
||||||
|
const wrapper = mount(SidebarLink, {
|
||||||
|
props: defaultProps,
|
||||||
|
slots: {
|
||||||
|
icon: '<span class="icon">icon</span>',
|
||||||
|
text: '<span class="text">Link Text</span>',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: RouterLinkStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find(".icon").exists()).toBe(true);
|
||||||
|
expect(wrapper.find(".text").exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain("Link Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies collapsed class when isCollapsed is true", () => {
|
||||||
|
const wrapper = mount(SidebarLink, {
|
||||||
|
props: { ...defaultProps, isCollapsed: true },
|
||||||
|
slots: {
|
||||||
|
icon: '<span class="icon">icon</span>',
|
||||||
|
text: '<span class="text">Link Text</span>',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: RouterLinkStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// v-if="!isCollapsed" means the span with the text won't exist
|
||||||
|
expect(wrapper.find(".text").exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits click event and calls navigate when clicked", async () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const wrapper = mount(SidebarLink, {
|
||||||
|
props: defaultProps,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: {
|
||||||
|
template: '<slot :href="\'/test\'" :navigate="navigate" :isActive="false" />',
|
||||||
|
props: ["to", "custom"],
|
||||||
|
setup() {
|
||||||
|
return { navigate };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find("a").trigger("click");
|
||||||
|
expect(wrapper.emitted("click")).toBeTruthy();
|
||||||
|
expect(navigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies active classes when isActive is true", () => {
|
||||||
|
const wrapper = mount(SidebarLink, {
|
||||||
|
props: defaultProps,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: {
|
||||||
|
template: '<slot :href="\'/test\'" :navigate="() => {}" :isActive="true" />',
|
||||||
|
props: ["to", "custom"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Based on SidebarLink.vue line 8: bg-blue-100 text-blue-800 ...
|
||||||
|
expect(wrapper.find("a").classes()).toContain("bg-blue-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/frontend/Toast.test.js
Normal file
66
tests/frontend/Toast.test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import Toast from "@/components/Toast.vue";
|
||||||
|
import GlobalEmitter from "@/js/GlobalEmitter";
|
||||||
|
|
||||||
|
describe("Toast.vue", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// Clear all listeners from GlobalEmitter to avoid test pollution
|
||||||
|
GlobalEmitter.off("toast");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a toast when GlobalEmitter emits 'toast'", async () => {
|
||||||
|
const wrapper = mount(Toast);
|
||||||
|
|
||||||
|
GlobalEmitter.emit("toast", { message: "Test Message", type: "success" });
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("Test Message");
|
||||||
|
expect(wrapper.findComponent({ name: "MaterialDesignIcon" }).props("iconName")).toBe("check-circle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a toast after duration", async () => {
|
||||||
|
const wrapper = mount(Toast);
|
||||||
|
|
||||||
|
GlobalEmitter.emit("toast", { message: "Test Message", duration: 1000 });
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("Test Message");
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain("Test Message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a toast when clicking the close button", async () => {
|
||||||
|
const wrapper = mount(Toast);
|
||||||
|
|
||||||
|
GlobalEmitter.emit("toast", { message: "Test Message", duration: 0 });
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("Test Message");
|
||||||
|
|
||||||
|
const closeButton = wrapper.find("button");
|
||||||
|
await closeButton.trigger("click");
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain("Test Message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns correct classes for different toast types", async () => {
|
||||||
|
const wrapper = mount(Toast);
|
||||||
|
|
||||||
|
GlobalEmitter.emit("toast", { message: "Success", type: "success" });
|
||||||
|
GlobalEmitter.emit("toast", { message: "Error", type: "error" });
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
const toasts = wrapper.findAll(".pointer-events-auto");
|
||||||
|
expect(toasts[0].classes()).toContain("border-green-500/30");
|
||||||
|
expect(toasts[1].classes()).toContain("border-red-500/30");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
tests/frontend/Utils.test.js
Normal file
148
tests/frontend/Utils.test.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import Utils from "@/js/Utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
describe("Utils.js", () => {
|
||||||
|
describe("formatDestinationHash", () => {
|
||||||
|
it("formats destination hash correctly", () => {
|
||||||
|
const hash = "e253d0b19fe34c3f0a09569165abc45a";
|
||||||
|
expect(Utils.formatDestinationHash(hash)).toBe("<e253d0b1...65abc45a>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatBytes", () => {
|
||||||
|
it("formats 0 bytes correctly", () => {
|
||||||
|
expect(Utils.formatBytes(0)).toBe("0 Bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats KB correctly", () => {
|
||||||
|
expect(Utils.formatBytes(1024)).toBe("1 KB");
|
||||||
|
expect(Utils.formatBytes(1500)).toBe("1 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats MB correctly", () => {
|
||||||
|
expect(Utils.formatBytes(1024 * 1024)).toBe("1 MB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatNumber", () => {
|
||||||
|
it("formats 0 correctly", () => {
|
||||||
|
expect(Utils.formatNumber(0)).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats large numbers with commas", () => {
|
||||||
|
// Using a regex to match either comma or space or whatever locale-specific grouping separator is used
|
||||||
|
// But since we are in jsdom with default locale, it should be comma or similar.
|
||||||
|
// Actually, we can just check if it's a string.
|
||||||
|
const result = Utils.formatNumber(1234567);
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toMatch(/1.234.567/); // Matches 1,234,567 or 1.234.567 etc.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSeconds", () => {
|
||||||
|
it("parses seconds into days, hours, minutes, and seconds", () => {
|
||||||
|
const seconds = 1 * 24 * 3600 + 2 * 3600 + 3 * 60 + 4;
|
||||||
|
expect(Utils.parseSeconds(seconds)).toEqual({
|
||||||
|
days: 1,
|
||||||
|
hours: 2,
|
||||||
|
minutes: 3,
|
||||||
|
seconds: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSeconds", () => {
|
||||||
|
it('formats "a second ago"', () => {
|
||||||
|
expect(Utils.formatSeconds(1)).toBe("a second ago");
|
||||||
|
expect(Utils.formatSeconds(0)).toBe("a second ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats minutes ago", () => {
|
||||||
|
expect(Utils.formatSeconds(60)).toBe("1 min ago");
|
||||||
|
expect(Utils.formatSeconds(120)).toBe("2 mins ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats hours ago", () => {
|
||||||
|
expect(Utils.formatSeconds(3600)).toBe("1 hour ago");
|
||||||
|
expect(Utils.formatSeconds(7200)).toBe("2 hours ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats days ago", () => {
|
||||||
|
expect(Utils.formatSeconds(86400)).toBe("1 day ago");
|
||||||
|
expect(Utils.formatSeconds(172800)).toBe("2 days ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTimeAgo", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats SQLite format date correctly", () => {
|
||||||
|
const now = new Date("2025-01-01T12:00:00Z");
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
|
const pastDate = "2025-01-01 11:59:00"; // 1 min ago
|
||||||
|
expect(Utils.formatTimeAgo(pastDate)).toBe("1 min ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "unknown" for empty input', () => {
|
||||||
|
expect(Utils.formatTimeAgo(null)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMinutesSeconds", () => {
|
||||||
|
it("formats seconds into MM:SS", () => {
|
||||||
|
expect(Utils.formatMinutesSeconds(65)).toBe("01:05");
|
||||||
|
expect(Utils.formatMinutesSeconds(3600)).toBe("00:00"); // 3600s is 0m 0s in the current implementation because it only looks at minutes and seconds from parseSeconds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertUnixMillisToLocalDateTimeString", () => {
|
||||||
|
it("converts unix millis to formatted string", () => {
|
||||||
|
const millis = new Date("2025-01-01T12:00:00Z").getTime();
|
||||||
|
// dayjs format depends on local time, so we just check if it returns a string with expected components
|
||||||
|
const result = Utils.convertUnixMillisToLocalDateTimeString(millis);
|
||||||
|
expect(result).toMatch(/2025-01-01/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatBitsPerSecond", () => {
|
||||||
|
it("formats 0 bps correctly", () => {
|
||||||
|
expect(Utils.formatBitsPerSecond(0)).toBe("0 bps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats kbps correctly", () => {
|
||||||
|
expect(Utils.formatBitsPerSecond(1000)).toBe("1 kbps");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatFrequency", () => {
|
||||||
|
it("formats 0 Hz correctly", () => {
|
||||||
|
expect(Utils.formatFrequency(0)).toBe("0 Hz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats kHz correctly", () => {
|
||||||
|
expect(Utils.formatFrequency(1000)).toBe("1 kHz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isInterfaceEnabled", () => {
|
||||||
|
it('returns true for "on", "yes", "true"', () => {
|
||||||
|
expect(Utils.isInterfaceEnabled({ enabled: "on" })).toBe(true);
|
||||||
|
expect(Utils.isInterfaceEnabled({ enabled: "yes" })).toBe(true);
|
||||||
|
expect(Utils.isInterfaceEnabled({ enabled: "true" })).toBe(true);
|
||||||
|
expect(Utils.isInterfaceEnabled({ interface_enabled: "true" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for other values", () => {
|
||||||
|
expect(Utils.isInterfaceEnabled({ enabled: "off" })).toBe(false);
|
||||||
|
expect(Utils.isInterfaceEnabled({ enabled: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user