@sutrata/editor 0.1.0 → 0.1.2

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 (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +36 -9
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. package/vitest.setup.ts +0 -65
@@ -1,22 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { render, screen } from '@testing-library/react'
3
- import { DocumentProvider } from '../src/context/DocumentContext'
4
- import { LanguageProvider } from '../src/i18n/LanguageContext'
5
- import { TranslationProvider } from '../src/i18n/useTranslation'
6
- import { AppShell } from '../src/shell/AppShell'
7
-
8
- describe('AppShell', () => {
9
- it('renders the AppBar and the ElementToolbar when ribbon is visible', () => {
10
- render(
11
- <TranslationProvider>
12
- <DocumentProvider>
13
- <LanguageProvider>
14
- <AppShell />
15
- </LanguageProvider>
16
- </DocumentProvider>
17
- </TranslationProvider>
18
- )
19
- expect(screen.getByRole('button', { name: /scene navigator/i })).toBeInTheDocument()
20
- expect(screen.getByRole('toolbar', { name: /editing toolbar/i })).toBeInTheDocument()
21
- })
22
- })
@@ -1,36 +0,0 @@
1
- import React from 'react'
2
- import { describe, it, expect } from 'vitest'
3
- import { render, screen } from '@testing-library/react'
4
- import { ElementToolbar } from '../src/shell/ElementToolbar'
5
- import { TranslationProvider } from '../src/i18n/useTranslation'
6
-
7
- describe('ElementToolbar', () => {
8
- it('renders element buttons and B/I/U at the end', () => {
9
- render(
10
- <TranslationProvider>
11
- <ElementToolbar />
12
- </TranslationProvider>
13
- )
14
- expect(screen.getByRole('button', { name: /scene heading/i })).toBeInTheDocument()
15
- expect(screen.getByRole('button', { name: /character/i })).toBeInTheDocument()
16
- const buttons = screen.getAllByRole('button')
17
- const labels = buttons.map(b => b.getAttribute('aria-label'))
18
- expect(labels.indexOf('Bold')).toBeGreaterThan(labels.indexOf('Scene Heading'))
19
- expect(labels).toContain('Underline')
20
- })
21
-
22
- it('renders a button for every screenplay element, including Lyrics', () => {
23
- render(
24
- <TranslationProvider>
25
- <ElementToolbar />
26
- </TranslationProvider>
27
- )
28
- const labels = screen.getAllByRole('button').map(b => b.getAttribute('aria-label'))
29
- for (const name of [
30
- 'Scene Heading', 'Action', 'Character', 'Dialogue', 'Parenthetical',
31
- 'Transition', 'Centered', 'Lyrics', 'Note', 'Page Break', 'Section',
32
- ]) {
33
- expect(labels).toContain(name)
34
- }
35
- })
36
- })
@@ -1,129 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
3
- import { LanguageStatus } from '../src/shell/LanguageStatus'
4
- import { LanguageProvider } from '../src/i18n/LanguageContext'
5
- import { DocumentProvider } from '../src/context/DocumentContext'
6
-
7
- function TestWrapper({ children }: { children: React.ReactNode }) {
8
- return (
9
- <DocumentProvider>
10
- <LanguageProvider>{children}</LanguageProvider>
11
- </DocumentProvider>
12
- )
13
- }
14
-
15
- describe('LanguageStatus', () => {
16
- it('renders with current language code', () => {
17
- render(
18
- <TestWrapper>
19
- <LanguageStatus />
20
- </TestWrapper>
21
- )
22
- // Should show "EN" for English (default)
23
- expect(screen.getByText('EN')).toBeInTheDocument()
24
- })
25
-
26
- it('opens menu on button click', () => {
27
- render(
28
- <TestWrapper>
29
- <LanguageStatus />
30
- </TestWrapper>
31
- )
32
- const btn = screen.getByText('EN')
33
- fireEvent.click(btn)
34
- // Menu should be visible with language options
35
- expect(screen.getByText('English')).toBeInTheDocument()
36
- expect(screen.getByText('Tamil')).toBeInTheDocument()
37
- })
38
-
39
- it('closes menu after language selection', () => {
40
- const { container } = render(
41
- <TestWrapper>
42
- <LanguageStatus />
43
- </TestWrapper>
44
- )
45
- const btn = screen.getByText('EN')
46
- fireEvent.click(btn)
47
- const tamilOption = screen.getByText('Tamil')
48
- fireEvent.click(tamilOption)
49
- // Menu should close
50
- expect(container.querySelector('.cs-lang-menu')).not.toBeInTheDocument()
51
- })
52
-
53
- it('highlights current language in menu', () => {
54
- const { container } = render(
55
- <TestWrapper>
56
- <LanguageStatus />
57
- </TestWrapper>
58
- )
59
- const btn = screen.getByText('EN')
60
- fireEvent.click(btn)
61
- // Find the English option in the menu
62
- const menuOptions = container.querySelectorAll('.cs-lang-option')
63
- // First option should be English and should be active
64
- expect(menuOptions[0]).toHaveClass('cs-lang-active')
65
- })
66
-
67
- it('updates document text with language tag on override', () => {
68
- const { container } = render(
69
- <TestWrapper>
70
- <LanguageStatus />
71
- </TestWrapper>
72
- )
73
- const btn = screen.getByText('EN')
74
- fireEvent.click(btn)
75
- const tamilOption = screen.getByText('Tamil')
76
- fireEvent.click(tamilOption)
77
- // Text should have been mutated with language tag
78
- // Menu should be closed
79
- expect(container.querySelector('.cs-lang-menu')).not.toBeInTheDocument()
80
- })
81
-
82
- it('renders all 10 language options in menu', () => {
83
- render(
84
- <TestWrapper>
85
- <LanguageStatus />
86
- </TestWrapper>
87
- )
88
- const btn = screen.getByText('EN')
89
- fireEvent.click(btn)
90
-
91
- const expectedLanguages = [
92
- 'English',
93
- 'Hindi (Devanagari)',
94
- 'Tamil',
95
- 'Telugu',
96
- 'Kannada',
97
- 'Malayalam',
98
- 'Bengali',
99
- 'Gujarati',
100
- 'Gurmukhi',
101
- 'Odia',
102
- ]
103
-
104
- expectedLanguages.forEach(lang => {
105
- expect(screen.getByText(lang)).toBeInTheDocument()
106
- })
107
- })
108
-
109
- it('toggles menu visibility on repeated button clicks', () => {
110
- const { container } = render(
111
- <TestWrapper>
112
- <LanguageStatus />
113
- </TestWrapper>
114
- )
115
- const btn = screen.getByText('EN')
116
-
117
- // Open menu
118
- fireEvent.click(btn)
119
- expect(container.querySelector('.cs-lang-menu')).toBeInTheDocument()
120
-
121
- // Close menu
122
- fireEvent.click(btn)
123
- expect(container.querySelector('.cs-lang-menu')).not.toBeInTheDocument()
124
-
125
- // Open menu again
126
- fireEvent.click(btn)
127
- expect(container.querySelector('.cs-lang-menu')).toBeInTheDocument()
128
- })
129
- })
@@ -1,71 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { render, screen, act, fireEvent } from '@testing-library/react';
3
- import React from 'react';
4
- import { SelectionMenu } from '../src/editor/SelectionMenu';
5
- import { DocumentProvider } from '../src/context/DocumentContext';
6
- import { LanguageProvider } from '../src/i18n/LanguageContext';
7
- import { TranslationProvider } from '../src/i18n/useTranslation';
8
-
9
- describe('SelectionMenu floating toolbar', () => {
10
- beforeEach(() => {
11
- vi.restoreAllMocks();
12
- });
13
-
14
- it('renders nothing when there is no selection', () => {
15
- render(
16
- <TranslationProvider>
17
- <DocumentProvider>
18
- <LanguageProvider>
19
- <SelectionMenu />
20
- </LanguageProvider>
21
- </DocumentProvider>
22
- </TranslationProvider>
23
- );
24
-
25
- expect(screen.queryByText('Format')).toBeNull();
26
- });
27
-
28
- it('renders menu when selection is present inside editor', () => {
29
- const editorDiv = document.createElement('div');
30
- editorDiv.className = 'ProseMirror';
31
-
32
- const mockSelection = {
33
- isCollapsed: false,
34
- rangeCount: 1,
35
- toString: () => 'some unformatted text',
36
- getRangeAt: () => ({
37
- commonAncestorContainer: editorDiv,
38
- getBoundingClientRect: () => ({
39
- left: 100,
40
- width: 200,
41
- top: 150,
42
- height: 20,
43
- }),
44
- }),
45
- };
46
-
47
- vi.spyOn(window, 'getSelection').mockImplementation(() => mockSelection as any);
48
-
49
- render(
50
- <TranslationProvider>
51
- <DocumentProvider>
52
- <LanguageProvider>
53
- <SelectionMenu />
54
- </LanguageProvider>
55
- </DocumentProvider>
56
- </TranslationProvider>
57
- );
58
-
59
- act(() => {
60
- document.dispatchEvent(new Event('selectionchange'));
61
- });
62
-
63
- expect(screen.getByText('Format')).toBeInTheDocument();
64
- expect(screen.getByText('Cut')).toBeInTheDocument();
65
- expect(screen.getByText('Copy')).toBeInTheDocument();
66
-
67
- // Clicking Format should open the confirm dialog
68
- fireEvent.click(screen.getByText('Format'));
69
- expect(screen.getByText('Formatting speech into Sutra screenplay format...')).toBeInTheDocument();
70
- });
71
- });
@@ -1,24 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { render, screen } from '@testing-library/react'
3
- import { ToolbarButton } from '../src/shell/ToolbarButton'
4
-
5
- describe('ToolbarButton', () => {
6
- it('renders label, sigil and shortcut in the tooltip', () => {
7
- render(
8
- <ToolbarButton label="Scene Heading" sigil="##" shortcut="" active={false} onClick={() => {}}>
9
- <span>icon</span>
10
- </ToolbarButton>
11
- )
12
- expect(screen.getByRole('button', { name: /scene heading/i })).toBeInTheDocument()
13
- expect(screen.getByText('##')).toBeInTheDocument()
14
- })
15
-
16
- it('applies active class when active', () => {
17
- render(
18
- <ToolbarButton label="Bold" sigil="" shortcut="Mod-B" active={true} onClick={() => {}}>
19
- <span>B</span>
20
- </ToolbarButton>
21
- )
22
- expect(screen.getByRole('button')).toHaveClass('cs-tb-active')
23
- })
24
- })
@@ -1,102 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { getAIConfig, saveAIConfig, getApiKey, setApiKey, deleteApiKey, fetchProviders, cleanVoiceResponse } from '../src/ai/ai-client';
3
-
4
- describe('AI Client Key Management & Onboarding', () => {
5
- beforeEach(() => {
6
- localStorage.clear();
7
- });
8
-
9
- it('should return default AIConfig when none is saved', () => {
10
- const config = getAIConfig();
11
- expect(config.preferredProvider).toBe('anthropic');
12
- expect(config.preferredModel).toBe('');
13
- expect(config.customProviders).toEqual([]);
14
- });
15
-
16
- it('should save and load AIConfig correctly', () => {
17
- const customConfig = {
18
- preferredProvider: 'groq',
19
- preferredModel: 'llama-3.3-70b-versatile',
20
- customProviders: [
21
- {
22
- id: 'local-ollama',
23
- name: 'Local Ollama',
24
- baseUrl: 'http://localhost:11434/v1',
25
- models: [{ id: 'llama3', name: 'Llama 3' }]
26
- }
27
- ]
28
- };
29
- saveAIConfig(customConfig);
30
- const loaded = getAIConfig();
31
- expect(loaded).toEqual(customConfig);
32
- });
33
-
34
- it('should store and delete API keys in localStorage', async () => {
35
- const geminiKey = 'test-gemini-key-123';
36
- await setApiKey('gemini', geminiKey);
37
- const loadedKey = await getApiKey('gemini');
38
- expect(loadedKey).toBe(geminiKey);
39
-
40
- await deleteApiKey('gemini');
41
- const clearedKey = await getApiKey('gemini');
42
- expect(clearedKey).toBeNull();
43
- });
44
-
45
- it('should merge custom providers with base providers', async () => {
46
- const customConfig = {
47
- preferredProvider: 'local-ollama',
48
- preferredModel: 'llama3',
49
- customProviders: [
50
- {
51
- id: 'local-ollama',
52
- name: 'Local Ollama',
53
- baseUrl: 'http://localhost:11434/v1',
54
- models: [{ id: 'llama3', name: 'Llama 3' }]
55
- }
56
- ]
57
- };
58
- const list = await fetchProviders(customConfig);
59
- const ollama = list.find(p => p.id === 'local-ollama');
60
- expect(ollama).toBeDefined();
61
- expect(ollama?.name).toBe('Local Ollama');
62
- expect(ollama?.models[0]?.id).toBe('llama3');
63
-
64
- // Default provider should still exist
65
- const gemini = list.find(p => p.id === 'gemini');
66
- expect(gemini).toBeDefined();
67
- });
68
-
69
- describe('cleanVoiceResponse', () => {
70
- it('should clean voice responses by stripping code blocks, reasoning, and shorthand comments', () => {
71
- const inputCluttered = `
72
- * Input: Raw voice transcript "New Scene scene #2 Exterior swimming pool daytime."
73
- * Task: Convert to Sutra screenplay format.
74
- * Sutra Rules:
75
- * Scene Headings: \`##\` (e.g., \`## INT. COFFEE SHOP - DAY\`).
76
- * Format: \`## EXT. SWIMMING POOL - DAY {#2}\` (Standard screenplay shorthand for Exterior and Daytime).
77
-
78
- ## EXT. SWIMMING POOL - DAY {#2}
79
- `;
80
- expect(cleanVoiceResponse(inputCluttered)).toBe('## EXT. SWIMMING POOL - DAY {#2}');
81
-
82
- const inputWithCodeBlock = '```cine\n## EXT. SWIMMING POOL - DAY {#2}\n```';
83
- expect(cleanVoiceResponse(inputWithCodeBlock)).toBe('## EXT. SWIMMING POOL - DAY {#2}');
84
-
85
- const inputWithTrailingShorthand = '## EXT. SWIMMING POOL - DAY {#2} (Standard screenplay shorthand for Exterior and Daytime)';
86
- expect(cleanVoiceResponse(inputWithTrailingShorthand)).toBe('## EXT. SWIMMING POOL - DAY {#2}');
87
-
88
- const userGemmaClutter = `
89
- Rules:
90
- Scene ID: Append as \`{#ID}\` at the end.
91
-
92
- "7" -> Scene ID {#7}
93
- "exterior" -> EXT.
94
- "Road" -> ROAD
95
- "evening" -> EVENING
96
-
97
- * \`## EXT. ROAD - EVENING {#7}
98
- `;
99
- expect(cleanVoiceResponse(userGemmaClutter)).toBe('## EXT. ROAD - EVENING {#7}');
100
- });
101
- });
102
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { render, screen, act } from '@testing-library/react'
3
- import React from 'react'
4
- import { formatActiveCalls, subscribeToAICalls, mockActiveCallsList } from '../src/ai/ai-client'
5
- import { AppShell } from '../src/shell/AppShell'
6
- import { DocumentProvider } from '../src/context/DocumentContext'
7
- import { LanguageProvider } from '../src/i18n/LanguageContext'
8
- import { TranslationProvider } from '../src/i18n/useTranslation'
9
-
10
- describe('AI Status Bar indicator', () => {
11
- it('formats active calls correctly', () => {
12
- expect(formatActiveCalls([])).toBe('')
13
- expect(formatActiveCalls([{ providerName: 'Gemini', model: 'gemma-31b' }])).toBe('Calling Gemini (gemma-31b)...')
14
- expect(formatActiveCalls([
15
- { providerName: 'Gemini', model: 'gemma-31b' },
16
- { providerName: 'Gemini', model: 'gemma-31b' },
17
- ])).toBe('Calling Gemini (gemma-31b) (2 calls)...')
18
- expect(formatActiveCalls([
19
- { providerName: 'Gemini', model: 'gemma-31b' },
20
- { providerName: 'Groq', model: 'llama3' },
21
- ])).toBe('Calling Gemini (gemma-31b), Groq (llama3)...')
22
- })
23
-
24
- it('subscribes and notifies when calls change', () => {
25
- const cb = vi.fn()
26
- const unsubscribe = subscribeToAICalls(cb)
27
- expect(cb).toHaveBeenCalledWith([])
28
-
29
- act(() => {
30
- mockActiveCallsList([{ providerName: 'Groq', model: 'llama3' }])
31
- })
32
- expect(cb).toHaveBeenLastCalledWith([{ providerName: 'Groq', model: 'llama3' }])
33
-
34
- act(() => {
35
- mockActiveCallsList([])
36
- })
37
- expect(cb).toHaveBeenLastCalledWith([])
38
-
39
- unsubscribe()
40
- })
41
-
42
- it('renders status indicator inside AppShell', () => {
43
- render(
44
- <TranslationProvider>
45
- <DocumentProvider>
46
- <LanguageProvider>
47
- <AppShell />
48
- </LanguageProvider>
49
- </DocumentProvider>
50
- </TranslationProvider>
51
- )
52
-
53
- // Initially, no indicator is rendered (since calls list is empty)
54
- expect(screen.queryByText(/Calling/)).toBeNull()
55
-
56
- // Mock active calls
57
- act(() => {
58
- mockActiveCallsList([{ providerName: 'Google AI Studio', model: 'gemma-31b' }])
59
- })
60
-
61
- // Now it should be displayed
62
- expect(screen.getByText('Calling Google AI Studio (gemma-31b)...')).toBeInTheDocument()
63
-
64
- // Clear calls
65
- act(() => {
66
- mockActiveCallsList([])
67
- })
68
-
69
- // Indicator should be gone
70
- expect(screen.queryByText(/Calling/)).toBeNull()
71
- })
72
- })
@@ -1,94 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { extractJson, listModels, hasConfiguredApiKey, callAI, setApiKey, deleteApiKey, ProviderConfig } from '../src/ai/ai-client';
3
-
4
- describe('extractJson', () => {
5
- it('parses a clean JSON object', () => {
6
- expect(extractJson<{ synopsis: string; duration: string }>('{"synopsis":"A meets B.","duration":"01:30"}'))
7
- .toEqual({ synopsis: 'A meets B.', duration: '01:30' });
8
- });
9
-
10
- it('parses JSON wrapped in a markdown fence', () => {
11
- const raw = '```json\n{"synopsis":"A meets B.","duration":"01:30"}\n```';
12
- expect(extractJson<{ synopsis: string; duration: string }>(raw))
13
- .toEqual({ synopsis: 'A meets B.', duration: '01:30' });
14
- });
15
-
16
- it('recovers a JSON object embedded in stray commentary', () => {
17
- const raw = 'Sure, here is the result:\n{"synopsis":"A meets B.","duration":"01:30"}\nHope that helps!';
18
- expect(extractJson<{ synopsis: string; duration: string }>(raw))
19
- .toEqual({ synopsis: 'A meets B.', duration: '01:30' });
20
- });
21
-
22
- it('throws with the raw text attached when nothing parses', () => {
23
- expect(() => extractJson('not json at all')).toThrow(/AI did not return valid JSON/);
24
- });
25
- });
26
-
27
- describe('listModels', () => {
28
- it('falls back to the static model list when the live endpoint is unreachable', async () => {
29
- const provider: ProviderConfig = {
30
- id: 'made-up-provider',
31
- name: 'Made Up Provider',
32
- baseUrl: 'not-a-real-url',
33
- models: [{ id: 'static-model', name: 'Static Model' }],
34
- };
35
- const models = await listModels(provider, 'fake-key');
36
- expect(models).toEqual(provider.models);
37
- });
38
- });
39
-
40
- describe('preferred-model resolution (regression: wrong provider/model used)', () => {
41
- beforeEach(() => {
42
- localStorage.clear();
43
- });
44
-
45
- it('sends the user-selected model for the preferred provider, even when that model is not in the static provider list', async () => {
46
- // Reproduces the reported bug: the user picks a model from a
47
- // provider's *live* /models listing (e.g. "qwen/qwen3.8-27b" for
48
- // Groq) via Settings, which is never a member of the narrow static
49
- // generated-providers.json list. That used to get silently discarded
50
- // and replaced with the static list's first (often stale/retired)
51
- // model, making the call fail — or, if another provider's key also
52
- // happened to be configured, silently fall through to a call against
53
- // a completely different provider.
54
- const originalFetch = global.fetch;
55
- const sentBodies: any[] = [];
56
- global.fetch = vi.fn(async (url: any, init: any) => {
57
- if (String(url).includes('api.groq.com')) {
58
- sentBodies.push(JSON.parse(init.body));
59
- return new Response(JSON.stringify({ choices: [{ message: { content: 'ok' } }] }), { status: 200 });
60
- }
61
- throw new Error('network unavailable in test');
62
- }) as any;
63
-
64
- try {
65
- await setApiKey('groq', 'test-groq-key');
66
- const config = { preferredProvider: 'groq', preferredModel: 'qwen/qwen3.8-27b', customProviders: [] };
67
-
68
- const result = await callAI('hello', 'system prompt', config);
69
-
70
- expect(result).toBe('ok');
71
- expect(sentBodies).toHaveLength(1);
72
- expect(sentBodies[0].model).toBe('qwen/qwen3.8-27b');
73
- } finally {
74
- global.fetch = originalFetch;
75
- await deleteApiKey('groq');
76
- }
77
- });
78
- });
79
-
80
- describe('hasConfiguredApiKey', () => {
81
- beforeEach(() => {
82
- localStorage.clear();
83
- });
84
-
85
- it('is false with no stored keys and true once one provider has a key', async () => {
86
- const config = { preferredProvider: 'anthropic', preferredModel: '', customProviders: [] };
87
- expect(await hasConfiguredApiKey(config)).toBe(false);
88
-
89
- await setApiKey('anthropic', 'test-key');
90
- expect(await hasConfiguredApiKey(config)).toBe(true);
91
-
92
- await deleteApiKey('anthropic');
93
- });
94
- });
@@ -1,20 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { createAutocompletePlugin } from '../../src/autocomplete/AutocompletePlugin'
3
-
4
- describe('AutocompletePlugin', () => {
5
- it('createAutocompletePlugin returns a ProseMirror Plugin', () => {
6
- const plugin = createAutocompletePlugin()
7
- expect(plugin).toBeDefined()
8
- expect(typeof plugin.spec).toBe('object')
9
- })
10
-
11
- it('plugin has handleKeyDown prop', () => {
12
- const plugin = createAutocompletePlugin()
13
- expect(typeof plugin.spec.props?.handleKeyDown).toBe('function')
14
- })
15
-
16
- it('plugin has view prop', () => {
17
- const plugin = createAutocompletePlugin()
18
- expect(typeof plugin.spec.view).toBe('function')
19
- })
20
- })
@@ -1,106 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import {
3
- buildCharacterCandidates,
4
- buildLocationCandidates,
5
- buildTransitionCandidates,
6
- buildMetadataKeyCandidates,
7
- filterCandidates,
8
- } from '../../src/autocomplete/suggestions'
9
-
10
- const SAMPLE = `## INT. LIVING ROOM - DAY
11
- & location: INT. LIVING ROOM
12
-
13
- @RIYA
14
- Hello.
15
-
16
- @ARJUN (V.O.)
17
- I remember that night.
18
-
19
- ## EXT. STREET - NIGHT
20
-
21
- >> FADE TO:
22
-
23
- & status: done
24
- & mood: tense
25
- `
26
-
27
- describe('suggestions', () => {
28
- describe('buildCharacterCandidates', () => {
29
- it('returns character names from @ cues', () => {
30
- const cands = buildCharacterCandidates(SAMPLE)
31
- const labels = cands.map(c => c.label)
32
- expect(labels).toContain('RIYA')
33
- expect(labels).toContain('ARJUN (V.O.)')
34
- })
35
-
36
- it('all have type "character"', () => {
37
- const cands = buildCharacterCandidates(SAMPLE)
38
- expect(cands.every(c => c.type === 'character')).toBe(true)
39
- })
40
- })
41
-
42
- describe('buildLocationCandidates', () => {
43
- it('extracts locations from scene headings', () => {
44
- const cands = buildLocationCandidates(SAMPLE)
45
- const labels = cands.map(c => c.label)
46
- expect(labels).toContain('INT. LIVING ROOM')
47
- expect(labels).toContain('EXT. STREET')
48
- })
49
-
50
- it('all have type "location"', () => {
51
- const cands = buildLocationCandidates(SAMPLE)
52
- expect(cands.every(c => c.type === 'location')).toBe(true)
53
- })
54
- })
55
-
56
- describe('buildTransitionCandidates', () => {
57
- it('includes predefined transitions', () => {
58
- const cands = buildTransitionCandidates(SAMPLE)
59
- const labels = cands.map(c => c.label)
60
- expect(labels).toContain('CUT TO:')
61
- expect(labels).toContain('FADE IN:')
62
- })
63
-
64
- it('includes transitions used in text', () => {
65
- const cands = buildTransitionCandidates(SAMPLE)
66
- const labels = cands.map(c => c.label)
67
- expect(labels).toContain('FADE TO:')
68
- })
69
- })
70
-
71
- describe('buildMetadataKeyCandidates', () => {
72
- it('includes reserved keys', () => {
73
- const cands = buildMetadataKeyCandidates(SAMPLE)
74
- const labels = cands.map(c => c.label)
75
- expect(labels).toContain('status')
76
- expect(labels).toContain('location')
77
- })
78
-
79
- it('includes keys used in text', () => {
80
- const cands = buildMetadataKeyCandidates(SAMPLE)
81
- const labels = cands.map(c => c.label)
82
- expect(labels).toContain('mood')
83
- })
84
- })
85
-
86
- describe('filterCandidates', () => {
87
- it('filters by prefix case-insensitively', () => {
88
- const cands = [
89
- { label: 'RIYA', type: 'character' as const },
90
- { label: 'ARJUN', type: 'character' as const },
91
- { label: 'RAJ', type: 'character' as const },
92
- ]
93
- const result = filterCandidates(cands, 'ri')
94
- expect(result.length).toBe(1)
95
- expect(result[0]?.label).toBe('RIYA')
96
- })
97
-
98
- it('returns all candidates for empty prefix', () => {
99
- const cands = [
100
- { label: 'A', type: 'character' as const },
101
- { label: 'B', type: 'character' as const },
102
- ]
103
- expect(filterCandidates(cands, '').length).toBe(2)
104
- })
105
- })
106
- })