UNPKG

@tldraw/tlschema

Version:

tldraw infinite canvas SDK (schema).

256 lines (233 loc) 8.08 kB
import { describe, expect, it } from 'vitest' import { createTLSchema } from '../createTLSchema' import { toRichText } from '../misc/TLRichText' import { commentReactionRecordConfig, commentRecordConfig, commentSchemaRecords, commentThreadRecordConfig, createComment, createCommentId, createCommentReaction, createCommentReactionId, createCommentThread, createCommentThreadId, isCommentId, isCommentReactionId, isCommentThreadId, TLCommentAnchor, } from './TLComment' import { TLPageId } from './TLPage' import { TLShapeId } from './TLShape' const pageId = 'page:page1' as TLPageId const shapeId = 'shape:box1' as TLShapeId const anchors: TLCommentAnchor[] = [ { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false }, { type: 'point', x: 100, y: 200 }, { type: 'region', x: 0, y: 0, w: 300, h: 150 }, { type: 'page' }, ] describe('TLCommentThread', () => { it.each(anchors)('creates a valid thread anchored to %j', (anchor) => { const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 }) expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread) expect(thread).toMatchObject({ typeName: 'comment-thread', pageId, anchor, createdAt: 1000, resolved: null, meta: {}, }) }) it('validates resolution state', () => { const thread = createCommentThread({ pageId, anchor: { type: 'page' }, createdBy: 'user1', now: 1000, }) const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } } expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved) expect(() => commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } }) ).toThrow() }) it('rejects unknown anchor kinds and malformed anchors', () => { const thread = createCommentThread({ pageId, anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false }, createdBy: 'user1', now: 1000, }) expect(() => commentThreadRecordConfig.validator.validate({ ...thread, anchor: { type: 'volume', level: 11 }, }) ).toThrow() expect(() => commentThreadRecordConfig.validator.validate({ ...thread, // shape anchors must reference a shape id anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false }, }) ).toThrow() }) }) describe('TLComment', () => { const threadId = createCommentThreadId('thread1') it('creates a valid comment with a rich text body', () => { const comment = createComment({ threadId, pageId, authorId: 'user1', body: toRichText('hello'), now: 1000, }) expect(commentRecordConfig.validator.validate(comment)).toEqual(comment) expect(comment).toMatchObject({ typeName: 'comment', threadId, pageId, createdAt: 1000, editedAt: null, isDeleted: false, }) }) it('validates soft-deletion state', () => { const comment = createComment({ threadId, pageId, authorId: 'user1', body: toRichText('hello'), now: 1000, }) const deleted = { ...comment, isDeleted: true } expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted) expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow() }) it('accepts an editedAt timestamp after edits', () => { const comment = createComment({ threadId, pageId, authorId: 'user1', body: toRichText('hello'), now: 1000, }) const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') } expect(commentRecordConfig.validator.validate(edited)).toEqual(edited) }) it('rejects a threadId that is not a comment-thread id', () => { const comment = createComment({ threadId, pageId, authorId: 'user1', body: toRichText('hello'), now: 1000, }) expect(() => commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' }) ).toThrow() }) }) describe('schema registration', () => { it('registers both record types via commentSchemaRecords', () => { const schema = createTLSchema({ records: commentSchemaRecords }) // custom record types aren't part of the default TLRecord union, so index untyped const types = schema.types as Record<string, { scope: string } | undefined> expect(types['comment-thread']?.scope).toBe('document') expect(types['comment']?.scope).toBe('document') }) }) describe('isCommentThreadId / isCommentId', () => { it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => { expect(isCommentThreadId(createCommentThreadId())).toBe(true) expect(isCommentThreadId(createCommentId())).toBe(false) expect(isCommentThreadId('shape:box1')).toBe(false) expect(isCommentThreadId('not-an-id')).toBe(false) }) it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => { expect(isCommentId(createCommentId())).toBe(true) expect(isCommentId(createCommentThreadId())).toBe(false) expect(isCommentId('shape:box1')).toBe(false) expect(isCommentId('not-an-id')).toBe(false) }) }) describe('comment-thread migrations', () => { const migration = (commentThreadRecordConfig.migrations as any).sequence.find( (m: any) => m.id === 'com.tldraw.comment-thread/2' ) as { up(r: any): any; down(r: any): any } it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => { expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({ anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false }, }) }) it('leaves shape anchors that already have x/y unchanged', () => { const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true } expect(migration.up({ anchor })).toEqual({ anchor }) }) it('leaves non-shape anchors untouched', () => { const anchor = { type: 'point', x: 5, y: 6 } expect(migration.up({ anchor })).toEqual({ anchor }) }) it('down-migrates by dropping x/y', () => { expect( migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } }) ).toEqual({ anchor: { type: 'shape', shapeId }, }) }) }) describe('comment-reaction record', () => { it('createCommentReaction derives its id from the comment, user, and emoji', () => { const commentId = createCommentId('c1') const make = (emoji: string) => createCommentReaction({ commentId, threadId: createCommentThreadId('t1'), pageId, userId: 'user1', emoji, }) // same emoji → same record (re-picking toggles it); different emoji → its own record (multi) expect(make('👍').id).toBe(make('👍').id) expect(make('👍').id).not.toBe(make('🎉').id) expect(isCommentReactionId(make('👍').id)).toBe(true) }) it('validates a well-formed reaction record', () => { const reaction = createCommentReaction({ commentId: createCommentId('c1'), threadId: createCommentThreadId('t1'), pageId, userId: 'user1', emoji: '👍', }) expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction) }) it('rejects an empty or oversized emoji token', () => { const reaction = createCommentReaction({ commentId: createCommentId('c1'), threadId: createCommentThreadId('t1'), pageId, userId: 'user1', emoji: '👍', }) // the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id expect(() => commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' }) ).toThrow() expect(() => commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) }) ).toThrow() }) it('createCommentReactionId is injective when a part contains a colon', () => { // two different triples that a naive slice+join would collapse onto one id: // comment "foo" + user "bar:baz" + 👍 vs comment "foo:bar" + user "baz" + 👍 const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍') const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍') expect(a).not.toBe(b) expect(isCommentReactionId(a)).toBe(true) expect(isCommentReactionId(b)).toBe(true) }) })