Skip to content

Commit e5829cf

Browse files
authored
test(superdoc): raise package coverage to 90% (#2836)
* test(superdoc): add unit tests for pdf, comments, and pdf-viewer Adds test coverage for previously untested files: - core/pdf/pdf-adapter.js — factory, adapter, CDN worker helper - components/CommentsLayer/use-conversation.js — conversation composable - components/CommentsLayer/use-floating-comment.js — floating comment state - components/CommentsLayer/CommentGroup.vue — grouped comment rendering - components/PdfViewer/PdfViewer.vue — top-level viewer with adapter mocks - components/PdfViewer/PdfViewerDocument.vue — page list wrapper - components/PdfViewer/PdfViewerPage.vue — page render + text layer 62 new tests. Lifts filtered coverage from 78.6% to 81.1%. * test(superdoc): add tests for composables and comments list - composables/use-field.js — useField + sub-handlers (SELECT/IMAGE/CHECKBOX) - composables/use-ai.js — AI writer cursor positioning, fallbacks, tool click - components/CommentsLayer/commentsList/super-comments-list.js — Vue app lifecycle - components/Whiteboard/use-whiteboard.js — event wiring, page ready, teardown 46 new tests. Lifts filtered coverage 81.1% → 83.3%. * test(superdoc): cover WhiteboardPage canvas interactions Mock Konva Stage/Layer/Line/Text/Image/Transformer and trigger stage/text-node event listeners to exercise the interactive code paths: draw/erase flows, stage click handlers (text + select), text node selection + drag + Delete, image node events. 38 tests. Lifts WhiteboardPage coverage 40% → 81%, package 83.3% → 88.5%. * fix(test): add textBetween/nodesBetween to plan-engine tr.doc mocks The word-diff executor added in #2817 calls `tr.doc.textBetween(...)` and `tr.doc.nodesBetween(...)` in the text.rewrite path, but the `tr.doc` mocks in determinism-stress, executor, and preview-parity tests were never updated to match. That broke determinism-stress, executeCompiledPlan, previewPlan, and revision-drift-guard tests on main with `TypeError: tr.doc.textBetween is not a function` / `doc.nodesBetween is not a function`. * test(superdoc): push coverage past 90% Add targeted tests to close remaining gaps: - use-comment extended — resolveComment, setIsInternal, setText, mentions - superdoc-store extended — reset, getDocument, handlePageReady, getPageBounds, areDocumentsReady, setExceptionHandler, collaboration mode - Whiteboard extended — tool/enabled propagation, opacity clamping, callbacks - New small files: comment-schemas, DocumentUsers.vue, SuperToolbar.vue, use-selection, CommentsLayer/helpers formatDate Lifts package coverage from 83.3% to 90.1%.
1 parent 3428fc9 commit e5829cf

23 files changed

Lines changed: 2790 additions & 0 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ function makeFreshEditor(): { editor: Editor; dispatch: ReturnType<typeof vi.fn>
9797
doc: {
9898
resolve: () => ({ marks: () => [] }),
9999
textContent: 'Hello world',
100+
textBetween: vi.fn(() => 'Hello world'),
101+
nodesBetween: vi.fn(),
100102
},
101103
};
102104
tr.replaceWith.mockReturnValue(tr);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ function makeEditor(text = 'Hello'): {
121121
doc: {
122122
resolve: () => ({ marks: () => [] }),
123123
textContent: text,
124+
textBetween: vi.fn(() => text),
125+
nodesBetween: vi.fn(),
124126
},
125127
};
126128
tr.replace.mockReturnValue(tr);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ function makeEditor(text = 'Hello'): {
116116
textContent: text,
117117
descendants: vi.fn(),
118118
textBetween: vi.fn(() => text),
119+
nodesBetween: vi.fn(),
119120
},
120121
};
121122
tr.replaceWith.mockReturnValue(tr);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { mount } from '@vue/test-utils';
3+
import { defineComponent, h, ref } from 'vue';
4+
5+
let commentsStoreStub;
6+
7+
vi.mock('@superdoc/stores/comments-store', () => ({
8+
useCommentsStore: () => commentsStoreStub,
9+
}));
10+
11+
vi.mock('@superdoc/components/CommentsLayer/CommentDialog.vue', () => ({
12+
default: defineComponent({
13+
name: 'CommentDialogStub',
14+
props: ['data', 'user', 'currentDocument', 'showGrouped'],
15+
setup(props) {
16+
return () =>
17+
h('div', {
18+
class: 'comment-dialog-stub',
19+
'data-id': props.data.conversationId,
20+
});
21+
},
22+
}),
23+
}));
24+
25+
import CommentGroup from './CommentGroup.vue';
26+
27+
const clickOutsideDirective = { mounted: () => {}, unmounted: () => {} };
28+
29+
const makeConvo = (overrides = {}) => ({
30+
conversationId: 'c-1',
31+
selection: { documentId: 'doc-1' },
32+
isFocused: false,
33+
...overrides,
34+
});
35+
36+
const mountGroup = (props = {}) =>
37+
mount(CommentGroup, {
38+
props: {
39+
data: [makeConvo()],
40+
currentDocument: { id: 'doc-1' },
41+
parent: document.createElement('div'),
42+
...props,
43+
},
44+
global: {
45+
directives: { 'click-outside': clickOutsideDirective },
46+
},
47+
});
48+
49+
describe('CommentGroup.vue', () => {
50+
beforeEach(() => {
51+
commentsStoreStub = {
52+
getCommentLocation: vi.fn(() => ({ top: 100, left: 20 })),
53+
activeComment: ref(null),
54+
};
55+
});
56+
57+
it('renders the collapsed badge with the group count when no comment is active', () => {
58+
const wrapper = mountGroup({
59+
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
60+
});
61+
const bubble = wrapper.find('.number-bubble');
62+
expect(bubble.exists()).toBe(true);
63+
expect(bubble.text()).toBe('2');
64+
});
65+
66+
it('applies the computed top style based on comment location', () => {
67+
const wrapper = mountGroup();
68+
const group = wrapper.find('.comments-group');
69+
expect(group.attributes('style')).toMatch(/top:\s*90px/);
70+
});
71+
72+
it('renders empty style when getCommentLocation returns null', () => {
73+
commentsStoreStub.getCommentLocation.mockReturnValueOnce(null);
74+
const wrapper = mountGroup();
75+
// no top declared
76+
expect(wrapper.find('.comments-group').attributes('style') || '').not.toMatch(/top:/);
77+
});
78+
79+
it('renders nothing-style (no attr) when data is empty', () => {
80+
const wrapper = mountGroup({ data: [] });
81+
const style = wrapper.find('.comments-group').attributes('style');
82+
expect(style === undefined || style === '').toBe(true);
83+
});
84+
85+
it('expands and renders CommentDialogs when clicked', async () => {
86+
const wrapper = mountGroup({
87+
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
88+
});
89+
await wrapper.find('.comments-group').trigger('click');
90+
expect(wrapper.find('.comments-group.expanded').exists()).toBe(true);
91+
expect(wrapper.findAll('.comment-dialog-stub')).toHaveLength(2);
92+
});
93+
94+
it('expands by default when the group contains the active comment', () => {
95+
commentsStoreStub.activeComment.value = 'c-2';
96+
const wrapper = mountGroup({
97+
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
98+
});
99+
// when active, the collapsed node is suppressed and expanded renders only active convo
100+
expect(wrapper.find('.comments-group.expanded').exists()).toBe(true);
101+
const dialogs = wrapper.findAll('.comment-dialog-stub');
102+
expect(dialogs).toHaveLength(1);
103+
expect(dialogs[0].attributes('data-id')).toBe('c-2');
104+
});
105+
106+
it('resolves getCommentLocation with the active convo selection first', () => {
107+
commentsStoreStub.activeComment.value = 'c-2';
108+
const data = [
109+
makeConvo({ conversationId: 'c-1', selection: { documentId: 'doc-1', page: 1 } }),
110+
makeConvo({ conversationId: 'c-2', selection: { documentId: 'doc-1', page: 9 } }),
111+
];
112+
mountGroup({ data });
113+
expect(commentsStoreStub.getCommentLocation).toHaveBeenCalledWith(data[1].selection, expect.any(Object));
114+
});
115+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { conversation, comment } from './comment-schemas.js';
3+
4+
describe('comment-schemas', () => {
5+
it('exposes a conversation template with null defaults', () => {
6+
expect(conversation).toEqual({
7+
conversationId: null,
8+
documentId: null,
9+
creatorEmail: null,
10+
creatorName: null,
11+
comments: [],
12+
selection: null,
13+
});
14+
});
15+
16+
it('exposes a comment template with user/timestamp placeholders', () => {
17+
expect(comment).toEqual({
18+
comment: null,
19+
user: { name: null, email: null },
20+
timestamp: null,
21+
});
22+
});
23+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { defineComponent, h } from 'vue';
3+
4+
vi.mock('./commentsList.vue', () => ({
5+
default: defineComponent({
6+
name: 'CommentsListStub',
7+
setup() {
8+
return () => h('div', { class: 'comments-list-stub' });
9+
},
10+
}),
11+
}));
12+
13+
vi.mock('@superdoc/common', () => ({
14+
vClickOutside: { mounted: vi.fn(), unmounted: vi.fn() },
15+
}));
16+
17+
import { SuperComments } from './super-comments-list.js';
18+
19+
describe('SuperComments', () => {
20+
let element;
21+
const superdocStub = { id: 'sd-1' };
22+
23+
beforeEach(() => {
24+
element = document.createElement('div');
25+
document.body.appendChild(element);
26+
});
27+
28+
it('mounts the Vue app into the provided element on construction', () => {
29+
const instance = new SuperComments({ element, comments: [] }, superdocStub);
30+
expect(instance.app).not.toBeNull();
31+
expect(instance.element).toBe(element);
32+
expect(element.querySelector('.comments-list-stub')).not.toBeNull();
33+
});
34+
35+
it('exposes the merged config', () => {
36+
const comments = [{ id: 'c-1' }];
37+
const instance = new SuperComments({ element, comments }, superdocStub);
38+
expect(instance.config.comments).toBe(comments);
39+
expect(instance.config.element).toBe(element);
40+
});
41+
42+
it('exposes the superdoc reference as $superdoc on the Vue app', () => {
43+
const instance = new SuperComments({ element }, superdocStub);
44+
expect(instance.app.config.globalProperties.$superdoc).toBe(superdocStub);
45+
});
46+
47+
it('resolves element via selector when no element is provided', () => {
48+
const el = document.createElement('div');
49+
el.id = 'my-comments-host';
50+
document.body.appendChild(el);
51+
const instance = new SuperComments({ selector: 'my-comments-host' }, superdocStub);
52+
expect(instance.element).toBe(el);
53+
});
54+
55+
it('close() unmounts the app and clears refs', () => {
56+
const instance = new SuperComments({ element }, superdocStub);
57+
instance.close();
58+
expect(instance.app).toBeNull();
59+
expect(instance.container).toBeNull();
60+
expect(instance.element).toBeNull();
61+
});
62+
63+
it('close() is a no-op when there is no app', () => {
64+
const instance = new SuperComments({ element }, superdocStub);
65+
instance.close();
66+
expect(() => instance.close()).not.toThrow();
67+
});
68+
69+
it('open() re-creates the app after close', () => {
70+
const instance = new SuperComments({ element }, superdocStub);
71+
instance.close();
72+
// Re-provide element and re-open
73+
instance.element = document.body.appendChild(document.createElement('div'));
74+
instance.open();
75+
expect(instance.app).not.toBeNull();
76+
});
77+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { formatDate } from './helpers.js';
3+
4+
describe('formatDate', () => {
5+
it('formats a morning timestamp with AM and padded minutes', () => {
6+
// 2024-01-15 09:05:00 local
7+
const ts = new Date(2024, 0, 15, 9, 5).getTime();
8+
expect(formatDate(ts)).toBe('9:05AM Jan 15');
9+
});
10+
11+
it('formats an afternoon timestamp with PM', () => {
12+
const ts = new Date(2024, 5, 1, 14, 30).getTime();
13+
expect(formatDate(ts)).toBe('2:30PM Jun 1');
14+
});
15+
16+
it('displays midnight as 12 AM', () => {
17+
const ts = new Date(2024, 11, 31, 0, 0).getTime();
18+
expect(formatDate(ts)).toBe('12:00AM Dec 31');
19+
});
20+
21+
it('displays noon as 12 PM', () => {
22+
const ts = new Date(2024, 6, 4, 12, 45).getTime();
23+
expect(formatDate(ts)).toBe('12:45PM Jul 4');
24+
});
25+
26+
it('pads single-digit minutes with a leading zero', () => {
27+
const ts = new Date(2024, 2, 2, 8, 9).getTime();
28+
expect(formatDate(ts)).toBe('8:09AM Mar 2');
29+
});
30+
});

0 commit comments

Comments
 (0)