@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.
- package/CHANGELOG.md +91 -0
- package/dist/index.d.mts +288 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +5423 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5385 -0
- package/dist/index.mjs.map +1 -0
- package/e2e/monaco-editor.spec.ts +126 -0
- package/package.json +57 -0
- package/src/SsmlEditor.tsx +1302 -0
- package/src/buttonVisibility.ts +36 -0
- package/src/clearSsmlDocument.ts +57 -0
- package/src/components/popovers/InsertionPopover.tsx +136 -0
- package/src/components/popovers/InsertionPopovers.tsx +47 -0
- package/src/components/popovers/ProsodyPopovers.tsx +78 -0
- package/src/components/popovers/TextPopovers.tsx +8 -0
- package/src/components/popovers/TimingPopovers.tsx +8 -0
- package/src/constants/ssmlPresets.ts +660 -0
- package/src/constants/ui.ts +11 -0
- package/src/editableSsml.ts +251 -0
- package/src/formatXml.ts +600 -0
- package/src/hooks/useSsmlEditorState.ts +704 -0
- package/src/hooks/useSsmlMonaco.ts +393 -0
- package/src/index.tsx +50 -0
- package/src/locales.ts +435 -0
- package/src/ssmlCodeAction.ts +144 -0
- package/src/ssmlCodeLens.ts +226 -0
- package/src/ssmlCompletion.ts +129 -0
- package/src/ssmlContext.ts +196 -0
- package/src/ssmlDiagnostics.ts +191 -0
- package/src/ssmlHover.ts +703 -0
- package/src/ssmlInsertion.ts +75 -0
- package/src/ssmlInsertions.ts +471 -0
- package/src/styles/editorStyles.ts +282 -0
- package/test/format-xml-edge-cases.test.ts +107 -0
- package/test/index.test.ts +597 -0
- package/test/randomized-editor-invariants.test.ts +520 -0
- package/test/ui-components.test.tsx +854 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.mjs +10 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Monaco } from "@monaco-editor/react";
|
|
4
|
+
import { isSsmlEditorButtonVisible, type SsmlEditorButtonVisibility } from "../src/buttonVisibility.ts";
|
|
5
|
+
import { clearSsmlDocument } from "../src/clearSsmlDocument.ts";
|
|
6
|
+
import { EXPRESS_AS_STYLE_PRESETS, resolveExpressAsStyles } from "../src/constants/ssmlPresets.ts";
|
|
7
|
+
import { formatXml } from "../src/formatXml.ts";
|
|
8
|
+
import { registerSsmlCompletionProvider } from "../src/ssmlCompletion.ts";
|
|
9
|
+
import { findActiveSsmlTags, findSsmlVoiceContext, updateTagAttribute } from "../src/ssmlContext.ts";
|
|
10
|
+
import { SSML_TAG_DEFINITIONS, findSsmlHoverTarget, formatSsmlHover, getSsmlTagDefinition } from "../src/ssmlHover.ts";
|
|
11
|
+
import { createSsmlInsertionEdit } from "../src/ssmlInsertion.ts";
|
|
12
|
+
import { registerSsmlCodeLens } from "../src/ssmlCodeLens.ts";
|
|
13
|
+
|
|
14
|
+
type CompletionProvider = Parameters<Monaco["languages"]["registerCompletionItemProvider"]>[1];
|
|
15
|
+
type CompletionMethod = NonNullable<CompletionProvider["provideCompletionItems"]>;
|
|
16
|
+
type CompletionModel = Parameters<CompletionMethod>[0];
|
|
17
|
+
type CompletionPosition = Parameters<CompletionMethod>[1];
|
|
18
|
+
|
|
19
|
+
function createCompletionProvider(outerVoiceName?: string, model?: CompletionModel): CompletionProvider {
|
|
20
|
+
let provider: CompletionProvider | undefined;
|
|
21
|
+
const monaco = {
|
|
22
|
+
languages: {
|
|
23
|
+
CompletionItemKind: { Snippet: 1, Value: 2 },
|
|
24
|
+
CompletionItemInsertTextRule: { InsertAsSnippet: 4 },
|
|
25
|
+
registerCompletionItemProvider: (_language: string, nextProvider: CompletionProvider) => {
|
|
26
|
+
provider = nextProvider;
|
|
27
|
+
return { dispose() {} };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
} as unknown as Monaco;
|
|
31
|
+
|
|
32
|
+
registerSsmlCompletionProvider(monaco, { getOuterVoiceName: () => outerVoiceName, model });
|
|
33
|
+
assert.ok(provider);
|
|
34
|
+
return provider;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createCompletionModel(source: string): CompletionModel {
|
|
38
|
+
return {
|
|
39
|
+
getValue: () => source,
|
|
40
|
+
getOffsetAt: (position: CompletionPosition) => position.column - 1,
|
|
41
|
+
getValueInRange: (range: { startColumn: number; endColumn: number }) =>
|
|
42
|
+
source.slice(range.startColumn - 1, range.endColumn - 1),
|
|
43
|
+
} as CompletionModel;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getSuggestions(source: string, column = source.length + 1, outerVoiceName?: string) {
|
|
47
|
+
const provider = createCompletionProvider(outerVoiceName);
|
|
48
|
+
const model = createCompletionModel(source);
|
|
49
|
+
const position = { lineNumber: 1, column } as CompletionPosition;
|
|
50
|
+
const result = provider.provideCompletionItems?.(model, position);
|
|
51
|
+
|
|
52
|
+
assert.ok(result && !(result instanceof Promise));
|
|
53
|
+
return result.suggestions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("shows editor buttons by default and hides configured buttons", () => {
|
|
57
|
+
const visibility: SsmlEditorButtonVisibility = {
|
|
58
|
+
rate: false,
|
|
59
|
+
format: true,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
assert.equal(isSsmlEditorButtonVisible(undefined, "help"), true);
|
|
63
|
+
assert.equal(isSsmlEditorButtonVisible(visibility, "rate"), false);
|
|
64
|
+
assert.equal(isSsmlEditorButtonVisible(visibility, "pitch"), true);
|
|
65
|
+
assert.equal(isSsmlEditorButtonVisible(visibility, "format"), true);
|
|
66
|
+
assert.equal(isSsmlEditorButtonVisible({ "mstts:silence": false }, "mstts:silence"), false);
|
|
67
|
+
assert.equal(isSsmlEditorButtonVisible({ customTag: false }, "customTag"), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("provides attribute values from the active SSML tag and attribute", () => {
|
|
71
|
+
const suggestions = getSuggestions('<prosody rate="');
|
|
72
|
+
|
|
73
|
+
assert.equal(
|
|
74
|
+
suggestions.some((suggestion) => suggestion.label === "x-slow" && suggestion.kind === 2),
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
suggestions.some((suggestion) => suggestion.label === "break"),
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("generates CodeLens actions for prosody and break tags", () => {
|
|
84
|
+
let provider: Parameters<Monaco["languages"]["registerCodeLensProvider"]>[1] | undefined;
|
|
85
|
+
let commandId: string | undefined;
|
|
86
|
+
let commandHandler: ((accessor: unknown, ...args: unknown[]) => void) | undefined;
|
|
87
|
+
const monaco = {
|
|
88
|
+
languages: {
|
|
89
|
+
registerCodeLensProvider: (_language: string, nextProvider: typeof provider) => {
|
|
90
|
+
provider = nextProvider;
|
|
91
|
+
return { dispose() {} };
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
editor: {
|
|
95
|
+
registerCommand: (id: string, handler: typeof commandHandler) => {
|
|
96
|
+
commandId = id;
|
|
97
|
+
commandHandler = handler;
|
|
98
|
+
return { dispose() {} };
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
} as unknown as Monaco;
|
|
102
|
+
const editor = {
|
|
103
|
+
addAction: () => ({ dispose() {} }),
|
|
104
|
+
} as never;
|
|
105
|
+
const source = '<prosody rate="+10%" pitch="high">Hello</prosody><break time="500ms"/>';
|
|
106
|
+
registerSsmlCodeLens(monaco, editor, () => undefined);
|
|
107
|
+
const model = {
|
|
108
|
+
getValue: () => source,
|
|
109
|
+
getPositionAt: (offset: number) => ({ lineNumber: 1, column: offset + 1 }),
|
|
110
|
+
};
|
|
111
|
+
const result = provider?.provideCodeLenses?.(model as never);
|
|
112
|
+
assert.ok(result && !(result instanceof Promise));
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
result.lenses.map((lens) => lens.command?.title),
|
|
115
|
+
[
|
|
116
|
+
"⚡ Rate: +10% (Click to edit)",
|
|
117
|
+
"⚡ Pitch: high (Click to edit)",
|
|
118
|
+
"Unwrap",
|
|
119
|
+
"⚡ Time: 500ms (Click to edit)",
|
|
120
|
+
"Delete",
|
|
121
|
+
],
|
|
122
|
+
);
|
|
123
|
+
assert.equal(commandId, "ssml-editor.codeLens");
|
|
124
|
+
assert.equal(typeof commandHandler, "function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("updates only a tag attribute without breaking XML", () => {
|
|
128
|
+
const source = '<prosody rate="+10%" pitch="high">Hello & goodbye</prosody>';
|
|
129
|
+
const tagEnd = source.indexOf(">") + 1;
|
|
130
|
+
const updated = updateTagAttribute(source, { start: 0, end: tagEnd }, "rate", "x-fast");
|
|
131
|
+
|
|
132
|
+
assert.equal(updated, '<prosody rate="x-fast" pitch="high">Hello & goodbye</prosody>');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("supports single-quoted and case-insensitive attribute contexts", () => {
|
|
136
|
+
const provider = createCompletionProvider();
|
|
137
|
+
assert.deepEqual(provider.triggerCharacters, ["<", '"', "'"]);
|
|
138
|
+
|
|
139
|
+
const suggestions = getSuggestions("<SAY-AS INTERPRET-AS='");
|
|
140
|
+
assert.equal(
|
|
141
|
+
suggestions.some((suggestion) => suggestion.label === "characters" && suggestion.kind === 2),
|
|
142
|
+
true,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("resolves express-as styles by normalized voice name and handles unsupported voices", () => {
|
|
147
|
+
assert.deepEqual(resolveExpressAsStyles("ja-JP-MayuNeural"), ["cheerful", "calm", "sad"]);
|
|
148
|
+
assert.deepEqual(resolveExpressAsStyles("ja-JP-NanamiNeural"), [
|
|
149
|
+
"cheerful",
|
|
150
|
+
"sad",
|
|
151
|
+
"chat",
|
|
152
|
+
"customerservice",
|
|
153
|
+
"whispering",
|
|
154
|
+
]);
|
|
155
|
+
assert.deepEqual(resolveExpressAsStyles(" JA-jp-mayuneural "), ["cheerful", "calm", "sad"]);
|
|
156
|
+
assert.deepEqual(resolveExpressAsStyles("ja-JP-KeitaNeural"), ["chat"]);
|
|
157
|
+
assert.deepEqual(resolveExpressAsStyles(undefined), EXPRESS_AS_STYLE_PRESETS);
|
|
158
|
+
assert.deepEqual(resolveExpressAsStyles(null), EXPRESS_AS_STYLE_PRESETS);
|
|
159
|
+
assert.deepEqual(resolveExpressAsStyles(""), EXPRESS_AS_STYLE_PRESETS);
|
|
160
|
+
assert.deepEqual(resolveExpressAsStyles("custom-Voice", ["custom", "cheerful"]), []);
|
|
161
|
+
assert.deepEqual(resolveExpressAsStyles("en-US-GuyNeural", ["custom", "friendly", "chat"]), ["friendly"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("finds the innermost open voice at a source offset", () => {
|
|
165
|
+
const source = '<voice name="outer"><prosody><voice name=\'inner\'>text</voice><mstts:express-as style="';
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(findSsmlVoiceContext(source, source.length), { voiceName: "outer" });
|
|
168
|
+
assert.deepEqual(findSsmlVoiceContext(source, source.indexOf("text") + 2), { voiceName: "inner" });
|
|
169
|
+
assert.deepEqual(findSsmlVoiceContext("<voice>text", "<voice>text".length), {});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("filters express-as style completions by the effective voice", () => {
|
|
173
|
+
const outerVoiceSuggestions = getSuggestions('<mstts:express-as style="', undefined, "ja-JP-NanamiNeural");
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
outerVoiceSuggestions.map((suggestion) => suggestion.label),
|
|
176
|
+
["cheerful", "sad", "chat", "customerservice", "whispering"],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const innerVoiceSuggestions = getSuggestions(
|
|
180
|
+
'<voice name="en-US-GuyNeural"><mstts:express-as style="',
|
|
181
|
+
undefined,
|
|
182
|
+
"ja-JP-KeitaNeural",
|
|
183
|
+
);
|
|
184
|
+
assert.equal(
|
|
185
|
+
innerVoiceSuggestions.some((suggestion) => suggestion.label === "friendly"),
|
|
186
|
+
true,
|
|
187
|
+
);
|
|
188
|
+
assert.equal(
|
|
189
|
+
innerVoiceSuggestions.some((suggestion) => suggestion.label === "assistant"),
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const unknownInnerVoiceSuggestions = getSuggestions(
|
|
194
|
+
'<voice name="custom"><mstts:express-as style="',
|
|
195
|
+
undefined,
|
|
196
|
+
"ja-JP-KeitaNeural",
|
|
197
|
+
);
|
|
198
|
+
assert.deepEqual(unknownInnerVoiceSuggestions, []);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("keeps non-style attribute completions independent of voice", () => {
|
|
202
|
+
const suggestions = getSuggestions(
|
|
203
|
+
'<voice name="ja-JP-KeitaNeural"><mstts:express-as role="',
|
|
204
|
+
undefined,
|
|
205
|
+
"ja-JP-KeitaNeural",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
assert.equal(
|
|
209
|
+
suggestions.some((suggestion) => suggestion.label === "Girl"),
|
|
210
|
+
true,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("does not provide completion items for a different Monaco model", () => {
|
|
215
|
+
const source = '<mstts:express-as style="';
|
|
216
|
+
const provider = createCompletionProvider("en-US-JennyNeural", createCompletionModel(source));
|
|
217
|
+
const result = provider.provideCompletionItems?.(
|
|
218
|
+
createCompletionModel(source),
|
|
219
|
+
{ lineNumber: 1, column: source.length + 1 } as CompletionPosition,
|
|
220
|
+
{} as never,
|
|
221
|
+
{} as never,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.ok(result && !(result instanceof Promise));
|
|
225
|
+
assert.deepEqual(result.suggestions, []);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("ignores XML non-content and quoted brackets while finding voice context", () => {
|
|
229
|
+
const source =
|
|
230
|
+
'<?xml version="1.0"?><voice name="outer > inner"><!-- <voice name="comment"> --><![CDATA[<voice name="cdata">]]><prosody>text';
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(findSsmlVoiceContext(source, source.length), { voiceName: "outer > inner" });
|
|
233
|
+
assert.equal(findSsmlVoiceContext('<voice name="closed"></voice><prosody>text', 44), undefined);
|
|
234
|
+
assert.doesNotThrow(() => findSsmlVoiceContext('<voice name="unfinished', 24));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("finds nested active SSML tags at the cursor", () => {
|
|
238
|
+
const source = "<voice><prosody>text<emphasis>strong</emphasis></prosody></voice>";
|
|
239
|
+
|
|
240
|
+
assert.deepEqual([...findActiveSsmlTags(source, source.indexOf("strong") + 2)], ["voice", "prosody", "emphasis"]);
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
[...findActiveSsmlTags(source, source.indexOf("</emphasis>") + 3)],
|
|
243
|
+
["voice", "prosody", "emphasis"],
|
|
244
|
+
);
|
|
245
|
+
assert.deepEqual([...findActiveSsmlTags(source, source.indexOf("</prosody>"))], ["voice", "prosody"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("finds tags while the cursor is on opening and self-closing elements", () => {
|
|
249
|
+
const source = '<voice><mstts:express-as style="chat">text</mstts:express-as><break time="500ms"/></voice>';
|
|
250
|
+
const expressAsStart = source.indexOf("<mstts:express-as");
|
|
251
|
+
const breakStart = source.indexOf("<break");
|
|
252
|
+
const breakEnd = source.indexOf("/>", breakStart) + 2;
|
|
253
|
+
|
|
254
|
+
assert.deepEqual([...findActiveSsmlTags(source, expressAsStart + 4)], ["voice", "mstts:express-as"]);
|
|
255
|
+
assert.deepEqual([...findActiveSsmlTags(source, breakStart + 3)], ["voice", "break"]);
|
|
256
|
+
assert.deepEqual([...findActiveSsmlTags(source, breakEnd)], ["voice"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("ignores XML non-content and quoted brackets while finding active tags", () => {
|
|
260
|
+
const source =
|
|
261
|
+
'<?xml version="1.0"?><voice name="outer > inner"><!-- <prosody> --><![CDATA[<emphasis>]]><mstts:express-as>text';
|
|
262
|
+
|
|
263
|
+
assert.deepEqual([...findActiveSsmlTags(source, source.length)], ["voice", "mstts:express-as"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("replaces a typed opening bracket when selecting a tag completion", () => {
|
|
267
|
+
const suggestions = getSuggestions("<");
|
|
268
|
+
const subSuggestion = suggestions.find((suggestion) => suggestion.label === "sub");
|
|
269
|
+
|
|
270
|
+
assert.deepEqual(subSuggestion?.range, {
|
|
271
|
+
startLineNumber: 1,
|
|
272
|
+
startColumn: 1,
|
|
273
|
+
endLineNumber: 1,
|
|
274
|
+
endColumn: 2,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("replaces a partially typed tag prefix when selecting a tag completion", () => {
|
|
279
|
+
for (const [source, startColumn] of [
|
|
280
|
+
["<s", 1],
|
|
281
|
+
["<br", 1],
|
|
282
|
+
["text <s", 6],
|
|
283
|
+
] as const) {
|
|
284
|
+
const suggestions = getSuggestions(source);
|
|
285
|
+
const subSuggestion = suggestions.find((suggestion) => suggestion.label === "sub");
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(subSuggestion?.range, {
|
|
288
|
+
startLineNumber: 1,
|
|
289
|
+
startColumn,
|
|
290
|
+
endLineNumber: 1,
|
|
291
|
+
endColumn: source.length + 1,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("does not provide opening-tag completions while typing a closing tag", () => {
|
|
297
|
+
for (const source of ["</", "</p", "text </prosody"]) {
|
|
298
|
+
const suggestions = getSuggestions(source);
|
|
299
|
+
|
|
300
|
+
assert.deepEqual(suggestions, []);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("replaces an automatically inserted closing bracket after an opening bracket", () => {
|
|
305
|
+
const suggestions = getSuggestions("<>", 2);
|
|
306
|
+
const subSuggestion = suggestions.find((suggestion) => suggestion.label === "sub");
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(subSuggestion?.range, {
|
|
309
|
+
startLineNumber: 1,
|
|
310
|
+
startColumn: 1,
|
|
311
|
+
endLineNumber: 1,
|
|
312
|
+
endColumn: 3,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("keeps wrapped insertion tags inline and terminates them with a line break", () => {
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
createSsmlInsertionEdit("Hello world", 6, 11, {
|
|
319
|
+
prefix: '<prosody rate="slow">',
|
|
320
|
+
suffix: "</prosody>",
|
|
321
|
+
mode: "wrap",
|
|
322
|
+
}),
|
|
323
|
+
{
|
|
324
|
+
replacement: '<prosody rate="slow">world</prosody>\n',
|
|
325
|
+
selectionOffset: '<prosody rate="slow">'.length,
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("places inserted elements on their own lines", () => {
|
|
331
|
+
assert.deepEqual(
|
|
332
|
+
createSsmlInsertionEdit("Hello world", 6, 11, {
|
|
333
|
+
prefix: '<break time="500ms"/>',
|
|
334
|
+
suffix: "",
|
|
335
|
+
mode: "insert",
|
|
336
|
+
}),
|
|
337
|
+
{
|
|
338
|
+
replacement: '\n<break time="500ms"/>\nworld',
|
|
339
|
+
selectionOffset: '\n<break time="500ms"/>\n'.length,
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("does not add duplicate line breaks at existing boundaries", () => {
|
|
345
|
+
assert.deepEqual(
|
|
346
|
+
createSsmlInsertionEdit("Hello\nworld", 6, 6, {
|
|
347
|
+
prefix: '<break time="500ms"/>',
|
|
348
|
+
suffix: "",
|
|
349
|
+
mode: "insert",
|
|
350
|
+
}),
|
|
351
|
+
{
|
|
352
|
+
replacement: '<break time="500ms"/>\n',
|
|
353
|
+
selectionOffset: '<break time="500ms"/>\n'.length,
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("uses the model line ending for insertion edits", () => {
|
|
359
|
+
assert.deepEqual(
|
|
360
|
+
createSsmlInsertionEdit(
|
|
361
|
+
"Hello world",
|
|
362
|
+
6,
|
|
363
|
+
11,
|
|
364
|
+
{
|
|
365
|
+
prefix: '<break time="500ms"/>',
|
|
366
|
+
suffix: "",
|
|
367
|
+
mode: "insert",
|
|
368
|
+
},
|
|
369
|
+
"\r\n",
|
|
370
|
+
),
|
|
371
|
+
{
|
|
372
|
+
replacement: '\r\n<break time="500ms"/>\r\nworld',
|
|
373
|
+
selectionOffset: '\r\n<break time="500ms"/>\r\n'.length,
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("uses the model line ending for wrapped insertion edits", () => {
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
createSsmlInsertionEdit(
|
|
381
|
+
"Hello world",
|
|
382
|
+
6,
|
|
383
|
+
11,
|
|
384
|
+
{
|
|
385
|
+
prefix: '<prosody rate="slow">',
|
|
386
|
+
suffix: "</prosody>",
|
|
387
|
+
mode: "wrap",
|
|
388
|
+
},
|
|
389
|
+
"\r\n",
|
|
390
|
+
),
|
|
391
|
+
{
|
|
392
|
+
replacement: '<prosody rate="slow">world</prosody>\r\n',
|
|
393
|
+
selectionOffset: '<prosody rate="slow">'.length,
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("does not duplicate an existing line ending after wrapped insertion", () => {
|
|
399
|
+
const template = {
|
|
400
|
+
prefix: '<prosody rate="slow">',
|
|
401
|
+
suffix: "</prosody>",
|
|
402
|
+
mode: "wrap" as const,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
assert.deepEqual(createSsmlInsertionEdit("Hello\nworld\n", 6, 11, template), {
|
|
406
|
+
replacement: '<prosody rate="slow">world</prosody>',
|
|
407
|
+
selectionOffset: template.prefix.length,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("moves an empty insertion cursor past an existing line ending", () => {
|
|
412
|
+
const tag = '<break time="500ms"/>';
|
|
413
|
+
assert.deepEqual(
|
|
414
|
+
createSsmlInsertionEdit("Hello\nworld", 5, 5, {
|
|
415
|
+
prefix: tag,
|
|
416
|
+
suffix: "",
|
|
417
|
+
mode: "insert",
|
|
418
|
+
}),
|
|
419
|
+
{
|
|
420
|
+
replacement: `\n${tag}`,
|
|
421
|
+
selectionOffset: `\n${tag}\n`.length,
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("terminates an insertion at the end of the document with a line ending", () => {
|
|
427
|
+
const tag = '<break time="500ms"/>';
|
|
428
|
+
assert.deepEqual(
|
|
429
|
+
createSsmlInsertionEdit("Hello", 5, 5, {
|
|
430
|
+
prefix: tag,
|
|
431
|
+
suffix: "",
|
|
432
|
+
mode: "insert",
|
|
433
|
+
}),
|
|
434
|
+
{
|
|
435
|
+
replacement: `\n${tag}\n`,
|
|
436
|
+
selectionOffset: `\n${tag}\n`.length,
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("defines the supported SSML tags", () => {
|
|
442
|
+
assert.ok(SSML_TAG_DEFINITIONS.length > 0);
|
|
443
|
+
assert.equal(getSsmlTagDefinition("prosody")?.name, "prosody");
|
|
444
|
+
assert.equal(getSsmlTagDefinition("mstts:express-as")?.name, "mstts:express-as");
|
|
445
|
+
assert.equal(getSsmlTagDefinition("express-as")?.name, "mstts:express-as");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("finds a tag name and returns its range", () => {
|
|
449
|
+
const target = findSsmlHoverTarget('<prosody rate="fast">Hello</prosody>', 1, 3);
|
|
450
|
+
|
|
451
|
+
assert.equal(target?.kind, "tag");
|
|
452
|
+
assert.equal(target?.tagName, "prosody");
|
|
453
|
+
assert.deepEqual(target?.range, {
|
|
454
|
+
startLineNumber: 1,
|
|
455
|
+
startColumn: 2,
|
|
456
|
+
endLineNumber: 1,
|
|
457
|
+
endColumn: 9,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("finds attributes and quoted attribute values", () => {
|
|
462
|
+
const source = '<prosody rate="fast" pitch="+2st">Hello</prosody>';
|
|
463
|
+
const attribute = findSsmlHoverTarget(source, 1, 11);
|
|
464
|
+
const value = findSsmlHoverTarget(source, 1, 17);
|
|
465
|
+
|
|
466
|
+
assert.equal(attribute?.kind, "parameter");
|
|
467
|
+
assert.equal(attribute?.parameter?.name, "rate");
|
|
468
|
+
assert.deepEqual(attribute?.range, {
|
|
469
|
+
startLineNumber: 1,
|
|
470
|
+
startColumn: 10,
|
|
471
|
+
endLineNumber: 1,
|
|
472
|
+
endColumn: 14,
|
|
473
|
+
});
|
|
474
|
+
assert.equal(value?.kind, "parameter-value");
|
|
475
|
+
assert.equal(value?.parameter?.name, "rate");
|
|
476
|
+
assert.deepEqual(value?.range, {
|
|
477
|
+
startLineNumber: 1,
|
|
478
|
+
startColumn: 16,
|
|
479
|
+
endLineNumber: 1,
|
|
480
|
+
endColumn: 20,
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("supports namespaced tags, hyphenated attributes, and closing tags", () => {
|
|
485
|
+
const source = '<mstts:express-as style-degree="1.5">Hello</mstts:express-as>';
|
|
486
|
+
const tag = findSsmlHoverTarget(source, 1, 5);
|
|
487
|
+
const parameter = findSsmlHoverTarget(source, 1, 24);
|
|
488
|
+
const closingTag = findSsmlHoverTarget(source, 1, 48);
|
|
489
|
+
|
|
490
|
+
assert.equal(tag?.definition.name, "mstts:express-as");
|
|
491
|
+
assert.equal(parameter?.parameter?.name, "styledegree");
|
|
492
|
+
assert.equal(closingTag?.kind, "tag");
|
|
493
|
+
assert.equal(closingTag?.isClosingTag, true);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("supports multiline and incomplete start tags", () => {
|
|
497
|
+
const source = '<prosody\n rate="fa';
|
|
498
|
+
const tag = findSsmlHoverTarget(source, 1, 4);
|
|
499
|
+
const attribute = findSsmlHoverTarget(source, 2, 4);
|
|
500
|
+
const value = findSsmlHoverTarget(source, 2, 10);
|
|
501
|
+
|
|
502
|
+
assert.equal(tag?.tagName, "prosody");
|
|
503
|
+
assert.equal(attribute?.parameter?.name, "rate");
|
|
504
|
+
assert.equal(value?.kind, "parameter-value");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("does not provide help for unknown tags, attributes, or text", () => {
|
|
508
|
+
assert.equal(findSsmlHoverTarget('<custom answer="42">text</custom>', 1, 3), undefined);
|
|
509
|
+
assert.equal(findSsmlHoverTarget('<prosody unknown="42">text</prosody>', 1, 11), undefined);
|
|
510
|
+
assert.equal(findSsmlHoverTarget("<prosody>text</prosody>", 1, 11), undefined);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("formats tag and parameter documentation as safe markdown", () => {
|
|
514
|
+
const tag = findSsmlHoverTarget('<break strength="strong"/>', 1, 3);
|
|
515
|
+
const parameter = findSsmlHoverTarget('<break strength="strong"/>', 1, 10);
|
|
516
|
+
|
|
517
|
+
assert.ok(tag);
|
|
518
|
+
assert.ok(parameter);
|
|
519
|
+
assert.match(formatSsmlHover(tag), /Inserts a pause/);
|
|
520
|
+
assert.match(formatSsmlHover(tag), /`strength`/);
|
|
521
|
+
assert.match(formatSsmlHover(parameter), /\*\*Parameter `strength`\*\*/);
|
|
522
|
+
assert.match(formatSsmlHover(parameter), /`strong`/);
|
|
523
|
+
assert.doesNotMatch(formatSsmlHover(tag), /<script>/i);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("formats hover documentation in the selected locale", () => {
|
|
527
|
+
const target = findSsmlHoverTarget('<break strength="strong"/>', 1, 3);
|
|
528
|
+
|
|
529
|
+
assert.ok(target);
|
|
530
|
+
assert.match(formatSsmlHover(target, "ja"), /間/);
|
|
531
|
+
assert.match(formatSsmlHover(target, "ja"), /単語やその他の音声コンテンツ/);
|
|
532
|
+
assert.match(formatSsmlHover(target, "en"), /Break/);
|
|
533
|
+
assert.match(formatSsmlHover(target, "en"), /Inserts a pause/);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("formats nested XML with readable line breaks", () => {
|
|
537
|
+
assert.equal(
|
|
538
|
+
formatXml(
|
|
539
|
+
'<speak version="1.0"><voice name="Jenny"><prosody rate="slow">Hello</prosody><break time="500ms"/></voice></speak>',
|
|
540
|
+
),
|
|
541
|
+
'<speak version="1.0">\n <voice name="Jenny">\n <prosody rate="slow">Hello</prosody>\n <break time="500ms"/>\n </voice>\n</speak>',
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("puts text-only SSML content on a readable line", () => {
|
|
546
|
+
assert.equal(
|
|
547
|
+
formatXml('<speak version="1.0" xml:lang="en-US">Welcome to the Builder .</speak>'),
|
|
548
|
+
'<speak version="1.0" xml:lang="en-US">\n Welcome to the Builder .\n</speak>',
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("keeps formatted XML stable and handles empty input", () => {
|
|
553
|
+
const formatted = "<root>\n <child>text</child>\n</root>";
|
|
554
|
+
|
|
555
|
+
assert.equal(formatXml(formatted), formatted);
|
|
556
|
+
assert.equal(formatXml(" \n\t "), "");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("preserves voice elements when clearing SSML markup", () => {
|
|
560
|
+
const document = {
|
|
561
|
+
type: "speak" as const,
|
|
562
|
+
version: "1.0",
|
|
563
|
+
lang: "en-US",
|
|
564
|
+
children: [
|
|
565
|
+
"Before ",
|
|
566
|
+
{
|
|
567
|
+
type: "voice" as const,
|
|
568
|
+
name: "en-US-JennyNeural",
|
|
569
|
+
effect: "eq_car",
|
|
570
|
+
attributes: { "data-source": "test" },
|
|
571
|
+
children: [
|
|
572
|
+
{
|
|
573
|
+
type: "prosody" as const,
|
|
574
|
+
rate: "slow",
|
|
575
|
+
children: ["Hello ", { type: "break" as const, time: "500ms" }, "world"],
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
" after",
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
assert.deepEqual(clearSsmlDocument(document), {
|
|
584
|
+
...document,
|
|
585
|
+
children: [
|
|
586
|
+
"Before ",
|
|
587
|
+
{
|
|
588
|
+
type: "voice",
|
|
589
|
+
name: "en-US-JennyNeural",
|
|
590
|
+
effect: "eq_car",
|
|
591
|
+
attributes: { "data-source": "test" },
|
|
592
|
+
children: ["Hello world"],
|
|
593
|
+
},
|
|
594
|
+
" after",
|
|
595
|
+
],
|
|
596
|
+
});
|
|
597
|
+
});
|