Files
OwnCord/Client/tauri-client/tests/unit/drag-reorder.test.ts
T
J3vbandClaude 9df0e63b5f build: toolchain and dependency upgrades (TypeScript 6, Node 24 CI, Vite 8, Vitest 4, Go/Rust deps) (#1401)
* 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>
2026-08-22 06:48:58 +02:00

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