Files
edentext/tests/unit/caption.test.ts
T
Steffen Becker 61ddf69d54 fix: a caption follows the frame's place, not the paragraph's
A picture with "above and below" wrap is a frame centred in the
column while its paragraph carries no alignment at all, so the
caption still went to the left margin. framePlacement reads the
place off the frame: centred, the side it is set against, or its
offset as the caption's indent. The debug dump gains a frames
section — every other one measures the flow downwards, and this
is a sideways question.
2026-08-14 22:40:42 +02:00

125 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { Schema } from '@tiptap/pm/model';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import TextAlign from '@tiptap/extension-text-align';
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf, SequenceField, framePlacement } from '../../src/lib/editor/extensions/caption';
// The numbering rule both word processors apply: one counter per category, in document
// order. A schema of its own keeps the check off the editor's full extension list.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
sequenceField: {
group: 'inline', inline: true, atom: true,
attrs: { category: { default: 'figure' }, number: { default: 1 } },
toDOM: () => ['span'],
},
image: {
group: 'inline', inline: true, atom: true,
attrs: { wrap: { default: 'inline' }, wrapAlign: { default: null }, wrapOffset: { default: null } },
toDOM: () => ['img'],
},
},
});
const seq = (category: string, number = 1) => schema.nodes.sequenceField.create({ category, number });
const para = (...content: ReturnType<typeof seq>[]) => schema.nodes.paragraph.create(null, content);
describe('sequenceFields', () => {
it('counts each category on its own, in document order', () => {
const doc = schema.nodes.doc.create(null, [
para(seq('figure')), para(seq('table')), para(seq('figure')), para(seq('table')), para(seq('figure')),
]);
expect(sequenceFields(doc).map((f) => `${f.node.attrs.category}${f.number}`))
.toEqual(['figure1', 'table1', 'figure2', 'table2', 'figure3']);
});
it('numbers two fields in one paragraph left to right', () => {
const doc = schema.nodes.doc.create(null, [para(seq('figure'), seq('figure'))]);
expect(sequenceFields(doc).map((f) => f.number)).toEqual([1, 2]);
});
it('reports the stale cached number, which is what triggers a renumber', () => {
const doc = schema.nodes.doc.create(null, [para(seq('figure', 7))]);
const [only] = sequenceFields(doc);
expect(only.number).toBe(1);
expect(only.node.attrs.number).toBe(7);
});
});
describe('framePlacement', () => {
const picture = (attrs: Record<string, unknown>) =>
schema.nodes.paragraph.create(null, schema.nodes.image.create(attrs));
it('centres the caption under a frame that names neither side nor offset', () => {
expect(framePlacement(picture({ wrap: 'topBottom' }))).toEqual({ textAlign: 'center', indent: null });
});
it('follows the side a frame is set against', () => {
expect(framePlacement(picture({ wrap: 'topBottom', wrapAlign: 'right' })))
.toEqual({ textAlign: 'right', indent: null });
});
it('indents to a frame’s own offset, which no alignment can express', () => {
expect(framePlacement(picture({ wrap: 'topBottom', wrapOffset: 3.4 })))
.toEqual({ textAlign: null, indent: 3.4 });
});
it('leaves an inline or side-wrapped picture to the flow', () => {
// A side wrap has the caption beside the frame — where LibreOffice puts it too.
expect(framePlacement(picture({ wrap: 'inline' }))).toBeNull();
expect(framePlacement(picture({ wrap: 'right' }))).toBeNull();
expect(framePlacement(schema.nodes.paragraph.create(null, schema.text('no picture')))).toBeNull();
});
});
describe('insertCaption', () => {
const makeEditor = (textAlign: string | null) => {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({
element: el,
extensions: [Document, Paragraph, Text, SequenceField, TextAlign.configure({ types: ['paragraph'] })],
content: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign }, content: [{ type: 'text', text: 'picture' }] }] },
});
};
const insert = (editor: Editor) =>
editor.chain().focus().insertCaption({ category: 'figure', label: 'Figure', separator: ': ', text: 'x', above: false }).run();
it('takes the alignment of the block it captions, so it stands under the picture', () => {
const editor = makeEditor('center');
insert(editor);
const [, caption] = editor.getJSON().content as any[];
expect(caption.attrs.textAlign).toBe('center');
editor.destroy();
});
it('leaves the caption of an unaligned block unaligned', () => {
const editor = makeEditor(null);
insert(editor);
const [, caption] = editor.getJSON().content as any[];
expect(caption.attrs.textAlign ?? null).toBe(null);
editor.destroy();
});
});
describe('category names', () => {
it('reads both spellings of the picture counter', () => {
expect(ODF_SEQ_CATEGORY.Illustration).toBe('figure'); // LibreOffice's
expect(ODF_SEQ_CATEGORY.Figure).toBe('figure'); // Word's
expect(ODF_SEQ_CATEGORY.Table).toBe('table');
expect(ODF_SEQ_CATEGORY.Drawing).toBeUndefined();
});
it('falls back to the picture category', () => {
expect(seqCategoryOf('table')).toBe('table');
expect(seqCategoryOf(null)).toBe('figure');
expect(seqCategoryOf('nonsense')).toBe('figure');
});
});