@ssml-builder-js/ssml-editor-react 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/index.d.mts +288 -0
  3. package/dist/index.d.ts +288 -0
  4. package/dist/index.js +5423 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +5385 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/e2e/monaco-editor.spec.ts +126 -0
  9. package/package.json +57 -0
  10. package/src/SsmlEditor.tsx +1302 -0
  11. package/src/buttonVisibility.ts +36 -0
  12. package/src/clearSsmlDocument.ts +57 -0
  13. package/src/components/popovers/InsertionPopover.tsx +136 -0
  14. package/src/components/popovers/InsertionPopovers.tsx +47 -0
  15. package/src/components/popovers/ProsodyPopovers.tsx +78 -0
  16. package/src/components/popovers/TextPopovers.tsx +8 -0
  17. package/src/components/popovers/TimingPopovers.tsx +8 -0
  18. package/src/constants/ssmlPresets.ts +660 -0
  19. package/src/constants/ui.ts +11 -0
  20. package/src/editableSsml.ts +251 -0
  21. package/src/formatXml.ts +600 -0
  22. package/src/hooks/useSsmlEditorState.ts +704 -0
  23. package/src/hooks/useSsmlMonaco.ts +393 -0
  24. package/src/index.tsx +50 -0
  25. package/src/locales.ts +435 -0
  26. package/src/ssmlCodeAction.ts +144 -0
  27. package/src/ssmlCodeLens.ts +226 -0
  28. package/src/ssmlCompletion.ts +129 -0
  29. package/src/ssmlContext.ts +196 -0
  30. package/src/ssmlDiagnostics.ts +191 -0
  31. package/src/ssmlHover.ts +703 -0
  32. package/src/ssmlInsertion.ts +75 -0
  33. package/src/ssmlInsertions.ts +471 -0
  34. package/src/styles/editorStyles.ts +282 -0
  35. package/test/format-xml-edge-cases.test.ts +107 -0
  36. package/test/index.test.ts +597 -0
  37. package/test/randomized-editor-invariants.test.ts +520 -0
  38. package/test/ui-components.test.tsx +854 -0
  39. package/tsconfig.json +10 -0
  40. package/tsup.config.ts +17 -0
  41. package/vitest.config.mjs +10 -0
@@ -0,0 +1,854 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { useEffect, useRef, type ComponentProps } from "react";
4
+ import { act, cleanup, render, screen, within } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { getEditableText, updateEditableText } from "../src/editableSsml";
8
+
9
+ const monacoState = vi.hoisted(() => {
10
+ let value = "Hello world";
11
+ let modelEol = "\n";
12
+ let changeHandler: ((value: string) => void) | null = null;
13
+ let selectionStartOffset = 0;
14
+ let selectionEndOffset = 0;
15
+ const contentChangeListeners = new Set<() => void>();
16
+ const cursorPositionListeners = new Set<(event: { position: { lineNumber: number; column: number } }) => void>();
17
+ let latestContentDispose: ReturnType<typeof vi.fn> | null = null;
18
+ let latestCursorPositionDispose: ReturnType<typeof vi.fn> | null = null;
19
+ const positionAt = (offset: number) => {
20
+ const lines = value.slice(0, offset).split(/\r\n|\r|\n/);
21
+ return {
22
+ lineNumber: lines.length,
23
+ column: (lines.at(-1)?.length ?? 0) + 1,
24
+ };
25
+ };
26
+ const selection = {
27
+ selectionStartLineNumber: 1,
28
+ selectionStartColumn: 1,
29
+ positionLineNumber: 1,
30
+ positionColumn: 1,
31
+ getStartPosition: () => positionAt(selectionStartOffset),
32
+ getEndPosition: () => positionAt(selectionEndOffset),
33
+ isEmpty: () => selectionStartOffset === selectionEndOffset,
34
+ };
35
+ const model = {
36
+ getValue: () => value,
37
+ getPositionAt: positionAt,
38
+ getOffsetAt: (position: { lineNumber: number; column: number }) => {
39
+ const lines = value.split(/\r\n|\r|\n/);
40
+ return (
41
+ lines.slice(0, position.lineNumber - 1).reduce((offset, line) => offset + line.length + modelEol.length, 0) +
42
+ position.column -
43
+ 1
44
+ );
45
+ },
46
+ getEOL: () => modelEol,
47
+ getValueInRange: (
48
+ range:
49
+ | typeof selection
50
+ | {
51
+ startLineNumber: number;
52
+ startColumn: number;
53
+ endLineNumber: number;
54
+ endColumn: number;
55
+ },
56
+ ) => {
57
+ const startOffset =
58
+ "getStartPosition" in range
59
+ ? model.getOffsetAt(range.getStartPosition())
60
+ : model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn });
61
+ const endOffset =
62
+ "getEndPosition" in range
63
+ ? model.getOffsetAt(range.getEndPosition())
64
+ : model.getOffsetAt({ lineNumber: range.endLineNumber, column: range.endColumn });
65
+ return value.slice(startOffset, endOffset);
66
+ },
67
+ getLineContent: (lineNumber: number) => value.split(/\r\n|\r|\n/)[lineNumber - 1] ?? "",
68
+ deltaDecorations: vi.fn(() => []),
69
+ uri: {},
70
+ getVersionId: () => 1,
71
+ };
72
+ const disposable = () => ({ dispose: vi.fn() });
73
+ const editor = {
74
+ getModel: () => model,
75
+ getValue: () => value,
76
+ getSelection: () => selection,
77
+ getPosition: () => selection.getEndPosition(),
78
+ getScrolledVisiblePosition: () => null,
79
+ getLayoutInfo: () => ({ height: 100 }),
80
+ onDidChangeCursorSelection: vi.fn(disposable),
81
+ onDidChangeCursorPosition: vi.fn(
82
+ (listener: (event: { position: { lineNumber: number; column: number } }) => void) => {
83
+ cursorPositionListeners.add(listener);
84
+ latestCursorPositionDispose = vi.fn(() => cursorPositionListeners.delete(listener));
85
+ return { dispose: latestCursorPositionDispose };
86
+ },
87
+ ),
88
+ onDidChangeModelContent: vi.fn((listener: () => void) => {
89
+ contentChangeListeners.add(listener);
90
+ latestContentDispose = vi.fn(() => contentChangeListeners.delete(listener));
91
+ return { dispose: latestContentDispose };
92
+ }),
93
+ onDidScrollChange: vi.fn(disposable),
94
+ onDidLayoutChange: vi.fn(disposable),
95
+ onDidContentSizeChange: vi.fn(disposable),
96
+ pushUndoStop: vi.fn(),
97
+ executeEdits: vi.fn(
98
+ (
99
+ _source: string,
100
+ edits: Array<{
101
+ range:
102
+ | typeof selection
103
+ | {
104
+ startLineNumber: number;
105
+ startColumn: number;
106
+ endLineNumber: number;
107
+ endColumn: number;
108
+ };
109
+ text: string;
110
+ }>,
111
+ ) => {
112
+ const edit = edits[0];
113
+ if (edit) {
114
+ const startOffset =
115
+ "getStartPosition" in edit.range
116
+ ? model.getOffsetAt(edit.range.getStartPosition())
117
+ : model.getOffsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn });
118
+ const endOffset =
119
+ "getEndPosition" in edit.range
120
+ ? model.getOffsetAt(edit.range.getEndPosition())
121
+ : model.getOffsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn });
122
+ value = `${value.slice(0, startOffset)}${edit.text}${value.slice(endOffset)}`;
123
+ }
124
+ return true;
125
+ },
126
+ ),
127
+ setSelection: vi.fn(),
128
+ focus: vi.fn(),
129
+ trigger: vi.fn(),
130
+ addAction: vi.fn(disposable),
131
+ };
132
+ const monaco = {
133
+ languages: {
134
+ registerHoverProvider: vi.fn(disposable),
135
+ registerCompletionItemProvider: vi.fn(disposable),
136
+ registerCodeActionProvider: vi.fn(disposable),
137
+ registerCodeLensProvider: vi.fn(disposable),
138
+ CompletionItemKind: {
139
+ Snippet: 27,
140
+ Value: 18,
141
+ },
142
+ CompletionItemInsertTextRule: {
143
+ InsertAsSnippet: 4,
144
+ },
145
+ },
146
+ editor: {
147
+ registerCommand: vi.fn(disposable),
148
+ setModelMarkers: vi.fn(),
149
+ },
150
+ MarkerSeverity: {
151
+ Error: 8,
152
+ },
153
+ };
154
+
155
+ return {
156
+ editor,
157
+ monaco,
158
+ reset: () => {
159
+ for (const mock of [
160
+ editor.onDidChangeCursorSelection,
161
+ editor.onDidChangeCursorPosition,
162
+ editor.onDidChangeModelContent,
163
+ editor.onDidScrollChange,
164
+ editor.onDidLayoutChange,
165
+ editor.onDidContentSizeChange,
166
+ editor.pushUndoStop,
167
+ editor.executeEdits,
168
+ editor.setSelection,
169
+ editor.focus,
170
+ editor.trigger,
171
+ editor.addAction,
172
+ model.deltaDecorations,
173
+ monaco.languages.registerHoverProvider,
174
+ monaco.languages.registerCompletionItemProvider,
175
+ monaco.languages.registerCodeActionProvider,
176
+ monaco.languages.registerCodeLensProvider,
177
+ monaco.editor.registerCommand,
178
+ monaco.editor.setModelMarkers,
179
+ ]) {
180
+ mock.mockClear();
181
+ }
182
+ value = "Hello world";
183
+ modelEol = "\n";
184
+ changeHandler = null;
185
+ selectionStartOffset = 0;
186
+ selectionEndOffset = 0;
187
+ contentChangeListeners.clear();
188
+ cursorPositionListeners.clear();
189
+ latestContentDispose = null;
190
+ latestCursorPositionDispose = null;
191
+ },
192
+ setValue: (nextValue: string) => {
193
+ value = nextValue;
194
+ },
195
+ setSelectionOffsets: (startOffset: number, endOffset: number) => {
196
+ selectionStartOffset = startOffset;
197
+ selectionEndOffset = endOffset;
198
+ },
199
+ getValue: () => value,
200
+ setEOL: (nextEol: string) => {
201
+ modelEol = nextEol;
202
+ },
203
+ setChangeHandler: (handler: ((value: string) => void) | undefined) => {
204
+ changeHandler = handler ?? null;
205
+ },
206
+ emitContentChange: (nextValue?: string) => {
207
+ if (nextValue !== undefined) {
208
+ value = nextValue;
209
+ }
210
+ changeHandler?.(value);
211
+ for (const listener of contentChangeListeners) {
212
+ listener();
213
+ }
214
+ },
215
+ emitCursorPositionChange: (offset: number) => {
216
+ selectionStartOffset = offset;
217
+ selectionEndOffset = offset;
218
+ const position = positionAt(offset);
219
+ for (const listener of cursorPositionListeners) {
220
+ listener({ position });
221
+ }
222
+ },
223
+ getLatestContentDispose: () => latestContentDispose,
224
+ getLatestCursorPositionDispose: () => latestCursorPositionDispose,
225
+ };
226
+ });
227
+
228
+ vi.mock("@ssml-builder-js/ssml-core", async () => import("../../ssml-core/src/index"));
229
+
230
+ vi.mock("@monaco-editor/react", () => ({
231
+ default: function MockEditor({
232
+ options,
233
+ onMount,
234
+ onChange,
235
+ }: {
236
+ options?: {
237
+ autoClosingBrackets?: string;
238
+ inlayHints?: { enabled?: string };
239
+ };
240
+ onMount?: (editor: typeof monacoState.editor, monaco: typeof monacoState.monaco) => void;
241
+ onChange?: (value: string) => void;
242
+ }) {
243
+ const mounted = useRef(false);
244
+ monacoState.setChangeHandler(onChange);
245
+ useEffect(() => {
246
+ if (!mounted.current) {
247
+ mounted.current = true;
248
+ onMount?.(monacoState.editor, monacoState.monaco);
249
+ }
250
+ }, [onMount]);
251
+
252
+ return (
253
+ <div
254
+ data-testid="monaco-editor"
255
+ data-auto-closing-brackets={options?.autoClosingBrackets}
256
+ data-inlay-hints={options?.inlayHints?.enabled}
257
+ />
258
+ );
259
+ },
260
+ }));
261
+
262
+ import { SsmlEditor } from "../src/SsmlEditor";
263
+
264
+ const editorDocument = {
265
+ type: "speak" as const,
266
+ version: "1.0" as const,
267
+ lang: "en-US",
268
+ children: ["Hello world"],
269
+ };
270
+
271
+ function createVoiceDocument(name: string) {
272
+ return {
273
+ ...editorDocument,
274
+ children: [
275
+ {
276
+ type: "voice" as const,
277
+ name,
278
+ children: ["Hello world"],
279
+ },
280
+ ],
281
+ };
282
+ }
283
+
284
+ function renderEditor(props: Partial<ComponentProps<typeof SsmlEditor>> = {}) {
285
+ return render(<SsmlEditor document={editorDocument} {...props} />);
286
+ }
287
+
288
+ beforeEach(() => {
289
+ monacoState.reset();
290
+ Object.defineProperty(window, "matchMedia", {
291
+ configurable: true,
292
+ value: () => ({
293
+ matches: false,
294
+ addEventListener: vi.fn(),
295
+ removeEventListener: vi.fn(),
296
+ }),
297
+ });
298
+ });
299
+
300
+ afterEach(() => {
301
+ cleanup();
302
+ });
303
+
304
+ describe("editable SSML utilities", () => {
305
+ it("keeps speak and voice wrappers outside the editable text", () => {
306
+ const document = {
307
+ type: "speak" as const,
308
+ version: "1.0",
309
+ lang: "en-US",
310
+ children: [
311
+ {
312
+ type: "voice" as const,
313
+ name: "en-US-JennyNeural",
314
+ attributes: { "data-source": "test" },
315
+ children: ["Hello"],
316
+ },
317
+ ],
318
+ };
319
+
320
+ expect(getEditableText(document)).toBe("Hello");
321
+ expect(updateEditableText(document, "Updated")).toEqual({
322
+ ...document,
323
+ children: [
324
+ {
325
+ type: "voice",
326
+ name: "en-US-JennyNeural",
327
+ attributes: { "data-source": "test" },
328
+ children: ["Updated"],
329
+ },
330
+ ],
331
+ });
332
+ });
333
+ });
334
+
335
+ describe("SsmlEditor toolbar menus", () => {
336
+ it("registers a Monaco command for CodeLens actions", () => {
337
+ renderEditor();
338
+
339
+ expect(monacoState.monaco.editor.registerCommand).toHaveBeenCalledWith(
340
+ "ssml-editor.codeLens",
341
+ expect.any(Function),
342
+ );
343
+ });
344
+
345
+ it("highlights prosody buttons while the cursor is inside a prosody element", () => {
346
+ const value = '<prosody rate="slow">Hello</prosody> outside';
347
+ monacoState.setValue(value);
348
+ monacoState.setSelectionOffsets(value.length, value.length);
349
+ renderEditor({ locale: "en" });
350
+
351
+ const rateButton = screen.getByRole("button", { name: "Rate" });
352
+ const pitchButton = screen.getByRole("button", { name: "Pitch" });
353
+ const volumeButton = screen.getByRole("button", { name: "Volume" });
354
+
355
+ expect(rateButton.style.backgroundColor).toBe("var(--ssml-editor-control-bg)");
356
+
357
+ act(() => monacoState.emitCursorPositionChange(value.indexOf("Hello") + 2));
358
+
359
+ expect(rateButton.style.backgroundColor).toBe("var(--ssml-editor-active-bg)");
360
+ expect(rateButton.style.border).toBe("1px solid var(--ssml-editor-active-border)");
361
+ expect(pitchButton.style.backgroundColor).toBe("var(--ssml-editor-active-bg)");
362
+ expect(volumeButton.style.backgroundColor).toBe("var(--ssml-editor-active-bg)");
363
+ expect(screen.getByRole("button", { name: "Emphasis" }).style.backgroundColor).toBe(
364
+ "var(--ssml-editor-control-bg)",
365
+ );
366
+
367
+ act(() => monacoState.emitCursorPositionChange(value.length));
368
+
369
+ expect(rateButton.style.backgroundColor).toBe("var(--ssml-editor-control-bg)");
370
+ expect(rateButton.style.border).toBe("1px solid var(--ssml-editor-control-border)");
371
+ });
372
+
373
+ it("opens the break, prosody, and express-as popovers", async () => {
374
+ const user = userEvent.setup();
375
+ renderEditor({ locale: "en" });
376
+
377
+ for (const label of ["Break", "Rate", "Emotion"]) {
378
+ const trigger = screen.getByRole("button", { name: label });
379
+ await user.click(trigger);
380
+
381
+ expect(screen.getByRole("menu", { name: label })).toBeTruthy();
382
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
383
+
384
+ await user.click(trigger);
385
+ expect(screen.queryByRole("menu", { name: label })).toBeNull();
386
+ }
387
+ });
388
+
389
+ it("filters emotion options using the document voice outside Monaco", async () => {
390
+ const user = userEvent.setup();
391
+ renderEditor({ document: createVoiceDocument("ja-JP-NanamiNeural"), locale: "en" });
392
+
393
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
394
+ const menu = screen.getByRole("menu", { name: "Emotion" });
395
+
396
+ expect(within(menu).getByRole("menuitem", { name: "cheerful" })).toBeTruthy();
397
+ expect(within(menu).getByRole("menuitem", { name: "chat" })).toBeTruthy();
398
+ expect(within(menu).queryByRole("menuitem", { name: "friendly" })).toBeNull();
399
+ });
400
+
401
+ it("filters Mayu emotion options to calm, cheerful, and sad", async () => {
402
+ const user = userEvent.setup();
403
+ renderEditor({ document: createVoiceDocument("ja-JP-MayuNeural"), locale: "en" });
404
+
405
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
406
+ const menu = screen.getByRole("menu", { name: "Emotion" });
407
+
408
+ expect(
409
+ within(menu)
410
+ .getAllByRole("menuitem")
411
+ .map((option) => option.textContent),
412
+ ).toEqual(["cheerful", "calm", "sad"]);
413
+ });
414
+
415
+ it("groups emotion styles into localized menu sections and keeps custom styles in Other", async () => {
416
+ const user = userEvent.setup();
417
+ renderEditor({
418
+ emotionStyles: ["cheerful", "chat", "newscast", "custom"],
419
+ locale: "en",
420
+ });
421
+
422
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
423
+ const menu = screen.getByRole("menu", { name: "Emotion" });
424
+ const groups = within(menu).getAllByRole("group");
425
+
426
+ expect(groups.map((group) => group.querySelector("legend")?.textContent)).toEqual([
427
+ "Emotions / Tone",
428
+ "Conversations / Scenarios",
429
+ "Media / Broadcast",
430
+ "Other",
431
+ ]);
432
+ expect(within(menu).getByRole("group", { name: "Emotions / Tone" })).toBe(groups[0]);
433
+ expect(within(groups[0] as HTMLElement).getByRole("menuitem", { name: "cheerful" })).toBeTruthy();
434
+ expect(within(groups[1] as HTMLElement).getByRole("menuitem", { name: "chat" })).toBeTruthy();
435
+ expect(within(groups[2] as HTMLElement).getByRole("menuitem", { name: "newscast" })).toBeTruthy();
436
+ expect(within(groups[3] as HTMLElement).getByRole("menuitem", { name: "custom" })).toBeTruthy();
437
+ expect(within(menu).queryByRole("combobox")).toBeNull();
438
+ });
439
+
440
+ it("uses an inner Monaco voice instead of the document voice", async () => {
441
+ const user = userEvent.setup();
442
+ const value = '<voice name="en-US-GuyNeural">Hello</voice>';
443
+ monacoState.setValue(value);
444
+ monacoState.setSelectionOffsets(value.indexOf("Hello") + 2, value.indexOf("Hello") + 2);
445
+ renderEditor({ document: createVoiceDocument("ja-JP-KeitaNeural"), locale: "en" });
446
+
447
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
448
+ const menu = screen.getByRole("menu", { name: "Emotion" });
449
+
450
+ expect(within(menu).getByRole("menuitem", { name: "friendly" })).toBeTruthy();
451
+ expect(within(menu).queryByRole("menuitem", { name: "assistant" })).toBeNull();
452
+ });
453
+
454
+ it("intersects custom emotion styles with the active voice styles", async () => {
455
+ const user = userEvent.setup();
456
+ renderEditor({
457
+ document: createVoiceDocument("ja-JP-NanamiNeural"),
458
+ emotionStyles: ["friendly", "cheerful", "custom"],
459
+ locale: "en",
460
+ });
461
+
462
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
463
+ const options = within(screen.getByRole("menu", { name: "Emotion" })).getAllByRole("menuitem");
464
+
465
+ expect(options.map((option) => option.textContent)).toEqual(["cheerful"]);
466
+ });
467
+
468
+ it("shows a disabled localized option when the active voice supports no styles", async () => {
469
+ const user = userEvent.setup();
470
+ renderEditor({ document: createVoiceDocument("th-TH-PremwadeeNeural") });
471
+
472
+ await user.click(screen.getByRole("button", { name: "感情" }));
473
+
474
+ const menu = screen.getByRole("menu", { name: "感情" });
475
+ const option = within(menu).getByRole("option", { name: "この音声はスタイル指定に対応していません" });
476
+ expect((option as HTMLOptionElement).disabled).toBe(true);
477
+ });
478
+
479
+ it("refreshes emotion options after the document voice changes", async () => {
480
+ const user = userEvent.setup();
481
+ const { rerender } = renderEditor({ document: createVoiceDocument("ja-JP-NanamiNeural"), locale: "en" });
482
+
483
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
484
+ expect(within(screen.getByRole("menu", { name: "Emotion" })).getByRole("menuitem", { name: "chat" })).toBeTruthy();
485
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
486
+
487
+ rerender(<SsmlEditor document={createVoiceDocument("th-TH-PremwadeeNeural")} locale="en" />);
488
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
489
+
490
+ const option = within(screen.getByRole("menu", { name: "Emotion" })).getByRole("option", {
491
+ name: "This voice does not support style selection.",
492
+ });
493
+ expect((option as HTMLOptionElement).disabled).toBe(true);
494
+ });
495
+
496
+ it("inserts an emotion style allowed by the active voice", async () => {
497
+ const user = userEvent.setup();
498
+ monacoState.setSelectionOffsets(0, 5);
499
+ renderEditor({ document: createVoiceDocument("ja-JP-NanamiNeural"), locale: "en" });
500
+
501
+ await user.click(screen.getByRole("button", { name: "Emotion" }));
502
+ await user.click(within(screen.getByRole("menu", { name: "Emotion" })).getByRole("menuitem", { name: "chat" }));
503
+
504
+ expect(monacoState.editor.executeEdits.mock.calls[0]?.[1][0].text).toBe(
505
+ '<mstts:express-as style="chat">Hello</mstts:express-as>\n',
506
+ );
507
+ });
508
+
509
+ it("inserts a tag and closes the popover when an option is clicked", async () => {
510
+ const user = userEvent.setup();
511
+ renderEditor({ locale: "en" });
512
+
513
+ await user.click(screen.getByRole("button", { name: "Break" }));
514
+ const menu = screen.getByRole("menu", { name: "Break" });
515
+ await user.click(within(menu).getByRole("menuitem", { name: "500ms" }));
516
+
517
+ expect(monacoState.editor.executeEdits).toHaveBeenCalledTimes(1);
518
+ expect(monacoState.editor.executeEdits.mock.calls[0]?.[1][0].text).toContain('<break time="500ms"/>');
519
+ expect(monacoState.editor.setSelection).toHaveBeenCalledWith(
520
+ expect.objectContaining({
521
+ selectionStartLineNumber: 2,
522
+ positionLineNumber: 2,
523
+ selectionStartColumn: 1,
524
+ positionColumn: 1,
525
+ }),
526
+ );
527
+ expect(screen.queryByRole("menu", { name: "Break" })).toBeNull();
528
+ });
529
+
530
+ it("wraps selected text when a wrapping tag option is clicked", async () => {
531
+ const user = userEvent.setup();
532
+ monacoState.setSelectionOffsets(6, 11);
533
+ renderEditor({ locale: "en" });
534
+
535
+ await user.click(screen.getByRole("button", { name: "Rate" }));
536
+ const menu = screen.getByRole("menu", { name: "Rate" });
537
+ await user.click(within(menu).getByRole("menuitem", { name: "slow" }));
538
+
539
+ expect(monacoState.editor.executeEdits.mock.calls[0]?.[1][0].text).toBe('<prosody rate="slow">world</prosody>\n');
540
+ expect(monacoState.getValue()).toBe('Hello <prosody rate="slow">world</prosody>\n');
541
+ });
542
+
543
+ it("inserts a tag and closes the popover when Enter is pressed", async () => {
544
+ const user = userEvent.setup();
545
+ renderEditor({ locale: "en" });
546
+
547
+ await user.click(screen.getByRole("button", { name: "Rate" }));
548
+ const option = within(screen.getByRole("menu", { name: "Rate" })).getByRole("menuitem", { name: "slow" });
549
+ option.focus();
550
+ await user.keyboard("{Enter}");
551
+
552
+ expect(monacoState.editor.executeEdits).toHaveBeenCalledTimes(1);
553
+ expect(screen.queryByRole("menu", { name: "Rate" })).toBeNull();
554
+ });
555
+
556
+ it("closes an open popover with Escape or an outside click", async () => {
557
+ const user = userEvent.setup();
558
+ renderEditor({ locale: "en" });
559
+
560
+ const breakTrigger = screen.getByRole("button", { name: "Break" });
561
+ await user.click(breakTrigger);
562
+ await user.keyboard("{Escape}");
563
+ expect(screen.queryByRole("menu", { name: "Break" })).toBeNull();
564
+ expect(breakTrigger.getAttribute("aria-expanded")).toBe("false");
565
+
566
+ await user.click(breakTrigger);
567
+ expect(screen.getByRole("menu", { name: "Break" })).toBeTruthy();
568
+ await user.click(document.body);
569
+ expect(screen.queryByRole("menu", { name: "Break" })).toBeNull();
570
+ });
571
+ });
572
+
573
+ describe("SsmlEditor props", () => {
574
+ it("updates toolbar and popover text when the locale changes", async () => {
575
+ const user = userEvent.setup();
576
+ const { rerender } = renderEditor({ locale: "ja", showToolbarLabels: true });
577
+
578
+ expect(screen.getByRole("button", { name: "間" })).toBeTruthy();
579
+ await user.click(screen.getByRole("button", { name: "間" }));
580
+ const japaneseMenu = screen.getByRole("menu", { name: "間" });
581
+ expect(within(japaneseMenu).getByRole("menuitem", { name: "500ms" }).getAttribute("title")).toBe("500ミリ秒の無音");
582
+ await user.click(screen.getByRole("button", { name: "間" }));
583
+
584
+ rerender(<SsmlEditor document={editorDocument} locale="en" showToolbarLabels />);
585
+
586
+ const englishTrigger = screen.getByRole("button", { name: "Break" });
587
+ expect(englishTrigger).toBeTruthy();
588
+ expect(screen.queryByRole("button", { name: "間" })).toBeNull();
589
+ await user.click(englishTrigger);
590
+ const englishMenu = screen.getByRole("menu", { name: "Break" });
591
+ expect(within(englishMenu).getByRole("menuitem", { name: "500ms" }).getAttribute("title")).toBe(
592
+ "Inserts 500 milliseconds of silence.",
593
+ );
594
+ });
595
+
596
+ it("keeps empty pair tags editable after a model content change", () => {
597
+ const onSsmlChange = vi.fn();
598
+ renderEditor({
599
+ document: {
600
+ type: "speak",
601
+ version: "1.0",
602
+ lang: "ja-JP",
603
+ children: ["<emphasis></emphasis>"],
604
+ },
605
+ onSsmlChange,
606
+ });
607
+
608
+ act(() => {
609
+ monacoState.emitContentChange("<emphasis></emphasis>");
610
+ });
611
+
612
+ expect(monacoState.editor.getValue()).toBe("<emphasis></emphasis>");
613
+ expect(onSsmlChange).toHaveBeenLastCalledWith(expect.stringContaining("<emphasis></emphasis>"));
614
+
615
+ act(() => {
616
+ monacoState.emitContentChange("<emphasis>テキスト</emphasis>");
617
+ });
618
+
619
+ expect(monacoState.editor.getValue()).toBe("<emphasis>テキスト</emphasis>");
620
+ expect(onSsmlChange).toHaveBeenLastCalledWith(expect.stringContaining("<emphasis>テキスト</emphasis>"));
621
+ });
622
+
623
+ it("reflects showDecorations in the Monaco editor settings", async () => {
624
+ const { rerender } = renderEditor({ showDecorations: false });
625
+ expect(screen.getByTestId("monaco-editor").getAttribute("data-inlay-hints")).toBe("off");
626
+ expect(screen.getByRole("switch", { name: "装飾" }).getAttribute("aria-checked")).toBe("false");
627
+
628
+ rerender(<SsmlEditor document={editorDocument} showDecorations />);
629
+
630
+ expect(screen.getByTestId("monaco-editor").getAttribute("data-inlay-hints")).toBe("on");
631
+ expect(screen.getByRole("switch", { name: "装飾" }).getAttribute("aria-checked")).toBe("true");
632
+ });
633
+
634
+ it("disables automatic bracket closing for SSML completion", () => {
635
+ renderEditor();
636
+ expect(screen.getByTestId("monaco-editor").getAttribute("data-auto-closing-brackets")).toBe("never");
637
+ });
638
+
639
+ it("allows CodeLens quick controls to be disabled", () => {
640
+ renderEditor({ enableCodeLens: false });
641
+ expect(monacoState.monaco.languages.registerCodeLensProvider).not.toHaveBeenCalled();
642
+ });
643
+
644
+ it("does not render the toolbar when showToolbar is false", () => {
645
+ renderEditor({ showToolbar: false });
646
+
647
+ expect(screen.queryByRole("toolbar", { name: "SSMLツールバー" })).toBeNull();
648
+ expect(screen.getByTestId("monaco-editor")).toBeTruthy();
649
+ });
650
+
651
+ it("debounces diagnostics after Monaco model content changes", () => {
652
+ vi.useFakeTimers();
653
+ try {
654
+ renderEditor();
655
+ const initialMarkerCallCount = monacoState.monaco.editor.setModelMarkers.mock.calls.length;
656
+
657
+ monacoState.setValue("<voice>");
658
+ monacoState.emitContentChange();
659
+
660
+ vi.advanceTimersByTime(299);
661
+ expect(monacoState.monaco.editor.setModelMarkers).toHaveBeenCalledTimes(initialMarkerCallCount);
662
+
663
+ vi.advanceTimersByTime(1);
664
+ expect(monacoState.monaco.editor.setModelMarkers).toHaveBeenCalledTimes(initialMarkerCallCount + 1);
665
+ expect(monacoState.monaco.editor.setModelMarkers.mock.lastCall?.[1]).toBe("ssml");
666
+ expect(monacoState.monaco.editor.setModelMarkers.mock.lastCall?.[2]).toHaveLength(1);
667
+ } finally {
668
+ vi.useRealTimers();
669
+ }
670
+ });
671
+
672
+ it("offers a preferred quick fix for a missing time unit", () => {
673
+ monacoState.setValue('<break time="500"/>');
674
+ renderEditor();
675
+
676
+ const marker = monacoState.monaco.editor.setModelMarkers.mock.lastCall?.[2]?.[0];
677
+ const provider = monacoState.monaco.languages.registerCodeActionProvider.mock.lastCall?.[1];
678
+ const result = provider?.provideCodeActions?.(
679
+ monacoState.editor.getModel(),
680
+ marker ?? {
681
+ startLineNumber: 1,
682
+ startColumn: 1,
683
+ endLineNumber: 1,
684
+ endColumn: 1,
685
+ },
686
+ { markers: marker ? [marker] : [], trigger: 1 },
687
+ {} as never,
688
+ );
689
+ const actions = result && !(result instanceof Promise) ? result.actions : [];
690
+
691
+ expect(marker?.code).toBe("MISSING_TIME_UNIT");
692
+ expect(actions).toEqual([
693
+ expect.objectContaining({
694
+ title: '単位 "ms" を付与して修復',
695
+ kind: "quickfix",
696
+ isPreferred: true,
697
+ edit: expect.objectContaining({
698
+ edits: [
699
+ expect.objectContaining({
700
+ textEdit: expect.objectContaining({ text: "500ms" }),
701
+ }),
702
+ ],
703
+ }),
704
+ }),
705
+ ]);
706
+
707
+ const edit = actions[0]?.edit?.edits[0];
708
+ if (edit && "textEdit" in edit) {
709
+ monacoState.editor.executeEdits("ssml-code-action", [
710
+ {
711
+ range: edit.textEdit.range,
712
+ text: edit.textEdit.text,
713
+ },
714
+ ]);
715
+ }
716
+ expect(monacoState.getValue()).toBe('<break time="500ms"/>');
717
+ });
718
+
719
+ it("offers a preferred quick fix for an unclosed tag", () => {
720
+ monacoState.setValue("<voice>Hello");
721
+ renderEditor();
722
+
723
+ const marker = monacoState.monaco.editor.setModelMarkers.mock.lastCall?.[2]?.[0];
724
+ const provider = monacoState.monaco.languages.registerCodeActionProvider.mock.lastCall?.[1];
725
+ const result = provider?.provideCodeActions?.(
726
+ monacoState.editor.getModel(),
727
+ marker ?? {
728
+ startLineNumber: 1,
729
+ startColumn: 1,
730
+ endLineNumber: 1,
731
+ endColumn: 1,
732
+ },
733
+ { markers: marker ? [marker] : [], trigger: 1 },
734
+ {} as never,
735
+ );
736
+ const actions = result && !(result instanceof Promise) ? result.actions : [];
737
+
738
+ expect(marker?.code).toEqual({ value: "UNCLOSED_TAG", target: "voice" });
739
+ expect(actions).toEqual([
740
+ expect.objectContaining({
741
+ title: '閉じタグ "</voice>" を自動挿入',
742
+ kind: "quickfix",
743
+ isPreferred: true,
744
+ edit: expect.objectContaining({
745
+ edits: [
746
+ expect.objectContaining({
747
+ textEdit: expect.objectContaining({
748
+ range: {
749
+ startLineNumber: marker?.endLineNumber,
750
+ startColumn: marker?.endColumn,
751
+ endLineNumber: marker?.endLineNumber,
752
+ endColumn: marker?.endColumn,
753
+ },
754
+ text: "</voice>",
755
+ }),
756
+ }),
757
+ ],
758
+ }),
759
+ }),
760
+ ]);
761
+
762
+ const edit = actions[0]?.edit?.edits[0];
763
+ if (edit && "textEdit" in edit) {
764
+ monacoState.editor.executeEdits("ssml-code-action", [
765
+ {
766
+ range: edit.textEdit.range,
767
+ text: edit.textEdit.text,
768
+ },
769
+ ]);
770
+ }
771
+ expect(monacoState.getValue()).toBe("<voice>Hello</voice>");
772
+ });
773
+
774
+ it("offers a preferred quick fix for an invalid attribute value", () => {
775
+ monacoState.setValue('<prosody rate="invalid">Hello</prosody>');
776
+ renderEditor();
777
+
778
+ const marker = monacoState.monaco.editor.setModelMarkers.mock.lastCall?.[2]?.[0];
779
+ const provider = monacoState.monaco.languages.registerCodeActionProvider.mock.lastCall?.[1];
780
+ const result = provider?.provideCodeActions?.(
781
+ monacoState.editor.getModel(),
782
+ marker ?? {
783
+ startLineNumber: 1,
784
+ startColumn: 1,
785
+ endLineNumber: 1,
786
+ endColumn: 1,
787
+ },
788
+ { markers: marker ? [marker] : [], trigger: 1 },
789
+ {} as never,
790
+ );
791
+ const actions = result && !(result instanceof Promise) ? result.actions : [];
792
+
793
+ expect(marker?.code).toEqual({ value: "INVALID_ATTR_VALUE", suggestedValue: "x-fast" });
794
+ expect(actions).toEqual([
795
+ expect.objectContaining({
796
+ title: '"x-fast" に変更',
797
+ kind: "quickfix",
798
+ isPreferred: true,
799
+ edit: expect.objectContaining({
800
+ edits: [
801
+ expect.objectContaining({
802
+ textEdit: expect.objectContaining({
803
+ text: "x-fast",
804
+ range: {
805
+ startLineNumber: marker?.startLineNumber,
806
+ startColumn: marker?.startColumn,
807
+ endLineNumber: marker?.endLineNumber,
808
+ endColumn: marker?.endColumn,
809
+ },
810
+ }),
811
+ }),
812
+ ],
813
+ }),
814
+ }),
815
+ ]);
816
+
817
+ const edit = actions[0]?.edit?.edits[0];
818
+ if (edit && "textEdit" in edit) {
819
+ monacoState.editor.executeEdits("ssml-code-action", [
820
+ {
821
+ range: edit.textEdit.range,
822
+ text: edit.textEdit.text,
823
+ },
824
+ ]);
825
+ }
826
+ expect(monacoState.getValue()).toBe('<prosody rate="x-fast">Hello</prosody>');
827
+ });
828
+
829
+ it("cleans up the diagnostics listener and pending timer on unmount", () => {
830
+ vi.useFakeTimers();
831
+ try {
832
+ const { unmount } = renderEditor();
833
+ const contentDispose = monacoState.getLatestContentDispose();
834
+ const cursorPositionDispose = monacoState.getLatestCursorPositionDispose();
835
+ const codeActionDispose = monacoState.monaco.languages.registerCodeActionProvider.mock.results[0]?.value.dispose;
836
+
837
+ expect(contentDispose).not.toBeNull();
838
+ expect(cursorPositionDispose).not.toBeNull();
839
+ expect(codeActionDispose).toBeDefined();
840
+ monacoState.setValue("<voice>");
841
+ monacoState.emitContentChange();
842
+ unmount();
843
+
844
+ expect(contentDispose).toHaveBeenCalledTimes(1);
845
+ expect(cursorPositionDispose).toHaveBeenCalledTimes(1);
846
+ expect(codeActionDispose).toHaveBeenCalledTimes(1);
847
+ const markerCallCountAfterUnmount = monacoState.monaco.editor.setModelMarkers.mock.calls.length;
848
+ vi.advanceTimersByTime(300);
849
+ expect(monacoState.monaco.editor.setModelMarkers).toHaveBeenCalledTimes(markerCallCountAfterUnmount);
850
+ } finally {
851
+ vi.useRealTimers();
852
+ }
853
+ });
854
+ });