ribbit/test/collaboration.test.ts

274 lines
11 KiB
TypeScript
Raw Normal View History

import { ribbit, resetDOM } from './setup';
const r = ribbit();
function mockTransport() {
const receiveListeners: Array<(update: Uint8Array) => void> = [];
const lockListeners: Array<(holder: any) => void> = [];
return {
connected: false,
sent: [] as Uint8Array[],
locked: false,
connect() { this.connected = true; },
disconnect() { this.connected = false; },
send(update: Uint8Array) { this.sent.push(update); },
onReceive(cb: (update: Uint8Array) => void) { receiveListeners.push(cb); },
simulateRemote(content: string) {
const encoded = new TextEncoder().encode(content);
receiveListeners.forEach(cb => cb(encoded));
},
lock: async function() { this.locked = true; return true; },
unlock() { this.locked = false; },
forceLock: async function() { this.locked = true; return true; },
onLockChange(cb: (holder: any) => void) { lockListeners.push(cb); },
simulateLock(holder: any) { lockListeners.forEach(cb => cb(holder)); },
};
}
function mockPresence() {
const listeners: Array<(peers: any[]) => void> = [];
return {
lastSent: null as any,
send(info: any) { this.lastSent = info; },
onUpdate(cb: (peers: any[]) => void) { listeners.push(cb); },
simulatePeers(peers: any[]) { listeners.forEach(cb => cb(peers)); },
};
}
function mockRevisions() {
const store: any[] = [];
return {
store,
list: async () => store,
get: async (id: string) => store.find((r: any) => r.id === id),
create: async (content: string, meta?: any) => {
const rev = { id: String(store.length + 1), timestamp: new Date().toISOString(), content, ...meta };
store.push(rev);
return rev;
},
};
}
describe('CollaborationManager', () => {
beforeEach(() => resetDOM('initial'));
it('does not create manager without settings', () => {
const editor = new r.Editor({});
editor.run();
expect(editor.collaboration).toBeUndefined();
});
it('creates manager with settings', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
expect(editor.collaboration).toBeDefined();
});
describe('connection lifecycle', () => {
it('connects on wysiwyg', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.wysiwyg();
expect(transport.connected).toBe(true);
});
it('connects on edit', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.edit();
expect(transport.connected).toBe(true);
});
it('disconnects on view', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.wysiwyg();
editor.view();
expect(transport.connected).toBe(false);
});
});
describe('source mode pausing', () => {
it('pauses on entering source mode', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.edit();
expect(editor.collaboration!.isPaused()).toBe(true);
});
it('counts remote changes while paused', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.edit();
transport.simulateRemote('change 1');
transport.simulateRemote('change 2');
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
});
it('fires remoteActivity event while paused', (done) => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
on: { remoteActivity: ({ count }: any) => { if (count === 1) done(); } },
});
editor.run();
editor.edit();
transport.simulateRemote('change');
});
it('resumes on switching to wysiwyg', () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.edit();
editor.wysiwyg();
expect(editor.collaboration!.isPaused()).toBe(false);
});
});
describe('locking', () => {
it('lock returns true', async () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
expect(await editor.lockForEditing()).toBe(true);
});
it('forceLock returns true', async () => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
expect(await editor.forceLockEditing()).toBe(true);
});
it('fires lockChange event', (done) => {
const transport = mockTransport();
const editor = new r.Editor({
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
on: { lockChange: ({ holder }: any) => { if (holder?.userId === 'alice') done(); } },
});
editor.run();
transport.simulateLock({ userId: 'alice', displayName: 'Alice', status: 'active', lastActive: Date.now() });
});
});
describe('presence', () => {
it('sends cursor with status', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new r.Editor({
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now(), color: '#f00' } },
});
editor.run();
editor.wysiwyg();
editor.collaboration!.sendCursor(42);
expect(presence.lastSent.status).toBe('active');
expect(presence.lastSent.cursor).toBe(42);
});
it('sends editing status when paused', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new r.Editor({
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.edit();
editor.collaboration!.sendCursor(10);
expect(presence.lastSent.status).toBe('editing');
});
it('applies idle status to peers', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new r.Editor({
collaboration: { transport, presence, idleTimeout: 100, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
presence.simulatePeers([
{ userId: 'a', displayName: 'A', status: 'active', lastActive: Date.now() - 200 },
{ userId: 'b', displayName: 'B', status: 'active', lastActive: Date.now() },
]);
const peers = editor.collaboration!.getPeers();
expect(peers[0].status).toBe('idle');
expect(peers[1].status).toBe('active');
});
});
describe('revisions', () => {
it('lists revisions', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('v1', { author: 'test' });
const editor = new r.Editor({
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
const list = await editor.listRevisions();
expect(list).toHaveLength(1);
});
it('creates revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
const editor = new r.Editor({
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
const rev = await editor.createRevision({ author: 'test', summary: 'test rev' });
expect(rev).toBeDefined();
expect(revisions.store).toHaveLength(1);
});
it('restores revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('old content', { author: 'test' });
const editor = new r.Editor({
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
});
editor.run();
editor.wysiwyg();
await editor.restoreRevision('1');
expect(editor.getMarkdown()).toBe('old content');
});
it('fires revisionCreated event', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
let fired = false;
const editor = new r.Editor({
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
on: { revisionCreated: () => { fired = true; } },
});
editor.run();
await editor.createRevision({ author: 'test' });
expect(fired).toBe(true);
});
});
});