Skip to content

Commit 33377b9

Browse files
authored
test(superdoc): push test coverage (#2840)
* test(superdoc): quick wins + WhiteboardPage deep coverage - read-file, use-selected-text, use-high-contrast, comment-focus (new) - WhiteboardPage image onload/onerror/transform paths + editTextNode - create-app.js devtools edge cases Lifts coverage 90.07% → 91.33%. * test(collab): cover setupAwarenessHandler branches
1 parent 29c1a7a commit 33377b9

7 files changed

Lines changed: 783 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { useHighContrastMode } from './use-high-contrast-mode.js';
3+
4+
describe('useHighContrastMode', () => {
5+
it('exposes a shared reactive isHighContrastMode flag defaulting to false', () => {
6+
const { isHighContrastMode, setHighContrastMode } = useHighContrastMode();
7+
setHighContrastMode(false);
8+
expect(isHighContrastMode.value).toBe(false);
9+
});
10+
11+
it('setHighContrastMode updates the flag', () => {
12+
const { isHighContrastMode, setHighContrastMode } = useHighContrastMode();
13+
setHighContrastMode(true);
14+
expect(isHighContrastMode.value).toBe(true);
15+
setHighContrastMode(false);
16+
expect(isHighContrastMode.value).toBe(false);
17+
});
18+
19+
it('state is shared across invocations (module-scoped ref)', () => {
20+
const a = useHighContrastMode();
21+
const b = useHighContrastMode();
22+
a.setHighContrastMode(true);
23+
expect(b.isHighContrastMode.value).toBe(true);
24+
a.setHighContrastMode(false);
25+
});
26+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { ref } from 'vue';
3+
import { useSelectedText } from './use-selected-text.js';
4+
5+
const makeEditor = (textBetween) => ({
6+
state: {
7+
doc: { textBetween: (from, to, sep) => textBetween(from, to, sep) },
8+
selection: { from: 3, to: 7 },
9+
},
10+
});
11+
12+
describe('useSelectedText', () => {
13+
it('returns empty string when ref is null', () => {
14+
const { selectedText } = useSelectedText(ref(null));
15+
expect(selectedText.value).toBe('');
16+
});
17+
18+
it('returns empty string when editor has no state', () => {
19+
const { selectedText } = useSelectedText(ref({}));
20+
expect(selectedText.value).toBe('');
21+
});
22+
23+
it('returns textBetween over the current selection range, space-joined', () => {
24+
const fn = (from, to, sep) => `text[${from}..${to}|${sep}]`;
25+
const { selectedText } = useSelectedText(ref(makeEditor(fn)));
26+
expect(selectedText.value).toBe('text[3..7| ]');
27+
});
28+
29+
it('recomputes when the editor ref changes', () => {
30+
const r = ref(makeEditor(() => 'first'));
31+
const { selectedText } = useSelectedText(r);
32+
expect(selectedText.value).toBe('first');
33+
r.value = makeEditor(() => 'second');
34+
expect(selectedText.value).toBe('second');
35+
});
36+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
3+
vi.mock('y-websocket', () => ({ WebsocketProvider: class {} }));
4+
vi.mock('@hocuspocus/provider', () => ({ HocuspocusProvider: class {} }));
5+
vi.mock('yjs', () => ({ Doc: class {} }));
6+
vi.mock('@superdoc/common/collaboration/awareness', () => ({
7+
awarenessStatesToArray: vi.fn(() => [{ name: 'X' }]),
8+
}));
9+
10+
import { setupAwarenessHandler } from './collaboration.js';
11+
12+
const makeAwareness = (overrides = {}) => {
13+
const listeners = new Map();
14+
return {
15+
setLocalStateField: vi.fn(),
16+
on: vi.fn((event, fn) => listeners.set(event, fn)),
17+
off: vi.fn(),
18+
getStates: vi.fn(() => new Map([[1, { user: { name: 'Alice' } }]])),
19+
_listeners: listeners,
20+
...overrides,
21+
};
22+
};
23+
24+
describe('setupAwarenessHandler', () => {
25+
it('warns and returns a no-op cleanup when provider has no awareness', () => {
26+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
27+
const cleanup = setupAwarenessHandler({}, { emit: vi.fn() }, { name: 'A' });
28+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing awareness'));
29+
expect(typeof cleanup).toBe('function');
30+
expect(() => cleanup()).not.toThrow();
31+
warn.mockRestore();
32+
});
33+
34+
it('sets the local user state when setLocalStateField is available', () => {
35+
const awareness = makeAwareness();
36+
const user = { name: 'Alice', email: 'a@x.com' };
37+
setupAwarenessHandler({ awareness }, { emit: vi.fn() }, user);
38+
expect(awareness.setLocalStateField).toHaveBeenCalledWith('user', user);
39+
});
40+
41+
it('skips setLocalStateField when user is falsy', () => {
42+
const awareness = makeAwareness();
43+
setupAwarenessHandler({ awareness }, { emit: vi.fn() }, null);
44+
expect(awareness.setLocalStateField).not.toHaveBeenCalled();
45+
});
46+
47+
it('skips setLocalStateField when awareness has no such method', () => {
48+
const awareness = makeAwareness({ setLocalStateField: undefined });
49+
setupAwarenessHandler({ awareness }, { emit: vi.fn() }, { name: 'A' });
50+
// no throw
51+
});
52+
53+
it('listens on change and invokes awarenessHandler on emission', () => {
54+
const awareness = makeAwareness();
55+
const emit = vi.fn();
56+
setupAwarenessHandler({ awareness }, { emit }, { name: 'A' });
57+
expect(awareness.on).toHaveBeenCalledWith('change', expect.any(Function));
58+
const handler = awareness._listeners.get('change');
59+
handler({ added: [1], removed: [] });
60+
expect(emit).toHaveBeenCalledWith('awareness-update', expect.objectContaining({ added: [1], removed: [] }));
61+
});
62+
63+
it('cleanup removes the change listener when awareness.off is available', () => {
64+
const awareness = makeAwareness();
65+
const cleanup = setupAwarenessHandler({ awareness }, { emit: vi.fn() }, { name: 'A' });
66+
cleanup();
67+
expect(awareness.off).toHaveBeenCalledWith('change', expect.any(Function));
68+
});
69+
70+
it('cleanup is a no-op when awareness.off is not a function', () => {
71+
const awareness = makeAwareness({ off: 'not-a-function' });
72+
const cleanup = setupAwarenessHandler({ awareness }, { emit: vi.fn() }, { name: 'A' });
73+
expect(() => cleanup()).not.toThrow();
74+
});
75+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
3+
const createAppMock = vi.fn();
4+
const createPiniaMock = vi.fn();
5+
const useSuperdocStoreMock = vi.fn();
6+
const useCommentsStoreMock = vi.fn();
7+
const useHighContrastModeMock = vi.fn();
8+
const clickOutsideDirectiveMock = vi.fn();
9+
10+
vi.mock('vue', () => ({ createApp: createAppMock }));
11+
vi.mock('pinia', () => ({ createPinia: createPiniaMock }));
12+
vi.mock('@superdoc/common', () => ({ vClickOutside: clickOutsideDirectiveMock }));
13+
vi.mock('../stores/superdoc-store', () => ({ useSuperdocStore: useSuperdocStoreMock }));
14+
vi.mock('../stores/comments-store', () => ({ useCommentsStore: useCommentsStoreMock }));
15+
vi.mock('../composables/use-high-contrast-mode', () => ({
16+
useHighContrastMode: useHighContrastModeMock,
17+
}));
18+
vi.mock('../SuperDoc.vue', () => ({ default: { name: 'SuperDocMock' } }));
19+
20+
const setupAppMocks = () => {
21+
const originalUnmount = vi.fn();
22+
const app = {
23+
use: vi.fn(),
24+
directive: vi.fn(),
25+
unmount: originalUnmount,
26+
};
27+
createAppMock.mockReturnValue(app);
28+
createPiniaMock.mockReturnValue({});
29+
useSuperdocStoreMock.mockReturnValue({});
30+
useCommentsStoreMock.mockReturnValue({});
31+
useHighContrastModeMock.mockReturnValue({});
32+
return { app, originalUnmount };
33+
};
34+
35+
const safeDelete = (key) => {
36+
const desc = Object.getOwnPropertyDescriptor(globalThis, key);
37+
if (!desc || desc.configurable !== false) {
38+
delete globalThis[key];
39+
}
40+
};
41+
42+
describe('createSuperdocVueApp edge cases', () => {
43+
beforeEach(() => {
44+
vi.resetModules();
45+
vi.clearAllMocks();
46+
safeDelete('__VUE_DEVTOOLS_GLOBAL_HOOK__');
47+
safeDelete('__VUE_DEVTOOLS_PLUGINS__');
48+
});
49+
50+
afterEach(() => {
51+
safeDelete('__VUE_DEVTOOLS_GLOBAL_HOOK__');
52+
safeDelete('__VUE_DEVTOOLS_PLUGINS__');
53+
});
54+
55+
it('handles queue replacement via property setter', async () => {
56+
const { app } = setupAppMocks();
57+
const { createSuperdocVueApp } = await import('./create-app.js');
58+
createSuperdocVueApp({ disablePiniaDevtools: true });
59+
60+
const newQueue = [];
61+
globalThis.__VUE_DEVTOOLS_PLUGINS__ = newQueue;
62+
newQueue.push([{ id: 'dev.esm.pinia', app }, vi.fn()]);
63+
expect(newQueue).toHaveLength(0);
64+
65+
const otherApp = {};
66+
newQueue.push([{ id: 'dev.esm.pinia', app: otherApp }, vi.fn()]);
67+
expect(newQueue).toHaveLength(1);
68+
});
69+
70+
it('handles multiple unmount calls safely', async () => {
71+
const { app } = setupAppMocks();
72+
const { createSuperdocVueApp } = await import('./create-app.js');
73+
createSuperdocVueApp({ disablePiniaDevtools: true });
74+
app.unmount();
75+
app.unmount(); // second call — no-op via ref count guard
76+
expect(true).toBe(true);
77+
});
78+
79+
it('handles non-array pre-existing queue', async () => {
80+
globalThis.__VUE_DEVTOOLS_PLUGINS__ = { notAnArray: true };
81+
const { app } = setupAppMocks();
82+
const { createSuperdocVueApp } = await import('./create-app.js');
83+
expect(() => createSuperdocVueApp({ disablePiniaDevtools: true })).not.toThrow();
84+
});
85+
86+
it('emits unrelated events without suppression', async () => {
87+
const emitSpy = vi.fn(() => 'emitted');
88+
globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__ = { emit: emitSpy };
89+
const { app } = setupAppMocks();
90+
const { createSuperdocVueApp } = await import('./create-app.js');
91+
createSuperdocVueApp({ disablePiniaDevtools: true });
92+
const hook = globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__;
93+
expect(hook.emit('other-event', { id: 'other', app: {} })).toBe('emitted');
94+
expect(emitSpy).toHaveBeenCalled();
95+
});
96+
97+
it('returns app + stores from factory', async () => {
98+
const { app } = setupAppMocks();
99+
const { createSuperdocVueApp } = await import('./create-app.js');
100+
const result = createSuperdocVueApp({ disablePiniaDevtools: false });
101+
expect(result.app).toBe(app);
102+
expect(result.pinia).toBeDefined();
103+
expect(result.superdocStore).toBeDefined();
104+
expect(result.commentsStore).toBeDefined();
105+
expect(result.highContrastModeStore).toBeDefined();
106+
});
107+
108+
it('replacing hook at runtime picks up new hook instance', async () => {
109+
const { app } = setupAppMocks();
110+
const { createSuperdocVueApp } = await import('./create-app.js');
111+
createSuperdocVueApp({ disablePiniaDevtools: true });
112+
113+
const firstEmit = vi.fn(() => 'a');
114+
globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__ = { emit: firstEmit };
115+
let hook = globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__;
116+
expect(hook.emit('devtools-plugin:setup', { id: 'dev.esm.pinia', app }, vi.fn())).toBeUndefined();
117+
118+
const secondEmit = vi.fn(() => 'b');
119+
globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__ = { emit: secondEmit };
120+
hook = globalThis.__VUE_DEVTOOLS_GLOBAL_HOOK__;
121+
expect(hook.emit('devtools-plugin:setup', { id: 'dev.esm.pinia', app }, vi.fn())).toBeUndefined();
122+
});
123+
124+
it('replacement queue also intercepts pinia setup for suppressed app', async () => {
125+
const { app } = setupAppMocks();
126+
const { createSuperdocVueApp } = await import('./create-app.js');
127+
createSuperdocVueApp({ disablePiniaDevtools: true });
128+
129+
const replacement1 = [];
130+
globalThis.__VUE_DEVTOOLS_PLUGINS__ = replacement1;
131+
replacement1.push([{ id: 'dev.esm.pinia', app }, vi.fn()]);
132+
expect(replacement1).toHaveLength(0);
133+
134+
// Replace again — new queue should also get patched
135+
const replacement2 = [];
136+
globalThis.__VUE_DEVTOOLS_PLUGINS__ = replacement2;
137+
replacement2.push([{ id: 'dev.esm.pinia', app }, vi.fn()]);
138+
expect(replacement2).toHaveLength(0);
139+
});
140+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { readFileAsArrayBuffer } from './read-file.js';
3+
4+
describe('readFileAsArrayBuffer', () => {
5+
class FakeReader {
6+
constructor() {
7+
this.onload = null;
8+
this.onerror = null;
9+
}
10+
readAsDataURL() {
11+
this._invoke();
12+
}
13+
}
14+
15+
const originalFileReader = globalThis.FileReader;
16+
17+
beforeEach(() => {
18+
globalThis.FileReader = FakeReader;
19+
});
20+
21+
afterEach(() => {
22+
globalThis.FileReader = originalFileReader;
23+
});
24+
25+
it('resolves with the reader result on load', async () => {
26+
FakeReader.prototype._invoke = function () {
27+
queueMicrotask(() => this.onload({ target: { result: 'data:url' } }));
28+
};
29+
const blob = new Blob(['abc']);
30+
await expect(readFileAsArrayBuffer(blob)).resolves.toBe('data:url');
31+
});
32+
33+
it('rejects when the reader emits an error', async () => {
34+
FakeReader.prototype._invoke = function () {
35+
queueMicrotask(() => this.onerror(new Error('bad read')));
36+
};
37+
const blob = new Blob(['abc']);
38+
await expect(readFileAsArrayBuffer(blob)).rejects.toThrow('bad read');
39+
});
40+
});

0 commit comments

Comments
 (0)