@pie-lib/math-input-mathlive 1.1.5-beta.5 → 1.1.5-beta.7

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.
@@ -0,0 +1,271 @@
1
+ import { withVisibleEmptySlots as latexForDisplay } from '../latex-bridge';
2
+ import { keyToAction, toMathLive } from '../latex-bridge';
3
+
4
+ /**
5
+ * Keys use empty groups to show a command's shape (`\frac{}{}`, `x^{}`).
6
+ * MathLive renders `{}` as nothing, so labels need a visible stand-in - but the
7
+ * value that gets *inserted* must stay untouched.
8
+ */
9
+ describe('keypad label display normalisation', () => {
10
+ it('gives empty groups a visible glyph', () => {
11
+ const out = latexForDisplay('\\frac{}{}');
12
+
13
+ expect(out).not.toEqual('\\frac{}{}');
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');
17
+ expect(out).toContain('pie-empty');
18
+ });
19
+
20
+ it.each([
21
+ ['absolute value', '\\abs{}', '|', '|'],
22
+ ['parenthesis', '\\left(\\right)', '(', ')'],
23
+ ['brackets', '\\left[\\right]', '[', ']'],
24
+ ])('gives an empty %s a tall, filled slot (the same muted tag as other slots)', (_name, latex, open, close) => {
25
+ const out = latexForDisplay(toMathLive(latex));
26
+
27
+ expect(out).toContain('pie-empty=1');
28
+ expect(out).toContain('\\rule[-0.25em]{0.55em}{1em}');
29
+ expect(out).not.toContain('\\boxed');
30
+ // the strut that keeps the fences tall while the box is centred on the axis
31
+ expect(out).toContain('\\rule{0em}{1em}');
32
+ expect(out.startsWith(`\\left${open}`)).toBe(true);
33
+ expect(out.endsWith(`\\right${close}`)).toBe(true);
34
+ });
35
+
36
+ it('leaves a filled pair of fences alone', () => {
37
+ expect(latexForDisplay(toMathLive('\\abs{x}'))).toEqual('\\left|x\\right|');
38
+ expect(latexForDisplay('\\left(x\\right)')).toEqual('\\left(x\\right)');
39
+ });
40
+
41
+ it('handles every empty-group key shape', () => {
42
+ [
43
+ '\\frac{}{}',
44
+ '\\overline{}',
45
+ 'x^{}',
46
+ 'x_{}',
47
+ '\\sqrt{}',
48
+ '\\sqrt[{}]{}',
49
+ '\\abs{}',
50
+ '\\log_{}',
51
+ '\\longdiv{}',
52
+ ].forEach((latex) => {
53
+ expect(latexForDisplay(latex)).not.toContain('{}');
54
+ });
55
+ });
56
+
57
+ it('treats a whitespace-only group as empty', () => {
58
+ // e.g. \frac{x}{ }
59
+ expect(latexForDisplay('\\frac{x}{ }')).toContain('pie-empty');
60
+ });
61
+
62
+ // MathQuill needed a nested \overline{} to draw the empty box. MathLive
63
+ // renders that as a real line, so those labels showed a spurious bar (a line
64
+ // under the arrows, and a straight line instead of the arc).
65
+ it('accent keys do not carry the nested-overline box hack', () => {
66
+ // eslint-disable-next-line global-require
67
+ const geometry = require('../keys/geometry');
68
+
69
+ Object.values(geometry).forEach((key) => {
70
+ if (key && typeof key.latex === 'string') {
71
+ expect(key.latex).not.toContain('\\overline{}}');
72
+ }
73
+ });
74
+ });
75
+
76
+ it('an accent over an empty slot still gets a visible box', () => {
77
+ ['\\overleftrightarrow{}', '\\overarc{}'].forEach((latex) => {
78
+ const out = latexForDisplay(latex);
79
+
80
+ expect(out).toContain('pie-empty');
81
+ expect(out).not.toContain('\\overline');
82
+ });
83
+ });
84
+
85
+ // A glyph's width comes from font metrics; U+25AB is not in the KaTeX fonts,
86
+ // so the browser substituted a wider one and stretchy accents came out
87
+ // narrower than the box. A rule has an explicit width.
88
+ it('the empty slot has an explicit width, not font-dependent metrics', () => {
89
+ const out = latexForDisplay('\\overarc{}');
90
+
91
+ expect(out).toContain('\\rule');
92
+ expect(out).toMatch(/rule\{[\d.]+em\}\{[\d.]+em\}/);
93
+ expect(out).not.toContain('unicode');
94
+ });
95
+
96
+ // Measured in the browser: the accent's immediate wrapper computes to 0px
97
+ // (its .ML__stretchy child is absolutely positioned), so the svg width has to
98
+ // resolve against .ML__base - whose width equals the content. Without pinning
99
+ // that, the arc collapsed to a spike and sat right of the box.
100
+ it('pins the accent containing block and drops the centering shift', () => {
101
+ // eslint-disable-next-line global-require
102
+ const { placeholderStyles } = require('../mf/common-styles');
103
+
104
+ expect(placeholderStyles['& .ML__base'].position).toEqual('relative');
105
+ expect(placeholderStyles['& .ML__stretchy']).toEqual({ position: 'absolute', left: 0, width: '100%' });
106
+ // must be !important: MathLive writes margin-left as an inline style, and a
107
+ // class cannot override that. Leaving it un-important also halved the arc's
108
+ // width, because .ML__center is the stretchy's containing block.
109
+ expect(placeholderStyles['& .ML__center:has(.ML__stretchy)'].marginLeft).toEqual('0 !important');
110
+ });
111
+
112
+ // MathLive sets the index of \sqrt[n]{x} in scriptscriptstyle - an inline
113
+ // `font-size: 50%` - which is too small to read on the nth-root key and in
114
+ // authored radicals. It is scaled up to 80%.
115
+ describe('radical index size', () => {
116
+ // Real MathLive 0.110 output for `\sqrt[1]{2}`, trimmed to the index. The
117
+ // selector walks this exact chain, so this fixture is what pins it down.
118
+ const INDEX_MARKUP = `
119
+ <span class="ML__base">
120
+ <span class="ML__sqrt-index">
121
+ <span class="ML__vlist-t"><span class="ML__vlist-r"><span class="ML__vlist" style="height:0.78em">
122
+ <span style="top:-3.45em">
123
+ <span class="ML__pstrut" style="height:3em"></span>
124
+ <span id="index-size" style="height:0.33em;display:inline-block;font-size: 50%">
125
+ <span class="ML__cmr">1</span>
126
+ <span id="nested-size" style="font-size: 50%">nested</span>
127
+ </span>
128
+ </span>
129
+ </span></span></span>
130
+ </span>
131
+ <span class="ML__sqrt-sign"><span class="ML__delim-size1">&#8730;</span></span>
132
+ </span>`;
133
+
134
+ const rule = () => {
135
+ // eslint-disable-next-line global-require
136
+ const { rootIndexStyles } = require('../mf/common-styles');
137
+ const selectors = Object.keys(rootIndexStyles);
138
+
139
+ expect(selectors).toHaveLength(1);
140
+
141
+ return { selector: selectors[0], value: rootIndexStyles[selectors[0]].fontSize };
142
+ };
143
+
144
+ it("scales the index glyph to 80%, up from MathLive's 50%", () => {
145
+ // !important: the 50% is an inline style, which a class cannot override.
146
+ expect(rule().value).toEqual('80% !important');
147
+ });
148
+
149
+ it('targets the span MathLive puts the inline font-size on', () => {
150
+ const holder = document.createElement('div');
151
+
152
+ holder.innerHTML = INDEX_MARKUP;
153
+
154
+ const matched = holder.querySelectorAll(rule().selector.replace(/^&\s*/, ''));
155
+
156
+ expect(Array.from(matched).map((el) => el.id)).toEqual(['index-size']);
157
+ });
158
+
159
+ it('leaves the radical index box itself unscaled', () => {
160
+ // Scaling `.ML__sqrt-index` would scale the em-based top/height offsets
161
+ // that tuck the index into the radical, lifting it clear of the crook.
162
+ const { selector } = rule();
163
+
164
+ expect(selector).not.toMatch(/\.ML__sqrt-index\s*\{?$/);
165
+ expect(selector.startsWith('& .ML__sqrt-index >')).toBe(true);
166
+ });
167
+
168
+ it('applies wherever math renders - keypad labels, static math, mathfield', () => {
169
+ // eslint-disable-next-line global-require
170
+ const cs = require('../mf/common-styles');
171
+ const { selector } = rule();
172
+
173
+ ['commonKeyboardStyles', 'commonMathLiveStyles', 'supsubStyles'].forEach((name) => {
174
+ expect(cs[name][selector]).toBeDefined();
175
+ });
176
+
177
+ // The live mathfield renders in a shadow root, which host styles cannot
178
+ // reach, so the same rule ships as plain CSS for `applyShadowStyles`.
179
+ expect(cs.ROOT_INDEX_CSS).toContain('.ML__sqrt-index >');
180
+ // Derived, so the size lives in one place: the two copies cannot drift.
181
+ expect(cs.ROOT_INDEX_CSS).toContain(`font-size:${rule().value}`);
182
+ });
183
+ });
184
+
185
+ it('applies those rules wherever static math renders', () => {
186
+ // eslint-disable-next-line global-require
187
+ const cs = require('../mf/common-styles');
188
+
189
+ ['commonKeyboardStyles', 'commonMathLiveStyles'].forEach((name) => {
190
+ expect(cs[name]['& .ML__stretchy']).toBeDefined();
191
+ expect(cs[name]['& .ML__base']).toBeDefined();
192
+ });
193
+ });
194
+
195
+ // A bare `& svg` rule re-parents every svg in the subtree; MathLive's own rule
196
+ // is descendant-scoped to .ML__latex .ML__stretchy svg.
197
+ it('does not use a blanket svg rule', () => {
198
+ // eslint-disable-next-line global-require
199
+ const cs = require('../mf/common-styles');
200
+
201
+ Object.values(cs).forEach((style) => {
202
+ if (style && typeof style === 'object') {
203
+ expect(Object.keys(style)).not.toContain('& svg');
204
+ }
205
+ });
206
+ });
207
+
208
+ // The Percent key showed as a blank button on the Advanced Algebra keypad
209
+ // (row 1, position 6). MathQuill defined `LatexCmds['%']` as a symbol that
210
+ // serialised to `\\%`, so its bare `%` label parsed fine; MathLive parses real
211
+ // LaTeX, where `%` opens a comment - the label rendered nothing, and pressing
212
+ // the key inserted a comment that swallowed the rest of the expression.
213
+ it('the percent key is escaped, not a latex comment', () => {
214
+ // eslint-disable-next-line global-require
215
+ const { percentage } = require('../keys/misc');
216
+
217
+ expect(percentage.latex).toEqual('\\%');
218
+ expect(keyToAction(percentage).value).toEqual('\\%');
219
+ });
220
+
221
+ // Any bare `%` in a key would fail the same way, silently.
222
+ it('no keypad key carries an unescaped %', () => {
223
+ // eslint-disable-next-line global-require
224
+ const { baseSet } = require('../keys');
225
+ // eslint-disable-next-line global-require
226
+ const { gradeSets } = require('../keys/grades');
227
+
228
+ const every = [...baseSet, ...gradeSets.map((g) => g.set)];
229
+
230
+ const walk = (node) => {
231
+ if (Array.isArray(node)) {
232
+ node.forEach(walk);
233
+ return;
234
+ }
235
+
236
+ if (!node) {
237
+ return;
238
+ }
239
+
240
+ ['latex', 'write', 'command'].forEach((field) => {
241
+ const value = node[field];
242
+
243
+ if (typeof value === 'string') {
244
+ // a `%` is only safe when preceded by an odd run of backslashes
245
+ expect(value.replace(/\\./g, '')).not.toContain('%');
246
+ }
247
+ });
248
+ };
249
+
250
+ walk(every);
251
+ });
252
+
253
+ it('leaves latex without empty groups alone', () => {
254
+ ['\\pi', '\\sin', 'x^2', '\\overline{AB}'].forEach((latex) => {
255
+ expect(latexForDisplay(latex)).toEqual(latex);
256
+ });
257
+ });
258
+
259
+ it('handles nullish input', () => {
260
+ expect(latexForDisplay(undefined)).toEqual('');
261
+ expect(latexForDisplay('')).toEqual('');
262
+ });
263
+
264
+ it('does NOT affect what gets inserted into the field', () => {
265
+ // the action must use the key's own latex, never the display form
266
+ const key = { name: 'frac', latex: '\\frac{}{}' };
267
+
268
+ expect(keyToAction(key).value).toEqual('\\frac{}{}');
269
+ expect(keyToAction(key).value).not.toContain('pie-empty');
270
+ });
271
+ });
@@ -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
+ });