@elabs-ai/components-viewer 4.0.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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,947 @@
1
+ import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useEffect } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import type { DocumentAddressKind } from "@elabs-ai/components-ui";
7
+
8
+ import type { DocumentHighlight } from "../core/highlight";
9
+ import { createRegistry } from "../core/registry";
10
+ import {
11
+ PROTOCOL_VERSION,
12
+ type AdapterManifest,
13
+ type AdapterModule,
14
+ type AdapterRendererProps,
15
+ } from "../core/types";
16
+ import { FileViewer, FileViewerContent, FileViewerFrame, FileViewerProvider } from "./file-viewer";
17
+ import { useFileViewer } from "./file-viewer-context";
18
+
19
+ const textSource = { kind: "text", text: "hello world", name: "notes.txt" } as const;
20
+
21
+ /** A registry with only the text adapter — enough to exercise the state grid. */
22
+ function textOnlyRegistry() {
23
+ const registry = createRegistry();
24
+ registry.register(
25
+ {
26
+ id: "text",
27
+ protocol: PROTOCOL_VERSION,
28
+ categories: ["text"],
29
+ mediaTypes: ["text/"],
30
+ capabilities: { text: true },
31
+ },
32
+ () => import("../adapters/text/text-adapter"),
33
+ );
34
+ return registry;
35
+ }
36
+
37
+ describe("FileViewer — the state grid", () => {
38
+ it("shows the empty state with no source, and does not call it an error", () => {
39
+ render(<FileViewer registry={textOnlyRegistry()} />);
40
+ expect(screen.getByText("No file selected")).toBeInTheDocument();
41
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
42
+ });
43
+
44
+ it("announces loading once, then renders the file", async () => {
45
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
46
+
47
+ // The skeleton is decorative; the single live region is what AT hears.
48
+ expect(screen.getByRole("status")).toHaveTextContent("Loading notes.txt…");
49
+ await waitFor(() => {
50
+ expect(screen.getByText("hello world")).toBeInTheDocument();
51
+ });
52
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
53
+ });
54
+
55
+ it("reports a file no adapter claims as unsupported, with no retry offered", async () => {
56
+ render(
57
+ <FileViewer
58
+ source={{ kind: "text", text: "x", name: "thing.pdf", mediaType: "application/pdf" }}
59
+ registry={textOnlyRegistry()}
60
+ />,
61
+ );
62
+
63
+ await waitFor(() => {
64
+ expect(screen.getByText("Can't preview this file type")).toBeInTheDocument();
65
+ });
66
+ // Retrying cannot change the outcome — offering it would teach the user the
67
+ // button does nothing.
68
+ expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
69
+ });
70
+
71
+ it("names the missing package when an optional peer is not installed", async () => {
72
+ const registry = createRegistry();
73
+ const manifest: AdapterManifest = {
74
+ id: "csv",
75
+ protocol: PROTOCOL_VERSION,
76
+ extensions: ["csv"],
77
+ requires: ["papaparse"],
78
+ };
79
+ registry.register(manifest, () => Promise.reject(new Error("Cannot find module 'papaparse'")));
80
+
81
+ render(
82
+ <FileViewer source={{ kind: "text", text: "a,b", name: "r.csv" }} registry={registry} />,
83
+ );
84
+
85
+ await waitFor(() => {
86
+ expect(screen.getByText(/needs papaparse to be installed/)).toBeInTheDocument();
87
+ });
88
+ });
89
+
90
+ it("names the missing package when the peer is reached from INSIDE load()", async () => {
91
+ // Where every parser engine actually lives: `await import("mammoth")` runs
92
+ // in the adapter's own `load()`, so the module resolves fine and the
93
+ // rejection lands on the provider, not on `registry.load()`. Before this was
94
+ // handled, the reader was told their file was damaged ("isn't a valid file")
95
+ // and offered a retry that could never succeed.
96
+ const registry = createRegistry();
97
+ const manifest: AdapterManifest = {
98
+ id: "docx",
99
+ protocol: PROTOCOL_VERSION,
100
+ extensions: ["docx"],
101
+ requires: ["mammoth"],
102
+ };
103
+ registry.register(manifest, () =>
104
+ Promise.resolve({
105
+ manifest,
106
+ create: () => ({
107
+ load: () => Promise.reject(new Error("Failed to resolve module specifier 'mammoth'")),
108
+ }),
109
+ Renderer: () => null,
110
+ } satisfies AdapterModule),
111
+ );
112
+
113
+ render(
114
+ <FileViewer source={{ kind: "text", text: "x", name: "report.docx" }} registry={registry} />,
115
+ );
116
+
117
+ await waitFor(() => {
118
+ expect(screen.getByText(/needs mammoth to be installed/)).toBeInTheDocument();
119
+ });
120
+ // A capability gap, so: no alarm, and no retry that cannot work.
121
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
122
+ expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
123
+ });
124
+
125
+ it("presents a capability gap as news, not as an alarm", async () => {
126
+ render(
127
+ <FileViewer
128
+ source={{ kind: "text", text: "x", name: "thing.pdf", mediaType: "application/pdf" }}
129
+ registry={textOnlyRegistry()}
130
+ />,
131
+ );
132
+ await screen.findByText("Can't preview this file type");
133
+
134
+ // "This build can't draw PDFs" is not the reader's failure, and `alert`
135
+ // interrupts. It is still announced — politely.
136
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
137
+ expect(screen.getByRole("status")).toHaveTextContent("Can't preview this file type");
138
+ });
139
+
140
+ it("keeps a real failure an alert", async () => {
141
+ const registry = createRegistry();
142
+ registry.register({ id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] }, () =>
143
+ Promise.resolve({
144
+ manifest: { id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] },
145
+ create: () => ({ load: () => Promise.reject(new Error("bad bytes")) }),
146
+ Renderer: () => null,
147
+ } satisfies AdapterModule),
148
+ );
149
+
150
+ render(<FileViewer source={textSource} registry={registry} />);
151
+ await screen.findByText("Couldn't read this file");
152
+ expect(screen.getByRole("alert")).toBeInTheDocument();
153
+ });
154
+
155
+ it("offers a retry for a read failure, and re-runs the load", async () => {
156
+ const load = vi
157
+ .fn()
158
+ .mockRejectedValueOnce(new Error("network died"))
159
+ .mockResolvedValueOnce({ kind: "text", text: "second try" });
160
+ const adapterModule: AdapterModule = {
161
+ manifest: { id: "flaky", protocol: PROTOCOL_VERSION, categories: ["text"] },
162
+ create: () => ({ load }),
163
+ Renderer: ({ document: doc }) => <p>{(doc as { text: string }).text}</p>,
164
+ };
165
+ const registry = createRegistry();
166
+ registry.register(adapterModule.manifest, () => Promise.resolve(adapterModule));
167
+
168
+ render(<FileViewer source={textSource} registry={registry} />);
169
+
170
+ const retry = await screen.findByRole("button", { name: "Try again" });
171
+ await userEvent.click(retry);
172
+
173
+ await waitFor(() => {
174
+ expect(screen.getByText("second try")).toBeInTheDocument();
175
+ });
176
+ expect(load).toHaveBeenCalledTimes(2);
177
+ });
178
+
179
+ it("keeps a parent's loading prop additive — it cannot clear a real error", async () => {
180
+ const registry = createRegistry();
181
+ registry.register({ id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] }, () =>
182
+ Promise.resolve({
183
+ manifest: { id: "bad", protocol: PROTOCOL_VERSION, categories: ["text"] },
184
+ create: () => ({ load: () => Promise.reject(new Error("bad bytes")) }),
185
+ Renderer: () => null,
186
+ } satisfies AdapterModule),
187
+ );
188
+
189
+ const { rerender } = render(<FileViewer source={textSource} registry={registry} />);
190
+ await screen.findByText("Couldn't read this file");
191
+
192
+ rerender(<FileViewer source={textSource} registry={registry} loading />);
193
+ expect(screen.getByText("Couldn't read this file")).toBeInTheDocument();
194
+ });
195
+ });
196
+
197
+ describe("FileViewer — chrome", () => {
198
+ it("names the region and the file, and offers a download", async () => {
199
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
200
+
201
+ expect(screen.getByRole("region", { name: "File viewer" })).toBeInTheDocument();
202
+ expect(screen.getByText("notes.txt")).toBeInTheDocument();
203
+ expect(await screen.findByRole("button", { name: "Download notes.txt" })).toBeInTheDocument();
204
+ });
205
+
206
+ it("renders no identity row when there is no file", () => {
207
+ render(<FileViewer registry={textOnlyRegistry()} />);
208
+ // A row holding a generic glyph and a blank name reads as a broken render.
209
+ expect(document.querySelector('[data-slot="file-viewer-toolbar"]')).toBeNull();
210
+ expect(screen.queryByRole("button", { name: /Download/ })).not.toBeInTheDocument();
211
+ });
212
+
213
+ it("makes the scrolling content pane a real keyboard stop", async () => {
214
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
215
+ await screen.findByText("hello world");
216
+
217
+ // A plain-text file contains nothing focusable, so without this the pane
218
+ // scrolls for a mouse and is unreachable from a keyboard (WCAG 2.1.1).
219
+ const content = screen.getByRole("region", { name: "File content" });
220
+ expect(content).toHaveAttribute("tabindex", "0");
221
+
222
+ await userEvent.tab();
223
+ await userEvent.tab();
224
+ expect(content).toHaveFocus();
225
+ });
226
+
227
+ it("keeps ONE scroll boundary — a flowing adapter does not nest a second", async () => {
228
+ // Two nested `overflow-auto` boxes do not compose: the inner one clips
229
+ // while the outer one's padding stays put, so a long file ends flush
230
+ // against a band of whitespace and reads as a failed render.
231
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
232
+ const content = await screen.findByRole("region", { name: "File content" });
233
+ expect(content.querySelector(".overflow-auto")).toBeNull();
234
+ });
235
+
236
+ it("does not claim role=toolbar — that promises arrow-key navigation it lacks", () => {
237
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
238
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
239
+ });
240
+
241
+ it("replaces the default composition when children are given", () => {
242
+ render(
243
+ <FileViewer source={textSource} registry={textOnlyRegistry()}>
244
+ <p>custom</p>
245
+ </FileViewer>,
246
+ );
247
+ expect(screen.getByText("custom")).toBeInTheDocument();
248
+ expect(screen.queryByRole("button", { name: /Download/ })).not.toBeInTheDocument();
249
+ });
250
+ });
251
+
252
+ /* -------------------------------------------------------------------------- */
253
+ /* Page, zoom and rotation (ADR 0026) */
254
+ /* -------------------------------------------------------------------------- */
255
+
256
+ /**
257
+ * A three-page format that claims every view capability and echoes back what the
258
+ * shell handed it.
259
+ *
260
+ * The echo is the point: page, zoom and rotation used to be `useState` inside
261
+ * each renderer, so the only way to observe them was through that renderer's own
262
+ * chrome. Now the shell owns them and the adapter is downstream — which is what
263
+ * these assertions check.
264
+ */
265
+ function pagedRegistry(
266
+ capabilities: AdapterManifest["capabilities"] = { pages: true, zoom: true, rotate: true },
267
+ reportZoom?: number,
268
+ ) {
269
+ const manifest: AdapterManifest = {
270
+ id: "paged",
271
+ protocol: PROTOCOL_VERSION,
272
+ categories: ["text"],
273
+ capabilities,
274
+ };
275
+ const registry = createRegistry();
276
+ registry.register(manifest, () =>
277
+ Promise.resolve({
278
+ manifest,
279
+ create: () => ({ load: () => Promise.resolve({ kind: "paged", pageCount: 3 }) }),
280
+ Renderer: ({ pageNumber, zoom, rotation, onZoomResolved }: AdapterRendererProps) => {
281
+ // Standing in for the real thing: only the renderer can measure its own
282
+ // viewport, so a fit mode is resolved down here and reported back up.
283
+ useEffect(() => {
284
+ if (reportZoom !== undefined) onZoomResolved?.(reportZoom);
285
+ }, [onZoomResolved]);
286
+ return <div data-testid="view">{`${pageNumber}|${String(zoom)}|${rotation}`}</div>;
287
+ },
288
+ } satisfies AdapterModule),
289
+ );
290
+ return registry;
291
+ }
292
+
293
+ const view = () => screen.getByTestId("view").textContent;
294
+
295
+ describe("FileViewer — paging", () => {
296
+ it("shows no page control for a format that has no pages", async () => {
297
+ render(<FileViewer source={textSource} registry={textOnlyRegistry()} />);
298
+ await screen.findByText("hello world");
299
+ // An inert "1 of 0" over a text file is chrome with nothing in it.
300
+ expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument();
301
+ });
302
+
303
+ it("pages with the buttons and stops at both ends", async () => {
304
+ render(<FileViewer source={textSource} registry={pagedRegistry()} />);
305
+ await screen.findByTestId("view");
306
+
307
+ const next = screen.getByRole("button", { name: "Next page" });
308
+ const previous = screen.getByRole("button", { name: "Previous page" });
309
+ expect(previous).toBeDisabled();
310
+ expect(screen.getByText("of 3")).toBeInTheDocument();
311
+
312
+ await userEvent.click(next);
313
+ expect(view()).toBe("2|fit-width|0");
314
+ await userEvent.click(next);
315
+ expect(next).toBeDisabled();
316
+
317
+ await userEvent.click(previous);
318
+ expect(view()).toBe("2|fit-width|0");
319
+ });
320
+
321
+ it("takes a typed page number, and clamps one past the end", async () => {
322
+ render(<FileViewer source={textSource} registry={pagedRegistry()} />);
323
+ await screen.findByTestId("view");
324
+
325
+ const field = screen.getByRole("textbox", { name: "Page number" });
326
+ await userEvent.clear(field);
327
+ await userEvent.type(field, "3{Enter}");
328
+ expect(view()).toBe("3|fit-width|0");
329
+
330
+ await userEvent.clear(field);
331
+ await userEvent.type(field, "99{Enter}");
332
+ expect(view()).toBe("3|fit-width|0");
333
+ expect(field).toHaveValue("3");
334
+ });
335
+
336
+ it("abandons a half-typed page on Escape instead of navigating", async () => {
337
+ render(<FileViewer source={textSource} registry={pagedRegistry()} />);
338
+ await screen.findByTestId("view");
339
+
340
+ const field = screen.getByRole("textbox", { name: "Page number" });
341
+ await userEvent.clear(field);
342
+ await userEvent.type(field, "2{Escape}");
343
+ expect(view()).toBe("1|fit-width|0");
344
+ expect(field).toHaveValue("1");
345
+ });
346
+
347
+ it("says where the reader is, in ONE live region", async () => {
348
+ render(<FileViewer source={textSource} registry={pagedRegistry()} />);
349
+ await screen.findByTestId("view");
350
+
351
+ // Paging repaints a canvas, which announces nothing on its own — and one
352
+ // region for the group, not one per control (loading-states.md).
353
+ const live = screen.getByText("Page 1 of 3");
354
+ expect(live).toHaveAttribute("aria-live", "polite");
355
+ await userEvent.click(screen.getByRole("button", { name: "Next page" }));
356
+ expect(screen.getByText("Page 2 of 3")).toBeInTheDocument();
357
+ });
358
+
359
+ it("lets an app own the page — controlled means controlled", async () => {
360
+ const onPageNumberChange = vi.fn();
361
+ render(
362
+ <FileViewer
363
+ source={textSource}
364
+ registry={pagedRegistry()}
365
+ pageNumber={2}
366
+ onPageNumberChange={onPageNumberChange}
367
+ />,
368
+ );
369
+ await screen.findByTestId("view");
370
+ expect(view()).toBe("2|fit-width|0");
371
+
372
+ await userEvent.click(screen.getByRole("button", { name: "Next page" }));
373
+ expect(onPageNumberChange).toHaveBeenCalledWith(3);
374
+ // The app did not move it, so it did not move — a deep-linked page that
375
+ // drifted out from under its own URL would be worse than an inert button.
376
+ expect(view()).toBe("2|fit-width|0");
377
+ });
378
+ });
379
+
380
+ describe("FileViewer — zoom and rotation", () => {
381
+ it("shows neither control for a format that claims neither", async () => {
382
+ render(<FileViewer source={textSource} registry={pagedRegistry({ pages: true })} />);
383
+ await screen.findByTestId("view");
384
+ expect(screen.queryByRole("group", { name: "Zoom" })).not.toBeInTheDocument();
385
+ expect(screen.queryByRole("button", { name: "Rotate clockwise" })).not.toBeInTheDocument();
386
+ });
387
+
388
+ it("asks for a fit by default and lets the renderer resolve it", async () => {
389
+ render(<FileViewer source={textSource} registry={pagedRegistry(undefined, 1.5)} />);
390
+ await screen.findByTestId("view");
391
+
392
+ // The state is the REQUEST ("fit-width"); the percentage is what came back.
393
+ expect(view()).toBe("1|fit-width|0");
394
+ expect(screen.getByRole("combobox", { name: "Zoom level" })).toHaveTextContent("Fit width");
395
+ await waitFor(() => expect(screen.getByText("Zoom 150%")).toBeInTheDocument());
396
+ });
397
+
398
+ it("steps from what is ON SCREEN, not from the last number asked for", async () => {
399
+ // A page fitted to 150% steps to 200%, not to 125% — otherwise the first
400
+ // press of "+" makes the page smaller.
401
+ render(<FileViewer source={textSource} registry={pagedRegistry(undefined, 1.5)} />);
402
+ await screen.findByTestId("view");
403
+ await waitFor(() => expect(screen.getByText("Zoom 150%")).toBeInTheDocument());
404
+
405
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
406
+ expect(view()).toBe("1|2|0");
407
+ expect(screen.getByRole("combobox", { name: "Zoom level" })).toHaveTextContent("200%");
408
+ });
409
+
410
+ it("stops at both ends of the ladder", async () => {
411
+ render(<FileViewer source={textSource} registry={pagedRegistry()} defaultZoom={3} />);
412
+ await screen.findByTestId("view");
413
+ expect(screen.getByRole("button", { name: "Zoom in" })).toBeDisabled();
414
+
415
+ await userEvent.click(screen.getByRole("button", { name: "Zoom out" }));
416
+ expect(view()).toBe("1|2|0");
417
+ });
418
+
419
+ it("turns the document a quarter at a time, all the way round", async () => {
420
+ render(<FileViewer source={textSource} registry={pagedRegistry()} />);
421
+ await screen.findByTestId("view");
422
+
423
+ const rotate = screen.getByRole("button", { name: "Rotate clockwise" });
424
+ for (const expected of ["90", "180", "270", "0"]) {
425
+ await userEvent.click(rotate);
426
+ expect(view()).toBe(`1|fit-width|${expected}`);
427
+ }
428
+ });
429
+
430
+ it("starts the next file at page 1, the right way up, at the same scale", async () => {
431
+ const { rerender } = render(<FileViewer source={textSource} registry={pagedRegistry()} />);
432
+ await screen.findByTestId("view");
433
+ await userEvent.click(screen.getByRole("button", { name: "Next page" }));
434
+ await userEvent.click(screen.getByRole("button", { name: "Rotate clockwise" }));
435
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
436
+ expect(view()).toBe("2|1.25|90");
437
+
438
+ rerender(
439
+ <FileViewer
440
+ source={{ kind: "text", text: "other", name: "other.txt" }}
441
+ registry={pagedRegistry()}
442
+ />,
443
+ );
444
+ // Page and rotation belong to the document. The scale is the READER's
445
+ // preference — resetting it would undo their choice on every file.
446
+ await waitFor(() => expect(view()).toBe("1|1.25|0"));
447
+ });
448
+ });
449
+
450
+ /* -------------------------------------------------------------------------- */
451
+ /* Highlights */
452
+ /* -------------------------------------------------------------------------- */
453
+
454
+ /** Renders what the shell handed it, so a test can assert on the seam itself. */
455
+ function spyRegistry(
456
+ highlight?: readonly DocumentAddressKind[],
457
+ options: { truncated?: boolean } = {},
458
+ ) {
459
+ const manifest: AdapterManifest = {
460
+ id: "spy",
461
+ protocol: PROTOCOL_VERSION,
462
+ categories: ["text"],
463
+ capabilities: { text: true, ...(highlight ? { highlight } : {}) },
464
+ };
465
+ const registry = createRegistry();
466
+ registry.register(manifest, () =>
467
+ Promise.resolve({
468
+ manifest,
469
+ create: () => ({
470
+ // Reads the source rather than hard-coding a string, so a test can
471
+ // choose the document text it needs (case sensitivity, no matches).
472
+ load: (source, _context) =>
473
+ source
474
+ .text()
475
+ .then((text) => ({ kind: "spy", text, textTruncated: options.truncated ?? false })),
476
+ }),
477
+ Renderer: ({ highlights = [], activeHighlightId }: AdapterRendererProps) => (
478
+ <ul data-testid="seen">
479
+ <li data-testid="active">{activeHighlightId ?? "none"}</li>
480
+ {highlights.map((entry) => (
481
+ <li key={entry.id}>{`${entry.id}:${entry.status}:${String(entry.active)}`}</li>
482
+ ))}
483
+ </ul>
484
+ ),
485
+ } satisfies AdapterModule),
486
+ );
487
+ return registry;
488
+ }
489
+
490
+ const QUOTE: DocumentHighlight = { id: "c1", address: { kind: "quote", text: "WORLD" } };
491
+
492
+ describe("FileViewer — pointing at part of a document", () => {
493
+ it("hands the adapter located highlights, not raw requests", async () => {
494
+ render(
495
+ <FileViewer source={textSource} registry={spyRegistry(["quote"])} highlights={[QUOTE]} />,
496
+ );
497
+ // LOCATE ran in the shell: the quote is case-folded, found, and reported as
498
+ // resolved before the renderer ever sees it.
499
+ expect(await screen.findByText("c1:resolved:false")).toBeInTheDocument();
500
+ });
501
+
502
+ it("reports a kind the adapter never declared as unsupported, and still renders", async () => {
503
+ render(<FileViewer source={textSource} registry={spyRegistry()} highlights={[QUOTE]} />);
504
+ expect(await screen.findByText("c1:unsupported:false")).toBeInTheDocument();
505
+ });
506
+
507
+ it("forwards every provider prop — including the ones added after it shipped", async () => {
508
+ // The regression this locks: `FileViewer` used to peel off four props by
509
+ // name and spread the rest onto the frame, so `baseHeadingLevel` and every
510
+ // later provider prop type-checked, never reached the provider, and landed
511
+ // on the <section> as an unknown DOM attribute.
512
+ const { container } = render(
513
+ <FileViewer
514
+ source={textSource}
515
+ registry={spyRegistry(["quote"])}
516
+ highlights={[QUOTE]}
517
+ activeHighlightId="c1"
518
+ baseHeadingLevel={1}
519
+ />,
520
+ );
521
+ expect(await screen.findByText("c1:resolved:true")).toBeInTheDocument();
522
+ expect(container.querySelector("[baseheadinglevel]")).toBeNull();
523
+ expect(container.querySelector("[activehighlightid]")).toBeNull();
524
+ });
525
+
526
+ it("owns the active highlight when it is uncontrolled", async () => {
527
+ function Harness() {
528
+ const { actions } = useFileViewer();
529
+ return (
530
+ <>
531
+ <button onClick={actions.nextHighlight}>next</button>
532
+ <FileViewerContent />
533
+ </>
534
+ );
535
+ }
536
+ render(
537
+ <FileViewer
538
+ source={textSource}
539
+ registry={spyRegistry(["quote"])}
540
+ defaultHighlights={[QUOTE, { id: "c2", address: { kind: "quote", text: "hello" } }]}
541
+ >
542
+ <Harness />
543
+ </FileViewer>,
544
+ );
545
+
546
+ await screen.findByText("c1:resolved:false");
547
+ await userEvent.click(screen.getByRole("button", { name: "next" }));
548
+ // Document order, not the order asked for: "hello" precedes "world".
549
+ expect(screen.getByTestId("active")).toHaveTextContent("c2");
550
+ });
551
+
552
+ it("never writes a controlled active id locally — it only reports", async () => {
553
+ const onActiveHighlightChange = vi.fn();
554
+ function Harness() {
555
+ const { actions } = useFileViewer();
556
+ return (
557
+ <>
558
+ <button onClick={actions.nextHighlight}>next</button>
559
+ <FileViewerContent />
560
+ </>
561
+ );
562
+ }
563
+ render(
564
+ <FileViewer
565
+ source={textSource}
566
+ registry={spyRegistry(["quote"])}
567
+ highlights={[QUOTE]}
568
+ activeHighlightId={null}
569
+ onActiveHighlightChange={onActiveHighlightChange}
570
+ >
571
+ <Harness />
572
+ </FileViewer>,
573
+ );
574
+
575
+ await screen.findByText("c1:resolved:false");
576
+ await userEvent.click(screen.getByRole("button", { name: "next" }));
577
+ expect(onActiveHighlightChange).toHaveBeenCalledWith("c1");
578
+ // The owner said `null` and has not said otherwise, so it is still null.
579
+ expect(screen.getByTestId("active")).toHaveTextContent("none");
580
+ });
581
+ });
582
+
583
+ describe("FileViewer — find-in-document", () => {
584
+ function Probe() {
585
+ const { state, meta } = useFileViewer();
586
+ return (
587
+ <p data-testid="find">
588
+ {`${String(meta.canFind)}/${String(state.find.matches)}/${String(state.find.activeIndex)}`}
589
+ </p>
590
+ );
591
+ }
592
+
593
+ it("offers find when the adapter can paint a range, without a declared flag", async () => {
594
+ // `capabilities.search` is an override in the OFF direction only. Read as
595
+ // the source of truth it would deny the find box to PDF, whose manifest
596
+ // predates the feature and never declared it.
597
+ render(
598
+ <FileViewer source={textSource} registry={spyRegistry(["range"])}>
599
+ <Probe />
600
+ </FileViewer>,
601
+ );
602
+ await waitFor(() => {
603
+ expect(screen.getByTestId("find")).toHaveTextContent("true/0/0");
604
+ });
605
+ });
606
+
607
+ it("does not offer find when nothing could paint the result", async () => {
608
+ render(
609
+ <FileViewer source={textSource} registry={spyRegistry(["quote"])}>
610
+ <Probe />
611
+ </FileViewer>,
612
+ );
613
+ await waitFor(() => {
614
+ expect(screen.getByTestId("find")).toHaveTextContent("false/0/0");
615
+ });
616
+ });
617
+
618
+ it("turns a query into resolved matches the adapter can draw", async () => {
619
+ function Harness() {
620
+ const { actions } = useFileViewer();
621
+ return (
622
+ <>
623
+ <button
624
+ onClick={() => {
625
+ actions.openFind();
626
+ actions.setFindQuery("l");
627
+ }}
628
+ >
629
+ search
630
+ </button>
631
+ <Probe />
632
+ <FileViewerContent />
633
+ </>
634
+ );
635
+ }
636
+ render(
637
+ <FileViewer source={textSource} registry={spyRegistry(["range"])}>
638
+ <Harness />
639
+ </FileViewer>,
640
+ );
641
+
642
+ await screen.findByTestId("seen");
643
+ await userEvent.click(screen.getByRole("button", { name: "search" }));
644
+
645
+ // "hello world" — three l's, and the first is current.
646
+ await waitFor(() => {
647
+ expect(screen.getByTestId("find")).toHaveTextContent("true/3/0");
648
+ });
649
+ expect(screen.getByText("find:0:resolved:true")).toBeInTheDocument();
650
+ expect(screen.getByText("find:2:resolved:false")).toBeInTheDocument();
651
+ });
652
+
653
+ it("points the RENDERER at the find match, not at the citation knob", async () => {
654
+ // The regression this locks: the shell passed `state.activeHighlightId` —
655
+ // the citation knob — to the renderer, while marking the find match active
656
+ // in the list. Every renderer that navigates or scrolls keys off that id, so
657
+ // with no citation active (the ordinary find case) Enter repainted the mark
658
+ // and never scrolled to it, never turned the PDF's page, and never switched
659
+ // the workbook's sheet.
660
+ function Harness() {
661
+ const { actions } = useFileViewer();
662
+ return (
663
+ <>
664
+ <button
665
+ onClick={() => {
666
+ actions.openFind();
667
+ actions.setFindQuery("world");
668
+ }}
669
+ >
670
+ search
671
+ </button>
672
+ <FileViewerContent />
673
+ </>
674
+ );
675
+ }
676
+ render(
677
+ <FileViewer source={textSource} registry={spyRegistry(["range", "quote"])}>
678
+ <Harness />
679
+ </FileViewer>,
680
+ );
681
+
682
+ await screen.findByTestId("seen");
683
+ expect(screen.getByTestId("active")).toHaveTextContent("none");
684
+ await userEvent.click(screen.getByRole("button", { name: "search" }));
685
+ await waitFor(() => {
686
+ expect(screen.getByTestId("active")).toHaveTextContent("find:0");
687
+ });
688
+ });
689
+
690
+ it("lets the reader's own match outrank a citation, and only one is current", async () => {
691
+ function Harness() {
692
+ const { actions } = useFileViewer();
693
+ return (
694
+ <>
695
+ <button
696
+ onClick={() => {
697
+ actions.openFind();
698
+ actions.setFindQuery("world");
699
+ }}
700
+ >
701
+ search
702
+ </button>
703
+ <FileViewerContent />
704
+ </>
705
+ );
706
+ }
707
+ render(
708
+ <FileViewer
709
+ source={textSource}
710
+ registry={spyRegistry(["range", "quote"])}
711
+ highlights={[QUOTE]}
712
+ activeHighlightId="c1"
713
+ >
714
+ <Harness />
715
+ </FileViewer>,
716
+ );
717
+
718
+ expect(await screen.findByText("c1:resolved:true")).toBeInTheDocument();
719
+ await userEvent.click(screen.getByRole("button", { name: "search" }));
720
+
721
+ // Find wins while its box is open: it is what the reader's keystrokes move.
722
+ // Two things painted as "current" would make the reader's own match look
723
+ // like every other match.
724
+ await waitFor(() => {
725
+ expect(screen.getByText("find:0:resolved:true")).toBeInTheDocument();
726
+ });
727
+ expect(screen.getByText("c1:resolved:false")).toBeInTheDocument();
728
+ expect(screen.getByTestId("active")).toHaveTextContent("find:0");
729
+ });
730
+
731
+ it("hands the citation back the moment the find bar closes", async () => {
732
+ function Harness() {
733
+ const { actions, state } = useFileViewer();
734
+ return (
735
+ <>
736
+ <button
737
+ onClick={() => {
738
+ actions.openFind();
739
+ actions.setFindQuery("world");
740
+ }}
741
+ >
742
+ search
743
+ </button>
744
+ <button onClick={actions.closeFind}>close</button>
745
+ <p data-testid="open">{String(state.find.open)}</p>
746
+ <FileViewerContent />
747
+ </>
748
+ );
749
+ }
750
+ render(
751
+ <FileViewer
752
+ source={textSource}
753
+ registry={spyRegistry(["range", "quote"])}
754
+ highlights={[QUOTE]}
755
+ activeHighlightId="c1"
756
+ >
757
+ <Harness />
758
+ </FileViewer>,
759
+ );
760
+
761
+ await screen.findByText("c1:resolved:true");
762
+ await userEvent.click(screen.getByRole("button", { name: "search" }));
763
+ await waitFor(() => {
764
+ expect(screen.getByTestId("active")).toHaveTextContent("find:0");
765
+ });
766
+
767
+ await userEvent.click(screen.getByRole("button", { name: "close" }));
768
+ // The query survives the close, but the citation is current again — the
769
+ // reader is no longer stepping through anything.
770
+ expect(screen.getByTestId("active")).toHaveTextContent("c1");
771
+ });
772
+ });
773
+
774
+ /** Open the find bar the way a reader does, and return the content region. */
775
+ async function openFind() {
776
+ const content = await screen.findByRole("region", { name: "File content" });
777
+ content.focus();
778
+ // Scoped to the frame, never `document`: a page may hold two viewers, and a
779
+ // document-level listener would let whichever mounted last win.
780
+ await userEvent.keyboard("{Control>}f{/Control}");
781
+ return content;
782
+ }
783
+
784
+ describe("FileViewer — the find bar", () => {
785
+ it("stays out of the way until Ctrl/Cmd+F, then takes the caret", async () => {
786
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
787
+ await screen.findByTestId("seen");
788
+ expect(screen.queryByRole("search")).not.toBeInTheDocument();
789
+
790
+ await openFind();
791
+
792
+ expect(screen.getByRole("search", { name: "Find in document" })).toBeInTheDocument();
793
+ // A find box that does not take the caret sends the next keystroke to the
794
+ // document behind it.
795
+ expect(screen.getByRole("textbox", { name: "Find in document" })).toHaveFocus();
796
+ });
797
+
798
+ it("leaves the browser's own find alone when no find part is composed in", async () => {
799
+ // The regression this locks: the frame gated the shortcut on the ADAPTER's
800
+ // capability alone, so a hand-composed viewer that omits `FileViewerFind` —
801
+ // which every citation story does — swallowed Ctrl/Cmd+F, showed no box, and
802
+ // left the reader with neither the viewer's find nor the browser's.
803
+ render(
804
+ <FileViewerProvider source={textSource} registry={spyRegistry(["range"])}>
805
+ <FileViewerFrame>
806
+ <FileViewerContent />
807
+ </FileViewerFrame>
808
+ </FileViewerProvider>,
809
+ );
810
+ const content = await screen.findByRole("region", { name: "File content" });
811
+ content.focus();
812
+
813
+ const event = createEvent.keyDown(content, { key: "f", ctrlKey: true, bubbles: true });
814
+ fireEvent(content, event);
815
+ expect(event.defaultPrevented).toBe(false);
816
+ expect(screen.queryByRole("search")).not.toBeInTheDocument();
817
+ });
818
+
819
+ it("leaves the browser's own find alone when it could not paint a result", async () => {
820
+ render(<FileViewer source={textSource} registry={spyRegistry(["quote"])} />);
821
+ await screen.findByTestId("seen");
822
+ await openFind();
823
+ expect(screen.queryByRole("search")).not.toBeInTheDocument();
824
+ });
825
+
826
+ it("counts matches and steps through them with Enter / Shift+Enter", async () => {
827
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
828
+ await screen.findByTestId("seen");
829
+ await openFind();
830
+ await userEvent.keyboard("l");
831
+
832
+ // "hello world" — three l's.
833
+ await waitFor(() => {
834
+ expect(screen.getByRole("status")).toHaveTextContent("1 of 3");
835
+ });
836
+ await userEvent.keyboard("{Enter}");
837
+ expect(screen.getByRole("status")).toHaveTextContent("2 of 3");
838
+ await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
839
+ expect(screen.getByRole("status")).toHaveTextContent("1 of 3");
840
+ // …and it wraps rather than dead-ending at the top.
841
+ await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
842
+ expect(screen.getByRole("status")).toHaveTextContent("3 of 3");
843
+ });
844
+
845
+ it("says 'No matches' rather than counting to zero", async () => {
846
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
847
+ await screen.findByTestId("seen");
848
+ await openFind();
849
+ await userEvent.keyboard("zzz");
850
+
851
+ await waitFor(() => {
852
+ expect(screen.getByRole("status")).toHaveTextContent("No matches");
853
+ });
854
+ expect(screen.getByRole("status")).not.toHaveTextContent("0 of 0");
855
+ });
856
+
857
+ it("keeps the step controls focusable when there is nothing to step to", async () => {
858
+ // `aria-disabled`, never the native attribute: a focused control that goes
859
+ // `disabled` is dropped from the focus order, stranding a keyboard reader.
860
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
861
+ await screen.findByTestId("seen");
862
+ await openFind();
863
+
864
+ const next = screen.getByRole("button", { name: "Next match" });
865
+ expect(next).toHaveAttribute("aria-disabled", "true");
866
+ expect(next).not.toBeDisabled();
867
+ next.focus();
868
+ expect(next).toHaveFocus();
869
+ });
870
+
871
+ it("closes on Escape and hands the caret back to the document", async () => {
872
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
873
+ await screen.findByTestId("seen");
874
+ const content = await openFind();
875
+ await userEvent.keyboard("{Escape}");
876
+
877
+ expect(screen.queryByRole("search")).not.toBeInTheDocument();
878
+ expect(content).toHaveFocus();
879
+ });
880
+
881
+ it("matches case only when asked to", async () => {
882
+ render(
883
+ <FileViewer
884
+ source={{ kind: "text", text: "Hello hello", name: "notes.txt" }}
885
+ registry={spyRegistry(["range"])}
886
+ />,
887
+ );
888
+ await screen.findByTestId("seen");
889
+ await openFind();
890
+ await userEvent.keyboard("hello");
891
+ await waitFor(() => {
892
+ expect(screen.getByRole("status")).toHaveTextContent("1 of 2");
893
+ });
894
+
895
+ await userEvent.click(screen.getByRole("button", { name: "Match case" }));
896
+ await waitFor(() => {
897
+ expect(screen.getByRole("status")).toHaveTextContent("1 of 1");
898
+ });
899
+ });
900
+ });
901
+
902
+ describe("FileViewer — a citation that cannot be located", () => {
903
+ const MISSING: DocumentHighlight = { id: "c9", address: { kind: "quote", text: "not in here" } };
904
+
905
+ it("says so, rather than showing an untouched document", async () => {
906
+ render(
907
+ <FileViewer source={textSource} registry={spyRegistry(["quote"])} highlights={[MISSING]} />,
908
+ );
909
+ expect(await screen.findByText(/Couldn't find that passage/)).toBeInTheDocument();
910
+ // Information, not a failure: the file itself opened fine.
911
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
912
+ });
913
+
914
+ it("distinguishes 'not in the document' from 'past what we previewed'", async () => {
915
+ render(
916
+ <FileViewer
917
+ source={textSource}
918
+ registry={spyRegistry(["quote"], { truncated: true })}
919
+ highlights={[MISSING]}
920
+ />,
921
+ );
922
+ expect(await screen.findByText(/beyond the part of this document/)).toBeInTheDocument();
923
+ });
924
+
925
+ it("calls a kind the adapter never declared a capability gap, not a miss", async () => {
926
+ render(
927
+ <FileViewer
928
+ source={textSource}
929
+ registry={spyRegistry()}
930
+ highlights={[{ id: "c1", address: { kind: "rect", page: 1, rects: [] } }]}
931
+ />,
932
+ );
933
+ expect(await screen.findByText(/can't point at part of a/)).toBeInTheDocument();
934
+ });
935
+
936
+ it("stays quiet for a fruitless SEARCH — the find bar already counts that", async () => {
937
+ render(<FileViewer source={textSource} registry={spyRegistry(["range"])} />);
938
+ await screen.findByTestId("seen");
939
+ await openFind();
940
+ await userEvent.keyboard("zzz");
941
+
942
+ await waitFor(() => {
943
+ expect(screen.getByRole("status")).toHaveTextContent("No matches");
944
+ });
945
+ expect(screen.queryByText(/Couldn't find that passage/)).not.toBeInTheDocument();
946
+ });
947
+ });