|
| 1 | +// @vitest-environment jsdom |
| 2 | +// The chat panel's icon-only controls name themselves in the shared tooltip, not in a native |
| 3 | +// `title`. Rewind and Compact say what they do, Send shows Enter as a chip, and a button that is |
| 4 | +// greyed for now (Send with no provider, Compact with no conversation) still opens its tooltip: |
| 5 | +// that is where "Set up a provider" is said, and a natively disabled button takes no pointer |
| 6 | +// events, so it uses `aria-disabled`. |
| 7 | + |
| 8 | +import "@testing-library/jest-dom"; |
| 9 | +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
| 10 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | + |
| 12 | +const snapshot = vi.hoisted(() => ({ |
| 13 | + value: { |
| 14 | + config: null as { provider: string; model: string } | null, |
| 15 | + connectedProviders: [] as string[], |
| 16 | + availableProviders: [] as unknown[], |
| 17 | + credentialSummary: [] as unknown[], |
| 18 | + }, |
| 19 | +})); |
| 20 | +const sessions = vi.hoisted(() => ({ list: [] as unknown[] })); |
| 21 | +const chatCompact = vi.hoisted(() => vi.fn()); |
| 22 | + |
| 23 | +vi.mock("@/native/client", () => ({ |
| 24 | + nativeBridgeClient: { |
| 25 | + aiEdition: { |
| 26 | + llmGetSnapshot: () => Promise.resolve(snapshot.value), |
| 27 | + chatListSessions: () => Promise.resolve(sessions.list), |
| 28 | + chatSelectSession: () => |
| 29 | + Promise.resolve({ |
| 30 | + id: "session-1", |
| 31 | + projectId: "project-1", |
| 32 | + title: "Conversation 1", |
| 33 | + createdAt: "2026-09-23T00:00:00Z", |
| 34 | + updatedAt: "2026-09-23T00:00:00Z", |
| 35 | + messages: [ |
| 36 | + { |
| 37 | + id: "m1", |
| 38 | + role: "user", |
| 39 | + content: "Cut the silences", |
| 40 | + checkpointId: "cp1", |
| 41 | + createdAt: "2026-09-23T00:00:01Z", |
| 42 | + }, |
| 43 | + { |
| 44 | + id: "m2", |
| 45 | + role: "assistant", |
| 46 | + content: "Done", |
| 47 | + createdAt: "2026-09-23T00:00:02Z", |
| 48 | + }, |
| 49 | + ], |
| 50 | + }), |
| 51 | + chatBudget: () => Promise.resolve(null), |
| 52 | + chatCompact, |
| 53 | + llmListProviderModels: () => Promise.resolve({ models: [] }), |
| 54 | + }, |
| 55 | + }, |
| 56 | +})); |
| 57 | + |
| 58 | +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); |
| 59 | + |
| 60 | +// An echoing translator: the assertions read against keys, not prose that drifts with copy edits. |
| 61 | +vi.mock("@/contexts/I18nContext", () => ({ |
| 62 | + useI18n: () => ({ locale: "en", setLocale: vi.fn() }), |
| 63 | + useScopedT: () => (key: string) => key, |
| 64 | +})); |
| 65 | + |
| 66 | +import { TooltipProvider } from "@/components/ui/tooltip"; |
| 67 | +import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext"; |
| 68 | +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; |
| 69 | +import { ChatStripPanel } from "./LeftPanel"; |
| 70 | + |
| 71 | +class StubResizeObserver { |
| 72 | + observe = vi.fn(); |
| 73 | + unobserve = vi.fn(); |
| 74 | + disconnect = vi.fn(); |
| 75 | +} |
| 76 | + |
| 77 | +const oneSession = [ |
| 78 | + { id: "session-1", title: "Conversation 1", messageCount: 2, createdAt: "2026-09-23T00:00:00Z" }, |
| 79 | +]; |
| 80 | + |
| 81 | +beforeEach(() => { |
| 82 | + vi.stubGlobal("ResizeObserver", StubResizeObserver); |
| 83 | + // jsdom implements no scrolling, and the transcript pins itself to the bottom on every render. |
| 84 | + Element.prototype.scrollTo = vi.fn(); |
| 85 | + (window as unknown as { electronAPI?: unknown }).electronAPI = { |
| 86 | + onAiEditionChatEvent: () => vi.fn(), |
| 87 | + }; |
| 88 | + useProjectStore.setState({ projectId: "project-1" }); |
| 89 | + snapshot.value = { |
| 90 | + config: null, |
| 91 | + connectedProviders: [], |
| 92 | + availableProviders: [], |
| 93 | + credentialSummary: [], |
| 94 | + }; |
| 95 | + sessions.list = []; |
| 96 | + chatCompact.mockClear(); |
| 97 | +}); |
| 98 | + |
| 99 | +afterEach(() => { |
| 100 | + cleanup(); |
| 101 | + vi.unstubAllGlobals(); |
| 102 | + (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; |
| 103 | +}); |
| 104 | + |
| 105 | +function renderPanel() { |
| 106 | + return render( |
| 107 | + <TooltipProvider> |
| 108 | + <EditorDialogsProvider> |
| 109 | + <ChatStripPanel /> |
| 110 | + </EditorDialogsProvider> |
| 111 | + </TooltipProvider>, |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +/** Opens a control's tooltip the way the keyboard does (focus opens it at once) and returns what |
| 116 | + * it says and whether it shows a shortcut chip. */ |
| 117 | +async function tooltipOn(control: HTMLElement) { |
| 118 | + act(() => control.focus()); |
| 119 | + const text = (await screen.findByRole("tooltip")).textContent; |
| 120 | + const chip = document.querySelector('[data-slot="tooltip-content"] kbd')?.textContent ?? null; |
| 121 | + act(() => control.blur()); |
| 122 | + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); |
| 123 | + return { text, chip }; |
| 124 | +} |
| 125 | + |
| 126 | +describe("ChatStripPanel tooltips", () => { |
| 127 | + it("names the header buttons in the shared tooltip, with no native title", async () => { |
| 128 | + sessions.list = oneSession; |
| 129 | + renderPanel(); |
| 130 | + await screen.findByRole("button", { name: "chat.deleteConversation" }); |
| 131 | + |
| 132 | + for (const name of [ |
| 133 | + "chat.compactContext", |
| 134 | + "chat.aiSettings", |
| 135 | + "chat.history", |
| 136 | + "chat.newConversation", |
| 137 | + "chat.renameConversation", |
| 138 | + "chat.deleteConversation", |
| 139 | + ]) { |
| 140 | + const button = screen.getByRole("button", { name }); |
| 141 | + expect(button).not.toHaveAttribute("title"); |
| 142 | + expect((await tooltipOn(button)).text).toBe(name); |
| 143 | + } |
| 144 | + }); |
| 145 | + |
| 146 | + // Plain text has no interactive element to hold a tooltip, so its hint stays native. |
| 147 | + it("keeps the native title only on plain text", async () => { |
| 148 | + sessions.list = oneSession; |
| 149 | + renderPanel(); |
| 150 | + expect(await screen.findByTitle("chat.clickToRename")).toBeInTheDocument(); |
| 151 | + }); |
| 152 | + |
| 153 | + it("says what Rewind does, not only what the button is called", async () => { |
| 154 | + sessions.list = oneSession; |
| 155 | + renderPanel(); |
| 156 | + const rewind = await screen.findByRole("button", { name: "chat.rewindToMessage" }); |
| 157 | + expect(rewind).not.toHaveAttribute("title"); |
| 158 | + expect((await tooltipOn(rewind)).text).toBe("chat.rewindTip"); |
| 159 | + }); |
| 160 | + |
| 161 | + it("names Copy in the shared tooltip too", async () => { |
| 162 | + sessions.list = oneSession; |
| 163 | + renderPanel(); |
| 164 | + const [copy] = await screen.findAllByRole("button", { name: "chat.copyMessage" }); |
| 165 | + expect(copy).not.toHaveAttribute("title"); |
| 166 | + expect((await tooltipOn(copy)).text).toBe("chat.copyMessage"); |
| 167 | + }); |
| 168 | + |
| 169 | + // Nothing to talk to yet: Send is greyed, but it is where the reason is said. |
| 170 | + it("says why Send is off when no provider is set up, and shows no chip then", async () => { |
| 171 | + renderPanel(); |
| 172 | + const send = await screen.findByRole("button", { name: "chat.send" }); |
| 173 | + await waitFor(() => expect(send).toHaveAttribute("aria-disabled", "true")); |
| 174 | + expect(send).not.toBeDisabled(); |
| 175 | + expect(send).not.toHaveAttribute("title"); |
| 176 | + const tip = await tooltipOn(send); |
| 177 | + expect(tip.text).toBe("chat.composerDisabledNoProvider"); |
| 178 | + expect(tip.chip).toBeNull(); |
| 179 | + }); |
| 180 | + |
| 181 | + it("shows Enter as a chip on Send once it can send, never inside the string", async () => { |
| 182 | + snapshot.value = { |
| 183 | + config: { provider: "openai", model: "gpt-x" }, |
| 184 | + connectedProviders: ["openai"], |
| 185 | + availableProviders: [], |
| 186 | + credentialSummary: [], |
| 187 | + }; |
| 188 | + renderPanel(); |
| 189 | + fireEvent.change(await screen.findByPlaceholderText("chat.composerPlaceholder"), { |
| 190 | + target: { value: "Cut the silences" }, |
| 191 | + }); |
| 192 | + const send = screen.getByRole("button", { name: "chat.send" }); |
| 193 | + await waitFor(() => expect(send).not.toHaveAttribute("aria-disabled")); |
| 194 | + const tip = await tooltipOn(send); |
| 195 | + expect(tip.text).toBe("chat.sendEnter"); |
| 196 | + expect(tip.chip).toBe("Enter"); |
| 197 | + }); |
| 198 | + |
| 199 | + it("keeps Compact tooltip-able while there is no conversation, and does nothing", async () => { |
| 200 | + renderPanel(); |
| 201 | + const compact = await screen.findByRole("button", { name: "chat.compactContext" }); |
| 202 | + expect(compact).toHaveAttribute("aria-disabled", "true"); |
| 203 | + expect(compact).not.toBeDisabled(); |
| 204 | + expect((await tooltipOn(compact)).text).toBe("chat.compactContext"); |
| 205 | + fireEvent.click(compact); |
| 206 | + expect(chatCompact).not.toHaveBeenCalled(); |
| 207 | + }); |
| 208 | +}); |
0 commit comments