@pie-lib/math-input-mathlive 1.1.5-beta.1 → 1.1.5-beta.10

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 (50) hide show
  1. package/lib/__tests__/composites.test.js +255 -0
  2. package/lib/__tests__/drop-in-compat.test.js +133 -0
  3. package/lib/__tests__/embedded-fonts.test.js +121 -0
  4. package/lib/__tests__/index.test.js +118 -0
  5. package/lib/__tests__/input-imperative.test.js +155 -0
  6. package/lib/__tests__/keypad-label.test.js +231 -0
  7. package/lib/__tests__/keypad-render.test.js +206 -0
  8. package/lib/__tests__/latex-bridge.test.js +386 -0
  9. package/lib/__tests__/mathlive-instance.test.js +419 -0
  10. package/lib/__tests__/mount-race.test.js +218 -0
  11. package/lib/__tests__/static-prompts.test.js +405 -0
  12. package/lib/__tests__/tmp-markup.test.js +26 -0
  13. package/lib/keypad/index.js +36 -12
  14. package/lib/keypad/index.js.map +1 -1
  15. package/lib/latex-bridge.js +159 -4
  16. package/lib/latex-bridge.js.map +1 -1
  17. package/lib/mathlive-instance.js +21 -28
  18. package/lib/mathlive-instance.js.map +1 -1
  19. package/lib/mf/common-styles.js +17 -2
  20. package/lib/mf/common-styles.js.map +1 -1
  21. package/lib/mf/index.js +6 -0
  22. package/lib/mf/index.js.map +1 -1
  23. package/lib/mf/input.js +5 -0
  24. package/lib/mf/input.js.map +1 -1
  25. package/lib/mf/static.js +4 -2
  26. package/lib/mf/static.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/composites.test.jsx +220 -0
  29. package/src/__tests__/drop-in-compat.test.jsx +112 -0
  30. package/src/__tests__/index.test.jsx +109 -0
  31. package/src/__tests__/input-imperative.test.jsx +144 -0
  32. package/src/__tests__/keypad-label.test.jsx +284 -0
  33. package/src/__tests__/keypad-render.test.jsx +123 -0
  34. package/src/__tests__/latex-bridge.test.js +389 -0
  35. package/src/__tests__/mathlive-instance.test.js +418 -0
  36. package/src/__tests__/mount-race.test.jsx +142 -0
  37. package/src/__tests__/static-prompts.test.jsx +364 -0
  38. package/src/keypad/index.jsx +18 -3
  39. package/src/latex-bridge.js +171 -4
  40. package/src/mathlive-instance.js +27 -29
  41. package/src/mf/common-styles.js +19 -0
  42. package/src/mf/index.js +2 -0
  43. package/src/mf/input.jsx +5 -0
  44. package/src/mf/static.jsx +4 -1
  45. package/lib/fonts/embedded.js +0 -120
  46. package/lib/fonts/index.js +0 -81
  47. package/lib/fonts/index.js.map +0 -1
  48. package/scripts/generate-embedded-fonts.js +0 -94
  49. package/src/fonts/embedded.js +0 -116
  50. package/src/fonts/index.js +0 -71
@@ -0,0 +1,418 @@
1
+ describe('mathlive-instance', () => {
2
+ let instance;
3
+
4
+ beforeEach(() => {
5
+ jest.resetModules();
6
+ // eslint-disable-next-line global-require
7
+ instance = require('../mathlive-instance');
8
+ instance.__resetEmbeds();
9
+ });
10
+
11
+ describe('registerEmbed', () => {
12
+ it('registers a known embed and exposes it as a macro', () => {
13
+ instance.registerEmbed('answerBlock', () => ({
14
+ htmlString: '<div class="answerBlock"></div>',
15
+ latex: () => '\\embed{answerBlock}[]',
16
+ }));
17
+
18
+ expect(instance.registeredEmbeds()).toEqual(['answerBlock']);
19
+ // The macro tags output with data-embed so CSS can target it, replacing
20
+ // MathQuill's htmlString hook.
21
+ expect(instance.getMacros().answerBlock).toEqual('\\htmlData{embed=answerBlock}{\\placeholder{}}');
22
+ });
23
+
24
+ it('registers newLine as a harmless macro (it is handled by latex-bridge)', () => {
25
+ instance.registerEmbed('newLine', () => ({ latex: () => '\\embed{newLine}[]' }));
26
+
27
+ expect(instance.getMacros().newLine).toEqual('\\htmlData{embed=newLine}{}');
28
+ });
29
+
30
+ it('falls back to a placeholder for an unknown embed', () => {
31
+ instance.registerEmbed('somethingCustom', () => ({ htmlString: '<b>x</b>' }));
32
+
33
+ expect(instance.getMacros().somethingCustom).toEqual('\\htmlData{embed=somethingCustom}{\\placeholder{}}');
34
+ });
35
+
36
+ it('honours an explicit mathliveLatex from the factory', () => {
37
+ instance.registerEmbed('custom', () => ({ mathliveLatex: '\\alpha' }));
38
+
39
+ expect(instance.getMacros().custom).toEqual('\\htmlData{embed=custom}{\\alpha}');
40
+ });
41
+
42
+ it('does not throw when the factory throws', () => {
43
+ expect(() =>
44
+ instance.registerEmbed('bad', () => {
45
+ throw new Error('boom');
46
+ }),
47
+ ).not.toThrow();
48
+
49
+ expect(instance.registeredEmbeds()).toEqual(['bad']);
50
+ });
51
+
52
+ it('ignores bad arguments', () => {
53
+ instance.registerEmbed(undefined, () => ({}));
54
+ instance.registerEmbed('noFactory', undefined);
55
+
56
+ expect(instance.registeredEmbeds()).toEqual([]);
57
+ });
58
+
59
+ it('always includes the pie macro set', () => {
60
+ const macros = instance.getMacros();
61
+
62
+ expect(macros.parallelogram).toBeDefined();
63
+ expect(macros.longdiv).toBeDefined();
64
+ expect(macros.napprox).toEqual('\\not\\approx');
65
+ });
66
+
67
+ // Argument-taking macros exist only so previously authored latex parses.
68
+ // Editing never goes through them: MathLive serialises a macro from its
69
+ // original arguments, so typed content never comes back out.
70
+ it('argument-taking macros are plain parse-only definitions', () => {
71
+ ['longdiv', 'overarc', 'abs'].forEach((name) => {
72
+ expect(typeof instance.PIE_MACROS[name]).toBe('string');
73
+ expect(instance.PIE_MACROS[name]).toContain('#1');
74
+ });
75
+ });
76
+
77
+ it('argument-less symbols stay atomic', () => {
78
+ ['perpendicular', 'square', 'degree', 'napprox', 'parallelogram'].forEach((name) => {
79
+ expect(typeof instance.PIE_MACROS[name]).toBe('string');
80
+ });
81
+ });
82
+
83
+ it('longdiv matches the MathJax macro used by math-rendering', () => {
84
+ expect(instance.PIE_MACROS.longdiv).toEqual('\\enclose{longdiv}{#1}');
85
+ });
86
+ });
87
+
88
+ describe('applyStaticMath', () => {
89
+ it('returns undefined without an element', () => {
90
+ expect(instance.applyStaticMath(null, 'x')).toBeUndefined();
91
+ });
92
+
93
+ it('defers (returns undefined) when MathLive has not loaded', () => {
94
+ const el = document.createElement('div');
95
+
96
+ expect(instance.applyStaticMath(el, 'x')).toBeUndefined();
97
+ });
98
+
99
+ it('renders synchronously once MathLive is available', () => {
100
+ const el = document.createElement('div');
101
+
102
+ instance.__setMathLiveForTest({ convertLatexToMarkup: (l) => `<span>${l}</span>` });
103
+
104
+ expect(instance.applyStaticMath(el, '\\pi')).toBe(el);
105
+ expect(el.innerHTML).toEqual('<span>\\pi</span>');
106
+ });
107
+
108
+ it('falls back to the element textContent when no latex is passed', () => {
109
+ const el = document.createElement('div');
110
+
111
+ el.textContent = '\\theta';
112
+ instance.__setMathLiveForTest({ convertLatexToMarkup: (l) => `<i>${l}</i>` });
113
+ instance.applyStaticMath(el);
114
+
115
+ expect(el.innerHTML).toEqual('<i>\\theta</i>');
116
+ });
117
+
118
+ it('bridges stored latex (newLine embed) before rendering', () => {
119
+ const el = document.createElement('div');
120
+
121
+ instance.__setMathLiveForTest({ convertLatexToMarkup: (l) => l });
122
+ instance.applyStaticMath(el, 'a\\embed{newLine}[]b');
123
+
124
+ // \embed{newLine}[] must become a multiline environment
125
+ expect(el.innerHTML).toContain('displaylines');
126
+ });
127
+ });
128
+
129
+ describe('configureFonts', () => {
130
+ // MathLive's default fontsDirectory is the relative './fonts/', which it
131
+ // resolves against its own (empty, when bundled) script url and throws
132
+ // "Invalid base URL" from loadFonts. Paths must become absolute.
133
+ it('is exported and callable before MathLive loads', () => {
134
+ expect(typeof instance.configureFonts).toBe('function');
135
+ expect(() => instance.configureFonts('/mathlive-fonts')).not.toThrow();
136
+ });
137
+
138
+ it('accepts null to disable font loading', () => {
139
+ expect(() => instance.configureFonts(null)).not.toThrow();
140
+ });
141
+
142
+ it('applies an absolute url to an already-loaded MathfieldElement', () => {
143
+ const MathfieldElement = {};
144
+
145
+ // simulate a loaded instance
146
+ instance.__setMathLiveForTest({ MathfieldElement });
147
+ instance.configureFonts('/mathlive-fonts');
148
+
149
+ expect(MathfieldElement.fontsDirectory).toEqual(`${document.baseURI.replace(/\/$/, '')}/mathlive-fonts`);
150
+ expect(MathfieldElement.fontsDirectory.startsWith('http')).toBe(true);
151
+ });
152
+
153
+ // \neq, \nsim and \ncong are built from Private Use Area glyphs that only
154
+ // the KaTeX fonts contain, so "no fonts" renders garbage rather than
155
+ // degrading gracefully - hence a CDN fallback by default.
156
+ it('falls back to a version-pinned CDN when nothing is configured', () => {
157
+ const MathfieldElement = {};
158
+
159
+ instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '9.9.9' } });
160
+ instance.configureFonts(undefined);
161
+
162
+ expect(MathfieldElement.fontsDirectory).toEqual('https://unpkg.com/mathlive@9.9.9/fonts');
163
+ });
164
+
165
+ it('uses /fonts, not /dist/fonts (the latter 404s on unpkg)', () => {
166
+ const MathfieldElement = {};
167
+
168
+ instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
169
+ instance.configureFonts(undefined);
170
+
171
+ expect(MathfieldElement.fontsDirectory).toEqual('https://unpkg.com/mathlive@0.110.0/fonts');
172
+ expect(MathfieldElement.fontsDirectory).not.toContain('/dist/');
173
+ });
174
+
175
+ it('a configured path wins over the CDN', () => {
176
+ const MathfieldElement = {};
177
+
178
+ instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
179
+ instance.configureFonts('/mathlive-fonts');
180
+
181
+ expect(MathfieldElement.fontsDirectory).not.toContain('unpkg.com');
182
+ });
183
+
184
+ it('null still opts out entirely', () => {
185
+ const MathfieldElement = {};
186
+
187
+ instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
188
+ instance.configureFonts(null);
189
+
190
+ expect(MathfieldElement.fontsDirectory).toBeNull();
191
+ });
192
+
193
+ it('leaves a fully-qualified url untouched', () => {
194
+ const MathfieldElement = {};
195
+
196
+ instance.__setMathLiveForTest({ MathfieldElement });
197
+ instance.configureFonts('https://cdn.example.com/fonts');
198
+
199
+ expect(MathfieldElement.fontsDirectory).toEqual('https://cdn.example.com/fonts');
200
+ });
201
+
202
+ it('passes null through rather than resolving it', () => {
203
+ const MathfieldElement = {};
204
+
205
+ instance.__setMathLiveForTest({ MathfieldElement });
206
+ instance.configureFonts(null);
207
+
208
+ expect(MathfieldElement.fontsDirectory).toBeNull();
209
+ });
210
+ });
211
+
212
+ // A live <math-field> renders in shadow DOM, so nothing in the page's
213
+ // stylesheets reaches it - the stretchy-accent fix has to be injected there.
214
+ /**
215
+ * A keypad key like \frac inserts `\frac{#?}{#?}`; MathLive turns each `#?`
216
+ * into a `\placeholder{}` atom and *selects* the first one. Measured in
217
+ * Chromium: that atom is a leaf (its offsets are before/after, there is no
218
+ * position inside), so MathLive renders NO caret element and shows only the
219
+ * selection highlight - the box read as focused-but-dead. The caret is drawn
220
+ * by our own shadow CSS, gated on a class so it is placeholder-specific.
221
+ */
222
+ describe('trackPlaceholderCaret', () => {
223
+ const fakeField = (selection, latex) => {
224
+ const classes = new Set();
225
+
226
+ return {
227
+ listeners: {},
228
+ selection,
229
+ selectionIsCollapsed: !selection,
230
+ getValue: () => latex,
231
+ classList: {
232
+ toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
233
+ contains: (name) => classes.has(name),
234
+ },
235
+ addEventListener(name, fn) {
236
+ this.listeners[name] = fn;
237
+ },
238
+ removeEventListener(name) {
239
+ delete this.listeners[name];
240
+ },
241
+ };
242
+ };
243
+
244
+ it('marks the host when a placeholder is selected', () => {
245
+ const mf = fakeField({ ranges: [[1, 2]] }, '\\placeholder{}');
246
+
247
+ instance.trackPlaceholderCaret(mf);
248
+
249
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(true);
250
+ });
251
+
252
+ it('does not mark an ordinary selection', () => {
253
+ const mf = fakeField({ ranges: [[0, 3]] }, 'x+1');
254
+
255
+ instance.trackPlaceholderCaret(mf);
256
+
257
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
258
+ });
259
+
260
+ it('does not mark a collapsed selection, which has a real caret already', () => {
261
+ const mf = fakeField(null, '\\placeholder{}');
262
+
263
+ instance.trackPlaceholderCaret(mf);
264
+
265
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
266
+ });
267
+
268
+ it('leaves a named prompt alone - it takes a real caret inside', () => {
269
+ const mf = fakeField({ ranges: [[1, 2]] }, '\\placeholder[r1]{}');
270
+
271
+ instance.trackPlaceholderCaret(mf);
272
+
273
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
274
+ });
275
+
276
+ it('re-evaluates whenever the selection moves', () => {
277
+ const mf = fakeField(null, '');
278
+
279
+ instance.trackPlaceholderCaret(mf);
280
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
281
+
282
+ // the caret lands on a placeholder
283
+ mf.selection = { ranges: [[1, 2]] };
284
+ mf.selectionIsCollapsed = false;
285
+ mf.getValue = () => '\\placeholder{}';
286
+ mf.listeners['selection-change']();
287
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(true);
288
+
289
+ // ...and moves off it again
290
+ mf.selectionIsCollapsed = true;
291
+ mf.listeners['selection-change']();
292
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
293
+ });
294
+
295
+ it('returns a teardown that detaches the listener', () => {
296
+ const mf = fakeField(null, '');
297
+ const off = instance.trackPlaceholderCaret(mf);
298
+
299
+ expect(typeof mf.listeners['selection-change']).toEqual('function');
300
+ off();
301
+ expect(mf.listeners['selection-change']).toBeUndefined();
302
+ });
303
+
304
+ it('survives a field that throws while reading the selection', () => {
305
+ const mf = fakeField({ ranges: [[1, 2]] }, '');
306
+
307
+ mf.getValue = () => {
308
+ throw new Error('detached');
309
+ };
310
+
311
+ expect(() => instance.trackPlaceholderCaret(mf)).not.toThrow();
312
+ expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
313
+ });
314
+
315
+ it('is a no-op without a field', () => {
316
+ expect(instance.trackPlaceholderCaret(undefined)).toBeUndefined();
317
+ expect(instance.trackPlaceholderCaret({})).toBeUndefined();
318
+ });
319
+ });
320
+
321
+ describe('applyShadowStyles', () => {
322
+ const host = () => {
323
+ const el = document.createElement('div');
324
+
325
+ el.attachShadow({ mode: 'open' });
326
+
327
+ return el;
328
+ };
329
+
330
+ it('is a no-op without a shadow root', () => {
331
+ expect(() => instance.applyShadowStyles(undefined)).not.toThrow();
332
+ expect(() => instance.applyShadowStyles(document.createElement('div'))).not.toThrow();
333
+ });
334
+
335
+ it('injects the accent fix into the shadow root', () => {
336
+ const el = host();
337
+
338
+ instance.applyShadowStyles(el);
339
+
340
+ const viaSheet = el.shadowRoot.__pieAccentSheet;
341
+ const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
342
+ const css = viaSheet ? 'adopted' : (viaStyle && viaStyle.textContent) || '';
343
+
344
+ expect(viaSheet || viaStyle).toBeTruthy();
345
+
346
+ if (viaStyle) {
347
+ expect(css).toContain('margin-left:0 !important');
348
+ // scoped so single-glyph accents keep their centring offset
349
+ expect(css).toContain(':has(.ML__stretchy)');
350
+ }
351
+ });
352
+
353
+ it('injects the placeholder caret rules', () => {
354
+ const el = host();
355
+
356
+ instance.applyShadowStyles(el);
357
+
358
+ const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
359
+
360
+ if (viaStyle) {
361
+ const css = viaStyle.textContent;
362
+
363
+ // gated on the host class, so an ordinary selection gets no caret
364
+ expect(css).toContain(`:host(.${instance.PLACEHOLDER_CARET_CLASS})`);
365
+ // MathLive's own blink keyframes and caret colour, already in this root
366
+ expect(css).toContain('ML__caret-blink');
367
+ expect(css).toContain('--_caret-color');
368
+ // the innermost selected element only: MathLive marks a wrapper AND the
369
+ // glyph span, which would otherwise render two carets
370
+ expect(css).toContain('.ML__selected:not(:has(.ML__selected))');
371
+ }
372
+ });
373
+
374
+ it('injects the radical index size fix', () => {
375
+ const el = host();
376
+
377
+ instance.applyShadowStyles(el);
378
+
379
+ const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
380
+
381
+ if (viaStyle) {
382
+ const css = viaStyle.textContent;
383
+
384
+ // MathLive's scriptscriptstyle index (an inline 50%) reads too small in
385
+ // the authoring UI; a mathfield's shadow root needs its own copy of the
386
+ // rule, since host styles do not cross the boundary. Compared against
387
+ // the exported rule, so the size stays defined in one place.
388
+ // eslint-disable-next-line global-require
389
+ const { ROOT_INDEX_CSS } = require('../mf/common-styles');
390
+
391
+ expect(ROOT_INDEX_CSS).toContain('.ML__sqrt-index >');
392
+ expect(css).toContain(ROOT_INDEX_CSS);
393
+ }
394
+ });
395
+
396
+ it('does not inject twice', () => {
397
+ const el = host();
398
+
399
+ instance.applyShadowStyles(el);
400
+ instance.applyShadowStyles(el);
401
+
402
+ const styles = el.shadowRoot.querySelectorAll('style[data-pie-accent]');
403
+
404
+ // either one adopted sheet, or exactly one style node
405
+ expect(el.shadowRoot.__pieAccentSheet ? 1 : styles.length).toBe(1);
406
+ });
407
+ });
408
+
409
+ describe('latexToMarkup', () => {
410
+ it('returns empty string when MathLive is not loaded', () => {
411
+ expect(instance.latexToMarkup('\\pi')).toEqual('');
412
+ });
413
+
414
+ it('returns empty string for empty latex', () => {
415
+ expect(instance.latexToMarkup('')).toEqual('');
416
+ });
417
+ });
418
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MathLive loads asynchronously, so `componentDidMount` resumes after an await
3
+ * before appending a <math-field>. Two orderings must both hold:
4
+ *
5
+ * - StrictMode dev double-mount (mount -> unmount -> mount on the SAME
6
+ * instance) must end up with exactly ONE field. A boolean "unmounted" flag
7
+ * latches here and blocks the remount entirely - which is why this uses a
8
+ * generation counter.
9
+ * - A genuine unmount while the load is in flight must append nothing.
10
+ *
11
+ * MathLive cannot load under jsdom, so it is mocked with a minimal stub in
12
+ * order to actually count appended fields.
13
+ */
14
+ jest.mock('../mathlive-instance', () => {
15
+ // Must be a real DOM node: the component uses replaceChildren(), which
16
+ // rejects anything that is not a Node. Returning an element from the
17
+ // constructor replaces `this`, so callers get a genuine <math-field>.
18
+ class FakeMathfieldElement {
19
+ constructor() {
20
+ // `global` rather than `document`: jest forbids out-of-scope variables in
21
+ // a mock factory, and `global` is on its allow-list.
22
+ const el = global.document.createElement('math-field');
23
+
24
+ el.value = '';
25
+ el.getValue = () => el.value;
26
+
27
+ return el;
28
+ }
29
+ }
30
+
31
+ return {
32
+ loadMathLive: () => Promise.resolve({ MathfieldElement: FakeMathfieldElement }),
33
+ getMacros: () => ({}),
34
+ // must mirror the real export, otherwise the component sets `undefined`
35
+ MATH_MODE_SPACE: '\\ ',
36
+ applyShadowStyles: () => {},
37
+ trackPlaceholderCaret: () => () => {},
38
+ latexToMarkup: (l) => `<span>${l}</span>`,
39
+ };
40
+ });
41
+
42
+ // eslint-disable-next-line import/first
43
+ import Input from '../mf/input';
44
+
45
+ const holderFor = (cmp) => {
46
+ const el = document.createElement('span');
47
+
48
+ cmp.holderRef = { current: el };
49
+
50
+ return el;
51
+ };
52
+
53
+ const fieldCount = (el) => Array.from(el.childNodes).filter((n) => n.tagName === 'MATH-FIELD').length;
54
+
55
+ describe('async mount race', () => {
56
+ it('StrictMode mount -> unmount -> mount yields exactly one field', async () => {
57
+ const input = new Input({});
58
+ const el = holderFor(input);
59
+
60
+ const first = input.componentDidMount();
61
+
62
+ input.componentWillUnmount();
63
+
64
+ const second = input.componentDidMount();
65
+
66
+ await Promise.all([first, second]);
67
+
68
+ expect(fieldCount(el)).toBe(1);
69
+ expect(input.mathField).toBeDefined();
70
+ });
71
+
72
+ it('appends nothing when unmounted while the load is in flight', async () => {
73
+ const input = new Input({});
74
+ const el = holderFor(input);
75
+
76
+ const pending = input.componentDidMount();
77
+
78
+ input.componentWillUnmount();
79
+
80
+ await pending;
81
+
82
+ expect(fieldCount(el)).toBe(0);
83
+ expect(input.mathField).toBeUndefined();
84
+ });
85
+
86
+ it('a normal single mount creates the field', async () => {
87
+ const input = new Input({});
88
+ const el = holderFor(input);
89
+
90
+ await input.componentDidMount();
91
+
92
+ expect(fieldCount(el)).toBe(1);
93
+ });
94
+
95
+ it('repeated mounts never accumulate fields', async () => {
96
+ const input = new Input({});
97
+ const el = holderFor(input);
98
+
99
+ await input.componentDidMount();
100
+ await input.componentDidMount();
101
+ await input.componentDidMount();
102
+
103
+ expect(fieldCount(el)).toBe(1);
104
+ });
105
+
106
+ // MathLive's mathModeSpace defaults to '', which makes the spacebar a no-op.
107
+ it('configures the spacebar so a space can be typed', async () => {
108
+ const input = new Input({});
109
+
110
+ holderFor(input);
111
+ await input.componentDidMount();
112
+
113
+ // a LaTeX control space - what MathQuill produced, and MathJax understands
114
+ expect(input.mathField.mathModeSpace).toEqual('\\ ');
115
+ expect(input.mathField.mathModeSpace).not.toEqual('');
116
+ });
117
+
118
+ // MathLive leaves a superscript after one digit by default (`smartSuperscript`),
119
+ // so x^23 / 10^6 cannot be typed.
120
+ it('stays in the exponent after a digit instead of jumping out', async () => {
121
+ const input = new Input({});
122
+
123
+ holderFor(input);
124
+ await input.componentDidMount();
125
+
126
+ expect(input.mathField.smartSuperscript).toBe(false);
127
+ });
128
+
129
+ it('reports itself through innerRef on mount and clears it on unmount', async () => {
130
+ const innerRef = jest.fn();
131
+ const input = new Input({ innerRef });
132
+
133
+ holderFor(input);
134
+ await input.componentDidMount();
135
+
136
+ expect(innerRef).toHaveBeenCalledWith(input);
137
+
138
+ input.componentWillUnmount();
139
+
140
+ expect(innerRef).toHaveBeenLastCalledWith(null);
141
+ });
142
+ });