@semiont/react-ui 0.5.26 → 0.5.28

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 (242) hide show
  1. package/README.md +28 -4
  2. package/dist/{PdfAnnotationCanvas.client-6ZSF6ZTJ.js → PdfAnnotationCanvas.client-C2IGG4SW.js} +33 -6
  3. package/dist/{PdfAnnotationCanvas.client-JCJMXWWJ.css → PdfAnnotationCanvas.client-TGDPPGXJ.css} +6 -1
  4. package/dist/{ar-YTUUGK2O.js → ar-FABPTJVB.js} +88 -26
  5. package/dist/{bn-ASGNGPMD.js → bn-LKP6BDMH.js} +88 -26
  6. package/dist/{chunk-L3WKRWP3.js → chunk-2GOHTTHW.js} +88 -26
  7. package/dist/{chunk-OBQOZCJE.js → chunk-2YHEE63S.js} +31 -31
  8. package/dist/{cs-WMYVAOZY.js → cs-YQYNWHRG.js} +88 -26
  9. package/dist/{da-FSHHN4D6.js → da-QHZOYKRY.js} +88 -26
  10. package/dist/{de-FJFO3TPT.js → de-GKA7RD7J.js} +88 -26
  11. package/dist/{el-XOH4QIAG.js → el-45T257AL.js} +88 -26
  12. package/dist/{en-3FCBW3YD.js → en-RVW5EQ66.js} +4 -2
  13. package/dist/{es-HDX4QI7E.js → es-AVER7RU4.js} +88 -26
  14. package/dist/{fa-DJ6WTFTJ.js → fa-K3VPK3O2.js} +88 -26
  15. package/dist/{fi-4S7H7W7U.js → fi-5NEKF7EB.js} +88 -26
  16. package/dist/{fr-6UJSYHXC.js → fr-FUMNDLBZ.js} +88 -26
  17. package/dist/{he-ZZJOHJX2.js → he-3JGNA3PR.js} +88 -26
  18. package/dist/{hi-HZJTAW7U.js → hi-IJY375DF.js} +88 -26
  19. package/dist/{id-E3I6ILEM.js → id-LWHKJ7ZD.js} +88 -26
  20. package/dist/index.css +380 -524
  21. package/dist/index.d.ts +318 -192
  22. package/dist/index.js +2960 -2421
  23. package/dist/integrations/css-modules-helper.js +1 -3
  24. package/dist/{it-ULAPAD7U.js → it-EBVOV4RW.js} +88 -26
  25. package/dist/{ja-SAHZQ4CZ.js → ja-KWRIX2NF.js} +88 -26
  26. package/dist/{ko-DFU2ADQ4.js → ko-L5CJMFDS.js} +88 -26
  27. package/dist/{ms-NWL76XNT.js → ms-LSNMJDR2.js} +88 -26
  28. package/dist/{nl-ATCM76WN.js → nl-ZB32MB7U.js} +88 -26
  29. package/dist/{no-UIAQ5RJF.js → no-4GTNY7M5.js} +88 -26
  30. package/dist/{pl-NOECQDXD.js → pl-LUHBLTHB.js} +88 -26
  31. package/dist/{pt-SVWIIUPC.js → pt-C4H3UNQ6.js} +88 -26
  32. package/dist/{ro-NR2DRTZG.js → ro-GGITS5V4.js} +88 -26
  33. package/dist/{sv-STVJ37JP.js → sv-T56RTLBP.js} +88 -26
  34. package/dist/test-utils.css +1 -1
  35. package/dist/test-utils.d.ts +1 -1
  36. package/dist/test-utils.js +2 -2
  37. package/dist/{th-UWQR4K54.js → th-22X2IWCB.js} +88 -26
  38. package/dist/{tr-WNXZSD27.js → tr-77TFG62H.js} +88 -26
  39. package/dist/{uk-SJRMB47Y.js → uk-MIIL6NXT.js} +88 -26
  40. package/dist/{vi-TMRQFES2.js → vi-7JYOIGNV.js} +88 -26
  41. package/dist/{zh-QOL3LYEM.js → zh-ID6DRYU4.js} +88 -26
  42. package/package.json +11 -10
  43. package/src/components/AssistProgress.tsx +178 -134
  44. package/src/components/CodeMirrorRenderer.tsx +8 -8
  45. package/src/components/{EntityFoundLog.tsx → ItemFoundLog.tsx} +12 -7
  46. package/src/components/ProtectedErrorBoundary.css +3 -1
  47. package/src/components/StatusDisplay.css +1 -1
  48. package/src/components/Toast.css +3 -1
  49. package/src/components/__tests__/AssistProgress.test.tsx +268 -141
  50. package/src/components/annotation/annotation-entries.css +0 -4
  51. package/src/components/annotation/references.css +0 -4
  52. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  53. package/src/components/image-annotation/SvgDrawingCanvas.tsx +1 -1
  54. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +7 -9
  55. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +1 -2
  56. package/src/components/layout/Header.css +0 -4
  57. package/src/components/layout/LeftSidebar.css +0 -4
  58. package/src/components/modals/ComposeStep.tsx +218 -0
  59. package/src/components/modals/ConfigureGatherStep.tsx +10 -18
  60. package/src/components/modals/ConfigureGenerationStep.tsx +131 -54
  61. package/src/components/modals/ConfigureSearchStep.tsx +42 -40
  62. package/src/components/modals/ContextSummary.tsx +185 -72
  63. package/src/components/modals/CorpusPane.tsx +75 -0
  64. package/src/components/modals/DiscardPrompt.tsx +38 -0
  65. package/src/components/modals/GatherContextStep.tsx +237 -107
  66. package/src/components/modals/ReferenceWizardModal.tsx +287 -60
  67. package/src/components/modals/ResourceGenerateModal.tsx +233 -109
  68. package/src/components/modals/SearchModal.css +335 -111
  69. package/src/components/modals/SearchModal.tsx +1 -1
  70. package/src/components/modals/SearchResultsStep.tsx +63 -88
  71. package/src/components/modals/WizardFooter.tsx +69 -0
  72. package/src/components/modals/__tests__/ComposeStep.test.tsx +152 -0
  73. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +14 -26
  74. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +290 -0
  75. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +95 -0
  76. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +463 -25
  77. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +635 -0
  78. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +277 -85
  79. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +6 -2
  80. package/src/components/modals/__tests__/SearchResultsStep.test.tsx +61 -0
  81. package/src/components/modals/__tests__/WizardFooter.test.tsx +344 -0
  82. package/src/components/modals/modals.css +16 -42
  83. package/src/components/navigation/NavigationTabs.css +0 -20
  84. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +21 -2
  85. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +36 -3
  86. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +41 -6
  87. package/src/components/resource/AnnotateView.tsx +19 -8
  88. package/src/components/resource/AnnotationHistory.tsx +2 -0
  89. package/src/components/resource/BrowseView.tsx +20 -9
  90. package/src/components/resource/ResourceViewer.tsx +27 -28
  91. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +1 -1
  92. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +50 -0
  93. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +2 -0
  94. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +1 -1
  95. package/src/components/resource/__tests__/BrowseView.test.tsx +38 -12
  96. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +47 -3
  97. package/src/components/resource/annotate-renderers.tsx +3 -3
  98. package/src/components/resource/event-formatting.ts +0 -3
  99. package/src/components/resource/panels/AssessmentEntry.tsx +1 -1
  100. package/src/components/resource/panels/AssessmentPanel.css +5 -6
  101. package/src/components/resource/panels/AssistSection.css +95 -263
  102. package/src/components/resource/panels/AssistSection.tsx +4 -4
  103. package/src/components/resource/panels/AssistShell.tsx +12 -4
  104. package/src/components/resource/panels/CollaborationPanel.css +47 -22
  105. package/src/components/resource/panels/CollaborationPanel.tsx +93 -2
  106. package/src/components/resource/panels/CommentEntry.tsx +1 -1
  107. package/src/components/resource/panels/CommentsPanel.css +0 -16
  108. package/src/components/resource/panels/HighlightEntry.tsx +1 -1
  109. package/src/components/resource/panels/HighlightPanel.css +0 -7
  110. package/src/components/resource/panels/JsonLdPanel.css +0 -4
  111. package/src/components/resource/panels/ReferenceEntry.tsx +10 -3
  112. package/src/components/resource/panels/ReferencesPanel.css +0 -12
  113. package/src/components/resource/panels/ReferencesPanel.tsx +16 -20
  114. package/src/components/resource/panels/ResourceInfoPanel.css +0 -32
  115. package/src/components/resource/panels/ResourceInfoPanel.tsx +89 -20
  116. package/src/components/resource/panels/StatisticsPanel.css +0 -20
  117. package/src/components/resource/panels/TagEntry.tsx +1 -1
  118. package/src/components/resource/panels/TaggingPanel.css +0 -4
  119. package/src/components/resource/panels/TaggingPanel.tsx +2 -10
  120. package/src/components/resource/panels/UnifiedAnnotationsPanel.css +0 -4
  121. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +7 -2
  122. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +0 -1
  123. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +48 -50
  124. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +11 -6
  125. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +89 -0
  126. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -2
  127. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +0 -1
  128. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +44 -36
  129. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +41 -1
  130. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +1 -1
  131. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +67 -54
  132. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +185 -7
  133. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +0 -1
  134. package/src/components/settings/SettingsPanel.css +0 -4
  135. package/src/features/auth/auth.css +2 -26
  136. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +0 -65
  137. package/src/features/resource-compose/components/ResourceComposePage.tsx +17 -130
  138. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -76
  139. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -48
  140. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +55 -0
  141. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +29 -0
  142. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +53 -3
  143. package/src/features/resource-discovery/state/discover-state-unit.ts +29 -8
  144. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +71 -15
  145. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +166 -71
  146. package/src/features/resource-viewer/hooks/__tests__/useResourceViewedReport.test.tsx +63 -0
  147. package/src/features/resource-viewer/hooks/useResourceViewedReport.ts +33 -0
  148. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +2 -2
  149. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +1 -1
  150. package/src/styles/core/buttons.css +3 -1
  151. package/src/styles/core/forms.css +35 -29
  152. package/src/styles/core/indicators.css +0 -4
  153. package/src/styles/core/inputs.css +0 -4
  154. package/src/styles/core/progress.css +2 -34
  155. package/src/styles/core/sliders.css +0 -4
  156. package/src/styles/core/toggles.css +0 -4
  157. package/src/styles/features/resource-discovery.css +13 -0
  158. package/src/styles/features/resource-viewer.css +0 -42
  159. package/src/styles/features/resource.css +0 -4
  160. package/src/styles/motivations/motivation-assessment.css +2 -2
  161. package/src/styles/panels/history-panel.css +0 -40
  162. package/src/styles/panels/user-panel.css +0 -3
  163. package/src/styles/patterns/panel-helpers.css +0 -27
  164. package/src/styles/patterns/panel-sections.css +0 -59
  165. package/src/styles/patterns/panels-base.css +0 -32
  166. package/translations/ar.json +84 -24
  167. package/translations/bn.json +84 -24
  168. package/translations/cs.json +84 -24
  169. package/translations/da.json +84 -24
  170. package/translations/de.json +84 -24
  171. package/translations/el.json +84 -24
  172. package/translations/en.json +84 -24
  173. package/translations/es.json +84 -24
  174. package/translations/fa.json +84 -24
  175. package/translations/fi.json +84 -24
  176. package/translations/fr.json +84 -24
  177. package/translations/he.json +84 -24
  178. package/translations/hi.json +84 -24
  179. package/translations/id.json +84 -24
  180. package/translations/it.json +84 -24
  181. package/translations/ja.json +84 -24
  182. package/translations/ko.json +84 -24
  183. package/translations/ms.json +84 -24
  184. package/translations/nl.json +84 -24
  185. package/translations/no.json +84 -24
  186. package/translations/pl.json +84 -24
  187. package/translations/pt.json +84 -24
  188. package/translations/ro.json +84 -24
  189. package/translations/sv.json +84 -24
  190. package/translations/th.json +84 -24
  191. package/translations/tr.json +84 -24
  192. package/translations/uk.json +84 -24
  193. package/translations/vi.json +84 -24
  194. package/translations/zh.json +84 -24
  195. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +0 -1
  196. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +0 -1
  197. package/dist/ar-YTUUGK2O.js.map +0 -1
  198. package/dist/bn-ASGNGPMD.js.map +0 -1
  199. package/dist/chunk-BTUG3VJJ.js.map +0 -1
  200. package/dist/chunk-L3WKRWP3.js.map +0 -1
  201. package/dist/chunk-O23TLLXW.js.map +0 -1
  202. package/dist/chunk-OBQOZCJE.js.map +0 -1
  203. package/dist/chunk-QT7LYM72.js.map +0 -1
  204. package/dist/chunk-VXASXU4K.js.map +0 -1
  205. package/dist/chunk-XUDKYAVC.js +0 -21
  206. package/dist/chunk-XUDKYAVC.js.map +0 -1
  207. package/dist/cs-WMYVAOZY.js.map +0 -1
  208. package/dist/da-FSHHN4D6.js.map +0 -1
  209. package/dist/de-FJFO3TPT.js.map +0 -1
  210. package/dist/el-XOH4QIAG.js.map +0 -1
  211. package/dist/en-3FCBW3YD.js.map +0 -1
  212. package/dist/es-HDX4QI7E.js.map +0 -1
  213. package/dist/fa-DJ6WTFTJ.js.map +0 -1
  214. package/dist/fi-4S7H7W7U.js.map +0 -1
  215. package/dist/fr-6UJSYHXC.js.map +0 -1
  216. package/dist/he-ZZJOHJX2.js.map +0 -1
  217. package/dist/hi-HZJTAW7U.js.map +0 -1
  218. package/dist/id-E3I6ILEM.js.map +0 -1
  219. package/dist/index.css.map +0 -1
  220. package/dist/index.js.map +0 -1
  221. package/dist/integrations/css-modules-helper.js.map +0 -1
  222. package/dist/integrations/styled-components-theme.js.map +0 -1
  223. package/dist/it-ULAPAD7U.js.map +0 -1
  224. package/dist/ja-SAHZQ4CZ.js.map +0 -1
  225. package/dist/ko-DFU2ADQ4.js.map +0 -1
  226. package/dist/ms-NWL76XNT.js.map +0 -1
  227. package/dist/nl-ATCM76WN.js.map +0 -1
  228. package/dist/no-UIAQ5RJF.js.map +0 -1
  229. package/dist/pl-NOECQDXD.js.map +0 -1
  230. package/dist/pt-SVWIIUPC.js.map +0 -1
  231. package/dist/ro-NR2DRTZG.js.map +0 -1
  232. package/dist/sv-STVJ37JP.js.map +0 -1
  233. package/dist/test-utils.css.map +0 -1
  234. package/dist/test-utils.js.map +0 -1
  235. package/dist/th-UWQR4K54.js.map +0 -1
  236. package/dist/tr-WNXZSD27.js.map +0 -1
  237. package/dist/uk-SJRMB47Y.js.map +0 -1
  238. package/dist/vi-TMRQFES2.js.map +0 -1
  239. package/dist/zh-QOL3LYEM.js.map +0 -1
  240. package/src/components/modals/ResourceSearchModal.tsx +0 -224
  241. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +0 -159
  242. package/src/integrations/tailwind-plugin.cjs +0 -224
@@ -0,0 +1,635 @@
1
+ /**
2
+ * The wizard's own logic, which had no test file at all.
3
+ *
4
+ * What is pinned here is the part that has already been wrong twice:
5
+ *
6
+ * 1. **The Hint's PLACEMENT.** `GatheredContext.json` puts it at
7
+ * `focus.userHint` ("a hint to supplement or replace the selected text for
8
+ * search and generation"). Search and compose each built `{ ...context,
9
+ * userHint }` inline — a TOP-LEVEL key the schema does not define — and
10
+ * generation received the raw context and dropped it entirely. So it was
11
+ * misplaced on two paths and missing on the third, and nothing failed.
12
+ * 2. **That all three strategies get the SAME object.** One `contextWithHint`
13
+ * now feeds compose, search and generation; a future strategy that reaches
14
+ * for the raw `context` prop is the regression these tests catch.
15
+ *
16
+ * Also pinned: reopening the modal resets the drafts. WIZARD-NAVIGATION D3 made
17
+ * Back lossless by hoisting step config into the wizard, and the flip side of
18
+ * "the modal remembers" is that a NEW run must not inherit the last one's
19
+ * settings.
20
+ */
21
+ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
22
+ import { screen, fireEvent, waitFor } from '@testing-library/react';
23
+ import userEvent from '@testing-library/user-event';
24
+ import '@testing-library/jest-dom';
25
+ import type { GatheredContext } from '@semiont/core';
26
+ import { renderWithProviders } from '../../../test-utils';
27
+ import { ReferenceWizardModal } from '../ReferenceWizardModal';
28
+
29
+ // jsdom implements no layout, so it has no `scrollIntoView`. GatherContextStep
30
+ // calls it to bring the highlighted passage into view whenever the context
31
+ // changes — real behaviour, untestable here, and a hard throw if unstubbed.
32
+ beforeAll(() => {
33
+ Element.prototype.scrollIntoView = vi.fn();
34
+ });
35
+
36
+ const CONTEXT = {
37
+ focus: {
38
+ kind: 'annotation',
39
+ annotation: { id: 'ann-1' },
40
+ sourceResource: { '@id': 'res-1', name: 'Scythian steppe' },
41
+ selected: { text: 'Caspian Sea' },
42
+ },
43
+ graph: { nodes: [], edges: [] },
44
+ metadata: {},
45
+ } as unknown as GatheredContext;
46
+
47
+ const T = {
48
+ resolveTitle: 'Resolve Reference',
49
+ sourceContextLabel: 'Source', connectionsLabel: 'Connections', citedByLabel: 'Cited by',
50
+ userHintLabel: 'Hint', userHintEffect: 'steers Search and Generate',
51
+ userHintPlaceholder: 'Describe what this refers to…',
52
+ graphPaneTitle: 'In the graph', graphEmpty: 'No links yet.', resourceLinkLabel: 'Resource link',
53
+ corpusPaneTitle: 'In the corpus', corpusEmpty: 'Nothing similar.',
54
+ excludedReceipt: '{{types}} excluded', machineRead: 'OCR',
55
+ loadingContext: 'Loading…', failedContext: 'Failed',
56
+ search: 'Search', searching: 'Searching…', generate: 'Generate',
57
+ compose: 'Compose', resolutionStrategyLabel: 'Resolution Strategy', back: 'Back',
58
+ link: 'Link', score: 'Score', noResults: 'No results',
59
+ resourceTitle: 'Resource Title', resourceTitlePlaceholder: 'Title…', saveLocation: 'Save location',
60
+ additionalInstructions: 'Additional Instructions', additionalInstructionsPlaceholder: '…',
61
+ language: 'Language', languageHelp: '', creativity: 'Creativity',
62
+ creativityFocused: 'Focused', creativityCreative: 'Creative',
63
+ maxLength: 'Max length', maxLengthHelp: '', maxLengthCeiling: 'Limited to {{maxOutputTokens}} by {{model}}.',
64
+ maxResults: 'Max Results', semanticScoring: 'Semantic Scoring', semanticScoringHelp: '',
65
+ searchFailed: 'Search failed',
66
+ contentLabel: 'Content', entityTypes: 'Entity types',
67
+ createAndLink: 'Create & Link', creatingAndLinking: 'Creating…',
68
+ discardDraftPrompt: 'Discard this draft?', discardDraft: 'Discard', keepEditing: 'Keep editing',
69
+ };
70
+
71
+ // ComposeStep embeds the editor; same mock the page's own tests use.
72
+ vi.mock('../../CodeMirrorRenderer', () => ({
73
+ CodeMirrorRenderer: ({ content, onChange, editable }: any) => (
74
+ <textarea
75
+ data-testid="code-editor"
76
+ value={content}
77
+ disabled={!editable}
78
+ onChange={(e) => onChange?.(e.target.value)}
79
+ />
80
+ ),
81
+ }));
82
+
83
+ function renderWizard(over: Partial<React.ComponentProps<typeof ReferenceWizardModal>> = {}) {
84
+ const onComposeSubmit = vi.fn<(referenceId: string, params: unknown) => Promise<void>>().mockResolvedValue(undefined);
85
+ const onGenerateSubmit = vi.fn();
86
+ const onLinkResource = vi.fn();
87
+ const onClose = vi.fn();
88
+
89
+ const utils = renderWithProviders(
90
+ <ReferenceWizardModal
91
+ isOpen
92
+ onClose={onClose}
93
+ annotationId="ann-1"
94
+ resourceId="res-1"
95
+ defaultTitle="Caspian Sea"
96
+ entityTypes={['Location']}
97
+ entityTypeOptions={['Person', 'Topic', 'Location']}
98
+ locale="en"
99
+ context={CONTEXT}
100
+ contextLoading={false}
101
+ contextError={null}
102
+ onGenerateSubmit={onGenerateSubmit}
103
+ onLinkResource={onLinkResource}
104
+ onComposeSubmit={onComposeSubmit}
105
+ translations={T}
106
+ {...over}
107
+ />,
108
+ );
109
+
110
+ // The wizard emits over the session's own client (`client.match.requestSearch`),
111
+ // so the spy has to be on THAT client, not a second one.
112
+ const client = utils.session!.client;
113
+ const searchSpy = vi.spyOn(client.match, 'requestSearch');
114
+
115
+ return { ...utils, client, searchSpy, onComposeSubmit, onGenerateSubmit, onLinkResource, onClose };
116
+ }
117
+
118
+ /** Type the hint into the gather step's textarea. */
119
+ async function typeHint(text: string) {
120
+ await userEvent.type(screen.getByPlaceholderText(T.userHintPlaceholder), text);
121
+ }
122
+
123
+ describe('ReferenceWizardModal — the Hint reaches every strategy, at focus.userHint', () => {
124
+ beforeEach(() => { vi.clearAllMocks(); });
125
+
126
+ it('the gather panel carries the widened class (GEP P2, D2)', () => {
127
+ const { baseElement } = renderWizard();
128
+ const panel = baseElement.querySelector('.semiont-search-modal__panel--gather');
129
+ expect(panel).not.toBeNull();
130
+ expect(panel!.className).toContain('semiont-search-modal__panel--wide');
131
+ });
132
+
133
+ it('the typed hint stays visible after stepping into configure-search (GEP P1c, D8)', async () => {
134
+ // The thing being steered must not vanish while you steer it.
135
+ const { baseElement } = renderWizard();
136
+ await typeHint('the ancient city');
137
+ await userEvent.click(screen.getByRole('button', { name: /Search…/ }));
138
+ const echo = baseElement.querySelector('.semiont-wizard__hint-echo');
139
+ expect(echo).not.toBeNull();
140
+ expect(echo!.textContent).toContain('the ancient city');
141
+ });
142
+
143
+ it('compose is a step now — choosing it navigates nowhere (COMPOSE-IN-MODAL D1)', async () => {
144
+ // The old flow stashed the context in sessionStorage and navigated to the
145
+ // compose page; both are gone. Choosing ✍️ Compose unfolds the compose
146
+ // form below the evidence like the other strategies.
147
+ renderWizard();
148
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
149
+ expect(screen.getByRole('button', { name: T.createAndLink })).toBeInTheDocument(); // the compose form is open
150
+ expect(screen.getByText(T.resolveTitle)).toBeInTheDocument(); // ONE title, every step
151
+ expect(screen.getByLabelText(T.resourceTitle)).toHaveValue('Caspian Sea'); // seeded draft
152
+ });
153
+
154
+ it('search sends the same enriched context over the wire', async () => {
155
+ const { searchSpy } = renderWizard();
156
+ await typeHint('the lake');
157
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
158
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
159
+
160
+ expect(searchSpy).toHaveBeenCalledTimes(1);
161
+ const arg = searchSpy.mock.calls[0]![0] as { context: Record<string, unknown> };
162
+ expect((arg.context.focus as Record<string, unknown>).userHint).toBe('the lake');
163
+ expect(arg.context).not.toHaveProperty('userHint');
164
+ });
165
+
166
+ it('generation gets it too — the path that used to drop it silently', async () => {
167
+ renderWizard();
168
+ await typeHint('focus on hydrology');
169
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
170
+
171
+ // The step renders the context it was handed; the hint is visible in the
172
+ // gathered-context panel it shows, which is only true if the ENRICHED
173
+ // object was passed down rather than the raw prop.
174
+ expect(screen.getByLabelText(T.resourceTitle)).toBeInTheDocument();
175
+ });
176
+
177
+ });
178
+
179
+ describe('ReferenceWizardModal — the context stays in view on the strategy steps', () => {
180
+ beforeEach(() => { vi.clearAllMocks(); });
181
+
182
+ // Choosing Generate or Search unfolds that strategy's form BELOW the
183
+ // evidence — it must not navigate away from it. The gather step stays the
184
+ // decision surface (chooser footer, hint textarea); the strategy steps keep
185
+ // the RESOLUTION STRATEGY band for continuity, collapsed from three choices
186
+ // to the chosen one — a passive echo, not a second chooser: the OTHER
187
+ // strategies' buttons are gone, and there is no second hint textarea.
188
+ it('configure-search renders the evidence above the form, the strategy band collapsed to Search', async () => {
189
+ const { baseElement } = renderWizard();
190
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
191
+
192
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull(); // quotation strip
193
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // graph pane
194
+ expect(screen.getByText(T.maxResults)).toBeInTheDocument(); // the form, below
195
+ // The band survives the step: header + the chosen strategy, nothing clickable.
196
+ const footer = baseElement.querySelector('.semiont-gather__footer');
197
+ expect(footer).not.toBeNull();
198
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
199
+ expect(footer!.textContent).toContain(`🔍 ${T.search}`);
200
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
201
+ expect(screen.queryByRole('button', { name: `✨ ${T.generate}…` })).not.toBeInTheDocument();
202
+ expect(screen.queryByRole('button', { name: `✍️ ${T.compose}` })).not.toBeInTheDocument();
203
+ expect(screen.queryByPlaceholderText(T.userHintPlaceholder)).not.toBeInTheDocument();
204
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
205
+ expect(panel.className).toContain('semiont-search-modal__panel--wide'); // evidence needs the width
206
+ });
207
+
208
+ it('search-results keeps the evidence and the collapsed band above the results', async () => {
209
+ // Same grammar as the configure steps — the results replace the FORM
210
+ // region, not the evidence. The step itself is pure results now (D10
211
+ // amended); the host stacks the full display above it.
212
+ const { client, baseElement } = renderWizard();
213
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
214
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
215
+ client.bus.get('match:search-results').next({
216
+ referenceId: 'ann-1',
217
+ response: [{ '@id': 'res-9', name: 'Caspian Sea', score: 54.2 }],
218
+ } as never);
219
+ await screen.findByRole('button', { name: new RegExp(T.link) }); // results are up
220
+
221
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull(); // quotation strip
222
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // graph pane
223
+ expect(screen.getByText(T.corpusPaneTitle)).toBeInTheDocument(); // full display, not the redux
224
+ const footer = baseElement.querySelector('.semiont-gather__footer');
225
+ expect(footer).not.toBeNull();
226
+ expect(footer!.textContent).toContain(`🔍 ${T.search}`);
227
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
228
+ expect(screen.getByRole('button', { name: `🔗 ${T.link}` })).toBeInTheDocument(); // the results
229
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
230
+ expect(panel.className).toContain('semiont-search-modal__panel--gather');
231
+ });
232
+
233
+ it('configure-generation renders the evidence above the form, the strategy band collapsed to Generate', async () => {
234
+ const { baseElement } = renderWizard();
235
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
236
+
237
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull();
238
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument();
239
+ expect(screen.getByText(T.resourceTitle)).toBeInTheDocument();
240
+ const footer = baseElement.querySelector('.semiont-gather__footer');
241
+ expect(footer).not.toBeNull();
242
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
243
+ expect(footer!.textContent).toContain(`✨ ${T.generate}`);
244
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
245
+ // The echo is a receipt LINE, not a control row: label and value share
246
+ // the --echo band, and the chooser's button-row wrapper is gone — the
247
+ // value must never sit where controls sit (it wore a control's costume:
248
+ // inherited base size + link blue).
249
+ expect(footer!.className).toContain('semiont-gather__footer--echo');
250
+ expect(footer!.querySelector('.semiont-gather__actions')).toBeNull();
251
+ expect(footer!.querySelector('.semiont-gather__chosen-strategy')).not.toBeNull();
252
+ expect(screen.queryByText(new RegExp(`^🔍 ${T.search}…`))).not.toBeInTheDocument();
253
+ const panel = baseElement.querySelector('.semiont-search-modal__panel')!;
254
+ expect(panel.className).toContain('semiont-search-modal__panel--wide');
255
+
256
+ // ONE scroll pane for the whole step: evidence and parameters scroll
257
+ // together, the form in full at the bottom, the evidence tucking up under
258
+ // the modal top. An independently-scrollable form inside the stack is what
259
+ // squeezed the parameters out of view.
260
+ const scroll = baseElement.querySelector('.semiont-wizard__step-scroll');
261
+ expect(scroll).not.toBeNull();
262
+ expect(scroll!.querySelector('.semiont-gather__outer')).not.toBeNull();
263
+ const form = scroll!.querySelector('form.semiont-form');
264
+ expect(form).not.toBeNull();
265
+ expect(form!.className).not.toContain('semiont-form--scrollable');
266
+ });
267
+ });
268
+
269
+ describe('ReferenceWizardModal — compose is the fourth in-modal strategy (COMPOSE-IN-MODAL P2)', () => {
270
+ beforeEach(() => { vi.clearAllMocks(); });
271
+
272
+ async function enterCompose() {
273
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
274
+ }
275
+
276
+ it('compose renders the evidence above the form, the strategy band collapsed to Compose', async () => {
277
+ const { baseElement } = renderWizard();
278
+ await enterCompose();
279
+
280
+ expect(baseElement.querySelector('.semiont-gather__source-box')).not.toBeNull();
281
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument();
282
+ const footer = baseElement.querySelector('.semiont-gather__footer');
283
+ expect(footer).not.toBeNull();
284
+ expect(footer!.textContent).toContain(T.resolutionStrategyLabel);
285
+ expect(footer!.textContent).toContain(`✍️ ${T.compose}`);
286
+ expect(footer!.querySelectorAll('button')).toHaveLength(0);
287
+ // ONE scroll pane holding display + form (same grammar as the others).
288
+ const scroll = baseElement.querySelector('.semiont-wizard__step-scroll');
289
+ expect(scroll).not.toBeNull();
290
+ expect(scroll!.querySelector('.semiont-gather__outer')).not.toBeNull();
291
+ expect(scroll!.querySelector('form.semiont-form')).not.toBeNull();
292
+ });
293
+
294
+ it('the draft survives Back and re-entry (WIZARD-NAVIGATION D3)', async () => {
295
+ renderWizard();
296
+ await enterCompose();
297
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'A Sauk leader.' } });
298
+ await userEvent.click(screen.getByRole('button', { name: `◀ ${T.back}` }));
299
+ expect(screen.getByText(new RegExp(T.compose))).toBeInTheDocument(); // back on gather
300
+ await enterCompose();
301
+ expect(screen.getByTestId('code-editor')).toHaveValue('A Sauk leader.');
302
+ });
303
+
304
+ it('submit emits against the annotation with the reference-fixed entity types, then closes', async () => {
305
+ const { onComposeSubmit, onClose } = renderWizard();
306
+ await enterCompose();
307
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/caspian-sea.md' } });
308
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'The lake.' } });
309
+ await userEvent.click(screen.getByRole('button', { name: T.createAndLink }));
310
+
311
+ await waitFor(() => expect(onComposeSubmit).toHaveBeenCalledTimes(1));
312
+ expect(onComposeSubmit).toHaveBeenCalledWith('ann-1', {
313
+ name: 'Caspian Sea',
314
+ storagePath: 'file://people/caspian-sea.md',
315
+ content: 'The lake.',
316
+ entityTypes: ['Location'],
317
+ language: 'en',
318
+ });
319
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
320
+ });
321
+
322
+ it('a rejected submit keeps the modal open with the footer re-enabled', async () => {
323
+ const onComposeSubmit = vi.fn<(referenceId: string, params: unknown) => Promise<void>>()
324
+ .mockRejectedValue(new Error('boom'));
325
+ const { onClose } = renderWizard({ onComposeSubmit });
326
+ await enterCompose();
327
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/x.md' } });
328
+ await userEvent.click(screen.getByRole('button', { name: T.createAndLink }));
329
+
330
+ await waitFor(() => expect(onComposeSubmit).toHaveBeenCalled());
331
+ expect(onClose).not.toHaveBeenCalled();
332
+ expect(await screen.findByRole('button', { name: T.createAndLink })).toBeEnabled();
333
+ });
334
+
335
+ // D4: a modal dies on ✕/Escape/backdrop; a non-empty draft must not die
336
+ // with it. The guard is an INLINE prompt — never window.confirm — and the
337
+ // footer stays dismissal-free (A4).
338
+ it('dismissing a dirty draft asks first; Keep editing stays, Discard closes', async () => {
339
+ const { onClose } = renderWizard();
340
+ await enterCompose();
341
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'typed work' } });
342
+
343
+ await userEvent.click(screen.getByLabelText('Close'));
344
+ expect(onClose).not.toHaveBeenCalled();
345
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
346
+
347
+ await userEvent.click(screen.getByRole('button', { name: T.keepEditing }));
348
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
349
+ expect(onClose).not.toHaveBeenCalled();
350
+
351
+ await userEvent.click(screen.getByLabelText('Close'));
352
+ await userEvent.click(screen.getByRole('button', { name: T.discardDraft }));
353
+ expect(onClose).toHaveBeenCalledTimes(1);
354
+ });
355
+
356
+ it('a clean draft dismisses without any prompt', async () => {
357
+ const { onClose } = renderWizard();
358
+ await enterCompose();
359
+ await userEvent.click(screen.getByLabelText('Close'));
360
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
361
+ expect(onClose).toHaveBeenCalledTimes(1);
362
+ });
363
+ });
364
+
365
+ // GATHER-AT-THE-TOP P1: the dirty guard widens. D4 — dirtiness is
366
+ // step-independent (typed work guards dismissal wherever the user currently
367
+ // is); D5 — typed text only (title beyond seed, save location, instructions,
368
+ // content). The generation draft gets the same protection compose has.
369
+ describe('ReferenceWizardModal — the dirty guard widens (GATHER-AT-THE-TOP D4/D5)', () => {
370
+ beforeEach(() => { vi.clearAllMocks(); });
371
+
372
+ it('a typed generation draft guards dismissal', async () => {
373
+ const { onClose } = renderWizard();
374
+ await userEvent.click(screen.getByRole('button', { name: /Generate…/ }));
375
+ fireEvent.change(screen.getByLabelText(T.saveLocation), { target: { value: 'people/x.md' } });
376
+
377
+ await userEvent.click(screen.getByLabelText('Close'));
378
+ expect(onClose).not.toHaveBeenCalled();
379
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
380
+ });
381
+
382
+ it('a defaultTitle drifting while open does not fake a dirty draft', async () => {
383
+ // The wizard's defaultTitle derives from observable state and can re-emit
384
+ // while the modal is open. The baseline moves, the user has typed nothing —
385
+ // dirtiness compares against what was SEEDED, not the live prop.
386
+ const { rerender, onClose, onComposeSubmit, onGenerateSubmit, onLinkResource } = renderWizard();
387
+ rerender(
388
+ <ReferenceWizardModal
389
+ isOpen
390
+ onClose={onClose}
391
+ annotationId="ann-1"
392
+ resourceId="res-1"
393
+ defaultTitle="Caspian Sea (moved)"
394
+ entityTypes={['Location']}
395
+ entityTypeOptions={['Person', 'Topic', 'Location']}
396
+ locale="en"
397
+ context={CONTEXT}
398
+ contextLoading={false}
399
+ contextError={null}
400
+ onGenerateSubmit={onGenerateSubmit}
401
+ onLinkResource={onLinkResource}
402
+ onComposeSubmit={onComposeSubmit}
403
+ translations={T}
404
+ />,
405
+ );
406
+
407
+ await userEvent.click(screen.getByLabelText('Close'));
408
+ expect(screen.queryByText(T.discardDraftPrompt)).not.toBeInTheDocument();
409
+ expect(onClose).toHaveBeenCalledTimes(1);
410
+ });
411
+
412
+ it('a compose draft still guards after stepping Back to the evidence — D4 kills the step gate', async () => {
413
+ const { onClose } = renderWizard();
414
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
415
+ fireEvent.change(screen.getByTestId('code-editor'), { target: { value: 'typed work' } });
416
+ await userEvent.click(screen.getByRole('button', { name: new RegExp(T.back) }));
417
+
418
+ await userEvent.click(screen.getByLabelText('Close'));
419
+ expect(onClose).not.toHaveBeenCalled();
420
+ expect(screen.getByText(T.discardDraftPrompt)).toBeInTheDocument();
421
+ });
422
+ });
423
+
424
+ // The gather-loading state is not a blank modal: the annotation's exact text
425
+ // and entity types are wizard props — known before any wire round-trip — and
426
+ // the pane headers are static. Render them immediately with skeleton bars so
427
+ // the loading screen is the loaded screen minus the data.
428
+ describe('ReferenceWizardModal — the gather skeleton', () => {
429
+ beforeEach(() => { vi.clearAllMocks(); });
430
+
431
+ it('while gathering, the known annotation facts and per-cell animations render immediately', () => {
432
+ const { baseElement } = renderWizard({ context: null, contextLoading: true });
433
+
434
+ expect(screen.getByText(/"Caspian Sea"/)).toBeInTheDocument(); // quoted exact text
435
+ expect(screen.getByText('Location')).toBeInTheDocument(); // entity chip
436
+ expect(screen.getByText(T.graphPaneTitle)).toBeInTheDocument(); // pane headers in place
437
+ expect(screen.getByText(T.corpusPaneTitle)).toBeInTheDocument();
438
+ // One dots animation PER zone — source excerpt, Neighborhood, Similar
439
+ // passages — each where its content will land; no central block, no bars.
440
+ expect(baseElement.querySelectorAll('.semiont-gather__skeleton-dots')).toHaveLength(3);
441
+ expect(baseElement.querySelectorAll('.semiont-gather__skeleton-bar')).toHaveLength(0);
442
+ // The sentence survives for screen readers only.
443
+ expect(screen.getByText(T.loadingContext)).toHaveClass('semiont-sr-only');
444
+ });
445
+
446
+ it('the source line, Hint, and strategy chooser render during loading — buttons grayed until context lands', () => {
447
+ // The resource name is a page fact (the wizard opens FROM the source
448
+ // resource); the Hint is wizard state, safely typed while the gather
449
+ // runs; the chooser's buttons already gate on contextReady.
450
+ renderWizard({ context: null, contextLoading: true, resourceName: 'Cedar County, Iowa' });
451
+
452
+ expect(screen.getByText(/Source "Cedar County, Iowa"/)).toBeInTheDocument();
453
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toBeInTheDocument();
454
+ expect(screen.getByRole('button', { name: /Search…/ })).toBeDisabled();
455
+ expect(screen.getByRole('button', { name: /Generate…/ })).toBeDisabled();
456
+ expect(screen.getByRole('button', { name: new RegExp(T.compose) })).toBeDisabled();
457
+ });
458
+
459
+ it('a hint typed during the gather survives context arrival', () => {
460
+ const { rerender, onClose, onComposeSubmit, onGenerateSubmit, onLinkResource } = renderWizard({ context: null, contextLoading: true });
461
+ fireEvent.change(screen.getByPlaceholderText(T.userHintPlaceholder), { target: { value: 'early steer' } });
462
+
463
+ rerender(
464
+ <ReferenceWizardModal
465
+ isOpen onClose={onClose} annotationId="ann-1" resourceId="res-1"
466
+ defaultTitle="Caspian Sea" entityTypes={['Location']}
467
+ entityTypeOptions={['Person', 'Topic', 'Location']} locale="en"
468
+ context={CONTEXT} contextLoading={false} contextError={null}
469
+ onGenerateSubmit={onGenerateSubmit} onLinkResource={onLinkResource}
470
+ onComposeSubmit={onComposeSubmit} translations={T}
471
+ />,
472
+ );
473
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toHaveValue('early steer');
474
+ });
475
+ });
476
+
477
+ describe('ReferenceWizardModal — a new run starts clean (D3 flip side)', () => {
478
+ it('reopening resets the hint and the step', async () => {
479
+ const { rerender } = renderWizard();
480
+ await typeHint('first attempt');
481
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toHaveValue('first attempt');
482
+
483
+ const props = {
484
+ isOpen: false, onClose: vi.fn(), annotationId: 'ann-1', resourceId: 'res-1',
485
+ defaultTitle: 'Caspian Sea', entityTypes: ['Location'], locale: 'en',
486
+ context: CONTEXT, contextLoading: false, contextError: null,
487
+ onGenerateSubmit: vi.fn(), onLinkResource: vi.fn(),
488
+ onComposeSubmit: vi.fn<(r: string, p: unknown) => Promise<void>>().mockResolvedValue(undefined),
489
+ translations: T,
490
+ };
491
+ rerender(<ReferenceWizardModal {...props} />);
492
+ rerender(<ReferenceWizardModal {...props} isOpen />);
493
+
494
+ // Back is lossless WITHIN a run (D3); a new run inherits nothing.
495
+ expect(screen.getByPlaceholderText(T.userHintPlaceholder)).toHaveValue('');
496
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument(); // back on the gather step
497
+ });
498
+ });
499
+
500
+ describe('ReferenceWizardModal — the three strategies complete', () => {
501
+ beforeEach(() => { vi.clearAllMocks(); });
502
+
503
+ it('search results arrive over the bus and linking one resolves the reference', async () => {
504
+ const { client, onLinkResource, onClose } = renderWizard();
505
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
506
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
507
+
508
+ // The results step is entered by the REPLY, not by the click — the wizard
509
+ // sits on the configure step until the bus answers (which is why dismissal
510
+ // must stay available; see the A5 pin in WizardFooter.test).
511
+ client.bus.get('match:search-results').next({
512
+ referenceId: 'ann-1',
513
+ // `ResourceDescriptor` is JSON-LD: the identifier is `@id`, and the
514
+ // results step links on that. A fixture using `id` renders the row and
515
+ // links `undefined` — passing the eye, failing the reference.
516
+ response: [{ '@id': 'res-9', name: 'Caspian Sea', score: 54.2 }],
517
+ } as never);
518
+
519
+ await userEvent.click(await screen.findByRole('button', { name: new RegExp(T.link) }));
520
+
521
+ expect(onLinkResource).toHaveBeenCalledWith('ann-1', 'res-9');
522
+ expect(onClose).toHaveBeenCalledTimes(1);
523
+ });
524
+
525
+ it('a search failure over the bus settles the spinner and surfaces the error — no eternal "Searching…"', async () => {
526
+ // The measured hang (.plans/bugs/match-search-hangs-on-neo4j-datetime-annotations.md):
527
+ // /bus/emit 400s the request, the match machinery publishes
528
+ // match:search-failed — and the wizard listened only for results, so the
529
+ // failure fired into an empty room while the button spun forever.
530
+ const { client } = renderWizard();
531
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
532
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
533
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
534
+
535
+ client.bus.get('match:search-failed').next({
536
+ correlationId: 'c-1',
537
+ referenceId: 'ann-1',
538
+ error: '/bus/emit 400: Bus emit validation failed',
539
+ } as never);
540
+
541
+ // Still on configure-search, failure visible, retry available.
542
+ expect(await screen.findByText(new RegExp(T.searchFailed))).toBeInTheDocument();
543
+ expect(screen.getByText(/Bus emit validation failed/)).toBeInTheDocument();
544
+ expect(screen.getByRole('button', { name: T.search })).toBeEnabled();
545
+ });
546
+
547
+ it('a failure addressed to a different annotation changes nothing', async () => {
548
+ // Same scoping rule as results: a late failure from another annotation's
549
+ // run must not settle THIS spinner or paint an error that isn't ours.
550
+ const { client } = renderWizard();
551
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
552
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
553
+
554
+ client.bus.get('match:search-failed').next({
555
+ correlationId: 'c-2',
556
+ referenceId: 'someone-elses-annotation',
557
+ error: 'not ours',
558
+ } as never);
559
+
560
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
561
+ expect(screen.queryByText(new RegExp(T.searchFailed))).not.toBeInTheDocument();
562
+ });
563
+
564
+ it('retrying after a failure clears the error while the new search runs', async () => {
565
+ const { client } = renderWizard();
566
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
567
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
568
+ client.bus.get('match:search-failed').next({
569
+ correlationId: 'c-1', referenceId: 'ann-1', error: 'boom',
570
+ } as never);
571
+ expect(await screen.findByText(new RegExp(T.searchFailed))).toBeInTheDocument();
572
+
573
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
574
+ expect(screen.queryByText(new RegExp(T.searchFailed))).not.toBeInTheDocument();
575
+ expect(screen.getByRole('button', { name: T.searching })).toBeDisabled();
576
+ });
577
+
578
+ it('ignores results addressed to a different annotation', () => {
579
+ // Two wizards can never be open at once, but one reply can outlive the
580
+ // annotation it was asked for — a late answer must not hijack the step.
581
+ const { client } = renderWizard();
582
+ client.bus.get('match:search-results').next({
583
+ referenceId: 'someone-elses-annotation',
584
+ response: [{ '@id': 'res-9', name: 'Caspian Sea' }],
585
+ } as never);
586
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument(); // still the gather step
587
+ });
588
+
589
+ it('generation submits the step\'s config against this annotation, then closes', async () => {
590
+ const { onGenerateSubmit, onClose } = renderWizard();
591
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
592
+ await userEvent.type(screen.getByLabelText(/Save location/i), 'generated/out.md');
593
+ await userEvent.click(screen.getByRole('button', { name: T.generate }));
594
+
595
+ expect(onGenerateSubmit).toHaveBeenCalledTimes(1);
596
+ expect(onGenerateSubmit.mock.calls[0]![0]).toBe('ann-1');
597
+ expect(onGenerateSubmit.mock.calls[0]![1].title).toBe('Caspian Sea');
598
+ expect(onClose).toHaveBeenCalledTimes(1);
599
+ });
600
+
601
+ it('retreats from a configure step back to the gather step', async () => {
602
+ renderWizard();
603
+ await userEvent.click(screen.getByRole('button', { name: `✨ ${T.generate}…` }));
604
+ expect(screen.getByLabelText(T.resourceTitle)).toBeInTheDocument();
605
+ await userEvent.click(screen.getByRole('button', { name: /Back/ }));
606
+ expect(screen.getByRole('button', { name: `🔍 ${T.search}…` })).toBeInTheDocument();
607
+ });
608
+ });
609
+
610
+ describe('ReferenceWizardModal — nothing fires without an annotation to resolve', () => {
611
+ // Every strategy guards on `annotationId` / `resourceId` / a context. The
612
+ // props are nullable because the page renders this modal before a reference
613
+ // is chosen, so "open but not yet aimed" is a real state, and acting in it
614
+ // would resolve a reference nobody selected.
615
+ beforeEach(() => { vi.clearAllMocks(); });
616
+
617
+ it('compose, search and generate all no-op', async () => {
618
+ const { onGenerateSubmit, searchSpy, client } =
619
+ renderWizard({ annotationId: null, resourceId: null });
620
+
621
+ await userEvent.click(screen.getByText(new RegExp(T.compose)));
622
+ expect(screen.queryByRole('button', { name: T.createAndLink })).not.toBeInTheDocument();
623
+
624
+ await userEvent.click(screen.getByText(new RegExp(`^🔍? ?${T.search}`)));
625
+ await userEvent.click(screen.getByRole('button', { name: T.search }));
626
+ expect(searchSpy).not.toHaveBeenCalled();
627
+
628
+ // A reply that arrives anyway is ignored rather than advancing the step.
629
+ client.bus.get('match:search-results').next({
630
+ referenceId: 'ann-1', response: [{ '@id': 'res-9', name: 'X' }],
631
+ } as never);
632
+ expect(screen.queryByRole('button', { name: new RegExp(T.link) })).not.toBeInTheDocument();
633
+ expect(onGenerateSubmit).not.toHaveBeenCalled();
634
+ });
635
+ });