Skip to content

Commit ac00dd3

Browse files
committed
test: created tests around TOC programmatic update
1 parent 45e7dcb commit ac00dd3

4 files changed

Lines changed: 335 additions & 8 deletions

File tree

packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ vi.mock('../constants.js', () => ({
3131
trackChangesAccept: 'Accept Tracked Changes',
3232
trackChangesReject: 'Reject Tracked Changes',
3333
cellBackground: 'Cell background',
34+
updateTableOfContents: 'Update table of contents',
3435
},
3536
ICONS: {
3637
ai: '<svg>ai-icon</svg>',
@@ -42,6 +43,7 @@ vi.mock('../constants.js', () => ({
4243
copy: '<svg>copy-icon</svg>',
4344
paste: '<svg>paste-icon</svg>',
4445
cellBackground: '<svg>cell-background-icon</svg>',
46+
updateTableOfContents: '<svg>rotate-right-icon</svg>',
4547
},
4648
TRIGGERS: {
4749
slash: 'slash',
@@ -1059,4 +1061,110 @@ describe('menuItems.js', () => {
10591061
expect(callOrder).toEqual(['setSelection', 'handleClipboardPaste']);
10601062
});
10611063
});
1064+
1065+
// ---------------------------------------------------------------------------
1066+
// SD-2664 — "Update table of contents" item
1067+
// ---------------------------------------------------------------------------
1068+
1069+
describe('update-table-of-contents item', () => {
1070+
const findItem = (sections) => {
1071+
for (const section of sections) {
1072+
const item = section.items.find((it) => it.id === 'update-table-of-contents');
1073+
if (item) return item;
1074+
}
1075+
return undefined;
1076+
};
1077+
1078+
it('appears when right-clicking inside a TOC (tocAncestor.sdBlockId set, click trigger)', () => {
1079+
mockContext = createMockContext({
1080+
editor: mockEditor,
1081+
trigger: TRIGGERS.click,
1082+
tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' },
1083+
});
1084+
const sections = getItems(mockContext);
1085+
expect(findItem(sections)).toBeDefined();
1086+
});
1087+
1088+
it('is hidden when no tocAncestor is present', () => {
1089+
mockContext = createMockContext({
1090+
editor: mockEditor,
1091+
trigger: TRIGGERS.click,
1092+
tocAncestor: null,
1093+
});
1094+
const sections = getItems(mockContext);
1095+
expect(findItem(sections)).toBeUndefined();
1096+
});
1097+
1098+
it('is hidden when tocAncestor exists but sdBlockId is missing', () => {
1099+
mockContext = createMockContext({
1100+
editor: mockEditor,
1101+
trigger: TRIGGERS.click,
1102+
tocAncestor: { node: {}, pos: 5, sdBlockId: null },
1103+
});
1104+
const sections = getItems(mockContext);
1105+
expect(findItem(sections)).toBeUndefined();
1106+
});
1107+
1108+
it('is hidden on the slash trigger even when inside a TOC', () => {
1109+
mockContext = createMockContext({
1110+
editor: mockEditor,
1111+
trigger: TRIGGERS.slash,
1112+
tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' },
1113+
});
1114+
const sections = getItems(mockContext);
1115+
expect(findItem(sections)).toBeUndefined();
1116+
});
1117+
1118+
it('action invokes editor.doc.toc.update with the resolved sdBlockId and mode "all"', () => {
1119+
const update = vi.fn();
1120+
const ed = { ...mockEditor, doc: { toc: { update } } };
1121+
mockContext = createMockContext({
1122+
editor: ed,
1123+
trigger: TRIGGERS.click,
1124+
tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-42' },
1125+
});
1126+
const sections = getItems(mockContext);
1127+
const item = findItem(sections);
1128+
expect(item).toBeDefined();
1129+
item.action(ed, mockContext);
1130+
expect(update).toHaveBeenCalledWith({
1131+
target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-42' },
1132+
mode: 'all',
1133+
});
1134+
});
1135+
1136+
it('action is a no-op when sdBlockId is missing', () => {
1137+
const update = vi.fn();
1138+
const ed = { ...mockEditor, doc: { toc: { update } } };
1139+
// showWhen would normally hide the item — invoke action directly to assert the guard.
1140+
mockContext = createMockContext({
1141+
editor: ed,
1142+
trigger: TRIGGERS.click,
1143+
tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' },
1144+
});
1145+
const sections = getItems(mockContext);
1146+
const item = findItem(sections);
1147+
// Re-invoke action with a context that has no sdBlockId.
1148+
item.action(ed, { ...mockContext, tocAncestor: { node: {}, pos: 5, sdBlockId: null } });
1149+
expect(update).not.toHaveBeenCalled();
1150+
});
1151+
1152+
it('action swallows errors thrown by editor.doc.toc.update', () => {
1153+
const update = vi.fn(() => {
1154+
throw new Error('boom');
1155+
});
1156+
const ed = { ...mockEditor, doc: { toc: { update } } };
1157+
mockContext = createMockContext({
1158+
editor: ed,
1159+
trigger: TRIGGERS.click,
1160+
tocAncestor: { node: {}, pos: 5, sdBlockId: 'toc-1' },
1161+
});
1162+
const sections = getItems(mockContext);
1163+
const item = findItem(sections);
1164+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1165+
expect(() => item.action(ed, mockContext)).not.toThrow();
1166+
expect(warn).toHaveBeenCalled();
1167+
warn.mockRestore();
1168+
});
1169+
});
10621170
});

packages/super-editor/src/editors/v1/extensions/field-update/field-update.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,10 @@ import {
66
resolveMainBodyEditor,
77
} from '../../document-api-adapters/helpers/word-statistics.js';
88

9-
/** Field types eligible for value updates via F9. */
9+
/** Stat-field types refreshed by F9 when the doc has no TOCs. */
1010
const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES']);
1111

12-
/**
13-
* Collect every `tableOfContents` node's sdBlockId, in document order.
14-
* @param {import('prosemirror-model').Node} doc
15-
* @returns {string[]}
16-
*/
12+
/** Every `tableOfContents` node's sdBlockId in document order. */
1713
function collectTocBlockIds(doc) {
1814
const ids = [];
1915
doc.descendants((node) => {

packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
* Tests for the FieldUpdate extension's updateFieldsInSelection command.
55
*
66
* Uses the numwords.docx fixture which contains NUMWORDS, NUMCHARS, and
7-
* NUMPAGES fields with known imported values.
7+
* NUMPAGES fields with known imported values for the stat-field path. The
8+
* TOC path is exercised via direct command-function invocation against a
9+
* synthetic doc/editor — no docx fixture required.
810
*/
911

10-
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
12+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
13+
import { Schema } from 'prosemirror-model';
1114
import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
1215
import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js';
16+
import { FieldUpdate } from './field-update.js';
1317

1418
describe('FieldUpdate extension', () => {
1519
let docData;
@@ -107,3 +111,135 @@ describe('FieldUpdate extension', () => {
107111
expect(numcharsField.attrs.resolvedText).toBe(expectedValue);
108112
});
109113
});
114+
115+
// ---------------------------------------------------------------------------
116+
// TOC path — invoked directly against synthetic state to avoid needing a
117+
// fully-imported TOC fixture.
118+
// ---------------------------------------------------------------------------
119+
120+
const tocSchema = new Schema({
121+
nodes: {
122+
doc: { content: 'block+' },
123+
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
124+
tableOfContents: {
125+
group: 'block',
126+
content: 'paragraph*',
127+
attrs: { sdBlockId: { default: null } },
128+
toDOM: () => ['div', 0],
129+
},
130+
text: { group: 'inline' },
131+
},
132+
});
133+
134+
const buildTocDoc = (sdBlockIds) => {
135+
const para = (txt) => tocSchema.nodes.paragraph.create({}, txt ? tocSchema.text(txt) : null);
136+
const tocs = sdBlockIds.map((id) => tocSchema.nodes.tableOfContents.create({ sdBlockId: id }, [para('entry')]));
137+
return tocSchema.nodes.doc.create({}, [para('intro'), ...tocs, para('outro')]);
138+
};
139+
140+
const runUpdateFields = (overrides) => {
141+
const { doc, editor } = overrides;
142+
const dispatch = 'dispatch' in overrides ? overrides.dispatch : () => {};
143+
// FieldUpdate is wrapped by Extension.create(); reach into config.addCommands
144+
// to invoke the raw command function the same way ExtensionService does.
145+
const commands = FieldUpdate.config.addCommands.call({ editor });
146+
const command = commands.updateFieldsInSelection();
147+
const tr = { setMeta: vi.fn() };
148+
const state = { doc, selection: { from: 0, to: 0 }, schema: tocSchema, tr };
149+
return { result: command({ editor, state, tr, dispatch }), tr };
150+
};
151+
152+
describe('updateFieldsInSelection — TOC path', () => {
153+
it('calls editor.doc.toc.update for every tableOfContents node in document order', () => {
154+
const update = vi.fn(() => ({ success: true }));
155+
const editor = { doc: { toc: { update } } };
156+
const doc = buildTocDoc(['toc-a', 'toc-b']);
157+
158+
const { result } = runUpdateFields({ doc, editor });
159+
160+
expect(result).toBe(true);
161+
expect(update).toHaveBeenCalledTimes(2);
162+
expect(update.mock.calls[0][0]).toEqual({
163+
target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-a' },
164+
mode: 'all',
165+
});
166+
expect(update.mock.calls[1][0]).toEqual({
167+
target: { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-b' },
168+
mode: 'all',
169+
});
170+
});
171+
172+
it('sets preventDispatch on the framework tr so CommandService skips its auto-dispatch', () => {
173+
const update = vi.fn(() => ({ success: true }));
174+
const editor = { doc: { toc: { update } } };
175+
const doc = buildTocDoc(['toc-a']);
176+
177+
const { tr } = runUpdateFields({ doc, editor });
178+
expect(tr.setMeta).toHaveBeenCalledWith('preventDispatch', true);
179+
});
180+
181+
it('returns true on a can()-style probe (no dispatch) when any TOC exists', () => {
182+
const update = vi.fn();
183+
const editor = { doc: { toc: { update } } };
184+
const doc = buildTocDoc(['toc-a']);
185+
186+
const { result } = runUpdateFields({ doc, editor, dispatch: undefined });
187+
expect(result).toBe(true);
188+
expect(update).not.toHaveBeenCalled();
189+
});
190+
191+
it('skips a TOC whose sdBlockId is missing or empty', () => {
192+
const update = vi.fn(() => ({ success: true }));
193+
const editor = { doc: { toc: { update } } };
194+
const doc = buildTocDoc([null, '', 'toc-real']);
195+
196+
runUpdateFields({ doc, editor });
197+
expect(update).toHaveBeenCalledTimes(1);
198+
expect(update.mock.calls[0][0].target.nodeId).toBe('toc-real');
199+
});
200+
201+
it('swallows toc.update errors and continues with the remaining TOCs', () => {
202+
const update = vi
203+
.fn()
204+
.mockImplementationOnce(() => {
205+
throw new Error('boom');
206+
})
207+
.mockImplementationOnce(() => ({ success: true }));
208+
const editor = { doc: { toc: { update } } };
209+
const doc = buildTocDoc(['toc-a', 'toc-b']);
210+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
211+
212+
const { result } = runUpdateFields({ doc, editor });
213+
expect(result).toBe(true);
214+
expect(update).toHaveBeenCalledTimes(2);
215+
expect(warnSpy).toHaveBeenCalled();
216+
warnSpy.mockRestore();
217+
});
218+
219+
it('falls through to the stat-field path when the doc has no TOCs', () => {
220+
const update = vi.fn();
221+
const editor = { doc: { toc: { update } } };
222+
const para = (txt) => tocSchema.nodes.paragraph.create({}, txt ? tocSchema.text(txt) : null);
223+
const doc = tocSchema.nodes.doc.create({}, [para('hello world')]);
224+
225+
const { tr } = runUpdateFields({ doc, editor });
226+
expect(update).not.toHaveBeenCalled();
227+
expect(tr.setMeta).not.toHaveBeenCalled(); // no preventDispatch when not taking the TOC path
228+
});
229+
});
230+
231+
describe('FieldUpdate extension shortcuts', () => {
232+
it('binds F9 to updateFieldsInSelection', () => {
233+
const ed = { commands: { updateFieldsInSelection: vi.fn(() => true) } };
234+
const shortcuts = FieldUpdate.config.addShortcuts.call({ editor: ed });
235+
expect(typeof shortcuts.F9).toBe('function');
236+
shortcuts.F9();
237+
expect(ed.commands.updateFieldsInSelection).toHaveBeenCalledTimes(1);
238+
});
239+
240+
it('does not bind any other key alongside F9', () => {
241+
const ed = { commands: { updateFieldsInSelection: vi.fn() } };
242+
const shortcuts = FieldUpdate.config.addShortcuts.call({ editor: ed });
243+
expect(Object.keys(shortcuts)).toEqual(['F9']);
244+
});
245+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { test, expect } from '../../fixtures/superdoc.js';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
const DOC_PATH = path.resolve(__dirname, '../../test-data/layout/toc-with-heading2.docx');
8+
9+
test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull');
10+
11+
/**
12+
* Reads every TOC entry's title text from the document.
13+
*
14+
* The rebuilt entries are wrapped in `run` nodes whose first text run holds
15+
* the title (without the page-number `tocPageNumber` mark).
16+
*/
17+
const readTocTitles = async (superdoc) =>
18+
superdoc.page.evaluate(() => {
19+
const editor = (window as unknown as { editor?: { state: { doc: unknown } } }).editor;
20+
if (!editor?.state?.doc) return [];
21+
const titles: string[] = [];
22+
(editor.state.doc as { descendants: (cb: (n: any) => boolean | void) => void }).descendants((node) => {
23+
if (node?.type?.name !== 'tableOfContents') return true;
24+
node.descendants((child: any) => {
25+
if (child?.type?.name !== 'paragraph') return true;
26+
// First non-page-number text run is the entry title.
27+
let captured = false;
28+
child.descendants((leaf: any) => {
29+
if (captured) return false;
30+
if (!leaf.isText || !leaf.text) return true;
31+
const isPageNumber = (leaf.marks ?? []).some((m: any) => m.type?.name === 'tocPageNumber');
32+
if (!isPageNumber) {
33+
titles.push(leaf.text);
34+
captured = true;
35+
}
36+
return true;
37+
});
38+
return false;
39+
});
40+
return false;
41+
});
42+
return titles;
43+
});
44+
45+
test('@behavior SD-2664: updateFieldsInSelection (F9) rebuilds every TOC entry from the document headings', async ({
46+
superdoc,
47+
}) => {
48+
await superdoc.loadDocument(DOC_PATH);
49+
await superdoc.waitForStable(2000);
50+
51+
// Capture the original TOC entries.
52+
const titlesBefore = await readTocTitles(superdoc);
53+
expect(titlesBefore.length).toBeGreaterThan(0);
54+
55+
// Read the heading texts that should drive the rebuilt TOC. The fixture
56+
// contains Heading1/Heading2 paragraphs in the body.
57+
const headingTexts = await superdoc.page.evaluate(() => {
58+
const editor = (window as unknown as { editor?: { state: { doc: unknown } } }).editor;
59+
if (!editor?.state?.doc) return [];
60+
const out: string[] = [];
61+
(editor.state.doc as { descendants: (cb: (n: any) => boolean | void) => void }).descendants((node) => {
62+
if (node?.type?.name === 'tableOfContents') return false; // skip TOC contents
63+
if (node?.type?.name !== 'paragraph') return true;
64+
const styleId = node.attrs?.paragraphProperties?.styleId;
65+
if (!styleId || !/^Heading[1-9]$/.test(styleId)) return true;
66+
let text = '';
67+
node.descendants((c: any) => {
68+
if (c.isText && c.text) text += c.text;
69+
return true;
70+
});
71+
if (text.trim()) out.push(text.trim());
72+
return true;
73+
});
74+
return out;
75+
});
76+
expect(headingTexts.length).toBeGreaterThan(0);
77+
78+
// Press F9 — the FieldUpdate extension binds it to updateFieldsInSelection,
79+
// which routes through editor.doc.toc.update for every TOC in the doc.
80+
await superdoc.executeCommand('updateFieldsInSelection');
81+
await superdoc.waitForStable(2000);
82+
83+
const titlesAfter = await readTocTitles(superdoc);
84+
// Every heading in the doc should now appear as an entry, and every entry
85+
// should map to a heading text. Order must match document order.
86+
expect(titlesAfter).toEqual(headingTexts);
87+
});

0 commit comments

Comments
 (0)