Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384// src/layout/__tests__/layout-persistence.test.tsimport { describe, it, expect, beforeEach, vi } from 'vitest'import { saveLayoutState, loadLayoutState, saveWorkspaceTabs, loadWorkspaceTabs, LAYOUT_STORAGE_KEY, WORKSPACE_TABS_STORAGE_KEY, LAYOUT_VERSION,} from '../layout-persistence'import { createInitialLayoutState, setEditorMode } from '../layout-state'
describe('layout-persistence', () => { it('uses the mobile fresh default while preserving an explicitly saved expanded sidebar', () => { vi.stubGlobal('innerWidth', 390) try { localStorage.clear() expect(loadLayoutState().sidebarMode).toBe('ribbon') localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, sidebarMode: 'invalid' })) expect(loadLayoutState().sidebarMode).toBe('ribbon') saveLayoutState({ ...createInitialLayoutState(), sidebarMode: 'full' }) expect(loadLayoutState().sidebarMode).toBe('full') } finally { vi.unstubAllGlobals() } }) beforeEach(() => { localStorage.clear() })
describe('saveLayoutState', () => { it('saves state to localStorage', () => { const state = createInitialLayoutState() saveLayoutState(state) const stored = localStorage.getItem(LAYOUT_STORAGE_KEY) expect(stored).not.toBeNull() const parsed = JSON.parse(stored!) expect(parsed.version).toBe(LAYOUT_VERSION) expect(parsed.sidebarWidth).toBe(state.sidebarWidth) })
it('v5: does NOT save tabs (moved to workspace persistence)', () => { // Layout state no longer has tabs/activeTabId fields, but verify the // saved blob also doesn't include them (defends against accidental // re-introduction). saveLayoutState(createInitialLayoutState())
const stored = localStorage.getItem(LAYOUT_STORAGE_KEY) const parsed = JSON.parse(stored!) expect(parsed.tabs).toBeUndefined() expect(parsed.activeTabId).toBeUndefined() })
it('saves editorMode correctly', () => { let state = createInitialLayoutState() state = { ...state, editorMode: 'source' } saveLayoutState(state) const stored = localStorage.getItem(LAYOUT_STORAGE_KEY) const parsed = JSON.parse(stored!) expect(parsed.editorMode).toBe('source') }) })
describe('loadLayoutState', () => { it('returns default state when localStorage is empty', () => { const state = loadLayoutState() const defaults = createInitialLayoutState() expect(state).toEqual(defaults) })
it('loads saved v5 state', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: 5, sidebarWidth: 300, sidebarMode: 'hidden', rightPanelWidth: 280, rightPanelCollapsed: true, editorMode: 'live-preview', }))
const loaded = loadLayoutState()
expect(loaded.sidebarWidth).toBe(300) expect(loaded.sidebarMode).toBe('hidden') // v5+ LayoutState has no tabs/activeTabId fields (workspace-owned) expect((loaded as { tabs?: unknown }).tabs).toBeUndefined() expect((loaded as { activeTabId?: unknown }).activeTabId).toBeUndefined() })
it('returns default state for invalid JSON', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, 'not json') const state = loadLayoutState() const defaults = createInitialLayoutState() expect(state).toEqual(defaults) })
it('returns default state when parsed JSON is not an object', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify([1, 2, 3])) expect(loadLayoutState()).toEqual(createInitialLayoutState())
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(42)) expect(loadLayoutState()).toEqual(createInitialLayoutState())
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify('a-string')) expect(loadLayoutState()).toEqual(createInitialLayoutState()) })
it('returns default state for wrong version', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: 999, sidebarWidth: 300, })) const state = loadLayoutState() const defaults = createInitialLayoutState() expect(state).toEqual(defaults) })
it('returns default state for missing version', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ sidebarWidth: 300, })) const state = loadLayoutState() const defaults = createInitialLayoutState() expect(state).toEqual(defaults) })
it.each([1, 2, 3, 4])('returns defaults for obsolete layout version %i', version => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version, sidebarWidth: 300, sidebarMode: 'hidden', sidebarCollapsed: true, editorMode: 'source', })) expect(loadLayoutState()).toEqual(createInitialLayoutState()) })
it('validates sidebarMode and defaults to full for invalid values', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: 5, sidebarMode: 'invalid-mode', })) const state = loadLayoutState() expect(state.sidebarMode).toBe('full') })
it('validates sidebarMode accepts ribbon', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: 5, sidebarMode: 'ribbon', })) const state = loadLayoutState() expect(state.sidebarMode).toBe('ribbon') })
it('handles partial state by merging with defaults', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, sidebarWidth: 300, // Missing other fields }))
const state = loadLayoutState()
expect(state.sidebarWidth).toBe(300) expect(state.sidebarMode).toBe('full') // Default })
it('validates numeric fields are within bounds', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, sidebarWidth: 9999, // Out of bounds rightPanelWidth: -100, // Out of bounds })) const state = loadLayoutState() // Should be clamped to max/min expect(state.sidebarWidth).toBe(400) // MAX_SIDEBAR_WIDTH expect(state.rightPanelWidth).toBe(200) // MIN_RIGHT_PANEL_WIDTH })
it('v5: ignores any tabs in storage (workspace manages tabs)', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, tabs: [ { id: 'valid', docUrl: 'automerge:123', title: 'Valid.md' }, ], activeTabId: 'valid', }))
const state = loadLayoutState()
// v5+ LayoutState doesn't carry tab fields, even when storage has them expect((state as { tabs?: unknown }).tabs).toBeUndefined() expect((state as { activeTabId?: unknown }).activeTabId).toBeUndefined() })
it('normalizes persisted read editorMode to live-preview', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, editorMode: 'read', })) const state = loadLayoutState() expect(state.editorMode).toBe('live-preview') })
it('defaults editorMode to live-preview when missing', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, sidebarWidth: 300, // editorMode not present })) const state = loadLayoutState() expect(state.editorMode).toBe('live-preview') })
it('defaults editorMode to live-preview when invalid', () => { localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ version: LAYOUT_VERSION, editorMode: 'invalid-mode', })) const state = loadLayoutState() expect(state.editorMode).toBe('live-preview') }) })
describe('round-trip persistence', () => { it('save and load round-trip preserves source mode (v5: no tabs)', () => { // Create initial state (default 'live-preview') let state = createInitialLayoutState()
// Set editorMode to 'source' via setEditorMode state = setEditorMode(state, 'source') state = { ...state, sidebarMode: 'ribbon' }
// Save state saveLayoutState(state)
// Load state const loaded = loadLayoutState()
// Verify source mode round-trips correctly expect(loaded.editorMode).toBe('source') expect(loaded.sidebarMode).toBe('ribbon') // LayoutState no longer carries tabs expect((loaded as { tabs?: unknown }).tabs).toBeUndefined() })
it('save and load round-trip preserves layout fields (v5: no tabs)', () => { let state = createInitialLayoutState() state = { ...state, editorMode: 'live-preview', sidebarMode: 'ribbon', sidebarWidth: 350 }
saveLayoutState(state) const loaded = loadLayoutState()
expect(loaded.editorMode).toBe('live-preview') expect(loaded.sidebarMode).toBe('ribbon') expect(loaded.sidebarWidth).toBe(350) expect((loaded as { tabs?: unknown }).tabs).toBeUndefined() }) })
describe('localStorage unavailable', () => { it('saveLayoutState does not throw when localStorage throws', () => { const mockStorage = { getItem: vi.fn(), setItem: vi.fn(() => { throw new Error('Storage full') }), removeItem: vi.fn(), clear: vi.fn(), length: 0, key: vi.fn(), } vi.stubGlobal('localStorage', mockStorage) const state = createInitialLayoutState() expect(() => saveLayoutState(state)).not.toThrow() vi.unstubAllGlobals() })
it('loadLayoutState returns defaults when localStorage throws', () => { const mockStorage = { getItem: vi.fn(() => { throw new Error('Access denied') }), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), length: 0, key: vi.fn(), } vi.stubGlobal('localStorage', mockStorage) const state = loadLayoutState() const defaults = createInitialLayoutState() expect(state).toEqual(defaults) vi.unstubAllGlobals() }) })
describe('workspace tabs persistence', () => { beforeEach(() => { localStorage.clear() })
it('saveWorkspaceTabs writes to localStorage', () => { saveWorkspaceTabs({ tabs: [{ docUrl: 'automerge:doc1' }, { docUrl: 'automerge:doc2' }], activeIndex: 1, })
const stored = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY) expect(stored).not.toBeNull() const parsed = JSON.parse(stored!) expect(parsed.tabs).toHaveLength(2) expect(parsed.tabs[0].docUrl).toBe('automerge:doc1') expect(parsed.activeIndex).toBe(1) })
it('loadWorkspaceTabs returns null for empty storage', () => { expect(loadWorkspaceTabs()).toBeNull() })
it('loadWorkspaceTabs round-trips correctly', () => { const state = { tabs: [{ docUrl: 'automerge:doc1' }], activeIndex: 0, } saveWorkspaceTabs(state) const loaded = loadWorkspaceTabs() expect(loaded).toEqual(state) })
it('loadWorkspaceTabs filters invalid tabs', () => { localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify({ tabs: [ { docUrl: 'valid' }, { notDocUrl: 'invalid' }, 'also invalid', ], activeIndex: 0, })) const loaded = loadWorkspaceTabs() expect(loaded?.tabs).toHaveLength(1) expect(loaded?.tabs[0].docUrl).toBe('valid') })
it('loadWorkspaceTabs defaults activeIndex to 0 when missing', () => { localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify({ tabs: [{ docUrl: 'automerge:doc1' }], })) const loaded = loadWorkspaceTabs() expect(loaded?.activeIndex).toBe(0) })
it('loadWorkspaceTabs returns null for invalid JSON', () => { localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, 'not json') expect(loadWorkspaceTabs()).toBeNull() })
it('loadWorkspaceTabs returns null when tabs is not an array', () => { localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify({ tabs: 'not an array', activeIndex: 0, })) expect(loadWorkspaceTabs()).toBeNull() }) })})