@bendyline/squisq-editor-react 1.6.2 → 2.0.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.
- package/README.md +12 -3
- package/dist/index.d.ts +1229 -179
- package/dist/index.js +18017 -11231
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +1471 -245
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +348 -260
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +107 -14
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +309 -69
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +12 -3
- package/src/TemplatePicker.tsx +75 -21
- package/src/ThemeCustomizerPanel.tsx +8 -0
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +202 -41
- package/src/TransitionPicker.tsx +36 -4
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/WysiwygEditor.tsx +124 -91
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +123 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +0 -12
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +15 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +218 -5
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +23 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/headingTransition.ts +0 -15
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +191 -19
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +48 -18
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +5 -1
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +21 -7
- package/src/scene/layers/edgeGeometry.ts +9 -1
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +5 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +13 -4
- package/src/scene/tools/SceneTool.ts +8 -0
- package/src/scene/tools/SelectTool.ts +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +706 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +12 -5
- package/src/timeline/TimelineEditorWidget.tsx +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +24 -16
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -272
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
6
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
7
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
8
|
+
import { buildNarrationScript } from '@bendyline/squisq/narration';
|
|
9
|
+
import { DEFAULT_THEME } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { TeleprompterView } from '../teleprompter/TeleprompterView';
|
|
11
|
+
import { stepScroll, targetOffsetFor, type TokenLineMap } from '../teleprompter/scrollModel';
|
|
12
|
+
import { detectFloatTiers } from '../teleprompter/floatingWindow';
|
|
13
|
+
import { PCM_WORKLET_SOURCE, PCM_WORKLET_NAME } from '../teleprompter/pcmWorklet';
|
|
14
|
+
import { vadConfigForSensitivity } from '../teleprompter/useTeleprompter';
|
|
15
|
+
|
|
16
|
+
const MD = `# First Section
|
|
17
|
+
|
|
18
|
+
One two three four five six seven eight.
|
|
19
|
+
|
|
20
|
+
## Second Section
|
|
21
|
+
|
|
22
|
+
Nine ten eleven twelve.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
// Prefs persist to localStorage; keep tests order-independent.
|
|
28
|
+
window.localStorage.removeItem('squisq:teleprompter-prefs');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('scrollModel', () => {
|
|
32
|
+
const lines: TokenLineMap = {
|
|
33
|
+
tokenTops: [0, 0, 0, 60, 60, 120, 120, 180],
|
|
34
|
+
tokenHeights: [60, 60, 60, 60, 60, 60, 60, 60],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it('places the active line at the eye-line', () => {
|
|
38
|
+
// Token 5 sits at top 120; eye-line = 35% of 600 = 210 → offset keeps it clamped ≥ 0.
|
|
39
|
+
expect(targetOffsetFor(5, lines, 600)).toBe(0);
|
|
40
|
+
// Smaller viewport pushes real scrolling: 120 + 30 − 0.35×200 = 80.
|
|
41
|
+
expect(targetOffsetFor(5, lines, 200)).toBe(80);
|
|
42
|
+
// Fractional positions interpolate between token tops.
|
|
43
|
+
expect(targetOffsetFor(4.5, lines, 200)).toBe(60 + 30 + 30 - 70);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('clamps to non-negative offsets and empty maps', () => {
|
|
47
|
+
expect(targetOffsetFor(0, lines, 600)).toBe(0);
|
|
48
|
+
expect(targetOffsetFor(3, { tokenTops: [], tokenHeights: [] }, 600)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('stepScroll approaches the target and respects the speed clamp', () => {
|
|
52
|
+
// A huge jump advances by at most maxPxPerSec × dt.
|
|
53
|
+
expect(stepScroll(0, 100000, 16, 2600)).toBeCloseTo(2600 * 0.016, 5);
|
|
54
|
+
// Small distances converge and snap exactly.
|
|
55
|
+
let offset = 0;
|
|
56
|
+
for (let i = 0; i < 120; i++) offset = stepScroll(offset, 300, 16);
|
|
57
|
+
expect(offset).toBe(300);
|
|
58
|
+
// No time → no movement.
|
|
59
|
+
expect(stepScroll(50, 300, 0)).toBe(50);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('detectFloatTiers', () => {
|
|
64
|
+
it('reports popup + docked under jsdom (no PiP APIs)', () => {
|
|
65
|
+
const tiers = detectFloatTiers();
|
|
66
|
+
expect(tiers).toContain('popup');
|
|
67
|
+
expect(tiers[tiers.length - 1]).toBe('docked');
|
|
68
|
+
expect(tiers).not.toContain('document-pip');
|
|
69
|
+
expect(tiers).not.toContain('video-pip');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reports document-pip first when the API exists', () => {
|
|
73
|
+
const win = window as Window & { documentPictureInPicture?: unknown };
|
|
74
|
+
win.documentPictureInPicture = { requestWindow: () => Promise.reject(new Error('stub')) };
|
|
75
|
+
try {
|
|
76
|
+
expect(detectFloatTiers()[0]).toBe('document-pip');
|
|
77
|
+
} finally {
|
|
78
|
+
delete win.documentPictureInPicture;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('pcmWorklet source', () => {
|
|
84
|
+
it('compiles under stubbed worklet globals and registers the tap', () => {
|
|
85
|
+
const registered: string[] = [];
|
|
86
|
+
const factory = new Function(
|
|
87
|
+
'registerProcessor',
|
|
88
|
+
'AudioWorkletProcessor',
|
|
89
|
+
'currentTime',
|
|
90
|
+
PCM_WORKLET_SOURCE,
|
|
91
|
+
);
|
|
92
|
+
class ProcessorStub {
|
|
93
|
+
port = { postMessage: () => undefined };
|
|
94
|
+
}
|
|
95
|
+
factory((name: string) => registered.push(name), ProcessorStub, 0);
|
|
96
|
+
expect(registered).toEqual([PCM_WORKLET_NAME]);
|
|
97
|
+
expect(PCM_WORKLET_SOURCE).toContain('process(inputs)');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('vadConfigForSensitivity', () => {
|
|
102
|
+
it('maps 0.5 to the engine defaults and keeps hysteresis ordering', () => {
|
|
103
|
+
const mid = vadConfigForSensitivity(0.5);
|
|
104
|
+
expect(mid.enterRatio).toBeCloseTo(3.0, 5);
|
|
105
|
+
for (const s of [0, 0.25, 0.5, 0.75, 1]) {
|
|
106
|
+
const cfg = vadConfigForSensitivity(s);
|
|
107
|
+
expect(cfg.enterRatio!).toBeGreaterThan(cfg.exitRatio! - 1e-9);
|
|
108
|
+
}
|
|
109
|
+
// Higher sensitivity → lower (easier) enter threshold.
|
|
110
|
+
expect(vadConfigForSensitivity(0.9).enterRatio!).toBeLessThan(
|
|
111
|
+
vadConfigForSensitivity(0.1).enterRatio!,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('TeleprompterView', () => {
|
|
117
|
+
function renderView(markdown: string | null) {
|
|
118
|
+
const doc = markdown ? markdownToDoc(parseMarkdown(markdown)) : null;
|
|
119
|
+
render(<TeleprompterView doc={doc} theme={DEFAULT_THEME} />);
|
|
120
|
+
return doc;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('renders one span per script token plus block markers and controls', () => {
|
|
124
|
+
const doc = renderView(MD);
|
|
125
|
+
const script = buildNarrationScript(doc!);
|
|
126
|
+
const spans = document.querySelectorAll('[data-token-idx]');
|
|
127
|
+
expect(spans.length).toBe(script.tokens.length);
|
|
128
|
+
expect(
|
|
129
|
+
screen.getByText('First Section', { selector: '.squisq-teleprompter-block-marker' }),
|
|
130
|
+
).toBeTruthy();
|
|
131
|
+
expect(screen.getByTestId('teleprompter-controls')).toBeTruthy();
|
|
132
|
+
expect(screen.getByRole('button', { name: 'Start prompter' })).toBeTruthy();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('mirrors the live surface into a presentation target without duplicating controls', () => {
|
|
136
|
+
const doc = markdownToDoc(parseMarkdown(MD));
|
|
137
|
+
const audienceDocument = document.implementation.createHTMLDocument('Audience');
|
|
138
|
+
const audienceTarget = audienceDocument.createElement('div');
|
|
139
|
+
audienceDocument.body.appendChild(audienceTarget);
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<TeleprompterView doc={doc} theme={DEFAULT_THEME} presentationTarget={audienceTarget} />,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(audienceTarget.querySelector('[aria-label="Audience presentation"]')).toBeTruthy();
|
|
146
|
+
expect(audienceTarget.querySelectorAll('[data-token-idx]').length).toBe(
|
|
147
|
+
buildNarrationScript(doc).tokens.length,
|
|
148
|
+
);
|
|
149
|
+
expect(audienceTarget.querySelector('[data-testid="teleprompter-controls"]')).toBeNull();
|
|
150
|
+
expect(screen.getAllByTestId('teleprompter-controls')).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('shows an empty state for an empty doc', () => {
|
|
154
|
+
renderView(null);
|
|
155
|
+
expect(screen.getByText(/Nothing to narrate yet/)).toBeTruthy();
|
|
156
|
+
expect(screen.queryByTestId('teleprompter-controls')).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('mirrors the empty state into a presentation target', () => {
|
|
160
|
+
const audienceDocument = document.implementation.createHTMLDocument('Audience');
|
|
161
|
+
const audienceTarget = audienceDocument.createElement('div');
|
|
162
|
+
audienceDocument.body.appendChild(audienceTarget);
|
|
163
|
+
|
|
164
|
+
render(
|
|
165
|
+
<TeleprompterView doc={null} theme={DEFAULT_THEME} presentationTarget={audienceTarget} />,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(audienceTarget.querySelector('[aria-label="Audience presentation"]')).toBeTruthy();
|
|
169
|
+
expect(audienceTarget.textContent).toContain('Nothing to narrate yet');
|
|
170
|
+
expect(audienceTarget.querySelector('[data-testid="teleprompter-controls"]')).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('start button enters countdown and Escape cancels back to stopped', () => {
|
|
174
|
+
renderView(MD);
|
|
175
|
+
fireEvent.click(screen.getByRole('button', { name: 'Start prompter' }));
|
|
176
|
+
// Default prefs use a 3 s countdown → the overlay digit appears.
|
|
177
|
+
expect(document.querySelector('.squisq-teleprompter-countdown-digit')?.textContent).toBe('3');
|
|
178
|
+
fireEvent.keyDown(screen.getByTestId('teleprompter-view'), { key: 'Escape' });
|
|
179
|
+
expect(document.querySelector('.squisq-teleprompter-countdown-digit')).toBeNull();
|
|
180
|
+
expect(screen.getByRole('button', { name: 'Start prompter' })).toBeTruthy();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('mirror toggle flips the surface class', () => {
|
|
184
|
+
renderView(MD);
|
|
185
|
+
const surface = screen.getByTestId('teleprompter-surface');
|
|
186
|
+
expect(surface.className).not.toContain('--mirrored');
|
|
187
|
+
fireEvent.click(screen.getByRole('button', { name: '⇋ Mirror' }));
|
|
188
|
+
expect(screen.getByTestId('teleprompter-surface').className).toContain('--mirrored');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -2,6 +2,15 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
3
3
|
|
|
4
4
|
describe('Template annotation round-trip', () => {
|
|
5
|
+
it('hides an empty annotation in Write view while preserving it on round-trip', () => {
|
|
6
|
+
const original = '## Getting Started {[]}';
|
|
7
|
+
const html = markdownToTiptap(original);
|
|
8
|
+
|
|
9
|
+
expect(html).toContain('data-template-empty="true"');
|
|
10
|
+
expect(html).not.toContain('{[]}');
|
|
11
|
+
expect(tiptapToMarkdown(html).trim()).toBe(original);
|
|
12
|
+
});
|
|
13
|
+
|
|
5
14
|
it('extracts {[template]} into data-template on markdownToTiptap', () => {
|
|
6
15
|
const html = markdownToTiptap('## Getting Started {[comparisonBar]}');
|
|
7
16
|
expect(html).toContain('data-template="comparisonBar"');
|
|
@@ -16,6 +25,23 @@ describe('Template annotation round-trip', () => {
|
|
|
16
25
|
expect(back.trim()).toBe(original);
|
|
17
26
|
});
|
|
18
27
|
|
|
28
|
+
it('preserves a heading whose last word is also the template label', () => {
|
|
29
|
+
const original = '### A Famous Quote {[quote]}';
|
|
30
|
+
const html = markdownToTiptap(original);
|
|
31
|
+
|
|
32
|
+
expect(tiptapToMarkdown(html).trim()).toBe(original);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('ignores legacy text rendered inside a template badge', () => {
|
|
36
|
+
const tiptapRendered =
|
|
37
|
+
'<h3 data-template="quote">' +
|
|
38
|
+
'<span class="squisq-heading-content">A Famous Quote</span>' +
|
|
39
|
+
'<span class="squisq-template-badge" contenteditable="false">Quote</span>' +
|
|
40
|
+
'</h3>';
|
|
41
|
+
|
|
42
|
+
expect(tiptapToMarkdown(tiptapRendered).trim()).toBe('### A Famous Quote {[quote]}');
|
|
43
|
+
});
|
|
44
|
+
|
|
19
45
|
it('round-trips param-only squiggly annotations', () => {
|
|
20
46
|
const original = '## Intro {[transition=fade]}';
|
|
21
47
|
const html = markdownToTiptap(original);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cleanup, render } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { TEMPLATE_GALLERY_PORTAL_SELECTOR, TemplateBadgePopover } from '../TemplatePicker';
|
|
4
|
+
|
|
5
|
+
const anchorRect = new DOMRect(20, 20, 80, 24);
|
|
6
|
+
const originalElementsFromPoint = document.elementsFromPoint;
|
|
7
|
+
|
|
8
|
+
function badge(key: string) {
|
|
9
|
+
return (
|
|
10
|
+
<TemplateBadgePopover
|
|
11
|
+
key={key}
|
|
12
|
+
anchorRect={anchorRect}
|
|
13
|
+
value=""
|
|
14
|
+
onChange={vi.fn()}
|
|
15
|
+
onClose={vi.fn()}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
Object.defineProperty(document, 'elementsFromPoint', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: () => [],
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanup();
|
|
29
|
+
Object.defineProperty(document, 'elementsFromPoint', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
value: originalElementsFromPoint,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('template gallery portal ids', () => {
|
|
36
|
+
it('uses an instance-scoped id for a single open heading-badge gallery', () => {
|
|
37
|
+
render(badge('only'));
|
|
38
|
+
|
|
39
|
+
const gallery = document.querySelector(TEMPLATE_GALLERY_PORTAL_SELECTOR);
|
|
40
|
+
expect(gallery).not.toBeNull();
|
|
41
|
+
expect(gallery?.id).toMatch(/^squisq-template-gallery-portal-\S+$/);
|
|
42
|
+
expect(gallery?.id).not.toBe('squisq-template-gallery-portal');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps concurrent editor galleries unique and preserves the survivor id', () => {
|
|
46
|
+
const view = render(
|
|
47
|
+
<>
|
|
48
|
+
{badge('first')}
|
|
49
|
+
{badge('second')}
|
|
50
|
+
</>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const galleries = [...document.querySelectorAll(TEMPLATE_GALLERY_PORTAL_SELECTOR)];
|
|
54
|
+
expect(galleries).toHaveLength(2);
|
|
55
|
+
expect(new Set(galleries.map((gallery) => gallery.id)).size).toBe(2);
|
|
56
|
+
const survivorId = galleries[1].id;
|
|
57
|
+
|
|
58
|
+
view.rerender(<>{badge('second')}</>);
|
|
59
|
+
const survivor = document.querySelector(TEMPLATE_GALLERY_PORTAL_SELECTOR);
|
|
60
|
+
expect(survivor?.id).toBe(survivorId);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -283,6 +283,29 @@ describe('tiptapToMarkdown', () => {
|
|
|
283
283
|
expect(md).toContain('plain code');
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
it('converts code blocks when <pre> carries attributes (real getHTML shape)', () => {
|
|
287
|
+
// StarterKit is configured with codeBlock HTMLAttributes, so real
|
|
288
|
+
// editor.getHTML() output puts class="squisq-code-block" on <pre>.
|
|
289
|
+
const md = tiptapToMarkdown(
|
|
290
|
+
'<pre class="squisq-code-block"><code class="language-js">const x = 1;</code></pre>',
|
|
291
|
+
);
|
|
292
|
+
expect(md).toContain('```js');
|
|
293
|
+
expect(md).toContain('const x = 1;');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('converts attribute-bearing code blocks without language', () => {
|
|
297
|
+
const md = tiptapToMarkdown('<pre class="squisq-code-block"><code>plain code</code></pre>');
|
|
298
|
+
expect(md).toContain('```\nplain code\n```');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('extracts language regardless of surrounding attributes on <code>', () => {
|
|
302
|
+
const md = tiptapToMarkdown(
|
|
303
|
+
'<pre class="squisq-code-block"><code spellcheck="false" class="language-python">x = 1</code></pre>',
|
|
304
|
+
);
|
|
305
|
+
expect(md).toContain('```python');
|
|
306
|
+
expect(md).toContain('x = 1');
|
|
307
|
+
});
|
|
308
|
+
|
|
286
309
|
it('converts blockquotes', () => {
|
|
287
310
|
const md = tiptapToMarkdown('<blockquote><p>A wise quote</p></blockquote>');
|
|
288
311
|
expect(md).toContain('> ');
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
5
|
+
import { HeadingWithTemplate } from '../TemplateAnnotation';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Code-fence round-trip through a REAL tiptap editor.
|
|
9
|
+
*
|
|
10
|
+
* The string-level bridge tests feed hand-written `<pre><code>` HTML, but
|
|
11
|
+
* the production editor configures StarterKit's codeBlock with
|
|
12
|
+
* `HTMLAttributes: { class: 'squisq-code-block' }`, which tiptap renders
|
|
13
|
+
* onto the <pre>. The serializer regex used to require a bare `<pre><code`
|
|
14
|
+
* and silently dropped every fence on save. These tests exercise the real
|
|
15
|
+
* `getHTML()` output so that shape can never regress unnoticed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function roundTripThroughEditor(md: string): string {
|
|
19
|
+
const editor = new Editor({
|
|
20
|
+
extensions: [
|
|
21
|
+
StarterKit.configure({
|
|
22
|
+
heading: false,
|
|
23
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
24
|
+
}),
|
|
25
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
26
|
+
],
|
|
27
|
+
content: markdownToTiptap(md),
|
|
28
|
+
});
|
|
29
|
+
const html = editor.getHTML();
|
|
30
|
+
editor.destroy();
|
|
31
|
+
return tiptapToMarkdown(html);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('code fences through a real editor (getHTML → markdown)', () => {
|
|
35
|
+
it('emits class="squisq-code-block" on <pre> (the shape the serializer must accept)', () => {
|
|
36
|
+
const editor = new Editor({
|
|
37
|
+
extensions: [
|
|
38
|
+
StarterKit.configure({
|
|
39
|
+
heading: false,
|
|
40
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
41
|
+
}),
|
|
42
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
43
|
+
],
|
|
44
|
+
content: markdownToTiptap('```js\nconst x = 1;\n```\n'),
|
|
45
|
+
});
|
|
46
|
+
const html = editor.getHTML();
|
|
47
|
+
editor.destroy();
|
|
48
|
+
expect(html).toContain('<pre class="squisq-code-block">');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('round-trips a fence with a language byte-for-byte', () => {
|
|
52
|
+
const md = '```js\nconst x = 1;\n```\n';
|
|
53
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('round-trips a fence without a language byte-for-byte', () => {
|
|
57
|
+
const md = '```\nplain text content\n```\n';
|
|
58
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('round-trips a fence surrounded by prose', () => {
|
|
62
|
+
const md = 'Before the code.\n\n```python\nprint("hi")\n```\n\nAfter the code.\n';
|
|
63
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('round-trips HTML-significant characters inside a fence', () => {
|
|
67
|
+
const md = '```\nif (a < b && c > d) { echo " "; }\n```\n';
|
|
68
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round-trips trailing spaces inside fence lines', () => {
|
|
72
|
+
const md = '```\nline with trailing \nspaces\n```\n';
|
|
73
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('round-trips a blank last line inside a fence', () => {
|
|
77
|
+
const md = '```\nfoo\n\n```\n';
|
|
78
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('round-trips Unicode box-drawing art byte-for-byte', () => {
|
|
82
|
+
const md = [
|
|
83
|
+
'```',
|
|
84
|
+
'┌────────┐ ┌────────┐',
|
|
85
|
+
'│ CLI │ │ MCP │',
|
|
86
|
+
'└───┬────┘ └───┬────┘',
|
|
87
|
+
' │ │',
|
|
88
|
+
' ▼ ▼',
|
|
89
|
+
'┌──────────────────────┐',
|
|
90
|
+
'│ kernel │',
|
|
91
|
+
'└──────────────────────┘',
|
|
92
|
+
'```',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('round-trips +--+ ASCII art byte-for-byte', () => {
|
|
99
|
+
const md = [
|
|
100
|
+
'```text',
|
|
101
|
+
'+--------+ +--------+',
|
|
102
|
+
'| Input | --> | Output |',
|
|
103
|
+
'+--------+ +--------+',
|
|
104
|
+
'```',
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolvePersistedTransformStyleId } from '../transformStyleId';
|
|
3
|
+
|
|
4
|
+
describe('resolvePersistedTransformStyleId', () => {
|
|
5
|
+
it('accepts canonical ids and canonicalizes the legacy wire value', () => {
|
|
6
|
+
expect(resolvePersistedTransformStyleId('data-driven')).toBe('data-driven');
|
|
7
|
+
expect(resolvePersistedTransformStyleId('dataDriven')).toBe('data-driven');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('does not turn an unknown id into the default transform', () => {
|
|
11
|
+
expect(resolvePersistedTransformStyleId('not-a-style')).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -12,6 +12,14 @@ import {
|
|
|
12
12
|
} from '@bendyline/squisq/imageEdit';
|
|
13
13
|
import { useImageEditor } from '../imageEditor/useImageEditor.js';
|
|
14
14
|
|
|
15
|
+
function deferred<T>() {
|
|
16
|
+
let resolve!: (value: T) => void;
|
|
17
|
+
const promise = new Promise<T>((res) => {
|
|
18
|
+
resolve = res;
|
|
19
|
+
});
|
|
20
|
+
return { promise, resolve };
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
beforeEach(() => {
|
|
16
24
|
// jsdom doesn't implement createObjectURL or HTMLImageElement loading.
|
|
17
25
|
// Stub both so the hook can complete its asset-resolution path.
|
|
@@ -131,6 +139,65 @@ describe('useImageEditor', () => {
|
|
|
131
139
|
expect(persisted?.canvas.width).toBe(25);
|
|
132
140
|
});
|
|
133
141
|
|
|
142
|
+
it('does not mark a newer edit clean when an older write resolves', async () => {
|
|
143
|
+
const container = new MemoryContentContainer();
|
|
144
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
145
|
+
const originalWrite = container.writeFile.bind(container);
|
|
146
|
+
const gate = deferred<void>();
|
|
147
|
+
const started = deferred<void>();
|
|
148
|
+
let writes = 0;
|
|
149
|
+
vi.spyOn(container, 'writeFile').mockImplementation(async (...args) => {
|
|
150
|
+
writes += 1;
|
|
151
|
+
if (writes === 1) {
|
|
152
|
+
started.resolve();
|
|
153
|
+
await gate.promise;
|
|
154
|
+
}
|
|
155
|
+
return originalWrite(...args);
|
|
156
|
+
});
|
|
157
|
+
const { result } = renderHook(() => useImageEditor({ container, persistDebounceMs: 5 }));
|
|
158
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
159
|
+
|
|
160
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 20, height: 20 } }));
|
|
161
|
+
await started.promise;
|
|
162
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 30, height: 30 } }));
|
|
163
|
+
gate.resolve();
|
|
164
|
+
|
|
165
|
+
await waitFor(() => expect(result.current.state?.dirty).toBe(false));
|
|
166
|
+
expect((await readImageEditDoc(container))?.canvas.width).toBe(30);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('flushes a dirty debounce when the editor unmounts', async () => {
|
|
170
|
+
const container = new MemoryContentContainer();
|
|
171
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
172
|
+
const { result, unmount } = renderHook(() =>
|
|
173
|
+
useImageEditor({ container, persistDebounceMs: 100000 }),
|
|
174
|
+
);
|
|
175
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
176
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 44, height: 44 } }));
|
|
177
|
+
unmount();
|
|
178
|
+
await waitFor(async () => expect((await readImageEditDoc(container))?.canvas.width).toBe(44));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not populate the URL cache after unmount', async () => {
|
|
182
|
+
const container = new MemoryContentContainer();
|
|
183
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
184
|
+
await container.writeFile('assets/late.png', new Uint8Array([1]).buffer, 'image/png');
|
|
185
|
+
const { result, unmount } = renderHook(() => useImageEditor({ container }));
|
|
186
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
187
|
+
const originalRead = container.readFile.bind(container);
|
|
188
|
+
const gate = deferred<ArrayBuffer | null>();
|
|
189
|
+
vi.spyOn(container, 'readFile').mockImplementation((path) =>
|
|
190
|
+
path === 'assets/late.png' ? gate.promise : originalRead(path),
|
|
191
|
+
);
|
|
192
|
+
const create = vi.mocked(URL.createObjectURL);
|
|
193
|
+
const before = create.mock.calls.length;
|
|
194
|
+
const pending = result.current.resolveAssetUrl('assets/late.png');
|
|
195
|
+
unmount();
|
|
196
|
+
gate.resolve(new Uint8Array([1]).buffer);
|
|
197
|
+
await expect(pending).rejects.toThrow(/cancelled/);
|
|
198
|
+
expect(create).toHaveBeenCalledTimes(before);
|
|
199
|
+
});
|
|
200
|
+
|
|
134
201
|
it('versioning is null when allowVersioning is false', async () => {
|
|
135
202
|
const container = new MemoryContentContainer();
|
|
136
203
|
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
@@ -50,6 +50,7 @@ interface FakeRecorderHandle {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
let lastRecorder: FakeRecorderHandle | null = null;
|
|
53
|
+
let lastStream: FakeMediaStream | null = null;
|
|
53
54
|
|
|
54
55
|
class FakeMediaRecorder implements FakeRecorderHandle {
|
|
55
56
|
state: 'recording' | 'inactive' = 'inactive';
|
|
@@ -90,6 +91,7 @@ beforeEach(() => {
|
|
|
90
91
|
lastRecorder = null;
|
|
91
92
|
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = FakeMediaRecorder;
|
|
92
93
|
const fakeStream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
|
|
94
|
+
lastStream = fakeStream;
|
|
93
95
|
Object.defineProperty(globalThis, 'navigator', {
|
|
94
96
|
value: {
|
|
95
97
|
mediaDevices: {
|
|
@@ -224,4 +226,77 @@ describe('useMediaRecorder lifecycle', () => {
|
|
|
224
226
|
expect(result.current.stream).toBeNull();
|
|
225
227
|
expect(tracks.every((t) => t.stop.mock.calls.length > 0)).toBe(true);
|
|
226
228
|
});
|
|
229
|
+
|
|
230
|
+
it('deduplicates concurrent permission requests', async () => {
|
|
231
|
+
let resolve!: (stream: FakeMediaStream) => void;
|
|
232
|
+
const pending = new Promise<FakeMediaStream>((res) => {
|
|
233
|
+
resolve = res;
|
|
234
|
+
});
|
|
235
|
+
const getUserMedia = vi.fn().mockReturnValue(pending);
|
|
236
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
237
|
+
configurable: true,
|
|
238
|
+
value: { mediaDevices: { getUserMedia } },
|
|
239
|
+
});
|
|
240
|
+
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
|
|
241
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
242
|
+
let first!: Promise<void>;
|
|
243
|
+
let second!: Promise<void>;
|
|
244
|
+
act(() => {
|
|
245
|
+
first = result.current.request();
|
|
246
|
+
second = result.current.request();
|
|
247
|
+
});
|
|
248
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
249
|
+
resolve(stream);
|
|
250
|
+
await act(async () => Promise.all([first, second]));
|
|
251
|
+
expect(result.current.state).toBe('ready');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('releases an acquired stream when recorder construction fails', async () => {
|
|
255
|
+
class ThrowingRecorder {
|
|
256
|
+
static isTypeSupported() {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
constructor() {
|
|
260
|
+
throw new Error('construction failed');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = ThrowingRecorder;
|
|
264
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
265
|
+
await act(async () => {
|
|
266
|
+
await expect(result.current.request()).rejects.toThrow('construction failed');
|
|
267
|
+
});
|
|
268
|
+
expect(lastStream?.getTracks().every((track) => track.stop.mock.calls.length > 0)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('ignores a late onstop event after cancel', async () => {
|
|
272
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
273
|
+
await act(async () => result.current.request());
|
|
274
|
+
act(() => result.current.start());
|
|
275
|
+
const lateOnStop = lastRecorder?.onstop;
|
|
276
|
+
act(() => result.current.cancel());
|
|
277
|
+
act(() => lateOnStop?.());
|
|
278
|
+
expect(result.current.state).toBe('idle');
|
|
279
|
+
expect(result.current.blob).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('returns the same in-flight stop promise to repeated callers', async () => {
|
|
283
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
284
|
+
await act(async () => result.current.request());
|
|
285
|
+
act(() => result.current.start());
|
|
286
|
+
const recorder = lastRecorder!;
|
|
287
|
+
const complete = recorder.onstop!;
|
|
288
|
+
recorder.stop = vi.fn(() => {
|
|
289
|
+
recorder.state = 'inactive';
|
|
290
|
+
});
|
|
291
|
+
let first!: Promise<Blob | null>;
|
|
292
|
+
let second!: Promise<Blob | null>;
|
|
293
|
+
act(() => {
|
|
294
|
+
first = result.current.stop();
|
|
295
|
+
second = result.current.stop();
|
|
296
|
+
});
|
|
297
|
+
expect(second).toBe(first);
|
|
298
|
+
act(() => complete());
|
|
299
|
+
await first;
|
|
300
|
+
expect(result.current.state).toBe('stopped');
|
|
301
|
+
});
|
|
227
302
|
});
|