Skip to content

Commit a346499

Browse files
committed
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 76f281c commit a346499

8 files changed

Lines changed: 774 additions & 0 deletions

File tree

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: 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+
});
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
vi.mock('@superdoc/core/collaboration/helpers.js', () => ({
4+
syncCommentsToClients: vi.fn(),
5+
}));
6+
7+
import useComment from './use-comment.js';
8+
import { syncCommentsToClients } from '@superdoc/core/collaboration/helpers.js';
9+
10+
const makeSuperdoc = () => ({
11+
emit: vi.fn(),
12+
activeEditor: {
13+
commands: {
14+
resolveComment: vi.fn(),
15+
setCommentInternal: vi.fn(),
16+
setActiveComment: vi.fn(),
17+
},
18+
},
19+
});
20+
21+
describe('use-comment: extended coverage', () => {
22+
beforeEach(() => {
23+
syncCommentsToClients.mockClear();
24+
});
25+
26+
describe('resolveComment', () => {
27+
it('sets resolved fields and emits a resolved event', () => {
28+
const c = useComment({ commentId: 'c-1', fileId: 'doc-1' });
29+
const superdoc = makeSuperdoc();
30+
c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc });
31+
expect(c.resolvedByEmail).toBe('a@b.com');
32+
expect(c.resolvedByName).toBe('Alice');
33+
expect(typeof c.resolvedTime).toBe('number');
34+
expect(superdoc.emit).toHaveBeenCalledWith(
35+
'comments-update',
36+
expect.objectContaining({ type: expect.any(String) }),
37+
);
38+
expect(superdoc.activeEditor.commands.resolveComment).toHaveBeenCalled();
39+
});
40+
41+
it('is a no-op when already resolved', () => {
42+
const c = useComment({ commentId: 'c-1', resolvedTime: 1234 });
43+
const superdoc = makeSuperdoc();
44+
c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc });
45+
expect(c.resolvedByEmail).toBeNull();
46+
expect(superdoc.emit).not.toHaveBeenCalled();
47+
});
48+
49+
it('emits when tracked change is present (suggestion resolve path)', () => {
50+
const c = useComment({ commentId: 'c-1', trackedChange: { insert: {} } });
51+
const superdoc = makeSuperdoc();
52+
c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc });
53+
expect(superdoc.emit).toHaveBeenCalled();
54+
expect(superdoc.activeEditor.commands.resolveComment).toHaveBeenCalled();
55+
});
56+
});
57+
58+
describe('setIsInternal', () => {
59+
it('updates the flag and emits', () => {
60+
const c = useComment({ commentId: 'c-1', isInternal: true });
61+
const superdoc = makeSuperdoc();
62+
c.setIsInternal({ isInternal: false, superdoc });
63+
expect(c.isInternal).toBe(false);
64+
expect(superdoc.emit).toHaveBeenCalled();
65+
expect(superdoc.activeEditor.commands.setCommentInternal).toHaveBeenCalledWith(
66+
expect.objectContaining({ isInternal: false }),
67+
);
68+
});
69+
70+
it('short-circuits when value is unchanged', () => {
71+
const c = useComment({ commentId: 'c-1', isInternal: true });
72+
const superdoc = makeSuperdoc();
73+
c.setIsInternal({ isInternal: true, superdoc });
74+
expect(superdoc.emit).not.toHaveBeenCalled();
75+
});
76+
77+
it('does not call editor commands when activeEditor is missing', () => {
78+
const c = useComment({ commentId: 'c-1', isInternal: true });
79+
const superdoc = { emit: vi.fn(), activeEditor: null };
80+
c.setIsInternal({ isInternal: false, superdoc });
81+
expect(superdoc.emit).toHaveBeenCalled();
82+
expect(c.isInternal).toBe(false);
83+
});
84+
});
85+
86+
describe('setActive', () => {
87+
it('invokes setActiveComment on the active editor', () => {
88+
const c = useComment({ commentId: 'c-1' });
89+
const superdoc = makeSuperdoc();
90+
c.setActive(superdoc);
91+
expect(superdoc.activeEditor.commands.setActiveComment).toHaveBeenCalledWith(
92+
expect.objectContaining({ commentId: 'c-1' }),
93+
);
94+
});
95+
96+
it('is a no-op when no active editor', () => {
97+
const c = useComment({ commentId: 'c-1' });
98+
expect(() => c.setActive({ activeEditor: null })).not.toThrow();
99+
});
100+
});
101+
102+
describe('setText', () => {
103+
it('updates comment text and extracts mentions', () => {
104+
const c = useComment({ commentId: 'c-1' });
105+
const superdoc = makeSuperdoc();
106+
c.setText({
107+
text:
108+
'Hello <span data-type="mention" email="a@b.com" name="Alice"></span> and ' +
109+
'<span data-type="mention" email="c@d.com" name="Carol"></span>',
110+
superdoc,
111+
});
112+
expect(c.commentText).toContain('Hello');
113+
expect(c.mentions).toHaveLength(2);
114+
expect(c.mentions[0]).toMatchObject({ email: 'a@b.com', name: 'Alice' });
115+
expect(superdoc.emit).toHaveBeenCalled();
116+
});
117+
118+
it('deduplicates repeated mentions by email + name', () => {
119+
const c = useComment({ commentId: 'c-1' });
120+
const superdoc = makeSuperdoc();
121+
c.setText({
122+
text:
123+
'<span data-type="mention" email="a@b.com" name="Alice"></span>' +
124+
'<span data-type="mention" email="a@b.com" name="Alice"></span>',
125+
superdoc,
126+
});
127+
expect(c.mentions).toHaveLength(1);
128+
});
129+
130+
it('skips emit when suppressUpdate is true', () => {
131+
const c = useComment({ commentId: 'c-1' });
132+
const superdoc = makeSuperdoc();
133+
c.setText({ text: 'silent update', superdoc, suppressUpdate: true });
134+
expect(c.commentText).toBe('silent update');
135+
expect(superdoc.emit).not.toHaveBeenCalled();
136+
});
137+
});
138+
139+
describe('updatePosition', () => {
140+
it('translates coords relative to the parent bounding rect', () => {
141+
const c = useComment({
142+
commentId: 'c-1',
143+
selection: { documentId: 'doc-1', selectionBounds: { top: 0, left: 0 } },
144+
});
145+
const parent = { getBoundingClientRect: () => ({ top: 50, left: 0 }) };
146+
c.updatePosition({ top: 100, left: 10, right: 20, bottom: 150 }, parent);
147+
expect(c.selection.selectionBounds).toEqual({
148+
top: 50,
149+
left: 10,
150+
right: 20,
151+
bottom: 100,
152+
});
153+
expect(c.selection.source).toBe('super-editor');
154+
});
155+
});
156+
157+
describe('getCommentUser', () => {
158+
it('returns imported author when present', () => {
159+
const c = useComment({
160+
commentId: 'c-1',
161+
importedAuthor: { name: 'Imported One', email: 'imp@x.com' },
162+
});
163+
expect(c.getCommentUser()).toEqual({ name: 'Imported One', email: 'imp@x.com' });
164+
});
165+
166+
it('uses fallback "(Imported)" when importedAuthor.name is missing', () => {
167+
const c = useComment({
168+
commentId: 'c-1',
169+
importedAuthor: { email: 'imp@x.com' },
170+
});
171+
expect(c.getCommentUser()).toEqual({ name: '(Imported)', email: 'imp@x.com' });
172+
});
173+
174+
it('returns creator info when no imported author', () => {
175+
const c = useComment({
176+
commentId: 'c-1',
177+
creatorName: 'Alice',
178+
creatorEmail: 'a@b.com',
179+
creatorImage: '/a.png',
180+
});
181+
expect(c.getCommentUser()).toEqual({
182+
name: 'Alice',
183+
email: 'a@b.com',
184+
image: '/a.png',
185+
});
186+
});
187+
});
188+
189+
describe('getValues', () => {
190+
it('maps mentions to fall back to email when name is missing', () => {
191+
const c = useComment({ commentId: 'c-1' });
192+
const superdoc = makeSuperdoc();
193+
c.setText({
194+
text: '<span data-type="mention" email="x@y.com"></span>',
195+
superdoc,
196+
suppressUpdate: true,
197+
});
198+
const v = c.getValues();
199+
expect(v.mentions[0]).toEqual({ email: 'x@y.com', name: 'x@y.com' });
200+
});
201+
202+
it('returns selection.getValues() when selection was provided', () => {
203+
const c = useComment({
204+
commentId: 'c-1',
205+
selection: { documentId: 'doc-1', page: 2, source: 'superdoc' },
206+
});
207+
const v = c.getValues();
208+
expect(v.selection.documentId).toBe('doc-1');
209+
expect(v.selection.page).toBe(2);
210+
});
211+
212+
it('falls back to synthetic selection when none provided', () => {
213+
const c = useComment({ commentId: 'c-1', fileId: 'doc-1' });
214+
const v = c.getValues();
215+
expect(v.selection).toBeDefined();
216+
expect(v.selection.documentId).toBe('doc-1');
217+
});
218+
});
219+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { mount } from '@vue/test-utils';
3+
import { defineComponent, h } from 'vue';
4+
5+
vi.mock('@superdoc/super-editor', () => ({
6+
Toolbar: defineComponent({
7+
name: 'ToolbarStub',
8+
emits: ['command'],
9+
setup(_, { emit, expose }) {
10+
expose({ triggerCommand: (payload) => emit('command', payload) });
11+
return () => h('div', { class: 'toolbar-stub' });
12+
},
13+
}),
14+
}));
15+
16+
import SuperToolbar from './SuperToolbar.vue';
17+
18+
describe('SuperToolbar.vue', () => {
19+
const onToolbarCommand = vi.fn();
20+
21+
const mountToolbar = () => {
22+
return mount(SuperToolbar, {
23+
global: {
24+
config: {
25+
globalProperties: {
26+
$superdoc: { onToolbarCommand },
27+
},
28+
},
29+
},
30+
});
31+
};
32+
33+
it('renders the inner Toolbar', () => {
34+
const wrapper = mountToolbar();
35+
expect(wrapper.find('.toolbar-stub').exists()).toBe(true);
36+
});
37+
38+
it('forwards toolbar commands to $superdoc.onToolbarCommand', async () => {
39+
const wrapper = mountToolbar();
40+
const inner = wrapper.findComponent({ name: 'ToolbarStub' });
41+
await inner.vm.$emit('command', { item: 'bold', argument: true });
42+
expect(onToolbarCommand).toHaveBeenCalledWith({ item: 'bold', argument: true });
43+
});
44+
45+
it('exposes innerToolbar via defineExpose', () => {
46+
const wrapper = mountToolbar();
47+
expect(wrapper.vm.innerToolbar).toBeDefined();
48+
});
49+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { mount } from '@vue/test-utils';
3+
import { ref } from 'vue';
4+
5+
vi.mock('@stores/superdoc-store', () => ({
6+
useSuperdocStore: () => ({
7+
documentUsers: ref([
8+
{ name: 'Alice', email: 'a@x.com' },
9+
{ name: 'Bob', email: 'b@x.com' },
10+
{ name: 'Carol', email: 'c@x.com' },
11+
]),
12+
}),
13+
}));
14+
15+
vi.mock('pinia', async (orig) => {
16+
const actual = await orig();
17+
return {
18+
...actual,
19+
storeToRefs: (store) => store,
20+
};
21+
});
22+
23+
import DocumentUsers from './DocumentUsers.vue';
24+
25+
describe('DocumentUsers.vue', () => {
26+
it('renders all users when no filter is provided', () => {
27+
const wrapper = mount(DocumentUsers);
28+
const rows = wrapper.findAll('.user-row');
29+
expect(rows).toHaveLength(3);
30+
expect(rows[0].text()).toBe('Alice');
31+
});
32+
33+
it('filters users by case-insensitive prefix match', () => {
34+
const wrapper = mount(DocumentUsers, { props: { filter: 'b' } });
35+
const rows = wrapper.findAll('.user-row');
36+
expect(rows).toHaveLength(1);
37+
expect(rows[0].text()).toBe('Bob');
38+
});
39+
40+
it('matches filter case-insensitively', () => {
41+
const wrapper = mount(DocumentUsers, { props: { filter: 'A' } });
42+
expect(wrapper.findAll('.user-row')).toHaveLength(1);
43+
});
44+
45+
it('renders no rows when filter has no matches', () => {
46+
const wrapper = mount(DocumentUsers, { props: { filter: 'zzz' } });
47+
expect(wrapper.findAll('.user-row')).toHaveLength(0);
48+
});
49+
});

0 commit comments

Comments
 (0)