mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* build(client): upgrade TypeScript to 6.0.3 Staging step toward TypeScript 7 (the native compiler), which needs its 7.1 stable API before typescript-eslint and Stryker's typescript-checker can run on it. TS 6 is the JS-based bridge release that aligns config defaults with 7. Two fallout fixes: - tsconfig.e2e.json: TS 6 defaults "types" to [] instead of every installed @types package, so the Playwright layer's Node globals (process, Buffer) need an explicit "types": ["node"]. - media-visibility.test.ts: TS 6's DOM lib adds scrollMargin to IntersectionObserver, so the mock grows the property. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(server): bump chi to 5.3.2, modernc.org/sqlite to 1.57.0, toolchain to go1.26.7 Go 1.27 deliberately deferred until 1.27.1 lands. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(tauri): bump tokio-tungstenite to 0.30, refresh Cargo.lock In-range lockfile refresh via cargo update; tungstenite 0.29/0.30 changes are client-API-neutral (header handling, server-side handshake hardening). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(client): upgrade vite 8, vitest 4, jsdom 30, stryker 10 + minors - vite 6 -> 8: Rolldown requires the function form of manualChunks; __dirname -> import.meta.dirname in configs - vitest 3 -> 4: browser provider moved to @vitest/browser-playwright; vi.fn() mocks now need explicit signatures (typed throughout tests); constructor mocks use function impls; restoreAllMocks no longer resets vi.fn state; matchMedia spies replaced with vi.stubGlobal - jsdom 29 -> 30: one internal bookkeeping abort listener per signal, listener-count regression tests adjusted (leak detection retained) - stryker 9 -> 10, @types/node 20 -> 24, eslint/oxlint/livekit-client minors - tsconfigs: explicit "types" now that TS6/vitest4 stop injecting @types/node ambiently; build config keeps Node globals out of src/ Validated: tsc (main/build/e2e), eslint, oxlint, knip, prettier, unit+integration (5196 tests), browser suite, vite build, stryker dry run. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * ci: move Node 20 (EOL 2026-04-30) to Node 24 LTS The jsdom suite runs on modern Node without --no-experimental-webstorage: tests/setup.ts already replaces the shadowed localStorage with an in-memory shim. Client CLAUDE.md gotcha updated accordingly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn --------- Co-authored-by: Claude <noreply@anthropic.com>
722 lines
27 KiB
TypeScript
722 lines
27 KiB
TypeScript
/**
|
|
* Tests for src/components/channel-sidebar/drag-reorder.ts.
|
|
*
|
|
* This module was the least-covered file in the client (38.8% statements) and
|
|
* had no test of its own — the reorder index arithmetic, the admin-only gate,
|
|
* the 5px drag threshold and the listener ref-counting were all unverified.
|
|
* Off-by-one errors in the insert index silently reorder the wrong pair of
|
|
* channels for every member of the server.
|
|
*/
|
|
|
|
import { beforeEach, afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
|
|
import {
|
|
attachDragHandlers,
|
|
ensureGlobalDragListeners,
|
|
} from "@components/channel-sidebar/drag-reorder";
|
|
import type { ChannelReorderData } from "@components/ChannelSidebar";
|
|
import { authStore } from "@stores/auth.store";
|
|
import { channelsStore, setRoles } from "@stores/channels.store";
|
|
import type { Channel } from "@stores/channels.store";
|
|
import type { UserWithRole } from "@lib/types";
|
|
import { Permission } from "@lib/types";
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function makeUser(role: string): UserWithRole {
|
|
return { id: 1, username: "tester", avatar: null, role };
|
|
}
|
|
|
|
function signIn(role: string): void {
|
|
authStore.setState(() => ({
|
|
token: "t",
|
|
user: makeUser(role),
|
|
serverName: "s",
|
|
motd: "",
|
|
isAuthenticated: true,
|
|
}));
|
|
}
|
|
|
|
function makeCh(id: number, position: number, name = `ch-${id}`): Channel {
|
|
return {
|
|
id,
|
|
name,
|
|
type: "text",
|
|
category: null,
|
|
position,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
lastMessageId: null,
|
|
canSend: true,
|
|
topic: "",
|
|
slowMode: 0,
|
|
nsfw: false,
|
|
voiceMaxUsers: 0,
|
|
voiceMaxVideo: 0,
|
|
};
|
|
}
|
|
|
|
interface Rig {
|
|
container: HTMLElement;
|
|
items: Map<number, HTMLElement>;
|
|
channels: Channel[];
|
|
onReorder: Mock<(reorders: readonly ChannelReorderData[]) => void>;
|
|
abort: AbortController;
|
|
}
|
|
|
|
/** Every rig's owner controller, aborted in afterEach so the shared document
|
|
* listeners are fully torn down between tests. */
|
|
const rigAborts: AbortController[] = [];
|
|
|
|
/** jsdom does not lay out, so stub the geometry the module reads: a 20px-tall
|
|
* row at y = idx*20 while attached, and — exactly like a real browser — an
|
|
* all-zero rect once the row is detached (e.g. a re-render replaced it). */
|
|
function stubRowRect(el: HTMLElement, idx: number): void {
|
|
const top = idx * 20;
|
|
el.getBoundingClientRect = () =>
|
|
el.isConnected
|
|
? {
|
|
top,
|
|
bottom: top + 20,
|
|
height: 20,
|
|
left: 0,
|
|
right: 100,
|
|
width: 100,
|
|
x: 0,
|
|
y: top,
|
|
toJSON: () => ({}),
|
|
}
|
|
: {
|
|
top: 0,
|
|
bottom: 0,
|
|
height: 0,
|
|
left: 0,
|
|
right: 0,
|
|
width: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
};
|
|
}
|
|
|
|
/** Builds a container with one 20px-tall row per channel, stacked vertically. */
|
|
function buildRig(channels: Channel[]): Rig {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
const items = new Map<number, HTMLElement>();
|
|
const onReorder = vi.fn<(reorders: readonly ChannelReorderData[]) => void>();
|
|
const abort = new AbortController();
|
|
rigAborts.push(abort);
|
|
|
|
channels.forEach((ch, idx) => {
|
|
const el = document.createElement("div");
|
|
container.appendChild(el);
|
|
stubRowRect(el, idx);
|
|
attachDragHandlers(el, ch, container, channels, abort.signal, onReorder);
|
|
items.set(ch.id, el);
|
|
});
|
|
|
|
return { container, items, channels, onReorder, abort };
|
|
}
|
|
|
|
/** Simulates ChannelSidebar.renderChannels() rebuilding a category group:
|
|
* fresh rows in a fresh `.category-channels-container` under the same
|
|
* sidebar owner, while the previous container sits detached. */
|
|
function rebuildContainer(
|
|
rig: Rig,
|
|
channels: Channel[],
|
|
): { container: HTMLElement; items: Map<number, HTMLElement> } {
|
|
const container = document.createElement("div");
|
|
container.className = "category-channels-container";
|
|
document.body.appendChild(container);
|
|
|
|
const items = new Map<number, HTMLElement>();
|
|
channels.forEach((ch, idx) => {
|
|
const el = document.createElement("div");
|
|
container.appendChild(el);
|
|
stubRowRect(el, idx);
|
|
attachDragHandlers(el, ch, container, channels, rig.abort.signal, rig.onReorder);
|
|
items.set(ch.id, el);
|
|
});
|
|
|
|
return { container, items };
|
|
}
|
|
|
|
/** Row `idx` spans y = idx*20 .. idx*20+20; its midpoint is +10. */
|
|
function yInRow(idx: number, half: "top" | "bottom"): number {
|
|
return idx * 20 + (half === "top" ? 4 : 16);
|
|
}
|
|
|
|
/** A real drag holds the left button down for its whole duration, so `buttons`
|
|
* (the held-button bitmask) is set alongside `button` (which button changed)
|
|
* on every synthetic event — mousemove gates on `buttons` to detect a stale
|
|
* latch left by an off-row release. */
|
|
function mouse(type: string, clientX: number, clientY: number): MouseEvent {
|
|
return new MouseEvent(type, { clientX, clientY, button: 0, buttons: 1, bubbles: true });
|
|
}
|
|
|
|
/** Drives a full drag of `fromId` onto the given half of row `toIdx`. */
|
|
function drag(rig: Rig, fromId: number, toIdx: number, half: "top" | "bottom"): void {
|
|
const source = rig.items.get(fromId);
|
|
if (source === undefined) throw new Error(`no row for channel ${fromId}`);
|
|
const fromIdx = rig.channels.findIndex((c) => c.id === fromId);
|
|
|
|
source.dispatchEvent(mouse("mousedown", 0, yInRow(fromIdx, "top")));
|
|
// Past the 5px threshold so the drag activates.
|
|
source.dispatchEvent(mouse("mousemove", 0, yInRow(fromIdx, "top") + 20));
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(toIdx, half)));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
authStore.setState(() => ({
|
|
token: null,
|
|
user: null,
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: false,
|
|
}));
|
|
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
|
|
document.body.className = "";
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
afterEach(() => {
|
|
// End any drag still in flight so it cannot leak into the next test.
|
|
// Re-arm the global handlers under a throwaway owner first: a test that
|
|
// aborted every owner tore them down, and the mouseup below needs a live
|
|
// listener to do the clearing.
|
|
const flush = new AbortController();
|
|
ensureGlobalDragListeners(flush.signal);
|
|
document.dispatchEvent(mouse("mouseup", 0, -1000));
|
|
flush.abort();
|
|
// Abort every rig owner so the shared document listeners are torn down.
|
|
for (const ac of rigAborts) ac.abort();
|
|
rigAborts.length = 0;
|
|
document.body.innerHTML = "";
|
|
document.body.className = "";
|
|
});
|
|
|
|
// ── permission gate ────────────────────────────────────────────────────────
|
|
|
|
describe("attachDragHandlers permission gate", () => {
|
|
it.each([
|
|
["owner", true],
|
|
["admin", true],
|
|
["Owner", true],
|
|
["ADMIN", true],
|
|
["moderator", false],
|
|
["member", false],
|
|
["", false],
|
|
])("role %s → draggable %s", (role, draggable) => {
|
|
signIn(role);
|
|
const rig = buildRig([makeCh(1, 0)]);
|
|
const el = rig.items.get(1);
|
|
|
|
expect(el?.classList.contains("channel-draggable")).toBe(draggable);
|
|
});
|
|
|
|
// Gated on the MANAGE_CHANNELS bit, not the literal role name — the same
|
|
// derivation as Edit/Delete (context-menu.ts) and the category "+"
|
|
// (ChannelSidebar.ts), so the three cannot drift apart on who may reorder.
|
|
it("makes a custom role holding MANAGE_CHANNELS draggable, even though its name is neither owner nor admin", () => {
|
|
setRoles([{ id: 9, name: "Curator", color: null, permissions: Permission.MANAGE_CHANNELS }]);
|
|
signIn("Curator");
|
|
const rig = buildRig([makeCh(1, 0)]);
|
|
|
|
expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(true);
|
|
});
|
|
|
|
it("does not make a role literally named 'admin' draggable when it lacks MANAGE_CHANNELS", () => {
|
|
setRoles([{ id: 9, name: "admin", color: null, permissions: 0 }]);
|
|
signIn("admin");
|
|
const rig = buildRig([makeCh(1, 0)]);
|
|
|
|
expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false);
|
|
});
|
|
|
|
it("does nothing when no user is signed in", () => {
|
|
const rig = buildRig([makeCh(1, 0)]);
|
|
|
|
expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false);
|
|
});
|
|
|
|
it("does nothing when no onReorderChannel callback is supplied", () => {
|
|
signIn("owner");
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const el = document.createElement("div");
|
|
container.appendChild(el);
|
|
|
|
attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], new AbortController().signal);
|
|
|
|
expect(el.classList.contains("channel-draggable")).toBe(false);
|
|
expect(el.dataset.dragChannelId).toBeUndefined();
|
|
});
|
|
|
|
it("stamps the channel id for hit-testing when permitted", () => {
|
|
signIn("admin");
|
|
const rig = buildRig([makeCh(7, 0)]);
|
|
|
|
expect(rig.items.get(7)?.dataset.dragChannelId).toBe("7");
|
|
});
|
|
});
|
|
|
|
// ── drag threshold ─────────────────────────────────────────────────────────
|
|
|
|
describe("drag activation threshold", () => {
|
|
it("does not start a drag below the 5px threshold", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const el = rig.items.get(1);
|
|
|
|
el?.dispatchEvent(mouse("mousedown", 0, 4));
|
|
el?.dispatchEvent(mouse("mousemove", 2, 6)); // dx+dy = 4
|
|
|
|
expect(el?.classList.contains("dragging")).toBe(false);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
});
|
|
|
|
it("starts a drag once movement exceeds the threshold", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const el = rig.items.get(1);
|
|
|
|
el?.dispatchEvent(mouse("mousedown", 0, 4));
|
|
el?.dispatchEvent(mouse("mousemove", 3, 10)); // dx+dy = 9
|
|
|
|
expect(el?.classList.contains("dragging")).toBe(true);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(true);
|
|
});
|
|
|
|
it("ignores non-left mouse buttons", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const el = rig.items.get(1);
|
|
|
|
el?.dispatchEvent(new MouseEvent("mousedown", { clientX: 0, clientY: 4, button: 2 }));
|
|
el?.dispatchEvent(mouse("mousemove", 0, 40));
|
|
|
|
expect(el?.classList.contains("dragging")).toBe(false);
|
|
});
|
|
|
|
it("a mouseup before the threshold cancels the pending drag", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const el = rig.items.get(1);
|
|
|
|
el?.dispatchEvent(mouse("mousedown", 0, 4));
|
|
el?.dispatchEvent(mouse("mouseup", 0, 4));
|
|
el?.dispatchEvent(mouse("mousemove", 0, 60));
|
|
|
|
expect(el?.classList.contains("dragging")).toBe(false);
|
|
});
|
|
|
|
it("a stale pending-drag latch left by an off-row release does not resume on a later buttonless hover", () => {
|
|
// Press near row 1, drift under the 5px threshold, and release over row 2:
|
|
// no drag ran, and because mousedown/mouseup targeted different elements,
|
|
// row 1's own mouseup listener never fires, so its pendingDrag latch is
|
|
// never cleared.
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
const row1 = rig.items.get(1)!;
|
|
|
|
row1.dispatchEvent(mouse("mousedown", 0, yInRow(0, "bottom")));
|
|
row1.dispatchEvent(mouse("mousemove", 0, yInRow(0, "bottom") + 1)); // 1px, below threshold
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top"))); // released over row 2
|
|
|
|
expect(row1.classList.contains("dragging")).toBe(false);
|
|
|
|
// Later, the pointer crosses row 1 again with no button held (a plain
|
|
// hover). Without the fix this satisfies the 5px threshold against the
|
|
// stale startX/startY and silently starts a real drag.
|
|
row1.dispatchEvent(
|
|
new MouseEvent("mousemove", {
|
|
clientX: 0,
|
|
clientY: yInRow(0, "top") + 20,
|
|
buttons: 0,
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
|
|
expect(row1.classList.contains("dragging")).toBe(false);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── reorder arithmetic ─────────────────────────────────────────────────────
|
|
|
|
describe("reorder index arithmetic", () => {
|
|
it("dropping on the top half inserts before the target", () => {
|
|
signIn("owner");
|
|
// Rows: idx0=ch1, idx1=ch2, idx2=ch3.
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
|
|
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
|
|
|
|
expect(rig.onReorder).toHaveBeenCalledTimes(1);
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 2 });
|
|
});
|
|
|
|
it("dropping on the bottom half inserts after the target", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
|
|
drag(rig, 1, 1, "bottom"); // ch1 after ch2 → [2, 1, 3]
|
|
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
|
|
});
|
|
|
|
it("only reports channels whose position actually changed", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2), makeCh(4, 3)]);
|
|
|
|
drag(rig, 1, 1, "bottom"); // → [2, 1, 3, 4]; ch3 and ch4 keep their slots
|
|
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
const touched = reorders.map((r) => r.channelId).sort((a, b) => a - b);
|
|
expect(touched).toEqual([1, 2]);
|
|
});
|
|
|
|
it("updates the channels store immediately (optimistic reorder)", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
channelsStore.setState(() => ({
|
|
channels: new Map([
|
|
[1, makeCh(1, 0)],
|
|
[2, makeCh(2, 1)],
|
|
]),
|
|
activeChannelId: null,
|
|
roles: [],
|
|
}));
|
|
|
|
drag(rig, 1, 1, "bottom"); // → [2, 1]
|
|
|
|
expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
|
|
expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
|
|
});
|
|
|
|
it("reassigns the group's own position slots, not a 0-based range, for a category with a non-zero global offset", () => {
|
|
// This category's channels sit at global positions 5, 7, 9 (interleaved
|
|
// with another category's channels at 0/1/2/etc in the server's single
|
|
// global position space). A within-category drag must preserve that
|
|
// range -- renumbering to 0..n-1 would stomp the other category's slots.
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 5), makeCh(2, 7), makeCh(3, 9)]);
|
|
|
|
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
|
|
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
expect(positionsOf(reorders)).toEqual({ 3: 5, 1: 7, 2: 9 });
|
|
});
|
|
|
|
it("still reorders when every channel in the group shares one position", () => {
|
|
// The server does not enforce unique positions, and newly created
|
|
// channels commonly all sit at position 0. Reassigning the group's own
|
|
// slots must still produce distinct positions, or the drop is a no-op.
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 0)]);
|
|
|
|
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
|
|
|
|
expect(rig.onReorder).toHaveBeenCalledTimes(1);
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
// ch3 keeps position 0 (unchanged, so unreported); ch1 and ch2 move up.
|
|
expect(positionsOf(reorders)).toEqual({ 1: 1, 2: 2 });
|
|
});
|
|
|
|
it("gives partially tied positions a deterministic order after the drop", () => {
|
|
signIn("owner");
|
|
// Two channels tied at 0, one at 5.
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 5)]);
|
|
|
|
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
|
|
|
|
expect(rig.onReorder).toHaveBeenCalledTimes(1);
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
// Slots [0, 0, 5] become the strictly increasing [0, 1, 5]: every channel
|
|
// ends at a distinct position, so the rendered order matches the drop.
|
|
expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 5 });
|
|
});
|
|
|
|
it("does not fire when dropped on itself", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
|
|
drag(rig, 1, 0, "bottom"); // row 0 is ch1 itself
|
|
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not fire when dropped outside any row", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const el = rig.items.get(1);
|
|
|
|
el?.dispatchEvent(mouse("mousedown", 0, 4));
|
|
el?.dispatchEvent(mouse("mousemove", 0, 30));
|
|
document.dispatchEvent(mouse("mouseup", 0, 9999)); // below every row
|
|
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not fire when the drag lands back in its original slot", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
|
|
// ch1 dropped on the top half of ch2 → insert before ch2 → [1, 2, 3],
|
|
// which is the order it already had.
|
|
drag(rig, 1, 1, "top");
|
|
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears visual state on drop", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
|
|
drag(rig, 1, 1, "bottom");
|
|
|
|
expect(rig.items.get(1)?.classList.contains("dragging")).toBe(false);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── drop indicator ─────────────────────────────────────────────────────────
|
|
|
|
describe("drop indicator", () => {
|
|
it("marks the hovered row and never the dragged row", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
const source = rig.items.get(1);
|
|
|
|
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
|
|
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
|
|
|
|
// Hovering the dragged row itself must not show a drop target.
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top")));
|
|
expect(rig.items.get(1)?.classList.contains("channel-drop-indicator")).toBe(false);
|
|
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(false);
|
|
});
|
|
|
|
it("moves the indicator as the cursor moves between rows", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
|
|
const source = rig.items.get(1);
|
|
|
|
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
|
|
expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(true);
|
|
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
|
|
expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(false);
|
|
expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
|
|
});
|
|
|
|
it("global handlers no-op when no drag is active", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top")));
|
|
|
|
expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── mid-drag re-render ─────────────────────────────────────────────────────
|
|
//
|
|
// ChannelSidebar.renderChannels() clears the channel list and rebuilds every
|
|
// category group, so any store-driven re-render while the mouse button is
|
|
// down detaches the container the drag captured at mousedown. Detached rows
|
|
// report all-zero rects, so the drag must re-resolve the live rows or every
|
|
// hit-test silently fails.
|
|
|
|
describe("mid-drag sidebar re-render", () => {
|
|
function setStoreChannels(channels: Channel[]): void {
|
|
channelsStore.setState(() => ({
|
|
channels: new Map(channels.map((c) => [c.id, c])),
|
|
activeChannelId: null,
|
|
roles: [],
|
|
}));
|
|
}
|
|
|
|
it("resolves the drop against the live rows after a re-render replaces the container", () => {
|
|
signIn("owner");
|
|
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
|
|
setStoreChannels(channels);
|
|
const rig = buildRig(channels);
|
|
const source = rig.items.get(1)!;
|
|
|
|
// Start the drag on the original rows.
|
|
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
expect(source.classList.contains("dragging")).toBe(true);
|
|
|
|
// A store-driven re-render rebuilds the sidebar mid-drag.
|
|
rig.container.remove();
|
|
const live = rebuildContainer(rig, channels);
|
|
|
|
// Release over the bottom half of row 1 (ch2): ch1 lands after ch2.
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));
|
|
|
|
expect(rig.onReorder).toHaveBeenCalledTimes(1);
|
|
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
|
|
expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
|
|
expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
|
|
expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
|
|
expect(live.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
});
|
|
|
|
it("moves the drop indicator to the live rows after a re-render replaces the container", () => {
|
|
signIn("owner");
|
|
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
|
|
setStoreChannels(channels);
|
|
const rig = buildRig(channels);
|
|
const source = rig.items.get(1)!;
|
|
|
|
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
|
|
rig.container.remove();
|
|
const live = rebuildContainer(rig, channels);
|
|
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
|
|
|
|
expect(live.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
|
|
});
|
|
|
|
it("discards the drop when the dragged channel has no live row after the re-render", () => {
|
|
signIn("owner");
|
|
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
|
|
setStoreChannels(channels);
|
|
const rig = buildRig(channels);
|
|
const source = rig.items.get(1)!;
|
|
|
|
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
|
|
// The re-render drops ch1 entirely (deleted mid-drag).
|
|
rig.container.remove();
|
|
rebuildContainer(rig, [makeCh(2, 1), makeCh(3, 2)]);
|
|
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));
|
|
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── listener lifecycle ─────────────────────────────────────────────────────
|
|
//
|
|
// Ownership of the shared document listeners is per sidebar AbortSignal, not
|
|
// per attached row. This block used to pin the old per-row ref-count's
|
|
// asymmetry as a KNOWN BUG ("N rows take N refs, destroy returns 1, the
|
|
// listeners live forever"); that bug is fixed, so the block now pins the
|
|
// fixed contract: one owner registration no matter how many rows attach, and
|
|
// the owner's abort is the release.
|
|
|
|
describe("global listener ownership", () => {
|
|
it("attaching many rows under one owner is a single registration; one abort tears down", () => {
|
|
signIn("owner");
|
|
// 2 channels, and a re-attach of the same rows (a sidebar re-render) —
|
|
// under the old ref-count this took 4 refs a single destroy never repaid.
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
for (const [id, el] of rig.items) {
|
|
const ch = rig.channels.find((c) => c.id === id)!;
|
|
attachDragHandlers(el, ch, rig.container, rig.channels, rig.abort.signal, rig.onReorder);
|
|
}
|
|
|
|
rig.abort.abort();
|
|
|
|
// The single owner is gone → listeners torn down; a drag no longer works.
|
|
// (The rig's own element listeners share the aborted signal, so drive the
|
|
// document handlers directly to prove they are dead.)
|
|
document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
|
|
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top")));
|
|
expect(rig.onReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the listeners alive while another sidebar still owns them", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const other = new AbortController(); // a second sidebar
|
|
ensureGlobalDragListeners(other.signal);
|
|
|
|
other.abort(); // the second sidebar goes away
|
|
|
|
// The first sidebar still owns the listeners, so its drag must work.
|
|
drag(rig, 1, 1, "bottom");
|
|
expect(rig.onReorder).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-registration after full teardown works (a new sidebar after all closed)", () => {
|
|
signIn("owner");
|
|
const first = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
first.abort.abort(); // teardown
|
|
|
|
const second = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
drag(second, 1, 1, "bottom");
|
|
expect(second.onReorder).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("aborting the owner mid-drag clears the in-flight visual state", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const source = rig.items.get(1);
|
|
|
|
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
expect(source?.classList.contains("dragging")).toBe(true);
|
|
|
|
rig.abort.abort();
|
|
|
|
// A sidebar destroyed mid-drag must not leave the row stuck in the
|
|
// dragging state or the body stuck in reorder mode.
|
|
expect(source?.classList.contains("dragging")).toBe(false);
|
|
expect(document.body.classList.contains("channel-reordering")).toBe(false);
|
|
});
|
|
|
|
it("leaves a drag owned by a different sidebar alone", () => {
|
|
signIn("owner");
|
|
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
|
|
const source = rig.items.get(1);
|
|
const other = new AbortController();
|
|
ensureGlobalDragListeners(other.signal);
|
|
|
|
source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
|
|
source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
|
|
|
|
other.abort();
|
|
|
|
expect(source?.classList.contains("dragging")).toBe(true);
|
|
});
|
|
|
|
it("an already-aborted owner is refused (no dead registration, abort is safe to repeat)", () => {
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
expect(() => {
|
|
ensureGlobalDragListeners(ac.signal);
|
|
ac.abort();
|
|
ac.abort();
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
/** Collapses reorder data into a channelId → newPosition map. */
|
|
function positionsOf(reorders: readonly ChannelReorderData[]): Record<number, number> {
|
|
const out: Record<number, number> = {};
|
|
for (const r of reorders) out[r.channelId] = r.newPosition;
|
|
return out;
|
|
}
|