Files
OwnCord/Client/tauri-client/tests/unit/channel-sidebar.test.ts
T
J3vbandClaude Fable 5 544360642e fix(voice): freeze sidebar voice join/leave row on WS reconnect
The WS-reconnect control freeze (spec item 6) only reached the VoiceWidget's
in-call controls. The actual join affordance — clicking a voice-channel row in
ChannelSidebar — stayed a plain clickable div with no disabled state, so a click
while the socket was reconnecting/disconnected was a silent no-op (only the
VoiceCallbacks socketLive() backstop stopped the send).

Gate renderVoiceChannelItem on ui.store.connectionStatus using the same
disabled-with-reason pattern as VoiceWidget: apply a .disabled class,
aria-disabled, and a "Reconnecting…" / "Not connected" title while not connected,
and make the click a no-op. Subscribe the sidebar to connectionStatus so the row
freezes/unfreezes reactively (mirrors the existing collapsedCategories selector).

Docs: README.md §3 callout now notes the sidebar join affordance takes the
disabled-with-reason state too; voice-and-e2ee.md lists ChannelSidebar.ts as a
source of truth for the freeze.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:53:48 +02:00

1329 lines
39 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock livekitSession (required by streamPreview)
vi.mock("@lib/livekitSession", () => ({
setUserVolume: vi.fn(),
getUserVolume: vi.fn(() => 1),
getRemoteVideoStream: vi.fn(() => null),
}));
// Mock streamPreview to isolate sidebar tests from preview DOM logic
const mockAttachStreamPreview = vi.fn();
const mockAttachScrollCollapse = vi.fn();
vi.mock("@lib/streamPreview", () => ({
attachStreamPreview: (...args: unknown[]) => mockAttachStreamPreview(...args),
attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args),
}));
import { createChannelSidebar } from "../../src/components/ChannelSidebar";
import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import { uiStore, toggleCategory } from "../../src/stores/ui.store";
import { voiceStore, updateVoiceState } from "../../src/stores/voice.store";
import { membersStore } from "../../src/stores/members.store";
import type { ReadyChannel } from "../../src/lib/types";
function resetStores(): void {
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
authStore.setState(() => ({
token: null,
user: null,
serverName: "Test Server",
motd: null,
isAuthenticated: false,
}));
uiStore.setState(() => ({
sidebarCollapsed: false,
memberListVisible: true,
settingsOpen: false,
activeModal: null,
theme: "dark" as const,
// Default to a live socket: the voice join/leave affordance is only usable
// when connected. Frozen-state behavior is exercised explicitly below.
connectionStatus: "connected" as const,
transientError: null,
persistentError: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
}));
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
}
const testChannels: ReadyChannel[] = [
{
id: 1,
name: "general",
type: "text",
category: "Text Channels",
position: 0,
unread_count: 2,
last_message_id: 100,
},
{
id: 2,
name: "random",
type: "text",
category: "Text Channels",
position: 1,
unread_count: 0,
last_message_id: 50,
},
{
id: 3,
name: "voice-lobby",
type: "voice",
category: "Voice Channels",
position: 0,
},
{
id: 4,
name: "announcements",
type: "announcement",
category: "Info",
position: 0,
unread_count: 5,
last_message_id: 200,
},
];
/** Set auth user so admin-gated features (context menus, drag, create channel) activate. */
function setAdminUser(): void {
authStore.setState(() => ({
token: "tok",
user: { id: 1, username: "Admin", avatar: null, role: "admin" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
}
describe("ChannelSidebar", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
let onVoiceJoin: ReturnType<typeof vi.fn>;
let onVoiceLeave: ReturnType<typeof vi.fn>;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
onVoiceJoin = vi.fn();
onVoiceLeave = vi.fn();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
// Clean up any context menus left on document.body
document.querySelectorAll(".context-menu").forEach((el) => el.remove());
document.querySelectorAll(".user-vol-menu").forEach((el) => el.remove());
});
it("renders channel list from store", () => {
setChannels(testChannels);
sidebar.mount(container);
const items = container.querySelectorAll(".channel-item");
expect(items.length).toBe(4);
const names = Array.from(container.querySelectorAll(".ch-name")).map((el) => el.textContent);
expect(names).toContain("general");
expect(names).toContain("random");
expect(names).toContain("voice-lobby");
expect(names).toContain("announcements");
});
it("groups channels by category", () => {
setChannels(testChannels);
sidebar.mount(container);
const categories = container.querySelectorAll(".category");
const categoryNames = Array.from(categories).map(
(el) => el.querySelector(".category-name")?.textContent,
);
expect(categoryNames).toContain("Text Channels");
expect(categoryNames).toContain("Voice Channels");
expect(categoryNames).toContain("Info");
});
it("click channel sets active and clears unread", () => {
setChannels(testChannels);
sidebar.mount(container);
// Channel 1 (general) has unread_count of 2
const ch1Before = channelsStore.getState().channels.get(1);
expect(ch1Before?.unreadCount).toBe(2);
const firstItem = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(firstItem).not.toBeNull();
firstItem.click();
const state = channelsStore.getState();
expect(state.activeChannelId).toBe(1);
expect(state.channels.get(1)?.unreadCount).toBe(0);
});
it("category collapse toggles visibility", () => {
setChannels(testChannels);
sidebar.mount(container);
// Text Channels category should have 2 channels visible
const textChannelsBefore = container.querySelectorAll(".channel-item");
expect(textChannelsBefore.length).toBe(4);
// Click the "Text Channels" category header to collapse
const headers = container.querySelectorAll(".category");
const textHeader = Array.from(headers).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
) as HTMLElement;
expect(textHeader).not.toBeUndefined();
textHeader.click();
uiStore.flush();
// After collapse, "Text Channels" channels should be hidden
// The sidebar re-renders on uiStore change, so channels under
// collapsed category are not in the DOM
const itemsAfter = container.querySelectorAll(".channel-item");
expect(itemsAfter.length).toBe(2); // only Voice + Info channels remain
// Expand again
const headersAfter = container.querySelectorAll(".category");
const textHeaderAfter = Array.from(headersAfter).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
) as HTMLElement;
textHeaderAfter.click();
uiStore.flush();
const itemsExpanded = container.querySelectorAll(".channel-item");
expect(itemsExpanded.length).toBe(4);
});
it("displays server name from auth store", () => {
sidebar.mount(container);
const serverName = container.querySelector(".channel-sidebar-header h2");
expect(serverName?.textContent).toBe("Test Server");
});
it("shows unread badge for channels with unread messages", () => {
setChannels(testChannels);
sidebar.mount(container);
const badges = container.querySelectorAll(".unread-badge");
expect(badges.length).toBe(2); // general (2) and announcements (5)
const badgeTexts = Array.from(badges).map((b) => b.textContent);
expect(badgeTexts).toContain("2");
expect(badgeTexts).toContain("5");
});
it("marks active channel with active class", () => {
setChannels(testChannels);
setActiveChannel(2);
sidebar.mount(container);
const activeItem = container.querySelector('[data-channel-id="2"]');
expect(activeItem?.classList.contains("active")).toBe(true);
});
it("shows voice icon for voice channels", () => {
setChannels(testChannels);
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]');
const icon = voiceItem?.querySelector(".ch-icon");
expect(icon).not.toBeNull();
});
it("clicking voice channel calls onVoiceJoin instead of setActiveChannel", () => {
setChannels(testChannels);
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
voiceItem.click();
// Should NOT set active channel
expect(channelsStore.getState().activeChannelId).toBeNull();
// Should call onVoiceJoin with channel id
expect(onVoiceJoin).toHaveBeenCalledWith(3);
});
it("clicking text channel still sets active channel normally", () => {
setChannels(testChannels);
sidebar.mount(container);
const textItem = container.querySelector('[data-channel-id="1"]') as HTMLElement;
textItem.click();
expect(channelsStore.getState().activeChannelId).toBe(1);
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("clicking joined voice channel calls onVoiceLeave", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
voiceItem.click();
expect(onVoiceLeave).toHaveBeenCalled();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
// ── Voice join/leave freeze while the WS socket is not connected (§3) ──
it("disables voice channel join with a 'Reconnecting…' reason while reconnecting", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
expect(voiceItem.getAttribute("aria-disabled")).toBe("true");
expect(voiceItem.title).toBe("Reconnecting…");
// Frozen: the click must not fire the join callback.
voiceItem.click();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("disables voice channel join with a 'Not connected' reason while disconnected", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "disconnected" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
expect(voiceItem.title).toBe("Not connected");
voiceItem.click();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("frozen voice channel does not fire onVoiceLeave even when joined", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
voiceItem.click();
expect(onVoiceLeave).not.toHaveBeenCalled();
});
it("re-enables voice channel join when the connection returns to connected", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
// Initially frozen.
let voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
// Connection restored — the sidebar re-renders and unfreezes the row.
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
uiStore.flush();
voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(false);
expect(voiceItem.hasAttribute("aria-disabled")).toBe(false);
voiceItem.click();
expect(onVoiceJoin).toHaveBeenCalledWith(3);
});
it("shows connected voice users under voice channel", () => {
setChannels(testChannels);
// Add a member so username resolves
membersStore.setState((prev) => ({
...prev,
members: new Map([
[
10,
{ id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const },
],
]),
}));
updateVoiceState({
channel_id: 3,
user_id: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const voiceUsersList = container.querySelector(".voice-users-list");
expect(voiceUsersList).not.toBeNull();
const userItems = container.querySelectorAll(".voice-user-item");
expect(userItems.length).toBe(1);
const userName = userItems[0]?.querySelector(".vu-name");
expect(userName?.textContent).toBe("Alice");
});
it("highlights voice channel as active when user is joined", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]');
expect(voiceItem?.classList.contains("active")).toBe(true);
});
it("re-renders when voice store changes", () => {
setChannels(testChannels);
sidebar.mount(container);
// Initially no voice users
let voiceUsers = container.querySelectorAll(".voice-user-item");
expect(voiceUsers.length).toBe(0);
// Add a voice user
updateVoiceState({
channel_id: 3,
user_id: 20,
username: "Bob",
muted: true,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
voiceStore.flush();
voiceUsers = container.querySelectorAll(".voice-user-item");
expect(voiceUsers.length).toBe(1);
// Should show muted icon
const mutedIcon = voiceUsers[0]?.querySelector(".vu-muted");
expect(mutedIcon).not.toBeNull();
});
it("shows LIVE badge when user has screenshare active", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
const liveBadge = container.querySelector(".vu-live-badge");
expect(liveBadge).not.toBeNull();
expect(liveBadge!.textContent).toBe("LIVE");
});
it("shows monitor icon when user has screenshare active", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
// The screenshare user row should contain an SVG icon (monitor)
const voiceUserItems = container.querySelectorAll(".voice-user-item");
expect(voiceUserItems.length).toBe(1);
const screenIcon = voiceUserItems[0]?.querySelector("svg");
expect(screenIcon).not.toBeNull();
});
it("calls onWatchStream when clicking a user row with active stream", () => {
const onWatchStream = vi.fn();
sidebar.destroy?.();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
const voiceUserItem = container.querySelector(".voice-user-item") as HTMLElement;
expect(voiceUserItem).not.toBeNull();
voiceUserItem.click();
// User has screenshare: true, so tileId = userId + SCREENSHARE_TILE_ID_OFFSET
expect(onWatchStream).toHaveBeenCalledWith(30 + 1_000_000);
});
// ── Empty state ──
it("shows empty state when no channels exist", () => {
sidebar.mount(container);
const emptyText = container.querySelector(".channel-list-empty-text");
expect(emptyText).not.toBeNull();
expect(emptyText!.textContent).toBe("No channels yet");
const hint = container.querySelector(".channel-list-empty-hint");
expect(hint).not.toBeNull();
});
// ── Server name updates ──
it("updates server name when auth store changes", () => {
sidebar.mount(container);
const h2 = container.querySelector(".channel-sidebar-header h2");
expect(h2?.textContent).toBe("Test Server");
authStore.setState((prev) => ({ ...prev, serverName: "Renamed Server" }));
authStore.flush();
expect(h2?.textContent).toBe("Renamed Server");
});
it("falls back to 'Server Name' when serverName is null", () => {
authStore.setState((prev) => ({ ...prev, serverName: null }));
sidebar.mount(container);
const h2 = container.querySelector(".channel-sidebar-header h2");
expect(h2?.textContent).toBe("Server Name");
});
// ── Deafened and camera icons on voice users ──
it("shows both mic-off and headphones-off icons for deafened user", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 40,
username: "DeafUser",
muted: false,
deafened: true,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userRow = container.querySelector(".voice-user-item");
expect(userRow).not.toBeNull();
// Deafened shows TWO .vu-muted elements (mic-off + headphones-off)
const mutedIcons = userRow!.querySelectorAll(".vu-muted");
expect(mutedIcons.length).toBe(2);
});
it("shows camera icon for user with active camera", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 50,
username: "CameraUser",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
});
sidebar.mount(container);
const statusIcon = container.querySelector(".vu-status");
expect(statusIcon).not.toBeNull();
});
// ── Speaking state in-place toggle ──
it("toggles speaking class in-place without re-rendering entire DOM", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 60,
username: "Talker",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userRow = container.querySelector('.voice-user-item[data-voice-uid="60"]');
expect(userRow).not.toBeNull();
expect(userRow!.classList.contains("speaking")).toBe(false);
// Update only speaking flag (structural signature stays the same)
updateVoiceState({
channel_id: 3,
user_id: 60,
username: "Talker",
muted: false,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
// The same DOM element should now have speaking class toggled
const updatedRow = container.querySelector('.voice-user-item[data-voice-uid="60"]');
expect(updatedRow).not.toBeNull();
expect(updatedRow!.classList.contains("speaking")).toBe(true);
});
// ── Voice user avatar ──
it("renders first-letter avatar with deterministic color for voice user", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 70,
username: "Zara",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const avatar = container.querySelector(".vu-avatar");
expect(avatar).not.toBeNull();
expect(avatar!.textContent).toBe("Z");
// Avatar should have a background color set
expect((avatar as HTMLElement).style.background).not.toBe("");
});
it("shows '?' avatar for user with empty username", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 71,
username: "",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const avatar = container.querySelector(".vu-avatar");
expect(avatar).not.toBeNull();
expect(avatar!.textContent).toBe("?");
});
it("shows 'Unknown' name for user with empty username", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 71,
username: "",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const name = container.querySelector(".vu-name");
expect(name?.textContent).toBe("Unknown");
});
// ── Context menu for channel edit/delete ──
it("right-click on channel opens context menu with Edit and Delete for admin", () => {
const onEditChannel = vi.fn();
const onDeleteChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
onDeleteChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(channelEl).not.toBeNull();
// Dispatch right-click
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]');
expect(ctxMenu).not.toBeNull();
const editItem = document.querySelector('[data-testid="ctx-edit-channel"]');
expect(editItem).not.toBeNull();
expect(editItem!.textContent).toBe("Edit Channel");
const deleteItem = document.querySelector('[data-testid="ctx-delete-channel"]');
expect(deleteItem).not.toBeNull();
expect(deleteItem!.textContent).toBe("Delete Channel");
});
it("clicking Edit in context menu calls onEditChannel with the correct channel", () => {
const onEditChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const editItem = document.querySelector('[data-testid="ctx-edit-channel"]') as HTMLElement;
editItem.click();
expect(onEditChannel).toHaveBeenCalledTimes(1);
const calledWith = onEditChannel.mock.calls[0]![0];
expect(calledWith.id).toBe(1);
expect(calledWith.name).toBe("general");
});
it("clicking Delete in context menu calls onDeleteChannel with the correct channel", () => {
const onDeleteChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onDeleteChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const deleteItem = document.querySelector('[data-testid="ctx-delete-channel"]') as HTMLElement;
deleteItem.click();
expect(onDeleteChannel).toHaveBeenCalledTimes(1);
expect(onDeleteChannel.mock.calls[0]![0].id).toBe(1);
});
it("does not show context menu for non-admin users", () => {
const onEditChannel = vi.fn();
sidebar.destroy?.();
// Set a regular member (not admin/owner)
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
// No context menu should appear for non-admin
const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]');
expect(ctxMenu).toBeNull();
});
// ── Create channel button ──
it("shows create channel button on category header for admin users", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector('[data-testid="create-channel-text-channels"]');
expect(addBtn).not.toBeNull();
expect(addBtn!.textContent).toBe("+");
});
it("clicking create channel button calls onCreateChannel with category name", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector(
'[data-testid="create-channel-text-channels"]',
) as HTMLElement;
addBtn.click();
expect(onCreateChannel).toHaveBeenCalledWith("Text Channels");
});
it("create channel button does not collapse the category", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// All 4 channels visible before click
expect(container.querySelectorAll(".channel-item").length).toBe(4);
const addBtn = container.querySelector(
'[data-testid="create-channel-text-channels"]',
) as HTMLElement;
addBtn.click();
// Category should NOT have collapsed (stopPropagation in the handler)
expect(container.querySelectorAll(".channel-item").length).toBe(4);
});
it("does not show create channel button for non-admin users", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector(".category-add-btn");
expect(addBtn).toBeNull();
});
// ── Voice user volume context menu ──
it("right-click on other user's voice row opens volume context menu", () => {
// Set current user to something different from the voice user
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Me", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 80,
username: "OtherUser",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const voiceRow = container.querySelector(".voice-user-item") as HTMLElement;
voiceRow.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 150,
clientY: 250,
}),
);
const volMenu = document.querySelector(".user-vol-menu");
expect(volMenu).not.toBeNull();
// Should display the username
expect(volMenu!.textContent).toContain("OtherUser");
// Should have a volume slider
const slider = volMenu!.querySelector('input[type="range"]');
expect(slider).not.toBeNull();
// Should have a Reset Volume button
expect(volMenu!.textContent).toContain("Reset Volume");
});
// ── Collapsed category shows arrow-right, expanded shows arrow-down ──
it("collapsed category header has 'collapsed' class", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({
...prev,
collapsedCategories: new Set(["Text Channels"]),
}));
sidebar.mount(container);
const headers = container.querySelectorAll(".category");
const textHeader = Array.from(headers).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
);
expect(textHeader).not.toBeUndefined();
expect(textHeader!.classList.contains("collapsed")).toBe(true);
});
// ── Channels store subscription re-renders on channel map changes ──
it("re-renders when channels store changes after mount", () => {
sidebar.mount(container);
expect(container.querySelectorAll(".channel-item").length).toBe(0);
// Add channels after mount
setChannels(testChannels);
channelsStore.flush();
expect(container.querySelectorAll(".channel-item").length).toBe(4);
});
// ── Destroy cleanup ──
it("destroy removes the sidebar from the DOM", () => {
setChannels(testChannels);
sidebar.mount(container);
expect(container.querySelector('[data-testid="channel-sidebar"]')).not.toBeNull();
sidebar.destroy?.();
expect(container.querySelector('[data-testid="channel-sidebar"]')).toBeNull();
});
// ── Drag reorder setup for admin (attaches drag handlers) ──
it("admin sidebar with onReorderChannel adds channel-draggable class to items", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// Admin + onReorderChannel -> items should have channel-draggable class
const draggables = container.querySelectorAll(".channel-draggable");
expect(draggables.length).toBeGreaterThan(0);
});
it("non-admin does not get draggable class on channel items", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const draggables = container.querySelectorAll(".channel-draggable");
expect(draggables.length).toBe(0);
});
it("destroy cleans up global drag listeners when last sidebar instance is destroyed", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// After mount with drag support, destroy should not throw
sidebar.destroy?.();
// Verify sidebar is removed
expect(container.querySelector('[data-testid="channel-sidebar"]')).toBeNull();
// Re-create for afterEach cleanup
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
});
// ── Multiple voice users in same channel ──
it("renders multiple voice users under the same channel", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 90,
username: "UserA",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
updateVoiceState({
channel_id: 3,
user_id: 91,
username: "UserB",
muted: true,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userItems = container.querySelectorAll(".voice-user-item");
expect(userItems.length).toBe(2);
const names = Array.from(userItems).map((el) => el.querySelector(".vu-name")?.textContent);
expect(names).toContain("UserA");
expect(names).toContain("UserB");
});
// ── User with camera + screenshare shows both icons ──
it("shows both camera and monitor icons when user has camera and screenshare", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 92,
username: "MultiStream",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: true,
});
sidebar.mount(container);
const userRow = container.querySelector(".voice-user-item");
expect(userRow).not.toBeNull();
// Camera icon + screen icon = 2 .vu-status elements
const statusIcons = userRow!.querySelectorAll(".vu-status");
expect(statusIcons.length).toBe(2);
// Plus LIVE badge
const liveBadge = userRow!.querySelector(".vu-live-badge");
expect(liveBadge).not.toBeNull();
});
// T1: Screenshare click → offset tileId
it("passes screenshare tile offset when clicking screensharing user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "Streamer",
speaking: false,
muted: false,
deafened: false,
camera: false,
screenshare: true,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99 + 1_000_000);
sidebarWithWatch.destroy?.();
});
// T2: Camera-only click → raw userId
it("passes raw userId when clicking camera-only user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "Cammer",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99);
sidebarWithWatch.destroy?.();
});
// T12: Self-user → no preview attached
it("does not attach stream preview for self user", () => {
mockAttachStreamPreview.mockClear();
authStore.setState(() => ({
token: "tok",
user: { id: 42, username: "Me", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
42,
{
userId: 42,
username: "Me",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
// Should not have called attachStreamPreview for self
expect(mockAttachStreamPreview).not.toHaveBeenCalled();
});
// T20: Constant shared — sidebar uses SCREENSHARE_TILE_ID_OFFSET from constants
it("uses shared SCREENSHARE_TILE_ID_OFFSET constant", async () => {
// Verify the constant is imported and used by checking the offset value
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
1,
{
userId: 1,
username: "User",
speaking: false,
muted: false,
deafened: false,
camera: false,
screenshare: true,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
container.querySelector<HTMLElement>(".voice-user-item")?.click();
// 1 + 1_000_000 = 1_000_001 — proves the shared constant is used
expect(onWatchStream).toHaveBeenCalledWith(1_000_001);
sidebarWithWatch.destroy?.();
});
// T14: attachScrollCollapse is called for voice users containers
it("attaches scroll collapse to voice-users-list containers", () => {
mockAttachScrollCollapse.mockClear();
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "User",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
expect(mockAttachScrollCollapse).toHaveBeenCalled();
});
});