@sutrata/editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/LICENSE +674 -0
  2. package/dist/ai/OnboardingDialog.d.ts +6 -0
  3. package/dist/ai/OnboardingDialog.js +115 -0
  4. package/dist/ai/VoiceConfirmDialog.d.ts +9 -0
  5. package/dist/ai/VoiceConfirmDialog.js +37 -0
  6. package/dist/ai/VoiceToolbar.d.ts +2 -0
  7. package/dist/ai/VoiceToolbar.js +177 -0
  8. package/dist/ai/ai-client.d.ts +71 -0
  9. package/dist/ai/ai-client.js +494 -0
  10. package/dist/ai/commands.d.ts +12 -0
  11. package/dist/ai/commands.js +140 -0
  12. package/dist/ai/generated-providers.json +3696 -0
  13. package/dist/ai/prompts.d.ts +25 -0
  14. package/dist/ai/prompts.js +59 -0
  15. package/dist/ai/voice-service.d.ts +15 -0
  16. package/dist/ai/voice-service.js +81 -0
  17. package/dist/autocomplete/AutocompletePlugin.d.ts +2 -0
  18. package/dist/autocomplete/AutocompletePlugin.js +19 -0
  19. package/dist/autocomplete/suggestions.d.ts +25 -0
  20. package/dist/autocomplete/suggestions.js +126 -0
  21. package/dist/context/DocumentContext.d.ts +68 -0
  22. package/dist/context/DocumentContext.js +605 -0
  23. package/dist/desktop-api.d.ts +40 -0
  24. package/dist/editor/EditorView.d.ts +2 -0
  25. package/dist/editor/EditorView.js +78 -0
  26. package/dist/editor/SelectionMenu.d.ts +2 -0
  27. package/dist/editor/SelectionMenu.js +102 -0
  28. package/dist/editor/attr-pencil-plugin.d.ts +17 -0
  29. package/dist/editor/attr-pencil-plugin.js +158 -0
  30. package/dist/editor/character-decoration-plugin.d.ts +26 -0
  31. package/dist/editor/character-decoration-plugin.js +77 -0
  32. package/dist/editor/character-view.d.ts +35 -0
  33. package/dist/editor/character-view.js +112 -0
  34. package/dist/editor/editor-bus.d.ts +14 -0
  35. package/dist/editor/editor-bus.js +39 -0
  36. package/dist/editor/find-highlight-plugin.d.ts +7 -0
  37. package/dist/editor/find-highlight-plugin.js +57 -0
  38. package/dist/editor/frontmatter-field-view.d.ts +10 -0
  39. package/dist/editor/frontmatter-field-view.js +48 -0
  40. package/dist/editor/frontmatter-field.d.ts +21 -0
  41. package/dist/editor/frontmatter-field.js +116 -0
  42. package/dist/editor/grapheme-cursor.d.ts +10 -0
  43. package/dist/editor/grapheme-cursor.js +104 -0
  44. package/dist/editor/ime-plugin.d.ts +18 -0
  45. package/dist/editor/ime-plugin.js +71 -0
  46. package/dist/editor/insert-helper.d.ts +1 -0
  47. package/dist/editor/insert-helper.js +42 -0
  48. package/dist/editor/keymap.d.ts +3 -0
  49. package/dist/editor/keymap.js +131 -0
  50. package/dist/editor/language-class-plugin.d.ts +7 -0
  51. package/dist/editor/language-class-plugin.js +75 -0
  52. package/dist/editor/plugins.d.ts +1 -0
  53. package/dist/editor/plugins.js +25 -0
  54. package/dist/editor/prosemirror-to-sutra.d.ts +2 -0
  55. package/dist/editor/prosemirror-to-sutra.js +208 -0
  56. package/dist/editor/scene-heading-view.d.ts +27 -0
  57. package/dist/editor/scene-heading-view.js +136 -0
  58. package/dist/editor/schema.d.ts +3 -0
  59. package/dist/editor/schema.js +135 -0
  60. package/dist/editor/sutra-to-prosemirror.d.ts +3 -0
  61. package/dist/editor/sutra-to-prosemirror.js +154 -0
  62. package/dist/editor/transliterate-input.d.ts +10 -0
  63. package/dist/editor/transliterate-input.js +88 -0
  64. package/dist/extensions/storage-adapter.d.ts +59 -0
  65. package/dist/extensions/storage-adapter.js +9 -0
  66. package/dist/extensions/types.d.ts +73 -0
  67. package/dist/extensions/types.js +12 -0
  68. package/dist/file/docx-exporter.d.ts +8 -0
  69. package/dist/file/docx-exporter.js +494 -0
  70. package/dist/file/docx-importer.d.ts +10 -0
  71. package/dist/file/docx-importer.js +283 -0
  72. package/dist/file/print-paginator.d.ts +29 -0
  73. package/dist/file/print-paginator.js +289 -0
  74. package/dist/file/templates.d.ts +2 -0
  75. package/dist/file/templates.js +66 -0
  76. package/dist/file/workflow-reports.d.ts +34 -0
  77. package/dist/file/workflow-reports.js +719 -0
  78. package/dist/findreplace/FindReplace.d.ts +2 -0
  79. package/dist/findreplace/FindReplace.js +164 -0
  80. package/dist/findreplace/find-engine.d.ts +29 -0
  81. package/dist/findreplace/find-engine.js +69 -0
  82. package/dist/findreplace/scroll-to-match.d.ts +29 -0
  83. package/dist/findreplace/scroll-to-match.js +74 -0
  84. package/dist/fonts/tofu-detect.d.ts +1 -0
  85. package/dist/fonts/tofu-detect.js +48 -0
  86. package/dist/i18n/LanguageContext.d.ts +10 -0
  87. package/dist/i18n/LanguageContext.js +174 -0
  88. package/dist/i18n/language-detect.d.ts +1 -0
  89. package/dist/i18n/language-detect.js +58 -0
  90. package/dist/i18n/useTranslation.d.ts +11 -0
  91. package/dist/i18n/useTranslation.js +60 -0
  92. package/dist/index.d.ts +11 -0
  93. package/dist/index.js +5 -0
  94. package/dist/locales/bn.json +143 -0
  95. package/dist/locales/en.json +143 -0
  96. package/dist/locales/gu.json +143 -0
  97. package/dist/locales/hi.json +143 -0
  98. package/dist/locales/kn.json +143 -0
  99. package/dist/locales/ml.json +143 -0
  100. package/dist/locales/mr.json +143 -0
  101. package/dist/locales/or.json +143 -0
  102. package/dist/locales/pa.json +143 -0
  103. package/dist/locales/si.json +143 -0
  104. package/dist/locales/ta.json +143 -0
  105. package/dist/locales/te.json +143 -0
  106. package/dist/navigator/SceneNavigator.d.ts +2 -0
  107. package/dist/navigator/SceneNavigator.js +612 -0
  108. package/dist/navigator/StatisticsDialog.d.ts +6 -0
  109. package/dist/navigator/StatisticsDialog.js +146 -0
  110. package/dist/navigator/scene-list.d.ts +16 -0
  111. package/dist/navigator/scene-list.js +70 -0
  112. package/dist/settings/SettingsDialog.d.ts +6 -0
  113. package/dist/settings/SettingsDialog.js +147 -0
  114. package/dist/shell/AppBar.d.ts +2 -0
  115. package/dist/shell/AppBar.js +34 -0
  116. package/dist/shell/AppShell.d.ts +2 -0
  117. package/dist/shell/AppShell.js +30 -0
  118. package/dist/shell/ConfirmDialog.d.ts +16 -0
  119. package/dist/shell/ConfirmDialog.js +23 -0
  120. package/dist/shell/ElementToolbar.d.ts +2 -0
  121. package/dist/shell/ElementToolbar.js +30 -0
  122. package/dist/shell/LanguageStatus.d.ts +2 -0
  123. package/dist/shell/LanguageStatus.js +65 -0
  124. package/dist/shell/StatusBar.d.ts +2 -0
  125. package/dist/shell/StatusBar.js +54 -0
  126. package/dist/shell/SutrataLogo.d.ts +8 -0
  127. package/dist/shell/SutrataLogo.js +7 -0
  128. package/dist/shell/Toast.d.ts +12 -0
  129. package/dist/shell/Toast.js +21 -0
  130. package/dist/shell/ToolbarButton.d.ts +11 -0
  131. package/dist/shell/ToolbarButton.js +4 -0
  132. package/dist/shell/VersionHistory.d.ts +12 -0
  133. package/dist/shell/VersionHistory.js +89 -0
  134. package/dist/shell/Watermark.d.ts +3 -0
  135. package/dist/shell/Watermark.js +10 -0
  136. package/dist/shell/fonts/fonts.css +1133 -0
  137. package/dist/shell/icons.d.ts +45 -0
  138. package/dist/shell/icons.js +51 -0
  139. package/dist/shell/toolbar-actions.d.ts +6 -0
  140. package/dist/shell/toolbar-actions.js +58 -0
  141. package/dist/source/SourceView.d.ts +1 -0
  142. package/dist/source/SourceView.js +106 -0
  143. package/dist/source/find-highlight-source.d.ts +14 -0
  144. package/dist/source/find-highlight-source.js +47 -0
  145. package/dist/source/sutra-lang.d.ts +2 -0
  146. package/dist/source/sutra-lang.js +97 -0
  147. package/dist/spellcheck/dictionaries.d.ts +9 -0
  148. package/dist/spellcheck/dictionaries.js +16 -0
  149. package/dist/spellcheck/hunspell.d.ts +23 -0
  150. package/dist/spellcheck/hunspell.js +94 -0
  151. package/dist/spellcheck/spellcheck-plugin.d.ts +18 -0
  152. package/dist/spellcheck/spellcheck-plugin.js +75 -0
  153. package/dist/styles/StyleDialog.d.ts +6 -0
  154. package/dist/styles/StyleDialog.js +63 -0
  155. package/dist/styles/builtin-styles.d.ts +43 -0
  156. package/dist/styles/builtin-styles.js +131 -0
  157. package/dist/styles/css-adapter.d.ts +32 -0
  158. package/dist/styles/css-adapter.js +71 -0
  159. package/dist/styles/docx-adapter.d.ts +7 -0
  160. package/dist/styles/docx-adapter.js +84 -0
  161. package/dist/styles/embed-fonts.d.ts +20 -0
  162. package/dist/styles/embed-fonts.js +81 -0
  163. package/dist/styles/pagination.d.ts +19 -0
  164. package/dist/styles/pagination.js +22 -0
  165. package/dist/styles/print-adapter.d.ts +6 -0
  166. package/dist/styles/print-adapter.js +288 -0
  167. package/dist/styles/registry.d.ts +6 -0
  168. package/dist/styles/registry.js +14 -0
  169. package/dist/styles/screenplay.css +3006 -0
  170. package/dist/styles/script-ranges.d.ts +6 -0
  171. package/dist/styles/script-ranges.js +17 -0
  172. package/dist/styles/types.d.ts +93 -0
  173. package/dist/styles/types.js +18 -0
  174. package/dist/styles/validate.d.ts +12 -0
  175. package/dist/styles/validate.js +105 -0
  176. package/dist/templates/templates.d.ts +10 -0
  177. package/dist/templates/templates.js +181 -0
  178. package/dist/titlepage/ExportDialog.d.ts +1 -0
  179. package/dist/titlepage/ExportDialog.js +152 -0
  180. package/dist/titlepage/TitlePageDialog.d.ts +6 -0
  181. package/dist/titlepage/TitlePageDialog.js +96 -0
  182. package/dist/titlepage/frontmatter-form.d.ts +56 -0
  183. package/dist/titlepage/frontmatter-form.js +245 -0
  184. package/dist/titlepage/languages.d.ts +10 -0
  185. package/dist/titlepage/languages.js +31 -0
  186. package/dist/types.d.ts +6 -0
  187. package/dist/types.js +1 -0
  188. package/package.json +47 -0
  189. package/scripts/copy-assets.mjs +16 -0
  190. package/scripts/generate-providers.mjs +127 -0
  191. package/src/ai/OnboardingDialog.tsx +390 -0
  192. package/src/ai/VoiceConfirmDialog.tsx +137 -0
  193. package/src/ai/VoiceToolbar.tsx +321 -0
  194. package/src/ai/ai-client.ts +596 -0
  195. package/src/ai/commands.ts +164 -0
  196. package/src/ai/generated-providers.json +3696 -0
  197. package/src/ai/prompts.ts +62 -0
  198. package/src/ai/voice-service.ts +96 -0
  199. package/src/autocomplete/AutocompletePlugin.ts +21 -0
  200. package/src/autocomplete/suggestions.ts +147 -0
  201. package/src/context/DocumentContext.tsx +648 -0
  202. package/src/desktop-api.d.ts +40 -0
  203. package/src/editor/EditorView.tsx +82 -0
  204. package/src/editor/SelectionMenu.tsx +152 -0
  205. package/src/editor/attr-pencil-plugin.ts +172 -0
  206. package/src/editor/character-decoration-plugin.ts +104 -0
  207. package/src/editor/editor-bus.ts +51 -0
  208. package/src/editor/find-highlight-plugin.ts +68 -0
  209. package/src/editor/frontmatter-field-view.ts +52 -0
  210. package/src/editor/frontmatter-field.ts +115 -0
  211. package/src/editor/grapheme-cursor.ts +99 -0
  212. package/src/editor/ime-plugin.ts +85 -0
  213. package/src/editor/insert-helper.ts +43 -0
  214. package/src/editor/keymap.ts +124 -0
  215. package/src/editor/language-class-plugin.ts +91 -0
  216. package/src/editor/plugins.ts +26 -0
  217. package/src/editor/prosemirror-to-sutra.ts +192 -0
  218. package/src/editor/scene-heading-view.ts +147 -0
  219. package/src/editor/schema.ts +156 -0
  220. package/src/editor/sutra-to-prosemirror.ts +156 -0
  221. package/src/extensions/storage-adapter.ts +65 -0
  222. package/src/extensions/types.ts +60 -0
  223. package/src/file/docx-exporter.ts +585 -0
  224. package/src/file/docx-importer.ts +313 -0
  225. package/src/file/print-paginator.ts +301 -0
  226. package/src/file/templates.ts +73 -0
  227. package/src/file/workflow-reports.ts +820 -0
  228. package/src/findreplace/FindReplace.tsx +242 -0
  229. package/src/findreplace/find-engine.ts +97 -0
  230. package/src/findreplace/scroll-to-match.ts +89 -0
  231. package/src/fonts/tofu-detect.ts +53 -0
  232. package/src/i18n/LanguageContext.tsx +207 -0
  233. package/src/i18n/language-detect.ts +73 -0
  234. package/src/i18n/useTranslation.tsx +74 -0
  235. package/src/index.ts +27 -0
  236. package/src/locales/bn.json +143 -0
  237. package/src/locales/en.json +143 -0
  238. package/src/locales/gu.json +143 -0
  239. package/src/locales/hi.json +143 -0
  240. package/src/locales/kn.json +143 -0
  241. package/src/locales/ml.json +143 -0
  242. package/src/locales/mr.json +143 -0
  243. package/src/locales/or.json +143 -0
  244. package/src/locales/pa.json +143 -0
  245. package/src/locales/si.json +143 -0
  246. package/src/locales/ta.json +143 -0
  247. package/src/locales/te.json +143 -0
  248. package/src/navigator/SceneNavigator.tsx +844 -0
  249. package/src/navigator/StatisticsDialog.tsx +249 -0
  250. package/src/navigator/scene-list.ts +84 -0
  251. package/src/settings/SettingsDialog.tsx +303 -0
  252. package/src/shell/AppBar.tsx +354 -0
  253. package/src/shell/AppShell.tsx +71 -0
  254. package/src/shell/ConfirmDialog.tsx +71 -0
  255. package/src/shell/ElementToolbar.tsx +175 -0
  256. package/src/shell/LanguageStatus.tsx +101 -0
  257. package/src/shell/StatusBar.tsx +135 -0
  258. package/src/shell/SutrataLogo.tsx +89 -0
  259. package/src/shell/Toast.tsx +46 -0
  260. package/src/shell/ToolbarButton.tsx +30 -0
  261. package/src/shell/VersionHistory.tsx +212 -0
  262. package/src/shell/Watermark.tsx +9 -0
  263. package/src/shell/fonts/fonts.css +1133 -0
  264. package/src/shell/icons.tsx +335 -0
  265. package/src/shell/toolbar-actions.ts +59 -0
  266. package/src/source/SourceView.tsx +111 -0
  267. package/src/source/find-highlight-source.ts +57 -0
  268. package/src/source/sutra-lang.ts +121 -0
  269. package/src/spellcheck/dictionaries.ts +21 -0
  270. package/src/spellcheck/hunspell.ts +110 -0
  271. package/src/spellcheck/spellcheck-plugin.ts +83 -0
  272. package/src/styles/StyleDialog.tsx +148 -0
  273. package/src/styles/builtin-styles.ts +139 -0
  274. package/src/styles/css-adapter.ts +76 -0
  275. package/src/styles/docx-adapter.ts +109 -0
  276. package/src/styles/embed-fonts.ts +81 -0
  277. package/src/styles/pagination.ts +27 -0
  278. package/src/styles/print-adapter.ts +288 -0
  279. package/src/styles/registry.ts +20 -0
  280. package/src/styles/screenplay.css +3006 -0
  281. package/src/styles/script-ranges.ts +17 -0
  282. package/src/styles/types.ts +104 -0
  283. package/src/styles/validate.ts +114 -0
  284. package/src/templates/templates.ts +194 -0
  285. package/src/titlepage/ExportDialog.tsx +494 -0
  286. package/src/titlepage/TitlePageDialog.tsx +312 -0
  287. package/src/titlepage/frontmatter-form.ts +280 -0
  288. package/src/titlepage/languages.ts +37 -0
  289. package/src/types.ts +7 -0
  290. package/tests/AppBar.test.tsx +35 -0
  291. package/tests/AppShell.test.tsx +22 -0
  292. package/tests/ElementToolbar.test.tsx +36 -0
  293. package/tests/LanguageStatus.test.tsx +129 -0
  294. package/tests/SelectionMenu.test.tsx +71 -0
  295. package/tests/ToolbarButton.test.tsx +24 -0
  296. package/tests/ai-onboarding.test.ts +102 -0
  297. package/tests/ai-status.test.tsx +72 -0
  298. package/tests/ai-structured.test.ts +94 -0
  299. package/tests/autocomplete/AutocompletePlugin.test.ts +20 -0
  300. package/tests/autocomplete/suggestions.test.ts +106 -0
  301. package/tests/document-context.test.tsx +565 -0
  302. package/tests/docx-importer.test.ts +160 -0
  303. package/tests/editor/character-decoration-plugin.test.ts +101 -0
  304. package/tests/editor/character-extension.test.ts +44 -0
  305. package/tests/editor/grapheme-cursor.test.ts +118 -0
  306. package/tests/editor/ime-plugin.test.ts +72 -0
  307. package/tests/editor/inline-note-bridge.test.ts +32 -0
  308. package/tests/editor/language-class-plugin.test.ts +265 -0
  309. package/tests/editor/title-page.test.ts +124 -0
  310. package/tests/embed-fonts.test.ts +17 -0
  311. package/tests/exporters.test.ts +365 -0
  312. package/tests/findreplace/FindReplace.test.tsx +391 -0
  313. package/tests/findreplace/find-engine.test.ts +86 -0
  314. package/tests/findreplace/scroll-to-match.test.ts +50 -0
  315. package/tests/fonts/tofu-detect.test.ts +122 -0
  316. package/tests/i18n/LanguageContext.test.tsx +146 -0
  317. package/tests/i18n/language-detect.test.ts +54 -0
  318. package/tests/i18n/locale-parity.test.ts +25 -0
  319. package/tests/i18n/useTranslation.test.tsx +45 -0
  320. package/tests/keymap.test.ts +18 -0
  321. package/tests/navigator/SceneNavigator.test.tsx +177 -0
  322. package/tests/navigator/StatisticsDialog.test.tsx +63 -0
  323. package/tests/navigator/scene-list.test.ts +71 -0
  324. package/tests/print-paginator.test.ts +82 -0
  325. package/tests/shell/VersionHistory.test.tsx +129 -0
  326. package/tests/spellcheck/hunspell.test.ts +82 -0
  327. package/tests/spellcheck/spellcheck-plugin.test.ts +56 -0
  328. package/tests/styles.test.ts +315 -0
  329. package/tests/templates/templates.test.ts +83 -0
  330. package/tests/titlepage/TitlePageDialog.test.tsx +90 -0
  331. package/tests/titlepage/apply-title-page.test.ts +61 -0
  332. package/tests/titlepage/frontmatter-form.test.ts +115 -0
  333. package/tests/toolbar-actions.test.ts +93 -0
  334. package/tests/voice-commands.test.ts +58 -0
  335. package/tsconfig.json +17 -0
  336. package/vitest.config.ts +11 -0
  337. package/vitest.setup.ts +65 -0
@@ -0,0 +1,565 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
3
+ import { DocumentProvider, useDocument } from '../src/context/DocumentContext'
4
+ import type { StorageAdapter } from '../src/extensions/storage-adapter'
5
+
6
+ // Must match RECOVERY_POINTER_KEY in ../src/context/DocumentContext.tsx.
7
+ const RECOVERY_POINTER_KEY = 'sutrata:recovery-pointer'
8
+
9
+ function makeMockStorageAdapter(overrides: Partial<StorageAdapter> = {}): StorageAdapter {
10
+ return {
11
+ saveDocument: vi.fn().mockResolvedValue(undefined),
12
+ loadDocument: vi.fn().mockResolvedValue(null),
13
+ listVersions: vi.fn().mockResolvedValue([]),
14
+ openFile: vi.fn().mockResolvedValue(null),
15
+ openDocx: vi.fn().mockResolvedValue(null),
16
+ openStyleJson: vi.fn().mockResolvedValue(null),
17
+ saveFile: vi.fn().mockResolvedValue(null),
18
+ saveStyle: vi.fn().mockResolvedValue(undefined),
19
+ loadAllStyles: vi.fn().mockResolvedValue([]),
20
+ deleteStyle: vi.fn().mockResolvedValue(undefined),
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ const mockStorageAdapter = makeMockStorageAdapter()
26
+
27
+ function Probe() {
28
+ const { ribbonVisible, setRibbonVisible, navVisible } = useDocument()
29
+ return (
30
+ <div>
31
+ <span data-testid="ribbon">{String(ribbonVisible)}</span>
32
+ <span data-testid="nav">{String(navVisible)}</span>
33
+ <button onClick={() => setRibbonVisible(!ribbonVisible)}>toggle</button>
34
+ </div>
35
+ )
36
+ }
37
+
38
+ describe('DocumentContext visibility flags', () => {
39
+ it('defaults ribbon visible and nav visible on desktop (jsdom innerWidth=1024)', () => {
40
+ render(<DocumentProvider storageAdapter={mockStorageAdapter}><Probe /></DocumentProvider>)
41
+ expect(screen.getByTestId('ribbon').textContent).toBe('true')
42
+ expect(screen.getByTestId('nav').textContent).toBe('true')
43
+ })
44
+
45
+ it('toggles ribbon visibility', () => {
46
+ render(<DocumentProvider storageAdapter={mockStorageAdapter}><Probe /></DocumentProvider>)
47
+ fireEvent.click(screen.getByText('toggle'))
48
+ expect(screen.getByTestId('ribbon').textContent).toBe('false')
49
+ })
50
+
51
+ it('Ctrl-S calls preventDefault and saves', () => {
52
+ render(<DocumentProvider storageAdapter={mockStorageAdapter}><Probe /></DocumentProvider>)
53
+ const ev = new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true, cancelable: true })
54
+ const prevent = vi.spyOn(ev, 'preventDefault')
55
+ act(() => { window.dispatchEvent(ev) })
56
+ expect(prevent).toHaveBeenCalled()
57
+ })
58
+ })
59
+
60
+ function FullProbe() {
61
+ const {
62
+ text, isDirty, filePath, lastSaveTarget, setText, newDocument, openFile, saveFile, saveFileAs, save,
63
+ confirmModal, toasts, versions, restoreVersion, clearVersionHistory,
64
+ } = useDocument()
65
+ return (
66
+ <div>
67
+ <span data-testid="text">{text}</span>
68
+ <span data-testid="dirty">{String(isDirty)}</span>
69
+ <span data-testid="filePath">{String(filePath)}</span>
70
+ <span data-testid="lastSaveTarget">{String(lastSaveTarget)}</span>
71
+ <span data-testid="toasts">{toasts.map(t => t.message).join('|')}</span>
72
+ <span data-testid="confirm-title">{confirmModal?.title ?? ''}</span>
73
+ <span data-testid="versions">{versions.map(v => v.id).join('|')}</span>
74
+ <button onClick={() => setText('## Scene 1\n\nEdited by the user.\n')}>edit</button>
75
+ <button onClick={() => newDocument()}>new</button>
76
+ <button onClick={() => void openFile()}>open</button>
77
+ <button onClick={() => void saveFile()}>save</button>
78
+ <button onClick={() => void saveFileAs()}>saveAs</button>
79
+ <button onClick={() => void save()}>autosaveNow</button>
80
+ <button onClick={() => confirmModal?.onConfirm()}>confirm</button>
81
+ <button onClick={() => confirmModal?.onCancel?.()}>cancel</button>
82
+ <button onClick={() => versions[1] && restoreVersion(versions[1])}>restoreSecond</button>
83
+ <button onClick={() => void clearVersionHistory()}>clearVersions</button>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ describe('DocumentContext reload recovery (Ctrl-R data loss fix)', () => {
89
+ beforeEach(() => {
90
+ localStorage.clear()
91
+ })
92
+
93
+ it('starts blank when there is no recovery pointer', () => {
94
+ const adapter = makeMockStorageAdapter()
95
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
96
+ expect(adapter.loadDocument).not.toHaveBeenCalled()
97
+ expect(screen.getByTestId('dirty').textContent).toBe('false')
98
+ })
99
+
100
+ it('restores autosaved content left behind by a previous session, marks it dirty, and toasts', async () => {
101
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: '__autosave__', updatedAt: Date.now() }))
102
+ const adapter = makeMockStorageAdapter({
103
+ loadDocument: vi.fn().mockResolvedValue('## Scene 1\n\nRECOVERED CONTENT.\n'),
104
+ })
105
+
106
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
107
+
108
+ await waitFor(() => {
109
+ expect(screen.getByTestId('text').textContent).toContain('RECOVERED CONTENT')
110
+ })
111
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
112
+ expect(screen.getByTestId('filePath').textContent).toBe('null')
113
+ expect(screen.getByTestId('toasts').textContent).toContain('Recovered unsaved changes')
114
+ })
115
+
116
+ it('stays marked dirty after the scheduled autosave re-backs-up a just-recovered draft (regression: reopening must not silently look saved)', async () => {
117
+ vi.useFakeTimers({ shouldAdvanceTime: true })
118
+ try {
119
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: '__autosave__', updatedAt: Date.now() }))
120
+ const adapter = makeMockStorageAdapter({
121
+ loadDocument: vi.fn().mockResolvedValue('## Scene 1\n\nRECOVERED CONTENT.\n'),
122
+ })
123
+
124
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
125
+
126
+ await act(async () => { await vi.advanceTimersByTimeAsync(0) })
127
+ expect(screen.getByTestId('text').textContent).toContain('RECOVERED CONTENT')
128
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
129
+
130
+ // The debounce/ceiling autosave fires on its own (no further edits) and
131
+ // backs the recovered draft up again — it must not clear isDirty, or the
132
+ // footer would flip to "saved" for a file that was never actually written.
133
+ await act(async () => { await vi.advanceTimersByTimeAsync(4000) })
134
+ expect(adapter.saveDocument).toHaveBeenCalled()
135
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
136
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('local')
137
+ } finally {
138
+ vi.useRealTimers()
139
+ }
140
+ })
141
+
142
+ it('restores under the real filePath when the pointer names one', async () => {
143
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: 'myscript.sutra', updatedAt: Date.now() }))
144
+ const adapter = makeMockStorageAdapter({
145
+ loadDocument: vi.fn().mockResolvedValue('## Scene 1\n\nRecovered named file.\n'),
146
+ })
147
+
148
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
149
+
150
+ await waitFor(() => {
151
+ expect(screen.getByTestId('filePath').textContent).toBe('myscript.sutra')
152
+ })
153
+ expect(adapter.loadDocument).toHaveBeenCalledWith('myscript.sutra')
154
+ })
155
+
156
+ it('openFile asks for confirmation instead of silently discarding unsaved edits', async () => {
157
+ const adapter = makeMockStorageAdapter()
158
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
159
+
160
+ fireEvent.click(screen.getByText('edit'))
161
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
162
+
163
+ fireEvent.click(screen.getByText('open'))
164
+ expect(screen.getByTestId('confirm-title').textContent).toBe('Discard Unsaved Changes?')
165
+ expect(adapter.openFile).not.toHaveBeenCalled()
166
+
167
+ await act(async () => { fireEvent.click(screen.getByText('confirm')) })
168
+ expect(adapter.openFile).toHaveBeenCalled()
169
+ })
170
+
171
+ it('openFile proceeds without a prompt when there is nothing unsaved', () => {
172
+ const adapter = makeMockStorageAdapter()
173
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
174
+ fireEvent.click(screen.getByText('open'))
175
+ expect(adapter.openFile).toHaveBeenCalled()
176
+ expect(screen.getByTestId('confirm-title').textContent).toBe('')
177
+ })
178
+
179
+ it('saveFile clears the recovery pointer since content is now safely on disk', async () => {
180
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: '__autosave__', updatedAt: Date.now() }))
181
+ const adapter = makeMockStorageAdapter({
182
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
183
+ })
184
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
185
+
186
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
187
+
188
+ expect(localStorage.getItem(RECOVERY_POINTER_KEY)).toBeNull()
189
+ })
190
+
191
+ it('autosaves at least once every 30s even during continuous, uninterrupted typing', async () => {
192
+ vi.useFakeTimers({ shouldAdvanceTime: true })
193
+ try {
194
+ const adapter = makeMockStorageAdapter()
195
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
196
+
197
+ fireEvent.click(screen.getByText('edit'))
198
+
199
+ // Simulate a keystroke every second for 35s straight — under the old pure-debounce
200
+ // implementation this would reset the 30s timer every time and never fire.
201
+ for (let i = 0; i < 35; i++) {
202
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
203
+ fireEvent.click(screen.getByText('edit'))
204
+ }
205
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
206
+
207
+ expect(adapter.saveDocument).toHaveBeenCalled()
208
+ } finally {
209
+ vi.useRealTimers()
210
+ }
211
+ })
212
+ })
213
+
214
+ describe('DocumentContext three-state save status (Phase 2)', () => {
215
+ beforeEach(() => {
216
+ localStorage.clear()
217
+ })
218
+
219
+ it('marks lastSaveTarget "local" after an autosave, distinct from a real file save', async () => {
220
+ const adapter = makeMockStorageAdapter()
221
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
222
+
223
+ fireEvent.click(screen.getByText('edit'))
224
+ await act(async () => { fireEvent.click(screen.getByText('autosaveNow')) })
225
+
226
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('local')
227
+ })
228
+
229
+ it('VS Code-style: a local autosave backs content up but does NOT clear isDirty — only saveFile does', async () => {
230
+ const adapter = makeMockStorageAdapter({
231
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
232
+ })
233
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
234
+
235
+ fireEvent.click(screen.getByText('edit'))
236
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
237
+
238
+ await act(async () => { fireEvent.click(screen.getByText('autosaveNow')) })
239
+ // Backed up locally, but still "unsaved" relative to the real file.
240
+ expect(adapter.saveDocument).toHaveBeenCalled()
241
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('local')
242
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
243
+
244
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
245
+ expect(screen.getByTestId('dirty').textContent).toBe('false')
246
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('file')
247
+ })
248
+
249
+ it('marks lastSaveTarget "file" after saveFile, overriding a prior local autosave', async () => {
250
+ const adapter = makeMockStorageAdapter({
251
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
252
+ })
253
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
254
+
255
+ fireEvent.click(screen.getByText('edit'))
256
+ await act(async () => { fireEvent.click(screen.getByText('autosaveNow')) })
257
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('local')
258
+
259
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
260
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('file')
261
+ })
262
+
263
+ it('marks lastSaveTarget "file" after opening a (non-fountain) file, with nothing unsaved yet', async () => {
264
+ const adapter = makeMockStorageAdapter({
265
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nHi.\n', handle: null }),
266
+ })
267
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
268
+
269
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
270
+
271
+ expect(screen.getByTestId('lastSaveTarget').textContent).toBe('file')
272
+ expect(screen.getByTestId('dirty').textContent).toBe('false')
273
+ })
274
+ })
275
+
276
+ describe('DocumentContext file handle persistence (Phase 2)', () => {
277
+ beforeEach(() => {
278
+ localStorage.clear()
279
+ })
280
+
281
+ it('persists the handle after saveFileAs and after opening a file', async () => {
282
+ const fakeHandle = { name: 'myscript.sutra' }
283
+ const adapter = makeMockStorageAdapter({
284
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: fakeHandle }),
285
+ openFile: vi.fn().mockResolvedValue({ name: 'other.sutra', content: '## Scene 1\n\nHi.\n', handle: fakeHandle }),
286
+ persistFileHandle: vi.fn().mockResolvedValue(undefined),
287
+ })
288
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
289
+
290
+ await act(async () => { fireEvent.click(screen.getByText('saveAs')) })
291
+ expect(adapter.persistFileHandle).toHaveBeenCalledWith('myscript.sutra', fakeHandle)
292
+
293
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
294
+ expect(adapter.persistFileHandle).toHaveBeenCalledWith('other.sutra', fakeHandle)
295
+ })
296
+
297
+ it('reattaches a still-permitted handle on boot recovery so the next save writes back in place', async () => {
298
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: 'myscript.sutra', updatedAt: Date.now() }))
299
+ const fakeHandle = { name: 'myscript.sutra' }
300
+ const adapter = makeMockStorageAdapter({
301
+ loadDocument: vi.fn().mockResolvedValue('## Scene 1\n\nRecovered.\n'),
302
+ restoreFileHandle: vi.fn().mockResolvedValue(fakeHandle),
303
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: fakeHandle }),
304
+ })
305
+
306
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
307
+
308
+ await waitFor(() => {
309
+ expect(screen.getByTestId('filePath').textContent).toBe('myscript.sutra')
310
+ })
311
+ expect(adapter.restoreFileHandle).toHaveBeenCalledWith('myscript.sutra')
312
+
313
+ // Confirms the reattached handle actually reaches saveFile as the existing handle
314
+ // to write back in place, instead of null (which would force a Save As prompt).
315
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
316
+ expect(adapter.saveFile).toHaveBeenCalledWith('myscript.sutra', expect.any(String), fakeHandle)
317
+ })
318
+
319
+ it('does not reattach a handle the adapter refuses (permission not granted)', async () => {
320
+ localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path: 'myscript.sutra', updatedAt: Date.now() }))
321
+ const adapter = makeMockStorageAdapter({
322
+ loadDocument: vi.fn().mockResolvedValue('## Scene 1\n\nRecovered.\n'),
323
+ restoreFileHandle: vi.fn().mockResolvedValue(null),
324
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
325
+ })
326
+
327
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
328
+
329
+ await waitFor(() => {
330
+ expect(screen.getByTestId('filePath').textContent).toBe('myscript.sutra')
331
+ })
332
+
333
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
334
+ expect(adapter.saveFile).toHaveBeenCalledWith('myscript.sutra', expect.any(String), null)
335
+ })
336
+ })
337
+
338
+ describe('DocumentContext desktop window-close guard (Phase 3)', () => {
339
+ afterEach(() => {
340
+ delete (window as { desktopAPI?: unknown }).desktopAPI
341
+ })
342
+
343
+ it('closes immediately when there is nothing unsaved', async () => {
344
+ let trigger: (() => void) | undefined
345
+ const closeWindow = vi.fn().mockResolvedValue(undefined)
346
+ window.desktopAPI = {
347
+ readFile: vi.fn(), writeFile: vi.fn(), showOpenDialog: vi.fn(), showSaveDialog: vi.fn(),
348
+ onMenuCommand: vi.fn().mockReturnValue(() => {}),
349
+ onCloseRequested: (cb: () => void) => { trigger = cb; return () => {} },
350
+ closeWindow,
351
+ } as unknown as typeof window.desktopAPI
352
+
353
+ const adapter = makeMockStorageAdapter()
354
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
355
+
356
+ expect(trigger).toBeDefined()
357
+ await act(async () => { trigger!() })
358
+
359
+ expect(closeWindow).toHaveBeenCalled()
360
+ })
361
+
362
+ it('asks for confirmation, then saves and closes, when there are unsaved edits', async () => {
363
+ let trigger: (() => void) | undefined
364
+ const closeWindow = vi.fn().mockResolvedValue(undefined)
365
+ const adapter = makeMockStorageAdapter({
366
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
367
+ })
368
+ window.desktopAPI = {
369
+ readFile: vi.fn(), writeFile: vi.fn(), showOpenDialog: vi.fn(), showSaveDialog: vi.fn(),
370
+ onMenuCommand: vi.fn().mockReturnValue(() => {}),
371
+ onCloseRequested: (cb: () => void) => { trigger = cb; return () => {} },
372
+ closeWindow,
373
+ } as unknown as typeof window.desktopAPI
374
+
375
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
376
+
377
+ fireEvent.click(screen.getByText('edit'))
378
+ act(() => { trigger!() })
379
+
380
+ expect(screen.getByTestId('confirm-title').textContent).toBe('Unsaved Changes')
381
+ expect(closeWindow).not.toHaveBeenCalled()
382
+
383
+ fireEvent.click(screen.getByText('confirm'))
384
+ // saveFile()'s internal await plus its .then() are both microtasks; a real
385
+ // macrotask boundary is enough to flush them without fake timers.
386
+ await act(async () => { await new Promise(r => setTimeout(r, 10)) })
387
+
388
+ expect(adapter.saveFile).toHaveBeenCalled()
389
+ expect(closeWindow).toHaveBeenCalled()
390
+ })
391
+
392
+ it('stays open if the save during close is cancelled (no handle returned)', async () => {
393
+ let trigger: (() => void) | undefined
394
+ const closeWindow = vi.fn().mockResolvedValue(undefined)
395
+ const adapter = makeMockStorageAdapter({
396
+ saveFile: vi.fn().mockResolvedValue(null), // user dismissed the Save As picker
397
+ })
398
+ window.desktopAPI = {
399
+ readFile: vi.fn(), writeFile: vi.fn(), showOpenDialog: vi.fn(), showSaveDialog: vi.fn(),
400
+ onMenuCommand: vi.fn().mockReturnValue(() => {}),
401
+ onCloseRequested: (cb: () => void) => { trigger = cb; return () => {} },
402
+ closeWindow,
403
+ } as unknown as typeof window.desktopAPI
404
+
405
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
406
+
407
+ fireEvent.click(screen.getByText('edit'))
408
+ act(() => { trigger!() })
409
+ fireEvent.click(screen.getByText('confirm'))
410
+ await act(async () => { await new Promise(r => setTimeout(r, 10)) })
411
+
412
+ expect(adapter.saveFile).toHaveBeenCalled()
413
+ expect(closeWindow).not.toHaveBeenCalled()
414
+ })
415
+ })
416
+
417
+ describe('DocumentContext version history (power-user feature)', () => {
418
+ beforeEach(() => {
419
+ localStorage.clear()
420
+ })
421
+
422
+ it('loads versions for the file once it has a filePath', async () => {
423
+ const versionList = [
424
+ { id: 'v2', timestamp: 200, content: '## Scene 1\n\nSecond save.\n' },
425
+ { id: 'v1', timestamp: 100, content: '## Scene 1\n\nFirst save.\n' },
426
+ ]
427
+ const adapter = makeMockStorageAdapter({
428
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nHi.\n', handle: null }),
429
+ listVersions: vi.fn().mockResolvedValue(versionList),
430
+ })
431
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
432
+
433
+ expect(screen.getByTestId('versions').textContent).toBe('')
434
+
435
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
436
+
437
+ await waitFor(() => {
438
+ expect(screen.getByTestId('versions').textContent).toBe('v2|v1')
439
+ })
440
+ expect(adapter.listVersions).toHaveBeenCalledWith('myscript.sutra')
441
+ })
442
+
443
+ it('refreshes the version list after re-saving the same already-named file', async () => {
444
+ const adapter = makeMockStorageAdapter({
445
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
446
+ listVersions: vi.fn()
447
+ .mockResolvedValueOnce([{ id: 'v1', timestamp: 100, content: 'old' }])
448
+ .mockResolvedValueOnce([
449
+ { id: 'v2', timestamp: 200, content: 'new' },
450
+ { id: 'v1', timestamp: 100, content: 'old' },
451
+ ]),
452
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nHi.\n', handle: null }),
453
+ })
454
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
455
+
456
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
457
+ await waitFor(() => {
458
+ expect(screen.getByTestId('versions').textContent).toBe('v1')
459
+ })
460
+
461
+ // filePath doesn't change on this save (already 'myscript.sutra'), so the
462
+ // filePath-change effect alone wouldn't refetch — saveFile must do it explicitly.
463
+ fireEvent.click(screen.getByText('edit'))
464
+ await act(async () => { fireEvent.click(screen.getByText('save')) })
465
+
466
+ await waitFor(() => {
467
+ expect(screen.getByTestId('versions').textContent).toBe('v2|v1')
468
+ })
469
+ })
470
+
471
+ it('creates no version on autosave — only explicit saveFile/saveFileAs does', async () => {
472
+ const adapter = makeMockStorageAdapter({
473
+ saveFile: vi.fn().mockResolvedValue({ savedName: 'myscript.sutra', handle: null }),
474
+ })
475
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
476
+
477
+ fireEvent.click(screen.getByText('edit'))
478
+ await act(async () => { fireEvent.click(screen.getByText('autosaveNow')) })
479
+
480
+ // Autosave goes through storageAdapter.saveDocument, never storageAdapter.saveFile.
481
+ expect(adapter.saveDocument).toHaveBeenCalled()
482
+ expect(adapter.saveFile).not.toHaveBeenCalled()
483
+ })
484
+
485
+ it('restoreVersion replaces the document content and marks it dirty', async () => {
486
+ const versionList = [
487
+ { id: 'v2', timestamp: 200, content: '## Scene 1\n\nCurrent.\n' },
488
+ { id: 'v1', timestamp: 100, content: '## Scene 1\n\nOlder version.\n' },
489
+ ]
490
+ const adapter = makeMockStorageAdapter({
491
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nCurrent.\n', handle: null }),
492
+ listVersions: vi.fn().mockResolvedValue(versionList),
493
+ })
494
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
495
+
496
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
497
+ await waitFor(() => {
498
+ expect(screen.getByTestId('versions').textContent).toBe('v2|v1')
499
+ })
500
+
501
+ fireEvent.click(screen.getByText('restoreSecond'))
502
+
503
+ expect(screen.getByTestId('text').textContent).toContain('Older version')
504
+ expect(screen.getByTestId('dirty').textContent).toBe('true')
505
+ })
506
+
507
+ it('clearVersionHistory asks for confirmation, then calls the adapter and empties the list', async () => {
508
+ const versionList = [
509
+ { id: 'v1', timestamp: 100, content: '## Scene 1\n\nFirst save.\n' },
510
+ ]
511
+ const adapter = makeMockStorageAdapter({
512
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nHi.\n', handle: null }),
513
+ listVersions: vi.fn().mockResolvedValue(versionList),
514
+ deleteAllVersions: vi.fn().mockResolvedValue(undefined),
515
+ })
516
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
517
+
518
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
519
+ await waitFor(() => {
520
+ expect(screen.getByTestId('versions').textContent).toBe('v1')
521
+ })
522
+
523
+ fireEvent.click(screen.getByText('clearVersions'))
524
+ expect(screen.getByTestId('confirm-title').textContent).toBe('Delete Version History?')
525
+ expect(adapter.deleteAllVersions).not.toHaveBeenCalled()
526
+
527
+ await act(async () => { fireEvent.click(screen.getByText('confirm')) })
528
+
529
+ expect(adapter.deleteAllVersions).toHaveBeenCalledWith('myscript.sutra')
530
+ expect(screen.getByTestId('versions').textContent).toBe('')
531
+ })
532
+
533
+ it('newDocument resets filePath and versions so new file is not tracked under old file history', async () => {
534
+ const versionList = [
535
+ { id: 'v1', timestamp: 100, content: '## Scene 1\n\nFirst save.\n' },
536
+ ]
537
+ const adapter = makeMockStorageAdapter({
538
+ openFile: vi.fn().mockResolvedValue({ name: 'myscript.sutra', content: '## Scene 1\n\nHi.\n', handle: null }),
539
+ listVersions: vi.fn().mockResolvedValue(versionList),
540
+ saveDocument: vi.fn().mockResolvedValue(undefined),
541
+ })
542
+ render(<DocumentProvider storageAdapter={adapter}><FullProbe /></DocumentProvider>)
543
+
544
+ // Open existing file with versions
545
+ await act(async () => { fireEvent.click(screen.getByText('open')) })
546
+ await waitFor(() => {
547
+ expect(screen.getByTestId('filePath').textContent).toBe('myscript.sutra')
548
+ expect(screen.getByTestId('versions').textContent).toBe('v1')
549
+ })
550
+
551
+ // Start a new document
552
+ act(() => { fireEvent.click(screen.getByText('new')) })
553
+
554
+ // filePath and versions must be cleared immediately
555
+ expect(screen.getByTestId('filePath').textContent).toBe('null')
556
+ expect(screen.getByTestId('versions').textContent).toBe('')
557
+ expect(screen.getByTestId('dirty').textContent).toBe('false')
558
+
559
+ // An edit to the new document and subsequent autosave must NOT write to 'myscript.sutra'
560
+ fireEvent.click(screen.getByText('edit'))
561
+ await act(async () => { fireEvent.click(screen.getByText('autosaveNow')) })
562
+ expect(adapter.saveDocument).toHaveBeenCalledWith('__autosave__', expect.any(String))
563
+ expect(adapter.saveDocument).not.toHaveBeenCalledWith('myscript.sutra', expect.any(String))
564
+ })
565
+ })