@pie-lib/math-input-mathlive 1.1.5-beta.6 → 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,109 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import {
5
+ keys,
6
+ keysForGrade,
7
+ addBrackets,
8
+ removeBrackets,
9
+ registerEmbed,
10
+ applyStaticMath,
11
+ updateSpans,
12
+ mf,
13
+ mq,
14
+ MathInput,
15
+ HorizontalKeypad,
16
+ KeyPad,
17
+ } from '../index';
18
+
19
+ describe('math-input-mathlive public API', () => {
20
+ it('mirrors the @pie-lib/math-input export surface', () => {
21
+ expect(typeof keysForGrade).toBe('function');
22
+ expect(typeof addBrackets).toBe('function');
23
+ expect(typeof removeBrackets).toBe('function');
24
+ expect(typeof registerEmbed).toBe('function');
25
+ expect(typeof applyStaticMath).toBe('function');
26
+ expect(typeof updateSpans).toBe('function');
27
+ expect(keys).toBeDefined();
28
+ expect(keys.baseSet).toBeDefined();
29
+ });
30
+
31
+ it('exports the field wrappers under both mf and mq', () => {
32
+ expect(mf.Input).toBeDefined();
33
+ expect(mf.Static).toBeDefined();
34
+ // `mq` alias lets a consumer swap packages by changing only the import.
35
+ expect(mq).toBe(mf);
36
+ });
37
+
38
+ it('updateSpans is a safe no-op', () => {
39
+ expect(() => updateSpans()).not.toThrow();
40
+ });
41
+
42
+ it('CommonMqStyles keeps the shape consumers destructure', () => {
43
+ // math-toolbar does:
44
+ // const { commonMqFontStyles, commonMqKeyboardStyles, longdivStyles, supsubStyles }
45
+ // = mq.CommonMqStyles;
46
+ const { commonMqFontStyles, commonMqKeyboardStyles, longdivStyles, supsubStyles } = mf.CommonMqStyles;
47
+
48
+ [commonMqFontStyles, commonMqKeyboardStyles, longdivStyles, supsubStyles].forEach((s) => {
49
+ expect(s).toBeDefined();
50
+ expect(typeof s).toBe('object');
51
+ });
52
+ });
53
+
54
+ it('bracket helpers round-trip', () => {
55
+ expect(addBrackets('x')).toEqual('\\(x\\)');
56
+ expect(removeBrackets('\\(x\\)')).toEqual('x');
57
+ expect(removeBrackets(addBrackets('\\frac{1}{2}'))).toEqual('\\frac{1}{2}');
58
+ });
59
+
60
+ it('keysForGrade returns a 5-row keyset', () => {
61
+ const set = keysForGrade('geometry');
62
+
63
+ expect(Array.isArray(set)).toBe(true);
64
+ expect(set.length).toBe(5);
65
+ });
66
+ });
67
+
68
+ describe('rendering', () => {
69
+ // MathLive is a browser-only custom element; loadMathLive() resolves async and
70
+ // is a no-op-ish under jsdom. These assert the components mount without
71
+ // throwing, which is what regressed most often in the MathQuill version.
72
+ it('KeyPad renders without a live MathLive instance', () => {
73
+ const { container } = render(<KeyPad onPress={() => {}} />);
74
+
75
+ expect(container.querySelectorAll('button').length).toBeGreaterThan(0);
76
+ });
77
+
78
+ it('KeyPad falls back to raw latex for labels before MathLive loads', () => {
79
+ const { container } = render(
80
+ <KeyPad onPress={() => {}} baseSet={[[{ name: 'pi', latex: '\\pi', category: 'constants' }]]} />,
81
+ );
82
+
83
+ expect(container.textContent).toContain('\\pi');
84
+ });
85
+
86
+ it('KeyPad fires onPress with the key definition', () => {
87
+ const onPress = jest.fn();
88
+ const key = { name: 'x', label: 'x', write: 'x', category: 'vars' };
89
+ const { container } = render(<KeyPad onPress={onPress} baseSet={[[key]]} />);
90
+
91
+ container.querySelector('button').click();
92
+
93
+ expect(onPress).toHaveBeenCalledWith(key);
94
+ });
95
+
96
+ it('MathInput mounts', () => {
97
+ expect(() => render(<MathInput latex="\\frac{1}{2}" onChange={() => {}} />)).not.toThrow();
98
+ });
99
+
100
+ it('HorizontalKeypad mounts and reports the legacy {value,type} model', () => {
101
+ const onClick = jest.fn();
102
+
103
+ expect(() => render(<HorizontalKeypad onClick={onClick} />)).not.toThrow();
104
+ });
105
+
106
+ it('Static renders display-mode latex without a mathfield', () => {
107
+ expect(() => render(<mf.Static latex="\\frac{1}{2}" />)).not.toThrow();
108
+ });
109
+ });
@@ -0,0 +1,144 @@
1
+ import { KEYSTROKES, keyToAction } from '../latex-bridge';
2
+ import Input from '../mf/input';
3
+
4
+ /**
5
+ * The imperative surface that @pie-lib/math-toolbar drives
6
+ * (editor-and-pad.jsx: clear/command/keystroke/write/latex).
7
+ *
8
+ * These call the methods against a stub mathfield, because a real
9
+ * <math-field> needs a browser. What matters here is the *translation*:
10
+ * MathQuill-era arguments must become valid MathLive operations.
11
+ */
12
+ const stubField = () => {
13
+ const calls = { insert: [], executeCommand: [], focus: 0 };
14
+
15
+ return {
16
+ calls,
17
+ value: '',
18
+ getValue: () => '',
19
+ insert(v) {
20
+ calls.insert.push(v);
21
+ },
22
+ executeCommand(v) {
23
+ calls.executeCommand.push(v);
24
+ },
25
+ focus() {
26
+ calls.focus++;
27
+ },
28
+ blur() {},
29
+ };
30
+ };
31
+
32
+ const withField = () => {
33
+ const input = new Input({});
34
+ const f = stubField();
35
+
36
+ input.mathField = f;
37
+
38
+ return { input, f };
39
+ };
40
+
41
+ describe('Input imperative API (drives math-toolbar)', () => {
42
+ describe('keystroke', () => {
43
+ it('maps MathQuill keystroke names to MathLive selectors', () => {
44
+ const { input, f } = withField();
45
+
46
+ input.keystroke('Left');
47
+ input.keystroke('Right');
48
+ input.keystroke('Backspace');
49
+
50
+ expect(f.calls.executeCommand).toEqual(['moveToPreviousChar', 'moveToNextChar', 'deleteBackward']);
51
+ });
52
+
53
+ it('passes an unknown value through as a selector', () => {
54
+ const { input, f } = withField();
55
+
56
+ input.keystroke('deleteForward');
57
+
58
+ expect(f.calls.executeCommand).toEqual(['deleteForward']);
59
+ });
60
+
61
+ it('does not throw when the engine rejects the selector', () => {
62
+ const { input } = withField();
63
+
64
+ input.mathField.executeCommand = () => {
65
+ throw new Error('unknown command');
66
+ };
67
+
68
+ expect(() => input.keystroke('Nonsense')).not.toThrow();
69
+ });
70
+
71
+ it('covers every keystroke the keypad emits', () => {
72
+ // navigation.js / edit.js only produce these three
73
+ ['Left', 'Right', 'Backspace'].forEach((k) => expect(KEYSTROKES[k]).toBeDefined());
74
+ });
75
+ });
76
+
77
+ describe('command', () => {
78
+ it('expands argument-taking commands into placeholders', () => {
79
+ const { input, f } = withField();
80
+
81
+ input.command('\\frac');
82
+
83
+ // a bare \frac would give no editable slots
84
+ expect(f.calls.insert).toEqual(['\\frac{#?}{#?}']);
85
+ });
86
+
87
+ it('handles array commands', () => {
88
+ const { input, f } = withField();
89
+
90
+ input.command(['\\log', '_']);
91
+
92
+ expect(f.calls.insert).toEqual(['\\log_{#?}']);
93
+ });
94
+
95
+ it('passes argument-less commands through', () => {
96
+ const { input, f } = withField();
97
+
98
+ input.command('\\pi');
99
+
100
+ expect(f.calls.insert).toEqual(['\\pi']);
101
+ });
102
+
103
+ it('agrees with keyToAction', () => {
104
+ expect(keyToAction({ command: '\\sqrt' }).value).toEqual('\\sqrt{#?}');
105
+ });
106
+ });
107
+
108
+ describe('safety before MathLive loads', () => {
109
+ it('every method is a no-op rather than a crash', () => {
110
+ const input = new Input({});
111
+
112
+ // mathField is undefined until loadMathLive() resolves
113
+ expect(input.latex()).toEqual('');
114
+ expect(input.clear()).toEqual('');
115
+ expect(input.command('\\frac')).toEqual('');
116
+ expect(input.keystroke('Left')).toEqual('');
117
+ expect(input.write('x')).toEqual('');
118
+ expect(() => input.focus()).not.toThrow();
119
+ expect(() => input.blur()).not.toThrow();
120
+ expect(() => input.setLatex('x')).not.toThrow();
121
+ expect(input.element).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe('mqCompat facade', () => {
126
+ // math-toolbar used to call this.input.mathField.latex(); external
127
+ // consumers may still do the same.
128
+ it('exposes latex() as a getter', () => {
129
+ const { input } = withField();
130
+
131
+ input.mathField.getValue = () => '\\frac{1}{2}';
132
+
133
+ expect(input.mqCompat.latex()).toEqual('\\frac{1}{2}');
134
+ });
135
+
136
+ it('exposes latex(v) as a setter and el()', () => {
137
+ const { input, f } = withField();
138
+
139
+ input.mqCompat.latex('x+1');
140
+ expect(f.value).toEqual('x+1');
141
+ expect(input.mqCompat.el()).toBe(f);
142
+ });
143
+ });
144
+ });
@@ -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
+ });