@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
613 lines (485 loc) • 20.4 kB
text/typescript
import { atom } from '@tldraw/state'
import { TLUserPreferences, defaultUserPreferences } from '../../../config/TLUserPreferences'
import { TLUser } from '../../../config/createTLUser'
import { UserPreferencesManager } from './UserPreferencesManager'
// Mock window.matchMedia
const mockMatchMedia = jest.fn()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: mockMatchMedia,
})
describe('UserPreferencesManager', () => {
let mockUser: jest.Mocked<TLUser>
let mockUserPreferences: TLUserPreferences
let userPreferencesAtom: any
let userPreferencesManager: UserPreferencesManager
const createMockUserPreferences = (
overrides: Partial<TLUserPreferences> = {}
): TLUserPreferences => ({
id: 'test-user-id',
name: 'Test User',
color: '#FF802B',
locale: 'en',
animationSpeed: 1,
areKeyboardShortcutsEnabled: true,
edgeScrollSpeed: 1,
colorScheme: 'light',
isSnapMode: false,
isWrapMode: false,
isDynamicSizeMode: false,
isPasteAtCursorMode: false,
...overrides,
})
beforeEach(() => {
jest.clearAllMocks()
mockUserPreferences = createMockUserPreferences()
userPreferencesAtom = atom('userPreferences', mockUserPreferences)
mockUser = {
userPreferences: userPreferencesAtom,
setUserPreferences: jest.fn((prefs) => {
userPreferencesAtom.set(prefs)
}),
}
// Default matchMedia mock - no dark mode preference
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})
})
describe('constructor', () => {
it('should initialize with light system color scheme when matchMedia not available', () => {
// Test when window.matchMedia is not available
delete (window as any).matchMedia
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
// Restore matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: mockMatchMedia,
})
})
it('should initialize with light system color scheme when dark mode not preferred', () => {
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should initialize with dark system color scheme when dark mode preferred', () => {
mockMatchMedia.mockReturnValue({
matches: true,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(userPreferencesManager.systemColorScheme.get()).toBe('dark')
})
it('should set up media query listener for color scheme changes', () => {
const mockAddEventListener = jest.fn()
const mockRemoveEventListener = jest.fn()
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
})
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
it('should handle media query change events', () => {
const mockAddEventListener = jest.fn()
let changeHandler: (e: MediaQueryListEvent) => void
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: (event: string, handler: any) => {
if (event === 'change') {
changeHandler = handler
}
mockAddEventListener(event, handler)
},
removeEventListener: jest.fn(),
})
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
// Simulate dark mode change
changeHandler!({ matches: true } as MediaQueryListEvent)
expect(userPreferencesManager.systemColorScheme.get()).toBe('dark')
// Simulate light mode change
changeHandler!({ matches: false } as MediaQueryListEvent)
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should work in server environment (no window)', () => {
const originalWindow = global.window
delete (global as any).window
expect(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
}).not.toThrow()
global.window = originalWindow
})
})
describe('dispose', () => {
it('should remove media query listener on dispose', () => {
const mockRemoveEventListener = jest.fn()
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: jest.fn(),
removeEventListener: mockRemoveEventListener,
})
userPreferencesManager = new UserPreferencesManager(mockUser, false)
userPreferencesManager.dispose()
expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
it('should call all disposables', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
const mockDisposable1 = jest.fn()
const mockDisposable2 = jest.fn()
userPreferencesManager.disposables.add(mockDisposable1)
userPreferencesManager.disposables.add(mockDisposable2)
userPreferencesManager.dispose()
expect(mockDisposable1).toHaveBeenCalled()
expect(mockDisposable2).toHaveBeenCalled()
})
})
describe('updateUserPreferences', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
it('should update user preferences with partial data', () => {
const updates = { name: 'Updated Name', color: '#EC5E41' }
userPreferencesManager.updateUserPreferences(updates)
expect(mockUser.setUserPreferences).toHaveBeenCalledWith({
...mockUserPreferences,
...updates,
})
})
it('should preserve existing preferences when updating', () => {
const updates = { animationSpeed: 0.5 }
userPreferencesManager.updateUserPreferences(updates)
expect(mockUser.setUserPreferences).toHaveBeenCalledWith({
...mockUserPreferences,
animationSpeed: 0.5,
})
})
it('should handle empty updates', () => {
userPreferencesManager.updateUserPreferences({})
expect(mockUser.setUserPreferences).toHaveBeenCalledWith(mockUserPreferences)
})
})
describe('getUserPreferences', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
it('should return complete user preferences with computed values', () => {
const result = userPreferencesManager.getUserPreferences()
expect(result).toEqual({
id: mockUserPreferences.id,
name: mockUserPreferences.name,
locale: mockUserPreferences.locale,
color: mockUserPreferences.color,
animationSpeed: mockUserPreferences.animationSpeed,
areKeyboardShortcutsEnabled: mockUserPreferences.areKeyboardShortcutsEnabled,
isSnapMode: mockUserPreferences.isSnapMode,
colorScheme: mockUserPreferences.colorScheme,
isDarkMode: false, // light mode
isWrapMode: mockUserPreferences.isWrapMode,
isDynamicResizeMode: mockUserPreferences.isDynamicSizeMode,
})
})
it('should use default values for missing properties', () => {
const minimalPrefs: TLUserPreferences = { id: 'test-id' }
userPreferencesAtom.set(minimalPrefs)
const result = userPreferencesManager.getUserPreferences()
expect(result.name).toBe(defaultUserPreferences.name)
expect(result.color).toBe(defaultUserPreferences.color)
expect(result.locale).toBe(defaultUserPreferences.locale)
expect(result.animationSpeed).toBe(defaultUserPreferences.animationSpeed)
})
})
describe('getIsDarkMode', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
it('should return true when colorScheme is dark', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'dark' })
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should return false when colorScheme is light', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'light' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
})
it('should follow system preference when colorScheme is system', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'system' })
// System is light
userPreferencesManager.systemColorScheme.set('light')
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
// System is dark
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should use inferDarkMode when colorScheme is undefined', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: undefined })
// With inferDarkMode = true
const managerWithInfer = new UserPreferencesManager(mockUser, true)
managerWithInfer.systemColorScheme.set('dark')
expect(managerWithInfer.getIsDarkMode()).toBe(true)
// With inferDarkMode = false
const managerWithoutInfer = new UserPreferencesManager(mockUser, false)
managerWithoutInfer.systemColorScheme.set('dark')
expect(managerWithoutInfer.getIsDarkMode()).toBe(false)
})
})
describe('individual preference getters', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
describe('getId', () => {
it('should return user id', () => {
expect(userPreferencesManager.getId()).toBe(mockUserPreferences.id)
})
})
describe('getName', () => {
it('should return trimmed user name', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: ' Test User ' })
expect(userPreferencesManager.getName()).toBe('Test User')
})
it('should return default name when name is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: null })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
it('should return default name when name is undefined', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: undefined })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
it('should return default name when name is empty after trimming', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: ' ' })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
})
describe('getLocale', () => {
it('should return user locale', () => {
expect(userPreferencesManager.getLocale()).toBe(mockUserPreferences.locale)
})
it('should return default locale when locale is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, locale: null })
expect(userPreferencesManager.getLocale()).toBe(defaultUserPreferences.locale)
})
})
describe('getColor', () => {
it('should return user color', () => {
expect(userPreferencesManager.getColor()).toBe(mockUserPreferences.color)
})
it('should return default color when color is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, color: null })
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
})
})
describe('getAnimationSpeed', () => {
it('should return user animation speed', () => {
expect(userPreferencesManager.getAnimationSpeed()).toBe(mockUserPreferences.animationSpeed)
})
it('should return default animation speed when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
expect(userPreferencesManager.getAnimationSpeed()).toBe(
defaultUserPreferences.animationSpeed
)
})
})
describe('getAreKeyboardShortcutsEnabled', () => {
it('should return user keyboard shortcuts', () => {
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
mockUserPreferences.areKeyboardShortcutsEnabled
)
})
it('should return default keyboard shortcuts when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, areKeyboardShortcutsEnabled: null })
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
defaultUserPreferences.areKeyboardShortcutsEnabled
)
})
})
describe('getEdgeScrollSpeed', () => {
it('should return user edge scroll speed', () => {
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
mockUserPreferences.edgeScrollSpeed
)
})
it('should return default edge scroll speed when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, edgeScrollSpeed: null })
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
defaultUserPreferences.edgeScrollSpeed
)
})
})
describe('getIsSnapMode', () => {
it('should return user snap mode setting', () => {
expect(userPreferencesManager.getIsSnapMode()).toBe(mockUserPreferences.isSnapMode)
})
it('should return default snap mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isSnapMode: null })
expect(userPreferencesManager.getIsSnapMode()).toBe(defaultUserPreferences.isSnapMode)
})
})
describe('getIsWrapMode', () => {
it('should return user wrap mode setting', () => {
expect(userPreferencesManager.getIsWrapMode()).toBe(mockUserPreferences.isWrapMode)
})
it('should return default wrap mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isWrapMode: null })
expect(userPreferencesManager.getIsWrapMode()).toBe(defaultUserPreferences.isWrapMode)
})
})
describe('getIsDynamicResizeMode', () => {
it('should return user dynamic resize mode setting', () => {
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
mockUserPreferences.isDynamicSizeMode
)
})
it('should return default dynamic resize mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isDynamicSizeMode: null })
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
defaultUserPreferences.isDynamicSizeMode
)
})
})
describe('getIsPasteAtCursorMode', () => {
it('should return user paste at cursor mode setting', () => {
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
mockUserPreferences.isPasteAtCursorMode
)
})
it('should return default paste at cursor mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isPasteAtCursorMode: null })
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
defaultUserPreferences.isPasteAtCursorMode
)
})
})
})
describe('reactive behavior', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
it('should react to user preferences changes', () => {
expect(userPreferencesManager.getName()).toBe('Test User')
userPreferencesManager.updateUserPreferences({ name: 'Updated User' })
expect(userPreferencesManager.getName()).toBe('Updated User')
})
it('should react to system color scheme changes', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'system' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should update getUserPreferences when individual preferences change', () => {
const initialPrefs = userPreferencesManager.getUserPreferences()
expect(initialPrefs.name).toBe('Test User')
userPreferencesManager.updateUserPreferences({ name: 'Changed Name' })
const updatedPrefs = userPreferencesManager.getUserPreferences()
expect(updatedPrefs.name).toBe('Changed Name')
})
})
describe('edge cases and error handling', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
})
it('should handle undefined user preferences gracefully', () => {
userPreferencesAtom.set({} as TLUserPreferences)
expect(() => userPreferencesManager.getUserPreferences()).not.toThrow()
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
})
it('should handle null values in preferences', () => {
const nullPrefs = createMockUserPreferences({
name: null,
color: null,
locale: null,
animationSpeed: null,
areKeyboardShortcutsEnabled: null,
edgeScrollSpeed: null,
isSnapMode: null,
isWrapMode: null,
isDynamicSizeMode: null,
isPasteAtCursorMode: null,
})
userPreferencesAtom.set(nullPrefs)
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
expect(userPreferencesManager.getLocale()).toBe(defaultUserPreferences.locale)
expect(userPreferencesManager.getAnimationSpeed()).toBe(defaultUserPreferences.animationSpeed)
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
defaultUserPreferences.areKeyboardShortcutsEnabled
)
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
defaultUserPreferences.edgeScrollSpeed
)
expect(userPreferencesManager.getIsSnapMode()).toBe(defaultUserPreferences.isSnapMode)
expect(userPreferencesManager.getIsWrapMode()).toBe(defaultUserPreferences.isWrapMode)
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
defaultUserPreferences.isDynamicSizeMode
)
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
defaultUserPreferences.isPasteAtCursorMode
)
})
it('should handle matchMedia with null response', () => {
// Mock matchMedia returning null (like in some environments)
mockMatchMedia.mockReturnValue(null)
expect(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
}).not.toThrow()
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should handle dispose gracefully in all cases', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
// Should not throw even if dispose is called multiple times
expect(() => userPreferencesManager.dispose()).not.toThrow()
expect(() => userPreferencesManager.dispose()).not.toThrow()
})
it('should handle empty disposables set', () => {
// Test in server environment where no event listeners are added
const originalWindow = global.window
delete (global as any).window
userPreferencesManager = new UserPreferencesManager(mockUser, false)
expect(() => userPreferencesManager.dispose()).not.toThrow()
expect(userPreferencesManager.disposables.size).toBe(0)
global.window = originalWindow
})
})
describe('integration scenarios', () => {
it('should work with real-world preference scenarios', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, true)
// User starts with system preference
userPreferencesManager.updateUserPreferences({ colorScheme: 'system' })
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
expect(userPreferencesManager.getUserPreferences().isDarkMode).toBe(true)
// User switches to light mode explicitly
userPreferencesManager.updateUserPreferences({ colorScheme: 'light' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
expect(userPreferencesManager.getUserPreferences().isDarkMode).toBe(false)
// System changes but user preference is respected
userPreferencesManager.systemColorScheme.set('light')
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
})
it('should handle preference updates with multiple fields', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, false)
const updates = {
name: 'New User',
color: '#F2555A',
animationSpeed: 0.5,
isSnapMode: true,
colorScheme: 'dark' as const,
}
userPreferencesManager.updateUserPreferences(updates)
const prefs = userPreferencesManager.getUserPreferences()
expect(prefs.name).toBe('New User')
expect(prefs.color).toBe('#F2555A')
expect(prefs.animationSpeed).toBe(0.5)
expect(prefs.isSnapMode).toBe(true)
expect(prefs.colorScheme).toBe('dark')
expect(prefs.isDarkMode).toBe(true)
})
})
})