@tomehq/theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,565 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
3
+ import { render, screen, fireEvent, within, act } from "@testing-library/react";
4
+ import { Shell } from "./Shell.js";
5
+
6
+ // ── jsdom matchMedia mock ─────────────────────────────────
7
+ // jsdom does not implement matchMedia; provide a minimal stub.
8
+ beforeAll(() => {
9
+ Object.defineProperty(window, "matchMedia", {
10
+ writable: true,
11
+ value: vi.fn().mockImplementation((query: string) => ({
12
+ matches: false,
13
+ media: query,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ })),
17
+ });
18
+ });
19
+
20
+ // ── Shared fixtures ───────────────────────────────────────
21
+
22
+ const baseConfig = {
23
+ name: "Test Docs",
24
+ theme: { preset: "amber" as const, mode: "light" as const },
25
+ };
26
+
27
+ const navigation = [
28
+ {
29
+ section: "Getting Started",
30
+ pages: [
31
+ { id: "intro", title: "Introduction", urlPath: "/intro" },
32
+ { id: "quickstart", title: "Quick Start", urlPath: "/quickstart" },
33
+ ],
34
+ },
35
+ ];
36
+
37
+ const allPages = [
38
+ { id: "intro", title: "Introduction", description: "Intro page" },
39
+ { id: "quickstart", title: "Quick Start" },
40
+ ];
41
+
42
+ function renderShell(overrides: Partial<React.ComponentProps<typeof Shell>> = {}) {
43
+ return render(
44
+ <Shell
45
+ config={baseConfig}
46
+ navigation={navigation}
47
+ currentPageId="intro"
48
+ pageHtml="<p>Hello world</p>"
49
+ pageTitle="Introduction"
50
+ headings={[]}
51
+ onNavigate={vi.fn()}
52
+ allPages={allPages}
53
+ {...overrides}
54
+ />
55
+ );
56
+ }
57
+
58
+ // ── Rendering ─────────────────────────────────────────────
59
+
60
+ describe("Shell rendering", () => {
61
+ it("renders the site name in the sidebar", () => {
62
+ renderShell();
63
+ expect(screen.getAllByText("Test Docs").length).toBeGreaterThan(0);
64
+ });
65
+
66
+ it("renders the page title", () => {
67
+ renderShell();
68
+ expect(screen.getByRole("heading", { name: "Introduction" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("renders pageHtml content", () => {
72
+ renderShell({ pageHtml: "<p>Test content</p>" });
73
+ expect(screen.getByText("Test content")).toBeInTheDocument();
74
+ });
75
+
76
+ it("renders navigation section labels", () => {
77
+ renderShell();
78
+ // Section label may appear in sidebar nav button and/or breadcrumb
79
+ expect(screen.getAllByText("Getting Started").length).toBeGreaterThan(0);
80
+ });
81
+
82
+ it("renders navigation page links", () => {
83
+ renderShell();
84
+ // Titles appear in sidebar nav and/or prev-next and/or breadcrumb
85
+ expect(screen.getAllByText("Introduction").length).toBeGreaterThan(0);
86
+ expect(screen.getAllByText("Quick Start").length).toBeGreaterThan(0);
87
+ });
88
+
89
+ it("renders page description when provided", () => {
90
+ renderShell({ pageDescription: "This is the intro page" });
91
+ expect(screen.getByText("This is the intro page")).toBeInTheDocument();
92
+ });
93
+
94
+ it("does not render description when omitted", () => {
95
+ renderShell({ pageDescription: undefined });
96
+ // No second paragraph about intro
97
+ expect(screen.queryByText("This is the intro page")).not.toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ // ── MDX rendering ─────────────────────────────────────────
102
+
103
+ describe("Shell MDX rendering", () => {
104
+ it("renders a pageComponent instead of HTML when provided", () => {
105
+ const PageComp = () => <div>MDX rendered content</div>;
106
+ renderShell({ pageComponent: PageComp, pageHtml: undefined });
107
+ expect(screen.getByText("MDX rendered content")).toBeInTheDocument();
108
+ });
109
+
110
+ it("passes mdxComponents to the pageComponent", () => {
111
+ const Custom = () => <span>custom component</span>;
112
+ let receivedComponents: Record<string, unknown> | undefined;
113
+ const PageComp = ({ components }: { components?: Record<string, React.ComponentType> }) => {
114
+ receivedComponents = components;
115
+ return <div>Page</div>;
116
+ };
117
+ renderShell({ pageComponent: PageComp, mdxComponents: { Custom } });
118
+ expect(receivedComponents).toHaveProperty("Custom");
119
+ });
120
+ });
121
+
122
+ // ── Navigation ────────────────────────────────────────────
123
+
124
+ describe("Shell navigation", () => {
125
+ it("calls onNavigate when a nav page button is clicked", () => {
126
+ const onNavigate = vi.fn();
127
+ const { container } = renderShell({ onNavigate });
128
+ // Click the nav sidebar button (first instance of "Quick Start")
129
+ const navButtons = container.querySelectorAll("aside nav button");
130
+ const qsBtn = Array.from(navButtons).find((b) => b.textContent === "Quick Start");
131
+ expect(qsBtn).toBeTruthy();
132
+ fireEvent.click(qsBtn!);
133
+ expect(onNavigate).toHaveBeenCalledWith("quickstart");
134
+ });
135
+
136
+ it("renders Next button for first page", () => {
137
+ renderShell({ currentPageId: "intro" });
138
+ // "Quick Start" appears in nav and/or next-button
139
+ expect(screen.getAllByText("Quick Start").length).toBeGreaterThan(0);
140
+ });
141
+
142
+ it("renders Prev button for last page", () => {
143
+ renderShell({ currentPageId: "quickstart" });
144
+ // "Introduction" appears in nav and/or prev-button
145
+ expect(screen.getAllByText("Introduction").length).toBeGreaterThan(0);
146
+ });
147
+ });
148
+
149
+ // ── Sidebar toggle ────────────────────────────────────────
150
+
151
+ describe("Shell sidebar toggle", () => {
152
+ it("toggles sidebar visibility on menu button click", () => {
153
+ const { container } = renderShell();
154
+ const menuBtn = container.querySelector("header button") as HTMLButtonElement;
155
+ const sidebar = container.querySelector("aside") as HTMLElement;
156
+ const initialWidth = sidebar.style.width;
157
+ fireEvent.click(menuBtn);
158
+ expect(sidebar.style.width).not.toBe(initialWidth);
159
+ });
160
+ });
161
+
162
+ // ── Theme mode ────────────────────────────────────────────
163
+
164
+ describe("Shell theme mode", () => {
165
+ it("does NOT render dark mode toggle when mode is 'light'", () => {
166
+ const { container } = renderShell({
167
+ config: { ...baseConfig, theme: { preset: "amber", mode: "light" } },
168
+ });
169
+ // Toggle button only present in "auto" mode — in light mode the placeholder div is rendered
170
+ // We check no moon/sun SVG icons are in the toggle slot
171
+ const footer = container.querySelector("aside > div:last-child");
172
+ expect(footer?.querySelectorAll("button")).toHaveLength(0);
173
+ });
174
+
175
+ it("renders dark mode toggle when mode is 'auto'", () => {
176
+ const { container } = renderShell({
177
+ config: { ...baseConfig, theme: { preset: "amber", mode: "auto" } },
178
+ });
179
+ const footer = container.querySelector("aside > div:last-child");
180
+ const buttons = footer?.querySelectorAll("button");
181
+ expect(buttons?.length).toBeGreaterThan(0);
182
+ });
183
+ });
184
+
185
+ // ── TOC ───────────────────────────────────────────────────
186
+
187
+ describe("Shell table of contents", () => {
188
+ it("renders TOC headings when headings are provided", () => {
189
+ // jsdom window.innerWidth defaults to 0 so 'wide' will be false — we need to set it
190
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1400 });
191
+ fireEvent(window, new Event("resize"));
192
+
193
+ renderShell({
194
+ headings: [
195
+ { depth: 2, text: "Overview", id: "overview" },
196
+ { depth: 3, text: "Details", id: "details" },
197
+ ],
198
+ });
199
+ expect(screen.getByText("Overview")).toBeInTheDocument();
200
+ expect(screen.getByText("Details")).toBeInTheDocument();
201
+ });
202
+ });
203
+
204
+ // ── Accent override ───────────────────────────────────────
205
+
206
+ describe("Shell accent override", () => {
207
+ it("renders without errors when a custom accent is provided", () => {
208
+ expect(() =>
209
+ renderShell({
210
+ config: { ...baseConfig, theme: { preset: "amber", mode: "light", accent: "#ff6b4a" } },
211
+ })
212
+ ).not.toThrow();
213
+ });
214
+
215
+ it("renders without errors when accent is omitted", () => {
216
+ expect(() => renderShell()).not.toThrow();
217
+ });
218
+ });
219
+
220
+ // ── Algolia search (TOM-16) ──────────────────────────────
221
+
222
+ describe("Shell Algolia search", () => {
223
+ beforeEach(() => {
224
+ vi.useFakeTimers();
225
+ });
226
+ afterEach(() => {
227
+ vi.useRealTimers();
228
+ vi.restoreAllMocks();
229
+ });
230
+
231
+ const algoliaConfig = {
232
+ name: "Test Docs",
233
+ theme: { preset: "amber" as const, mode: "light" as const },
234
+ search: {
235
+ provider: "algolia" as const,
236
+ appId: "test-app-id",
237
+ apiKey: "test-api-key",
238
+ indexName: "test-index",
239
+ },
240
+ };
241
+
242
+ it("renders Algolia search modal when provider is 'algolia'", async () => {
243
+ renderShell({ config: algoliaConfig });
244
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
245
+ await act(async () => { await vi.advanceTimersByTimeAsync(100); });
246
+
247
+ // @docsearch/react is not installed in test env, so AlgoliaSearchModal
248
+ // renders its loading/fallback state — key assertion: NOT the local search input
249
+ expect(screen.queryByPlaceholderText("Search documentation...")).not.toBeInTheDocument();
250
+ });
251
+
252
+ it("renders local search modal when provider is 'local' (default)", () => {
253
+ renderShell({ config: { ...baseConfig, search: { provider: "local" } } });
254
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
255
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
256
+ });
257
+
258
+ it("renders local search modal when search config is missing", () => {
259
+ renderShell({ config: baseConfig });
260
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
261
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
262
+ });
263
+
264
+ it("falls back to local search when algolia config is incomplete", () => {
265
+ const incompleteConfig = {
266
+ ...baseConfig,
267
+ search: { provider: "algolia" as const, appId: "test-app" },
268
+ // Missing apiKey and indexName
269
+ };
270
+ renderShell({ config: incompleteConfig });
271
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
272
+ // Should fall back to local SearchModal since apiKey and indexName are missing
273
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
274
+ });
275
+ });
276
+
277
+ // ── Search ────────────────────────────────────────────────
278
+
279
+ describe("Shell search", () => {
280
+ beforeEach(() => {
281
+ vi.useFakeTimers();
282
+ });
283
+ afterEach(() => {
284
+ vi.useRealTimers();
285
+ });
286
+
287
+ const searchPages = [
288
+ { id: "intro", title: "Introduction", description: "Getting started guide" },
289
+ { id: "quickstart", title: "Quick Start", description: "Fast setup" },
290
+ { id: "api", title: "API Reference", description: "Complete API docs" },
291
+ { id: "config", title: "Configuration", description: "Configure your site" },
292
+ ];
293
+
294
+ // Helper to get the search modal overlay (position: fixed with z-index 1000)
295
+ function getSearchModal(container: HTMLElement) {
296
+ return container.querySelector('[style*="position: fixed"]') as HTMLElement;
297
+ }
298
+
299
+ it("opens search modal on Cmd+K", () => {
300
+ renderShell({ allPages: searchPages });
301
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
302
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
303
+ });
304
+
305
+ it("closes search modal on Escape", () => {
306
+ renderShell({ allPages: searchPages });
307
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
308
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
309
+ fireEvent.keyDown(window, { key: "Escape" });
310
+ expect(screen.queryByPlaceholderText("Search documentation...")).not.toBeInTheDocument();
311
+ });
312
+
313
+ it("opens search modal on Ctrl+K", () => {
314
+ renderShell({ allPages: searchPages });
315
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
316
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
317
+ });
318
+
319
+ it("shows matching results after typing and debounce", async () => {
320
+ const { container } = renderShell({ allPages: searchPages });
321
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
322
+ const input = screen.getByPlaceholderText("Search documentation...");
323
+ fireEvent.change(input, { target: { value: "Quick" } });
324
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
325
+ const modal = getSearchModal(container);
326
+ expect(within(modal).getByText("Quick Start")).toBeInTheDocument();
327
+ });
328
+
329
+ it("performs case-insensitive search", async () => {
330
+ const { container } = renderShell({ allPages: searchPages });
331
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
332
+ const input = screen.getByPlaceholderText("Search documentation...");
333
+ fireEvent.change(input, { target: { value: "quick" } });
334
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
335
+ const modal = getSearchModal(container);
336
+ expect(within(modal).getByText("Quick Start")).toBeInTheDocument();
337
+ });
338
+
339
+ it("matches pages by description", async () => {
340
+ const { container } = renderShell({ allPages: searchPages });
341
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
342
+ const input = screen.getByPlaceholderText("Search documentation...");
343
+ fireEvent.change(input, { target: { value: "Complete API" } });
344
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
345
+ const modal = getSearchModal(container);
346
+ expect(within(modal).getByText("API Reference")).toBeInTheDocument();
347
+ });
348
+
349
+ it("shows no results for empty query", async () => {
350
+ renderShell({ allPages: searchPages });
351
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
352
+ const input = screen.getByPlaceholderText("Search documentation...");
353
+ fireEvent.change(input, { target: { value: "" } });
354
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
355
+ expect(screen.queryByText("No results found")).not.toBeInTheDocument();
356
+ });
357
+
358
+ it("shows 'No results found' for non-matching query", async () => {
359
+ renderShell({ allPages: searchPages });
360
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
361
+ const input = screen.getByPlaceholderText("Search documentation...");
362
+ fireEvent.change(input, { target: { value: "zzzznonexistent" } });
363
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
364
+ expect(screen.getByText("No results found")).toBeInTheDocument();
365
+ });
366
+
367
+ it("ArrowDown moves selection without error", async () => {
368
+ renderShell({ allPages: searchPages });
369
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
370
+ const input = screen.getByPlaceholderText("Search documentation...");
371
+ fireEvent.change(input, { target: { value: "a" } });
372
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
373
+ fireEvent.keyDown(input, { key: "ArrowDown" });
374
+ fireEvent.keyDown(input, { key: "ArrowDown" });
375
+ });
376
+
377
+ it("Enter on result calls onNavigate", async () => {
378
+ const onNavigate = vi.fn();
379
+ renderShell({ allPages: searchPages, onNavigate });
380
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
381
+ const input = screen.getByPlaceholderText("Search documentation...");
382
+ fireEvent.change(input, { target: { value: "API Ref" } });
383
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
384
+ fireEvent.keyDown(input, { key: "Enter" });
385
+ expect(onNavigate).toHaveBeenCalledWith("api");
386
+ });
387
+
388
+ it("clicking a result calls onNavigate", async () => {
389
+ const onNavigate = vi.fn();
390
+ const { container } = renderShell({ allPages: searchPages, onNavigate });
391
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
392
+ const input = screen.getByPlaceholderText("Search documentation...");
393
+ fireEvent.change(input, { target: { value: "API" } });
394
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
395
+ const modal = getSearchModal(container);
396
+ fireEvent.click(within(modal).getByText("API Reference"));
397
+ expect(onNavigate).toHaveBeenCalledWith("api");
398
+ });
399
+
400
+ it("fallback search works when pagefind is unavailable", async () => {
401
+ const { container } = renderShell({ allPages: searchPages });
402
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
403
+ const input = screen.getByPlaceholderText("Search documentation...");
404
+ fireEvent.change(input, { target: { value: "Configure" } });
405
+ await act(async () => { await vi.advanceTimersByTimeAsync(150); });
406
+ const modal = getSearchModal(container);
407
+ expect(within(modal).getByText("Configuration")).toBeInTheDocument();
408
+ });
409
+ });
410
+
411
+ // ── Version Switcher (TOM-30) ────────────────────────────
412
+
413
+ describe("Shell version switcher", () => {
414
+ const versioningInfo = { current: "v2", versions: ["v1", "v2"] };
415
+
416
+ it("renders version switcher when versioning prop is provided", () => {
417
+ renderShell({ versioning: versioningInfo });
418
+ expect(screen.getByTestId("version-switcher")).toBeInTheDocument();
419
+ });
420
+
421
+ it("does not render version switcher when versioning is absent", () => {
422
+ renderShell();
423
+ expect(screen.queryByTestId("version-switcher")).not.toBeInTheDocument();
424
+ });
425
+
426
+ it("shows current version label in the switcher button", () => {
427
+ renderShell({ versioning: versioningInfo, currentVersion: "v2" });
428
+ expect(screen.getByTestId("version-switcher").textContent).toContain("v2");
429
+ });
430
+
431
+ it("opens dropdown when version switcher is clicked", () => {
432
+ renderShell({ versioning: versioningInfo });
433
+ fireEvent.click(screen.getByTestId("version-switcher"));
434
+ expect(screen.getByTestId("version-dropdown")).toBeInTheDocument();
435
+ });
436
+
437
+ it("shows all versions in the dropdown", () => {
438
+ renderShell({ versioning: versioningInfo });
439
+ fireEvent.click(screen.getByTestId("version-switcher"));
440
+ const dropdown = screen.getByTestId("version-dropdown");
441
+ expect(within(dropdown).getByText("v1")).toBeInTheDocument();
442
+ expect(within(dropdown).getByText(/v2/)).toBeInTheDocument();
443
+ });
444
+
445
+ it("marks current version as latest in the dropdown", () => {
446
+ renderShell({ versioning: versioningInfo });
447
+ fireEvent.click(screen.getByTestId("version-switcher"));
448
+ const dropdown = screen.getByTestId("version-dropdown");
449
+ expect(within(dropdown).getByText("v2 (latest)")).toBeInTheDocument();
450
+ });
451
+
452
+ it("shows old version banner when viewing a non-current version", () => {
453
+ renderShell({ versioning: versioningInfo, currentVersion: "v1" });
454
+ expect(screen.getByTestId("old-version-banner")).toBeInTheDocument();
455
+ expect(screen.getByText(/You're viewing docs for v1/)).toBeInTheDocument();
456
+ expect(screen.getByText("Switch to latest.")).toBeInTheDocument();
457
+ });
458
+
459
+ it("does not show old version banner for the current version", () => {
460
+ renderShell({ versioning: versioningInfo, currentVersion: "v2" });
461
+ expect(screen.queryByTestId("old-version-banner")).not.toBeInTheDocument();
462
+ });
463
+
464
+ it("does not show old version banner when versioning is absent", () => {
465
+ renderShell();
466
+ expect(screen.queryByTestId("old-version-banner")).not.toBeInTheDocument();
467
+ });
468
+ });
469
+
470
+ // ── Language Switcher (TOM-34) ───────────────────────────
471
+
472
+ describe("Shell language switcher", () => {
473
+ const i18nInfo = {
474
+ defaultLocale: "en",
475
+ locales: ["en", "es", "ja"],
476
+ localeNames: { en: "English", es: "Español", ja: "日本語" },
477
+ };
478
+
479
+ it("renders language switcher when i18n prop is provided with multiple locales", () => {
480
+ renderShell({ i18n: i18nInfo, currentLocale: "en" });
481
+ expect(screen.getByTestId("language-switcher")).toBeInTheDocument();
482
+ });
483
+
484
+ it("does not render language switcher when i18n is absent", () => {
485
+ renderShell();
486
+ expect(screen.queryByTestId("language-switcher")).not.toBeInTheDocument();
487
+ });
488
+
489
+ it("does not render language switcher when i18n has only one locale", () => {
490
+ renderShell({ i18n: { defaultLocale: "en", locales: ["en"] } });
491
+ expect(screen.queryByTestId("language-switcher")).not.toBeInTheDocument();
492
+ });
493
+
494
+ it("shows locale display name in the switcher button", () => {
495
+ renderShell({ i18n: i18nInfo, currentLocale: "en" });
496
+ expect(screen.getByTestId("language-switcher").textContent).toContain("English");
497
+ });
498
+
499
+ it("shows locale code when localeNames is not provided", () => {
500
+ const i18nNoNames = { defaultLocale: "en", locales: ["en", "es"] };
501
+ renderShell({ i18n: i18nNoNames, currentLocale: "en" });
502
+ expect(screen.getByTestId("language-switcher").textContent).toContain("en");
503
+ });
504
+
505
+ it("opens dropdown when language switcher is clicked", () => {
506
+ renderShell({ i18n: i18nInfo, currentLocale: "en" });
507
+ fireEvent.click(screen.getByTestId("language-switcher"));
508
+ expect(screen.getByTestId("language-dropdown")).toBeInTheDocument();
509
+ });
510
+
511
+ it("shows all locales in the dropdown with display names", () => {
512
+ renderShell({ i18n: i18nInfo, currentLocale: "en" });
513
+ fireEvent.click(screen.getByTestId("language-switcher"));
514
+ const dropdown = screen.getByTestId("language-dropdown");
515
+ expect(within(dropdown).getByText("English")).toBeInTheDocument();
516
+ expect(within(dropdown).getByText("Español")).toBeInTheDocument();
517
+ expect(within(dropdown).getByText("日本語")).toBeInTheDocument();
518
+ });
519
+
520
+ it("calls onNavigate with locale-prefixed id when switching to non-default locale", () => {
521
+ const onNavigate = vi.fn();
522
+ renderShell({ i18n: i18nInfo, currentLocale: "en", currentPageId: "quickstart", onNavigate });
523
+ fireEvent.click(screen.getByTestId("language-switcher"));
524
+ const dropdown = screen.getByTestId("language-dropdown");
525
+ fireEvent.click(within(dropdown).getByText("Español"));
526
+ expect(onNavigate).toHaveBeenCalledWith("es/quickstart");
527
+ });
528
+
529
+ it("calls onNavigate with base id when switching to default locale", () => {
530
+ const onNavigate = vi.fn();
531
+ renderShell({ i18n: i18nInfo, currentLocale: "es", currentPageId: "es/quickstart", onNavigate });
532
+ fireEvent.click(screen.getByTestId("language-switcher"));
533
+ const dropdown = screen.getByTestId("language-dropdown");
534
+ fireEvent.click(within(dropdown).getByText("English"));
535
+ expect(onNavigate).toHaveBeenCalledWith("quickstart");
536
+ });
537
+
538
+ it("defaults to defaultLocale when currentLocale is not provided", () => {
539
+ renderShell({ i18n: i18nInfo });
540
+ expect(screen.getByTestId("language-switcher").textContent).toContain("English");
541
+ });
542
+ });
543
+
544
+ // ── AI Chat (TOM-32) ─────────────────────────────────────
545
+
546
+ describe("Shell AI chat integration", () => {
547
+ it("does not render AI chat when ai is not enabled", () => {
548
+ renderShell();
549
+ expect(screen.queryByTestId("ai-chat-button")).not.toBeInTheDocument();
550
+ });
551
+
552
+ it("does not render AI chat when ai.enabled is false", () => {
553
+ renderShell({
554
+ config: { ...baseConfig, ai: { enabled: false, provider: "openai" } },
555
+ });
556
+ expect(screen.queryByTestId("ai-chat-button")).not.toBeInTheDocument();
557
+ });
558
+
559
+ it("renders AI chat floating button when ai.enabled is true", () => {
560
+ renderShell({
561
+ config: { ...baseConfig, ai: { enabled: true, provider: "openai" } },
562
+ });
563
+ expect(screen.getByTestId("ai-chat-button")).toBeInTheDocument();
564
+ });
565
+ });