@sutrata/editor 0.1.1 → 0.2.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 (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. package/vitest.setup.ts +0 -65
@@ -1,129 +0,0 @@
1
- import { render, screen, fireEvent } from '@testing-library/react'
2
- import { VersionHistory } from '../../src/shell/VersionHistory'
3
- import type { VersionEntry } from '../../src/types'
4
-
5
- // Scoped to exclude the dialog's own "×" close button, which is also role="button"
6
- // and renders before the version list in DOM order.
7
- function clickOlderVersion() {
8
- const items = document.querySelectorAll<HTMLButtonElement>('.cs-version-list-item')
9
- fireEvent.click(items[1]!) // index 0 = "Current"
10
- }
11
-
12
- function makeVersions(): VersionEntry[] {
13
- return [
14
- { id: 'v2', timestamp: 200, content: 'In the last two weeks' },
15
- { id: 'v1', timestamp: 100, content: 'In the last five weeks' },
16
- ]
17
- }
18
-
19
- describe('VersionHistory word-level diff', () => {
20
- it('highlights only the changed word, leaving the rest of the sentence unstyled', () => {
21
- render(
22
- <VersionHistory
23
- versions={makeVersions()}
24
- filePath="myscript.sutra"
25
- onRestore={vi.fn()}
26
- onClearAll={vi.fn().mockResolvedValue(undefined)}
27
- visible={true}
28
- onClose={() => {}}
29
- />,
30
- )
31
-
32
- clickOlderVersion()
33
-
34
- const removed = document.querySelector('.cs-version-diff-removed')
35
- const added = document.querySelector('.cs-version-diff-added')
36
-
37
- expect(removed?.textContent).toBe('five')
38
- expect(added?.textContent).toBe('two')
39
-
40
- // The unchanged words are present but carry no diff styling.
41
- const content = document.querySelector('.cs-version-diff-content')
42
- expect(content?.textContent).toContain('In the last')
43
- expect(content?.textContent).toContain('weeks')
44
- })
45
-
46
- it('shows a no-differences message when the selected version matches current', () => {
47
- const versions: VersionEntry[] = [
48
- { id: 'v1', timestamp: 100, content: 'Same content.' },
49
- { id: 'v0', timestamp: 50, content: 'Same content.' },
50
- ]
51
- render(
52
- <VersionHistory
53
- versions={versions}
54
- filePath="myscript.sutra"
55
- onRestore={vi.fn()}
56
- onClearAll={vi.fn().mockResolvedValue(undefined)}
57
- visible={true}
58
- onClose={() => {}}
59
- />,
60
- )
61
-
62
- clickOlderVersion()
63
- expect(screen.getByText('No differences from the current version')).toBeInTheDocument()
64
- })
65
- })
66
-
67
- describe('VersionHistory scrollbar change indicator', () => {
68
- it('renders one marker per change so a change is findable in a long diff', () => {
69
- render(
70
- <VersionHistory
71
- versions={makeVersions()}
72
- filePath="myscript.sutra"
73
- onRestore={vi.fn()}
74
- onClearAll={vi.fn().mockResolvedValue(undefined)}
75
- visible={true}
76
- onClose={() => {}}
77
- />,
78
- )
79
-
80
- clickOlderVersion()
81
-
82
- const removedMarkers = document.querySelectorAll('.cs-version-diff-marker-removed')
83
- const addedMarkers = document.querySelectorAll('.cs-version-diff-marker-added')
84
- expect(removedMarkers.length).toBe(1)
85
- expect(addedMarkers.length).toBe(1)
86
- })
87
-
88
- it('renders no markers (but keeps the minimap strip, unconditionally, for stable layout) when there are no differences', () => {
89
- const versions: VersionEntry[] = [
90
- { id: 'v1', timestamp: 100, content: 'Same content.' },
91
- { id: 'v0', timestamp: 50, content: 'Same content.' },
92
- ]
93
- render(
94
- <VersionHistory
95
- versions={versions}
96
- filePath="myscript.sutra"
97
- onRestore={vi.fn()}
98
- onClearAll={vi.fn().mockResolvedValue(undefined)}
99
- visible={true}
100
- onClose={() => {}}
101
- />,
102
- )
103
-
104
- clickOlderVersion()
105
- // The strip itself is always in the DOM (see VersionHistory.tsx comment: this
106
- // keeps the diff pane's width — and thus every measured marker offset — stable
107
- // from the very first render instead of shifting once markers pop in).
108
- expect(document.querySelector('.cs-version-diff-minimap')).not.toBeNull()
109
- expect(document.querySelectorAll('.cs-version-diff-marker').length).toBe(0)
110
- })
111
-
112
- it('clicking a marker scrolls the diff pane without throwing', () => {
113
- render(
114
- <VersionHistory
115
- versions={makeVersions()}
116
- filePath="myscript.sutra"
117
- onRestore={vi.fn()}
118
- onClearAll={vi.fn().mockResolvedValue(undefined)}
119
- visible={true}
120
- onClose={() => {}}
121
- />,
122
- )
123
-
124
- clickOlderVersion()
125
-
126
- const marker = document.querySelector<HTMLButtonElement>('.cs-version-diff-marker')!
127
- expect(() => fireEvent.click(marker)).not.toThrow()
128
- })
129
- })
@@ -1,82 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { createCheckerFromWords, addToCustomDictionary, clearCheckerCache, loadDictionary } from '../../src/spellcheck/hunspell'
3
-
4
- describe('hunspell', () => {
5
- beforeEach(() => {
6
- clearCheckerCache()
7
- })
8
-
9
- describe('createCheckerFromWords (fixture dictionary)', () => {
10
- it('check returns true for known words', () => {
11
- const checker = createCheckerFromWords(['hello', 'world', 'screenplay'])
12
- expect(checker.check('hello')).toBe(true)
13
- expect(checker.check('world')).toBe(true)
14
- })
15
-
16
- it('check returns false for unknown words', () => {
17
- const checker = createCheckerFromWords(['hello', 'world'])
18
- expect(checker.check('xyzzy')).toBe(false)
19
- })
20
-
21
- it('check is case-insensitive', () => {
22
- const checker = createCheckerFromWords(['hello'])
23
- expect(checker.check('Hello')).toBe(true)
24
- expect(checker.check('HELLO')).toBe(true)
25
- })
26
-
27
- it('suggest returns an array', () => {
28
- const checker = createCheckerFromWords(['hello'])
29
- const suggestions = checker.suggest('helo')
30
- expect(Array.isArray(suggestions)).toBe(true)
31
- })
32
- })
33
-
34
- describe('addToCustomDictionary', () => {
35
- it('custom words pass check after being added', () => {
36
- const checker = createCheckerFromWords(['hello'])
37
- expect(checker.check('screenplay')).toBe(false)
38
-
39
- // Add directly via the internal method (the checker is a SimpleSpellChecker)
40
- ;(checker as any).addCustomWord('screenplay')
41
- expect(checker.check('screenplay')).toBe(true)
42
- })
43
-
44
- it('addToCustomDictionary updates a cached checker', async () => {
45
- // Prime the cache with an empty checker for lang 'en-test'
46
- const cached = await loadDictionary('xx-custom')
47
- expect(cached.check('screenplay')).toBe(false)
48
-
49
- addToCustomDictionary('xx-custom', 'screenplay')
50
- expect(cached.check('screenplay')).toBe(true)
51
- })
52
- })
53
-
54
- describe('loadDictionary', () => {
55
- it('returns a SpellChecker for a known language even with no dict file', async () => {
56
- const checker = await loadDictionary('en')
57
- expect(checker).toBeDefined()
58
- expect(typeof checker.check).toBe('function')
59
- expect(typeof checker.suggest).toBe('function')
60
- })
61
-
62
- it('caches the checker on second call', async () => {
63
- const checker1 = await loadDictionary('ta')
64
- const checker2 = await loadDictionary('ta')
65
- expect(checker1).toBe(checker2) // same reference
66
- })
67
-
68
- it('returns empty checker for unknown language code', async () => {
69
- const checker = await loadDictionary('xx')
70
- expect(checker.check('anything')).toBe(false)
71
- })
72
-
73
- it('returns a checker with check and suggest for all manifest languages', async () => {
74
- for (const lang of ['en', 'hi', 'ta', 'te', 'kn', 'ml', 'bn', 'gu', 'pa', 'or']) {
75
- clearCheckerCache()
76
- const checker = await loadDictionary(lang)
77
- expect(typeof checker.check).toBe('function')
78
- expect(typeof checker.suggest).toBe('function')
79
- }
80
- })
81
- })
82
- })
@@ -1,56 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { EditorState } from 'prosemirror-state'
3
- import { schema } from '../../src/editor/schema'
4
- import { createSpellcheckPlugin, setSpellcheckLanguage, tokenizeWords } from '../../src/spellcheck/spellcheck-plugin'
5
-
6
- describe('spellcheck-plugin', () => {
7
- it('createSpellcheckPlugin returns a Plugin', () => {
8
- const plugin = createSpellcheckPlugin()
9
- expect(plugin).toBeDefined()
10
- expect(typeof plugin.spec.state).toBe('object')
11
- })
12
-
13
- it('plugin initializes with empty DecorationSet', () => {
14
- const plugin = createSpellcheckPlugin()
15
- const state = EditorState.create({
16
- schema,
17
- plugins: [plugin],
18
- })
19
- // Access state via plugin key (correct way without a live EditorView)
20
- const pluginState = plugin.getState(state)
21
- expect(pluginState).toBeDefined()
22
- })
23
-
24
- it('setSpellcheckLanguage does not throw', () => {
25
- expect(() => setSpellcheckLanguage('hi')).not.toThrow()
26
- expect(() => setSpellcheckLanguage('en')).not.toThrow()
27
- })
28
-
29
- it('plugin is registered in buildPlugins', async () => {
30
- const { buildPlugins } = await import('../../src/editor/plugins')
31
- const plugins = buildPlugins()
32
- // Should have more than 4 plugins (history, keymaps x2, languageClass, ime, autocomplete, spellcheck)
33
- expect(plugins.length).toBeGreaterThan(4)
34
- })
35
-
36
- it('tokenizeWords splits plain text into tokens', () => {
37
- const tokens = tokenizeWords('hello world foo')
38
- expect(tokens).toHaveLength(3)
39
- expect(tokens[0]).toMatchObject({ word: 'hello', start: 0, end: 5 })
40
- expect(tokens[1]).toMatchObject({ word: 'world', start: 6, end: 11 })
41
- expect(tokens[2]).toMatchObject({ word: 'foo', start: 12, end: 15 })
42
- })
43
-
44
- it('tokenizeWords handles punctuation and numbers', () => {
45
- const tokens = tokenizeWords('Hello, world! 123')
46
- // Should only capture words, not numbers or punctuation
47
- const words = tokens.map(t => t.word)
48
- expect(words).toContain('Hello')
49
- expect(words).toContain('world')
50
- expect(words).not.toContain('123')
51
- })
52
-
53
- it('tokenizeWords returns empty array for empty string', () => {
54
- expect(tokenizeWords('')).toHaveLength(0)
55
- })
56
- })
@@ -1,315 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { CLASSIC_STYLE, INDUSTRY_COURIER_STYLE, TRADITIONAL_STYLE, BUILTIN_STYLES } from '../src/styles/builtin-styles'
3
- import { resolveStyle, allStyles } from '../src/styles/registry'
4
- import { styleToCssVars, applyStyleVars } from '../src/styles/css-adapter'
5
- import { styleToDocxParagraphStyles } from '../src/styles/docx-adapter'
6
- import { styleToPrintCss } from '../src/styles/print-adapter'
7
- import { validateStyleJson } from '../src/styles/validate'
8
- import { DEFAULT_COVER_LAYOUT } from '../src/styles/types'
9
- import type { ScreenplayStyleDefinition } from '../src/styles/types'
10
-
11
- const CONTENT_WIDTH_TWIPS_LETTER = 9360 // 12240 - 1440 - 1440, i.e. classic's 1in margins
12
-
13
- describe('builtin-styles', () => {
14
- it('Traditional uses 10pt body / 12pt headings (regression: was 16pt/18pt, too large)', () => {
15
- const e = TRADITIONAL_STYLE.elements
16
- expect(e.sceneHeading.fontSizePt).toBe(12)
17
- expect(e.section.fontSizePt).toBe(12)
18
- for (const key of ['action', 'character', 'parenthetical', 'dialogue', 'transition', 'centered', 'lyrics'] as const) {
19
- expect(e[key].fontSizePt).toBe(10)
20
- }
21
- })
22
-
23
- it('Traditional note/logline match the 10pt body size (regression: note was 13pt, logline was 16pt)', () => {
24
- const e = TRADITIONAL_STYLE.elements
25
- expect(e.note.fontSizePt).toBe(10)
26
- expect(e.logline.fontSizePt).toBe(10)
27
- })
28
-
29
- it('Traditional note text is gray, not the default black', () => {
30
- expect(TRADITIONAL_STYLE.elements.note.color).toBe('666666')
31
- })
32
-
33
- it('Traditional gives lyrics a small left margin so they read as distinct from flush-left action', () => {
34
- expect(TRADITIONAL_STYLE.elements.lyrics.marginLeftPct).toBeGreaterThan(0)
35
- expect(TRADITIONAL_STYLE.elements.action.marginLeftPct ?? 0).toBe(0)
36
- })
37
- })
38
-
39
- describe('registry', () => {
40
- it('resolves an unknown or missing id to classic', () => {
41
- expect(resolveStyle('nonexistent-id', [])).toBe(CLASSIC_STYLE)
42
- expect(resolveStyle(undefined, [])).toBe(CLASSIC_STYLE)
43
- })
44
-
45
- it('resolves a known builtin id', () => {
46
- expect(resolveStyle('industry-courier', [])).toBe(INDUSTRY_COURIER_STYLE)
47
- })
48
-
49
- it('resolves a custom style by id, falling back to classic if not supplied', () => {
50
- const custom: ScreenplayStyleDefinition = { ...CLASSIC_STYLE, id: 'my-custom', name: 'My Custom' }
51
- expect(resolveStyle('my-custom', [custom])).toBe(custom)
52
- expect(resolveStyle('my-custom', [])).toBe(CLASSIC_STYLE)
53
- })
54
-
55
- it('allStyles lists builtins before customs', () => {
56
- const custom: ScreenplayStyleDefinition = { ...CLASSIC_STYLE, id: 'my-custom', name: 'My Custom' }
57
- const list = allStyles([custom])
58
- expect(list).toEqual([...BUILTIN_STYLES, custom])
59
- })
60
- })
61
-
62
- describe('css-adapter', () => {
63
- it('produces expected CSS variable values for classic', () => {
64
- const vars = styleToCssVars(CLASSIC_STYLE)
65
- expect(vars['--cs-body-font-family']).toBe('Noto Sans')
66
- expect(vars['--cs-character-color']).toBe('#9c4221'.toUpperCase().replace('#', '#')) // sanity: literal below is canonical
67
- expect(vars['--cs-character-color']).toBe('#9C4221')
68
- expect(vars['--cs-scene-heading-color']).toBe('#0A5C6A')
69
- expect(vars['--cs-scene-heading-font-weight']).toBe('bold')
70
- expect(vars['--cs-action-font-weight']).toBe('normal')
71
- // null (not the string 'inherit'!) means "no override" — applyStyleVars clears the
72
- // property with removeProperty so the CSS var()'s own `inherit` fallback takes over.
73
- // Setting a custom property's literal value to 'inherit' would instead make the
74
- // property itself inherit from its own parent, breaking inheritance for everything below it.
75
- expect(vars['--cs-action-color']).toBeNull()
76
- expect(vars['--cs-dialogue-margin-left']).toBe('17%')
77
- expect(vars['--cs-transition-text-align']).toBe('right')
78
- expect(vars['--cs-centered-text-align']).toBe('center')
79
- expect(vars['--cs-action-text-align']).toBe('left')
80
- expect(vars['--cs-lyrics-font-style']).toBe('italic')
81
- expect(vars['--cs-scene-heading-margin-top']).toBe('13pt')
82
- })
83
-
84
- it('carries Traditional\'s lyrics left margin through to the CSS var, unlike flush-left action', () => {
85
- const vars = styleToCssVars(TRADITIONAL_STYLE)
86
- expect(vars['--cs-lyrics-margin-left']).toBe('6%')
87
- expect(vars['--cs-action-margin-left']).toBe('0%')
88
- })
89
-
90
- it('never emits margin-bottom / spaceAfterPt-derived vars (editor has no space-after concept)', () => {
91
- const vars = styleToCssVars(CLASSIC_STYLE)
92
- expect(Object.keys(vars).some(k => k.includes('margin-bottom'))).toBe(false)
93
- })
94
-
95
- it('applyStyleVars clears a previous style\'s color instead of setting the literal "inherit"', () => {
96
- const el = document.createElement('div')
97
- applyStyleVars(el, CLASSIC_STYLE) // has an action color? no — but character does
98
- applyStyleVars(el, { ...CLASSIC_STYLE, elements: { ...CLASSIC_STYLE.elements, character: { ...CLASSIC_STYLE.elements.character, color: 'FF0000' } } })
99
- expect(el.style.getPropertyValue('--cs-character-color')).toBe('#FF0000')
100
-
101
- // Switching to a style with no color for this element must clear the property
102
- // (not set it to the string 'inherit', which would break inheritance for descendants).
103
- applyStyleVars(el, INDUSTRY_COURIER_STYLE)
104
- expect(el.style.getPropertyValue('--cs-character-color')).toBe('')
105
- expect(el.style.cssText).not.toContain('inherit')
106
- })
107
- })
108
-
109
- describe('docx-adapter', () => {
110
- it('keeps the CineXxx style ids/names stable (docx-importer.ts depends on this)', () => {
111
- const styles = styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
112
- const ids = styles.map(s => s.id)
113
- expect(ids).toEqual([
114
- 'CineSceneHeading', 'CineAction', 'CineCharacter', 'CineParenthetical', 'CineDialogue',
115
- 'CineTransition', 'CineCentered', 'CineLyrics', 'CineSection', 'CineNote', 'CineComment',
116
- 'CineTitle', 'CineAuthor', 'CineLogline',
117
- ])
118
- for (const s of styles) expect(s.name).toBe(s.id)
119
- })
120
-
121
- it('defines the cover-page styles the exporter references (else Word falls back to Normal)', () => {
122
- const byId = Object.fromEntries(
123
- styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER).map(s => [s.id, s])
124
- )
125
- expect(byId['CineTitle']!.run!.size).toBe(48) // 24pt * 2
126
- expect(byId['CineTitle']!.run!.bold).toBe(true)
127
- expect(byId['CineTitle']!.run!.color).toBe('0A5C6A')
128
- expect(byId['CineTitle']!.paragraph!.alignment).toBe('center')
129
- expect(byId['CineAuthor']!.run!.size).toBe(32) // 16pt * 2
130
- expect(byId['CineAuthor']!.paragraph!.alignment).toBe('center')
131
- expect(byId['CineLogline']!.run!.italics).toBe(true)
132
- expect(byId['CineLogline']!.paragraph!.alignment).toBe('center')
133
- })
134
-
135
- it('converts classic pt/percent values to docx half-points/twips correctly', () => {
136
- const styles = styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
137
- const byId = Object.fromEntries(styles.map(s => [s.id, s]))
138
-
139
- expect(byId['CineSceneHeading']!.run!.size).toBe(26) // 13pt * 2
140
- expect(byId['CineSceneHeading']!.run!.bold).toBe(true)
141
- expect(byId['CineSceneHeading']!.run!.color).toBe('0A5C6A')
142
- expect(byId['CineSceneHeading']!.paragraph!.indent).toEqual({ left: 936, right: 749 }) // 10%/8% of 9360
143
- expect(byId['CineSceneHeading']!.paragraph!.spacing).toEqual({ before: 260, after: 144 })
144
-
145
- expect(byId['CineCharacter']!.paragraph!.indent).toEqual({ left: 1591, right: undefined })
146
- expect(byId['CineCharacter']!.paragraph!.spacing).toEqual({ before: 130, after: 0 })
147
-
148
- expect(byId['CineDialogue']!.run!.size).toBe(24) // 12pt * 2
149
- expect(byId['CineDialogue']!.paragraph!.indent).toEqual({ left: 1591, right: 1591 })
150
- })
151
-
152
- it('produces no crash and sensible output for a style with no colors set (industry-courier)', () => {
153
- const styles = styleToDocxParagraphStyles(INDUSTRY_COURIER_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
154
- for (const s of styles) expect(s.run!.color).toBeUndefined()
155
- })
156
-
157
- it('keeps scene headings/character cues/parentheticals with what follows them', () => {
158
- const byId = Object.fromEntries(
159
- styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER).map(s => [s.id, s])
160
- )
161
- for (const id of ['CineSceneHeading', 'CineCharacter', 'CineParenthetical', 'CineSection']) {
162
- expect(byId[id]!.paragraph!.keepNext).toBe(true)
163
- expect(byId[id]!.paragraph!.keepLines).toBe(true)
164
- }
165
- // Action/dialogue can legitimately outrun a page — they must stay breakable,
166
- // with widow control instead of keepNext/keepLines.
167
- for (const id of ['CineAction', 'CineDialogue']) {
168
- expect(byId[id]!.paragraph!.keepNext).toBeUndefined()
169
- expect(byId[id]!.paragraph!.keepLines).toBeUndefined()
170
- expect(byId[id]!.paragraph!.widowControl).toBe(true)
171
- }
172
- })
173
-
174
- it('scales indentation with a different content width (A4) instead of reusing Letter twips', () => {
175
- const letterStyles = styleToDocxParagraphStyles(CLASSIC_STYLE, 9360)
176
- const a4ContentWidth = 11906 - 1440 - 1440 // A4 width minus 1in margins
177
- const a4Styles = styleToDocxParagraphStyles(CLASSIC_STYLE, a4ContentWidth)
178
- const letterAction = letterStyles.find(s => s.id === 'CineAction')!
179
- const a4Action = a4Styles.find(s => s.id === 'CineAction')!
180
- expect(a4Action.paragraph!.indent!.left).not.toBe(letterAction.paragraph!.indent!.left)
181
- })
182
- })
183
-
184
- describe('print-adapter', () => {
185
- it('embeds the resolved page size, margins and body font', () => {
186
- const css = styleToPrintCss(CLASSIC_STYLE)
187
- expect(css).toContain('margin: 1in 1in 1in 1in;')
188
- expect(css).toContain('size: letter;')
189
- expect(styleToPrintCss(CLASSIC_STYLE, 'a4')).toContain('size: A4;')
190
- expect(css).toMatch(/font-family: 'Noto Sans', 'Noto Sans Tamil'/)
191
- })
192
-
193
- it('puts Noto Sans, the ISO 15919-capable Latin fallback, before the Indic families', () => {
194
- const css = styleToPrintCss(INDUSTRY_COURIER_STYLE)
195
- expect(css).toMatch(/font-family: 'Courier Prime', 'Noto Sans', 'Noto Sans Tamil'/)
196
- })
197
-
198
- it('lays the preview out at the printable width, so on-screen line breaks match the printed ones', () => {
199
- // The paginator measures the live window: a preview wider than the paper would
200
- // wrap later on screen than on paper and overflow every page it filled.
201
- expect(styleToPrintCss(CLASSIC_STYLE)).toMatch(/#print-flow, #print-pages, \.print-cover-page \{[\s\S]*?width: 6\.5in;/)
202
- expect(styleToPrintCss(CLASSIC_STYLE, 'a4')).toMatch(/#print-flow, #print-pages, \.print-cover-page \{[\s\S]*?width: 6\.27in;/)
203
- })
204
-
205
- it('puts Traditional-style Noto Serif and its per-script overrides first, with the Indic Noto Sans safety net after', () => {
206
- const css = styleToPrintCss(TRADITIONAL_STYLE)
207
- expect(css).toMatch(/font-family: 'Noto Serif', 'Noto Serif Devanagari', 'Noto Serif Tamil'[\s\S]*?'Noto Sans Tamil'/)
208
- })
209
-
210
- it('embeds per-element font-size/color/margins', () => {
211
- const css = styleToPrintCss(CLASSIC_STYLE)
212
- expect(css).toContain('.print-character {')
213
- expect(css).toContain('color: #9C4221;')
214
- expect(css).toMatch(/\.print-dialogue \{[\s\S]*?font-size: 12pt;/)
215
- })
216
-
217
- it('underlines .print-character when the style says so (regression: this rule used to be hand-built and dropped underline/italic)', () => {
218
- const css = styleToPrintCss(TRADITIONAL_STYLE)
219
- expect(css).toMatch(/\.print-character \{[\s\S]*?text-decoration: underline;/)
220
- })
221
-
222
- it('does not throw for a style with no colors set', () => {
223
- expect(() => styleToPrintCss(INDUSTRY_COURIER_STYLE)).not.toThrow()
224
- })
225
-
226
- it('emits both cover layouts, so a style can pick either', () => {
227
- const css = styleToPrintCss(CLASSIC_STYLE)
228
- expect(css).toMatch(/\.print-cover-centered \{[\s\S]*?text-align: center;/)
229
- expect(css).toMatch(/\.print-cover-corners \{[\s\S]*?justify-content: space-between;/)
230
- expect(css).toMatch(/\.print-cover-corners \.print-cover-bottom \{[\s\S]*?align-self: flex-end;/)
231
- })
232
-
233
- it('forbids a page break right after a scene heading or character cue', () => {
234
- const css = styleToPrintCss(CLASSIC_STYLE)
235
- for (const sel of ['.print-scene-heading', '.print-character', '.print-parenthetical', '.print-section']) {
236
- const rule = css.match(new RegExp(`\\${sel} \\{[\\s\\S]*?\\}`))![0]
237
- expect(rule).toContain('break-after: avoid;')
238
- expect(rule).toContain('page-break-after: avoid;') // legacy spelling for older print engines
239
- expect(rule).toContain('break-inside: avoid;')
240
- }
241
- })
242
-
243
- it('gives breakable elements orphan/widow control instead of an unbreakable box', () => {
244
- const css = styleToPrintCss(CLASSIC_STYLE)
245
- for (const sel of ['.print-action', '.print-dialogue']) {
246
- const rule = css.match(new RegExp(`\\${sel} \\{[\\s\\S]*?\\}`))![0]
247
- expect(rule).toContain('orphans: 2;')
248
- expect(rule).toContain('widows: 2;')
249
- expect(rule).not.toContain('break-after: avoid;')
250
- }
251
- })
252
- })
253
-
254
- describe('built-in cover layouts', () => {
255
- it('gives Traditional the corner cover and leaves the others centered', () => {
256
- expect(TRADITIONAL_STYLE.coverLayout).toBe('corners')
257
- expect(CLASSIC_STYLE.coverLayout ?? DEFAULT_COVER_LAYOUT).toBe('centered')
258
- expect(INDUSTRY_COURIER_STYLE.coverLayout ?? DEFAULT_COVER_LAYOUT).toBe('centered')
259
- })
260
-
261
- it('does not underline the title', () => {
262
- for (const style of [CLASSIC_STYLE, INDUSTRY_COURIER_STYLE, TRADITIONAL_STYLE]) {
263
- expect(style.elements.title.underline).toBeUndefined()
264
- }
265
- })
266
- })
267
-
268
- describe('validate', () => {
269
- it('accepts a valid style definition', () => {
270
- const result = validateStyleJson(CLASSIC_STYLE)
271
- expect(result.ok).toBe(true)
272
- })
273
-
274
- it('rejects non-objects', () => {
275
- expect(validateStyleJson(null).ok).toBe(false)
276
- expect(validateStyleJson('nope').ok).toBe(false)
277
- })
278
-
279
- it('collects multiple errors for a malformed style', () => {
280
- const bad = {
281
- id: '',
282
- name: 'Bad',
283
- // fontFamily missing
284
- page: { marginTopIn: 1, marginBottomIn: -1, marginLeftIn: 1 }, // missing marginRightIn, negative marginBottomIn
285
- elements: {
286
- sceneHeading: { fontSizePt: 'big' },
287
- // all other elements missing
288
- },
289
- }
290
- const result = validateStyleJson(bad)
291
- expect(result.ok).toBe(false)
292
- if (!result.ok) {
293
- expect(result.errors.some(e => e.startsWith('id:'))).toBe(true)
294
- expect(result.errors.some(e => e.startsWith('fontFamily:'))).toBe(true)
295
- expect(result.errors.some(e => e.includes('marginRightIn'))).toBe(true)
296
- expect(result.errors.some(e => e.includes('marginBottomIn'))).toBe(true)
297
- expect(result.errors.some(e => e.includes('elements.sceneHeading.fontSizePt'))).toBe(true)
298
- expect(result.errors.some(e => e.includes('elements.action: missing'))).toBe(true)
299
- }
300
- })
301
-
302
- it('rejects a bad color / align / margin value', () => {
303
- const style = JSON.parse(JSON.stringify(CLASSIC_STYLE))
304
- style.elements.character.color = '#9C4221' // leading '#' not allowed
305
- style.elements.transition.align = 'diagonal'
306
- style.elements.action.marginLeftPct = 150
307
- const result = validateStyleJson(style)
308
- expect(result.ok).toBe(false)
309
- if (!result.ok) {
310
- expect(result.errors.some(e => e.includes('elements.character.color'))).toBe(true)
311
- expect(result.errors.some(e => e.includes('elements.transition.align'))).toBe(true)
312
- expect(result.errors.some(e => e.includes('elements.action.marginLeftPct'))).toBe(true)
313
- }
314
- })
315
- })
@@ -1,83 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { getTemplate, TEMPLATES } from '../../src/templates/templates'
3
-
4
- describe('templates', () => {
5
- it('getTemplate returns a string for English', () => {
6
- const t = getTemplate('en')
7
- expect(typeof t).toBe('string')
8
- expect(t.length).toBeGreaterThan(0)
9
- })
10
-
11
- it('English template contains required Sutra elements', () => {
12
- const t = getTemplate('en')
13
- expect(t).toContain('---') // frontmatter
14
- expect(t).toContain('lang: en') // language declaration
15
- expect(t).toContain('## ') // scene heading
16
- expect(t).toContain('@') // character cue
17
- })
18
-
19
- it('returns Hindi template with Devanagari content', () => {
20
- const t = getTemplate('hi')
21
- expect(t).toContain('lang: hi')
22
- // Should contain Devanagari characters
23
- expect(/[ऀ-ॿ]/.test(t)).toBe(true)
24
- })
25
-
26
- it('returns Tamil template with Tamil content', () => {
27
- const t = getTemplate('ta')
28
- expect(t).toContain('lang: ta')
29
- expect(/[஀-௿]/.test(t)).toBe(true)
30
- })
31
-
32
- it('returns Telugu template with Telugu content', () => {
33
- const t = getTemplate('te')
34
- expect(t).toContain('lang: te')
35
- expect(/[ఀ-౿]/.test(t)).toBe(true)
36
- })
37
-
38
- it('returns Kannada template with Kannada content', () => {
39
- const t = getTemplate('kn')
40
- expect(t).toContain('lang: kn')
41
- expect(/[ಀ-೿]/.test(t)).toBe(true)
42
- })
43
-
44
- it('returns Malayalam template with Malayalam content', () => {
45
- const t = getTemplate('ml')
46
- expect(t).toContain('lang: ml')
47
- expect(/[ഀ-ൿ]/.test(t)).toBe(true)
48
- })
49
-
50
- it('returns Bengali template with Bengali content', () => {
51
- const t = getTemplate('bn')
52
- expect(t).toContain('lang: bn')
53
- expect(/[ঀ-৿]/.test(t)).toBe(true)
54
- })
55
-
56
- it('returns Gujarati template with Gujarati content', () => {
57
- const t = getTemplate('gu')
58
- expect(t).toContain('lang: gu')
59
- expect(/[઀-૿]/.test(t)).toBe(true)
60
- })
61
-
62
- it('falls back to English for unknown language code', () => {
63
- const t = getTemplate('xx')
64
- expect(t).toContain('lang: en')
65
- })
66
-
67
- it('all template languages are covered', () => {
68
- const langs = ['en', 'hi', 'ta', 'te', 'kn', 'ml', 'bn', 'gu', 'mr', 'pa', 'or', 'si']
69
- for (const lang of langs) {
70
- expect(TEMPLATES[lang]).toBeDefined()
71
- expect(TEMPLATES[lang].length).toBeGreaterThan(50)
72
- }
73
- })
74
-
75
- it('every template contains frontmatter, scene heading, character cue', () => {
76
- for (const [lang, tmpl] of Object.entries(TEMPLATES)) {
77
- expect(tmpl, `${lang}: missing frontmatter`).toContain('---')
78
- expect(tmpl, `${lang}: missing scene heading`).toContain('## ')
79
- expect(tmpl, `${lang}: missing character cue`).toContain('@')
80
- expect(tmpl.length, `${lang}: template too short`).toBeGreaterThan(50)
81
- }
82
- })
83
- })