@pie-lib/math-input-mathlive 1.1.4 → 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/README.md +14 -5
  2. package/lib/__tests__/composites.test.js +255 -0
  3. package/lib/__tests__/drop-in-compat.test.js +133 -0
  4. package/lib/__tests__/embedded-fonts.test.js +121 -0
  5. package/lib/__tests__/index.test.js +118 -0
  6. package/lib/__tests__/input-imperative.test.js +155 -0
  7. package/lib/__tests__/keypad-label.test.js +231 -0
  8. package/lib/__tests__/keypad-render.test.js +206 -0
  9. package/lib/__tests__/latex-bridge.test.js +386 -0
  10. package/lib/__tests__/mathlive-instance.test.js +419 -0
  11. package/lib/__tests__/mount-race.test.js +218 -0
  12. package/lib/__tests__/static-prompts.test.js +405 -0
  13. package/lib/__tests__/tmp-markup.test.js +26 -0
  14. package/lib/keypad/index.js +50 -28
  15. package/lib/keypad/index.js.map +1 -1
  16. package/lib/keys/geometry.js +7 -4
  17. package/lib/keys/geometry.js.map +1 -1
  18. package/lib/keys/misc.js +9 -2
  19. package/lib/keys/misc.js.map +1 -1
  20. package/lib/latex-bridge.js +207 -7
  21. package/lib/latex-bridge.js.map +1 -1
  22. package/lib/mathlive-instance.js +176 -12
  23. package/lib/mathlive-instance.js.map +1 -1
  24. package/lib/mf/common-styles.js +89 -10
  25. package/lib/mf/common-styles.js.map +1 -1
  26. package/lib/mf/index.js +12 -0
  27. package/lib/mf/index.js.map +1 -1
  28. package/lib/mf/input.js +34 -5
  29. package/lib/mf/input.js.map +1 -1
  30. package/lib/mf/static.js +107 -7
  31. package/lib/mf/static.js.map +1 -1
  32. package/package.json +3 -4
  33. package/src/__tests__/composites.test.jsx +220 -0
  34. package/src/__tests__/keypad-label.test.jsx +229 -2
  35. package/src/__tests__/keypad-render.test.jsx +123 -0
  36. package/src/__tests__/latex-bridge.test.js +159 -0
  37. package/src/__tests__/mathlive-instance.test.js +237 -0
  38. package/src/__tests__/mount-race.test.jsx +142 -0
  39. package/src/__tests__/static-prompts.test.jsx +364 -0
  40. package/src/keypad/index.jsx +33 -17
  41. package/src/keys/geometry.js +7 -2
  42. package/src/keys/misc.js +8 -2
  43. package/src/latex-bridge.js +223 -7
  44. package/src/mathlive-instance.js +196 -11
  45. package/src/mf/common-styles.js +97 -8
  46. package/src/mf/index.js +4 -0
  47. package/src/mf/input.jsx +42 -5
  48. package/src/mf/static.jsx +124 -6
  49. package/CHANGELOG.md +0 -30
  50. package/LICENSE.md +0 -5
@@ -0,0 +1,123 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import KeyPad, { LatexLabel } from '../keypad';
5
+
6
+ /**
7
+ * Keypad behaviour that is new in this package: labels are static markup from
8
+ * convertLatexToMarkup (the MathQuill version created a live StaticMath per
9
+ * button), with a raw-latex fallback when the engine is unavailable.
10
+ */
11
+ describe('LatexLabel', () => {
12
+ it('falls back to raw latex when MathLive is unavailable', () => {
13
+ // jsdom cannot load MathLive, so latexToMarkup returns '' - the button must
14
+ // still show something rather than render blank.
15
+ const { container } = render(<LatexLabel latex={'\\pi'} />);
16
+
17
+ expect(container.textContent).toContain('\\pi');
18
+ });
19
+
20
+ it('re-renders when the latex prop changes', () => {
21
+ const { container, rerender } = render(<LatexLabel latex={'\\pi'} />);
22
+
23
+ rerender(<LatexLabel latex={'\\theta'} />);
24
+
25
+ expect(container.textContent).toContain('\\theta');
26
+ });
27
+
28
+ it('does not warn more than once about a missing engine', async () => {
29
+ LatexLabel.warned = false;
30
+
31
+ const a = new LatexLabel({ latex: '\\pi' });
32
+ const b = new LatexLabel({ latex: '\\theta' });
33
+
34
+ a.state = { markup: '' };
35
+ b.state = { markup: '' };
36
+ a.setState = () => {};
37
+ b.setState = () => {};
38
+
39
+ await a.componentDidMount();
40
+ await b.componentDidMount();
41
+
42
+ expect(LatexLabel.warned).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe('KeyPad', () => {
47
+ const key = (over = {}) => ({ name: 'x', label: 'x', write: 'x', category: 'vars', ...over });
48
+
49
+ it('renders a button per key', () => {
50
+ const { container } = render(<KeyPad onPress={() => {}} baseSet={[[key(), key({ name: 'y', label: 'y' })]]} />);
51
+
52
+ expect(container.querySelectorAll('button').length).toBe(2);
53
+ });
54
+
55
+ it('calls onPress with the whole key definition', () => {
56
+ const onPress = jest.fn();
57
+ const k = key();
58
+ const { container } = render(<KeyPad onPress={onPress} baseSet={[[k]]} />);
59
+
60
+ container.querySelector('button').click();
61
+
62
+ expect(onPress).toHaveBeenCalledWith(k);
63
+ });
64
+
65
+ it('renders a latex key through LatexLabel', () => {
66
+ const { container } = render(<KeyPad onPress={() => {}} baseSet={[[{ name: 'pi', latex: '\\pi' }]]} />);
67
+
68
+ expect(container.textContent).toContain('\\pi');
69
+ });
70
+
71
+ it('disables the decimal separators when noDecimal is set', () => {
72
+ const dot = key({ name: '.', label: '.', write: '.' });
73
+ const comma = key({ name: ',', label: ',', write: ',' });
74
+ const { container } = render(<KeyPad onPress={() => {}} noDecimal baseSet={[[dot, comma]]} />);
75
+
76
+ const disabled = Array.from(container.querySelectorAll('button')).filter((b) => b.disabled);
77
+
78
+ expect(disabled.length).toBe(2);
79
+ });
80
+
81
+ it('leaves the separators enabled otherwise', () => {
82
+ const dot = key({ name: '.', label: '.', write: '.' });
83
+ const { container } = render(<KeyPad onPress={() => {}} baseSet={[[dot]]} />);
84
+
85
+ expect(container.querySelector('button').disabled).toBe(false);
86
+ });
87
+
88
+ it('renders a placeholder for empty grid slots', () => {
89
+ expect(() => render(<KeyPad onPress={() => {}} baseSet={[[null]]} />)).not.toThrow();
90
+ });
91
+
92
+ it('notifies setKeypadInteraction on pointer interaction', () => {
93
+ const setKeypadInteraction = jest.fn();
94
+ const { container } = render(
95
+ <KeyPad onPress={() => {}} setKeypadInteraction={setKeypadInteraction} baseSet={[[key()]]} />,
96
+ );
97
+
98
+ container.firstChild.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
99
+
100
+ expect(setKeypadInteraction).toHaveBeenCalledWith(true);
101
+ });
102
+
103
+ it('uses the mode as a class so mode-specific styling applies', () => {
104
+ const { container } = render(<KeyPad onPress={() => {}} mode="language" baseSet={[[key()]]} />);
105
+
106
+ expect(container.firstChild.className).toContain('language');
107
+ });
108
+
109
+ it('drops the base set for the modes that supply their own keys', () => {
110
+ const { container } = render(
111
+ <KeyPad onPress={() => {}} mode="language" baseSet={[[key(), key()]]} additionalKeys={[[key()]]} />,
112
+ );
113
+
114
+ // base set ignored, only the additional key renders
115
+ expect(container.querySelectorAll('button').length).toBe(1);
116
+ });
117
+
118
+ it('unmounts without throwing', () => {
119
+ const { unmount } = render(<KeyPad onPress={() => {}} controlledKeypadMode baseSet={[[key()]]} />);
120
+
121
+ expect(() => unmount()).not.toThrow();
122
+ });
123
+ });
@@ -4,6 +4,8 @@ import {
4
4
  fieldIds,
5
5
  keyToAction,
6
6
  toNativeCommands,
7
+ toCanonicalCommands,
8
+ MATHQUILL_ALIASES,
7
9
  NEWLINE_EMBED,
8
10
  DEFAULT_FIELD_ID,
9
11
  } from '../latex-bridge';
@@ -133,6 +135,28 @@ describe('latex-bridge', () => {
133
135
  expect(toNativeCommands('\\longdiv{1}\\longdiv{2}')).toEqual('\\enclose{longdiv}{1}\\enclose{longdiv}{2}');
134
136
  });
135
137
 
138
+ // `%` starts a comment in real LaTeX, so MathLive drops it and everything
139
+ // after it. MathQuill had no comments, so latex authored under it - or by
140
+ // hand - can carry a bare `%` that would silently truncate the expression.
141
+ it('escapes a bare % so it cannot comment out the rest of the line', () => {
142
+ expect(toNativeCommands('%')).toEqual('\\%');
143
+ expect(toNativeCommands('50%')).toEqual('50\\%');
144
+ expect(toNativeCommands('x%y')).toEqual('x\\%y');
145
+ expect(toNativeCommands('a%b%c')).toEqual('a\\%b\\%c');
146
+ });
147
+
148
+ it('leaves an already-escaped % alone', () => {
149
+ expect(toNativeCommands('\\%')).toEqual('\\%');
150
+ expect(toNativeCommands('50\\%')).toEqual('50\\%');
151
+ // idempotent: toMathLive may run over latex that came back out of a field
152
+ expect(toNativeCommands(toNativeCommands('50%'))).toEqual('50\\%');
153
+ });
154
+
155
+ // `\\` is a line break, not an escape, so the `%` after it is still bare.
156
+ it('counts backslashes to decide whether a % is escaped', () => {
157
+ expect(toNativeCommands('a\\\\%b')).toEqual('a\\\\\\%b');
158
+ });
159
+
136
160
  it('leaves other latex untouched', () => {
137
161
  ['\\frac{1}{2}', '\\pi', '\\sqrt{x}', '\\overline{AB}'].forEach((l) => {
138
162
  expect(toNativeCommands(l)).toEqual(l);
@@ -175,6 +199,120 @@ describe('latex-bridge', () => {
175
199
  });
176
200
  });
177
201
 
202
+ // MathQuill registered some keypad names as aliases of a different command:
203
+ // the divide key stored `\div`, not `\divide`. MathLive knows `\divide` only as
204
+ // a pie macro and serialises it under that name, so it reached storage and
205
+ // MathJax (which has no `\divide`) rendered it as red text.
206
+ describe('MathQuill command aliases', () => {
207
+ it('the divide and perpendicular keys insert the command MathQuill stored', () => {
208
+ /* eslint-disable global-require */
209
+ const { divide } = require('../keys/basic-operators');
210
+ // Exported under a pre-existing misspelling.
211
+ const { perpindicular: perpendicular } = require('../keys/geometry');
212
+ /* eslint-enable global-require */
213
+
214
+ expect(keyToAction(divide)).toEqual({ type: 'insert', value: '\\div' });
215
+ expect(keyToAction(perpendicular)).toEqual({ type: 'insert', value: '\\perp' });
216
+ });
217
+
218
+ it('stores the canonical command for latex that still carries an alias', () => {
219
+ // Typed into the field, or saved before the keypad resolved the alias.
220
+ expect(fromMathLive('1\\divide 6')).toEqual('1\\div 6');
221
+ expect(fromMathLive('a\\perpendicular b')).toEqual('a\\perp b');
222
+ });
223
+
224
+ it('only replaces whole command names', () => {
225
+ // \divideontimes is a real AMS symbol and must survive.
226
+ expect(toCanonicalCommands('a\\divideontimes b')).toEqual('a\\divideontimes b');
227
+ expect(toCanonicalCommands('\\divide\\divide')).toEqual('\\div\\div');
228
+ });
229
+
230
+ // The double prime key: MathLive serialises `5''` as `5^{\doubleprime}`,
231
+ // which MathJax cannot render (red `\doubleprime`). MathQuill stored `5''`.
232
+ it('stores the double prime key as apostrophes', () => {
233
+ expect(fromMathLive('5^{\\doubleprime}')).toEqual("5''");
234
+ expect(fromMathLive('x^{\\prime}')).toEqual("x'");
235
+ expect(fromMathLive('x^{\\prime\\prime\\prime}')).toEqual("x'''");
236
+ expect(fromMathLive('\\doubleprime')).toEqual("''");
237
+ });
238
+
239
+ // Typing `'` twice yields two superscript groups - a MathJax double-exponent
240
+ // error - so they collapse into apostrophes too.
241
+ it('collapses adjacent prime superscripts', () => {
242
+ expect(fromMathLive('x^{\\prime}^{\\prime}')).toEqual("x''");
243
+ expect(fromMathLive('5^{\\prime}^{\\doubleprime}')).toEqual("5'''");
244
+ });
245
+
246
+ it('leaves superscripts that hold more than primes alone', () => {
247
+ expect(fromMathLive('x^{\\prime 2}')).toEqual('x^{\\prime 2}');
248
+ expect(fromMathLive('x^{2}')).toEqual('x^{2}');
249
+ });
250
+
251
+ it('round-trips stored primes unchanged', () => {
252
+ expect(fromMathLive("12^{\\circ}30'15''")).toEqual("12^{\\circ}30'15''");
253
+ });
254
+
255
+ // `5′6″` typed or pasted as characters serialises as bare commands. A bare
256
+ // `\prime` is full size in MathJax while `''` is a superscript, so storing
257
+ // `5\prime6''` drew the single prime larger than each stroke of the double.
258
+ it('stores bare primes as apostrophes, so single and double match', () => {
259
+ expect(fromMathLive('5\\prime6\\doubleprime')).toEqual("5'6''");
260
+ expect(fromMathLive('5\\prime 6\\doubleprime')).toEqual("5' 6''");
261
+ expect(fromMathLive('\\prime')).toEqual("'");
262
+ expect(fromMathLive('\\frac{5\\prime}{2}')).toEqual("\\frac{5'}{2}");
263
+ });
264
+
265
+ // Inside a script a prime is already a superscript; an apostrophe there
266
+ // would be a prime *of* the script.
267
+ it('keeps primes that are inside a superscript or subscript', () => {
268
+ expect(fromMathLive('x^{\\prime 2}')).toEqual('x^{\\prime 2}');
269
+ expect(fromMathLive('x^\\prime')).toEqual('x^\\prime');
270
+ expect(fromMathLive('x_{\\prime}')).toEqual('x_{\\prime}');
271
+ // MathJax has no \doubleprime: spell it out rather than leave red text
272
+ expect(fromMathLive('x^{\\doubleprime 2}')).toEqual('x^{\\prime\\prime 2}');
273
+ });
274
+
275
+ it('closes the script scope at the matching brace', () => {
276
+ expect(fromMathLive('x^{2}\\prime')).toEqual("x^{2}'");
277
+ expect(fromMathLive('x^{\\frac{a}{b}}6\\doubleprime')).toEqual("x^{\\frac{a}{b}}6''");
278
+ });
279
+
280
+ it('does not touch commands that merely start with "prime"', () => {
281
+ expect(fromMathLive('\\primes')).toEqual('\\primes');
282
+ expect(fromMathLive('\\backprime')).toEqual('\\backprime');
283
+ });
284
+
285
+ it('no keypad key inserts an alias name', () => {
286
+ /* eslint-disable global-require */
287
+ const fs = require('fs');
288
+ const path = require('path');
289
+ /* eslint-enable global-require */
290
+ const keysDir = path.resolve(__dirname, '../keys');
291
+ const keys = [];
292
+
293
+ const collect = (v) => {
294
+ if (Array.isArray(v)) return v.forEach(collect);
295
+ if (v && typeof v === 'object' && (v.command || v.latex || v.write)) keys.push(v);
296
+ };
297
+
298
+ fs.readdirSync(keysDir)
299
+ .filter((f) => /\.js$/.test(f))
300
+ // eslint-disable-next-line global-require, import/no-dynamic-require
301
+ .forEach((f) => Object.values(require(path.join(keysDir, f))).forEach(collect));
302
+
303
+ expect(keys.length).toBeGreaterThan(50);
304
+
305
+ keys.forEach((k) => {
306
+ const action = keyToAction(k);
307
+ const value = (action && action.value) || '';
308
+
309
+ Object.keys(MATHQUILL_ALIASES).forEach((alias) => {
310
+ expect(new RegExp(`\\${alias}(?![a-zA-Z])`).test(value)).toBe(false);
311
+ });
312
+ });
313
+ });
314
+ });
315
+
178
316
  describe('keyToAction', () => {
179
317
  it('prefers latex when there is no command', () => {
180
318
  expect(keyToAction({ latex: '\\pi', write: 'p' })).toEqual({ type: 'insert', value: '\\pi' });
@@ -190,6 +328,27 @@ describe('latex-bridge', () => {
190
328
  expect(keyToAction({ command: '^' })).toEqual({ type: 'insert', value: '^{#?}' });
191
329
  });
192
330
 
331
+ // MathQuill's `/` turned the preceding expression into the numerator: type
332
+ // 5, press the key, get 5/_ with the caret in the denominator. `#@` is
333
+ // MathLive's "selection, or the item before the cursor" token, and is what
334
+ // MathLive binds to `/` itself.
335
+ it('division takes the preceding item as the numerator', () => {
336
+ expect(keyToAction({ command: '/' })).toEqual({ type: 'insert', value: '\\frac{#@}{#?}' });
337
+ });
338
+
339
+ it('an explicit blank-over-blank fraction stays empty', () => {
340
+ // distinct from `/`: this key is meant to insert two empty slots
341
+ expect(keyToAction({ command: '\\frac' })).toEqual({ type: 'insert', value: '\\frac{#?}{#?}' });
342
+ });
343
+
344
+ it('the x-over-blank key routes through division', () => {
345
+ // eslint-disable-next-line global-require
346
+ const { xOverBlank } = require('../keys/fractions');
347
+
348
+ expect(xOverBlank.command).toEqual('/');
349
+ expect(keyToAction(xOverBlank).value).toEqual('\\frac{#@}{#?}');
350
+ });
351
+
193
352
  it('passes argument-less commands through', () => {
194
353
  expect(keyToAction({ command: '\\pi' })).toEqual({ type: 'insert', value: '\\pi' });
195
354
  });
@@ -150,6 +150,46 @@ describe('mathlive-instance', () => {
150
150
  expect(MathfieldElement.fontsDirectory.startsWith('http')).toBe(true);
151
151
  });
152
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
+
153
193
  it('leaves a fully-qualified url untouched', () => {
154
194
  const MathfieldElement = {};
155
195
 
@@ -169,6 +209,203 @@ describe('mathlive-instance', () => {
169
209
  });
170
210
  });
171
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
+
172
409
  describe('latexToMarkup', () => {
173
410
  it('returns empty string when MathLive is not loaded', () => {
174
411
  expect(instance.latexToMarkup('\\pi')).toEqual('');