@pie-lib/math-input-mathlive 1.0.6 → 1.0.8
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.
- package/lib/__tests__/composites.test.js +255 -0
- package/lib/__tests__/composites.test.js.map +1 -0
- package/lib/__tests__/keypad-label.test.js +6 -7
- package/lib/__tests__/keypad-label.test.js.map +1 -1
- package/lib/__tests__/keypad-render.test.js +206 -0
- package/lib/__tests__/keypad-render.test.js.map +1 -0
- package/lib/__tests__/latex-bridge.test.js +65 -0
- package/lib/__tests__/latex-bridge.test.js.map +1 -1
- package/lib/__tests__/mathlive-instance.test.js +18 -0
- package/lib/__tests__/mathlive-instance.test.js.map +1 -1
- package/lib/__tests__/mount-race.test.js +194 -0
- package/lib/__tests__/mount-race.test.js.map +1 -0
- package/lib/__tests__/static-prompts.test.js +282 -0
- package/lib/__tests__/static-prompts.test.js.map +1 -0
- package/lib/keypad/index.js +4 -28
- package/lib/keypad/index.js.map +1 -1
- package/lib/latex-bridge.js +124 -4
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mathlive-instance.js +28 -4
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/common-styles.js +13 -0
- package/lib/mf/common-styles.js.map +1 -1
- package/lib/mf/input.js +18 -5
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +24 -7
- package/lib/mf/static.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/composites.test.jsx +220 -0
- package/src/__tests__/keypad-label.test.jsx +1 -1
- package/src/__tests__/keypad-render.test.jsx +123 -0
- package/src/__tests__/latex-bridge.test.js +87 -1
- package/src/__tests__/mathlive-instance.test.js +20 -0
- package/src/__tests__/mount-race.test.jsx +129 -0
- package/src/__tests__/static-prompts.test.jsx +251 -0
- package/src/keypad/index.jsx +3 -25
- package/src/latex-bridge.js +128 -3
- package/src/mathlive-instance.js +29 -5
- package/src/mf/common-styles.js +13 -0
- package/src/mf/input.jsx +19 -5
- package/src/mf/static.jsx +23 -4
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
toMathLive,
|
|
3
|
+
fromMathLive,
|
|
4
|
+
fieldIds,
|
|
5
|
+
keyToAction,
|
|
6
|
+
toNativeCommands,
|
|
7
|
+
NEWLINE_EMBED,
|
|
8
|
+
DEFAULT_FIELD_ID,
|
|
9
|
+
} from '../latex-bridge';
|
|
2
10
|
|
|
3
11
|
describe('latex-bridge', () => {
|
|
4
12
|
describe('toMathLive', () => {
|
|
@@ -64,6 +72,32 @@ describe('latex-bridge', () => {
|
|
|
64
72
|
expect(fromMathLive(toMathLive(stored))).toEqual(stored);
|
|
65
73
|
});
|
|
66
74
|
|
|
75
|
+
// MathLive serialises empty slots as \placeholder{}, which MathJax (used by
|
|
76
|
+
// @pie-lib/math-rendering for prompts/previews) renders as red error text.
|
|
77
|
+
it('strips unnamed placeholders that MathLive adds', () => {
|
|
78
|
+
expect(fromMathLive('\\longdiv{\\placeholder{}}')).toEqual('\\longdiv{}');
|
|
79
|
+
expect(fromMathLive('\\frac{\\placeholder{}}{\\placeholder{}}')).toEqual('\\frac{}{}');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps content inside an unnamed placeholder', () => {
|
|
83
|
+
expect(fromMathLive('\\sqrt{\\placeholder{7}}')).toEqual('\\sqrt{7}');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles a bare \\placeholder with no argument', () => {
|
|
87
|
+
expect(fromMathLive('\\longdiv{\\placeholder}')).toEqual('\\longdiv{}');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('never leaks the token \\placeholder', () => {
|
|
91
|
+
['\\longdiv{\\placeholder{}}', '\\frac{\\placeholder{}}{x}', '\\placeholder{}'].forEach((l) => {
|
|
92
|
+
expect(fromMathLive(l)).not.toContain('\\placeholder');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still maps NAMED placeholders to answer blocks', () => {
|
|
97
|
+
// named ones are answer blocks and must survive
|
|
98
|
+
expect(fromMathLive('\\placeholder[r1]{}')).toEqual('\\MathQuillMathField[r1]{}');
|
|
99
|
+
});
|
|
100
|
+
|
|
67
101
|
it('round-trips answer blocks combined with newlines', () => {
|
|
68
102
|
const stored = `\\MathQuillMathField[r1]{}${NEWLINE_EMBED}y`;
|
|
69
103
|
|
|
@@ -71,6 +105,58 @@ describe('latex-bridge', () => {
|
|
|
71
105
|
});
|
|
72
106
|
});
|
|
73
107
|
|
|
108
|
+
// A MathLive macro serialises from the arguments it was created with, so text
|
|
109
|
+
// typed inside its expansion never comes back out of getValue('latex').
|
|
110
|
+
// Argument-taking pie commands must therefore become native constructs before
|
|
111
|
+
// they reach a mathfield.
|
|
112
|
+
describe('toNativeCommands', () => {
|
|
113
|
+
it('expands \\longdiv to native \\enclose', () => {
|
|
114
|
+
expect(toNativeCommands('\\longdiv{1234}')).toEqual('\\enclose{longdiv}{1234}');
|
|
115
|
+
expect(toNativeCommands('\\longdiv{}')).toEqual('\\enclose{longdiv}{}');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('expands \\overarc to native \\overparen', () => {
|
|
119
|
+
expect(toNativeCommands('\\overarc{AB}')).toEqual('\\overparen{AB}');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('expands \\abs to native \\left|..\\right|', () => {
|
|
123
|
+
expect(toNativeCommands('\\abs{x}')).toEqual('\\left|x\\right|');
|
|
124
|
+
expect(toNativeCommands('\\abs{}')).toEqual('\\left|\\right|');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('handles nested braces inside \\abs', () => {
|
|
128
|
+
expect(toNativeCommands('\\abs{\\frac{1}{2}}')).toEqual('\\left|\\frac{1}{2}\\right|');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('handles several occurrences', () => {
|
|
132
|
+
expect(toNativeCommands('\\abs{a}+\\abs{b}')).toEqual('\\left|a\\right|+\\left|b\\right|');
|
|
133
|
+
expect(toNativeCommands('\\longdiv{1}\\longdiv{2}')).toEqual('\\enclose{longdiv}{1}\\enclose{longdiv}{2}');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('leaves other latex untouched', () => {
|
|
137
|
+
['\\frac{1}{2}', '\\pi', '\\sqrt{x}', '\\overline{AB}'].forEach((l) => {
|
|
138
|
+
expect(toNativeCommands(l)).toEqual(l);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('handles nullish input', () => {
|
|
143
|
+
expect(toNativeCommands(undefined)).toEqual('');
|
|
144
|
+
expect(toNativeCommands('')).toEqual('');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('is applied by toMathLive', () => {
|
|
148
|
+
expect(toMathLive('\\longdiv{5}')).toEqual('\\enclose{longdiv}{5}');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not leave a pie macro in what reaches the field', () => {
|
|
152
|
+
['\\longdiv{7}', '\\abs{7}', '\\overarc{7}'].forEach((l) => {
|
|
153
|
+
const out = toMathLive(l);
|
|
154
|
+
|
|
155
|
+
expect(out).not.toMatch(/\\longdiv|\\abs|\\overarc/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
74
160
|
describe('fieldIds', () => {
|
|
75
161
|
it('returns ids in document order', () => {
|
|
76
162
|
expect(fieldIds('\\MathQuillMathField[r1]{}+\\MathQuillMathField[r2]{}')).toEqual(['r1', 'r2']);
|
|
@@ -63,6 +63,26 @@ describe('mathlive-instance', () => {
|
|
|
63
63
|
expect(macros.longdiv).toBeDefined();
|
|
64
64
|
expect(macros.napprox).toEqual('\\not\\approx');
|
|
65
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
|
+
});
|
|
66
86
|
});
|
|
67
87
|
|
|
68
88
|
describe('applyStaticMath', () => {
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
latexToMarkup: (l) => `<span>${l}</span>`,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line import/first
|
|
41
|
+
import Input from '../mf/input';
|
|
42
|
+
|
|
43
|
+
const holderFor = (cmp) => {
|
|
44
|
+
const el = document.createElement('span');
|
|
45
|
+
|
|
46
|
+
cmp.holderRef = { current: el };
|
|
47
|
+
|
|
48
|
+
return el;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const fieldCount = (el) => Array.from(el.childNodes).filter((n) => n.tagName === 'MATH-FIELD').length;
|
|
52
|
+
|
|
53
|
+
describe('async mount race', () => {
|
|
54
|
+
it('StrictMode mount -> unmount -> mount yields exactly one field', async () => {
|
|
55
|
+
const input = new Input({});
|
|
56
|
+
const el = holderFor(input);
|
|
57
|
+
|
|
58
|
+
const first = input.componentDidMount();
|
|
59
|
+
|
|
60
|
+
input.componentWillUnmount();
|
|
61
|
+
|
|
62
|
+
const second = input.componentDidMount();
|
|
63
|
+
|
|
64
|
+
await Promise.all([first, second]);
|
|
65
|
+
|
|
66
|
+
expect(fieldCount(el)).toBe(1);
|
|
67
|
+
expect(input.mathField).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('appends nothing when unmounted while the load is in flight', async () => {
|
|
71
|
+
const input = new Input({});
|
|
72
|
+
const el = holderFor(input);
|
|
73
|
+
|
|
74
|
+
const pending = input.componentDidMount();
|
|
75
|
+
|
|
76
|
+
input.componentWillUnmount();
|
|
77
|
+
|
|
78
|
+
await pending;
|
|
79
|
+
|
|
80
|
+
expect(fieldCount(el)).toBe(0);
|
|
81
|
+
expect(input.mathField).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a normal single mount creates the field', async () => {
|
|
85
|
+
const input = new Input({});
|
|
86
|
+
const el = holderFor(input);
|
|
87
|
+
|
|
88
|
+
await input.componentDidMount();
|
|
89
|
+
|
|
90
|
+
expect(fieldCount(el)).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('repeated mounts never accumulate fields', async () => {
|
|
94
|
+
const input = new Input({});
|
|
95
|
+
const el = holderFor(input);
|
|
96
|
+
|
|
97
|
+
await input.componentDidMount();
|
|
98
|
+
await input.componentDidMount();
|
|
99
|
+
await input.componentDidMount();
|
|
100
|
+
|
|
101
|
+
expect(fieldCount(el)).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// MathLive's mathModeSpace defaults to '', which makes the spacebar a no-op.
|
|
105
|
+
it('configures the spacebar so a space can be typed', async () => {
|
|
106
|
+
const input = new Input({});
|
|
107
|
+
|
|
108
|
+
holderFor(input);
|
|
109
|
+
await input.componentDidMount();
|
|
110
|
+
|
|
111
|
+
// a LaTeX control space - what MathQuill produced, and MathJax understands
|
|
112
|
+
expect(input.mathField.mathModeSpace).toEqual('\\ ');
|
|
113
|
+
expect(input.mathField.mathModeSpace).not.toEqual('');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('reports itself through innerRef on mount and clears it on unmount', async () => {
|
|
117
|
+
const innerRef = jest.fn();
|
|
118
|
+
const input = new Input({ innerRef });
|
|
119
|
+
|
|
120
|
+
holderFor(input);
|
|
121
|
+
await input.componentDidMount();
|
|
122
|
+
|
|
123
|
+
expect(innerRef).toHaveBeenCalledWith(input);
|
|
124
|
+
|
|
125
|
+
input.componentWillUnmount();
|
|
126
|
+
|
|
127
|
+
expect(innerRef).toHaveBeenLastCalledWith(null);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import Static from '../mf/static';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The answer-block contract: `\MathQuillMathField[id]{}` in stored latex becomes
|
|
5
|
+
* a MathLive prompt, and sub-field values/focus are surfaced through the same
|
|
6
|
+
* callbacks @pie-lib/math-input exposed (`onSubFieldChange`, `onSubFieldFocus`,
|
|
7
|
+
* `getFieldName`, `setInput`).
|
|
8
|
+
*
|
|
9
|
+
* Driven against a stub mathfield: a real <math-field> needs a browser, and what
|
|
10
|
+
* matters here is the mapping, not MathLive's rendering.
|
|
11
|
+
*/
|
|
12
|
+
const stubField = (values = {}, selection = null) => ({
|
|
13
|
+
values: { ...values },
|
|
14
|
+
getPrompts() {
|
|
15
|
+
return Object.keys(this.values);
|
|
16
|
+
},
|
|
17
|
+
getPromptValue(id) {
|
|
18
|
+
return this.values[id];
|
|
19
|
+
},
|
|
20
|
+
setPromptValue(id, v) {
|
|
21
|
+
this.values[id] = v;
|
|
22
|
+
},
|
|
23
|
+
getPromptRange(id) {
|
|
24
|
+
const i = Object.keys(this.values).indexOf(id);
|
|
25
|
+
|
|
26
|
+
return [i * 10, i * 10 + 5];
|
|
27
|
+
},
|
|
28
|
+
selection,
|
|
29
|
+
addEventListener() {},
|
|
30
|
+
removeEventListener() {},
|
|
31
|
+
focus() {
|
|
32
|
+
this.focused = true;
|
|
33
|
+
},
|
|
34
|
+
blur() {
|
|
35
|
+
this.blurred = true;
|
|
36
|
+
},
|
|
37
|
+
remove() {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const make = (props = {}) => new Static({ latex: 'x=\\MathQuillMathField[r1]{}', getFieldName: () => {}, ...props });
|
|
41
|
+
|
|
42
|
+
describe('Static: answer blocks / prompts', () => {
|
|
43
|
+
describe('isInteractive', () => {
|
|
44
|
+
it('is interactive when the latex has answer blocks', () => {
|
|
45
|
+
expect(make({ latex: 'x=\\MathQuillMathField[r1]{}' }).isInteractive()).toBe(true);
|
|
46
|
+
expect(make({ latex: 'x=\\placeholder[r1]{}' }).isInteractive()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is display-only for plain latex', () => {
|
|
50
|
+
expect(make({ latex: '\\frac{1}{2}' }).isInteractive()).toBe(false);
|
|
51
|
+
expect(make({ latex: '' }).isInteractive()).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('values', () => {
|
|
56
|
+
it('returns {} before the field exists', () => {
|
|
57
|
+
expect(make().values()).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps each prompt id to its stored-form latex', () => {
|
|
61
|
+
const s = make();
|
|
62
|
+
|
|
63
|
+
s.mathField = stubField({ r1: '7', r2: '\\placeholder{}' });
|
|
64
|
+
|
|
65
|
+
// \placeholder must not leak into stored values
|
|
66
|
+
expect(s.values()).toEqual({ r1: '7', r2: '' });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('promptHandle / innerFields', () => {
|
|
71
|
+
it('exposes a MathQuill-field-like handle with get/set latex', () => {
|
|
72
|
+
const s = make();
|
|
73
|
+
|
|
74
|
+
s.mathField = stubField({ r1: '7' });
|
|
75
|
+
|
|
76
|
+
const h = s.promptHandle('r1');
|
|
77
|
+
|
|
78
|
+
expect(h.id).toEqual('r1');
|
|
79
|
+
expect(h.latex()).toEqual('7');
|
|
80
|
+
|
|
81
|
+
h.latex('9');
|
|
82
|
+
expect(s.mathField.values.r1).toEqual('9');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handle latex() is safe with no field', () => {
|
|
86
|
+
expect(make().promptHandle('r1').latex()).toEqual('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('innerFields returns one handle per prompt', () => {
|
|
90
|
+
const s = make();
|
|
91
|
+
|
|
92
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
93
|
+
|
|
94
|
+
expect(s.innerFields.map((f) => f.id)).toEqual(['r1', 'r2']);
|
|
95
|
+
expect(make().innerFields).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('onSubFieldChange', () => {
|
|
100
|
+
it('fires only for prompts whose value changed', () => {
|
|
101
|
+
const onSubFieldChange = jest.fn();
|
|
102
|
+
const s = make({ onSubFieldChange });
|
|
103
|
+
|
|
104
|
+
s.mathField = stubField({ r1: '1', r2: '2' });
|
|
105
|
+
s.promptValues = { r1: '1', r2: '2' };
|
|
106
|
+
|
|
107
|
+
s.mathField.values.r2 = '5';
|
|
108
|
+
s.onPromptInput();
|
|
109
|
+
|
|
110
|
+
expect(onSubFieldChange).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('r2', '5');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('uses getFieldName when it returns a name', () => {
|
|
115
|
+
const onSubFieldChange = jest.fn();
|
|
116
|
+
const s = make({ onSubFieldChange, getFieldName: (handle) => 'named-' + handle.id });
|
|
117
|
+
|
|
118
|
+
s.mathField = stubField({ r1: '1' });
|
|
119
|
+
s.promptValues = {};
|
|
120
|
+
|
|
121
|
+
s.onPromptInput();
|
|
122
|
+
|
|
123
|
+
expect(onSubFieldChange).toHaveBeenCalledWith('named-r1', '1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still tracks values when no callback is supplied', () => {
|
|
127
|
+
const s = make();
|
|
128
|
+
|
|
129
|
+
s.mathField = stubField({ r1: '3' });
|
|
130
|
+
s.onPromptInput();
|
|
131
|
+
|
|
132
|
+
expect(s.promptValues).toEqual({ r1: '3' });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('onSubFieldFocus', () => {
|
|
137
|
+
it('reports the prompt containing the selection, plus setInput', () => {
|
|
138
|
+
const onSubFieldFocus = jest.fn();
|
|
139
|
+
const setInput = jest.fn();
|
|
140
|
+
const s = make({ onSubFieldFocus, setInput });
|
|
141
|
+
|
|
142
|
+
// r2 occupies offsets 10..15
|
|
143
|
+
s.mathField = stubField({ r1: '', r2: '' }, { ranges: [[11, 11]] });
|
|
144
|
+
s.onPromptFocus();
|
|
145
|
+
|
|
146
|
+
expect(onSubFieldFocus).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(onSubFieldFocus.mock.calls[0][0]).toEqual('r2');
|
|
148
|
+
expect(onSubFieldFocus.mock.calls[0][1].id).toEqual('r2');
|
|
149
|
+
expect(setInput).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does nothing without a selection', () => {
|
|
153
|
+
const onSubFieldFocus = jest.fn();
|
|
154
|
+
const s = make({ onSubFieldFocus });
|
|
155
|
+
|
|
156
|
+
s.mathField = stubField({ r1: '' }, null);
|
|
157
|
+
s.onPromptFocus();
|
|
158
|
+
|
|
159
|
+
expect(onSubFieldFocus).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not throw when the field is missing', () => {
|
|
163
|
+
const s = make({ onSubFieldFocus: jest.fn() });
|
|
164
|
+
|
|
165
|
+
expect(() => s.onPromptFocus()).not.toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('accessibility announcements', () => {
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
document.body.innerHTML = '';
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('creates a polite off-screen live region', () => {
|
|
175
|
+
const s = make();
|
|
176
|
+
|
|
177
|
+
s.createLiveRegion();
|
|
178
|
+
|
|
179
|
+
expect(s.liveRegion.getAttribute('aria-live')).toEqual('polite');
|
|
180
|
+
expect(s.liveRegion.getAttribute('aria-atomic')).toEqual('true');
|
|
181
|
+
expect(document.body.contains(s.liveRegion)).toBe(true);
|
|
182
|
+
|
|
183
|
+
s.removeLiveRegion();
|
|
184
|
+
expect(s.liveRegion).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not create a second region', () => {
|
|
188
|
+
const s = make();
|
|
189
|
+
|
|
190
|
+
s.createLiveRegion();
|
|
191
|
+
const first = s.liveRegion;
|
|
192
|
+
|
|
193
|
+
s.createLiveRegion();
|
|
194
|
+
expect(s.liveRegion).toBe(first);
|
|
195
|
+
s.removeLiveRegion();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('announces when typed input expands into a command', () => {
|
|
199
|
+
const s = make();
|
|
200
|
+
|
|
201
|
+
s.createLiveRegion();
|
|
202
|
+
s.promptValues = { r1: 'x' };
|
|
203
|
+
// grew by more than one char AND gained a backslash => a conversion
|
|
204
|
+
s.announceConversion({ r1: 'x\\frac{}{}' });
|
|
205
|
+
|
|
206
|
+
expect(s.liveRegion.textContent).toEqual('Converted to math symbol');
|
|
207
|
+
s.removeLiveRegion();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not announce ordinary typing', () => {
|
|
211
|
+
const s = make();
|
|
212
|
+
|
|
213
|
+
s.createLiveRegion();
|
|
214
|
+
s.promptValues = { r1: 'x' };
|
|
215
|
+
s.announceConversion({ r1: 'xy' });
|
|
216
|
+
|
|
217
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
218
|
+
s.removeLiveRegion();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not announce a deletion', () => {
|
|
222
|
+
const s = make();
|
|
223
|
+
|
|
224
|
+
s.createLiveRegion();
|
|
225
|
+
s.promptValues = { r1: 'x\\frac{}{}' };
|
|
226
|
+
s.onKeyDown({ key: 'Backspace' });
|
|
227
|
+
s.announceConversion({ r1: 'x' });
|
|
228
|
+
|
|
229
|
+
expect(s.liveRegion.textContent).toEqual('');
|
|
230
|
+
s.removeLiveRegion();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('focus / blur', () => {
|
|
235
|
+
it('delegates to the field and is safe without one', () => {
|
|
236
|
+
const s = make();
|
|
237
|
+
|
|
238
|
+
expect(() => {
|
|
239
|
+
s.focus();
|
|
240
|
+
s.blur();
|
|
241
|
+
}).not.toThrow();
|
|
242
|
+
|
|
243
|
+
s.mathField = stubField();
|
|
244
|
+
s.focus();
|
|
245
|
+
s.blur();
|
|
246
|
+
|
|
247
|
+
expect(s.mathField.focused).toBe(true);
|
|
248
|
+
expect(s.mathField.blurred).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/keypad/index.jsx
CHANGED
|
@@ -57,28 +57,6 @@ const LabelHolder = styled('span')(() => ({
|
|
|
57
57
|
},
|
|
58
58
|
}));
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
* A visible stand-in for an empty argument slot.
|
|
62
|
-
*
|
|
63
|
-
* Key latex uses empty groups to show the shape of a command - `\frac{}{}`,
|
|
64
|
-
* `\overline{}`, `x^{}`. MathQuill drew those as a shaded box (`.mq-empty`);
|
|
65
|
-
* MathLive renders `{}` as nothing at all, so those buttons come out blank.
|
|
66
|
-
* `\placeholder{}` is not a substitute: in static markup it renders as a
|
|
67
|
-
* non-breaking space (the editable box only exists inside a live mathfield), so
|
|
68
|
-
* an actual glyph is required.
|
|
69
|
-
*
|
|
70
|
-
* Tagged with `pie-empty` so it can be muted via CSS.
|
|
71
|
-
*/
|
|
72
|
-
const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\unicode{"25AB}}';
|
|
73
|
-
const EMPTY_GROUP_REGEX = /\{\s*\}/g;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Display-only: give empty groups something visible.
|
|
77
|
-
* Never use this for the value that gets inserted into the field - the key's
|
|
78
|
-
* own latex is what gets typed.
|
|
79
|
-
*/
|
|
80
|
-
export const latexForDisplay = (latex) => (latex || '').replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`);
|
|
81
|
-
|
|
82
60
|
export class LatexLabel extends React.Component {
|
|
83
61
|
static propTypes = {
|
|
84
62
|
latex: PropTypes.string.isRequired,
|
|
@@ -86,7 +64,7 @@ export class LatexLabel extends React.Component {
|
|
|
86
64
|
|
|
87
65
|
constructor(props) {
|
|
88
66
|
super(props);
|
|
89
|
-
this.state = { markup: latexToMarkup(
|
|
67
|
+
this.state = { markup: latexToMarkup(props.latex) };
|
|
90
68
|
}
|
|
91
69
|
|
|
92
70
|
async componentDidMount() {
|
|
@@ -101,7 +79,7 @@ export class LatexLabel extends React.Component {
|
|
|
101
79
|
return;
|
|
102
80
|
}
|
|
103
81
|
|
|
104
|
-
const markup = latexToMarkup(
|
|
82
|
+
const markup = latexToMarkup(this.props.latex);
|
|
105
83
|
|
|
106
84
|
if (markup) {
|
|
107
85
|
this.setState({ markup });
|
|
@@ -119,7 +97,7 @@ export class LatexLabel extends React.Component {
|
|
|
119
97
|
|
|
120
98
|
componentDidUpdate(prevProps) {
|
|
121
99
|
if (prevProps.latex !== this.props.latex) {
|
|
122
|
-
this.setState({ markup: latexToMarkup(
|
|
100
|
+
this.setState({ markup: latexToMarkup(this.props.latex) });
|
|
123
101
|
}
|
|
124
102
|
}
|
|
125
103
|
|