@pie-lib/math-input-mathlive 1.0.9-next.6637 → 1.0.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 (51) hide show
  1. package/README.md +14 -5
  2. package/lib/__tests__/composites.test.js +255 -0
  3. package/lib/__tests__/composites.test.js.map +1 -0
  4. package/lib/__tests__/drop-in-compat.test.js +133 -0
  5. package/lib/__tests__/drop-in-compat.test.js.map +1 -0
  6. package/lib/__tests__/index.test.js +118 -0
  7. package/lib/__tests__/index.test.js.map +1 -0
  8. package/lib/__tests__/input-imperative.test.js +155 -0
  9. package/lib/__tests__/input-imperative.test.js.map +1 -0
  10. package/lib/__tests__/keypad-label.test.js +118 -0
  11. package/lib/__tests__/keypad-label.test.js.map +1 -0
  12. package/lib/__tests__/keypad-render.test.js +206 -0
  13. package/lib/__tests__/keypad-render.test.js.map +1 -0
  14. package/lib/__tests__/latex-bridge.test.js +280 -0
  15. package/lib/__tests__/latex-bridge.test.js.map +1 -0
  16. package/lib/__tests__/mathlive-instance.test.js +279 -0
  17. package/lib/__tests__/mathlive-instance.test.js.map +1 -0
  18. package/lib/__tests__/mount-race.test.js +195 -0
  19. package/lib/__tests__/mount-race.test.js.map +1 -0
  20. package/lib/__tests__/static-prompts.test.js +282 -0
  21. package/lib/__tests__/static-prompts.test.js.map +1 -0
  22. package/lib/keypad/index.js +11 -13
  23. package/lib/keypad/index.js.map +1 -1
  24. package/lib/keys/geometry.js +7 -4
  25. package/lib/keys/geometry.js.map +1 -1
  26. package/lib/latex-bridge.js +18 -3
  27. package/lib/latex-bridge.js.map +1 -1
  28. package/lib/mathlive-instance.js +103 -11
  29. package/lib/mathlive-instance.js.map +1 -1
  30. package/lib/mf/common-styles.js +33 -4
  31. package/lib/mf/common-styles.js.map +1 -1
  32. package/lib/mf/input.js +22 -5
  33. package/lib/mf/input.js.map +1 -1
  34. package/lib/mf/static.js +21 -4
  35. package/lib/mf/static.js.map +1 -1
  36. package/package.json +3 -4
  37. package/src/__tests__/composites.test.jsx +220 -0
  38. package/src/__tests__/keypad-label.test.jsx +81 -14
  39. package/src/__tests__/keypad-render.test.jsx +123 -0
  40. package/src/__tests__/latex-bridge.test.js +21 -0
  41. package/src/__tests__/mathlive-instance.test.js +87 -0
  42. package/src/__tests__/mount-race.test.jsx +130 -0
  43. package/src/__tests__/static-prompts.test.jsx +251 -0
  44. package/src/keypad/index.jsx +11 -13
  45. package/src/keys/geometry.js +7 -2
  46. package/src/latex-bridge.js +18 -3
  47. package/src/mathlive-instance.js +111 -11
  48. package/src/mf/common-styles.js +33 -4
  49. package/src/mf/input.jsx +23 -5
  50. package/src/mf/static.jsx +24 -4
  51. package/LICENSE.md +0 -5
@@ -0,0 +1,220 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import MathInput from '../math-input';
5
+ import HorizontalKeypad from '../horizontal-keypad';
6
+ import Input from '../mf/input';
7
+
8
+ /**
9
+ * The pieces that sit between the keypad and the field: how a pressed key is
10
+ * routed into the field, and the legacy `{value,type}` shape math-toolbar
11
+ * consumes.
12
+ */
13
+ describe('MathInput.keypadPress routing', () => {
14
+ const withInput = () => {
15
+ const mi = new MathInput({});
16
+ const calls = { write: [], keystroke: [] };
17
+
18
+ mi.input = {
19
+ write: (v) => calls.write.push(v),
20
+ keystroke: (v) => calls.keystroke.push(v),
21
+ };
22
+
23
+ return { mi, calls };
24
+ };
25
+
26
+ it('routes an insert action to write()', () => {
27
+ const { mi, calls } = withInput();
28
+
29
+ mi.keypadPress({ latex: '\\pi' });
30
+
31
+ expect(calls.write).toEqual(['\\pi']);
32
+ expect(calls.keystroke).toEqual([]);
33
+ });
34
+
35
+ it('expands an argument-taking command before writing', () => {
36
+ const { mi, calls } = withInput();
37
+
38
+ mi.keypadPress({ command: '\\frac' });
39
+
40
+ expect(calls.write).toEqual(['\\frac{#?}{#?}']);
41
+ });
42
+
43
+ it('routes a cursor keystroke to keystroke()', () => {
44
+ const { mi, calls } = withInput();
45
+
46
+ mi.keypadPress({ keystroke: 'Left' });
47
+
48
+ expect(calls.keystroke).toEqual(['moveToPreviousChar']);
49
+ expect(calls.write).toEqual([]);
50
+ });
51
+
52
+ it('ignores keys with no action', () => {
53
+ const { mi, calls } = withInput();
54
+
55
+ mi.keypadPress({});
56
+ mi.keypadPress({ keystroke: 'Nonsense' });
57
+
58
+ expect(calls.write).toEqual([]);
59
+ expect(calls.keystroke).toEqual([]);
60
+ });
61
+
62
+ it('is a no-op before the field ref exists', () => {
63
+ const mi = new MathInput({});
64
+
65
+ expect(() => mi.keypadPress({ latex: '\\pi' })).not.toThrow();
66
+ });
67
+
68
+ it('changeLatex only fires onChange when the value differs', () => {
69
+ const onChange = jest.fn();
70
+ const mi = new MathInput({ latex: 'x', onChange });
71
+
72
+ mi.changeLatex('x');
73
+ expect(onChange).not.toHaveBeenCalled();
74
+
75
+ mi.changeLatex('y');
76
+ expect(onChange).toHaveBeenCalledWith('y');
77
+ });
78
+
79
+ it('tracks focus state', () => {
80
+ const mi = new MathInput({});
81
+
82
+ mi.setState = (s) => Object.assign(mi.state, s);
83
+ mi.inputFocus();
84
+ expect(mi.state.focused).toBe(true);
85
+
86
+ mi.inputBlur();
87
+ expect(mi.state.focused).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe('HorizontalKeypad legacy {value,type} model', () => {
92
+ const pressed = (key) => {
93
+ const onClick = jest.fn();
94
+ const hk = new HorizontalKeypad({ onClick });
95
+
96
+ hk.keypadPress(key);
97
+
98
+ return onClick.mock.calls[0][0];
99
+ };
100
+
101
+ it('maps a command to {value, type: command}', () => {
102
+ expect(pressed({ command: '\\frac' })).toEqual({ value: '\\frac', type: 'command' });
103
+ });
104
+
105
+ it('maps a write to {value}', () => {
106
+ expect(pressed({ write: 'x' })).toEqual({ value: 'x' });
107
+ });
108
+
109
+ it('maps a keystroke to {type: cursor}', () => {
110
+ expect(pressed({ keystroke: 'Left' })).toEqual({ type: 'cursor', value: 'Left' });
111
+ });
112
+
113
+ it('renders without throwing', () => {
114
+ expect(() => render(<HorizontalKeypad onClick={() => {}} />)).not.toThrow();
115
+ });
116
+ });
117
+
118
+ describe('Input: controlled-value sync and events', () => {
119
+ const withField = () => {
120
+ const input = new Input({});
121
+
122
+ input.mathField = {
123
+ value: '',
124
+ getValue: () => input.mathField.value,
125
+ insert(v) {
126
+ input.mathField.value += v;
127
+ },
128
+ executeCommand() {},
129
+ focus() {},
130
+ blur() {},
131
+ addEventListener() {},
132
+ removeEventListener() {},
133
+ remove() {},
134
+ };
135
+
136
+ return input;
137
+ };
138
+
139
+ it('emits stored-form latex on edit', () => {
140
+ const onChange = jest.fn();
141
+ const input = withField();
142
+
143
+ input.props = { onChange };
144
+ input.mathField.value = '\\longdiv{\\placeholder{}}';
145
+ input.onInputEdit();
146
+
147
+ // \placeholder is MathLive-internal and must not reach the model
148
+ expect(onChange).toHaveBeenCalledWith('\\longdiv{}');
149
+ expect(input.lastEmitted).toEqual('\\longdiv{}');
150
+ });
151
+
152
+ it('does not push a prop echo back into the field', () => {
153
+ const input = withField();
154
+
155
+ input.mathField.value = 'x';
156
+ input.lastEmitted = 'x';
157
+ input.props = { latex: 'x' };
158
+
159
+ input.componentDidUpdate({ latex: '' });
160
+
161
+ expect(input.mathField.value).toEqual('x');
162
+ });
163
+
164
+ it('pushes a genuinely new latex prop into the field', () => {
165
+ const input = withField();
166
+
167
+ input.mathField.value = 'x';
168
+ input.lastEmitted = 'x';
169
+ input.props = { latex: 'y' };
170
+
171
+ input.componentDidUpdate({ latex: 'x' });
172
+
173
+ expect(input.mathField.value).toEqual('y');
174
+ });
175
+
176
+ it('bridges stored latex on the way in', () => {
177
+ const input = withField();
178
+
179
+ input.props = { latex: '\\longdiv{5}' };
180
+ input.componentDidUpdate({ latex: '' });
181
+
182
+ // must become native \enclose so the content stays editable
183
+ expect(input.mathField.value).toEqual('\\enclose{longdiv}{5}');
184
+ });
185
+
186
+ it('toggles readOnly', () => {
187
+ const input = withField();
188
+
189
+ input.props = { readOnly: true };
190
+ input.componentDidUpdate({ readOnly: false });
191
+
192
+ expect(input.mathField.readOnly).toBe(true);
193
+ });
194
+
195
+ it('clear() empties the field', () => {
196
+ const input = withField();
197
+
198
+ input.mathField.value = 'abc';
199
+ expect(input.clear()).toEqual('');
200
+ expect(input.mathField.value).toEqual('');
201
+ });
202
+
203
+ it('suppresses Enter so it cannot insert a line break', () => {
204
+ const input = withField();
205
+ const preventDefault = jest.fn();
206
+
207
+ input.onKeyDown({ key: 'Enter', preventDefault });
208
+ expect(preventDefault).toHaveBeenCalled();
209
+
210
+ preventDefault.mockClear();
211
+ input.onKeyDown({ key: 'a', preventDefault });
212
+ expect(preventDefault).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('componentDidUpdate is safe before the field exists', () => {
216
+ const input = new Input({ latex: 'x' });
217
+
218
+ expect(() => input.componentDidUpdate({ latex: '' })).not.toThrow();
219
+ });
220
+ });
@@ -11,24 +11,18 @@ describe('keypad label display normalisation', () => {
11
11
  const out = latexForDisplay('\\frac{}{}');
12
12
 
13
13
  expect(out).not.toEqual('\\frac{}{}');
14
- expect(out).toContain('25AB'); // white small square
14
+ // a \rule, not a glyph: font metrics for a substituted glyph do not match
15
+ // MathLive's, which left stretchy accents narrower than the box
16
+ expect(out).toContain('\\rule');
15
17
  expect(out).toContain('pie-empty');
16
18
  });
17
19
 
18
20
  it('handles every empty-group key shape', () => {
19
- [
20
- '\\frac{}{}',
21
- '\\overline{}',
22
- 'x^{}',
23
- 'x_{}',
24
- '\\sqrt{}',
25
- '\\sqrt[{}]{}',
26
- '\\abs{}',
27
- '\\log_{}',
28
- '\\longdiv{}',
29
- ].forEach((latex) => {
30
- expect(latexForDisplay(latex)).not.toContain('{}');
31
- });
21
+ ['\\frac{}{}', '\\overline{}', 'x^{}', 'x_{}', '\\sqrt{}', '\\sqrt[{}]{}', '\\abs{}', '\\log_{}', '\\longdiv{}'].forEach(
22
+ (latex) => {
23
+ expect(latexForDisplay(latex)).not.toContain('{}');
24
+ },
25
+ );
32
26
  });
33
27
 
34
28
  it('treats a whitespace-only group as empty', () => {
@@ -36,6 +30,79 @@ describe('keypad label display normalisation', () => {
36
30
  expect(latexForDisplay('\\frac{x}{ }')).toContain('pie-empty');
37
31
  });
38
32
 
33
+ // MathQuill needed a nested \overline{} to draw the empty box. MathLive
34
+ // renders that as a real line, so those labels showed a spurious bar (a line
35
+ // under the arrows, and a straight line instead of the arc).
36
+ it('accent keys do not carry the nested-overline box hack', () => {
37
+ // eslint-disable-next-line global-require
38
+ const geometry = require('../keys/geometry');
39
+
40
+ Object.values(geometry).forEach((key) => {
41
+ if (key && typeof key.latex === 'string') {
42
+ expect(key.latex).not.toContain('\\overline{}}');
43
+ }
44
+ });
45
+ });
46
+
47
+ it('an accent over an empty slot still gets a visible box', () => {
48
+ ['\\overleftrightarrow{}', '\\overarc{}'].forEach((latex) => {
49
+ const out = latexForDisplay(latex);
50
+
51
+ expect(out).toContain('pie-empty');
52
+ expect(out).not.toContain('\\overline');
53
+ });
54
+ });
55
+
56
+ // A glyph's width comes from font metrics; U+25AB is not in the KaTeX fonts,
57
+ // so the browser substituted a wider one and stretchy accents came out
58
+ // narrower than the box. A rule has an explicit width.
59
+ it('the empty slot has an explicit width, not font-dependent metrics', () => {
60
+ const out = latexForDisplay('\\overarc{}');
61
+
62
+ expect(out).toContain('\\rule');
63
+ expect(out).toMatch(/rule\{[\d.]+em\}\{[\d.]+em\}/);
64
+ expect(out).not.toContain('unicode');
65
+ });
66
+
67
+ // Measured in the browser: the accent's immediate wrapper computes to 0px
68
+ // (its .ML__stretchy child is absolutely positioned), so the svg width has to
69
+ // resolve against .ML__base - whose width equals the content. Without pinning
70
+ // that, the arc collapsed to a spike and sat right of the box.
71
+ it('pins the accent containing block and drops the centering shift', () => {
72
+ // eslint-disable-next-line global-require
73
+ const { placeholderStyles } = require('../mf/common-styles');
74
+
75
+ expect(placeholderStyles['& .ML__base'].position).toEqual('relative');
76
+ expect(placeholderStyles['& .ML__stretchy']).toEqual({ position: 'absolute', left: 0, width: '100%' });
77
+ // must be !important: MathLive writes margin-left as an inline style, and a
78
+ // class cannot override that. Leaving it un-important also halved the arc's
79
+ // width, because .ML__center is the stretchy's containing block.
80
+ expect(placeholderStyles['& .ML__center:has(.ML__stretchy)'].marginLeft).toEqual('0 !important');
81
+ });
82
+
83
+ it('applies those rules wherever static math renders', () => {
84
+ // eslint-disable-next-line global-require
85
+ const cs = require('../mf/common-styles');
86
+
87
+ ['commonKeyboardStyles', 'commonMathLiveStyles'].forEach((name) => {
88
+ expect(cs[name]['& .ML__stretchy']).toBeDefined();
89
+ expect(cs[name]['& .ML__base']).toBeDefined();
90
+ });
91
+ });
92
+
93
+ // A bare `& svg` rule re-parents every svg in the subtree; MathLive's own rule
94
+ // is descendant-scoped to .ML__latex .ML__stretchy svg.
95
+ it('does not use a blanket svg rule', () => {
96
+ // eslint-disable-next-line global-require
97
+ const cs = require('../mf/common-styles');
98
+
99
+ Object.values(cs).forEach((style) => {
100
+ if (style && typeof style === 'object') {
101
+ expect(Object.keys(style)).not.toContain('& svg');
102
+ }
103
+ });
104
+ });
105
+
39
106
  it('leaves latex without empty groups alone', () => {
40
107
  ['\\pi', '\\sin', 'x^2', '\\overline{AB}'].forEach((latex) => {
41
108
  expect(latexForDisplay(latex)).toEqual(latex);
@@ -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
+ });
@@ -190,6 +190,27 @@ describe('latex-bridge', () => {
190
190
  expect(keyToAction({ command: '^' })).toEqual({ type: 'insert', value: '^{#?}' });
191
191
  });
192
192
 
193
+ // MathQuill's `/` turned the preceding expression into the numerator: type
194
+ // 5, press the key, get 5/_ with the caret in the denominator. `#@` is
195
+ // MathLive's "selection, or the item before the cursor" token, and is what
196
+ // MathLive binds to `/` itself.
197
+ it('division takes the preceding item as the numerator', () => {
198
+ expect(keyToAction({ command: '/' })).toEqual({ type: 'insert', value: '\\frac{#@}{#?}' });
199
+ });
200
+
201
+ it('an explicit blank-over-blank fraction stays empty', () => {
202
+ // distinct from `/`: this key is meant to insert two empty slots
203
+ expect(keyToAction({ command: '\\frac' })).toEqual({ type: 'insert', value: '\\frac{#?}{#?}' });
204
+ });
205
+
206
+ it('the x-over-blank key routes through division', () => {
207
+ // eslint-disable-next-line global-require
208
+ const { xOverBlank } = require('../keys/fractions');
209
+
210
+ expect(xOverBlank.command).toEqual('/');
211
+ expect(keyToAction(xOverBlank).value).toEqual('\\frac{#@}{#?}');
212
+ });
213
+
193
214
  it('passes argument-less commands through', () => {
194
215
  expect(keyToAction({ command: '\\pi' })).toEqual({ type: 'insert', value: '\\pi' });
195
216
  });
@@ -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,53 @@ 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
+ describe('applyShadowStyles', () => {
215
+ const host = () => {
216
+ const el = document.createElement('div');
217
+
218
+ el.attachShadow({ mode: 'open' });
219
+
220
+ return el;
221
+ };
222
+
223
+ it('is a no-op without a shadow root', () => {
224
+ expect(() => instance.applyShadowStyles(undefined)).not.toThrow();
225
+ expect(() => instance.applyShadowStyles(document.createElement('div'))).not.toThrow();
226
+ });
227
+
228
+ it('injects the accent fix into the shadow root', () => {
229
+ const el = host();
230
+
231
+ instance.applyShadowStyles(el);
232
+
233
+ const viaSheet = el.shadowRoot.__pieAccentSheet;
234
+ const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
235
+ const css = viaSheet ? 'adopted' : (viaStyle && viaStyle.textContent) || '';
236
+
237
+ expect(viaSheet || viaStyle).toBeTruthy();
238
+
239
+ if (viaStyle) {
240
+ expect(css).toContain('margin-left:0 !important');
241
+ // scoped so single-glyph accents keep their centring offset
242
+ expect(css).toContain(':has(.ML__stretchy)');
243
+ }
244
+ });
245
+
246
+ it('does not inject twice', () => {
247
+ const el = host();
248
+
249
+ instance.applyShadowStyles(el);
250
+ instance.applyShadowStyles(el);
251
+
252
+ const styles = el.shadowRoot.querySelectorAll('style[data-pie-accent]');
253
+
254
+ // either one adopted sheet, or exactly one style node
255
+ expect(el.shadowRoot.__pieAccentSheet ? 1 : styles.length).toBe(1);
256
+ });
257
+ });
258
+
172
259
  describe('latexToMarkup', () => {
173
260
  it('returns empty string when MathLive is not loaded', () => {
174
261
  expect(instance.latexToMarkup('\\pi')).toEqual('');