@pie-lib/editable-html-tip-tap 1.2.0-next.12 → 1.2.0-next.13

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,327 @@
1
+ import React from 'react';
2
+ import { render, waitFor, fireEvent } from '@testing-library/react';
3
+ import { MathNode, MathNodeView } from '../math';
4
+
5
+ jest.mock('@tiptap/react', () => ({
6
+ NodeViewWrapper: ({ children, ...props }) => (
7
+ <div data-testid="node-view-wrapper" {...props}>
8
+ {children}
9
+ </div>
10
+ ),
11
+ ReactNodeViewRenderer: jest.fn((component) => component),
12
+ }));
13
+
14
+ jest.mock('react-dom', () => ({
15
+ ...jest.requireActual('react-dom'),
16
+ createPortal: (node) => node,
17
+ }));
18
+
19
+ jest.mock('@pie-lib/math-toolbar', () => {
20
+ const React = require('react');
21
+ return {
22
+ MathPreview: ({ latex }) => <div data-testid="math-preview">{latex}</div>,
23
+ MathToolbar: ({ latex, onChange, onDone }) => {
24
+ const [localLatex, setLocalLatex] = React.useState(latex);
25
+ return (
26
+ <div data-testid="math-toolbar">
27
+ <input
28
+ data-testid="math-input"
29
+ value={localLatex}
30
+ onChange={(e) => {
31
+ setLocalLatex(e.target.value);
32
+ onChange(e.target.value);
33
+ }}
34
+ />
35
+ <button data-testid="done-button" onClick={() => onDone(localLatex)}>
36
+ Done
37
+ </button>
38
+ </div>
39
+ );
40
+ },
41
+ };
42
+ });
43
+
44
+ jest.mock('@pie-lib/math-rendering', () => ({
45
+ wrapMath: (latex, wrapper) => latex,
46
+ }));
47
+
48
+ jest.mock('@tiptap/core', () => ({
49
+ Node: {
50
+ create: jest.fn((config) => config),
51
+ },
52
+ }));
53
+
54
+ jest.mock('prosemirror-state', () => ({
55
+ Plugin: jest.fn(function (config) {
56
+ return config;
57
+ }),
58
+ PluginKey: jest.fn(function (key) {
59
+ this.key = key;
60
+ }),
61
+ TextSelection: {
62
+ create: jest.fn((doc, pos) => ({ type: 'text', pos })),
63
+ },
64
+ NodeSelection: {
65
+ create: jest.fn((doc, pos) => ({ type: 'node', pos })),
66
+ },
67
+ }));
68
+
69
+ describe('MathNode', () => {
70
+ describe('configuration', () => {
71
+ it('has correct name', () => {
72
+ expect(MathNode.name).toBe('math');
73
+ });
74
+
75
+ it('is inline', () => {
76
+ expect(MathNode.inline).toBe(true);
77
+ });
78
+
79
+ it('is in inline group', () => {
80
+ expect(MathNode.group).toBe('inline');
81
+ });
82
+
83
+ it('is atomic', () => {
84
+ expect(MathNode.atom).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe('addAttributes', () => {
89
+ it('returns required attributes', () => {
90
+ const attributes = MathNode.addAttributes();
91
+
92
+ expect(attributes).toHaveProperty('latex');
93
+ expect(attributes).toHaveProperty('wrapper');
94
+ expect(attributes).toHaveProperty('html');
95
+
96
+ expect(attributes.latex).toEqual({ default: '' });
97
+ expect(attributes.wrapper).toEqual({ default: null });
98
+ expect(attributes.html).toEqual({ default: null });
99
+ });
100
+ });
101
+
102
+ describe('parseHTML', () => {
103
+ it('returns parsing rules for latex', () => {
104
+ const rules = MathNode.parseHTML();
105
+
106
+ expect(Array.isArray(rules)).toBe(true);
107
+ expect(rules).toHaveLength(2);
108
+ expect(rules[0]).toHaveProperty('tag', 'span[data-latex]');
109
+ });
110
+
111
+ it('returns parsing rules for mathml', () => {
112
+ const rules = MathNode.parseHTML();
113
+ expect(rules[1]).toHaveProperty('tag', 'span[data-type="mathml"]');
114
+ });
115
+ });
116
+
117
+ describe('renderHTML', () => {
118
+ it('renders mathml when html attribute is present', () => {
119
+ const result = MathNode.renderHTML({
120
+ HTMLAttributes: {
121
+ html: '<math><mi>x</mi></math>',
122
+ },
123
+ });
124
+
125
+ expect(result[0]).toBe('span');
126
+ expect(result[1]).toHaveProperty('data-type', 'mathml');
127
+ });
128
+
129
+ it('renders latex when html attribute is not present', () => {
130
+ const result = MathNode.renderHTML({
131
+ HTMLAttributes: {
132
+ latex: 'x^2',
133
+ },
134
+ });
135
+
136
+ expect(result[0]).toBe('span');
137
+ expect(result[1]).toHaveProperty('data-latex', '');
138
+ expect(result[1]).toHaveProperty('data-raw', 'x^2');
139
+ });
140
+ });
141
+
142
+ describe('addCommands', () => {
143
+ it('returns insertMath command', () => {
144
+ const commands = MathNode.addCommands();
145
+
146
+ expect(commands).toHaveProperty('insertMath');
147
+ expect(typeof commands.insertMath).toBe('function');
148
+ });
149
+ });
150
+
151
+ describe('addNodeView', () => {
152
+ it('returns ReactNodeViewRenderer result', () => {
153
+ const result = MathNode.addNodeView();
154
+
155
+ expect(result).toBeDefined();
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('MathNodeView', () => {
161
+ const createMockEditor = () => ({
162
+ state: {
163
+ selection: {
164
+ from: 0,
165
+ to: 1,
166
+ },
167
+ tr: {
168
+ setSelection: jest.fn().mockReturnThis(),
169
+ },
170
+ doc: {},
171
+ },
172
+ view: {
173
+ coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
174
+ dispatch: jest.fn(),
175
+ },
176
+ commands: {
177
+ focus: jest.fn(),
178
+ },
179
+ instanceId: 'editor-123',
180
+ _toolbarOpened: false,
181
+ });
182
+
183
+ const mockNode = {
184
+ attrs: {
185
+ latex: 'x^2',
186
+ },
187
+ };
188
+
189
+ let defaultProps;
190
+
191
+ beforeAll(() => {
192
+ Object.defineProperty(document.body, 'getBoundingClientRect', {
193
+ value: jest.fn(() => ({ top: 0, left: 0 })),
194
+ configurable: true,
195
+ });
196
+ });
197
+
198
+ beforeEach(() => {
199
+ jest.clearAllMocks();
200
+ defaultProps = {
201
+ node: mockNode,
202
+ updateAttributes: jest.fn(),
203
+ editor: createMockEditor(),
204
+ selected: false,
205
+ options: {},
206
+ };
207
+ });
208
+
209
+ it('renders without crashing', () => {
210
+ const { container } = render(<MathNodeView {...defaultProps} />);
211
+ expect(container).toBeInTheDocument();
212
+ });
213
+
214
+ it('renders NodeViewWrapper', () => {
215
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
216
+ expect(getByTestId('node-view-wrapper')).toBeInTheDocument();
217
+ });
218
+
219
+ it('displays math preview', () => {
220
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
221
+ expect(getByTestId('math-preview')).toBeInTheDocument();
222
+ });
223
+
224
+ it('shows toolbar when selected', async () => {
225
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
226
+ await waitFor(() => {
227
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
228
+ });
229
+ });
230
+
231
+ it('does not show toolbar when not selected', () => {
232
+ const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={false} />);
233
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
234
+ });
235
+
236
+ it('adds data-toolbar-for attribute with editor instanceId', async () => {
237
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
238
+ await waitFor(() => {
239
+ const toolbar = container.querySelector('[data-toolbar-for]');
240
+ expect(toolbar).toHaveAttribute('data-toolbar-for', 'editor-123');
241
+ });
242
+ });
243
+
244
+ it('renders toolbar with correct position', async () => {
245
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
246
+ await waitFor(() => {
247
+ const toolbar = container.querySelector('[data-toolbar-for]');
248
+ expect(toolbar).toHaveStyle({ position: 'absolute' });
249
+ });
250
+ });
251
+
252
+ it('calls updateAttributes when latex changes', async () => {
253
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
254
+ await waitFor(() => {
255
+ const input = getByTestId('math-input');
256
+ fireEvent.change(input, { target: { value: 'y^2' } });
257
+ });
258
+ expect(defaultProps.updateAttributes).toHaveBeenCalledWith({ latex: 'y^2' });
259
+ });
260
+
261
+ it('closes toolbar and updates attributes when done', async () => {
262
+ const updateAttributes = jest.fn();
263
+ const { getByTestId } = render(
264
+ <MathNodeView {...defaultProps} updateAttributes={updateAttributes} selected={true} />,
265
+ );
266
+
267
+ await waitFor(() => {
268
+ expect(getByTestId('done-button')).toBeInTheDocument();
269
+ });
270
+
271
+ const doneButton = getByTestId('done-button');
272
+ fireEvent.click(doneButton);
273
+
274
+ await waitFor(() => {
275
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
276
+ });
277
+ });
278
+
279
+ it('sets editor._toolbarOpened when toolbar is shown', async () => {
280
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
281
+ await waitFor(() => {
282
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
283
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
284
+ });
285
+ });
286
+
287
+ it('unsets editor._toolbarOpened when toolbar is closed', async () => {
288
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
289
+
290
+ await waitFor(() => {
291
+ expect(getByTestId('done-button')).toBeInTheDocument();
292
+ });
293
+
294
+ const doneButton = getByTestId('done-button');
295
+ fireEvent.click(doneButton);
296
+
297
+ await waitFor(() => {
298
+ expect(defaultProps.editor._toolbarOpened).toBe(false);
299
+ });
300
+ });
301
+
302
+ it('closes toolbar on outside click', async () => {
303
+ const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
304
+
305
+ await waitFor(() => {
306
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
307
+ });
308
+
309
+ fireEvent.mouseDown(document.body);
310
+
311
+ await waitFor(() => {
312
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
313
+ });
314
+ });
315
+
316
+ it('renders with empty latex', () => {
317
+ const nodeWithEmptyLatex = { attrs: { latex: '' } };
318
+ const { getByTestId } = render(<MathNodeView {...defaultProps} node={nodeWithEmptyLatex} />);
319
+ expect(getByTestId('math-preview')).toBeInTheDocument();
320
+ });
321
+
322
+ it('has correct styling on NodeViewWrapper', () => {
323
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
324
+ const wrapper = getByTestId('node-view-wrapper');
325
+ expect(wrapper).toHaveStyle({ display: 'inline-flex', cursor: 'pointer' });
326
+ });
327
+ });
@@ -101,6 +101,163 @@ describe('ResponseAreaExtension', () => {
101
101
  expect(commands).toHaveProperty('insertResponseArea');
102
102
  expect(typeof commands.insertResponseArea).toBe('function');
103
103
  });
104
+
105
+ it('returns refreshResponseArea command', () => {
106
+ const commands = ResponseAreaExtension.addCommands();
107
+
108
+ expect(commands).toHaveProperty('refreshResponseArea');
109
+ expect(typeof commands.refreshResponseArea).toBe('function');
110
+ });
111
+
112
+ it('refreshResponseArea handles node with attrs safely', () => {
113
+ const context = {
114
+ options: {
115
+ type: 'explicit-constructed-response',
116
+ maxResponseAreas: 5,
117
+ },
118
+ };
119
+
120
+ const commands = ResponseAreaExtension.addCommands.call(context);
121
+ const refreshCommand = commands.refreshResponseArea();
122
+
123
+ // Mock transaction and state
124
+ const mockNode = {
125
+ attrs: {
126
+ index: '0',
127
+ value: 'test',
128
+ },
129
+ };
130
+
131
+ const mockTr = {
132
+ setNodeMarkup: jest.fn(),
133
+ setSelection: jest.fn(),
134
+ };
135
+
136
+ const mockState = {
137
+ selection: {
138
+ from: 0,
139
+ $from: {
140
+ nodeAfter: mockNode,
141
+ },
142
+ },
143
+ tr: mockTr,
144
+ };
145
+
146
+ const mockCommands = {
147
+ focus: jest.fn(),
148
+ };
149
+
150
+ const mockDispatch = jest.fn();
151
+
152
+ refreshCommand({
153
+ tr: mockTr,
154
+ state: mockState,
155
+ commands: mockCommands,
156
+ dispatch: mockDispatch,
157
+ });
158
+
159
+ expect(mockTr.setNodeMarkup).toHaveBeenCalled();
160
+ });
161
+
162
+ it('refreshResponseArea handles node without attrs safely (optional chaining)', () => {
163
+ const context = {
164
+ options: {
165
+ type: 'explicit-constructed-response',
166
+ maxResponseAreas: 5,
167
+ },
168
+ };
169
+
170
+ const commands = ResponseAreaExtension.addCommands.call(context);
171
+ const refreshCommand = commands.refreshResponseArea();
172
+
173
+ // Mock transaction and state with node that has no attrs
174
+ const mockNode = null;
175
+
176
+ const mockTr = {
177
+ setNodeMarkup: jest.fn(),
178
+ setSelection: jest.fn(),
179
+ };
180
+
181
+ const mockState = {
182
+ selection: {
183
+ from: 0,
184
+ $from: {
185
+ nodeAfter: mockNode,
186
+ },
187
+ },
188
+ tr: mockTr,
189
+ };
190
+
191
+ const mockCommands = {
192
+ focus: jest.fn(),
193
+ };
194
+
195
+ const mockDispatch = jest.fn();
196
+
197
+ // This should not throw an error due to optional chaining on node?.attrs
198
+ expect(() => {
199
+ refreshCommand({
200
+ tr: mockTr,
201
+ state: mockState,
202
+ commands: mockCommands,
203
+ dispatch: mockDispatch,
204
+ });
205
+ }).not.toThrow();
206
+ });
207
+
208
+ it('refreshResponseArea updates timestamp in node attributes', () => {
209
+ const context = {
210
+ options: {
211
+ type: 'explicit-constructed-response',
212
+ maxResponseAreas: 5,
213
+ },
214
+ };
215
+
216
+ const commands = ResponseAreaExtension.addCommands.call(context);
217
+ const refreshCommand = commands.refreshResponseArea();
218
+
219
+ const mockNode = {
220
+ attrs: {
221
+ index: '0',
222
+ value: 'test',
223
+ updated: '1234567890',
224
+ },
225
+ };
226
+
227
+ const mockTr = {
228
+ setNodeMarkup: jest.fn((pos, type, attrs) => {
229
+ // Verify that updated timestamp is being set
230
+ expect(attrs.updated).toBeDefined();
231
+ expect(attrs.updated).not.toBe('1234567890');
232
+ }),
233
+ setSelection: jest.fn(),
234
+ };
235
+
236
+ const mockState = {
237
+ selection: {
238
+ from: 0,
239
+ $from: {
240
+ nodeAfter: mockNode,
241
+ },
242
+ },
243
+ tr: mockTr,
244
+ };
245
+
246
+ const mockCommands = {
247
+ focus: jest.fn(),
248
+ };
249
+
250
+ const mockDispatch = jest.fn();
251
+
252
+ refreshCommand({
253
+ tr: mockTr,
254
+ state: mockState,
255
+ commands: mockCommands,
256
+ dispatch: mockDispatch,
257
+ });
258
+
259
+ expect(mockTr.setNodeMarkup).toHaveBeenCalled();
260
+ });
104
261
  });
105
262
  });
106
263
 
@@ -253,6 +253,7 @@ export const MathNodeView = (props) => {
253
253
  ReactDOM.createPortal(
254
254
  <div
255
255
  ref={toolbarRef}
256
+ data-toolbar-for={editor.instanceId}
256
257
  style={{
257
258
  position: 'absolute',
258
259
  top: `${position.top}px`,
@@ -209,7 +209,7 @@ export const ResponseAreaExtension = Extension.create({
209
209
  const node = selection.$from.nodeAfter;
210
210
  const nodePos = selection.from;
211
211
 
212
- tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, updated: `${Date.now()}` });
212
+ tr.setNodeMarkup(nodePos, undefined, { ...node?.attrs, updated: `${Date.now()}` });
213
213
  tr.setSelection(NodeSelection.create(tr.doc, nodePos));
214
214
 
215
215
  if (dispatch) {