summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/tabs/ui.test.ts')
-rw-r--r--src/features/tabs/ui.test.ts495
1 files changed, 290 insertions, 205 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 6cd66bd..ae2a021 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,213 +1,298 @@
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
-import TabBar from "./ui/TabBar.svelte";
+import TabList from "./ui/TabList.svelte";
const sampleTabs: readonly Tab[] = [
- { conversationId: "c1", model: "openai/gpt-4", title: "First" },
- { conversationId: "c2", model: "anthropic/claude-3", title: "Second" },
- { conversationId: "c3", model: "google/gemini", title: "Third" },
+ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
+ { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
+ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
-describe("TabBar", () => {
- it("renders one role=tab element per tab showing each title", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs).toHaveLength(sampleTabs.length);
- expect(tabs[0]).toHaveTextContent("First");
- expect(tabs[1]).toHaveTextContent("Second");
- expect(tabs[2]).toHaveTextContent("Third");
- });
-
- it("applies tab-active to the active tab only", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c2",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs[0]).not.toHaveClass("tab-active");
- expect(tabs[1]).toHaveClass("tab-active");
- expect(tabs[2]).not.toHaveClass("tab-active");
- });
-
- it("applies tab-active to New chat button when activeConversationId is null", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: null,
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("tab-active");
- });
-
- it("calls onSelect with the conversationId when a tab is clicked", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- const secondTab = tabs[1];
- if (!secondTab) throw new Error("second tab not found");
- await user.click(secondTab);
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("c2");
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("calls onClose when the close button is clicked and does not call onSelect", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
- const firstClose = closeButtons[0];
- if (!firstClose) throw new Error("first close button not found");
- await user.click(firstClose);
-
- expect(onClose).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledWith("c1");
- expect(onSelect).not.toHaveBeenCalled();
- });
-
- it("calls onNewDraft when the New chat button is clicked", async () => {
- const onNewDraft = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft,
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- await user.click(newChat);
-
- expect(onNewDraft).toHaveBeenCalledTimes(1);
- });
-
- it("the New chat button has the sticky class", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("sticky");
- });
-
- it("shows visible 'New Chat' text when activeConversationId is null", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: null,
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveTextContent("New Chat");
- });
-
- it("does not show 'New Chat' text when a real tab is active", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).not.toHaveTextContent("New Chat");
- });
-
- it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
- const tabs: readonly Tab[] = [
- { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha" },
- { conversationId: "7c2db4e5-2222", model: "m", title: "Beta" },
- ];
- render(TabBar, {
- props: {
- tabs,
- activeConversationId: "3f9a1b2c-1111",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- expect(screen.getByText("3f9a")).toBeInTheDocument();
- expect(screen.getByText("7c2d")).toBeInTheDocument();
- });
-
- it("renders fixed-width tabs", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- for (const t of screen.getAllByRole("tab")) {
- expect(t).toHaveClass("w-48");
- }
- });
+describe("TabList", () => {
+ it("renders one role=tab element per tab showing each title", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(sampleTabs.length);
+ expect(tabs[0]).toHaveTextContent("First");
+ expect(tabs[1]).toHaveTextContent("Second");
+ expect(tabs[2]).toHaveTextContent("Third");
+ });
+
+ it("marks the active tab as aria-selected", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c2",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs[0]).toHaveAttribute("aria-selected", "false");
+ expect(tabs[1]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[2]).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("calls onSelect with the conversationId when a tab is clicked", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const secondTab = tabs[1];
+ if (!secondTab) throw new Error("second tab not found");
+ await user.click(secondTab);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("c2");
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose when the close button is clicked and does not call onSelect", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
+ const firstClose = closeButtons[0];
+ if (!firstClose) throw new Error("first close button not found");
+ await user.click(firstClose);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("c1");
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("calls onNewDraft when the New chat button is clicked", async () => {
+ const onNewDraft = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft,
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ await user.click(newChat);
+
+ expect(onNewDraft).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows visible 'New Chat' text when activeConversationId is null", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: null,
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveTextContent("New Chat");
+ });
+
+ it("does not show 'New Chat' text when a real tab is active", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).not.toHaveTextContent("New Chat");
+ });
+
+ it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
+ const tabs: readonly Tab[] = [
+ { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
+ { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
+ ];
+ render(TabList, {
+ props: {
+ tabs,
+ activeConversationId: "3f9a1b2c-1111",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ expect(screen.getByText("3f9a")).toBeInTheDocument();
+ expect(screen.getByText("7c2d")).toBeInTheDocument();
+ });
+
+ it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ // The scroll region containing the tab rows is a vertical flex column.
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.length).toBeGreaterThan(0);
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("flex-col");
+ });
+
+ it("fixes the tab list region at 40vh so a long set scrolls internally", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("h-[40vh]");
+ expect(region).toHaveClass("overflow-y-auto");
+ });
+
+ it("calls onRename when a tab title is double-clicked and committed with Enter", async () => {
+ const onRename = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onRename,
+ },
+ });
+
+ const titleButtons = screen.getAllByRole("button");
+ // The inline-rename trigger is the title span (role=button) — find the one
+ // whose text matches the first tab's title.
+ const titleButton = titleButtons.find((b) => b.textContent === "First");
+ if (!titleButton) throw new Error("title button not found");
+ await user.dblClick(titleButton);
+
+ const input = screen.getByRole("textbox");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(onRename).toHaveBeenCalledTimes(1);
+ expect(onRename).toHaveBeenCalledWith("c1", "Renamed");
+ });
+
+ it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ const onSelect = vi.fn();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" });
+ await fireEvent.click(idBadge);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText).toHaveBeenCalledWith("c1");
+ // The badge text is NOT swapped (no width shift) — the highlight (selection)
+ // is the only indicator.
+ expect(idBadge).toHaveTextContent("c1");
+ expect(idBadge).not.toHaveTextContent("Copied");
+ // The badge text is selected (highlighted) as the copy indicator.
+ const selection = window.getSelection();
+ expect(selection?.toString()).toBe("c1");
+ // Clicking the ID must NOT switch tabs (the badge stops propagation).
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("shows a loading RING for a 'queued' tab and loading DOTS for an 'active' tab", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ statusFor: (id: string) => (id === "c1" ? "queued" : id === "c2" ? "active" : undefined),
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ // c1 is queued → a ring (DaisyUI `loading-ring`), labeled "Queued".
+ const queuedRing = screen.getByLabelText("Queued");
+ expect(queuedRing.className).toContain("loading-ring");
+ expect(queuedRing.closest('[role="tab"]')).toHaveTextContent("First");
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(3);
+ const activeTab = tabs[1];
+ const idleTab = tabs[2];
+ if (activeTab === undefined || idleTab === undefined) throw new Error("missing tabs");
+
+ // c2 is active → loading dots (NOT a ring).
+ expect(activeTab.querySelector(".loading-dots")).not.toBeNull();
+ expect(activeTab.querySelector(".loading-ring")).toBeNull();
+
+ // c3 has no status → no spinner at all.
+ expect(idleTab.querySelector(".loading")).toBeNull();
+ });
});