From d444daa073e87425e1e2564f79d5e03054aed44b Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 16 Apr 2026 00:39:39 -0500 Subject: [PATCH] feat(tests): add end-to-end tests for keyboard shortcuts and message conversation scrolling; enhance navigation tests for improved coverage --- tests/e2e/keyboard-shortcuts.spec.js | 79 ++ .../e2e/messages-conversation-scroll.spec.js | 203 +++++ tests/e2e/navigation.spec.js | 16 +- .../ConversationViewer.scroll.test.js | 125 +++ ...ersationViewerPerformance.baseline.test.js | 180 ++++ tests/frontend/KeyboardShortcuts.test.js | 142 ++++ .../SettingsPage.config-persistence.test.js | 787 ++++++++++++++++++ tests/frontend/UIComponents.test.js | 76 ++ tests/frontend/conversationScroll.test.js | 137 +++ tests/frontend/designTokens.test.js | 8 +- tests/frontend/messageListVirtual.test.js | 42 + 11 files changed, 1789 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/keyboard-shortcuts.spec.js create mode 100644 tests/e2e/messages-conversation-scroll.spec.js create mode 100644 tests/frontend/ConversationViewer.scroll.test.js create mode 100644 tests/frontend/ConversationViewerPerformance.baseline.test.js create mode 100644 tests/frontend/KeyboardShortcuts.test.js create mode 100644 tests/frontend/SettingsPage.config-persistence.test.js create mode 100644 tests/frontend/conversationScroll.test.js create mode 100644 tests/frontend/messageListVirtual.test.js diff --git a/tests/e2e/keyboard-shortcuts.spec.js b/tests/e2e/keyboard-shortcuts.spec.js new file mode 100644 index 0000000..a8b6617 --- /dev/null +++ b/tests/e2e/keyboard-shortcuts.spec.js @@ -0,0 +1,79 @@ +const { test, expect } = require("@playwright/test"); +const { prepareE2eSession, dismissMapOnboardingTooltip } = require("./helpers"); + +test.describe("Keyboard shortcuts (global)", () => { + test.beforeEach(async ({ request }) => { + await prepareE2eSession(request); + }); + + test("Alt+2 opens Nomad Network", async ({ page }) => { + await page.goto("/#/messages"); + await expect(page).toHaveURL(/#\/messages/); + await page.keyboard.press("Alt+2"); + await expect(page).toHaveURL(/#\/nomadnetwork/, { timeout: 15000 }); + }); + + test("Alt+3 opens Map", async ({ page }) => { + await page.goto("/#/messages"); + await page.evaluate(() => { + localStorage.setItem("map_onboarding_seen", "true"); + }); + await page.keyboard.press("Alt+3"); + await expect(page).toHaveURL(/#\/map/, { timeout: 15000 }); + await dismissMapOnboardingTooltip(page); + }); + + test("Alt+4 opens Archives", async ({ page }) => { + await page.goto("/#/messages"); + await page.keyboard.press("Alt+4"); + await expect(page).toHaveURL(/#\/archives/, { timeout: 15000 }); + }); + + test("Alt+5 opens Calls", async ({ page }) => { + await page.goto("/#/messages"); + await page.keyboard.press("Alt+5"); + await expect(page).toHaveURL(/#\/call/, { timeout: 15000 }); + }); + + test("Alt+P opens Paper Message tool", async ({ page }) => { + await page.goto("/#/messages"); + await page.keyboard.press("Alt+p"); + await expect(page).toHaveURL(/#\/tools\/paper-message/, { timeout: 15000 }); + await expect(page.getByRole("heading", { name: "Paper Message Generator", exact: true })).toBeVisible({ + timeout: 20000, + }); + }); + + test("Alt+N goes to Messages (compose flow)", async ({ page }) => { + await page.goto("/#/settings"); + await expect(page).toHaveURL(/#\/settings/); + await page.keyboard.press("Alt+n"); + await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 }); + }); + + test("Ctrl+B toggles sidebar collapsed width", async ({ page }) => { + await page.goto("/#/messages"); + const classBefore = await page.evaluate(() => { + const ul = document.querySelector("ul.py-3"); + const el = ul && ul.closest(".fixed"); + return el ? el.className : ""; + }); + expect(classBefore).toMatch(/w-80|md:max-lg:w-64|lg:w-80/); + + await page.keyboard.press("Control+b"); + const classAfterCollapse = await page.evaluate(() => { + const ul = document.querySelector("ul.py-3"); + const el = ul && ul.closest(".fixed"); + return el ? el.className : ""; + }); + expect(classAfterCollapse).toContain("w-16"); + + await page.keyboard.press("Control+b"); + const classAfterExpand = await page.evaluate(() => { + const ul = document.querySelector("ul.py-3"); + const el = ul && ul.closest(".fixed"); + return el ? el.className : ""; + }); + expect(classAfterExpand).toMatch(/w-80|md:max-lg:w-64|lg:w-80/); + }); +}); diff --git a/tests/e2e/messages-conversation-scroll.spec.js b/tests/e2e/messages-conversation-scroll.spec.js new file mode 100644 index 0000000..48f36cd --- /dev/null +++ b/tests/e2e/messages-conversation-scroll.spec.js @@ -0,0 +1,203 @@ +const { test, expect } = require("@playwright/test"); +const { + prepareE2eSession, + seedE2eLongConversationThread, + getE2eLocalLxmfHash, + E2E_SCROLL_PEER_HASH, +} = require("./helpers"); + +async function scrollMetrics(page) { + const loc = page.locator("#messages"); + await expect(loc).toBeVisible({ timeout: 30000 }); + return loc.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + })); +} + +async function setMessagesToBottom(page) { + await page.locator("#messages").evaluate((el) => { + const inner = el.firstElementChild; + const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse"; + if (reverse) { + el.scrollTop = 0; + return; + } + el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + }); +} + +async function messagesDistanceFromBottom(page) { + return page.locator("#messages").evaluate((el) => { + const inner = el.firstElementChild; + const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse"; + const max = Math.max(0, el.scrollHeight - el.clientHeight); + if (reverse) { + return el.scrollTop; + } + return max - el.scrollTop; + }); +} + +async function messagesNearBottom(page) { + return page.locator("#messages").evaluate((el) => { + const inner = el.firstElementChild; + const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse"; + const max = Math.max(0, el.scrollHeight - el.clientHeight); + const eps = 12; + if (reverse) { + return el.scrollTop <= eps; + } + return max - el.scrollTop <= eps; + }); +} + +async function waitForMessagesOverflow(page) { + await page.waitForFunction( + () => { + const el = document.getElementById("messages"); + if (!el) { + return false; + } + return el.scrollHeight > el.clientHeight + 100; + }, + null, + { timeout: 30000 } + ); +} + +test.describe("Messages conversation scroll", () => { + test.use({ viewport: { width: 1280, height: 360 } }); + + test.beforeAll(async ({ request }) => { + await prepareE2eSession(request); + await seedE2eLongConversationThread(request, { messageCount: 120 }); + }); + + test.beforeEach(async ({ request }) => { + await prepareE2eSession(request); + }); + + test("starts near bottom with a long thread", async ({ page }) => { + await page.goto("/#/messages"); + await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 }); + await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click(); + await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 }); + await expect(page.locator("#messages").getByText(/E2E scroll seed 119/).first()).toBeVisible({ + timeout: 25000, + }); + + await waitForMessagesOverflow(page); + const m = await scrollMetrics(page); + expect(m.scrollHeight).toBeGreaterThan(m.clientHeight + 80); + expect(await messagesNearBottom(page)).toBe(true); + }); + + test("does not jump to bottom when scrolled up and inbound arrives", async ({ page, request }) => { + const localHash = await getE2eLocalLxmfHash(request); + await page.goto("/#/messages"); + await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 }); + await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click(); + await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 }); + await waitForMessagesOverflow(page); + + await page.locator("#messages").evaluate(() => { + const el = document.getElementById("messages"); + if (!el) { + return; + } + const max = Math.max(0, el.scrollHeight - el.clientHeight); + el.scrollTop = Math.max(0, max - 200); + }); + await page.waitForTimeout(300); + const before = await scrollMetrics(page); + expect(await messagesNearBottom(page)).toBe(false); + + await page.evaluate( + ({ peerHash, localHash: lh }) => { + const buf = new Uint8Array(16); + window.crypto.getRandomValues(buf); + const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); + return import("/js/WebSocketConnection.js").then((mod) => { + mod.default.emit("message", { + data: JSON.stringify({ + type: "lxmf.delivery", + lxmf_message: { + hash, + source_hash: peerHash, + destination_hash: lh, + content: "E2E synthetic inbound (scroll up)", + timestamp: Math.floor(Date.now() / 1000), + }, + }), + }); + }); + }, + { peerHash: E2E_SCROLL_PEER_HASH, localHash } + ); + + await page.waitForTimeout(500); + expect(await messagesNearBottom(page)).toBe(false); + }); + + test("stays pinned to bottom when new inbound arrives while at bottom", async ({ page, request }) => { + const localHash = await getE2eLocalLxmfHash(request); + await page.goto("/#/messages"); + await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 }); + await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click(); + await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 }); + await waitForMessagesOverflow(page); + + await setMessagesToBottom(page); + await page.waitForTimeout(200); + expect(await messagesNearBottom(page)).toBe(true); + + await page.evaluate( + ({ peerHash, localHash: lh }) => { + const buf = new Uint8Array(16); + window.crypto.getRandomValues(buf); + const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); + return import("/js/WebSocketConnection.js").then((mod) => { + mod.default.emit("message", { + data: JSON.stringify({ + type: "lxmf.delivery", + lxmf_message: { + hash, + source_hash: peerHash, + destination_hash: lh, + content: "E2E synthetic inbound (at bottom)", + timestamp: Math.floor(Date.now() / 1000), + }, + }), + }); + }); + }, + { peerHash: E2E_SCROLL_PEER_HASH, localHash } + ); + + await page.waitForTimeout(600); + expect(await messagesNearBottom(page)).toBe(true); + }); + + test("preserves scroll anchor when loading older messages from the top", async ({ page }) => { + await page.goto("/#/messages"); + await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 }); + await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click(); + await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 }); + await waitForMessagesOverflow(page); + + const initial = await scrollMetrics(page); + expect(initial.scrollHeight).toBeGreaterThan(initial.clientHeight + 80); + + await page.locator("#messages").hover(); + for (let i = 0; i < 60; i++) { + await page.mouse.wheel(0, -500); + } + await page.waitForTimeout(2500); + const loaded = await scrollMetrics(page); + if (loaded.scrollHeight > initial.scrollHeight) { + expect(await messagesDistanceFromBottom(page)).toBeGreaterThan(8); + } + }); +}); diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js index 69c923a..de5440b 100644 --- a/tests/e2e/navigation.spec.js +++ b/tests/e2e/navigation.spec.js @@ -1,5 +1,10 @@ const { test, expect } = require("@playwright/test"); -const { PALETTE_PLACEHOLDER, dismissMapOnboardingTooltip, openCommandPalette, prepareE2eSession } = require("./helpers"); +const { + PALETTE_PLACEHOLDER, + dismissMapOnboardingTooltip, + openCommandPalette, + prepareE2eSession, +} = require("./helpers"); test.describe("Getting started (tutorial page)", () => { test("tutorial route shows welcome copy", async ({ page }) => { @@ -102,15 +107,20 @@ test.describe("Sidebar and keyboard navigation", () => { await expect(page.getByText("MeshChatX", { exact: true }).first()).toBeVisible({ timeout: 20000 }); }); - test("Alt+1 jumps to Messages from another route", async ({ page }) => { + test("Alt+1 jumps to Messages from another route", async ({ page, request }) => { + await prepareE2eSession(request); await page.goto("/#/contacts"); await expect(page).toHaveURL(/#\/contacts/); await page.keyboard.press("Alt+1"); await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 }); }); - test("Alt+S opens Settings", async ({ page }) => { + test("Alt+S opens Settings", async ({ page, request }) => { + await prepareE2eSession(request); await page.goto("/#/map"); + await page.evaluate(() => { + localStorage.setItem("map_onboarding_seen", "true"); + }); await expect(page).toHaveURL(/#\/map/); await page.keyboard.press("Alt+s"); await expect(page).toHaveURL(/#\/settings/, { timeout: 15000 }); diff --git a/tests/frontend/ConversationViewer.scroll.test.js b/tests/frontend/ConversationViewer.scroll.test.js new file mode 100644 index 0000000..24bdcf9 --- /dev/null +++ b/tests/frontend/ConversationViewer.scroll.test.js @@ -0,0 +1,125 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ConversationViewer from "@/components/messages/ConversationViewer.vue"; +import WebSocketConnection from "@/js/WebSocketConnection"; +import GlobalState from "@/js/GlobalState"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(() => Promise.resolve(true)), + }, +})); + +function makeMessagesScrollTarget({ reverse, scrollTop, scrollHeight, clientHeight }) { + const outer = document.createElement("div"); + const inner = document.createElement("div"); + inner.style.flexDirection = reverse ? "column-reverse" : "column"; + outer.appendChild(inner); + document.body.appendChild(outer); + Object.defineProperty(outer, "scrollHeight", { value: scrollHeight, configurable: true }); + Object.defineProperty(outer, "clientHeight", { value: clientHeight, configurable: true }); + outer.scrollTop = scrollTop; + return outer; +} + +describe("ConversationViewer.vue scroll behavior", () => { + beforeEach(() => { + GlobalState.config.theme = "light"; + WebSocketConnection.connect(); + window.api = { + get: vi.fn().mockImplementation((url) => { + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + return Promise.resolve({ data: {} }); + }), + post: vi.fn().mockResolvedValue({ data: {} }), + }; + }); + + afterEach(() => { + delete window.api; + WebSocketConnection.destroy(); + }); + + const mountViewer = () => + mount(ConversationViewer, { + props: { + selectedPeer: { destination_hash: "abcdabcdabcdabcdabcdabcdabcdabcd", display_name: "Peer" }, + myLxmfAddressHash: "myhashmyhashmyhashmyhashmyhashmyha", + conversations: [], + }, + global: { + directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, + mocks: { $t: (key) => key }, + stubs: { + MaterialDesignIcon: true, + AddImageButton: true, + AddAudioButton: true, + SendMessageButton: true, + ConversationDropDownMenu: true, + PaperMessageModal: true, + AudioWaveformPlayer: true, + LxmfUserIcon: true, + }, + }, + }); + + it("onMessagesScroll sets autoScrollOnNewMessage when near bottom (column-reverse)", () => { + const wrapper = mountViewer(); + const el = makeMessagesScrollTarget({ + reverse: true, + scrollTop: 0, + scrollHeight: 5000, + clientHeight: 100, + }); + wrapper.vm.onMessagesScroll({ target: el }); + expect(wrapper.vm.autoScrollOnNewMessage).toBe(true); + el.remove(); + }); + + it("onMessagesScroll clears autoScroll when not near bottom (column-reverse)", () => { + const wrapper = mountViewer(); + const el = makeMessagesScrollTarget({ + reverse: true, + scrollTop: 2000, + scrollHeight: 5000, + clientHeight: 100, + }); + wrapper.vm.onMessagesScroll({ target: el }); + expect(wrapper.vm.autoScrollOnNewMessage).toBe(false); + el.remove(); + }); + + it("onMessagesScroll calls loadPrevious when near older-history edge (column-reverse)", () => { + const wrapper = mountViewer(); + const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {}); + const el = makeMessagesScrollTarget({ + reverse: true, + scrollTop: 4450, + scrollHeight: 5000, + clientHeight: 100, + }); + wrapper.vm.onMessagesScroll({ target: el }); + expect(spy).toHaveBeenCalledTimes(1); + wrapper.vm.onMessagesScroll({ target: el }); + expect(spy).toHaveBeenCalledTimes(1); + el.remove(); + }); + + it("onMessagesScroll does not hammer loadPrevious while held at older-history edge", () => { + const wrapper = mountViewer(); + const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {}); + const el = makeMessagesScrollTarget({ + reverse: true, + scrollTop: 4450, + scrollHeight: 5000, + clientHeight: 100, + }); + wrapper.vm.onMessagesScroll({ target: el }); + wrapper.vm.onMessagesScroll({ target: el }); + wrapper.vm.onMessagesScroll({ target: el }); + expect(spy).toHaveBeenCalledTimes(1); + el.remove(); + }); +}); diff --git a/tests/frontend/ConversationViewerPerformance.baseline.test.js b/tests/frontend/ConversationViewerPerformance.baseline.test.js new file mode 100644 index 0000000..57f2da0 --- /dev/null +++ b/tests/frontend/ConversationViewerPerformance.baseline.test.js @@ -0,0 +1,180 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import ConversationViewer from "@/components/messages/ConversationViewer.vue"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(() => Promise.resolve(true)), + }, +})); + +vi.mock("@/js/WebSocketConnection", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + connect: vi.fn(), + destroy: vi.fn(), + }, +})); + +vi.mock("@/js/GlobalEmitter", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, +})); + +function makeChatItems(count, myHash, peerHash) { + return Array.from({ length: count }, (_, i) => ({ + type: "lxmf_message", + is_outbound: i % 2 === 0, + lxmf_message: { + hash: `msg_${i}`.padEnd(32, "0"), + source_hash: i % 2 === 0 ? myHash : peerHash, + destination_hash: i % 2 === 0 ? peerHash : myHash, + content: `Message content ${i}.`, + created_at: new Date().toISOString(), + state: "delivered", + method: "direct", + progress: 1.0, + delivery_attempts: 1, + id: i, + }, + })); +} + +function groupSignature(vm) { + return vm.selectedPeerChatDisplayGroups.map((g) => `${g.type}:${g.key}`); +} + +describe("ConversationViewer performance baselines", () => { + const myLxmfAddressHash = "my_hash".padEnd(32, "0"); + const peerHash = "peer_hash".padEnd(32, "0"); + const selectedPeer = { + destination_hash: peerHash, + display_name: "Peer Name", + }; + + beforeEach(() => { + window.api = { + get: vi.fn(() => Promise.resolve({ data: {} })), + post: vi.fn(() => Promise.resolve({ data: {} })), + }; + }); + + const mountViewer = () => + mount(ConversationViewer, { + props: { + myLxmfAddressHash, + selectedPeer, + conversations: [selectedPeer], + config: { theme: "light", lxmf_address_hash: myLxmfAddressHash }, + }, + global: { + components: { + MaterialDesignIcon: { template: "" }, + ConversationDropDownMenu: { template: "
" }, + SendMessageButton: { template: "
" }, + IconButton: { template: "" }, + AddImageButton: { template: "
" }, + AddAudioButton: { template: "
" }, + PaperMessageModal: { template: "
" }, + AudioWaveformPlayer: { template: "
" }, + LxmfUserIcon: { template: "
" }, + }, + directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, + mocks: { + $t: (key) => key, + $i18n: { locale: "en" }, + }, + stubs: { + MarkdownRenderer: true, + }, + }, + }); + + it("selectedPeerChatDisplayGroups signature is stable for a fixed thread (regression guard)", async () => { + const wrapper = mountViewer(); + const items = makeChatItems(24, myLxmfAddressHash, peerHash); + await wrapper.setData({ chatItems: items }); + await wrapper.vm.$nextTick(); + const sig = groupSignature(wrapper.vm); + expect(sig.length).toBe(24); + expect(sig[0]).toMatch(/^single:msg_/); + }); + + it( + "bulk chatItems update: baseline ceiling (detect regressions)", + async () => { + const wrapper = mountViewer(); + const n = 800; + const items = makeChatItems(n, myLxmfAddressHash, peerHash); + + const t0 = performance.now(); + await wrapper.setData({ chatItems: items }); + await wrapper.vm.$nextTick(); + const ms = performance.now() - t0; + + expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n); + expect(ms).toBeLessThan(15000); + }, + 60_000 + ); + + it( + "incremental append: baseline ceiling when thread already large", + async () => { + const wrapper = mountViewer(); + const n = 600; + await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) }); + await wrapper.vm.$nextTick(); + + const newMsg = { + type: "lxmf_message", + is_outbound: true, + lxmf_message: { + hash: "newmsg".padEnd(32, "0"), + source_hash: myLxmfAddressHash, + destination_hash: peerHash, + content: "New", + created_at: new Date().toISOString(), + state: "delivered", + method: "direct", + progress: 1.0, + delivery_attempts: 1, + id: n, + }, + }; + + const t0 = performance.now(); + wrapper.vm.chatItems.push(newMsg); + await wrapper.vm.$nextTick(); + const ms = performance.now() - t0; + + expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n + 1); + expect(ms).toBeLessThan(8000); + }, + 60_000 + ); + + it( + "display groups computation alone stays bounded for large n", + async () => { + const wrapper = mountViewer(); + const n = 2000; + await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) }); + await wrapper.vm.$nextTick(); + + const t0 = performance.now(); + for (let k = 0; k < 20; k++) { + void wrapper.vm.selectedPeerChatDisplayGroups; + } + const ms = performance.now() - t0; + + expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n); + expect(ms).toBeLessThan(2000); + }, + 60_000 + ); +}); diff --git a/tests/frontend/KeyboardShortcuts.test.js b/tests/frontend/KeyboardShortcuts.test.js new file mode 100644 index 0000000..b78c64a --- /dev/null +++ b/tests/frontend/KeyboardShortcuts.test.js @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; + +const wsSend = vi.fn(); + +vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ + default: { + send: (...args) => wsSend(...args), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, +})); + +import KeyboardShortcuts from "../../meshchatx/src/frontend/js/KeyboardShortcuts"; + +function dispatchKeyDown(init) { + const ev = new KeyboardEvent("keydown", { + key: init.key, + code: init.code, + ctrlKey: !!init.ctrlKey, + altKey: !!init.altKey, + shiftKey: !!init.shiftKey, + metaKey: !!init.metaKey, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(ev); + return ev; +} + +describe("KeyboardShortcuts", () => { + let emitSpy; + + beforeEach(() => { + vi.clearAllMocks(); + emitSpy = vi.spyOn(GlobalEmitter, "emit"); + document.body.innerHTML = ""; + if (document.activeElement && document.activeElement !== document.body) { + document.activeElement.blur(); + } + }); + + afterEach(() => { + KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts()); + }); + + it("emits nav_messages for Alt+1", () => { + dispatchKeyDown({ key: "1", altKey: true, code: "Digit1" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages"); + }); + + it("emits nav_nomad for Alt+2", () => { + dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad"); + }); + + it("emits nav_map for Alt+3", () => { + dispatchKeyDown({ key: "3", altKey: true, code: "Digit3" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_map"); + }); + + it("emits nav_archives for Alt+4", () => { + dispatchKeyDown({ key: "4", altKey: true, code: "Digit4" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_archives"); + }); + + it("emits nav_calls for Alt+5", () => { + dispatchKeyDown({ key: "5", altKey: true, code: "Digit5" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_calls"); + }); + + it("emits nav_paper for Alt+P", () => { + dispatchKeyDown({ key: "p", altKey: true, code: "KeyP" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_paper"); + }); + + it("emits nav_settings for Alt+S", () => { + dispatchKeyDown({ key: "s", altKey: true, code: "KeyS" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_settings"); + }); + + it("emits compose_message for Alt+N", () => { + dispatchKeyDown({ key: "n", altKey: true, code: "KeyN" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "compose_message"); + }); + + it("emits sync_messages for Alt+R", () => { + dispatchKeyDown({ key: "r", altKey: true, code: "KeyR" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "sync_messages"); + }); + + it("emits toggle_sidebar for Ctrl+B", () => { + dispatchKeyDown({ key: "b", ctrlKey: true, code: "KeyB" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "toggle_sidebar"); + }); + + it("does not emit command_palette from KeyboardShortcuts (handled by CommandPalette)", () => { + dispatchKeyDown({ key: "k", ctrlKey: true, code: "KeyK" }); + expect(emitSpy).not.toHaveBeenCalledWith("keyboard-shortcut", "command_palette"); + }); + + it("allows Alt+digit navigation while focus is in an input (modifier shortcuts)", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad"); + }); + + it("setShortcuts replaces shortcuts and still emits for updated actions", () => { + KeyboardShortcuts.setShortcuts([ + { action: "nav_messages", keys: ["alt", "9"] }, + ...KeyboardShortcuts.getDefaultShortcuts().filter((s) => s.action !== "nav_messages"), + ]); + dispatchKeyDown({ key: "9", altKey: true, code: "Digit9" }); + expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages"); + KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts()); + }); + + it("saveShortcut sends keyboard_shortcuts.set over WebSocket", async () => { + await KeyboardShortcuts.saveShortcut("nav_messages", ["alt", "q"]); + expect(wsSend).toHaveBeenCalledWith( + JSON.stringify({ + type: "keyboard_shortcuts.set", + action: "nav_messages", + keys: ["alt", "q"], + }), + ); + }); + + it("deleteShortcut sends keyboard_shortcuts.delete over WebSocket", async () => { + await KeyboardShortcuts.deleteShortcut("nav_map"); + expect(wsSend).toHaveBeenCalledWith( + JSON.stringify({ + type: "keyboard_shortcuts.delete", + action: "nav_map", + }), + ); + }); +}); diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js new file mode 100644 index 0000000..9c8c79c --- /dev/null +++ b/tests/frontend/SettingsPage.config-persistence.test.js @@ -0,0 +1,787 @@ +import { mount, flushPromises } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue"; +import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue"; +import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; +import GlobalState from "../../meshchatx/src/frontend/js/GlobalState"; +import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection"; + +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(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: 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), + }, +})); + +/** + * Mirrors meshchat `get_config_dict` keys used by SettingsPage so PATCH merges stay realistic. + */ +function buildFullServerConfig(overrides = {}) { + return { + display_name: "Test User", + identity_hash: "abc123", + identity_public_key: "00", + lxmf_address_hash: "def456", + telephone_address_hash: null, + is_transport_enabled: false, + auto_announce_enabled: false, + auto_announce_interval_seconds: 0, + last_announced_at: null, + theme: "dark", + language: "en", + 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_delivery_transfer_limit_in_bytes: 10_000_000, + lxmf_propagation_transfer_limit_in_bytes: 256_000, + lxmf_propagation_sync_limit_in_bytes: 10_240_000, + lxmf_local_propagation_node_enabled: false, + lxmf_local_propagation_node_address_hash: "localhash", + lxmf_preferred_propagation_node_destination_hash: "", + lxmf_preferred_propagation_node_auto_select: false, + lxmf_preferred_propagation_node_auto_sync_interval_seconds: 3600, + lxmf_preferred_propagation_node_last_synced_at: null, + lxmf_user_icon_name: "account", + lxmf_user_icon_foreground_colour: "#111827", + lxmf_user_icon_background_colour: "#e5e7eb", + lxmf_inbound_stamp_cost: 8, + lxmf_propagation_node_stamp_cost: 16, + page_archiver_enabled: false, + page_archiver_max_versions: 5, + archives_max_storage_gb: 1, + backup_max_count: 5, + crawler_enabled: false, + crawler_max_retries: 3, + crawler_retry_delay_seconds: 30, + crawler_max_concurrent: 2, + auth_enabled: false, + voicemail_enabled: false, + voicemail_greeting: "", + voicemail_auto_answer_delay_seconds: 0, + voicemail_max_recording_seconds: 120, + voicemail_tts_speed: 1, + voicemail_tts_pitch: 1, + voicemail_tts_voice: "", + voicemail_tts_word_gap: 0, + custom_ringtone_enabled: false, + ringtone_filename: "", + ringtone_preferred_id: null, + ringtone_volume: 50, + map_offline_enabled: false, + map_mbtiles_dir: "", + map_tile_cache_enabled: true, + map_default_lat: "0", + map_default_lon: "0", + map_default_zoom: 2, + map_tile_server_url: "", + map_nominatim_api_url: "", + do_not_disturb_enabled: false, + telephone_allow_calls_from_contacts_only: false, + telephone_audio_profile_id: null, + telephone_web_audio_enabled: true, + telephone_web_audio_allow_fallback: true, + call_recording_enabled: false, + block_attachments_from_strangers: true, + block_all_from_strangers: false, + show_unknown_contact_banner: true, + banished_effect_enabled: true, + banished_text: "BANISHED", + banished_color: "#dc2626", + message_font_size: 14, + message_icon_size: 28, + ui_transparency: 0, + ui_glass_enabled: true, + message_outbound_bubble_color: "#4f46e5", + message_inbound_bubble_color: null, + message_failed_bubble_color: "#ef4444", + message_waiting_bubble_color: "#e5e7eb", + translator_enabled: false, + libretranslate_url: "http://localhost:5000", + desktop_open_calls_in_separate_window: false, + desktop_hardware_acceleration_enabled: true, + blackhole_integration_enabled: true, + announce_max_stored_lxmf_delivery: 1000, + announce_max_stored_nomadnetwork_node: 1000, + announce_max_stored_lxmf_propagation: 1000, + announce_fetch_limit_lxmf_delivery: 500, + announce_fetch_limit_nomadnetwork_node: 500, + announce_fetch_limit_lxmf_propagation: 500, + announce_search_max_fetch: 2000, + discovered_interfaces_max_return: 500, + csp_extra_connect_src: "", + csp_extra_img_src: "", + csp_extra_frame_src: "", + csp_extra_script_src: "", + csp_extra_style_src: "", + telephone_tone_generator_enabled: true, + telephone_tone_generator_volume: 50, + location_source: "browser", + location_manual_lat: "0.0", + location_manual_lon: "0.0", + location_manual_alt: "0.0", + telemetry_enabled: false, + nomad_render_markdown_enabled: true, + nomad_render_html_enabled: true, + nomad_render_plaintext_enabled: true, + nomad_default_page_path: "/page/index.mu", + gitea_base_url: "https://git.quad4.io", + docs_download_urls: "", + ...overrides, + }; +} + +function createWindowApi(serverConfigRef) { + return { + get: vi.fn().mockImplementation((url) => { + if (String(url).includes("/api/v1/config")) { + return Promise.resolve({ data: { config: { ...serverConfigRef.current } } }); + } + if (String(url).includes("/api/v1/telemetry/trusted-peers")) { + return Promise.resolve({ data: { trusted_peers: [] } }); + } + if (String(url).includes("/api/v1/stickers/export")) { + return Promise.resolve({ data: { stickers: [] } }); + } + if (String(url).includes("/api/v1/stickers") && !String(url).includes("export")) { + return Promise.resolve({ data: { stickers: [] } }); + } + if (String(url).includes("/api/v1/maintenance/messages/export")) { + return Promise.resolve({ data: { messages: [] } }); + } + if (String(url).includes("/api/v1/lxmf/folders/export")) { + return Promise.resolve({ data: { folders: [] } }); + } + return Promise.resolve({ data: {} }); + }), + patch: vi.fn().mockImplementation((url, body) => { + serverConfigRef.current = { ...serverConfigRef.current, ...body }; + return Promise.resolve({ data: { config: { ...serverConfigRef.current } } }); + }), + post: vi.fn().mockResolvedValue({ data: { message: "ok" } }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }; +} + +async function mountSettingsPage(api, router = { push: vi.fn() }) { + window.api = api; + const wrapper = mount(SettingsPage, { + global: { + stubs: { + MaterialDesignIcon: { template: "" }, + Toggle, + ShortcutRecorder: { template: "
" }, + RouterLink: { template: "" }, + }, + mocks: { + $t: (key) => key, + $router: router, + }, + }, + }); + await flushPromises(); + await wrapper.vm.$nextTick(); + return wrapper; +} + +describe("SettingsPage — config persistence (PATCH and related)", () => { + let serverConfigRef; + let api; + + beforeEach(() => { + serverConfigRef = { current: buildFullServerConfig() }; + api = createWindowApi(serverConfigRef); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete window.api; + vi.clearAllMocks(); + }); + + it("onThemeChange PATCHes theme", async () => { + const w = await mountSettingsPage(api); + w.vm.config.theme = "light"; + await w.vm.onThemeChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "light" }); + }); + + it("onLanguageChange PATCHes language", async () => { + const w = await mountSettingsPage(api); + w.vm.config.language = "de"; + await w.vm.onLanguageChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "de" }); + }); + + it("onMessageFontSizeChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.message_font_size = 18; + await w.vm.onMessageFontSizeChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { message_font_size: 18 }); + }); + + it("onDisplayNameChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.display_name = "New Name"; + await w.vm.onDisplayNameChange(); + await vi.advanceTimersByTimeAsync(600); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "New Name" }); + }); + + it("onMessageIconSizeChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.message_icon_size = 40; + await w.vm.onMessageIconSizeChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { message_icon_size: 40 }); + }); + + it("onUiTransparencyChange PATCHes clamped value after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.ui_transparency = 77; + w.vm.onUiTransparencyChange(); + await vi.advanceTimersByTimeAsync(400); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_transparency: 77 }); + }); + + it("onUiGlassEnabledChange PATCHes ui_glass_enabled", async () => { + const w = await mountSettingsPage(api); + w.vm.config.ui_glass_enabled = false; + await w.vm.onUiGlassEnabledChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_glass_enabled: false }); + }); + + it("resetAppearanceDefaults PATCHes full appearance payload", async () => { + const w = await mountSettingsPage(api); + await w.vm.resetAppearanceDefaults(); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + theme: "light", + message_font_size: 14, + message_icon_size: 28, + ui_transparency: 0, + ui_glass_enabled: true, + message_outbound_bubble_color: "#4f46e5", + message_inbound_bubble_color: null, + message_failed_bubble_color: "#ef4444", + message_waiting_bubble_color: "#e5e7eb", + }), + ); + }); + + it("onMessageBubbleColorChange PATCHes outbound after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.message_outbound_bubble_color = "#112233"; + await w.vm.onMessageBubbleColorChange("outbound"); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + message_outbound_bubble_color: "#112233", + }); + }); + + it("updateConfig can PATCH blackhole_integration_enabled (inline control)", async () => { + const w = await mountSettingsPage(api); + w.vm.config.blackhole_integration_enabled = false; + await w.vm.updateConfig({ blackhole_integration_enabled: false }, "blackhole_integration_enabled"); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { blackhole_integration_enabled: false }); + }); + + it("onAnnounceLimitsChange PATCHes announce and discovery caps", async () => { + const w = await mountSettingsPage(api); + w.vm.config.announce_max_stored_lxmf_delivery = 900; + await w.vm.onAnnounceLimitsChange(); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + announce_max_stored_lxmf_delivery: 900, + discovered_interfaces_max_return: 500, + }), + ); + }); + + it("message reliability toggles PATCH expected keys", async () => { + const w = await mountSettingsPage(api); + await w.vm.onAutoResendFailedMessagesWhenAnnounceReceivedChange(); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + auto_resend_failed_messages_when_announce_received: true, + }), + ); + await w.vm.onAllowAutoResendingFailedMessagesWithAttachmentsChange(); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + allow_auto_resending_failed_messages_with_attachments: false, + }), + ); + await w.vm.onAutoSendFailedMessagesToPropagationNodeChange(); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + auto_send_failed_messages_to_propagation_node: false, + }), + ); + }); + + it("onShowSuggestedCommunityInterfacesChange PATCHes flag", async () => { + const w = await mountSettingsPage(api); + w.vm.config.show_suggested_community_interfaces = false; + await w.vm.onShowSuggestedCommunityInterfacesChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + show_suggested_community_interfaces: false, + }); + }); + + it("onLxmfPreferredPropagationNodeDestinationHashChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_preferred_propagation_node_destination_hash = "deadbeef"; + await w.vm.onLxmfPreferredPropagationNodeDestinationHashChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_preferred_propagation_node_destination_hash: "deadbeef", + }); + }); + + it("onLxmfPreferredPropagationNodeAutoSelectChange PATCHes", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_preferred_propagation_node_auto_select = true; + await w.vm.onLxmfPreferredPropagationNodeAutoSelectChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_preferred_propagation_node_auto_select: true, + }); + }); + + it("onLxmfLocalPropagationNodeEnabledChange PATCHes", async () => { + const w = await mountSettingsPage(api); + await w.vm.onLxmfLocalPropagationNodeEnabledChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_local_propagation_node_enabled: false, + }); + }); + + it("onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange PATCHes", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds = 7200; + await w.vm.onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_preferred_propagation_node_auto_sync_interval_seconds: 7200, + }); + }); + + it("LXMF transfer/sync limits PATCH after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_delivery_transfer_limit_in_bytes = 9_000_000; + await w.vm.onLxmfDeliveryTransferLimitChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_delivery_transfer_limit_in_bytes: 9_000_000, + }); + + w.vm.config.lxmf_propagation_transfer_limit_in_bytes = 300_000; + await w.vm.onLxmfPropagationTransferLimitChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_propagation_transfer_limit_in_bytes: 300_000, + }); + + w.vm.config.lxmf_propagation_sync_limit_in_bytes = 9_000_000; + await w.vm.onLxmfPropagationSyncLimitChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + lxmf_propagation_sync_limit_in_bytes: 9_000_000, + }); + }); + + it("onLxmfInboundStampCostChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_inbound_stamp_cost = 12; + await w.vm.onLxmfInboundStampCostChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 12 }); + }); + + it("onInboundStampsEnabledChange(false) PATCHes zero stamp cost", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_inbound_stamp_cost = 12; + await w.vm.onInboundStampsEnabledChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 0 }); + }); + + it("onInboundStampsEnabledChange(true) restores stamp cost", async () => { + const w = await mountSettingsPage(api); + w.vm.lastRememberedInboundStampCost = 16; + w.vm.config.lxmf_inbound_stamp_cost = 0; + await w.vm.onInboundStampsEnabledChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 16 }); + }); + + it("onLxmfPropagationNodeStampCostChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.lxmf_propagation_node_stamp_cost = 20; + await w.vm.onLxmfPropagationNodeStampCostChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_propagation_node_stamp_cost: 20 }); + }); + + it("page archiver toggles and numeric config PATCH", async () => { + const w = await mountSettingsPage(api); + await w.vm.onPageArchiverEnabledChangeWrapper(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { page_archiver_enabled: true }); + + w.vm.config.page_archiver_max_versions = 12; + w.vm.config.archives_max_storage_gb = 2; + await w.vm.onPageArchiverConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + page_archiver_max_versions: 12, + archives_max_storage_gb: 2, + }); + }); + + it("Nomad renderer toggles and default path PATCH", async () => { + const w = await mountSettingsPage(api); + await w.vm.onNomadRendererMarkdownToggle(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_markdown_enabled: false }); + await w.vm.onNomadRendererHtmlToggle(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_html_enabled: false }); + await w.vm.onNomadRendererPlaintextToggle(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_plaintext_enabled: false }); + w.vm.config.nomad_default_page_path = "/page/custom.mu"; + await w.vm.onNomadDefaultPagePathChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_default_page_path: "/page/custom.mu" }); + }); + + it("stranger protection PATCHes each flag", async () => { + const w = await mountSettingsPage(api); + await w.vm.onStrangerAttachmentBlockChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_attachments_from_strangers: false }); + await w.vm.onBlockAllFromStrangersChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_all_from_strangers: true }); + await w.vm.onShowUnknownContactBannerChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { show_unknown_contact_banner: false }); + }); + + it("banishment PATCHes toggle and debounced text/color", async () => { + const w = await mountSettingsPage(api); + await w.vm.onBanishedEffectEnabledChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { banished_effect_enabled: false }); + w.vm.config.banished_text = "OUT"; + w.vm.config.banished_color = "#ff0000"; + await w.vm.onBanishedConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ banished_text: "OUT", banished_color: "#ff0000" }), + ); + }); + + it("crawler enabled and debounced crawler fields PATCH", async () => { + const w = await mountSettingsPage(api); + await w.vm.onCrawlerEnabledChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { crawler_enabled: true }); + w.vm.config.crawler_max_retries = 5; + w.vm.config.crawler_retry_delay_seconds = 60; + w.vm.config.crawler_max_concurrent = 3; + await w.vm.onCrawlerConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + crawler_max_retries: 5, + crawler_retry_delay_seconds: 60, + crawler_max_concurrent: 3, + }); + }); + + it("desktop toggles PATCH", async () => { + const w = await mountSettingsPage(api); + await w.vm.onDesktopOpenCallsInSeparateWindowChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { desktop_open_calls_in_separate_window: true }); + await w.vm.onDesktopHardwareAccelerationEnabledChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { desktop_hardware_acceleration_enabled: false }); + }); + + it("onAuthEnabledChange PATCHes auth and does not push router when disabling", async () => { + const router = { push: vi.fn() }; + const w = await mountSettingsPage(api, router); + await w.vm.onAuthEnabledChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { auth_enabled: false }); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("onAuthEnabledChange pushes auth route when enabling", async () => { + const router = { push: vi.fn() }; + const w = await mountSettingsPage(api, router); + await w.vm.onAuthEnabledChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { auth_enabled: true }); + expect(router.push).toHaveBeenCalledWith({ name: "auth" }); + }); + + it("translator toggle and debounced URL PATCH", async () => { + const w = await mountSettingsPage(api); + await w.vm.onTranslatorEnabledChange(true); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { translator_enabled: true }); + w.vm.config.libretranslate_url = "http://translate.example"; + await w.vm.onTranslatorConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { + libretranslate_url: "http://translate.example", + }); + }); + + it("onGiteaConfigChange PATCHes after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.gitea_base_url = "https://gitea.example"; + w.vm.config.docs_download_urls = "https://docs.example"; + await w.vm.onGiteaConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + gitea_base_url: "https://gitea.example", + docs_download_urls: "https://docs.example", + }), + ); + }); + + it("onCspConfigChange PATCHes CSP fields after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.csp_extra_connect_src = "wss://a.example"; + w.vm.config.csp_extra_img_src = "https://img.example"; + w.vm.config.csp_extra_frame_src = "https://frame.example"; + w.vm.config.csp_extra_script_src = "https://js.example"; + w.vm.config.csp_extra_style_src = "https://css.example"; + await w.vm.onCspConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith( + "/api/v1/config", + expect.objectContaining({ + csp_extra_connect_src: "wss://a.example", + csp_extra_style_src: "https://css.example", + }), + ); + }); + + it("onBackupConfigChange PATCHes backup_max_count after debounce", async () => { + const w = await mountSettingsPage(api); + w.vm.config.backup_max_count = 8; + await w.vm.onBackupConfigChange(); + await vi.advanceTimersByTimeAsync(1000); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { backup_max_count: 8 }); + }); + + it("inline location and telemetry PATCH via updateConfig", async () => { + const w = await mountSettingsPage(api); + w.vm.config.location_source = "manual"; + await w.vm.updateConfig({ location_source: "manual" }, "location_source"); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { location_source: "manual" }); + await w.vm.updateConfig({ telemetry_enabled: true }, "telemetry"); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { telemetry_enabled: true }); + }); +}); + +describe("SettingsPage — transport mode (POST, not PATCH)", () => { + let serverConfigRef; + let api; + + beforeEach(() => { + serverConfigRef = { current: buildFullServerConfig() }; + api = createWindowApi(serverConfigRef); + }); + + afterEach(() => { + delete window.api; + vi.clearAllMocks(); + }); + + it("onIsTransportEnabledChange POSTs enable when turning on", async () => { + const w = await mountSettingsPage(api); + w.vm.config.is_transport_enabled = true; + await w.vm.onIsTransportEnabledChange(); + expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/enable-transport"); + }); + + it("onIsTransportEnabledChange POSTs disable when turning off", async () => { + serverConfigRef.current = buildFullServerConfig({ is_transport_enabled: true }); + const w = await mountSettingsPage(api); + w.vm.config.is_transport_enabled = false; + await w.vm.onIsTransportEnabledChange(); + expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/disable-transport"); + }); +}); + +describe("SettingsPage — visualiser display prefs (localStorage + emitter)", () => { + let serverConfigRef; + let api; + + beforeEach(() => { + serverConfigRef = { current: buildFullServerConfig() }; + api = createWindowApi(serverConfigRef); + vi.spyOn(GlobalEmitter, "emit"); + localStorage.clear(); + }); + + afterEach(() => { + delete window.api; + GlobalEmitter.emit.mockRestore(); + vi.clearAllMocks(); + }); + + it("onVisualiserShowDisabledChange persists and emits", async () => { + const w = await mountSettingsPage(api); + w.vm.onVisualiserShowDisabledChange(true); + expect(localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces")).toBe("true"); + expect(GlobalEmitter.emit).toHaveBeenCalledWith("visualiser-display-prefs-changed"); + }); + + it("onVisualiserShowDiscoveredChange persists and emits", async () => { + const w = await mountSettingsPage(api); + w.vm.onVisualiserShowDiscoveredChange(true); + expect(localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces")).toBe("true"); + expect(GlobalEmitter.emit).toHaveBeenCalledWith("visualiser-display-prefs-changed"); + }); + + it("onDetailedOutboundSendStatusChange updates GlobalState and localStorage", async () => { + localStorage.removeItem("meshchatx_detailed_outbound_send_status"); + const w = await mountSettingsPage(api); + await w.vm.onDetailedOutboundSendStatusChange({ target: { checked: true } }); + expect(GlobalState.detailedOutboundSendStatus).toBe(true); + expect(localStorage.getItem("meshchatx_detailed_outbound_send_status")).toBe("true"); + await w.vm.onDetailedOutboundSendStatusChange({ target: { checked: false } }); + expect(GlobalState.detailedOutboundSendStatus).toBe(false); + expect(localStorage.getItem("meshchatx_detailed_outbound_send_status")).toBe("false"); + }); +}); + +describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", () => { + let serverConfigRef; + let api; + + beforeEach(() => { + serverConfigRef = { current: buildFullServerConfig() }; + api = createWindowApi(serverConfigRef); + vi.clearAllMocks(); + }); + + afterEach(() => { + delete window.api; + }); + + it("reloadRns POSTs reticulum reload", async () => { + const w = await mountSettingsPage(api); + await w.vm.reloadRns(); + expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/reload"); + }); + + it("clearMessages DELETEs maintenance messages", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearMessages(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages"); + }); + + it("clearAnnounces DELETEs announces", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearAnnounces(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/announces"); + }); + + it("clearNomadnetFavorites DELETEs with aspect param", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearNomadnetFavorites(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/favourites", { + params: { aspect: "nomadnetwork.node" }, + }); + }); + + it("clearLxmfIcons DELETEs lxmf-icons", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearLxmfIcons(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/lxmf-icons"); + }); + + it("clearStickers DELETEs stickers", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearStickers(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/stickers"); + }); + + it("clearArchives DELETEs archives", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearArchives(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/archives"); + }); + + it("clearReticulumDocs DELETEs docs", async () => { + const w = await mountSettingsPage(api); + await w.vm.clearReticulumDocs(); + expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/docs/reticulum"); + }); + + it("exportMessages GETs export endpoint", async () => { + const w = await mountSettingsPage(api); + await w.vm.exportMessages(); + expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export"); + }); + + it("exportFolders GETs folders export", async () => { + const w = await mountSettingsPage(api); + await w.vm.exportFolders(); + expect(api.get).toHaveBeenCalledWith("/api/v1/lxmf/folders/export"); + }); + + it("exportStickers GETs stickers export", async () => { + const w = await mountSettingsPage(api); + await w.vm.exportStickers(); + expect(api.get).toHaveBeenCalledWith("/api/v1/stickers/export"); + }); + + it("flushArchivedPages sends websocket flush after confirm", async () => { + const w = await mountSettingsPage(api); + await w.vm.flushArchivedPages(); + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ type: "nomadnet.page.archive.flush" }), + ); + }); + + it("revokeTelemetryTrust PATCHes contact telemetry flag", async () => { + const w = await mountSettingsPage(api); + await w.vm.revokeTelemetryTrust({ id: "c1", name: "Peer" }); + expect(api.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/c1", { + is_telemetry_trusted: false, + }); + }); +}); diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js index 2fef65d..9af059e 100644 --- a/tests/frontend/UIComponents.test.js +++ b/tests/frontend/UIComponents.test.js @@ -8,6 +8,7 @@ import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubL 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"; +import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils"; vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ default: { @@ -22,6 +23,9 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), }, })); @@ -537,6 +541,24 @@ describe("SettingsPage Component", () => { } }); + it("updates reload status from websocket events", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.onWebsocketMessage({ + data: JSON.stringify({ + type: "reticulum_reload_status", + message: "Stopping services...", + level: "info", + in_progress: true, + }), + }); + + expect(wrapper.vm.reloadingRns).toBe(true); + expect(wrapper.vm.reloadRnsStatusMessage).toBe("Stopping services..."); + expect(ToastUtils.info).toHaveBeenCalledWith("Stopping services...", 2500, "settings-rns-reload"); + }); + it("displays theme information correctly", async () => { const wrapper = mountSettingsPage(); await wrapper.vm.$nextTick(); @@ -555,6 +577,60 @@ describe("SettingsPage Component", () => { expect(wrapper.text()).toContain("app.transport"); }); + it("shows RNS reload controls in settings", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("app.reload_rns"); + }); + + it("enabling transport shows success toast after reload", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + axiosMock.post.mockResolvedValueOnce({ + data: { message: "Transport mode enabled and RNS restarted successfully." }, + }); + wrapper.vm.config.is_transport_enabled = true; + await wrapper.vm.onIsTransportEnabledChange(); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/enable-transport"); + expect(ToastUtils.success).toHaveBeenCalledWith("Transport mode enabled and RNS restarted successfully."); + }); + + it("disabling transport shows success toast after reload", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + axiosMock.post.mockResolvedValueOnce({ + data: { message: "Transport mode disabled and RNS restarted successfully." }, + }); + wrapper.vm.config.is_transport_enabled = false; + await wrapper.vm.onIsTransportEnabledChange(); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/disable-transport"); + expect(ToastUtils.success).toHaveBeenCalledWith("Transport mode disabled and RNS restarted successfully."); + }); + + it("shows error toast when enabling transport fails", async () => { + const wrapper = mountSettingsPage(); + await wrapper.vm.$nextTick(); + await wrapper.vm.getConfig(); + await wrapper.vm.$nextTick(); + + axiosMock.post.mockRejectedValueOnce(new Error("boom")); + wrapper.vm.config.is_transport_enabled = true; + await wrapper.vm.onIsTransportEnabledChange(); + + expect(ToastUtils.error).toHaveBeenCalledWith("settings.failed_enable_transport"); + }); + it("handles multiple toggle changes without errors", async () => { const wrapper = mountSettingsPage(); await wrapper.vm.$nextTick(); diff --git a/tests/frontend/conversationScroll.test.js b/tests/frontend/conversationScroll.test.js new file mode 100644 index 0000000..df1cfa2 --- /dev/null +++ b/tests/frontend/conversationScroll.test.js @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { + SCROLL_BOTTOM_EPS_PX, + isNearBottom, + isScrollColumnReverse, + maxScrollTop, + scrollContainerToBottom, + shouldLoadPreviousMessages, +} from "@/components/messages/conversationScroll.js"; + +function makeScrollContainer({ reverse, scrollTop, scrollHeight, clientHeight }) { + const outer = document.createElement("div"); + const inner = document.createElement("div"); + if (reverse) { + inner.style.flexDirection = "column-reverse"; + } else { + inner.style.flexDirection = "column"; + } + outer.appendChild(inner); + document.body.appendChild(outer); + Object.defineProperty(outer, "scrollHeight", { value: scrollHeight, configurable: true }); + Object.defineProperty(outer, "clientHeight", { value: clientHeight, configurable: true }); + outer.scrollTop = scrollTop; + return outer; +} + +describe("conversationScroll.js", () => { + it("maxScrollTop uses clientHeight", () => { + const el = document.createElement("div"); + Object.defineProperty(el, "scrollHeight", { value: 500, configurable: true }); + Object.defineProperty(el, "clientHeight", { value: 100, configurable: true }); + expect(maxScrollTop(el)).toBe(400); + }); + + it("isNearBottom for column-reverse uses small scrollTop", () => { + const el = makeScrollContainer({ + reverse: true, + scrollTop: 3, + scrollHeight: 5000, + clientHeight: 100, + }); + expect(isScrollColumnReverse(el)).toBe(true); + expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true); + el.scrollTop = 2000; + expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(false); + el.remove(); + }); + + it("isNearBottom for normal column uses distance from max scrollTop", () => { + const el = makeScrollContainer({ + reverse: false, + scrollTop: 392, + scrollHeight: 500, + clientHeight: 100, + }); + expect(isScrollColumnReverse(el)).toBe(false); + expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true); + el.scrollTop = 0; + expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(false); + el.remove(); + }); + + it("isNearBottom tolerates fractional scroll metrics (normal column)", () => { + const el = document.createElement("div"); + const inner = document.createElement("div"); + inner.style.flexDirection = "column"; + el.appendChild(inner); + document.body.appendChild(el); + Object.defineProperty(el, "scrollHeight", { value: 500.4, configurable: true }); + Object.defineProperty(el, "clientHeight", { value: 100.2, configurable: true }); + el.scrollTop = 400.19; + expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true); + el.remove(); + }); + + it("scrollContainerToBottom sets scrollTop for column-reverse", () => { + const el = makeScrollContainer({ + reverse: true, + scrollTop: 300, + scrollHeight: 800, + clientHeight: 100, + }); + scrollContainerToBottom(el); + expect(el.scrollTop).toBe(0); + el.remove(); + }); + + it("scrollContainerToBottom sets scrollTop to max for normal column", () => { + const el = makeScrollContainer({ + reverse: false, + scrollTop: 0, + scrollHeight: 600, + clientHeight: 100, + }); + scrollContainerToBottom(el); + expect(el.scrollTop).toBe(500); + el.remove(); + }); + + it("shouldLoadPreviousMessages mirrors edge for column-reverse", () => { + const el = makeScrollContainer({ + reverse: true, + scrollTop: 4450, + scrollHeight: 5000, + clientHeight: 100, + }); + expect(shouldLoadPreviousMessages(el)).toBe(true); + el.scrollTop = 0; + expect(shouldLoadPreviousMessages(el)).toBe(false); + el.remove(); + }); + + it("shouldLoadPreviousMessages is false at visual bottom for short column-reverse threads", () => { + const el = makeScrollContainer({ + reverse: true, + scrollTop: 0, + scrollHeight: 300, + clientHeight: 100, + }); + expect(maxScrollTop(el)).toBe(200); + expect(shouldLoadPreviousMessages(el)).toBe(false); + el.remove(); + }); + + it("shouldLoadPreviousMessages uses scrollTop for normal column", () => { + const el = makeScrollContainer({ + reverse: false, + scrollTop: 100, + scrollHeight: 5000, + clientHeight: 100, + }); + expect(shouldLoadPreviousMessages(el)).toBe(true); + el.scrollTop = 2000; + expect(shouldLoadPreviousMessages(el)).toBe(false); + el.remove(); + }); +}); diff --git a/tests/frontend/designTokens.test.js b/tests/frontend/designTokens.test.js index 842bdc0..f2b49c8 100644 --- a/tests/frontend/designTokens.test.js +++ b/tests/frontend/designTokens.test.js @@ -18,6 +18,7 @@ describe("design tokens", () => { expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]).toBe("#f8fafc"); expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]).toBe("#ffffff"); expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]).toBe("#2563eb"); + expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-action-primary"]).toBe("#2563eb"); expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]).toBe("#dc2626"); expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-info"]).toBe("#0284c7"); expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-success"]).toBe("#16a34a"); @@ -27,7 +28,8 @@ describe("design tokens", () => { it("preserves legacy dark palette anchors", () => { expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]).toBe("#09090b"); expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]).toBe("#18181b"); - expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]).toBe("#2563eb"); + expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]).toBe("#60a5fa"); + expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-action-primary"]).toBe("#2563eb"); expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]).toBe("#f87171"); expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-info"]).toBe("#38bdf8"); expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-success"]).toBe("#34d399"); @@ -38,11 +40,11 @@ describe("design tokens", () => { const t = vuetifyThemesFromTokens(); expect(t.light.colors.background).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]); expect(t.light.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]); - expect(t.light.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]); + expect(t.light.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-action-primary"]); expect(t.light.colors.error).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]); expect(t.dark.colors.background).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]); expect(t.dark.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]); - expect(t.dark.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]); + expect(t.dark.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-action-primary"]); expect(t.dark.colors.error).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]); }); diff --git a/tests/frontend/messageListVirtual.test.js b/tests/frontend/messageListVirtual.test.js new file mode 100644 index 0000000..11773d6 --- /dev/null +++ b/tests/frontend/messageListVirtual.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + MIN_VIRTUAL_DISPLAY_GROUPS, + displayGroupsOldestFirst, + estimateGroupHeight, + findDisplayGroupIndexForMessageHash, +} from "@/components/messages/messageListVirtual.js"; + +describe("messageListVirtual.js", () => { + it("displayGroupsOldestFirst reverses newest-first groups", () => { + const g = [ + { type: "single", key: "a", chatItem: { lxmf_message: { hash: "a" } } }, + { type: "single", key: "b", chatItem: { lxmf_message: { hash: "b" } } }, + ]; + const o = displayGroupsOldestFirst(g); + expect(o.map((x) => x.key).join(",")).toBe("b,a"); + }); + + it("estimateGroupHeight returns larger size for image groups", () => { + expect(estimateGroupHeight({ type: "imageGroup", items: [] })).toBeGreaterThan( + estimateGroupHeight({ type: "single", chatItem: {} }) + ); + }); + + it("findDisplayGroupIndexForMessageHash finds single and image group members", () => { + const groups = [ + { type: "single", key: "x", chatItem: { lxmf_message: { hash: "h1" } } }, + { + type: "imageGroup", + key: "ig", + items: [{ lxmf_message: { hash: "h2" } }, { lxmf_message: { hash: "h3" } }], + }, + ]; + expect(findDisplayGroupIndexForMessageHash(groups, "h1")).toBe(0); + expect(findDisplayGroupIndexForMessageHash(groups, "h3")).toBe(1); + expect(findDisplayGroupIndexForMessageHash(groups, "missing")).toBe(-1); + }); + + it("MIN_VIRTUAL_DISPLAY_GROUPS is a positive threshold", () => { + expect(MIN_VIRTUAL_DISPLAY_GROUPS).toBeGreaterThan(10); + }); +});