Files
tolaria/src/components/TldrawWhiteboard.test.tsx
2026-05-17 19:31:12 +02:00

278 lines
7.5 KiB
TypeScript

import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from 'tldraw'
import { TldrawWhiteboard } from './TldrawWhiteboard'
interface MockTldrawProps {
assetUrls: MockAssetUrls
onMount: (editor: Editor) => () => void
user?: MockTldrawUser
}
interface MockTldrawUser {
userPreferences: {
get: () => { colorScheme?: string }
}
}
interface MockAssetUrls {
embedIcons: Record<string, string>
fonts: Record<string, string>
icons: Record<string, string>
translations: Record<string, string>
}
const tldrawMock = vi.hoisted(() => ({
Tldraw: vi.fn(),
}))
const tldrawStoreMock = vi.hoisted(() => ({
createTLStore: vi.fn(() => ({
document: { records: {} },
listen: vi.fn(() => vi.fn()),
})),
getSnapshot: vi.fn((store: { document?: unknown }) => ({ document: store.document ?? { records: {} } })),
loadSnapshot: vi.fn((store: { document?: unknown }, snapshot: unknown) => {
store.document = snapshot
}),
}))
const assetImportMock = vi.hoisted(() => ({
getAssetUrlsByImport: vi.fn((formatAssetUrl: (assetUrl?: string) => string) => ({
embedIcons: {},
fonts: {
tldraw_draw: formatAssetUrl('/assets/Shantell_Sans-Informal_Regular.woff2'),
},
icons: {
'tool-pencil': `${formatAssetUrl('/assets/0_merged.svg')}#tool-pencil`,
},
translations: {
en: formatAssetUrl(undefined),
},
})),
}))
vi.mock('@tldraw/assets/imports.vite', () => assetImportMock)
vi.mock('tldraw', async () => {
const { createElement } = await import('react')
tldrawMock.Tldraw.mockImplementation(({ assetUrls }: MockTldrawProps) =>
createElement('div', {
'data-testid': 'mock-tldraw',
'data-draw-font-url': assetUrls.fonts.tldraw_draw,
})
)
return {
Box: class Box {
x: number
y: number
w: number
h: number
constructor(x: number, y: number, w: number, h: number) {
this.x = x
this.y = y
this.w = w
this.h = h
}
},
Tldraw: tldrawMock.Tldraw,
createTLStore: tldrawStoreMock.createTLStore,
defaultUserPreferences: {
colorScheme: 'light',
locale: 'en',
},
getSnapshot: tldrawStoreMock.getSnapshot,
loadSnapshot: tldrawStoreMock.loadSnapshot,
useTldrawUser: vi.fn(({ userPreferences }: { userPreferences: { colorScheme: string } }) => ({
setUserPreferences: vi.fn(),
userPreferences: {
get: () => userPreferences,
},
})),
}
})
function renderedTldrawAssetUrls(): MockAssetUrls {
const props = tldrawMock.Tldraw.mock.calls[0]?.[0] as MockTldrawProps
expect(props.assetUrls).toBeDefined()
return props.assetUrls
}
function renderedTldrawProps(): MockTldrawProps {
const props = tldrawMock.Tldraw.mock.lastCall?.[0] as MockTldrawProps
expect(props).toBeDefined()
return props
}
function mockEditor(): Editor {
const container = document.createElement('div')
const canvas = document.createElement('div')
canvas.className = 'tl-canvas'
container.append(canvas)
return {
dispatch: vi.fn(),
getContainer: vi.fn(() => container),
textMeasure: {
measureElementTextNodeSpans: vi.fn(() => {
throw new TypeError("Cannot read properties of undefined (reading 'top')")
}),
},
updateViewportScreenBounds: vi.fn(),
} as unknown as Editor
}
function measuredTextElement(): HTMLElement {
const element = document.createElement('div')
element.textContent = 'Label'
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({
height: 24,
width: 88,
}))
return element
}
function expectNoCdnUrls(urls: Record<string, string>) {
Object.values(urls).forEach((url) => {
expect(url).not.toContain('cdn.tldraw.com')
})
}
function expectBundledTldrawAssetUrls(assetUrls: MockAssetUrls) {
expect(assetUrls.fonts.tldraw_draw).toContain('Shantell_Sans-Informal_Regular.woff2')
expect(assetUrls.icons['tool-pencil']).toContain('0_merged.svg#tool-pencil')
expect(assetUrls.translations.en).toBe('data:application/json;base64,e30K')
expectNoCdnUrls(assetUrls.fonts)
expectNoCdnUrls(assetUrls.icons)
expectNoCdnUrls(assetUrls.translations)
}
describe('TldrawWhiteboard', () => {
afterEach(() => {
cleanup()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
vi.clearAllMocks()
})
it('uses bundled tldraw assets instead of CDN URLs', () => {
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(screen.getByTestId('mock-tldraw')).toHaveAttribute('data-draw-font-url')
expect(assetImportMock.getAssetUrlsByImport).toHaveBeenCalledWith(expect.any(Function))
expectBundledTldrawAssetUrls(renderedTldrawAssetUrls())
})
it('passes Tolaria dark mode to tldraw', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('dark')
})
it('updates the tldraw color scheme when Tolaria theme changes', async () => {
document.documentElement.setAttribute('data-theme', 'light')
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('light')
act(() => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
})
await waitFor(() => {
expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('dark')
})
})
it('installs the text measurement guard when the canvas mounts', () => {
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
const editor = mockEditor()
const cleanup = renderedTldrawProps().onMount(editor)
expect(editor.textMeasure.measureElementTextNodeSpans(measuredTextElement())).toEqual({
didTruncate: false,
spans: [{
box: { h: 24, w: 88, x: 0, y: 0 },
text: 'Label',
}],
})
cleanup()
expect(() => editor.textMeasure.measureElementTextNodeSpans(measuredTextElement())).toThrow('top')
})
it('resets the drawing store when switching to a blank board snapshot', () => {
const boardASnapshot = { records: { shape: 'from-board-a' } }
const { rerender } = render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot={JSON.stringify(boardASnapshot)}
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), boardASnapshot)
rerender(
<TldrawWhiteboard
boardId="board-2"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), { records: {} })
})
})