// src/layout/__tests__/layout-persistence.test.ts import { 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() }) }) })