180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { instanceToPlain } from 'class-transformer';
|
|
import { KGProject } from '../KGProject';
|
|
|
|
// --- Mock idb for reading old IndexedDB projects ---
|
|
const mockProjects: Array<{ name: string; data: Record<string, unknown>; lastModified: number }> = [];
|
|
|
|
vi.mock('idb', () => {
|
|
const stores: Record<string, Map<string, unknown>> = {};
|
|
const getStore = (name: string) => {
|
|
if (!stores[name]) stores[name] = new Map();
|
|
return stores[name];
|
|
};
|
|
|
|
return {
|
|
openDB: vi.fn(() => {
|
|
const db = {
|
|
getAll: vi.fn((storeName: string) => {
|
|
if (storeName === 'projects') return Promise.resolve([...mockProjects]);
|
|
return Promise.resolve([...getStore(storeName).values()]);
|
|
}),
|
|
get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)),
|
|
put: vi.fn((storeName: string, value: { name: string }) => {
|
|
getStore(storeName).set(value.name, value);
|
|
}),
|
|
close: vi.fn(),
|
|
objectStoreNames: { contains: () => false },
|
|
};
|
|
return Promise.resolve(db);
|
|
}),
|
|
__stores: stores,
|
|
__reset: () => Object.keys(stores).forEach((k) => delete stores[k]),
|
|
};
|
|
});
|
|
|
|
// --- Mock OPFS for KGProjectStorage ---
|
|
class MockWritable {
|
|
data = '';
|
|
async write(content: string) { this.data = content; }
|
|
async close() {}
|
|
}
|
|
|
|
class MockFileHandle {
|
|
kind = 'file' as const;
|
|
private _content = '';
|
|
constructor(public name: string) {}
|
|
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
|
async createWritable() {
|
|
const w = new MockWritable();
|
|
const origClose = w.close.bind(w);
|
|
w.close = async () => { this._content = w.data; await origClose(); };
|
|
return w;
|
|
}
|
|
}
|
|
|
|
class MockDirHandle {
|
|
kind = 'directory' as const;
|
|
entries = new Map<string, MockDirHandle | MockFileHandle>();
|
|
constructor(public name: string) {}
|
|
|
|
async getDirectoryHandle(name: string, opts?: { create?: boolean }) {
|
|
let e = this.entries.get(name);
|
|
if (!e || e.kind !== 'directory') {
|
|
if (opts?.create) {
|
|
e = new MockDirHandle(name);
|
|
this.entries.set(name, e);
|
|
} else {
|
|
throw new DOMException('Not found', 'NotFoundError');
|
|
}
|
|
}
|
|
return e as MockDirHandle;
|
|
}
|
|
|
|
async getFileHandle(name: string, opts?: { create?: boolean }) {
|
|
let e = this.entries.get(name);
|
|
if (!e || e.kind !== 'file') {
|
|
if (opts?.create) {
|
|
e = new MockFileHandle(name);
|
|
this.entries.set(name, e);
|
|
} else {
|
|
throw new DOMException('Not found', 'NotFoundError');
|
|
}
|
|
}
|
|
return e as MockFileHandle;
|
|
}
|
|
|
|
async removeEntry(name: string) { this.entries.delete(name); }
|
|
|
|
async *values() {
|
|
for (const e of this.entries.values()) yield e;
|
|
}
|
|
}
|
|
|
|
const mockRoot = new MockDirHandle('root');
|
|
|
|
vi.stubGlobal('navigator', {
|
|
...navigator,
|
|
storage: {
|
|
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
|
|
persist: vi.fn(() => Promise.resolve(true)),
|
|
},
|
|
});
|
|
|
|
// Now import the module under test
|
|
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
|
import { KGProjectStorage } from '../io/KGProjectStorage';
|
|
|
|
describe('upgradeConfigToV1', () => {
|
|
beforeEach(async () => {
|
|
mockProjects.length = 0;
|
|
mockRoot.entries.clear();
|
|
|
|
// Reset KGProjectStorage singleton
|
|
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
|
|
const storage = KGProjectStorage.getInstance();
|
|
await storage.initialize();
|
|
});
|
|
|
|
it('migrates projects from IndexedDB to OPFS', async () => {
|
|
// Set up a mock IndexedDB project
|
|
const project = new KGProject('My Old Song', 32, 0, 130);
|
|
mockProjects.push({
|
|
name: 'My Old Song',
|
|
data: instanceToPlain(project) as Record<string, unknown>,
|
|
lastModified: 1700000000000,
|
|
});
|
|
|
|
await upgradeConfigToV1();
|
|
|
|
const storage = KGProjectStorage.getInstance();
|
|
const names = await storage.list();
|
|
expect(names).toContain('My Old Song');
|
|
|
|
const loaded = await storage.load('My Old Song');
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded!.getBpm()).toBe(130);
|
|
});
|
|
|
|
it('sanitizes project names with invalid characters', async () => {
|
|
const project = new KGProject('My:Song/Here', 32, 0, 120);
|
|
mockProjects.push({
|
|
name: 'My:Song/Here',
|
|
data: instanceToPlain(project) as Record<string, unknown>,
|
|
lastModified: Date.now(),
|
|
});
|
|
|
|
await upgradeConfigToV1();
|
|
|
|
const storage = KGProjectStorage.getInstance();
|
|
const names = await storage.list();
|
|
// Should be sanitized — no colons or slashes
|
|
expect(names).toContain('My_Song_Here');
|
|
});
|
|
|
|
it('skips projects that already exist in OPFS', async () => {
|
|
// Pre-create a project in OPFS
|
|
const storage = KGProjectStorage.getInstance();
|
|
const existing = new KGProject('Existing', 32, 0, 100);
|
|
await storage.save('Existing', existing);
|
|
|
|
// Add same project to IndexedDB mock
|
|
mockProjects.push({
|
|
name: 'Existing',
|
|
data: instanceToPlain(existing) as Record<string, unknown>,
|
|
lastModified: Date.now(),
|
|
});
|
|
|
|
// Should not throw or overwrite
|
|
await upgradeConfigToV1();
|
|
|
|
const loaded = await storage.load('Existing');
|
|
expect(loaded!.getBpm()).toBe(100); // Original BPM preserved
|
|
});
|
|
|
|
it('handles empty IndexedDB gracefully', async () => {
|
|
// No projects in IndexedDB
|
|
await expect(upgradeConfigToV1()).resolves.not.toThrow();
|
|
});
|
|
});
|