mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 12:32:35 +00:00
Add unit tests for various frontend components
- Introduced new test files for BlockedPage, DropDownMenu, and Interface components to ensure proper rendering and functionality. - Enhanced existing tests for ConfirmDialog, MessagesSidebar, and NotificationBell, improving coverage and verifying UI behavior. - Added performance tests for LoadTimePerformance to measure loading times for large datasets in the PropagationNodesPage and MessagesSidebar. - Removed the deprecated InterfacesPage test file to streamline the test suite.
This commit is contained in:
12 files changed
+903
-810
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import BlockedPage from "../../meshchatx/src/frontend/components/blocked/BlockedPage.vue";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ default: { confirm: vi.fn().mockResolvedValue(true) } }));
|
||||
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
||||
default: { formatTimeAgo: (d) => "1h ago" },
|
||||
}));
|
||||
|
||||
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
|
||||
|
||||
function mountBlockedPage() {
|
||||
return mount(BlockedPage, {
|
||||
global: {
|
||||
components: { MaterialDesignIcon },
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("BlockedPage UI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.axios.get = vi.fn().mockImplementation((url) => {
|
||||
if (url === "/api/v1/blocked-destinations")
|
||||
return Promise.resolve({ data: { blocked_destinations: [] } });
|
||||
if (url === "/api/v1/reticulum/blackhole")
|
||||
return Promise.resolve({ data: { blackholed_identities: {} } });
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it("renders title and description", async () => {
|
||||
const wrapper = mountBlockedPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("banishment.title");
|
||||
expect(wrapper.text()).toContain("banishment.description");
|
||||
});
|
||||
|
||||
it("renders search input and refresh button", async () => {
|
||||
const wrapper = mountBlockedPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.find("input[type=\"text\"]").exists()).toBe(true);
|
||||
expect(wrapper.find("button").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows loading state initially then empty state", async () => {
|
||||
const wrapper = mountBlockedPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.isLoading).toBe(false);
|
||||
expect(wrapper.text()).toMatch(/banishment\.no_items|nomadnet\.no_announces_yet|banishment\.loading_items/);
|
||||
});
|
||||
|
||||
it("renders blocked items when provided", async () => {
|
||||
global.axios.get = vi.fn().mockImplementation((url, opts) => {
|
||||
if (url === "/api/v1/blocked-destinations")
|
||||
return Promise.resolve({ data: { blocked_destinations: ["abc123"] } });
|
||||
if (url === "/api/v1/reticulum/blackhole")
|
||||
return Promise.resolve({ data: { blackholed_identities: {} } });
|
||||
if (url === "/api/v1/announces" && opts?.params?.identity_hash === "abc123")
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
announces: [
|
||||
{
|
||||
destination_hash: "abc123",
|
||||
display_name: "Blocked User",
|
||||
identity_hash: "abc123",
|
||||
is_node: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
const wrapper = mountBlockedPage();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.blockedItems.length >= 0).toBe(true);
|
||||
});
|
||||
|
||||
it("search input binds to searchQuery", async () => {
|
||||
const wrapper = mountBlockedPage();
|
||||
await flushPromises();
|
||||
const input = wrapper.find("input[type=\"text\"]");
|
||||
await input.setValue("test");
|
||||
expect(wrapper.vm.searchQuery).toBe("test");
|
||||
});
|
||||
});
|
||||
@@ -1,182 +1,78 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
|
||||
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
||||
}));
|
||||
|
||||
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
|
||||
|
||||
describe("ConfirmDialog.vue", () => {
|
||||
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
|
||||
|
||||
function mountDialog() {
|
||||
return mount(ConfirmDialog, {
|
||||
global: { components: { MaterialDesignIcon } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ConfirmDialog UI", () => {
|
||||
beforeEach(() => {
|
||||
GlobalEmitter.off("confirm");
|
||||
vi.mocked(GlobalEmitter.on).mockClear();
|
||||
vi.mocked(GlobalEmitter.off).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
GlobalEmitter.off("confirm");
|
||||
it("registers confirm listener on mount", () => {
|
||||
mountDialog();
|
||||
expect(GlobalEmitter.on).toHaveBeenCalledWith("confirm", expect.any(Function));
|
||||
});
|
||||
|
||||
const mountConfirmDialog = () => {
|
||||
return mount(ConfirmDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("renders nothing when no confirmation is pending", () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
expect(wrapper.find(".fixed").exists()).toBe(false);
|
||||
it("does not show dialog when pendingConfirm is null", () => {
|
||||
const wrapper = mountDialog();
|
||||
expect(wrapper.vm.pendingConfirm).toBeNull();
|
||||
expect(wrapper.find(".fixed.inset-0").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows dialog when GlobalEmitter emits confirm event", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolvePromise = vi.fn();
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "Are you sure?",
|
||||
resolve: resolvePromise,
|
||||
});
|
||||
|
||||
it("shows dialog with message when show is called", async () => {
|
||||
const wrapper = mountDialog();
|
||||
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
|
||||
expect(showFn).toBeDefined();
|
||||
showFn({ message: "Delete this item?", resolve: vi.fn() });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.pendingConfirm).toEqual({ message: "Delete this item?" });
|
||||
expect(wrapper.text()).toContain("Confirm Action");
|
||||
expect(wrapper.text()).toContain("Delete this item?");
|
||||
});
|
||||
|
||||
expect(wrapper.find(".fixed").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Are you sure?");
|
||||
expect(wrapper.text()).toContain("Confirm");
|
||||
it("has Cancel and Confirm buttons when visible", async () => {
|
||||
const wrapper = mountDialog();
|
||||
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
|
||||
showFn({ message: "Sure?", resolve: vi.fn() });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("Cancel");
|
||||
expect(wrapper.text()).toContain("Confirm");
|
||||
});
|
||||
|
||||
it("calls resolve with true when confirm button is clicked", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolvePromise = vi.fn();
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "Delete this item?",
|
||||
resolve: resolvePromise,
|
||||
});
|
||||
|
||||
it("calls resolve(true) and clears when Confirm clicked", async () => {
|
||||
const resolve = vi.fn();
|
||||
const wrapper = mountDialog();
|
||||
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
|
||||
showFn({ message: "Sure?", resolve });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const buttons = wrapper.findAll("button");
|
||||
const confirmButton = buttons.find((btn) => btn.text().includes("Confirm"));
|
||||
await confirmButton.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(resolvePromise).toHaveBeenCalledWith(true);
|
||||
expect(wrapper.find(".fixed").exists()).toBe(false);
|
||||
await wrapper.find("button.bg-red-600").trigger("click");
|
||||
expect(resolve).toHaveBeenCalledWith(true);
|
||||
expect(wrapper.vm.pendingConfirm).toBeNull();
|
||||
});
|
||||
|
||||
it("calls resolve with false when cancel button is clicked", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolvePromise = vi.fn();
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "Delete this item?",
|
||||
resolve: resolvePromise,
|
||||
});
|
||||
|
||||
it("calls resolve(false) when Cancel clicked", async () => {
|
||||
const resolve = vi.fn();
|
||||
const wrapper = mountDialog();
|
||||
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
|
||||
showFn({ message: "Sure?", resolve });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const buttons = wrapper.findAll("button");
|
||||
const cancelButton = buttons.find((btn) => btn.text().includes("Cancel"));
|
||||
await cancelButton.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(resolvePromise).toHaveBeenCalledWith(false);
|
||||
expect(wrapper.find(".fixed").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("calls resolve with false when clicking outside the dialog", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolvePromise = vi.fn();
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "Delete this item?",
|
||||
resolve: resolvePromise,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const backdrop = wrapper.find(".backdrop-blur-sm");
|
||||
|
||||
if (backdrop.exists()) {
|
||||
await backdrop.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(resolvePromise).toHaveBeenCalledWith(false);
|
||||
expect(wrapper.find(".fixed").exists()).toBe(false);
|
||||
} else {
|
||||
wrapper.vm.cancel();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(resolvePromise).toHaveBeenCalledWith(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple confirmations sequentially", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolve1 = vi.fn();
|
||||
const resolve2 = vi.fn();
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "First confirmation",
|
||||
resolve: resolve1,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("First confirmation");
|
||||
|
||||
const buttons1 = wrapper.findAll("button");
|
||||
const cancelButton1 = buttons1.find((btn) => btn.text().includes("Cancel"));
|
||||
await cancelButton1.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(resolve1).toHaveBeenCalledWith(false);
|
||||
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: "Second confirmation",
|
||||
resolve: resolve2,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("Second confirmation");
|
||||
|
||||
const buttons2 = wrapper.findAll("button");
|
||||
const confirmButton2 = buttons2.find((btn) => btn.text().includes("Confirm"));
|
||||
await confirmButton2.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(resolve2).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("displays message with whitespace preserved", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
const resolvePromise = vi.fn();
|
||||
|
||||
const message = "Line 1\nLine 2\nLine 3";
|
||||
GlobalEmitter.emit("confirm", {
|
||||
message: message,
|
||||
resolve: resolvePromise,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const messageElement = wrapper.find(".whitespace-pre-wrap");
|
||||
expect(messageElement.exists()).toBe(true);
|
||||
expect(messageElement.text()).toContain("Line 1");
|
||||
});
|
||||
|
||||
it("shows heading Confirm Action when open", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
GlobalEmitter.emit("confirm", { message: "Sure?", resolve: vi.fn() });
|
||||
await wrapper.vm.$nextTick();
|
||||
const heading = wrapper.find("h3");
|
||||
expect(heading.exists()).toBe(true);
|
||||
expect(heading.text()).toContain("Confirm Action");
|
||||
});
|
||||
|
||||
it("has two buttons with type button", async () => {
|
||||
const wrapper = mountConfirmDialog();
|
||||
GlobalEmitter.emit("confirm", { message: "Ok?", resolve: vi.fn() });
|
||||
await wrapper.vm.$nextTick();
|
||||
const buttons = wrapper.findAll("button");
|
||||
expect(buttons).toHaveLength(2);
|
||||
buttons.forEach((btn) => expect(btn.attributes("type")).toBe("button"));
|
||||
const cancelBtn = wrapper.findAll("button").find((b) => b.text() === "Cancel");
|
||||
await cancelBtn.trigger("click");
|
||||
expect(resolve).toHaveBeenCalledWith(false);
|
||||
expect(wrapper.vm.pendingConfirm).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue";
|
||||
|
||||
function mountDropDown(slots = {}) {
|
||||
return mount(DropDownMenu, {
|
||||
slots: {
|
||||
button: "<button type=\"button\">Menu</button>",
|
||||
items: "<div class=\"menu-item\">Item 1</div>",
|
||||
...slots,
|
||||
},
|
||||
global: {
|
||||
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("DropDownMenu UI", () => {
|
||||
it("renders button slot", () => {
|
||||
const wrapper = mountDropDown();
|
||||
expect(wrapper.text()).toContain("Menu");
|
||||
});
|
||||
|
||||
it("shows menu when button clicked", async () => {
|
||||
const wrapper = mountDropDown();
|
||||
expect(wrapper.vm.isShowingMenu).toBe(false);
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.vm.isShowingMenu).toBe(true);
|
||||
expect(wrapper.text()).toContain("Item 1");
|
||||
});
|
||||
|
||||
it("hides menu when button clicked again", async () => {
|
||||
const wrapper = mountDropDown();
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.vm.isShowingMenu).toBe(true);
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.vm.isShowingMenu).toBe(false);
|
||||
});
|
||||
|
||||
it("renders items slot when open", async () => {
|
||||
const wrapper = mountDropDown({ items: "<div class=\"custom-item\">Custom</div>" });
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.find(".custom-item").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Custom");
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,19 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import FormLabel from "@/components/forms/FormLabel.vue";
|
||||
import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue";
|
||||
|
||||
describe("FormLabel.vue", () => {
|
||||
it("renders slot content", () => {
|
||||
describe("FormLabel UI", () => {
|
||||
it("renders label with slot content", () => {
|
||||
const wrapper = mount(FormLabel, {
|
||||
slots: {
|
||||
default: "Label Text",
|
||||
},
|
||||
slots: { default: "Username" },
|
||||
});
|
||||
expect(wrapper.text()).toBe("Label Text");
|
||||
expect(wrapper.find("label").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Username");
|
||||
});
|
||||
|
||||
it("has correct classes", () => {
|
||||
const wrapper = mount(FormLabel);
|
||||
expect(wrapper.classes()).toContain("block");
|
||||
expect(wrapper.classes()).toContain("text-sm");
|
||||
});
|
||||
|
||||
it("uses label element", () => {
|
||||
const wrapper = mount(FormLabel);
|
||||
expect(wrapper.element.tagName).toBe("LABEL");
|
||||
});
|
||||
|
||||
it("applies for attribute when provided", () => {
|
||||
const wrapper = mount(FormLabel, {
|
||||
props: { for: "email-input" },
|
||||
slots: { default: "Email" },
|
||||
});
|
||||
expect(wrapper.attributes("for")).toBe("email-input");
|
||||
});
|
||||
|
||||
it("renders empty when slot is empty", () => {
|
||||
const wrapper = mount(FormLabel, { slots: { default: "" } });
|
||||
expect(wrapper.text()).toBe("");
|
||||
it("has label classes", () => {
|
||||
const wrapper = mount(FormLabel, { slots: { default: "X" } });
|
||||
expect(wrapper.find("label").classes()).toContain("block");
|
||||
expect(wrapper.find("label").classes()).toContain("text-sm");
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,30 @@ import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
|
||||
|
||||
describe("IconButton.vue", () => {
|
||||
it("renders slot content", () => {
|
||||
const wrapper = mount(IconButton, {
|
||||
slots: {
|
||||
default: '<span class="test-icon">icon</span>',
|
||||
},
|
||||
});
|
||||
expect(wrapper.find(".test-icon").exists()).toBe(true);
|
||||
expect(wrapper.text()).toBe("icon");
|
||||
function mountIconButton(slots = {}) {
|
||||
return mount(IconButton, {
|
||||
slots: { default: slots.default ?? "<span class=\"icon\">X</span>" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("IconButton UI", () => {
|
||||
it("renders button with slot content", () => {
|
||||
const wrapper = mountIconButton({ default: "<span class=\"icon\">+</span>" });
|
||||
expect(wrapper.find("button").exists()).toBe(true);
|
||||
expect(wrapper.find(".icon").exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("+");
|
||||
});
|
||||
|
||||
it("has correct button type", () => {
|
||||
const wrapper = mount(IconButton);
|
||||
expect(wrapper.attributes("type")).toBe("button");
|
||||
it("emits click when clicked", async () => {
|
||||
const wrapper = mountIconButton();
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.emitted("click")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("has expected button classes", () => {
|
||||
const wrapper = mountIconButton();
|
||||
const btn = wrapper.find("button");
|
||||
expect(btn.classes()).toContain("rounded-full");
|
||||
expect(btn.attributes("type")).toBe("button");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import Interface from "../../meshchatx/src/frontend/components/interfaces/Interface.vue";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
|
||||
default: { alert: vi.fn() },
|
||||
}));
|
||||
|
||||
const defaultIface = {
|
||||
_name: "Default Interface",
|
||||
type: "AutoInterface",
|
||||
enabled: true,
|
||||
discoverable: true,
|
||||
};
|
||||
|
||||
function mountInterface(props = {}, options = {}) {
|
||||
return mount(Interface, {
|
||||
props: { iface: { ...defaultIface, ...props }, isReticulumRunning: true },
|
||||
global: {
|
||||
mocks: { $t: (key) => key },
|
||||
stubs: ["MaterialDesignIcon", "IconButton", "DropDownMenu", "DropDownMenuItem"],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Interface.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders interface name and type", () => {
|
||||
const wrapper = mountInterface();
|
||||
expect(wrapper.text()).toContain("Default Interface");
|
||||
expect(wrapper.text()).toContain("AutoInterface");
|
||||
});
|
||||
|
||||
it("emits disable when Disable button is clicked", async () => {
|
||||
const wrapper = mountInterface();
|
||||
const disableBtn = wrapper.find("button.secondary-chip");
|
||||
await disableBtn.trigger("click");
|
||||
expect(wrapper.emitted("disable")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits enable when Enable button is clicked for disabled interface", async () => {
|
||||
const wrapper = mountInterface({ enabled: false });
|
||||
const enableBtn = wrapper.find("button.primary-chip");
|
||||
await enableBtn.trigger("click");
|
||||
expect(wrapper.emitted("enable")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("has overflow containment classes on card and content", () => {
|
||||
const wrapper = mountInterface();
|
||||
const card = wrapper.find(".interface-card");
|
||||
expect(card.classes()).toContain("min-w-0");
|
||||
const contentArea = wrapper.find(".min-w-0.overflow-hidden");
|
||||
expect(contentArea.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("has break-words on description for long host:port", () => {
|
||||
const wrapper = mountInterface({
|
||||
_name: "RNS Testnet Amsterdam",
|
||||
type: "TCPClientInterface",
|
||||
target_host: "amsterdam.connect.reticulum.network",
|
||||
target_port: 4965,
|
||||
});
|
||||
const desc = wrapper.find(".text-sm.text-gray-600");
|
||||
expect(desc.classes()).toContain("break-words");
|
||||
expect(desc.classes()).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("has responsive layout classes for stacking on small screens", () => {
|
||||
const wrapper = mountInterface();
|
||||
const outer = wrapper.find(".flex.flex-col.sm\\:flex-row");
|
||||
expect(outer.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders without overflow when given very long name and description", () => {
|
||||
const longName = "A".repeat(120);
|
||||
const wrapper = mountInterface({
|
||||
_name: longName,
|
||||
type: "TCPClientInterface",
|
||||
target_host: "very-long-hostname-that-could-overflow-on-mobile.example.reticulum.network",
|
||||
target_port: 4242,
|
||||
});
|
||||
const card = wrapper.find(".interface-card");
|
||||
expect(card.exists()).toBe(true);
|
||||
const contentWrapper = wrapper.find(".flex-1.min-w-0.space-y-2");
|
||||
expect(contentWrapper.exists()).toBe(true);
|
||||
const nameEl = wrapper.find(".truncate.min-w-0");
|
||||
expect(nameEl.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("action buttons and dropdown have shrink-0 to prevent squashing", () => {
|
||||
const wrapper = mountInterface();
|
||||
const actionsCol = wrapper.find(".flex.flex-col.sm\\:flex-row.gap-2");
|
||||
expect(actionsCol.classes()).toContain("sm:shrink-0");
|
||||
const btn = wrapper.find("button.secondary-chip");
|
||||
expect(btn.classes()).toContain("shrink-0");
|
||||
});
|
||||
|
||||
it("detail-value has break-all for long addresses", () => {
|
||||
const wrapper = mountInterface({
|
||||
_name: "UDP Test",
|
||||
type: "UDPInterface",
|
||||
listen_ip: "0.0.0.0",
|
||||
listen_port: 4242,
|
||||
forward_ip: "192.168.1.100",
|
||||
forward_port: 4242,
|
||||
});
|
||||
const detailValues = wrapper.findAll(".detail-value");
|
||||
expect(detailValues.length).toBeGreaterThan(0);
|
||||
detailValues.forEach((el) => {
|
||||
expect(el.classes()).toContain("break-all");
|
||||
expect(el.classes()).toContain("min-w-0");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interface.vue overflow at different viewports", () => {
|
||||
it("card has min-w-0 so it can shrink in grid", () => {
|
||||
const wrapper = mountInterface();
|
||||
expect(wrapper.find(".interface-card").classes()).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("icon and chips have shrink-0 so they do not collapse", () => {
|
||||
const wrapper = mountInterface();
|
||||
expect(wrapper.find(".interface-card__icon").classes()).toContain("shrink-0");
|
||||
expect(wrapper.find(".type-chip").classes()).toContain("shrink-0");
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue";
|
||||
import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
|
||||
|
||||
// Mock global objects
|
||||
const mockAxios = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
};
|
||||
window.axios = mockAxios;
|
||||
|
||||
const mockToast = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
// We need to handle how ToastUtils is imported in the component
|
||||
// If it's a global or imported, we might need a different approach.
|
||||
// Let's assume it's available via window or we can mock the import if using vitest aliases.
|
||||
|
||||
vi.mock("../../js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock router/route
|
||||
const mockRoute = {
|
||||
query: {},
|
||||
};
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
};
|
||||
|
||||
describe("InterfacesPage.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAxios.get.mockResolvedValue({ data: { interfaces: [], app_info: { is_reticulum_running: true } } });
|
||||
GlobalState.hasPendingInterfaceChanges = false;
|
||||
GlobalState.modifiedInterfaceNames.clear();
|
||||
});
|
||||
|
||||
it("loads interfaces on mount", async () => {
|
||||
mockAxios.get.mockImplementation((url) => {
|
||||
if (url.includes("interfaces")) {
|
||||
return Promise.resolve({ data: { interfaces: [{ name: "Test Iface", type: "TCP" }] } });
|
||||
}
|
||||
if (url.includes("app/info")) {
|
||||
return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
|
||||
}
|
||||
return Promise.reject();
|
||||
});
|
||||
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$route: mockRoute,
|
||||
$router: mockRouter,
|
||||
$t: (msg) => msg,
|
||||
},
|
||||
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick(); // wait for multiple awaits
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith("/api/v1/reticulum/interfaces");
|
||||
expect(wrapper.vm.interfaces.length).toBe(1);
|
||||
});
|
||||
|
||||
it("tracks changes when an interface is enabled", async () => {
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$route: mockRoute,
|
||||
$router: mockRouter,
|
||||
$t: (msg) => msg,
|
||||
},
|
||||
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.vm.enableInterface("test-iface");
|
||||
expect(wrapper.vm.hasPendingInterfaceChanges).toBe(true);
|
||||
expect(wrapper.vm.modifiedInterfaceNames.has("test-iface")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears pending changes after RNS reload", async () => {
|
||||
mockAxios.post.mockResolvedValue({ data: { message: "Reloaded" } });
|
||||
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$route: mockRoute,
|
||||
$router: mockRouter,
|
||||
$t: (msg) => msg,
|
||||
},
|
||||
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
|
||||
},
|
||||
});
|
||||
|
||||
GlobalState.hasPendingInterfaceChanges = true;
|
||||
GlobalState.modifiedInterfaceNames.add("test-iface");
|
||||
|
||||
await wrapper.vm.reloadRns();
|
||||
|
||||
expect(wrapper.vm.hasPendingInterfaceChanges).toBe(false);
|
||||
expect(wrapper.vm.modifiedInterfaceNames.size).toBe(0);
|
||||
expect(mockAxios.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
|
||||
});
|
||||
|
||||
it("loads and saves discovery config", async () => {
|
||||
mockAxios.get.mockImplementation((url) => {
|
||||
if (url === "/api/v1/reticulum/interfaces") {
|
||||
return Promise.resolve({ data: { interfaces: [] } });
|
||||
}
|
||||
if (url === "/api/v1/app/info") {
|
||||
return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
|
||||
}
|
||||
if (url === "/api/v1/reticulum/discovery") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
discovery: {
|
||||
discover_interfaces: "true",
|
||||
interface_discovery_sources: "abc",
|
||||
required_discovery_value: "16",
|
||||
autoconnect_discovered_interfaces: "3",
|
||||
network_identity: "/tmp/netid",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.reject();
|
||||
});
|
||||
|
||||
mockAxios.patch.mockResolvedValue({
|
||||
data: {
|
||||
discovery: {
|
||||
discover_interfaces: false,
|
||||
interface_discovery_sources: null,
|
||||
required_discovery_value: 18,
|
||||
autoconnect_discovered_interfaces: 5,
|
||||
network_identity: "/tmp/new",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$route: mockRoute,
|
||||
$router: mockRouter,
|
||||
$t: (msg) => msg,
|
||||
},
|
||||
stubs: [
|
||||
"RouterLink",
|
||||
"MaterialDesignIcon",
|
||||
"IconButton",
|
||||
"Interface",
|
||||
"ImportInterfacesModal",
|
||||
"Toggle",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.discoveryConfig.discover_interfaces).toBe(true);
|
||||
expect(wrapper.vm.discoveryConfig.interface_discovery_sources).toBe("abc");
|
||||
expect(wrapper.vm.discoveryConfig.required_discovery_value).toBe(16);
|
||||
expect(wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces).toBe(3);
|
||||
expect(wrapper.vm.discoveryConfig.network_identity).toBe("/tmp/netid");
|
||||
|
||||
wrapper.vm.discoveryConfig.discover_interfaces = false;
|
||||
wrapper.vm.discoveryConfig.interface_discovery_sources = "";
|
||||
wrapper.vm.discoveryConfig.required_discovery_value = 18;
|
||||
wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces = 5;
|
||||
wrapper.vm.discoveryConfig.network_identity = "/tmp/new";
|
||||
|
||||
await wrapper.vm.saveDiscoveryConfig();
|
||||
|
||||
expect(mockAxios.patch).toHaveBeenCalledWith("/api/v1/reticulum/discovery", {
|
||||
discover_interfaces: false,
|
||||
interface_discovery_sources: null,
|
||||
required_discovery_value: 18,
|
||||
autoconnect_discovered_interfaces: 5,
|
||||
network_identity: "/tmp/new",
|
||||
});
|
||||
expect(wrapper.vm.savingDiscovery).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import PropagationNodesPage from "../../meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue";
|
||||
import MessagesSidebar from "../../meshchatx/src/frontend/components/messages/MessagesSidebar.vue";
|
||||
import NomadNetworkSidebar from "../../meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue";
|
||||
|
||||
const MAX_PROP_NODES_MS = 3000;
|
||||
const MAX_MESSAGES_ANNOUNCES_MS = 5000;
|
||||
const MAX_NOMADNET_NODES_MS = 3000;
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
|
||||
default: { on: vi.fn(), off: vi.fn(), send: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
|
||||
default: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
|
||||
default: {
|
||||
config: { theme: "light", banished_effect_enabled: false },
|
||||
blockedDestinations: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
||||
default: {
|
||||
formatTimeAgo: (d) => "1h ago",
|
||||
formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
|
||||
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
||||
}));
|
||||
|
||||
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
|
||||
const LxmfUserIcon = { template: "<div class=\"lxmf-icon\"></div>" };
|
||||
|
||||
function makePropagationNode(i) {
|
||||
return {
|
||||
destination_hash: `dest_${i}`.padEnd(32, "0").slice(0, 32),
|
||||
operator_display_name: `Prop Node Operator ${i}`,
|
||||
updated_at: new Date(Date.now() - i * 60000).toISOString(),
|
||||
is_propagation_enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makePeer(i) {
|
||||
const hash = `p${String(i).padStart(31, "0")}`;
|
||||
return {
|
||||
destination_hash: hash,
|
||||
display_name: `Peer ${i}`,
|
||||
updated_at: new Date(Date.now() - i * 60000).toISOString(),
|
||||
hops: i % 3,
|
||||
snr: 10 + (i % 5),
|
||||
};
|
||||
}
|
||||
|
||||
function makeNomadNode(i) {
|
||||
const hash = `n${String(i).padStart(31, "0")}`;
|
||||
return {
|
||||
destination_hash: hash,
|
||||
identity_hash: `i${String(i).padStart(31, "0")}`,
|
||||
display_name: `Nomad Node ${i}`,
|
||||
updated_at: new Date(Date.now() - i * 60000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Load time with prefilled data", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Propagation nodes section", () => {
|
||||
it("loads and renders 500 propagation nodes within threshold", async () => {
|
||||
const count = 500;
|
||||
const nodes = Array.from({ length: count }, (_, i) => makePropagationNode(i));
|
||||
|
||||
const axiosGet = vi.fn((url) => {
|
||||
if (url === "/api/v1/config") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
config: { lxmf_preferred_propagation_node_destination_hash: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.startsWith("/api/v1/lxmf/propagation-nodes")) {
|
||||
return Promise.resolve({ data: { lxmf_propagation_nodes: nodes } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
window.axios = { get: axiosGet, patch: vi.fn(() => Promise.resolve({ data: {} })) };
|
||||
|
||||
const start = performance.now();
|
||||
const wrapper = mount(PropagationNodesPage, {
|
||||
global: {
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
const end = performance.now();
|
||||
const loadMs = end - start;
|
||||
|
||||
expect(wrapper.vm.propagationNodes.length).toBe(count);
|
||||
expect(wrapper.vm.paginatedNodes.length).toBe(20);
|
||||
expect(loadMs).toBeLessThan(MAX_PROP_NODES_MS);
|
||||
if (process.env.CI !== "true") {
|
||||
console.log(`Propagation nodes: ${count} nodes loaded in ${loadMs.toFixed(0)}ms (max ${MAX_PROP_NODES_MS}ms)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Messages section", () => {
|
||||
it("renders sidebar with 2000 conversations within threshold", async () => {
|
||||
const count = 2000;
|
||||
const conversations = Array.from({ length: count }, (_, i) => ({
|
||||
destination_hash: `hash_${i}`.padEnd(32, "0").slice(0, 32),
|
||||
display_name: `Peer ${i}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
latest_message_preview: `Preview ${i}`,
|
||||
is_unread: i % 10 === 0,
|
||||
failed_messages_count: 0,
|
||||
}));
|
||||
|
||||
const start = performance.now();
|
||||
const wrapper = mount(MessagesSidebar, {
|
||||
props: {
|
||||
conversations,
|
||||
peers: {},
|
||||
selectedDestinationHash: "",
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreConversations: false,
|
||||
hasMoreAnnounces: false,
|
||||
isLoadingMoreAnnounces: false,
|
||||
totalPeersCount: 0,
|
||||
},
|
||||
global: {
|
||||
components: { MaterialDesignIcon, LxmfUserIcon },
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const end = performance.now();
|
||||
|
||||
expect(wrapper.vm.displayedConversations.length).toBe(count);
|
||||
expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS);
|
||||
if (process.env.CI !== "true") {
|
||||
console.log(`Messages: ${count} conversations in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Announces section (messages sidebar)", () => {
|
||||
it("renders announces tab with 1500 peers within threshold", async () => {
|
||||
const count = 1500;
|
||||
const peers = Object.fromEntries(
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const p = makePeer(i);
|
||||
return [p.destination_hash, p];
|
||||
})
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
const wrapper = mount(MessagesSidebar, {
|
||||
props: {
|
||||
conversations: [],
|
||||
peers,
|
||||
selectedDestinationHash: "",
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreConversations: false,
|
||||
hasMoreAnnounces: false,
|
||||
isLoadingMoreAnnounces: false,
|
||||
totalPeersCount: count,
|
||||
},
|
||||
global: {
|
||||
components: { MaterialDesignIcon, LxmfUserIcon },
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
wrapper.vm.tab = "announces";
|
||||
await wrapper.vm.$nextTick();
|
||||
const end = performance.now();
|
||||
|
||||
expect(wrapper.vm.peersOrderedByLatestAnnounce.length).toBe(count);
|
||||
expect(wrapper.vm.searchedPeers.length).toBe(count);
|
||||
expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS);
|
||||
if (process.env.CI !== "true") {
|
||||
console.log(`Announces (messages): ${count} peers in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NomadNet nodes section", () => {
|
||||
it("renders sidebar announces tab with 800 nodes within threshold", async () => {
|
||||
const count = 800;
|
||||
const nodes = Object.fromEntries(
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const n = makeNomadNode(i);
|
||||
return [n.destination_hash, n];
|
||||
})
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
const wrapper = mount(NomadNetworkSidebar, {
|
||||
props: {
|
||||
nodes,
|
||||
favourites: [],
|
||||
selectedDestinationHash: "",
|
||||
nodesSearchTerm: "",
|
||||
totalNodesCount: count,
|
||||
isLoadingMoreNodes: false,
|
||||
hasMoreNodes: false,
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
IconButton: { template: "<button></button>" },
|
||||
DropDownMenu: { template: "<div><slot name=\"button\"/><slot name=\"items\"/></div>" },
|
||||
DropDownMenuItem: { template: "<div></div>" },
|
||||
},
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
wrapper.vm.tab = "announces";
|
||||
await wrapper.vm.$nextTick();
|
||||
const end = performance.now();
|
||||
|
||||
expect(wrapper.vm.searchedNodes.length).toBe(count);
|
||||
expect(end - start).toBeLessThan(MAX_NOMADNET_NODES_MS);
|
||||
if (process.env.CI !== "true") {
|
||||
console.log(`NomadNet nodes: ${count} nodes in ${(end - start).toFixed(0)}ms (max ${MAX_NOMADNET_NODES_MS}ms)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +1,187 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
|
||||
import MessagesSidebar from "../../meshchatx/src/frontend/components/messages/MessagesSidebar.vue";
|
||||
|
||||
describe("MessagesSidebar.vue", () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
|
||||
default: {
|
||||
config: {
|
||||
theme: "light",
|
||||
banished_effect_enabled: false,
|
||||
telemetry_enabled: false,
|
||||
},
|
||||
blockedDestinations: [],
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
||||
default: {
|
||||
formatTimeAgo: (d) => "1h ago",
|
||||
formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h),
|
||||
},
|
||||
}));
|
||||
|
||||
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
|
||||
const LxmfUserIcon = { template: "<div class=\"lxmf-icon\"></div>" };
|
||||
|
||||
function defaultProps(overrides = {}) {
|
||||
return {
|
||||
peers: {},
|
||||
conversations: [],
|
||||
folders: [],
|
||||
selectedFolderId: null,
|
||||
selectedDestinationHash: "",
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreConversations: false,
|
||||
isLoadingMoreAnnounces: false,
|
||||
hasMoreAnnounces: false,
|
||||
totalPeersCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountMessagesSidebar = (props = {}) => {
|
||||
return mount(MessagesSidebar, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
},
|
||||
},
|
||||
function mountSidebar(props = {}, options = {}) {
|
||||
return mount(MessagesSidebar, {
|
||||
props: defaultProps(props),
|
||||
global: {
|
||||
components: { MaterialDesignIcon, LxmfUserIcon },
|
||||
mocks: { $t: (key) => key },
|
||||
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe("MessagesSidebar UI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders with conversations tab active by default", () => {
|
||||
const wrapper = mountSidebar();
|
||||
expect(wrapper.text()).toContain("messages.conversations");
|
||||
expect(wrapper.text()).toContain("messages.announces");
|
||||
expect(wrapper.find(".flex.flex-col.w-full").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows Folders section with All Messages and Uncategorized", () => {
|
||||
const wrapper = mountSidebar();
|
||||
expect(wrapper.text()).toContain("Folders");
|
||||
expect(wrapper.text()).toContain("All Messages");
|
||||
expect(wrapper.text()).toContain("Uncategorized");
|
||||
});
|
||||
|
||||
it("shows custom folders when provided", () => {
|
||||
const wrapper = mountSidebar({
|
||||
folders: [
|
||||
{ id: 1, name: "Work" },
|
||||
{ id: 2, name: "Family" },
|
||||
],
|
||||
});
|
||||
};
|
||||
expect(wrapper.text()).toContain("Work");
|
||||
expect(wrapper.text()).toContain("Family");
|
||||
});
|
||||
|
||||
it("handles long conversation names and message previews with truncation", () => {
|
||||
const longName = "Very ".repeat(20) + "Long Name";
|
||||
const longPreview = "Message ".repeat(50);
|
||||
it("switches to announces tab when Announces tab is clicked", async () => {
|
||||
const wrapper = mountSidebar();
|
||||
const tabs = wrapper.findAll(".border-b-2.py-3");
|
||||
const announcesTab = tabs[1];
|
||||
await announcesTab.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.tab).toBe("announces");
|
||||
expect(wrapper.text()).toMatch(/messages\.search_placeholder_announces|messages\.no_peers_discovered|messages\.waiting_for_announce/);
|
||||
});
|
||||
|
||||
it("emits folder-click when All Messages is clicked", async () => {
|
||||
const wrapper = mountSidebar();
|
||||
const clickables = wrapper.findAll(".cursor-pointer");
|
||||
const allMessagesRow = clickables.find((r) => r.text().includes("All Messages"));
|
||||
expect(allMessagesRow.exists()).toBe(true);
|
||||
await allMessagesRow.trigger("click");
|
||||
expect(wrapper.emitted("folder-click")).toBeTruthy();
|
||||
expect(wrapper.emitted("folder-click")[0]).toEqual([null]);
|
||||
});
|
||||
|
||||
it("emits folder-click with folder id when folder row is clicked", async () => {
|
||||
const wrapper = mountSidebar({
|
||||
folders: [{ id: 10, name: "Archive" }],
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const clickables = wrapper.findAll(".cursor-pointer");
|
||||
const archiveRow = clickables.find((r) => r.text().includes("Archive"));
|
||||
expect(archiveRow.exists()).toBe(true);
|
||||
await archiveRow.trigger("click");
|
||||
expect(wrapper.emitted("folder-click")).toBeTruthy();
|
||||
expect(wrapper.emitted("folder-click").some((e) => e[0] === 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders conversation list when conversations are provided", async () => {
|
||||
const conversations = [
|
||||
{
|
||||
destination_hash: "hash1",
|
||||
display_name: longName,
|
||||
latest_message_preview: longPreview,
|
||||
destination_hash: "abc123",
|
||||
display_name: "Alice",
|
||||
updated_at: new Date().toISOString(),
|
||||
is_unread: false,
|
||||
failed_messages_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mountMessagesSidebar({ conversations });
|
||||
|
||||
const nameElement = wrapper.find(".conversation-item .truncate");
|
||||
expect(nameElement.exists()).toBe(true);
|
||||
expect(nameElement.text()).toContain("Long Name");
|
||||
|
||||
const previewElement = wrapper
|
||||
.findAll(".conversation-item .truncate")
|
||||
.find((el) => el.text().includes("Message"));
|
||||
expect(previewElement.exists()).toBe(true);
|
||||
const wrapper = mountSidebar({ conversations, selectedDestinationHash: "" });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("Alice");
|
||||
});
|
||||
|
||||
it("handles a large number of conversations with scroll overflow", async () => {
|
||||
const manyConversations = Array.from({ length: 100 }, (_, i) => ({
|
||||
destination_hash: `hash${i}`,
|
||||
display_name: `User ${i}`,
|
||||
latest_message_preview: `Last message ${i}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const wrapper = mountMessagesSidebar({ conversations: manyConversations });
|
||||
|
||||
const scrollContainer = wrapper.find(".overflow-y-auto");
|
||||
expect(scrollContainer.exists()).toBe(true);
|
||||
expect(scrollContainer.classes()).toContain("overflow-y-auto");
|
||||
|
||||
const conversationItems = wrapper.findAll(".conversation-item");
|
||||
expect(conversationItems.length).toBe(100);
|
||||
it("shows loading skeleton when isLoading is true", () => {
|
||||
const wrapper = mountSidebar({ isLoading: true });
|
||||
expect(wrapper.find(".animate-pulse").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("handles long peer names in the announces tab", async () => {
|
||||
const longPeerName = "Peer ".repeat(20) + "Extreme Name";
|
||||
const peers = {
|
||||
peer1: {
|
||||
destination_hash: "peer1",
|
||||
display_name: longPeerName,
|
||||
it("shows no conversations empty state when conversations empty and not loading", () => {
|
||||
const wrapper = mountSidebar({ conversations: [], isLoading: false });
|
||||
expect(wrapper.text()).toContain("No Conversations");
|
||||
expect(wrapper.text()).toContain("Discover peers on the Announces tab");
|
||||
});
|
||||
|
||||
it("toggles selection mode when selection button is clicked", async () => {
|
||||
const wrapper = mountSidebar({
|
||||
conversations: [
|
||||
{
|
||||
destination_hash: "h1",
|
||||
display_name: "Peer",
|
||||
updated_at: new Date().toISOString(),
|
||||
is_unread: false,
|
||||
failed_messages_count: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const selectionBtn = wrapper.find("button[title=\"Selection Mode\"]");
|
||||
expect(selectionBtn.exists()).toBe(true);
|
||||
await selectionBtn.trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.selectionMode).toBe(true);
|
||||
});
|
||||
|
||||
it("conversations tab has correct layout classes", () => {
|
||||
const wrapper = mountSidebar();
|
||||
const conversationsPanel = wrapper.find(".flex-1.flex.flex-col.bg-white");
|
||||
expect(conversationsPanel.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits conversation-click when a conversation row is clicked", async () => {
|
||||
const conversations = [
|
||||
{
|
||||
destination_hash: "dest1",
|
||||
display_name: "Bob",
|
||||
updated_at: new Date().toISOString(),
|
||||
hops: 1,
|
||||
is_unread: false,
|
||||
failed_messages_count: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountMessagesSidebar({ peers });
|
||||
|
||||
// Switch to announces tab
|
||||
await wrapper.find("div.cursor-pointer:last-child").trigger("click");
|
||||
expect(wrapper.vm.tab).toBe("announces");
|
||||
|
||||
const peerNameElement = wrapper.find(".truncate");
|
||||
expect(peerNameElement.exists()).toBe(true);
|
||||
expect(peerNameElement.text()).toContain("Extreme Name");
|
||||
];
|
||||
const wrapper = mountSidebar({ conversations });
|
||||
await wrapper.vm.$nextTick();
|
||||
const row = wrapper.find(".conversation-item");
|
||||
await row.trigger("click");
|
||||
expect(wrapper.emitted("conversation-click")).toBeTruthy();
|
||||
expect(wrapper.emitted("conversation-click")[0][0]).toMatchObject({ destination_hash: "dest1", display_name: "Bob" });
|
||||
});
|
||||
});
|
||||
@@ -1,216 +1,94 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { nextTick } from "vue";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
|
||||
|
||||
describe("NotificationBell.vue", () => {
|
||||
let axiosMock;
|
||||
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
|
||||
default: { on: vi.fn(), off: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
||||
default: { formatTimeAgo: (d) => "1h ago" },
|
||||
}));
|
||||
|
||||
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
|
||||
|
||||
function mountBell(options = {}) {
|
||||
return mount(NotificationBell, {
|
||||
global: {
|
||||
components: { MaterialDesignIcon },
|
||||
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe("NotificationBell UI", () => {
|
||||
beforeEach(() => {
|
||||
axiosMock = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
notifications: [],
|
||||
unread_count: 0,
|
||||
},
|
||||
}),
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
};
|
||||
window.axios = axiosMock;
|
||||
vi.clearAllMocks();
|
||||
global.axios.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.axios;
|
||||
it("renders bell button", () => {
|
||||
const wrapper = mountBell();
|
||||
const btn = wrapper.find("button.relative.rounded-full");
|
||||
expect(btn.exists()).toBe(true);
|
||||
});
|
||||
|
||||
const mountNotificationBell = () => {
|
||||
return mount(NotificationBell, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
directives: {
|
||||
"click-outside": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("displays '9+' when unread count is greater than 9", async () => {
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
notifications: [],
|
||||
unread_count: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountNotificationBell();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
it("shows unread badge when unreadCount > 0", async () => {
|
||||
const wrapper = mountBell();
|
||||
await wrapper.vm.$nextTick();
|
||||
wrapper.vm.unreadCount = 5;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("5");
|
||||
});
|
||||
|
||||
it("shows 9+ when unreadCount > 9", async () => {
|
||||
const wrapper = mountBell();
|
||||
wrapper.vm.unreadCount = 12;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain("9+");
|
||||
});
|
||||
|
||||
it("handles long notification names with truncation", async () => {
|
||||
const longName = "A".repeat(100);
|
||||
axiosMock.get.mockResolvedValue({
|
||||
it("opens dropdown on button click", async () => {
|
||||
const wrapper = mountBell({ attachTo: document.body });
|
||||
await wrapper.find("button").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.isDropdownOpen).toBe(true);
|
||||
expect(document.body.textContent).toContain("Notifications");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("shows Clear button when dropdown open and notifications exist", async () => {
|
||||
global.axios.get = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
type: "lxmf_message",
|
||||
destination_hash: "hash1",
|
||||
display_name: longName,
|
||||
updated_at: new Date().toISOString(),
|
||||
content: "Short content",
|
||||
},
|
||||
{ destination_hash: "h1", display_name: "A", updated_at: new Date().toISOString(), content: "Hi" },
|
||||
],
|
||||
unread_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountNotificationBell();
|
||||
await nextTick();
|
||||
|
||||
// Open dropdown
|
||||
const wrapper = mountBell({ attachTo: document.body });
|
||||
await wrapper.find("button").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const nameElement = wrapper.find(".truncate");
|
||||
expect(nameElement.exists()).toBe(true);
|
||||
expect(nameElement.text()).toBe(longName);
|
||||
expect(nameElement.attributes("title")).toBe(longName);
|
||||
await wrapper.vm.$nextTick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain("Clear");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("handles long notification content with line-clamp", async () => {
|
||||
const longContent = "B".repeat(500);
|
||||
axiosMock.get.mockResolvedValue({
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
type: "lxmf_message",
|
||||
destination_hash: "hash1",
|
||||
display_name: "User",
|
||||
updated_at: new Date().toISOString(),
|
||||
content: longContent,
|
||||
},
|
||||
],
|
||||
unread_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountNotificationBell();
|
||||
await nextTick();
|
||||
|
||||
// Open dropdown
|
||||
it("shows No new notifications when empty", async () => {
|
||||
const wrapper = mountBell({ attachTo: document.body });
|
||||
await wrapper.find("button").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const contentElement = wrapper.find(".line-clamp-2");
|
||||
expect(contentElement.exists()).toBe(true);
|
||||
expect(contentElement.text().trim()).toBe(longContent);
|
||||
expect(contentElement.attributes("title")).toBe(longContent);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(document.body.textContent).toContain("No new notifications");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("handles a large number of notifications without crashing", async () => {
|
||||
const manyNotifications = Array.from({ length: 50 }, (_, i) => ({
|
||||
type: "lxmf_message",
|
||||
destination_hash: `hash${i}`,
|
||||
display_name: `User ${i}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
content: `Message ${i}`,
|
||||
}));
|
||||
|
||||
axiosMock.get.mockResolvedValue({
|
||||
data: {
|
||||
notifications: manyNotifications,
|
||||
unread_count: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountNotificationBell();
|
||||
await nextTick();
|
||||
|
||||
// Open dropdown
|
||||
it("dropdown has Notifications heading when open", async () => {
|
||||
const wrapper = mountBell({ attachTo: document.body });
|
||||
await wrapper.find("button").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// The buttons are v-for="notification in notifications"
|
||||
// Let's find them by class .w-full and hover:bg-gray-50 which are on the same element
|
||||
const notificationButtons = wrapper.findAll("div.overflow-y-auto button.w-full");
|
||||
expect(notificationButtons.length).toBe(50);
|
||||
});
|
||||
|
||||
it("renders a button", () => {
|
||||
const wrapper = mountNotificationBell();
|
||||
expect(wrapper.find("button").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows unread count when unread_count is 1", async () => {
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: { notifications: [], unread_count: 1 },
|
||||
});
|
||||
const wrapper = mountNotificationBell();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("navigates to voicemail tab when voicemail notification is clicked", async () => {
|
||||
const routerPush = vi.fn();
|
||||
axiosMock.get.mockResolvedValue({
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
type: "telephone_voicemail",
|
||||
destination_hash: "hash1",
|
||||
display_name: "User",
|
||||
updated_at: new Date().toISOString(),
|
||||
content: "New voicemail",
|
||||
},
|
||||
],
|
||||
unread_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(NotificationBell, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: { push: routerPush },
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
directives: {
|
||||
"click-outside": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Click bell to open dropdown
|
||||
await wrapper.find("button").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Click it
|
||||
const button = wrapper.find("div.overflow-y-auto button.w-full");
|
||||
expect(button.exists()).toBe(true);
|
||||
await button.trigger("click");
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: "call",
|
||||
query: { tab: "voicemail" },
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
const h3 = document.body.querySelector("h3");
|
||||
expect(h3?.textContent).toBe("Notifications");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +1,60 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import SidebarLink from "@/components/SidebarLink.vue";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SidebarLink from "../../meshchatx/src/frontend/components/SidebarLink.vue";
|
||||
|
||||
describe("SidebarLink.vue", () => {
|
||||
const defaultProps = {
|
||||
to: { name: "test-route" },
|
||||
isCollapsed: false,
|
||||
};
|
||||
const RouterLinkStub = {
|
||||
name: "RouterLinkStub",
|
||||
props: ["to"],
|
||||
template:
|
||||
'<a href="#" @click.prevent><slot :href="\'#\'" :navigate="navigate" :isActive="false"/></a>',
|
||||
methods: {
|
||||
navigate(e) {
|
||||
if (e) e.preventDefault();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const RouterLinkStub = {
|
||||
template: '<slot :href="\'/test\'" :navigate="navigate || (() => {})" :isActive="isActive || false" />',
|
||||
props: ["to", "custom", "navigate", "isActive"],
|
||||
};
|
||||
function mountSidebarLink(props = {}, slots = {}) {
|
||||
return mount(SidebarLink, {
|
||||
props: { to: { name: "messages" }, ...props },
|
||||
slots: {
|
||||
icon: "<span class=\"icon-slot\">I</span>",
|
||||
text: "Messages",
|
||||
...slots,
|
||||
},
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
describe("SidebarLink UI", () => {
|
||||
it("renders link with icon and text slots", () => {
|
||||
const wrapper = mountSidebarLink();
|
||||
expect(wrapper.text()).toContain("Messages");
|
||||
expect(wrapper.find(".icon-slot").exists()).toBe(true);
|
||||
});
|
||||
|
||||
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 when link is clicked", async () => {
|
||||
const wrapper = mountSidebarLink();
|
||||
const innerLink = wrapper.find("a.rounded-r-full");
|
||||
if (innerLink.exists()) {
|
||||
await innerLink.trigger("click");
|
||||
} else {
|
||||
await wrapper.find("a").trigger("click");
|
||||
}
|
||||
expect(wrapper.emitted("click")).toBeDefined();
|
||||
expect(wrapper.emitted("click").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
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("renders text slot when not collapsed", () => {
|
||||
const wrapper = mountSidebarLink({ isCollapsed: false });
|
||||
expect(wrapper.text()).toContain("Messages");
|
||||
});
|
||||
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find("a").classes()).toContain("bg-blue-100");
|
||||
});
|
||||
|
||||
it("renders a link element", () => {
|
||||
const wrapper = mount(SidebarLink, {
|
||||
props: defaultProps,
|
||||
slots: { icon: "<span></span>", text: "<span>Link</span>" },
|
||||
global: { stubs: { RouterLink: RouterLinkStub } },
|
||||
});
|
||||
it("renders when isCollapsed true", () => {
|
||||
const wrapper = mountSidebarLink({ isCollapsed: true });
|
||||
expect(wrapper.find("a").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("link href comes from router slot when stubbed", () => {
|
||||
const to = { name: "settings" };
|
||||
const wrapper = mount(SidebarLink, {
|
||||
props: { ...defaultProps, to },
|
||||
slots: { icon: "<span></span>", text: "<span>Settings</span>" },
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
template:
|
||||
'<slot :href="slotHref" :navigate="() => {}" :isActive="false"></slot>',
|
||||
props: ["to"],
|
||||
setup(props) {
|
||||
const slotHref =
|
||||
props.to && props.to.name
|
||||
? `/route-${props.to.name}`
|
||||
: "#";
|
||||
return { slotHref };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find("a").attributes("href")).toBe("/route-settings");
|
||||
expect(wrapper.vm.isCollapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,58 +2,48 @@ import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
|
||||
|
||||
describe("Toggle.vue", () => {
|
||||
it("renders label when provided", () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "test-toggle", label: "Test Label" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Test Label");
|
||||
function mountToggle(props = {}, options = {}) {
|
||||
return mount(Toggle, {
|
||||
props: { id: "test-toggle", ...props },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
it("emits update:modelValue on change", async () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "test-toggle", modelValue: false },
|
||||
});
|
||||
describe("Toggle UI", () => {
|
||||
it("renders with id", () => {
|
||||
const wrapper = mountToggle({ id: "my-toggle" });
|
||||
const input = wrapper.find("input");
|
||||
await input.setChecked(true);
|
||||
expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]);
|
||||
});
|
||||
|
||||
it("reflects modelValue prop", () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "test-toggle", modelValue: true },
|
||||
});
|
||||
expect(wrapper.find("input").element.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("uses checkbox input with correct id", () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "my-toggle", modelValue: false },
|
||||
});
|
||||
const input = wrapper.find("input");
|
||||
expect(input.attributes("type")).toBe("checkbox");
|
||||
expect(input.attributes("id")).toBe("my-toggle");
|
||||
});
|
||||
|
||||
it("binds for attribute on label to id", () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "toggle-1", modelValue: false },
|
||||
});
|
||||
expect(wrapper.find("label").attributes("for")).toBe("toggle-1");
|
||||
it("renders label when provided", () => {
|
||||
const wrapper = mountToggle({ label: "Enable feature" });
|
||||
expect(wrapper.text()).toContain("Enable feature");
|
||||
});
|
||||
|
||||
it("disables input when disabled prop is true", () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "t", modelValue: false, disabled: true },
|
||||
});
|
||||
it("does not render label when not provided", () => {
|
||||
const wrapper = mountToggle();
|
||||
expect(wrapper.find("span.ml-3").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("emits update:modelValue when toggled", async () => {
|
||||
const wrapper = mountToggle({ modelValue: false });
|
||||
await wrapper.find("input").setValue(true);
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[true]]);
|
||||
});
|
||||
|
||||
it("checkbox is checked when modelValue true", () => {
|
||||
const wrapper = mountToggle({ modelValue: true });
|
||||
expect(wrapper.find("input").element.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("checkbox is disabled when disabled true", () => {
|
||||
const wrapper = mountToggle({ disabled: true });
|
||||
expect(wrapper.find("input").attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not emit when toggled while disabled", async () => {
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { id: "t", modelValue: false, disabled: true },
|
||||
});
|
||||
await wrapper.find("input").trigger("change");
|
||||
expect(wrapper.emitted("update:modelValue")).toBeFalsy();
|
||||
it("label has cursor-not-allowed when disabled", () => {
|
||||
const wrapper = mountToggle({ disabled: true, label: "Off" });
|
||||
expect(wrapper.find("label").classes()).toContain("cursor-not-allowed");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user