Files

406 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { createQuickSwitcher } from "@components/QuickSwitcher";
import type { QuickSwitcherOptions } from "@components/QuickSwitcher";
import { channelsStore, setChannels } from "@stores/channels.store";
import { addDmToChannelsStore } from "../../src/pages/main-page/SidebarDmHelpers";
import type { DmChannel } from "@stores/dm.store";
import type { ReadyChannel } from "../../src/lib/types";
function resetStore(): void {
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
}
const testChannels: ReadyChannel[] = [
{ id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 0 },
{ id: 2, name: "random", type: "text", category: "Text", position: 1, unread_count: 0 },
{ id: 3, name: "voice-lobby", type: "voice", category: "Voice", position: 2 },
{ id: 4, name: "announcements", type: "text", category: null, position: 3, unread_count: 0 },
];
describe("QuickSwitcher", () => {
let container: HTMLDivElement;
let switcher: ReturnType<typeof createQuickSwitcher>;
let onSelectChannel: Mock<(channelId: number) => void>;
let onClose: Mock<() => void>;
beforeEach(() => {
resetStore();
setChannels(testChannels);
container = document.createElement("div");
document.body.appendChild(container);
onSelectChannel = vi.fn<(channelId: number) => void>();
onClose = vi.fn<() => void>();
switcher = createQuickSwitcher({ onSelectChannel, onClose });
});
afterEach(() => {
switcher.destroy?.();
container.remove();
});
it("mounts with quick-switcher-overlay class", () => {
switcher.mount(container);
const overlay = container.querySelector(".quick-switcher-overlay");
expect(overlay).not.toBeNull();
});
it("renders search input with placeholder", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.placeholder).toBe("Where do you want to go?");
});
it("renders all channels initially", () => {
switcher.mount(container);
const items = container.querySelectorAll(".quick-switcher__item");
expect(items.length).toBe(4);
});
it("first item is active by default", () => {
switcher.mount(container);
const activeItem = container.querySelector(".quick-switcher__item--active");
expect(activeItem).not.toBeNull();
});
it("filters channels by search query", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.value = "gen";
input.dispatchEvent(new Event("input"));
const items = container.querySelectorAll(".quick-switcher__item");
expect(items.length).toBe(1);
const name = items[0]!.querySelector(".quick-switcher__name");
expect(name?.textContent).toBe("general");
});
it("filters channels without calling external search (client-side only)", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.value = "random";
input.dispatchEvent(new Event("input"));
// Filtering should work client-side
const items = container.querySelectorAll(".quick-switcher__item");
expect(items.length).toBe(1);
expect(items[0]!.querySelector(".quick-switcher__name")?.textContent).toBe("random");
});
it("clicking a channel calls onSelectChannel and onClose", () => {
switcher.mount(container);
const firstItem = container.querySelector(".quick-switcher__item") as HTMLDivElement;
firstItem.click();
expect(onSelectChannel).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("Escape key calls onClose", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(onClose).toHaveBeenCalledOnce();
});
it("ArrowDown moves active index", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
const items = container.querySelectorAll(".quick-switcher__item");
expect(items[1]!.classList.contains("quick-switcher__item--active")).toBe(true);
expect(items[0]!.classList.contains("quick-switcher__item--active")).toBe(false);
});
it("ArrowUp wraps around to last item", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
const items = container.querySelectorAll(".quick-switcher__item");
expect(items[3]!.classList.contains("quick-switcher__item--active")).toBe(true);
});
it("Enter selects the active channel", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSelectChannel).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("shows voice icon for voice channels", () => {
switcher.mount(container);
// text channels should have an SVG hash icon
const hashIcons = container.querySelectorAll('svg[data-icon="hash"]');
expect(hashIcons.length).toBeGreaterThan(0);
// voice-lobby should have an SVG volume icon instead of an emoji
const voiceIcons = container.querySelectorAll('svg[data-icon="volume-2"]');
expect(voiceIcons.length).toBeGreaterThan(0);
});
it("shows category when present", () => {
switcher.mount(container);
const categories = container.querySelectorAll(".quick-switcher__category");
const categoryTexts = Array.from(categories).map((c) => c.textContent);
expect(categoryTexts).toContain("Text");
expect(categoryTexts).toContain("Voice");
});
it("clicking backdrop calls onClose", () => {
switcher.mount(container);
const overlay = container.querySelector(".quick-switcher-overlay") as HTMLDivElement;
// Simulate clicking on the overlay itself (not a child)
overlay.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onClose).toHaveBeenCalledOnce();
});
it("destroy removes DOM", () => {
switcher.mount(container);
expect(container.querySelector(".quick-switcher-overlay")).not.toBeNull();
switcher.destroy?.();
expect(container.querySelector(".quick-switcher-overlay")).toBeNull();
});
it("ArrowDown wraps around to first item from last", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
// Navigate to last item (4 channels, so press ArrowDown 3 times to reach last)
for (let i = 0; i < 3; i++) {
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
}
const items = container.querySelectorAll(".quick-switcher__item");
expect(items[3]!.classList.contains("quick-switcher__item--active")).toBe(true);
// One more ArrowDown should wrap to first
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
const itemsAfter = container.querySelectorAll(".quick-switcher__item");
expect(itemsAfter[0]!.classList.contains("quick-switcher__item--active")).toBe(true);
});
it("Ctrl+K closes the switcher when it is open", () => {
switcher.mount(container);
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }),
);
expect(onClose).toHaveBeenCalledOnce();
});
it("Ctrl+K closes the switcher with CapsLock on (KeyboardEvent.key reports uppercase 'K')", () => {
// OC-0150: the global close handler compares `e.key === "k"`
// case-sensitively, so CapsLock (or Ctrl+Shift+K) leaves the switcher
// stuck open.
switcher.mount(container);
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "K", ctrlKey: true, bubbles: true }),
);
expect(onClose).toHaveBeenCalledOnce();
});
it("AltGr+K (ctrlKey+altKey) does not close the switcher", () => {
// OC-0150: Windows/WebView2 reports AltGr as ctrlKey+altKey, so without
// an altKey exclusion an AltGr-produced 'k' character both fails to
// reach the composer and closes the switcher underneath it.
switcher.mount(container);
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, altKey: true, bubbles: true }),
);
expect(onClose).not.toHaveBeenCalled();
});
it("Enter is a no-op when search returns no results", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
// Search for something that won't match
input.value = "zzzznotachannel";
input.dispatchEvent(new Event("input"));
const items = container.querySelectorAll(".quick-switcher__item");
expect(items.length).toBe(0);
// Press Enter — should not crash or call onSelectChannel
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSelectChannel).not.toHaveBeenCalled();
});
it("updates results when channel store changes", () => {
switcher.mount(container);
// Initially 4 channels
expect(container.querySelectorAll(".quick-switcher__item").length).toBe(4);
// Add a new channel to the store
setChannels([
...testChannels,
{ id: 5, name: "new-channel", type: "text", category: "Text", position: 4, unread_count: 0 },
]);
channelsStore.flush();
// Results should update
expect(container.querySelectorAll(".quick-switcher__item").length).toBe(5);
});
it("ArrowUp/ArrowDown are no-ops when results are empty", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
// Filter to no results
input.value = "zzzznotachannel";
input.dispatchEvent(new Event("input"));
// Should not throw
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(container.querySelectorAll(".quick-switcher__item").length).toBe(0);
});
it("does not list DM rows synthesized into channelsStore (they belong to the DM sidebar section)", () => {
const dm: DmChannel = {
channelId: 99,
recipient: { id: 1, username: "bob", avatar: "", status: "online" },
participants: [{ id: 1, username: "bob", avatar: "", status: "online" }],
name: "",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
};
addDmToChannelsStore(dm);
switcher.mount(container);
// Still just the 4 text/voice channels — the synthesized DM row is not
// duplicated here, and selecting it here would bypass clearDmUnread.
const items = container.querySelectorAll(".quick-switcher__item");
expect(items.length).toBe(4);
const names = Array.from(items).map(
(el) => el.querySelector(".quick-switcher__name")?.textContent,
);
expect(names).not.toContain("bob");
});
it("does not show category for channels without one", () => {
switcher.mount(container);
// Channel 4 (announcements) has category: null
const items = container.querySelectorAll(".quick-switcher__item");
const lastItem = items[3]!;
expect(lastItem.querySelector(".quick-switcher__name")?.textContent).toBe("announcements");
expect(lastItem.querySelector(".quick-switcher__category")).toBeNull();
});
it("applies dialog semantics to the quick switcher modal", () => {
switcher.mount(container);
const modal = container.querySelector(".quick-switcher") as HTMLDivElement;
expect(modal.getAttribute("role")).toBe("dialog");
expect(modal.getAttribute("aria-modal")).toBe("true");
expect(modal.getAttribute("aria-label")).toBe("Quick switcher");
});
it("wires the input as a combobox controlling the results listbox", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
expect(input.getAttribute("role")).toBe("combobox");
expect(input.getAttribute("aria-expanded")).toBe("true");
expect(input.getAttribute("aria-autocomplete")).toBe("list");
expect(input.getAttribute("aria-controls")).toBe("quick-switcher-results");
const results = container.querySelector("#quick-switcher-results");
expect(results).not.toBeNull();
expect(results!.getAttribute("role")).toBe("listbox");
expect(results!.classList.contains("quick-switcher__results")).toBe(true);
});
it("marks result rows as options with aria-selected on the active row", () => {
switcher.mount(container);
const items = container.querySelectorAll(".quick-switcher__item");
items.forEach((item, i) => {
expect(item.getAttribute("role")).toBe("option");
expect(item.id).toBe(`qs-option-${i}`);
expect(item.getAttribute("aria-selected")).toBe(i === 0 ? "true" : "false");
});
});
it("aria-activedescendant follows arrow-key navigation", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-0");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-1");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(input.getAttribute("aria-activedescendant")).toBe("qs-option-0");
});
it("clears aria-activedescendant when no results match", () => {
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.value = "zzzznotachannel";
input.dispatchEvent(new Event("input"));
expect(input.hasAttribute("aria-activedescendant")).toBe(false);
});
it("aborts a discarded row's click listener on a full rebuild instead of retaining it for the overlay's lifetime (OC-0307)", () => {
switcher.mount(container);
const staleFirstItem = container.querySelector(".quick-switcher__item") as HTMLDivElement;
// ArrowDown does not change the result set, but renderResults() still
// tears down every row and rebuilds it from scratch just to move the
// highlight. The pre-rebuild row is now detached from the DOM.
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(container.contains(staleFirstItem)).toBe(false);
// If the discarded row's listener is still registered against the
// overlay-lifetime signal (instead of being torn down with the row),
// dispatching a click directly on the stale node still reaches it.
staleFirstItem.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onSelectChannel).not.toHaveBeenCalled();
// A live row must still work after the rebuild.
const liveItem = container.querySelector(".quick-switcher__item") as HTMLDivElement;
liveItem.click();
expect(onSelectChannel).toHaveBeenCalledOnce();
});
it("moves focus into the dialog on mount and restores it on destroy", () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();
switcher.mount(container);
const input = container.querySelector(".quick-switcher__input") as HTMLInputElement;
expect(document.activeElement).toBe(input);
switcher.destroy?.();
expect(document.activeElement).toBe(opener);
opener.remove();
});
});