Skip to content

Commit 0b2c60a

Browse files
test: add body-story and header CRUD coverage for bookmark wrappers
1 parent 4d0f852 commit 0b2c60a

2 files changed

Lines changed: 255 additions & 0 deletions

File tree

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,33 @@ describe('bookmarksInsertWrapper', () => {
234234
});
235235

236236
describe('bookmarksRenameWrapper', () => {
237+
it('renames a body bookmark and returns a plain address without commit', () => {
238+
const { editor, tr } = makeEditor();
239+
240+
vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({
241+
pos: 5,
242+
name: 'old-name',
243+
bookmarkId: '1',
244+
endPos: 8,
245+
node: { attrs: { name: 'old-name', id: '1' } } as never,
246+
});
247+
248+
vi.mocked(findAllBookmarks).mockReturnValueOnce([]);
249+
250+
const result = bookmarksRenameWrapper(editor, {
251+
target: { kind: 'entity', entityType: 'bookmark', name: 'old-name' },
252+
newName: 'new-name',
253+
});
254+
255+
expect(result).toEqual({
256+
success: true,
257+
bookmark: { kind: 'entity', entityType: 'bookmark', name: 'new-name' },
258+
});
259+
expect(result.success && !('story' in result.bookmark)).toBe(true);
260+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(5, undefined, { name: 'new-name', id: '1' });
261+
expect(disposeEphemeralWriteRuntime).toHaveBeenCalled();
262+
});
263+
237264
it('returns a story-qualified address and commits non-body story renames', () => {
238265
const { editor } = makeEditor();
239266
const commit = vi.fn();
@@ -277,6 +304,30 @@ describe('bookmarksRenameWrapper', () => {
277304
});
278305

279306
describe('bookmarksRemoveWrapper', () => {
307+
it('removes a body bookmark and returns a plain address without commit', () => {
308+
const { editor, tr } = makeEditor();
309+
310+
vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({
311+
pos: 5,
312+
name: 'bm-remove',
313+
bookmarkId: '1',
314+
endPos: 8,
315+
node: { attrs: { name: 'bm-remove', id: '1' }, nodeSize: 1 } as never,
316+
});
317+
318+
const result = bookmarksRemoveWrapper(editor, {
319+
target: { kind: 'entity', entityType: 'bookmark', name: 'bm-remove' },
320+
});
321+
322+
expect(result).toEqual({
323+
success: true,
324+
bookmark: { kind: 'entity', entityType: 'bookmark', name: 'bm-remove' },
325+
});
326+
expect(result.success && !('story' in result.bookmark)).toBe(true);
327+
expect(tr.delete).toHaveBeenCalled();
328+
expect(disposeEphemeralWriteRuntime).toHaveBeenCalled();
329+
});
330+
280331
it('returns a story-qualified address and commits non-body story removals', () => {
281332
const { editor } = makeEditor();
282333
const commit = vi.fn();
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import type { Page } from '@playwright/test';
5+
import type { StoryLocator } from '@superdoc/document-api';
6+
import { test, expect } from '../../fixtures/superdoc.js';
7+
import { assertDocumentApiReady } from '../../helpers/document-api.js';
8+
9+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
10+
const HEADER_DOC_PATH = path.resolve(
11+
__dirname,
12+
'../../../../packages/super-editor/src/editors/v1/tests/data/longer-header-sign-area.docx',
13+
);
14+
15+
test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Test document not available');
16+
17+
/**
18+
* Resolves a header story locator and a text range within it that can be used
19+
* as an insertion target for bookmarks.
20+
*/
21+
async function resolveHeaderInsertionTarget(page: Page) {
22+
return page.evaluate(() => {
23+
const docApi = (window as any).editor?.doc;
24+
if (!docApi?.headerFooters?.list || !docApi?.find || !docApi?.bookmarks) {
25+
throw new Error('Required document APIs are unavailable.');
26+
}
27+
28+
const headers = docApi.headerFooters.list({ kind: 'header' });
29+
const entry = headers?.items?.find((item: any) => item?.variant === 'default') ?? headers?.items?.[0];
30+
if (!entry?.section?.sectionId) {
31+
throw new Error('Unable to resolve a header/footer slot for the test document.');
32+
}
33+
34+
const story: StoryLocator = {
35+
kind: 'story',
36+
storyType: 'headerFooterSlot',
37+
section: entry.section,
38+
headerFooterKind: 'header',
39+
variant: entry.variant ?? 'default',
40+
} as any;
41+
42+
const toRanges = (item: any) => {
43+
const blocks = Array.isArray(item?.blocks) ? item.blocks : [];
44+
return blocks
45+
.map((block: any) => {
46+
const blockId = block?.blockId;
47+
const start = block?.range?.start;
48+
const end = block?.range?.end;
49+
if (typeof blockId !== 'string' || typeof start !== 'number' || typeof end !== 'number') return null;
50+
return { kind: 'text' as const, blockId, range: { start, end } };
51+
})
52+
.filter(Boolean);
53+
};
54+
55+
const queryMatch = docApi?.query?.match;
56+
const queryResult =
57+
typeof queryMatch === 'function'
58+
? queryMatch({
59+
select: { type: 'text', pattern: 'Generic content header', mode: 'contains' },
60+
require: 'any',
61+
in: story,
62+
})
63+
: null;
64+
65+
const queryItem = Array.isArray(queryResult?.items) ? queryResult.items[0] : null;
66+
const findResult =
67+
queryItem == null
68+
? docApi.find({
69+
select: { type: 'text', pattern: 'Generic content header', mode: 'contains' },
70+
in: story,
71+
limit: 1,
72+
})
73+
: null;
74+
const firstItem = queryItem ?? (Array.isArray(findResult?.items) ? findResult.items[0] : null);
75+
const textRange = toRanges(firstItem)[0] ?? null;
76+
77+
if (!textRange) {
78+
throw new Error('Unable to resolve a header text range for bookmark insertion.');
79+
}
80+
81+
return { story, textRange };
82+
});
83+
}
84+
85+
test.describe('Header bookmark CRUD', () => {
86+
test('@behavior insert, get, list, rename, and remove a bookmark in a header story', async ({ superdoc }) => {
87+
await superdoc.loadDocument(HEADER_DOC_PATH);
88+
await superdoc.waitForStable(2000);
89+
await assertDocumentApiReady(superdoc.page);
90+
91+
const { story, textRange } = await resolveHeaderInsertionTarget(superdoc.page);
92+
const bookmarkName = `hf-crud-${Date.now()}`;
93+
94+
// --- Insert ---
95+
const insertResult = await superdoc.page.evaluate(
96+
({ name, at, storyLocator }) => {
97+
return (window as any).editor.doc.bookmarks.insert({
98+
name,
99+
at: { kind: 'text', segments: [at], story: storyLocator },
100+
});
101+
},
102+
{ name: bookmarkName, at: textRange, storyLocator: story },
103+
);
104+
105+
expect(insertResult.success).toBe(true);
106+
expect(insertResult.bookmark).toEqual(
107+
expect.objectContaining({
108+
kind: 'entity',
109+
entityType: 'bookmark',
110+
name: bookmarkName,
111+
story: expect.objectContaining({ storyType: 'headerFooterSlot' }),
112+
}),
113+
);
114+
115+
// --- Get ---
116+
const getResult = await superdoc.page.evaluate(
117+
({ name, storyLocator }) => {
118+
return (window as any).editor.doc.bookmarks.get({
119+
target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator },
120+
});
121+
},
122+
{ name: bookmarkName, storyLocator: story },
123+
);
124+
125+
expect(getResult).toEqual(
126+
expect.objectContaining({
127+
name: bookmarkName,
128+
address: expect.objectContaining({
129+
kind: 'entity',
130+
entityType: 'bookmark',
131+
name: bookmarkName,
132+
}),
133+
}),
134+
);
135+
136+
// --- List (filtered to header story) ---
137+
const listResult = await superdoc.page.evaluate(
138+
({ storyLocator }) => {
139+
return (window as any).editor.doc.bookmarks.list({ in: storyLocator });
140+
},
141+
{ storyLocator: story },
142+
);
143+
144+
const listedNames: string[] = (listResult?.items ?? []).map((item: any) => item?.name ?? item?.address?.name);
145+
expect(listedNames).toContain(bookmarkName);
146+
147+
// --- Rename ---
148+
const renamedName = `${bookmarkName}-renamed`;
149+
const renameResult = await superdoc.page.evaluate(
150+
({ oldName, newName, storyLocator }) => {
151+
return (window as any).editor.doc.bookmarks.rename({
152+
target: { kind: 'entity', entityType: 'bookmark', name: oldName, story: storyLocator },
153+
newName,
154+
});
155+
},
156+
{ oldName: bookmarkName, newName: renamedName, storyLocator: story },
157+
);
158+
159+
expect(renameResult.success).toBe(true);
160+
expect(renameResult.bookmark).toEqual(
161+
expect.objectContaining({
162+
name: renamedName,
163+
story: expect.objectContaining({ storyType: 'headerFooterSlot' }),
164+
}),
165+
);
166+
167+
// --- Get after rename ---
168+
const getAfterRename = await superdoc.page.evaluate(
169+
({ name, storyLocator }) => {
170+
return (window as any).editor.doc.bookmarks.get({
171+
target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator },
172+
});
173+
},
174+
{ name: renamedName, storyLocator: story },
175+
);
176+
177+
expect(getAfterRename).toEqual(expect.objectContaining({ name: renamedName }));
178+
179+
// --- Remove ---
180+
const removeResult = await superdoc.page.evaluate(
181+
({ name, storyLocator }) => {
182+
return (window as any).editor.doc.bookmarks.remove({
183+
target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator },
184+
});
185+
},
186+
{ name: renamedName, storyLocator: story },
187+
);
188+
189+
expect(removeResult.success).toBe(true);
190+
191+
// --- List after remove (bookmark should be gone) ---
192+
const listAfterRemove = await superdoc.page.evaluate(
193+
({ storyLocator }) => {
194+
return (window as any).editor.doc.bookmarks.list({ in: storyLocator });
195+
},
196+
{ storyLocator: story },
197+
);
198+
199+
const remainingNames: string[] = (listAfterRemove?.items ?? []).map(
200+
(item: any) => item?.name ?? item?.address?.name,
201+
);
202+
expect(remainingNames).not.toContain(renamedName);
203+
});
204+
});

0 commit comments

Comments
 (0)