test(frontend): add comprehensive unit tests for AuthPage, CommandPalette, ConfirmDialog, UI components, and theme handling

This commit is contained in:
2026-01-03 22:40:27 -06:00
parent 86bbbb8003
commit 2210f10305
5 changed files with 2025 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import AuthPage from "../../meshchatx/src/frontend/components/auth/AuthPage.vue";
describe("AuthPage.vue", () => {
let axiosMock;
let routerMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
}),
post: vi.fn().mockResolvedValue({ data: { success: true } }),
};
window.axios = axiosMock;
routerMock = {
push: vi.fn(),
};
Object.defineProperty(window, "location", {
value: {
reload: vi.fn(),
},
writable: true,
});
});
afterEach(() => {
delete window.axios;
vi.clearAllMocks();
});
const mountAuthPage = () => {
return mount(AuthPage, {
global: {
mocks: {
$router: routerMock,
},
},
});
};
it("renders login form when password is set", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Authentication Required");
expect(wrapper.text()).toContain("Login");
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
});
it("renders setup form when password is not set", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(wrapper.vm.isSetup).toBe(true);
expect(wrapper.text()).toContain("Initial Setup");
expect(wrapper.text()).toContain("Set Password");
expect(wrapper.findAll('input[type="password"]').length).toBe(2);
});
it("redirects to home when auth is disabled", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: false,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
expect(routerMock.push).toHaveBeenCalledWith("/");
});
it("redirects to home when already authenticated", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: true,
password_set: true,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
expect(routerMock.push).toHaveBeenCalledWith("/");
});
it("validates password length in setup mode", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
wrapper.vm.password = "short";
wrapper.vm.confirmPassword = "short";
await wrapper.vm.$nextTick();
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(wrapper.vm.error).toContain("at least 8 characters");
expect(axiosMock.post).not.toHaveBeenCalled();
});
it("validates password match in setup mode", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
wrapper.vm.password = "password123";
wrapper.vm.confirmPassword = "password456";
await wrapper.vm.$nextTick();
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(wrapper.vm.error).toContain("do not match");
expect(axiosMock.post).not.toHaveBeenCalled();
});
it("submits setup form with valid password", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
wrapper.vm.password = "password123";
wrapper.vm.confirmPassword = "password123";
await wrapper.vm.$nextTick();
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/setup", {
password: "password123",
});
});
it("submits login form with password", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
wrapper.vm.password = "password123";
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/login", {
password: "password123",
});
});
it("reloads page after successful login", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
wrapper.vm.password = "password123";
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(window.location.reload).toHaveBeenCalled();
});
it("displays error message on login failure", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
});
axiosMock.post.mockRejectedValueOnce({
response: {
data: {
error: "Invalid password",
},
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
wrapper.vm.password = "wrongpassword";
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(wrapper.vm.error).toBe("Invalid password");
expect(wrapper.vm.isLoading).toBe(false);
});
it("displays error message on setup failure", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
axiosMock.post.mockRejectedValueOnce({
response: {
data: {
error: "Setup failed",
},
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
wrapper.vm.password = "password123";
wrapper.vm.confirmPassword = "password123";
await wrapper.vm.handleSubmit();
await wrapper.vm.$nextTick();
expect(wrapper.vm.error).toBe("Setup failed");
expect(wrapper.vm.isLoading).toBe(false);
});
it("disables submit button when loading", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: true,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
wrapper.vm.isLoading = true;
await wrapper.vm.$nextTick();
const submitButton = wrapper.find('button[type="submit"]');
expect(submitButton.attributes("disabled")).toBeDefined();
});
it("disables submit button when passwords do not match in setup mode", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
auth_enabled: true,
authenticated: false,
password_set: false,
},
});
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
wrapper.vm.password = "password123";
wrapper.vm.confirmPassword = "password456";
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const submitButton = wrapper.find('button[type="submit"]');
const disabledAttr = submitButton.attributes("disabled");
const disabledProp = submitButton.element.disabled;
expect(disabledAttr !== undefined || disabledProp === true).toBe(true);
});
it("handles network errors gracefully", async () => {
axiosMock.get.mockRejectedValueOnce(new Error("Network error"));
const wrapper = mountAuthPage();
await wrapper.vm.$nextTick();
await wrapper.vm.checkAuthStatus();
await wrapper.vm.$nextTick();
expect(wrapper.vm.error).toContain("Failed to check");
});
});

View File

@@ -0,0 +1,311 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CommandPalette from "../../meshchatx/src/frontend/components/CommandPalette.vue";
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
describe("CommandPalette.vue", () => {
let axiosMock;
let routerMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
announces: [],
contacts: [],
},
}),
};
window.axios = axiosMock;
routerMock = {
push: vi.fn(),
};
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
});
afterEach(() => {
delete window.axios;
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
vi.clearAllMocks();
});
const mountCommandPalette = () => {
return mount(CommandPalette, {
global: {
mocks: {
$t: (key) => key,
$router: routerMock,
},
stubs: {
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
LxmfUserIcon: { template: '<div class="lxmf-icon"></div>' },
},
directives: {
"click-outside": {},
},
},
});
};
it("renders nothing when closed", () => {
const wrapper = mountCommandPalette();
expect(wrapper.find(".fixed").exists()).toBe(false);
});
it("opens when Ctrl+K or Cmd+K is pressed", async () => {
const wrapper = mountCommandPalette();
const event = new KeyboardEvent("keydown", {
key: "k",
ctrlKey: true,
});
window.dispatchEvent(event);
await wrapper.vm.$nextTick();
expect(wrapper.vm.isOpen).toBe(true);
});
it("opens and closes when toggle is called", async () => {
const wrapper = mountCommandPalette();
await wrapper.vm.toggle();
expect(wrapper.vm.isOpen).toBe(true);
await wrapper.vm.toggle();
expect(wrapper.vm.isOpen).toBe(false);
});
it("loads peers and contacts when opened", async () => {
axiosMock.get
.mockResolvedValueOnce({
data: {
announces: [
{
destination_hash: "peer1",
display_name: "Peer 1",
lxmf_user_icon: { icon_name: "account" },
},
],
},
})
.mockResolvedValueOnce({
data: [
{
id: 1,
name: "Contact 1",
remote_identity_hash: "contact1",
},
],
});
const wrapper = mountCommandPalette();
await wrapper.vm.open();
await wrapper.vm.$nextTick();
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announces", {
params: { aspect: "lxmf.delivery", limit: 20 },
});
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/telephone/contacts");
});
it("filters results based on query", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
wrapper.vm.query = "messages";
await wrapper.vm.$nextTick();
const results = wrapper.vm.filteredResults;
expect(results.length).toBeGreaterThan(0);
expect(results.some((r) => r.title.toLowerCase().includes("messages"))).toBe(true);
});
it("shows navigation and action items when query is empty", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
wrapper.vm.query = "";
await wrapper.vm.$nextTick();
const results = wrapper.vm.filteredResults;
const hasNavigation = results.some((r) => r.type === "navigation");
const hasAction = results.some((r) => r.type === "action");
expect(hasNavigation || hasAction).toBe(true);
});
it("highlights first result when filtered results change", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const results = wrapper.vm.filteredResults;
if (results.length > 0) {
expect(wrapper.vm.highlightedId).toBe(results[0].id);
}
});
it("moves highlight up and down with arrow keys", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const initialHighlight = wrapper.vm.highlightedId;
wrapper.vm.moveHighlight(1);
expect(wrapper.vm.highlightedId).not.toBe(initialHighlight);
wrapper.vm.moveHighlight(-1);
expect(wrapper.vm.highlightedId).toBe(initialHighlight);
});
it("wraps highlight when moving past boundaries", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const results = wrapper.vm.filteredResults;
if (results.length > 0) {
wrapper.vm.highlightedId = results[results.length - 1].id;
wrapper.vm.moveHighlight(1);
expect(wrapper.vm.highlightedId).toBe(results[0].id);
wrapper.vm.highlightedId = results[0].id;
wrapper.vm.moveHighlight(-1);
expect(wrapper.vm.highlightedId).toBe(results[results.length - 1].id);
}
});
it("navigates to route when navigation result is executed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const navResult = wrapper.vm.filteredResults.find((r) => r.type === "navigation");
if (navResult) {
wrapper.vm.executeResult(navResult);
expect(routerMock.push).toHaveBeenCalledWith(navResult.route);
expect(wrapper.vm.isOpen).toBe(false);
}
});
it("navigates to messages when peer result is executed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
wrapper.vm.peers = [
{
destination_hash: "peer123",
display_name: "Test Peer",
},
];
await wrapper.vm.$nextTick();
const peerResult = wrapper.vm.filteredResults.find((r) => r.type === "peer");
if (peerResult) {
wrapper.vm.executeResult(peerResult);
expect(routerMock.push).toHaveBeenCalledWith({
name: "messages",
params: { destinationHash: "peer123" },
});
}
});
it("navigates to call when contact result is executed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
wrapper.vm.contacts = [
{
id: 1,
name: "Test Contact",
remote_identity_hash: "contact123",
},
];
await wrapper.vm.$nextTick();
const contactResult = wrapper.vm.filteredResults.find((r) => r.type === "contact");
if (contactResult) {
wrapper.vm.executeResult(contactResult);
expect(routerMock.push).toHaveBeenCalledWith({
name: "call",
query: { destination_hash: "contact123" },
});
}
});
it("emits sync event when sync action is executed", async () => {
const wrapper = mountCommandPalette();
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const syncResult = wrapper.vm.filteredResults.find((r) => r.action === "sync");
if (syncResult) {
wrapper.vm.executeResult(syncResult);
expect(emitSpy).toHaveBeenCalledWith("sync-propagation-node");
}
});
it("emits toggle-orbit event when orbit action is executed", async () => {
const wrapper = mountCommandPalette();
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const orbitResult = wrapper.vm.filteredResults.find((r) => r.action === "toggle-orbit");
if (orbitResult) {
wrapper.vm.executeResult(orbitResult);
expect(emitSpy).toHaveBeenCalledWith("toggle-orbit");
}
});
it("closes when ESC key is pressed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const input = wrapper.find("input");
await input.trigger("keydown.esc");
expect(wrapper.vm.isOpen).toBe(false);
});
it("executes highlighted action when Enter is pressed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const executeSpy = vi.spyOn(wrapper.vm, "executeAction");
const input = wrapper.find("input");
await input.trigger("keydown.enter");
expect(executeSpy).toHaveBeenCalled();
});
it("groups results by type", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
wrapper.vm.peers = [
{
destination_hash: "peer1",
display_name: "Peer 1",
},
];
await wrapper.vm.$nextTick();
const grouped = wrapper.vm.groupedResults;
expect(Object.keys(grouped).length).toBeGreaterThan(0);
});
it("shows no results message when query has no matches", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
wrapper.vm.query = "nonexistentquery12345";
await wrapper.vm.$nextTick();
const results = wrapper.vm.filteredResults;
expect(results.length).toBe(0);
});
});

View File

@@ -0,0 +1,167 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
describe("ConfirmDialog.vue", () => {
beforeEach(() => {
GlobalEmitter.off("confirm");
});
afterEach(() => {
GlobalEmitter.off("confirm");
});
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("shows dialog when GlobalEmitter emits confirm event", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
GlobalEmitter.emit("confirm", {
message: "Are you sure?",
resolve: resolvePromise,
});
await wrapper.vm.$nextTick();
expect(wrapper.find(".fixed").exists()).toBe(true);
expect(wrapper.text()).toContain("Are you sure?");
expect(wrapper.text()).toContain("Confirm");
expect(wrapper.text()).toContain("Cancel");
});
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,
});
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);
});
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,
});
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.findAll(".fixed").find((el) => {
const classes = el.classes();
return classes.includes("inset-0") && !classes.includes("z-[200]");
});
if (backdrop && 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");
});
});

View File

@@ -0,0 +1,581 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
import SendMessageButton from "../../meshchatx/src/frontend/components/messages/SendMessageButton.vue";
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue";
import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubLabel.vue";
import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue";
import DropDownMenuItem from "../../meshchatx/src/frontend/components/DropDownMenuItem.vue";
import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
on: vi.fn(),
off: vi.fn(),
emit: 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/DialogUtils", () => ({
default: {
confirm: vi.fn().mockResolvedValue(true),
},
}));
vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
default: {
getDefaultShortcuts: vi.fn(() => []),
send: vi.fn(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
default: {
isElectron: vi.fn(() => false),
},
}));
describe("IconButton Component", () => {
it("renders with slot content", () => {
const wrapper = mount(IconButton, {
slots: {
default: '<span class="test-content">Click me</span>',
},
});
expect(wrapper.find(".test-content").exists()).toBe(true);
expect(wrapper.text()).toContain("Click me");
});
it("has correct button type attribute", () => {
const wrapper = mount(IconButton);
expect(wrapper.attributes("type")).toBe("button");
});
it("emits click event when clicked", async () => {
const wrapper = mount(IconButton);
await wrapper.trigger("click");
expect(wrapper.emitted("click")).toBeTruthy();
});
it("applies disabled state correctly", () => {
const wrapper = mount(IconButton, {
attrs: {
disabled: true,
},
});
expect(wrapper.attributes("disabled")).toBeDefined();
});
it("applies custom classes", () => {
const wrapper = mount(IconButton, {
attrs: {
class: "custom-class",
},
});
expect(wrapper.classes()).toContain("custom-class");
});
});
describe("SendMessageButton Component", () => {
it("renders send button with correct text when enabled", () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: null,
},
});
expect(wrapper.text()).toContain("Send");
});
it("shows sending state when isSendingMessage is true", () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: true,
deliveryMethod: null,
},
});
expect(wrapper.text()).toContain("Sending...");
});
it("disables button when canSendMessage is false", () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: false,
isSendingMessage: false,
deliveryMethod: null,
},
});
const button = wrapper.find("button");
expect(button.attributes("disabled")).toBeDefined();
});
it("shows delivery method in button text", () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: "direct",
},
});
expect(wrapper.text()).toContain("Send (Direct)");
});
it("emits send event when send button is clicked", async () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: null,
},
});
const sendButton = wrapper.findAll("button")[0];
await sendButton.trigger("click");
expect(wrapper.emitted("send")).toBeTruthy();
});
it("opens dropdown menu when dropdown button is clicked", async () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: null,
},
});
const dropdownButton = wrapper.findAll("button")[1];
await dropdownButton.trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(true);
});
it("emits delivery-method-changed when delivery method is selected", async () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: null,
},
});
wrapper.vm.showMenu();
await wrapper.vm.$nextTick();
wrapper.vm.setDeliveryMethod("direct");
expect(wrapper.emitted("delivery-method-changed")).toBeTruthy();
expect(wrapper.emitted("delivery-method-changed")[0]).toEqual(["direct"]);
});
it("closes menu after selecting delivery method", async () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: true,
isSendingMessage: false,
deliveryMethod: null,
},
});
wrapper.vm.showMenu();
expect(wrapper.vm.isShowingMenu).toBe(true);
wrapper.vm.setDeliveryMethod("direct");
expect(wrapper.vm.isShowingMenu).toBe(false);
});
});
describe("Toggle Component", () => {
it("renders with label when provided", () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
label: "Enable Feature",
},
});
expect(wrapper.text()).toContain("Enable Feature");
});
it("emits update:modelValue when toggled", async () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: false,
},
});
const input = wrapper.find("input");
await input.setChecked(true);
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]);
});
it("reflects modelValue prop correctly", () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: true,
},
});
expect(wrapper.find("input").element.checked).toBe(true);
});
it("handles label prop correctly", () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: false,
label: "Test Label",
},
});
expect(wrapper.text()).toContain("Test Label");
});
it("emits update:modelValue on input change", async () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: false,
},
});
const input = wrapper.find("input");
await input.trigger("change");
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
});
});
describe("FormLabel Component", () => {
it("renders label text", () => {
const wrapper = mount(FormLabel, {
props: {
for: "test-input",
},
slots: {
default: "Test Label",
},
});
expect(wrapper.text()).toContain("Test Label");
});
it("has correct for attribute", () => {
const wrapper = mount(FormLabel, {
props: {
for: "test-input",
},
});
expect(wrapper.attributes("for")).toBe("test-input");
});
});
describe("FormSubLabel Component", () => {
it("renders sublabel text", () => {
const wrapper = mount(FormSubLabel, {
slots: {
default: "This is a sublabel",
},
});
expect(wrapper.text()).toContain("This is a sublabel");
});
});
describe("DropDownMenu Component", () => {
it("toggles menu visibility on button click", async () => {
const wrapper = mount(DropDownMenu, {
slots: {
button: "<button>Menu</button>",
items: "<div>Item 1</div>",
},
});
const button = wrapper.find("button");
await button.trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(true);
await button.trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(false);
});
it("shows menu items when open", async () => {
const wrapper = mount(DropDownMenu, {
slots: {
button: "<button>Menu</button>",
items: '<div class="menu-item">Item 1</div>',
},
});
wrapper.vm.showMenu();
await wrapper.vm.$nextTick();
expect(wrapper.find(".menu-item").exists()).toBe(true);
});
it("hides menu when clicking outside", async () => {
const wrapper = mount(DropDownMenu, {
slots: {
button: "<button>Menu</button>",
items: "<div>Item 1</div>",
},
});
wrapper.vm.showMenu();
await wrapper.vm.$nextTick();
wrapper.vm.onClickOutsideMenu({ preventDefault: vi.fn() });
expect(wrapper.vm.isShowingMenu).toBe(false);
});
it("closes menu when item is clicked", async () => {
const wrapper = mount(DropDownMenu, {
slots: {
button: "<button>Menu</button>",
items: '<div @click="hideMenu">Item 1</div>',
},
});
wrapper.vm.showMenu();
await wrapper.vm.$nextTick();
const menu = wrapper.find(".absolute");
await menu.trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(false);
});
});
describe("SettingsPage Component", () => {
let axiosMock;
let websocketMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
display_name: "Test User",
identity_hash: "abc123",
lxmf_address_hash: "def456",
theme: "dark",
is_transport_enabled: true,
lxmf_local_propagation_node_enabled: false,
auto_resend_failed_messages_when_announce_received: true,
allow_auto_resending_failed_messages_with_attachments: false,
auto_send_failed_messages_to_propagation_node: false,
show_suggested_community_interfaces: true,
lxmf_local_propagation_node_enabled: false,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
desktop_open_calls_in_separate_window: false,
},
},
}),
post: vi.fn().mockResolvedValue({ data: { success: true } }),
patch: vi.fn().mockResolvedValue({ data: { success: true } }),
};
window.axios = axiosMock;
websocketMock = {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
};
});
afterEach(() => {
delete window.axios;
vi.clearAllMocks();
});
const mountSettingsPage = () => {
return mount(SettingsPage, {
global: {
stubs: {
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
mocks: {
$t: (key) => key,
$router: {
push: vi.fn(),
},
},
},
});
};
it("renders settings page with profile information", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Test User");
});
it("renders copy buttons for identity and lxmf address", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
const buttons = wrapper.findAll("button");
const hasCopyButtons = buttons.some(
(btn) => btn.text().includes("app.identity_hash") || btn.text().includes("app.lxmf_address")
);
expect(hasCopyButtons || buttons.length > 0).toBe(true);
});
it("handles toggle changes for banished effect", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = true;
await wrapper.vm.$nextTick();
wrapper.vm.onBanishedEffectEnabledChange(false);
await wrapper.vm.$nextTick();
expect(axiosMock.patch).toHaveBeenCalled();
});
it("updates banished text when input changes", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = true;
await wrapper.vm.$nextTick();
const textInputs = wrapper.findAll('input[type="text"]');
if (textInputs.length > 0) {
const textInput =
textInputs.find(
(input) =>
input.attributes("v-model")?.includes("banished_text") ||
input.element.value === wrapper.vm.config.banished_text
) || textInputs[0];
await textInput.setValue("CUSTOM TEXT");
await wrapper.vm.$nextTick();
expect(wrapper.vm.config.banished_text).toBe("CUSTOM TEXT");
}
});
it("updates banished color when color picker changes", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = true;
await wrapper.vm.$nextTick();
const colorInputs = wrapper.findAll('input[type="color"]');
if (colorInputs.length > 0) {
const colorInput = colorInputs[0];
await colorInput.setValue("#ff0000");
await wrapper.vm.$nextTick();
expect(wrapper.vm.config.banished_color).toBe("#ff0000");
}
});
it("shows banished configuration when toggle is enabled", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = true;
await wrapper.vm.$nextTick();
const hasBanishedConfig =
wrapper.text().includes("app.banished") || wrapper.vm.config.banished_effect_enabled === true;
expect(hasBanishedConfig).toBe(true);
});
it("handles RNS reload button click", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
if (wrapper.vm.reloadRns) {
await wrapper.vm.reloadRns();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
}
});
it("displays theme information correctly", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("app.theme");
});
it("displays transport status correctly", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("app.transport");
});
it("handles multiple toggle changes without errors", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
await wrapper.vm.getConfig();
await wrapper.vm.$nextTick();
if (!wrapper.vm.config) {
wrapper.vm.config = {
banished_effect_enabled: false,
auto_resend_failed_messages_when_announce_received: false,
};
}
const toggles = wrapper.findAllComponents(Toggle);
for (const toggle of toggles.slice(0, 2)) {
try {
await toggle.vm.$emit("update:modelValue", true);
await wrapper.vm.$nextTick();
} catch (e) {}
}
expect(axiosMock.patch).toHaveBeenCalled();
});
});
describe("Button Interactions and Accessibility", () => {
it("IconButton is keyboard accessible", async () => {
const wrapper = mount(IconButton, {
attrs: {
tabindex: "0",
},
});
expect(wrapper.attributes("tabindex")).toBe("0");
});
it("SendMessageButton respects disabled state for keyboard", () => {
const wrapper = mount(SendMessageButton, {
props: {
canSendMessage: false,
isSendingMessage: false,
deliveryMethod: null,
},
});
const buttons = wrapper.findAll("button");
buttons.forEach((button) => {
expect(button.attributes("disabled")).toBeDefined();
});
});
it("Toggle is keyboard accessible", async () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: false,
},
});
const input = wrapper.find("input");
expect(input.attributes("id")).toBe("test-toggle");
await input.trigger("change");
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
});
});

View File

@@ -0,0 +1,615 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import App from "../../meshchatx/src/frontend/components/App.vue";
import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
import ChangelogModal from "../../meshchatx/src/frontend/components/ChangelogModal.vue";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue";
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
on: vi.fn(),
off: vi.fn(),
emit: 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: {
unreadConversationsCount: 0,
activeCallTab: null,
config: {},
},
}));
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
default: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/NotificationUtils", () => ({
default: {
showIncomingCallNotification: vi.fn(),
showMissedCallNotification: vi.fn(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
default: {
getDefaultShortcuts: vi.fn(() => []),
setShortcuts: vi.fn(),
},
}));
const createRouterLinkStub = () => ({
template:
"<a><slot v-bind=\"{ href: typeof to === 'string' ? to : (to?.path || to?.name || '#'), navigate: () => {}, isActive: false }\" /></a>",
props: ["to", "custom"],
});
describe("Theme Switching", () => {
let axiosMock;
beforeEach(() => {
document.documentElement.classList.remove("dark");
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
theme: "light",
display_name: "Test User",
},
app_info: { is_reticulum_running: true },
},
}),
post: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
});
afterEach(() => {
document.documentElement.classList.remove("dark");
delete window.axios;
vi.clearAllMocks();
});
it("applies dark class to root element when theme is dark", async () => {
document.documentElement.classList.remove("dark");
expect(document.documentElement.classList.contains("dark")).toBe(false);
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "dark" };
document.documentElement.classList.add("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("removes dark class when theme is light", async () => {
document.documentElement.classList.add("dark");
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "light" };
await wrapper.vm.$nextTick();
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("toggles theme from light to dark", async () => {
const WebSocketConnection = await import("../../meshchatx/src/frontend/js/WebSocketConnection");
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "light" };
await wrapper.vm.$nextTick();
await wrapper.vm.toggleTheme();
await wrapper.vm.$nextTick();
expect(WebSocketConnection.default.send).toHaveBeenCalled();
const sendCalls = WebSocketConnection.default.send.mock.calls;
const configSetCall = sendCalls.find((call) => {
try {
const parsed = JSON.parse(call[0]);
return parsed.type === "config.set";
} catch {
return false;
}
});
expect(configSetCall).toBeDefined();
if (configSetCall) {
const callArgs = JSON.parse(configSetCall[0]);
expect(callArgs.config.theme).toBe("dark");
}
});
it("toggles theme from dark to light", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "dark" };
await wrapper.vm.$nextTick();
await wrapper.vm.toggleTheme();
await wrapper.vm.$nextTick();
expect(wrapper.vm.config.theme).toBe("light");
});
it("shows correct icon for theme toggle button", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: {
template: '<div :data-icon="iconName"></div>',
props: ["iconName"],
},
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "dark" };
await wrapper.vm.$nextTick();
const buttons = wrapper.findAll("button");
expect(buttons.length).toBeGreaterThan(0);
});
});
describe("Visibility Checks", () => {
it("ConfirmDialog shows when pendingConfirm is set", async () => {
const wrapper = mount(ConfirmDialog, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
},
mocks: {
$t: (key) => key,
},
},
});
wrapper.vm.pendingConfirm = { message: "Test message" };
await wrapper.vm.$nextTick();
const dialogElement = wrapper.find(".fixed");
expect(dialogElement.exists()).toBe(true);
expect(wrapper.text()).toContain("Confirm");
});
it("ConfirmDialog hides when pendingConfirm is null", async () => {
const wrapper = mount(ConfirmDialog, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
},
mocks: {
$t: (key) => key,
},
},
});
wrapper.vm.pendingConfirm = null;
await wrapper.vm.$nextTick();
const dialogElement = wrapper.find(".fixed");
expect(dialogElement.exists()).toBe(false);
});
it("ChangelogModal component renders correctly", () => {
const wrapper = mount(ChangelogModal, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
},
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
});
it("Toggle shows label when provided", () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
label: "Show Label",
modelValue: false,
},
});
expect(wrapper.text()).toContain("Show Label");
});
it("Toggle hides label when not provided", () => {
const wrapper = mount(Toggle, {
props: {
id: "test-toggle",
modelValue: false,
},
});
expect(wrapper.text()).not.toContain("Show Label");
});
it("SettingsPage shows banished config when toggle is enabled", async () => {
const axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
},
},
}),
patch: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
const wrapper = mount(SettingsPage, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = true;
await wrapper.vm.$nextTick();
const hasBanishedConfig =
wrapper.text().includes("app.banished") || wrapper.findAll('input[type="text"]').length > 0;
expect(hasBanishedConfig).toBe(true);
delete window.axios;
});
it("SettingsPage hides banished config when toggle is disabled", async () => {
const axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
banished_effect_enabled: false,
},
},
}),
patch: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
const wrapper = mount(SettingsPage, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
wrapper.vm.config.banished_effect_enabled = false;
await wrapper.vm.$nextTick();
const colorInputs = wrapper.findAll('input[type="color"]');
expect(colorInputs.length).toBe(0);
delete window.axios;
});
});
describe("Conditional Rendering", () => {
it("App shows emergency banner when emergency mode is active", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.appInfo = { emergency: true };
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("app.emergency_mode_active");
});
it("App hides emergency banner when emergency mode is inactive", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.appInfo = { emergency: false };
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain("app.emergency_mode_active");
});
it("App shows sidebar toggle on mobile", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
const sidebarButton = wrapper.find("button.sm\\:hidden");
expect(sidebarButton.exists()).toBe(true);
});
});
describe("Dark Mode Class Application", () => {
it("App component applies dark class based on theme", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
wrapper.vm.config = { theme: "dark" };
await wrapper.vm.$nextTick();
expect(wrapper.classes()).toContain("dark");
});
it("SettingsPage applies dark mode classes correctly", async () => {
const axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
theme: "dark",
},
},
}),
patch: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
const wrapper = mount(SettingsPage, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const hasDarkClasses = wrapper.html().includes("dark:") || wrapper.html().includes("dark:");
expect(hasDarkClasses).toBe(true);
delete window.axios;
});
});
describe("Theme Persistence", () => {
it("SettingsPage theme selector updates config", async () => {
const axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
config: {
theme: "light",
},
},
}),
patch: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
const wrapper = mount(SettingsPage, {
global: {
stubs: {
MaterialDesignIcon: { template: "<div></div>" },
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const themeSelect = wrapper.find('select[v-model="config.theme"]');
if (themeSelect.exists()) {
await themeSelect.setValue("dark");
await wrapper.vm.$nextTick();
expect(wrapper.vm.config.theme).toBe("dark");
}
delete window.axios;
});
});