Files
Netcatty/components/TopTabs.test.ts
2026-06-17 23:32:36 +08:00

343 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 0) as unknown as number,
});
const {
computeHostTreeTabGutter,
resolveWorkspaceSessionTabDropTarget,
shouldKeepHostTreeToggleSurface,
shouldShowHostTreeToggle,
} = await import("./TopTabs.tsx");
const {
WORKSPACE_SESSION_DRAG_TYPE,
dataTransferHasType,
getTopTabInsertionTarget,
getWorkspaceSessionDragId,
hasWorkspaceSessionDrag,
isPointInsideRect,
} = await import("../application/state/terminalDragData.ts");
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
assert.equal(computeHostTreeTabGutter(280, 120), 160);
});
test("host tree tab gutter never goes negative", () => {
assert.equal(computeHostTreeTabGutter(120, 280), 0);
});
test("host tree tab surface stays mounted when root pages are active", () => {
assert.equal(shouldKeepHostTreeToggleSurface({
enabled: true,
activeWorkTabCount: 2,
}), true);
});
test("host tree tab surface is hidden without work tabs", () => {
assert.equal(shouldKeepHostTreeToggleSurface({
enabled: true,
activeWorkTabCount: 0,
}), false);
});
test("host tree tab layout transitions match the sidebar timing", () => {
const hostTreeCss = [
".top-tab-root-label",
".top-tab-host-tree-toggle-slot",
].map((selector) => {
const start = indexCss.indexOf(selector);
assert.notEqual(start, -1);
const end = indexCss.indexOf("}", start);
return indexCss.slice(start, end);
}).join("\n");
const gutterStart = indexCss.indexOf(".top-tab-host-tree-gutter");
assert.notEqual(gutterStart, -1);
const gutterEnd = indexCss.indexOf("}", gutterStart);
const gutterCss = indexCss.slice(gutterStart, gutterEnd);
assert.match(hostTreeCss, /width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
assert.match(hostTreeCss, /max-width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
assert.doesNotMatch(hostTreeCss, /transition:\s*none/);
assert.doesNotMatch(hostTreeCss, /280ms/);
assert.doesNotMatch(gutterCss, /transition/);
assert.match(indexCss, /\.top-tab-host-tree-gutter-exit[\s\S]*transition: width 220ms/);
});
test("host tree toggle appears with opacity only and no bounce animation", () => {
assert.doesNotMatch(indexCss, /top-tab-host-tree-toggle-pop/);
assert.doesNotMatch(indexCss, /@keyframes\s+pop-in/);
const start = indexCss.indexOf(".top-tab-host-tree-toggle-slot");
assert.notEqual(start, -1);
const end = indexCss.indexOf("}", start);
const toggleSlotCss = indexCss.slice(start, end);
assert.match(toggleSlotCss, /opacity 220ms ease/);
assert.doesNotMatch(toggleSlotCss, /transform/);
assert.doesNotMatch(toggleSlotCss, /scale/);
});
test("host tree toggle exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-host-tree-toggle"/);
});
test("quick switcher plus button exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
test("workspace session drag data is recognized with a dedicated drag type", () => {
const data = new Map([
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
["session-id", "fallback-session"],
]);
const transfer = {
types: [WORKSPACE_SESSION_DRAG_TYPE, "text/plain"],
getData: (format: string) => data.get(format) ?? "",
};
assert.equal(hasWorkspaceSessionDrag(transfer), true);
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
getData: (format: string) => (format === "session-id" ? "session-2" : ""),
};
assert.equal(dataTransferHasType(transfer, "session-id"), true);
assert.equal(hasWorkspaceSessionDrag(transfer), false);
assert.equal(getWorkspaceSessionDragId(transfer), "session-2");
});
test("point-in-rect detects pointer release inside the top tab bar", () => {
const rect = { left: 10, right: 110, top: 20, bottom: 60 };
assert.equal(isPointInsideRect({ clientX: 10, clientY: 20 }, rect), true);
assert.equal(isPointInsideRect({ clientX: 70, clientY: 40 }, rect), true);
assert.equal(isPointInsideRect({ clientX: 111, clientY: 40 }, rect), false);
assert.equal(isPointInsideRect({ clientX: 70, clientY: 61 }, rect), false);
});
test("top tab insertion target ignores fixed root tabs", () => {
const makeTab = (id: string, type: string, left: number, right: number) => ({
dataset: { tabId: id, tabType: type },
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
});
const root = {
getBoundingClientRect: () => ({ left: 0, right: 400, top: 0, bottom: 80, width: 400, height: 80 }),
querySelectorAll: () => [
makeTab("vault", "root", 0, 80),
makeTab("workspace-1", "workspace", 90, 210),
makeTab("session-1", "session", 210, 330),
],
} as unknown as HTMLElement;
assert.deepEqual(getTopTabInsertionTarget({ clientX: 20, clientY: 40 }, root), {
tabId: "workspace-1",
position: "before",
});
assert.deepEqual(getTopTabInsertionTarget({ clientX: 180, clientY: 40 }, root), {
tabId: "workspace-1",
position: "after",
});
assert.deepEqual(getTopTabInsertionTarget({ clientX: 380, clientY: 40 }, root), {
tabId: "session-1",
position: "after",
});
assert.equal(getTopTabInsertionTarget({ clientX: 180, clientY: 120 }, root), null);
});
test("workspace session tab drop forwards the requested insertion target", () => {
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: "session-3",
position: "after",
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [],
}), {
tabId: "session-3",
position: "after",
additionalTabIds: ["session-1", "session-3"],
});
});
test("workspace session tab drop targets the remaining terminal when its workspace dissolves", () => {
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: "workspace-1",
position: "before",
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [{
id: "workspace-1",
title: "Workspace",
focusedSessionId: "session-1",
root: {
id: "split-1",
type: "split",
direction: "horizontal",
children: [
{ id: "pane-1", type: "pane", sessionId: "session-1" },
{ id: "pane-2", type: "pane", sessionId: "session-2" },
],
sizes: [1, 1],
},
}],
}), {
tabId: "session-2",
position: "before",
additionalTabIds: ["session-1", "session-2"],
});
});
test("workspace session tab-bar blank drop inserts after the last work tab", () => {
const makeTab = (id: string, type: string, left: number, right: number) => ({
dataset: { tabId: id, tabType: type },
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
});
const root = {
getBoundingClientRect: () => ({ left: 0, right: 500, top: 0, bottom: 80, width: 500, height: 80 }),
querySelectorAll: () => [
makeTab("vault", "root", 0, 80),
makeTab("workspace-1", "workspace", 90, 210),
makeTab("session-3", "session", 210, 330),
],
} as unknown as HTMLElement;
const insertionTarget = getTopTabInsertionTarget({ clientX: 460, clientY: 40 }, root);
assert.deepEqual(insertionTarget, { tabId: "session-3", position: "after" });
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: insertionTarget!.tabId,
position: insertionTarget!.position,
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [],
}), {
tabId: "session-3",
position: "after",
additionalTabIds: ["session-1", "session-3"],
});
});
test("terminal top bar hides server stats before they crowd the host title", () => {
assert.match(indexCss, /\.terminal-topbar\s*\{[\s\S]*container-type: inline-size/);
assert.match(indexCss, /@container \(max-width: 760px\) \{[\s\S]*\.terminal-server-stats\s*\{[\s\S]*display: none/);
assert.match(terminalViewSource, /terminal-topbar/);
assert.match(terminalViewSource, /terminal-title-cluster/);
assert.match(terminalViewSource, /onPointerDown=\{onDetachPointerDown\}/);
});
test("workspace session drag no longer uses a full tab-bar drop zone", () => {
assert.doesNotMatch(topTabsSource, /top-tabs-workspace-detach-drop-zone/);
});
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
assert.match(topTabsSource, /hostTreeChromeReady/);
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
assert.match(topTabsSource, /scheduleChromeLayoutAnimation\(\(\) => \{\s*cancelRootTabsCompactRef\.current = null;\s*setRootTabsCompact\(true\);/);
assert.match(topTabsSource, /compact=\{rootTabsCompact\}/);
assert.match(topTabsSource, /data-visible=\{effectiveShowHostTreeToggle \? 'true' : 'false'\}/);
});
test("host tree chrome exits before root labels expand back on vault", () => {
assert.match(topTabsSource, /cancelChromeExitRef/);
assert.match(topTabsSource, /hostTreeGutterExiting/);
assert.match(topTabsSource, /setRootTabsCompact\(false\)/);
assert.match(topTabsSource, /top-tab-host-tree-gutter-exit/);
assert.match(topTabsSource, /effectiveShowHostTreeToggle = hostTreeChromeReady/);
});
test("host tree toggle is shown for an active editor tab", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "editor:file-1",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), true);
});
test("host tree toggle is shown for log tabs", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "log-1",
logViewIds: new Set(["log-1"]),
orderedTabs: ["session-1", "log-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), true);
});
test("host tree toggle is shown for log tabs before tab ordering catches up", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "log-1",
logViewIds: new Set(["log-1"]),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test("clicking a log tab activates the shared work-tab surface", () => {
activeTabStore.setActiveTabId("vault");
activateLogViewTab("log-1");
assert.equal(activeTabStore.getActiveTabId(), "log-1");
});
test("host tree toggle is hidden when host sidebar is disabled", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: false,
activeTabId: "session-1",
orderedTabs: ["session-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
});
test("host tree toggle is hidden on root pages", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "vault",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "sftp",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
});