import { describe, it, expect, beforeEach, vi } from 'vitest'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProject } from '../KGProject'; import { KGTrack } from '../track/KGTrack'; // --- OPFS mock infrastructure --- class MockFileSystemWritableFileStream { public data = ''; async write(content: string) { this.data = content; } async close() {} } class MockFileSystemFileHandle { kind = 'file' as const; constructor(public name: string, private _content: string = '') {} async getFile() { return { text: () => Promise.resolve(this._content) }; } async createWritable() { const stream = new MockFileSystemWritableFileStream(); // When stream closes, update our content const origClose = stream.close.bind(stream); stream.close = async () => { this._content = stream.data; await origClose(); }; return stream; } } class MockFileSystemDirectoryHandle { kind = 'directory' as const; private entries = new Map(); constructor(public name: string) {} async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { let entry = this.entries.get(name); if (!entry || entry.kind !== 'directory') { if (options?.create) { entry = new MockFileSystemDirectoryHandle(name); this.entries.set(name, entry); } else { throw new DOMException(`Directory "${name}" not found`, 'NotFoundError'); } } return entry as MockFileSystemDirectoryHandle; } async getFileHandle(name: string, options?: { create?: boolean }): Promise { let entry = this.entries.get(name); if (!entry || entry.kind !== 'file') { if (options?.create) { entry = new MockFileSystemFileHandle(name); this.entries.set(name, entry); } else { throw new DOMException(`File "${name}" not found`, 'NotFoundError'); } } return entry as MockFileSystemFileHandle; } async removeEntry(name: string, _options?: { recursive?: boolean }): Promise { if (!this.entries.has(name)) { throw new DOMException(`Entry "${name}" not found`, 'NotFoundError'); } this.entries.delete(name); } async *values(): AsyncIterableIterator { for (const entry of this.entries.values()) { yield entry; } } } // Install the mock const mockRoot = new MockFileSystemDirectoryHandle('root'); vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: vi.fn(() => Promise.resolve(mockRoot)), persist: vi.fn(() => Promise.resolve(true)), estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })), }, }); describe('KGProjectStorage', () => { let storage: KGProjectStorage; beforeEach(async () => { // Reset singleton and mock filesystem ;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined; // Clear the mock root directory entries const entries = (mockRoot as unknown as { entries: Map }).entries; entries.clear(); storage = KGProjectStorage.getInstance(); await storage.initialize(); }); function createTestProject(name = 'Test Project'): KGProject { return new KGProject(name, 16, 0, 120); } it('defaults both zoom levels to 1 on a fresh project', () => { const project = new KGProject(); expect(project.getBarWidthMultiplier()).toBe(1); expect(project.getPianoRollZoom()).toBe(1); }); it('initializes and creates the projects directory', async () => { // The projects directory should exist after init const projects = await mockRoot.getDirectoryHandle('projects'); expect(projects).toBeDefined(); expect(projects.kind).toBe('directory'); }); it('saves and loads a project', async () => { const project = createTestProject('My Song'); await storage.save('My Song', project); const loaded = await storage.load('My Song'); expect(loaded).not.toBeNull(); expect(loaded!.getName()).toBe('My Song'); expect(loaded!.getBpm()).toBe(120); }); it('preserves track mute and solo state when saving and loading', async () => { const track = new KGTrack('Track 1', 1); track.setMuted(true); track.setSolo(true); const project = new KGProject('My Song', 16, 0, 120, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); await storage.save('My Song', project); const loaded = await storage.load('My Song'); expect(loaded).not.toBeNull(); expect(loaded!.getTracks()).toHaveLength(1); expect(loaded!.getTracks()[0].getMuted()).toBe(true); expect(loaded!.getTracks()[0].getSolo()).toBe(true); }); it('preserves both main-grid and piano-roll zoom levels when saving and loading', async () => { const project = createTestProject('Zoom Song'); project.setBarWidthMultiplier(3); project.setPianoRollZoom(5); await storage.save('Zoom Song', project); const loaded = await storage.load('Zoom Song'); expect(loaded).not.toBeNull(); expect(loaded!.getBarWidthMultiplier()).toBe(3); expect(loaded!.getPianoRollZoom()).toBe(5); }); it('creates meta.json and media/ directory on save', async () => { const project = createTestProject('My Song'); await storage.save('My Song', project); const projectsDir = await mockRoot.getDirectoryHandle('projects'); const projectDir = await projectsDir.getDirectoryHandle('My Song'); // meta.json should exist const metaHandle = await projectDir.getFileHandle('meta.json'); const metaFile = await metaHandle.getFile(); const meta = JSON.parse(await metaFile.text()); expect(meta.name).toBe('My Song'); expect(meta.createdAt).toBeGreaterThan(0); expect(meta.updatedAt).toBeGreaterThan(0); // media/ directory should exist const mediaDir = await projectDir.getDirectoryHandle('media'); expect(mediaDir.kind).toBe('directory'); }); it('throws DuplicateEntryError when overwrite is false', async () => { const project = createTestProject('Duplicate'); await storage.save('Duplicate', project); await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError); }); it('allows overwrite when overwrite is true', async () => { const project = createTestProject('Overwrite Test'); await storage.save('Overwrite Test', project); project.setBpm(140); await storage.save('Overwrite Test', project, true); const loaded = await storage.load('Overwrite Test'); expect(loaded!.getBpm()).toBe(140); }); it('preserves createdAt on overwrite', async () => { const project = createTestProject('Preserve'); await storage.save('Preserve', project); // Read the original createdAt const projectsDir = await mockRoot.getDirectoryHandle('projects'); const projectDir = await projectsDir.getDirectoryHandle('Preserve'); const metaHandle1 = await projectDir.getFileHandle('meta.json'); const meta1 = JSON.parse(await (await metaHandle1.getFile()).text()); // Save again (overwrite) await storage.save('Preserve', project, true); const metaHandle2 = await projectDir.getFileHandle('meta.json'); const meta2 = JSON.parse(await (await metaHandle2.getFile()).text()); expect(meta2.createdAt).toBe(meta1.createdAt); expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt); }); it('lists project names', async () => { await storage.save('Alpha', createTestProject('Alpha')); await storage.save('Beta', createTestProject('Beta')); await storage.save('Charlie', createTestProject('Charlie')); const names = await storage.list(); expect(names).toEqual(['Alpha', 'Beta', 'Charlie']); }); it('checks if project exists', async () => { expect(await storage.exists('Nonexistent')).toBe(false); await storage.save('Exists', createTestProject('Exists')); expect(await storage.exists('Exists')).toBe(true); }); it('deletes a project', async () => { await storage.save('ToDelete', createTestProject('ToDelete')); expect(await storage.exists('ToDelete')).toBe(true); await storage.delete('ToDelete'); expect(await storage.exists('ToDelete')).toBe(false); }); it('returns null for non-existent project on load', async () => { const result = await storage.load('Ghost'); expect(result).toBeNull(); }); it('rejects invalid project names on save', async () => { const project = createTestProject(); await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name'); await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name'); await expect(storage.save('', project)).rejects.toThrow('Invalid project name'); }); it('renames a project', async () => { await storage.save('Old Name', createTestProject('Old Name')); await storage.rename('Old Name', 'New Name'); expect(await storage.exists('Old Name')).toBe(false); expect(await storage.exists('New Name')).toBe(true); const loaded = await storage.load('New Name'); expect(loaded!.getName()).toBe('New Name'); }); });