661 lines
22 KiB
JavaScript
661 lines
22 KiB
JavaScript
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("vuetify", () => ({
|
|
useTheme: vi.fn(() => ({
|
|
global: {
|
|
name: { value: "light" },
|
|
},
|
|
})),
|
|
}));
|
|
|
|
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",
|
|
blackhole_integration_enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
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 shows blackhole integration toggle", async () => {
|
|
const axiosMock = {
|
|
get: vi.fn().mockResolvedValue({
|
|
data: {
|
|
config: {
|
|
blackhole_integration_enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
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();
|
|
|
|
expect(wrapper.text()).toContain("app.blackhole_integration_enabled");
|
|
|
|
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;
|
|
});
|
|
});
|