mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-23 08:45:43 +00:00
360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
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 DialogUtils from "@/js/DialogUtils";
|
|
import GlobalEmitter from "@/js/GlobalEmitter";
|
|
import GlobalState from "@/js/GlobalState";
|
|
|
|
const RENDER_THRESHOLD_MS = 500;
|
|
|
|
vi.mock("@/js/DialogUtils", () => ({
|
|
default: {
|
|
confirm: vi.fn(() => Promise.resolve(true)),
|
|
alert: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/js/ToastUtils", () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
loading: vi.fn(),
|
|
info: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe("ConversationViewer.vue button interactions", () => {
|
|
let axiosMock;
|
|
|
|
beforeEach(() => {
|
|
WebSocketConnection.connect();
|
|
vi.clearAllMocks();
|
|
axiosMock = {
|
|
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: {} }),
|
|
delete: vi.fn().mockResolvedValue({ data: {} }),
|
|
};
|
|
window.api = axiosMock;
|
|
|
|
GlobalState.blockedDestinations = [];
|
|
GlobalState.config = { banished_effect_enabled: false };
|
|
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
});
|
|
window.URL.createObjectURL = vi.fn(() => "mock-url");
|
|
vi.stubGlobal(
|
|
"FileReader",
|
|
vi.fn(() => ({
|
|
readAsDataURL: vi.fn(function () {
|
|
this.result = "data:image/png;base64,mock";
|
|
this.onload?.({ target: { result: this.result } });
|
|
}),
|
|
}))
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete window.api;
|
|
vi.unstubAllGlobals();
|
|
WebSocketConnection.destroy();
|
|
});
|
|
|
|
const mountViewer = (overrides = {}) =>
|
|
mount(ConversationViewer, {
|
|
props: {
|
|
selectedPeer: { destination_hash: "a".repeat(32), display_name: "Test Peer" },
|
|
myLxmfAddressHash: "b".repeat(32),
|
|
conversations: [],
|
|
...overrides,
|
|
},
|
|
global: {
|
|
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
|
|
mocks: { $t: (key) => key, $i18n: { locale: "en" } },
|
|
stubs: {
|
|
MaterialDesignIcon: true,
|
|
AddImageButton: true,
|
|
AddAudioButton: true,
|
|
SendMessageButton: true,
|
|
ConversationDropDownMenu: true,
|
|
PaperMessageModal: true,
|
|
AudioWaveformPlayer: true,
|
|
LxmfUserIcon: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
it("mounts within render threshold", () => {
|
|
const start = performance.now();
|
|
const wrapper = mountViewer();
|
|
const elapsed = performance.now() - start;
|
|
expect(wrapper.find(".flex").exists()).toBe(true);
|
|
expect(elapsed).toBeLessThan(RENDER_THRESHOLD_MS);
|
|
});
|
|
|
|
it("banish button calls API when confirmed", async () => {
|
|
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
await wrapper.vm.onBanishHeaderClick();
|
|
|
|
expect(DialogUtils.confirm).toHaveBeenCalled();
|
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
"/api/v1/blocked-destinations",
|
|
expect.objectContaining({ destination_hash: "a".repeat(32) })
|
|
);
|
|
expect(emitSpy).toHaveBeenCalledWith("block-status-changed");
|
|
emitSpy.mockRestore();
|
|
});
|
|
|
|
it("banish button hidden when peer is blocked", async () => {
|
|
GlobalState.blockedDestinations = [{ destination_hash: "a".repeat(32) }];
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
wrapper.vm.checkIfSelectedPeerBlocked();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const banishBtn = wrapper.findAll("button").find((b) => b.attributes("title")?.includes("banish"));
|
|
expect(banishBtn).toBeUndefined();
|
|
});
|
|
|
|
it("telemetry history modal can be opened", async () => {
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
wrapper.vm.isTelemetryHistoryModalOpen = true;
|
|
await wrapper.vm.$nextTick();
|
|
|
|
expect(wrapper.vm.isTelemetryHistoryModalOpen).toBe(true);
|
|
});
|
|
|
|
it("close button emits close", async () => {
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const closeBtn = wrapper.findAll("button").find((b) => b.attributes("title") === "Close");
|
|
expect(closeBtn).toBeDefined();
|
|
|
|
await closeBtn.trigger("click");
|
|
|
|
expect(wrapper.emitted("close")).toHaveLength(1);
|
|
});
|
|
|
|
it("onMessageContextMenu opens menu and Reply works", async () => {
|
|
const wrapper = mountViewer();
|
|
const chatItem = {
|
|
type: "lxmf_message",
|
|
is_outbound: false,
|
|
lxmf_message: {
|
|
hash: "msg-1",
|
|
content: "Hello",
|
|
state: "delivered",
|
|
fields: {},
|
|
},
|
|
};
|
|
wrapper.vm.chatItems = [chatItem];
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const replySpy = vi.spyOn(wrapper.vm, "replyToMessage");
|
|
|
|
wrapper.vm.onMessageContextMenu({ clientX: 100, clientY: 100 }, chatItem);
|
|
await wrapper.vm.$nextTick();
|
|
|
|
expect(wrapper.vm.messageContextMenu.show).toBe(true);
|
|
|
|
const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find(
|
|
(el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete")
|
|
);
|
|
expect(menuEl).toBeTruthy();
|
|
|
|
const replyBtn = menuEl?.querySelector("button");
|
|
expect(replyBtn?.textContent).toContain("Reply");
|
|
|
|
replyBtn?.click();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
expect(replySpy).toHaveBeenCalledWith(chatItem);
|
|
});
|
|
|
|
it("message context menu Delete calls deleteChatItem", async () => {
|
|
const wrapper = mountViewer();
|
|
const chatItem = {
|
|
type: "lxmf_message",
|
|
is_outbound: false,
|
|
lxmf_message: { hash: "msg-del", content: "Hi", state: "delivered", fields: {} },
|
|
};
|
|
wrapper.vm.chatItems = [chatItem];
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const deleteSpy = vi.spyOn(wrapper.vm, "deleteChatItem");
|
|
|
|
wrapper.vm.messageContextMenu.chatItem = chatItem;
|
|
wrapper.vm.messageContextMenu.show = true;
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find(
|
|
(el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete")
|
|
);
|
|
const deleteBtn = menuEl
|
|
? Array.from(menuEl.querySelectorAll("button")).find((b) => b.textContent.includes("Delete"))
|
|
: null;
|
|
expect(deleteBtn).toBeTruthy();
|
|
|
|
deleteBtn?.click();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
expect(deleteSpy).toHaveBeenCalledWith(chatItem);
|
|
});
|
|
|
|
it("call button exists and onStartCall is callable", async () => {
|
|
const wrapper = mountViewer();
|
|
expect(typeof wrapper.vm.onStartCall).toBe("function");
|
|
await wrapper.vm.onStartCall();
|
|
});
|
|
|
|
it("share contact button exists and openShareContactModal is callable", async () => {
|
|
const wrapper = mountViewer();
|
|
expect(typeof wrapper.vm.openShareContactModal).toBe("function");
|
|
wrapper.vm.openShareContactModal();
|
|
});
|
|
|
|
describe("compose area: clipboard, file attachments, and toolbar actions", () => {
|
|
it("add files button triggers the hidden file input click", async () => {
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
const fileInput = wrapper.find('input[type="file"]');
|
|
const clickSpy = vi.spyOn(fileInput.element, "click").mockImplementation(() => {});
|
|
|
|
const actionButtons = wrapper.findAll(".attachment-action-button");
|
|
await actionButtons[0].trigger("click");
|
|
|
|
expect(clickSpy).toHaveBeenCalled();
|
|
clickSpy.mockRestore();
|
|
});
|
|
|
|
it("onFileInputChange appends selected files to newMessageFiles", () => {
|
|
const wrapper = mountViewer();
|
|
const f = new File(["x"], "attach.txt", { type: "text/plain" });
|
|
wrapper.vm.onFileInputChange({ target: { files: [f] } });
|
|
expect(wrapper.vm.newMessageFiles).toContain(f);
|
|
});
|
|
|
|
it("removeFileAttachment removes one file from newMessageFiles", () => {
|
|
const wrapper = mountViewer();
|
|
const f1 = new File(["a"], "a.txt", { type: "text/plain" });
|
|
const f2 = new File(["b"], "b.txt", { type: "text/plain" });
|
|
wrapper.vm.newMessageFiles = [f1, f2];
|
|
wrapper.vm.removeFileAttachment(f1);
|
|
expect(wrapper.vm.newMessageFiles).toEqual([f2]);
|
|
});
|
|
|
|
it("paste toolbar button reads clipboard text into the message field", async () => {
|
|
const readText = vi.fn(() => Promise.resolve("from-clipboard"));
|
|
vi.stubGlobal("navigator", {
|
|
...navigator,
|
|
clipboard: { readText },
|
|
});
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.$nextTick();
|
|
const ta = wrapper.find("#message-input").element;
|
|
ta.selectionStart = 0;
|
|
ta.selectionEnd = 0;
|
|
wrapper.vm.newMessageText = "";
|
|
|
|
const actionButtons = wrapper.findAll(".attachment-action-button");
|
|
await actionButtons[1].trigger("click");
|
|
await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard"));
|
|
});
|
|
|
|
it("translateMessage replaces text when the translator API succeeds", async () => {
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url.includes("/translator/translate")) {
|
|
return Promise.resolve({ data: { translated_text: "translated" } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
const wrapper = mountViewer();
|
|
wrapper.vm.newMessageText = "hello";
|
|
await wrapper.vm.translateMessage();
|
|
expect(wrapper.vm.newMessageText).toBe("translated");
|
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
"/api/v1/translator/translate",
|
|
expect.objectContaining({
|
|
text: "hello",
|
|
target_lang: "en",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("generatePaperMessageFromComposition sends lxm.generate_paper_uri over the websocket", async () => {
|
|
const sendSpy = vi.spyOn(WebSocketConnection, "send").mockImplementation(() => {});
|
|
const wrapper = mountViewer();
|
|
wrapper.vm.newMessageText = "paper body";
|
|
await wrapper.vm.generatePaperMessageFromComposition();
|
|
expect(sendSpy).toHaveBeenCalled();
|
|
const payload = JSON.parse(sendSpy.mock.calls[0][0]);
|
|
expect(payload.type).toBe("lxm.generate_paper_uri");
|
|
expect(payload.destination_hash).toBe("a".repeat(32));
|
|
expect(payload.content).toBe("paper body");
|
|
sendSpy.mockRestore();
|
|
});
|
|
|
|
it("requestLocation posts a telemetry request command", async () => {
|
|
const wrapper = mountViewer();
|
|
await wrapper.vm.requestLocation();
|
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
"/api/v1/lxmf-messages/send",
|
|
expect.objectContaining({
|
|
lxmf_message: expect.objectContaining({
|
|
destination_hash: "a".repeat(32),
|
|
fields: {
|
|
commands: [{ "0x01": expect.any(Number) }],
|
|
},
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("shareLocation with manual coordinates sets telemetry and calls sendMessage", async () => {
|
|
const wrapper = mountViewer({
|
|
config: {
|
|
location_source: "manual",
|
|
location_manual_lat: "12.34",
|
|
location_manual_lon: "56.78",
|
|
location_manual_alt: "9",
|
|
},
|
|
});
|
|
const sendSpy = vi.spyOn(wrapper.vm, "sendMessage").mockResolvedValue(undefined);
|
|
await wrapper.vm.shareLocation();
|
|
expect(sendSpy).toHaveBeenCalled();
|
|
expect(wrapper.vm.newMessageTelemetry).toMatchObject({
|
|
latitude: 12.34,
|
|
longitude: 56.78,
|
|
altitude: 9,
|
|
});
|
|
sendSpy.mockRestore();
|
|
});
|
|
|
|
it("openConversationPopout opens a popout URL with the peer hash", () => {
|
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
const wrapper = mountViewer();
|
|
wrapper.vm.openConversationPopout();
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining(encodeURIComponent("a".repeat(32))),
|
|
"_blank",
|
|
expect.stringContaining("width=")
|
|
);
|
|
openSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|