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; 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(); }); });