/** * UI State Persistence Tests * * Tests for localStorage utilities and UIStateContext */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loadUIState, saveUIState, updateUIState, clearUIState, addRecentFile, addRecentQuery, clearRecentFiles, clearRecentQueries, exportUIState, importUIState, DEFAULT_UI_STATE, UIState, } from '../../src/lib/storage/ui-state'; describe('UI State localStorage Utilities', () => { beforeEach(() => { localStorage.clear(); }); afterEach(() => { localStorage.clear(); }); describe('loadUIState', () => { it('should return default state when localStorage is empty', () => { const state = loadUIState(); expect(state).toEqual(DEFAULT_UI_STATE); }); it('should load saved state from localStorage', () => { const customState: UIState = { ...DEFAULT_UI_STATE, theme: 'dark', visualization: { ...DEFAULT_UI_STATE.visualization, layout: 'hierarchical', nodeSize: 12, }, }; localStorage.setItem('rdf-visualizer:ui-state', JSON.stringify(customState)); const loaded = loadUIState(); expect(loaded.theme).toBe('dark'); expect(loaded.visualization.layout).toBe('hierarchical'); expect(loaded.visualization.nodeSize).toBe(12); }); it('should return default state on parse error', () => { localStorage.setItem('rdf-visualizer:ui-state', 'invalid json'); const state = loadUIState(); expect(state).toEqual(DEFAULT_UI_STATE); }); it('should migrate old version to current version', () => { const oldState = { ...DEFAULT_UI_STATE, version: 0, // Old version theme: 'dark', }; localStorage.setItem('rdf-visualizer:ui-state', JSON.stringify(oldState)); const loaded = loadUIState(); expect(loaded.version).toBe(1); // Current version expect(loaded.theme).toBe('dark'); // Preserved data }); }); describe('saveUIState', () => { it('should save state to localStorage', () => { const state: UIState = { ...DEFAULT_UI_STATE, theme: 'dark', }; const success = saveUIState(state); expect(success).toBe(true); const stored = localStorage.getItem('rdf-visualizer:ui-state'); expect(stored).toBeTruthy(); const parsed = JSON.parse(stored!); expect(parsed.theme).toBe('dark'); }); it('should update last-updated timestamp', () => { const state = { ...DEFAULT_UI_STATE }; saveUIState(state); const timestamp = localStorage.getItem('rdf-visualizer:last-updated'); expect(timestamp).toBeTruthy(); expect(parseInt(timestamp!)).toBeGreaterThan(0); }); }); describe('updateUIState', () => { it('should update partial state', () => { // Save initial state saveUIState(DEFAULT_UI_STATE); // Update theme only const success = updateUIState({ theme: 'dark' }); expect(success).toBe(true); // Verify update const loaded = loadUIState(); expect(loaded.theme).toBe('dark'); expect(loaded.visualization).toEqual(DEFAULT_UI_STATE.visualization); // Unchanged }); it('should deep merge nested objects', () => { saveUIState(DEFAULT_UI_STATE); // Update only visualization.nodeSize updateUIState({ visualization: { ...DEFAULT_UI_STATE.visualization, nodeSize: 15, }, }); const loaded = loadUIState(); expect(loaded.visualization.nodeSize).toBe(15); expect(loaded.visualization.layout).toBe(DEFAULT_UI_STATE.visualization.layout); // Unchanged }); }); describe('clearUIState', () => { it('should remove state from localStorage', () => { saveUIState(DEFAULT_UI_STATE); expect(localStorage.getItem('rdf-visualizer:ui-state')).toBeTruthy(); const success = clearUIState(); expect(success).toBe(true); expect(localStorage.getItem('rdf-visualizer:ui-state')).toBeNull(); }); it('should remove last-updated timestamp', () => { saveUIState(DEFAULT_UI_STATE); expect(localStorage.getItem('rdf-visualizer:last-updated')).toBeTruthy(); clearUIState(); expect(localStorage.getItem('rdf-visualizer:last-updated')).toBeNull(); }); }); describe('addRecentFile', () => { it('should add file to recent files', () => { const file = { id: 'file-1', name: 'test.ttl', format: 'turtle', }; addRecentFile(file); const state = loadUIState(); expect(state.recentFiles).toHaveLength(1); expect(state.recentFiles[0].id).toBe('file-1'); expect(state.recentFiles[0].name).toBe('test.ttl'); expect(state.recentFiles[0].timestamp).toBeGreaterThan(0); }); it('should add file to front of list', () => { addRecentFile({ id: 'file-1', name: 'first.ttl', format: 'turtle' }); addRecentFile({ id: 'file-2', name: 'second.ttl', format: 'turtle' }); const state = loadUIState(); expect(state.recentFiles[0].id).toBe('file-2'); // Most recent first expect(state.recentFiles[1].id).toBe('file-1'); }); it('should remove duplicate files', () => { addRecentFile({ id: 'file-1', name: 'test.ttl', format: 'turtle' }); addRecentFile({ id: 'file-2', name: 'other.ttl', format: 'turtle' }); addRecentFile({ id: 'file-1', name: 'test.ttl', format: 'turtle' }); // Duplicate const state = loadUIState(); expect(state.recentFiles).toHaveLength(2); expect(state.recentFiles[0].id).toBe('file-1'); // Moved to front }); it('should limit to 10 recent files', () => { // Add 15 files for (let i = 0; i < 15; i++) { addRecentFile({ id: `file-${i}`, name: `file${i}.ttl`, format: 'turtle' }); } const state = loadUIState(); expect(state.recentFiles).toHaveLength(10); // Max 10 expect(state.recentFiles[0].id).toBe('file-14'); // Most recent expect(state.recentFiles[9].id).toBe('file-5'); // 10th most recent }); }); describe('addRecentQuery', () => { it('should add query to recent queries', () => { addRecentQuery('SELECT * WHERE { ?s ?p ?o }'); const state = loadUIState(); expect(state.recentQueries).toHaveLength(1); expect(state.recentQueries[0].query).toBe('SELECT * WHERE { ?s ?p ?o }'); }); it('should remove duplicate queries', () => { addRecentQuery('SELECT * WHERE { ?s ?p ?o }'); addRecentQuery('SELECT ?s WHERE { ?s a ?type }'); addRecentQuery('SELECT * WHERE { ?s ?p ?o }'); // Duplicate const state = loadUIState(); expect(state.recentQueries).toHaveLength(2); expect(state.recentQueries[0].query).toBe('SELECT * WHERE { ?s ?p ?o }'); // Moved to front }); it('should limit to 20 recent queries', () => { for (let i = 0; i < 25; i++) { addRecentQuery(`SELECT * WHERE { ?s ?p ?o${i} }`); } const state = loadUIState(); expect(state.recentQueries).toHaveLength(20); // Max 20 }); }); describe('clearRecentFiles', () => { it('should clear recent files', () => { addRecentFile({ id: 'file-1', name: 'test.ttl', format: 'turtle' }); addRecentFile({ id: 'file-2', name: 'other.ttl', format: 'turtle' }); clearRecentFiles(); const state = loadUIState(); expect(state.recentFiles).toHaveLength(0); }); }); describe('clearRecentQueries', () => { it('should clear recent queries', () => { addRecentQuery('SELECT * WHERE { ?s ?p ?o }'); addRecentQuery('SELECT ?s WHERE { ?s a ?type }'); clearRecentQueries(); const state = loadUIState(); expect(state.recentQueries).toHaveLength(0); }); }); describe('exportUIState', () => { it('should export state as JSON string', () => { saveUIState({ ...DEFAULT_UI_STATE, theme: 'dark' }); const json = exportUIState(); expect(json).toBeTruthy(); const parsed = JSON.parse(json); expect(parsed.theme).toBe('dark'); }); it('should export prettified JSON', () => { saveUIState(DEFAULT_UI_STATE); const json = exportUIState(); expect(json).toContain('\n'); // Contains newlines (prettified) expect(json).toContain(' '); // Contains indentation }); }); describe('importUIState', () => { it('should import valid JSON state', () => { const exportedState = { ...DEFAULT_UI_STATE, theme: 'dark' as const, visualization: { ...DEFAULT_UI_STATE.visualization, nodeSize: 15, }, }; const json = JSON.stringify(exportedState); const success = importUIState(json); expect(success).toBe(true); const loaded = loadUIState(); expect(loaded.theme).toBe('dark'); expect(loaded.visualization.nodeSize).toBe(15); }); it('should reject invalid JSON', () => { const success = importUIState('invalid json'); expect(success).toBe(false); }); it('should reject invalid state structure', () => { const invalidState = { foo: 'bar' }; // Missing required fields const success = importUIState(JSON.stringify(invalidState)); expect(success).toBe(false); }); }); describe('localStorage unavailable scenarios', () => { it('should handle localStorage errors gracefully', () => { // Save original setItem const originalSetItem = Storage.prototype.setItem; // Override with throwing version Storage.prototype.setItem = function() { throw new Error('QuotaExceededError'); }; const success = saveUIState(DEFAULT_UI_STATE); expect(success).toBe(false); // Restore original Storage.prototype.setItem = originalSetItem; }); it('should return default state when localStorage throws on getItem', () => { // Save original getItem const originalGetItem = Storage.prototype.getItem; // Override with throwing version Storage.prototype.getItem = function() { throw new Error('SecurityError'); }; const state = loadUIState(); expect(state).toEqual(DEFAULT_UI_STATE); // Restore original Storage.prototype.getItem = originalGetItem; }); }); });