relaton 3.0.6 → 3.0.8

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/bin/relaton +20 -0
  3. data/lib/relaton/bib/model/docidentifier.rb +17 -2
  4. data/lib/relaton/bibcollection.rb +146 -0
  5. data/lib/relaton/bibdata.rb +92 -0
  6. data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
  7. data/lib/relaton/cli/base_convertor.rb +134 -0
  8. data/lib/relaton/cli/command.rb +390 -0
  9. data/lib/relaton/cli/data_fetcher.rb +20 -0
  10. data/lib/relaton/cli/frontend/dist/404.html +18 -0
  11. data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
  12. data/lib/relaton/cli/frontend/dist/style.css +3 -0
  13. data/lib/relaton/cli/frontend/index.html +14 -0
  14. data/lib/relaton/cli/frontend/package-lock.json +2945 -0
  15. data/lib/relaton/cli/frontend/package.json +29 -0
  16. data/lib/relaton/cli/frontend/public/404.html +18 -0
  17. data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
  18. data/lib/relaton/cli/frontend/src/App.vue +506 -0
  19. data/lib/relaton/cli/frontend/src/app.ts +90 -0
  20. data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
  21. data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
  22. data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
  23. data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
  24. data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
  25. data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
  26. data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
  27. data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
  28. data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
  29. data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
  30. data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
  31. data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
  32. data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
  33. data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
  34. data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
  35. data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
  36. data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
  37. data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
  38. data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
  39. data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
  40. data/lib/relaton/cli/frontend/tsconfig.json +20 -0
  41. data/lib/relaton/cli/frontend/vite.config.ts +42 -0
  42. data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
  43. data/lib/relaton/cli/frontend_assets.rb +58 -0
  44. data/lib/relaton/cli/full_text_search.rb +98 -0
  45. data/lib/relaton/cli/i18n.yaml +1 -0
  46. data/lib/relaton/cli/index_item_normalizer.rb +380 -0
  47. data/lib/relaton/cli/index_site_generator.rb +1015 -0
  48. data/lib/relaton/cli/relaton_file.rb +290 -0
  49. data/lib/relaton/cli/subcommand_collection.rb +263 -0
  50. data/lib/relaton/cli/subcommand_db.rb +81 -0
  51. data/lib/relaton/cli/templates/_document.liquid +111 -0
  52. data/lib/relaton/cli/templates/_index.liquid +61 -0
  53. data/lib/relaton/cli/templates/index/page.liquid +20 -0
  54. data/lib/relaton/cli/templates/index-style.css +1017 -0
  55. data/lib/relaton/cli/util.rb +8 -0
  56. data/lib/relaton/cli/version.rb +11 -0
  57. data/lib/relaton/cli/xml_convertor.rb +47 -0
  58. data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
  59. data/lib/relaton/cli/yaml_convertor.rb +102 -0
  60. data/lib/relaton/cli.rb +113 -0
  61. data/lib/relaton/doi/CLAUDE.md +1 -1
  62. data/lib/relaton/element_finder.rb +29 -0
  63. data/lib/relaton/gb/CLAUDE.md +1 -1
  64. data/lib/relaton/ietf/CLAUDE.md +1 -1
  65. data/lib/relaton/iho/CLAUDE.md +3 -3
  66. data/lib/relaton/iho/bibliography.rb +41 -27
  67. data/lib/relaton/iho/processor.rb +3 -2
  68. data/lib/relaton/nist/CLAUDE.md +11 -1
  69. data/lib/relaton/nist/README.adoc +7 -1
  70. data/lib/relaton/nist/hit_collection.rb +25 -1
  71. data/lib/relaton/nist/mods_parser.rb +32 -8
  72. data/lib/relaton/nist/pubs_export.rb +69 -11
  73. data/lib/relaton/ogc/CLAUDE.md +1 -1
  74. data/lib/relaton/omg/CLAUDE.md +1 -1
  75. data/lib/relaton/plateau/CLAUDE.md +1 -1
  76. data/lib/relaton/render/general.rb +319 -0
  77. data/lib/relaton/render/iso690/csl_import.rb +129 -0
  78. data/lib/relaton/render/iso690/element.rb +153 -0
  79. data/lib/relaton/render/iso690/elements/access.rb +44 -0
  80. data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
  81. data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
  82. data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
  83. data/lib/relaton/render/iso690/elements/creator.rb +267 -0
  84. data/lib/relaton/render/iso690/elements/edition.rb +97 -0
  85. data/lib/relaton/render/iso690/elements/extent.rb +71 -0
  86. data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
  87. data/lib/relaton/render/iso690/elements/location.rb +73 -0
  88. data/lib/relaton/render/iso690/elements/medium.rb +32 -0
  89. data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
  90. data/lib/relaton/render/iso690/elements/production.rb +111 -0
  91. data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
  92. data/lib/relaton/render/iso690/elements/series.rb +66 -0
  93. data/lib/relaton/render/iso690/elements/size.rb +45 -0
  94. data/lib/relaton/render/iso690/elements/status.rb +30 -0
  95. data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
  96. data/lib/relaton/render/iso690/elements/title.rb +56 -0
  97. data/lib/relaton/render/iso690/elements/updated.rb +38 -0
  98. data/lib/relaton/render/iso690/elements.rb +67 -0
  99. data/lib/relaton/render/iso690/fields.rb +142 -0
  100. data/lib/relaton/render/iso690/i18n/en.yml +48 -0
  101. data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
  102. data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
  103. data/lib/relaton/render/iso690/i18n.rb +83 -0
  104. data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
  105. data/lib/relaton/render/iso690/index_renderer.rb +57 -0
  106. data/lib/relaton/render/iso690/kinds.rb +53 -0
  107. data/lib/relaton/render/iso690/renderer.rb +150 -0
  108. data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
  109. data/lib/relaton/render/iso690/rules/jis.rb +103 -0
  110. data/lib/relaton/render/iso690/rules/nist.rb +384 -0
  111. data/lib/relaton/render/iso690/rules.rb +227 -0
  112. data/lib/relaton/render/iso690/style/locale.rb +59 -0
  113. data/lib/relaton/render/iso690/style/name_form.rb +87 -0
  114. data/lib/relaton/render/iso690/style/scheme.rb +33 -0
  115. data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
  116. data/lib/relaton/render/iso690/style/template_map.rb +40 -0
  117. data/lib/relaton/render/iso690/style/type_template.rb +45 -0
  118. data/lib/relaton/render/iso690/style.rb +227 -0
  119. data/lib/relaton/render/iso690/styles/apa.yml +45 -0
  120. data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
  121. data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
  122. data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
  123. data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
  124. data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
  125. data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
  126. data/lib/relaton/render/iso690/styles/mla.yml +56 -0
  127. data/lib/relaton/render/iso690/template.rb +248 -0
  128. data/lib/relaton/render/iso690.rb +28 -0
  129. data/lib/relaton/render/version.rb +5 -0
  130. data/lib/relaton/un/CLAUDE.md +1 -1
  131. data/lib/relaton/version.rb +1 -1
  132. data/lib/relaton-cli.rb +17 -0
  133. data/lib/relaton-render.rb +13 -0
  134. metadata +192 -21
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "relaton-index-frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.0.0",
6
+ "description": "Compiled Vue+Tailwind island for the relaton-cli `index` command. Builds a single IIFE bundled into the relaton-cli gem.",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "typecheck": "vue-tsc --noEmit",
11
+ "test": "vitest run"
12
+ },
13
+ "devDependencies": {
14
+ "@tailwindcss/vite": "^4.0.0",
15
+ "@vitejs/plugin-vue": "^6.0.0",
16
+ "@vue/test-utils": "^2.4.11",
17
+ "happy-dom": "^21.0.4",
18
+ "tailwindcss": "^4.0.0",
19
+ "terser": "^5.0.0",
20
+ "typescript": "^5.4.0",
21
+ "vite": "^8.0.0",
22
+ "vitest": "^5.0.5",
23
+ "vue": "^3.5.0",
24
+ "vue-tsc": "^2.0.0"
25
+ },
26
+ "overrides": {
27
+ "minimatch": "^11.0.1"
28
+ }
29
+ }
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Redirecting…</title>
6
+ <script>
7
+ // GitHub Pages has no URL rewrites, so path-based document URLs
8
+ // (/doc/<id>) land on this 404 fallback. Hand the id to the app as
9
+ // ?doc=<id>, preserving the site's base path wherever it is hosted.
10
+ (function () {
11
+ var m = location.pathname.match(/\/doc\/([^/?#]+)$/);
12
+ var root = m ? location.pathname.slice(0, m.index) : location.pathname.replace(/[^/]*$/, "");
13
+ location.replace(root + "?doc=" + (m ? m[1] : ""));
14
+ })();
15
+ </script>
16
+ </head>
17
+ <body></body>
18
+ </html>
@@ -0,0 +1,501 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { reactive } from "vue";
4
+ import { flushPromises, mount } from "@vue/test-utils";
5
+ import App from "./App.vue";
6
+ import type { DetailRecord, Hydration, IndexData } from "./lib/types";
7
+
8
+ function hydration(over: Partial<Hydration> = {}): Hydration {
9
+ return { loading: false, total: null, requestAll: () => {}, ...over };
10
+ }
11
+
12
+ const data: IndexData = {
13
+ title: "BIPM Index",
14
+ documents: [
15
+ { id: "CCRI 2", title: "Second meeting", doctype: "meeting-report", stage: "published", date: "2009-06-19", link: null, yaml: "ccri/2.yaml" },
16
+ { id: "CCRI 10", title: "Tenth gathering", doctype: "meeting-report", stage: "draft", date: "2019-01-01", link: null, yaml: "ccri/10.yaml" },
17
+ { id: "SI Brochure", title: "The SI", doctype: "brochure", stage: "published", date: "2019-05-20", link: "http://x", yaml: "si.yaml" },
18
+ ],
19
+ };
20
+
21
+ const many: IndexData = {
22
+ title: "Big Index",
23
+ documents: Array.from({ length: 60 }, (_, i) => ({
24
+ id: `DOC ${i + 1}`, title: `Title ${i + 1}`,
25
+ doctype: "report", stage: "published", date: "2020-01-01",
26
+ link: null, yaml: `d/${i + 1}.yaml`,
27
+ })),
28
+ };
29
+
30
+ beforeEach(() => {
31
+ // A mount node is expected to exist (goto() scrolls it into view).
32
+ document.body.innerHTML = '<div id="relaton-index-app"></div>';
33
+ // Reset the URL so ?page= from one test doesn't leak into the next.
34
+ window.history.replaceState(null, "", "/");
35
+ });
36
+
37
+ describe("App island", () => {
38
+ it("renders a row per document and the title", () => {
39
+ const w = mount(App, { props: { data } });
40
+ expect(w.text()).toContain("BIPM Index");
41
+ expect(w.findAll(".document")).toHaveLength(3);
42
+ });
43
+
44
+ it("renders the description as the header subtitle when given", () => {
45
+ const w = mount(App, { props: { data: { ...data, description: "The BIPM index." } } });
46
+ expect(w.text()).toContain("The BIPM index.");
47
+ expect(w.text()).not.toContain("Please use the provided Relaton DocID");
48
+ });
49
+
50
+ it("falls back to the default subtitle without a description", () => {
51
+ const w = mount(App, { props: { data } });
52
+ expect(w.text()).toContain("Please use the provided Relaton DocID to refer to an item.");
53
+ });
54
+
55
+ it("filters by the search query", async () => {
56
+ const w = mount(App, { props: { data } });
57
+ await w.find('input[type="search"]').setValue("brochure");
58
+ expect(w.findAll(".document")).toHaveLength(1);
59
+ expect(w.text()).toContain("The SI");
60
+ });
61
+
62
+ it("shows the count summary", () => {
63
+ const w = mount(App, { props: { data } });
64
+ expect(w.text()).toContain("3 of 3 documents");
65
+ });
66
+
67
+ it("paginates to the viewport and mirrors the controls top and bottom", async () => {
68
+ const original = window.innerHeight;
69
+ Object.defineProperty(window, "innerHeight", { value: 400, configurable: true });
70
+ try {
71
+ const w = mount(App, { props: { data: many } });
72
+ await flushPromises();
73
+
74
+ const rows = w.findAll(".document").length;
75
+ expect(rows).toBeGreaterThan(0);
76
+ expect(rows).toBeLessThan(60); // paginated, not all 60 at once
77
+
78
+ // Pagination is rendered both above and below the list.
79
+ const navs = w.findAll('nav[aria-label="Pagination"]');
80
+ expect(navs).toHaveLength(2);
81
+ } finally {
82
+ Object.defineProperty(window, "innerHeight", { value: original, configurable: true });
83
+ }
84
+ });
85
+ });
86
+
87
+ describe("page number in the URL", () => {
88
+ const withSmallViewport = async (fn: () => Promise<void>) => {
89
+ const original = window.innerHeight;
90
+ Object.defineProperty(window, "innerHeight", { value: 400, configurable: true });
91
+ try {
92
+ await fn();
93
+ } finally {
94
+ Object.defineProperty(window, "innerHeight", { value: original, configurable: true });
95
+ }
96
+ };
97
+
98
+ it("restores the current page from ?page= on mount", async () => {
99
+ window.history.replaceState(null, "", "/?page=2");
100
+ await withSmallViewport(async () => {
101
+ const w = mount(App, { props: { data: many } });
102
+ await flushPromises();
103
+ expect(w.text()).toContain("Page 2 of");
104
+ });
105
+ });
106
+
107
+ it("pushes a new history entry and updates the URL when paging next", async () => {
108
+ await withSmallViewport(async () => {
109
+ const w = mount(App, { props: { data: many } });
110
+ await flushPromises();
111
+ const push = vi.spyOn(window.history, "pushState");
112
+
113
+ // Click the first Prev/Next nav's "Next" button.
114
+ await w.findAll('nav[aria-label="Pagination"] button')
115
+ .find((b) => b.text() === "Next")!.trigger("click");
116
+ await flushPromises();
117
+
118
+ expect(push).toHaveBeenCalledTimes(1);
119
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("2");
120
+ });
121
+ });
122
+
123
+ it("clamps an out-of-range ?page= to the last page on mount", async () => {
124
+ window.history.replaceState(null, "", "/?page=999");
125
+ await withSmallViewport(async () => {
126
+ const w = mount(App, { props: { data: many } });
127
+ await flushPromises();
128
+ // 60 docs at the small-viewport page size → 6 pages.
129
+ expect(w.text()).toContain("Page 6 of 6");
130
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("6");
131
+ });
132
+ });
133
+
134
+ it("syncs the page from the URL on Back/Forward (popstate)", async () => {
135
+ await withSmallViewport(async () => {
136
+ const w = mount(App, { props: { data: many } });
137
+ await flushPromises();
138
+ expect(w.text()).toContain("Page 1 of");
139
+
140
+ // Simulate the browser navigating Back to a ?page=3 history entry.
141
+ window.history.replaceState(null, "", "/?page=3");
142
+ window.dispatchEvent(new PopStateEvent("popstate"));
143
+ await flushPromises();
144
+ expect(w.text()).toContain("Page 3 of");
145
+ });
146
+ });
147
+
148
+ it("resets to page 1 and clears the param when the filter changes", async () => {
149
+ window.history.replaceState(null, "", "/?page=3");
150
+ await withSmallViewport(async () => {
151
+ const w = mount(App, { props: { data: many } });
152
+ await flushPromises();
153
+ expect(w.text()).toContain("Page 3 of");
154
+
155
+ await w.find('input[type="search"]').setValue("Title 1");
156
+ await flushPromises();
157
+
158
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false);
159
+ });
160
+ });
161
+ });
162
+
163
+ describe("document detail page", () => {
164
+ const isDetail = (w: ReturnType<typeof mount>) =>
165
+ w.find('[aria-label="Back to index"]').exists();
166
+
167
+ it("opens the detail view and sets a path-based /doc/ URL when a row is clicked", async () => {
168
+ const w = mount(App, { props: { data } });
169
+ // The first row's DocID link.
170
+ await w.find(".document a").trigger("click");
171
+ await flushPromises();
172
+
173
+ expect(isDetail(w)).toBe(true);
174
+ expect(w.find('input[type="search"]').exists()).toBe(false);
175
+ expect(decodeURIComponent(window.location.pathname)).toBe("/doc/CCRI 2");
176
+ });
177
+
178
+ it("restores the open document from ?doc= on mount", async () => {
179
+ window.history.replaceState(null, "", "/?doc=SI%20Brochure");
180
+ const w = mount(App, { props: { data } });
181
+ await flushPromises();
182
+
183
+ expect(isDetail(w)).toBe(true);
184
+ expect(w.text()).toContain("The SI");
185
+ });
186
+
187
+ it("returns to the list on Back/Forward (popstate)", async () => {
188
+ const w = mount(App, { props: { data } });
189
+ await w.find(".document a").trigger("click");
190
+ await flushPromises();
191
+ expect(isDetail(w)).toBe(true);
192
+
193
+ // Simulate the browser navigating Back to the param-free list URL.
194
+ window.history.replaceState(null, "", "/");
195
+ window.dispatchEvent(new PopStateEvent("popstate"));
196
+ await flushPromises();
197
+
198
+ expect(isDetail(w)).toBe(false);
199
+ expect(w.find('input[type="search"]').exists()).toBe(true);
200
+ });
201
+
202
+ it("falls back to the list for an unknown ?doc=", async () => {
203
+ window.history.replaceState(null, "", "/?doc=NOPE");
204
+ const w = mount(App, { props: { data } });
205
+ await flushPromises();
206
+
207
+ expect(isDetail(w)).toBe(false);
208
+ expect(w.find('input[type="search"]').exists()).toBe(true);
209
+ });
210
+
211
+ it("goes back to the list when the detail Back control is clicked", async () => {
212
+ const w = mount(App, { props: { data } });
213
+ await w.find(".document a").trigger("click");
214
+ await flushPromises();
215
+ expect(isDetail(w)).toBe(true);
216
+
217
+ await w.find('[aria-label="Back to index"]').trigger("click");
218
+ await flushPromises();
219
+
220
+ expect(isDetail(w)).toBe(false);
221
+ expect(new URLSearchParams(window.location.search).has("doc")).toBe(false);
222
+ });
223
+ });
224
+
225
+ // Summary shards arrive progressively, so for a while the app holds a corpus it
226
+ // knows is incomplete. Anything that treats "absent" as "does not exist" has to
227
+ // wait for `loading` to clear, or every deep link into a large index breaks.
228
+ describe("progressive shard loading", () => {
229
+ const isDetail = (w: ReturnType<typeof mount>) =>
230
+ w.find('[aria-label="Back to index"]').exists();
231
+
232
+ it("reports the corpus total, not just what has landed", () => {
233
+ const w = mount(App, {
234
+ props: { data, hydration: hydration({ loading: true, total: 166658 }) },
235
+ });
236
+ expect(w.text()).toContain("3 of 166658 documents");
237
+ expect(w.text()).toContain("loading…");
238
+ });
239
+
240
+ it("shows no loading hint once every shard has landed", () => {
241
+ const w = mount(App, { props: { data, hydration: hydration({ total: 3 }) } });
242
+ expect(w.text()).not.toContain("loading…");
243
+ });
244
+
245
+ it("keeps an unresolved ?doc= while loading instead of bouncing to the list", async () => {
246
+ window.history.replaceState(null, "", "/?doc=NOT%20YET");
247
+ const w = mount(App, {
248
+ props: { data, hydration: hydration({ loading: true, total: 99 }) },
249
+ });
250
+ await flushPromises();
251
+
252
+ expect(new URLSearchParams(window.location.search).get("doc")).toBe("NOT YET");
253
+ expect(isDetail(w)).toBe(false);
254
+ });
255
+
256
+ it("asks for the rest of the corpus when a deep link is unresolved", async () => {
257
+ window.history.replaceState(null, "", "/?doc=NOT%20YET");
258
+ const requestAll = vi.fn();
259
+ mount(App, {
260
+ props: { data, hydration: hydration({ loading: true, total: 99, requestAll }) },
261
+ });
262
+ await flushPromises();
263
+
264
+ expect(requestAll).toHaveBeenCalled();
265
+ });
266
+
267
+ it("drops a still-unresolved ?doc= once loading finishes", async () => {
268
+ window.history.replaceState(null, "", "/?doc=NEVER%20ARRIVES");
269
+ const hyd = reactive(hydration({ loading: true, total: 99 }));
270
+ const w = mount(App, { props: { data, hydration: hyd } });
271
+ await flushPromises();
272
+ expect(new URLSearchParams(window.location.search).get("doc")).toBe("NEVER ARRIVES");
273
+
274
+ hyd.loading = false;
275
+ await flushPromises();
276
+
277
+ expect(new URLSearchParams(window.location.search).has("doc")).toBe(false);
278
+ expect(isDetail(w)).toBe(false);
279
+ });
280
+
281
+ it("does not clamp ?page= against a partial corpus, then clamps when complete", async () => {
282
+ window.history.replaceState(null, "", "/?page=999");
283
+ const original = window.innerHeight;
284
+ Object.defineProperty(window, "innerHeight", { value: 400, configurable: true });
285
+ try {
286
+ const hyd = reactive(hydration({ loading: true, total: 60 }));
287
+ const w = mount(App, { props: { data: many, hydration: hyd } });
288
+ await flushPromises();
289
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("999");
290
+
291
+ hyd.loading = false;
292
+ await flushPromises();
293
+
294
+ expect(w.text()).toContain("Page 6 of 6");
295
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("6");
296
+ } finally {
297
+ Object.defineProperty(window, "innerHeight", { value: original, configurable: true });
298
+ }
299
+ });
300
+
301
+ it("hurries the load along when the user searches", async () => {
302
+ const requestAll = vi.fn();
303
+ const w = mount(App, {
304
+ props: { data, hydration: hydration({ loading: true, total: 99, requestAll }) },
305
+ });
306
+ requestAll.mockClear();
307
+
308
+ await w.find('input[type="search"]').setValue("brochure");
309
+ await flushPromises();
310
+
311
+ expect(requestAll).toHaveBeenCalled();
312
+ });
313
+ });
314
+
315
+ describe("on-demand detail fields", () => {
316
+ it("fetches the detail record when a document is opened and fills the panel in", async () => {
317
+ const loadDetail = vi.fn(
318
+ async (): Promise<DetailRecord> => ({
319
+ r: "CCRI 2",
320
+ abstract: "A late-arriving abstract.",
321
+ }),
322
+ );
323
+ const w = mount(App, { props: { data, loadDetail } });
324
+
325
+ await w.find(".document a").trigger("click");
326
+ await flushPromises();
327
+
328
+ // Position in the corpus is what addresses the detail shard.
329
+ expect(loadDetail).toHaveBeenCalledWith(0, "CCRI 2");
330
+ expect(w.text()).toContain("A late-arriving abstract.");
331
+ });
332
+
333
+ // A failed summary shard leaves a hole, after which array index and corpus
334
+ // position disagree. The position stamped on by the loader is authoritative —
335
+ // using indexOf here would address the wrong detail shard for every document
336
+ // after the hole.
337
+ it("addresses the detail shard by corpus position, not array index", async () => {
338
+ const holed: IndexData = {
339
+ title: "Holed",
340
+ documents: [
341
+ { ...data.documents[0], pos: 0 },
342
+ // its shard failed, so this one sits at array index 1 but position 5000
343
+ { ...data.documents[1], pos: 5000 },
344
+ ],
345
+ };
346
+ const loadDetail = vi.fn(async (): Promise<DetailRecord | null> => null);
347
+ const w = mount(App, { props: { data: holed, loadDetail } });
348
+
349
+ const links = w.findAll(".document a");
350
+ await links[links.length - 1].trigger("click");
351
+ await flushPromises();
352
+
353
+ expect(loadDetail).toHaveBeenCalledWith(5000, "CCRI 10");
354
+ });
355
+
356
+ it("asks for each document at most once", async () => {
357
+ const loadDetail = vi.fn(async (): Promise<DetailRecord | null> => null);
358
+ const w = mount(App, { props: { data, loadDetail } });
359
+
360
+ await w.find(".document a").trigger("click");
361
+ await flushPromises();
362
+ await w.find('[aria-label="Back to index"]').trigger("click");
363
+ await flushPromises();
364
+ await w.find(".document a").trigger("click");
365
+ await flushPromises();
366
+
367
+ expect(loadDetail).toHaveBeenCalledTimes(1);
368
+ });
369
+
370
+ it("renders the summary fields when no detail shard is available", async () => {
371
+ const w = mount(App, { props: { data } });
372
+ await w.find(".document a").trigger("click");
373
+ await flushPromises();
374
+
375
+ expect(w.find('[aria-label="Back to index"]').exists()).toBe(true);
376
+ expect(w.text()).toContain("Second meeting");
377
+ });
378
+
379
+ // loadSearchShards emits a NEW array each batch, carrying the previous
380
+ // batches' element objects forward. Detail fields are merged into one of those
381
+ // objects, and each document is only ever fetched once — so if a later batch
382
+ // replaced the objects rather than re-listing them, an open panel would
383
+ // silently lose its abstract with no second chance to refetch.
384
+ it("keeps merged detail fields when a later shard batch arrives", async () => {
385
+ const state = reactive<IndexData>({
386
+ title: "Big Index",
387
+ documents: data.documents.slice(0, 2),
388
+ });
389
+ const loadDetail = vi.fn(
390
+ async (): Promise<DetailRecord> => ({
391
+ r: "CCRI 2",
392
+ abstract: "Merged before the next batch.",
393
+ }),
394
+ );
395
+ const w = mount(App, {
396
+ props: { data: state, hydration: hydration({ loading: true, total: 3 }), loadDetail },
397
+ });
398
+
399
+ await w.find(".document a").trigger("click");
400
+ await flushPromises();
401
+ expect(w.text()).toContain("Merged before the next batch.");
402
+
403
+ // A further shard lands: same element objects, plus a new one.
404
+ state.documents = [...state.documents, data.documents[2]];
405
+ await flushPromises();
406
+
407
+ expect(w.text()).toContain("Merged before the next batch.");
408
+ expect(loadDetail).toHaveBeenCalledTimes(1);
409
+ });
410
+ });
411
+
412
+ describe("relations on the detail page", () => {
413
+ const isDetail = (w: ReturnType<typeof mount>) =>
414
+ w.find('[aria-label="Back to index"]').exists();
415
+
416
+ // "SI Brochure" is in this dataset; "ISO 9999" is not.
417
+ const withRelations = (): DetailRecord => ({
418
+ r: "CCRI 2",
419
+ relations: [
420
+ { type: "obsoletedBy", id: "SI Brochure" },
421
+ { type: "updates", id: "ISO 9999" },
422
+ ],
423
+ });
424
+
425
+ it("navigates to the related document when its link is clicked", async () => {
426
+ const loadDetail = vi.fn(async () => withRelations());
427
+ const w = mount(App, { props: { data, loadDetail } });
428
+
429
+ await w.find(".document a").trigger("click");
430
+ await flushPromises();
431
+
432
+ const link = w.findAll("a").find((a) => a.text() === "SI Brochure");
433
+ expect(link).toBeDefined();
434
+ await link!.trigger("click");
435
+ await flushPromises();
436
+
437
+ expect(isDetail(w)).toBe(true);
438
+ expect(w.text()).toContain("The SI");
439
+ expect(decodeURIComponent(window.location.pathname)).toBe("/doc/SI Brochure");
440
+ });
441
+
442
+ it("leaves a target outside the dataset unlinked but visible", async () => {
443
+ const loadDetail = vi.fn(async () => withRelations());
444
+ const w = mount(App, { props: { data, loadDetail } });
445
+
446
+ await w.find(".document a").trigger("click");
447
+ await flushPromises();
448
+
449
+ expect(w.findAll("a").map((a) => a.text())).not.toContain("ISO 9999");
450
+ expect(w.text()).toContain("ISO 9999");
451
+ });
452
+
453
+ // While summary shards are still arriving the corpus is incomplete, so a real
454
+ // intra-dataset target can look absent. Hurry the load rather than render it
455
+ // permanently as plain text.
456
+ it("hurries the shard load when the opened document has relations", async () => {
457
+ const requestAll = vi.fn();
458
+ const loadDetail = vi.fn(async () => withRelations());
459
+ const w = mount(App, {
460
+ props: { data, loadDetail, hydration: reactive(hydration({ loading: true, requestAll })) },
461
+ });
462
+
463
+ await w.find(".document a").trigger("click");
464
+ await flushPromises();
465
+
466
+ expect(requestAll).toHaveBeenCalled();
467
+ });
468
+ });
469
+
470
+ // The corpus loads progressively, so a relation target can be genuinely present
471
+ // yet not loaded yet. Resolution reads a computed id set through the `hasDoc`
472
+ // predicate, so the link must appear on its own once the shard carrying the
473
+ // target lands — without reopening the detail panel.
474
+ describe("relation links upgrade as the corpus loads", () => {
475
+ it("turns a plain-text target into a link when its shard arrives", async () => {
476
+ const partial: IndexData = reactive({
477
+ title: "Loading",
478
+ documents: [{ ...data.documents[0], pos: 0 }],
479
+ });
480
+ const loadDetail = vi.fn(
481
+ async (): Promise<DetailRecord> => ({
482
+ r: "CCRI 2",
483
+ relations: [{ type: "obsoletedBy", id: "SI Brochure" }],
484
+ }),
485
+ );
486
+ const w = mount(App, { props: { data: partial, loadDetail } });
487
+
488
+ await w.find(".document a").trigger("click");
489
+ await flushPromises();
490
+
491
+ // Target not loaded yet -> shown, but not a link.
492
+ expect(w.text()).toContain("SI Brochure");
493
+ expect(w.findAll("a").map((a) => a.text())).not.toContain("SI Brochure");
494
+
495
+ // A later shard delivers it.
496
+ partial.documents.push({ ...data.documents[2], pos: 1 });
497
+ await flushPromises();
498
+
499
+ expect(w.findAll("a").map((a) => a.text())).toContain("SI Brochure");
500
+ });
501
+ });