@bendyline/squisq-editor-react 1.6.2 → 2.0.1
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 +1275 -183
- package/dist/index.js +20345 -12777
- 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 +1607 -241
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +39 -33
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +349 -261
- 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 +362 -89
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/RecorderEntry.tsx +2 -0
- 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 +357 -51
- 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__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +51 -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__/mediaAttachmentFlow.test.ts +2 -2
- 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 +382 -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__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/selectionConversions.test.ts +80 -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 +71 -7
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -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 +254 -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 +284 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
- package/src/asciiDiagram/asciiDiagramData.ts +149 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +238 -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/codeContext/types.ts +1 -1
- 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/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- 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 +25 -6
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/frontmatterSettings.ts +23 -0
- 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 +198 -20
- 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/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- 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 +119 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +17 -3
- 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 +39 -23
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +596 -0
- package/src/styles/editor.css +736 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +189 -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 +861 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
- package/src/timeline/__tests__/timelineOps.test.ts +277 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +421 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +381 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +47 -21
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +390 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +177 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
- package/src/treeview/treeOps.ts +231 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +99 -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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
2
4
|
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
3
5
|
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
@@ -66,25 +68,25 @@ describe('markdownToTiptap', () => {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
it('converts mentions to chip spans', () => {
|
|
69
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
71
|
+
const html = markdownToTiptap('Hey @[Leo](person:leo), take a look.');
|
|
70
72
|
expect(html).toContain('data-mention="true"');
|
|
71
|
-
expect(html).toContain('data-kind="
|
|
73
|
+
expect(html).toContain('data-kind="person"');
|
|
72
74
|
expect(html).toContain('data-id="leo"');
|
|
73
75
|
expect(html).toContain('data-label="Leo"');
|
|
74
76
|
// "@Leo" appears inside the chip — NOT as a broken link
|
|
75
|
-
expect(html).not.toContain('href="
|
|
77
|
+
expect(html).not.toContain('href="person:leo"');
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
it('tolerates the backslash-escaped colon remark emits', () => {
|
|
79
|
-
// remark-stringify sometimes emits `
|
|
81
|
+
// remark-stringify sometimes emits `person\:leo` to disambiguate
|
|
80
82
|
// from autolink syntax. The bridge should still recognize it.
|
|
81
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
82
|
-
expect(html).toContain('data-kind="
|
|
83
|
+
const html = markdownToTiptap('Hey @[Leo](person\\:leo).');
|
|
84
|
+
expect(html).toContain('data-kind="person"');
|
|
83
85
|
expect(html).toContain('data-id="leo"');
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
it('round-trips mentions back to markdown', () => {
|
|
87
|
-
const md = 'Hey @[Leo](
|
|
89
|
+
const md = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.';
|
|
88
90
|
const html = markdownToTiptap(md);
|
|
89
91
|
const back = tiptapToMarkdown(html);
|
|
90
92
|
expect(back.trim()).toBe(md);
|
|
@@ -158,6 +160,12 @@ describe('markdownToTiptap', () => {
|
|
|
158
160
|
expect(html).toContain('This is a quote');
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
it('groups consecutive quote lines into one visual blockquote', () => {
|
|
164
|
+
const html = markdownToTiptap('> A wise quote\n> -- Ada');
|
|
165
|
+
expect(html.match(/<blockquote>/g)).toHaveLength(1);
|
|
166
|
+
expect(html).toContain('<blockquote><p>A wise quote</p><p>-- Ada</p></blockquote>');
|
|
167
|
+
});
|
|
168
|
+
|
|
161
169
|
it('converts horizontal rules', () => {
|
|
162
170
|
const md = 'Before\n\n---\n\nAfter';
|
|
163
171
|
const html = markdownToTiptap(md);
|
|
@@ -283,12 +291,42 @@ describe('tiptapToMarkdown', () => {
|
|
|
283
291
|
expect(md).toContain('plain code');
|
|
284
292
|
});
|
|
285
293
|
|
|
294
|
+
it('converts code blocks when <pre> carries attributes (real getHTML shape)', () => {
|
|
295
|
+
// StarterKit is configured with codeBlock HTMLAttributes, so real
|
|
296
|
+
// editor.getHTML() output puts class="squisq-code-block" on <pre>.
|
|
297
|
+
const md = tiptapToMarkdown(
|
|
298
|
+
'<pre class="squisq-code-block"><code class="language-js">const x = 1;</code></pre>',
|
|
299
|
+
);
|
|
300
|
+
expect(md).toContain('```js');
|
|
301
|
+
expect(md).toContain('const x = 1;');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('converts attribute-bearing code blocks without language', () => {
|
|
305
|
+
const md = tiptapToMarkdown('<pre class="squisq-code-block"><code>plain code</code></pre>');
|
|
306
|
+
expect(md).toContain('```\nplain code\n```');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('extracts language regardless of surrounding attributes on <code>', () => {
|
|
310
|
+
const md = tiptapToMarkdown(
|
|
311
|
+
'<pre class="squisq-code-block"><code spellcheck="false" class="language-python">x = 1</code></pre>',
|
|
312
|
+
);
|
|
313
|
+
expect(md).toContain('```python');
|
|
314
|
+
expect(md).toContain('x = 1');
|
|
315
|
+
});
|
|
316
|
+
|
|
286
317
|
it('converts blockquotes', () => {
|
|
287
318
|
const md = tiptapToMarkdown('<blockquote><p>A wise quote</p></blockquote>');
|
|
288
319
|
expect(md).toContain('> ');
|
|
289
320
|
expect(md).toContain('A wise quote');
|
|
290
321
|
});
|
|
291
322
|
|
|
323
|
+
it('keeps adjacent blockquote nodes on adjacent markdown lines', () => {
|
|
324
|
+
const md = tiptapToMarkdown(
|
|
325
|
+
'<blockquote><p>A wise quote</p></blockquote><blockquote><p>-- Ada</p></blockquote>',
|
|
326
|
+
);
|
|
327
|
+
expect(md).toBe('> A wise quote\n> -- Ada\n');
|
|
328
|
+
});
|
|
329
|
+
|
|
292
330
|
it('converts horizontal rules', () => {
|
|
293
331
|
const md = tiptapToMarkdown('<p>Before</p><hr><p>After</p>');
|
|
294
332
|
expect(md).toContain('---');
|
|
@@ -443,6 +481,32 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
|
|
|
443
481
|
expect(roundTrip('> Important note')).toContain('> Important note');
|
|
444
482
|
});
|
|
445
483
|
|
|
484
|
+
it('does not add a blank line before a quote attribution', () => {
|
|
485
|
+
const md = [
|
|
486
|
+
'### A Famous Quote {[quote]}',
|
|
487
|
+
'',
|
|
488
|
+
'> "The best way to predict the future is to invent it."',
|
|
489
|
+
'> -- Alan Kay',
|
|
490
|
+
].join('\n');
|
|
491
|
+
|
|
492
|
+
expect(roundTrip(md)).toBe(md + '\n');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('does not add a blank line after Tiptap normalizes a quote attribution', () => {
|
|
496
|
+
const md = ['> "The best way to predict the future is to invent it."', '> -- Alan Kay'].join(
|
|
497
|
+
'\n',
|
|
498
|
+
);
|
|
499
|
+
const editor = new Editor({
|
|
500
|
+
extensions: [StarterKit],
|
|
501
|
+
content: markdownToTiptap(md),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const normalizedHtml = editor.getHTML();
|
|
505
|
+
expect(normalizedHtml.match(/<blockquote>/g)).toHaveLength(1);
|
|
506
|
+
expect(tiptapToMarkdown(normalizedHtml)).toBe(md + '\n');
|
|
507
|
+
editor.destroy();
|
|
508
|
+
});
|
|
509
|
+
|
|
446
510
|
it('preserves unordered lists', () => {
|
|
447
511
|
const result = roundTrip('- Alpha\n- Beta');
|
|
448
512
|
expect(result).toContain('- Alpha');
|
|
@@ -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
|
+
});
|
|
@@ -12,7 +12,7 @@ import { tiptapToMarkdown } from '../tiptapBridge';
|
|
|
12
12
|
* when a pasted / uploaded image is inserted into the tiptap
|
|
13
13
|
* editor via `setImage({src, alt})`, does the markdown we serialize
|
|
14
14
|
* out (via `getHTML()` + `tiptapToMarkdown`) contain
|
|
15
|
-
* `` — the shape the
|
|
15
|
+
* `` — the shape the downstream service's image-attachment
|
|
16
16
|
* extractor expects?
|
|
17
17
|
*
|
|
18
18
|
* Unit tests on the regex alone have been green the whole time, and
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { Editor } from '@tiptap/core';
|
|
7
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
8
|
+
import TaskList from '@tiptap/extension-task-list';
|
|
9
|
+
import TaskItem from '@tiptap/extension-task-item';
|
|
10
|
+
import { EditorProvider, useEditorContext, type EditorContextValue } from '../EditorContext';
|
|
11
|
+
import { Toolbar } from '../Toolbar';
|
|
12
|
+
import { tiptapToMarkdown } from '../tiptapBridge';
|
|
13
|
+
|
|
14
|
+
let currentContext: EditorContextValue | null = null;
|
|
15
|
+
|
|
16
|
+
function ContextProbe() {
|
|
17
|
+
currentContext = useEditorContext();
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function context(): EditorContextValue {
|
|
22
|
+
if (!currentContext) throw new Error('EditorContext has not mounted');
|
|
23
|
+
return currentContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function monacoWithSelection(selectedText: string) {
|
|
27
|
+
const selection = {
|
|
28
|
+
startLineNumber: 1,
|
|
29
|
+
startColumn: 1,
|
|
30
|
+
endLineNumber: selectedText.split('\n').length,
|
|
31
|
+
endColumn: 1,
|
|
32
|
+
};
|
|
33
|
+
const model = {
|
|
34
|
+
getValueInRange: () => selectedText,
|
|
35
|
+
getLineContent: () => selectedText.split('\n')[0] ?? '',
|
|
36
|
+
};
|
|
37
|
+
const disposable = { dispose: vi.fn() };
|
|
38
|
+
const executeEdits = vi.fn();
|
|
39
|
+
const editor = {
|
|
40
|
+
getSelection: () => selection,
|
|
41
|
+
getModel: () => model,
|
|
42
|
+
getPosition: () => ({ lineNumber: 1, column: 1 }),
|
|
43
|
+
onDidChangeCursorPosition: () => disposable,
|
|
44
|
+
onDidChangeModelContent: () => disposable,
|
|
45
|
+
executeEdits,
|
|
46
|
+
focus: vi.fn(),
|
|
47
|
+
};
|
|
48
|
+
return { editor, executeEdits, selection };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
currentContext = null;
|
|
53
|
+
if (typeof window.matchMedia !== 'function') {
|
|
54
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
55
|
+
configurable: true,
|
|
56
|
+
value: () => ({
|
|
57
|
+
matches: false,
|
|
58
|
+
addEventListener: vi.fn(),
|
|
59
|
+
removeEventListener: vi.fn(),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
64
|
+
class ResizeObserverStub {
|
|
65
|
+
observe(): void {}
|
|
66
|
+
unobserve(): void {}
|
|
67
|
+
disconnect(): void {}
|
|
68
|
+
}
|
|
69
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('<Toolbar> selection conversion menu', () => {
|
|
74
|
+
it('shows Convert above Insert and replaces a delimited Monaco selection', async () => {
|
|
75
|
+
const source = 'Name,Role\nAda,Engineer\n';
|
|
76
|
+
const { editor, executeEdits, selection } = monacoWithSelection(source);
|
|
77
|
+
render(
|
|
78
|
+
<EditorProvider initialMarkdown={source} initialView="raw" allowRecording={false}>
|
|
79
|
+
<Toolbar />
|
|
80
|
+
<ContextProbe />
|
|
81
|
+
</EditorProvider>,
|
|
82
|
+
);
|
|
83
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
84
|
+
|
|
85
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
86
|
+
const menu = await screen.findByRole('menu');
|
|
87
|
+
expect(
|
|
88
|
+
within(menu)
|
|
89
|
+
.getAllByText(/^(Convert|Insert)$/)
|
|
90
|
+
.map((node) => node.textContent),
|
|
91
|
+
).toEqual(['Convert', 'Insert']);
|
|
92
|
+
|
|
93
|
+
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Convert selection to Table' }));
|
|
94
|
+
|
|
95
|
+
expect(executeEdits).toHaveBeenCalledWith('toolbar-convert-selection', [
|
|
96
|
+
{
|
|
97
|
+
range: selection,
|
|
98
|
+
text: '| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n',
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not show Convert without selected text', async () => {
|
|
104
|
+
const { editor } = monacoWithSelection('');
|
|
105
|
+
render(
|
|
106
|
+
<EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
|
|
107
|
+
<Toolbar />
|
|
108
|
+
<ContextProbe />
|
|
109
|
+
</EditorProvider>,
|
|
110
|
+
);
|
|
111
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
114
|
+
const menu = await screen.findByRole('menu');
|
|
115
|
+
expect(within(menu).queryByText('Convert')).toBeNull();
|
|
116
|
+
expect(within(menu).queryByRole('menuitem', { name: 'Convert selection to Table' })).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('replaces fully selected Write paragraphs without leaving a blank paragraph', async () => {
|
|
120
|
+
const editor = new Editor({
|
|
121
|
+
extensions: [StarterKit, TaskList, TaskItem],
|
|
122
|
+
content: '<p>First</p><p>Second</p><p>Third</p><h2>After</h2>',
|
|
123
|
+
});
|
|
124
|
+
const paragraphs: Array<{ pos: number; contentSize: number }> = [];
|
|
125
|
+
editor.state.doc.descendants((node, pos) => {
|
|
126
|
+
if (node.type.name === 'paragraph') {
|
|
127
|
+
paragraphs.push({ pos, contentSize: node.content.size });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const first = paragraphs[0];
|
|
131
|
+
const third = paragraphs[2];
|
|
132
|
+
expect(first).toBeDefined();
|
|
133
|
+
expect(third).toBeDefined();
|
|
134
|
+
editor.commands.setTextSelection({
|
|
135
|
+
from: first.pos + 1,
|
|
136
|
+
to: third.pos + 1 + third.contentSize,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
render(
|
|
140
|
+
<EditorProvider
|
|
141
|
+
initialMarkdown="First\n\nSecond\n\nThird\n\n## After"
|
|
142
|
+
initialView="wysiwyg"
|
|
143
|
+
allowRecording={false}
|
|
144
|
+
>
|
|
145
|
+
<Toolbar />
|
|
146
|
+
<ContextProbe />
|
|
147
|
+
</EditorProvider>,
|
|
148
|
+
);
|
|
149
|
+
act(() => context().setTiptapEditor(editor));
|
|
150
|
+
|
|
151
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
152
|
+
fireEvent.click(
|
|
153
|
+
await screen.findByRole('menuitem', { name: 'Convert selection to Task List' }),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => {
|
|
157
|
+
expect(editor.getJSON().content?.map((node) => node.type)).toEqual(['taskList', 'heading']);
|
|
158
|
+
});
|
|
159
|
+
expect(tiptapToMarkdown(editor.getHTML())).toBe(
|
|
160
|
+
'- [ ] First\n- [ ] Second\n- [ ] Third\n\n## After\n',
|
|
161
|
+
);
|
|
162
|
+
editor.destroy();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -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
|
});
|