Complete Story 3.3: Preset Import & Export
Implements FR-19: Preset import/export as JSON New Files: - src/core/PresetImportExportManager.js - Core logic for export/import with merge/replace - src/ui/gm/PresetExportDialog.js - Export dialog with file download - src/ui/gm/PresetImportDialog.js - Import dialog with file picker, preview, merge/replace - templates/preset-export.hbs - Export dialog template - templates/preset-import.hbs - Import dialog template - styles/components/_preset-import-export.less - Dialog styles - tests/unit/core/PresetImportExportManager.test.js - 38 unit tests - _bmad-output/implementation-artifacts/3-3-preset-import-and-export.md - Story file Modified Files: - src/ui/gm/DirectorsBoard.js - Added export/import button handlers - templates/directors-board.hbs - Added Export/Import buttons to footer - styles/scrying-pool.less - Added stylesheet import - lang/en.json - Added localization strings for new UI - _bmad-output/implementation-artifacts/sprint-status.yaml - Story status: review Features: - Export all presets from current scene as JSON file - Import presets with merge (add new, skip duplicates) or replace (overwrite all) modes - Preview of presets before import with validation status - Confirmation dialog for replace mode to prevent data loss - Comprehensive error handling and validation - All ACs satisfied (AC-9 deferred for README docs) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* PresetImportExportManager tests
|
||||
* Story 3.3: Preset Import & Export
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PresetImportExportManager } from '../../../src/core/PresetImportExportManager.js';
|
||||
import { createScenePreset, SCENE_PRESET_VERSION, MAX_PRESETS_PER_WORLD } from '../../../src/contracts/scene-preset.js';
|
||||
|
||||
// Mock FoundryAdapter
|
||||
const createMockAdapter = () => ({
|
||||
scenes: {
|
||||
current: () => ({ name: 'Test Scene', id: 'test-scene' }),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock ScenePresetManager
|
||||
const createMockScenePresetManager = (presets = []) => {
|
||||
const cache = new Map();
|
||||
presets.forEach(p => cache.set(p.name, p));
|
||||
|
||||
return {
|
||||
list: () => Array.from(cache.values()),
|
||||
save: vi.fn(async (name) => {
|
||||
const preset = createScenePreset(name, {});
|
||||
cache.set(name, preset);
|
||||
return preset;
|
||||
}),
|
||||
delete: vi.fn(async (name) => {
|
||||
cache.delete(name);
|
||||
}),
|
||||
get: vi.fn((name) => cache.get(name) ?? null),
|
||||
_presetsCache: cache,
|
||||
_saveScenePresets: vi.fn(async () => {}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('PresetImportExportManager', () => {
|
||||
let adapter;
|
||||
let scenePresetManager;
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMockAdapter();
|
||||
scenePresetManager = createMockScenePresetManager();
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// Constructor Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should accept valid adapter and scenePresetManager', () => {
|
||||
expect(() => {
|
||||
new PresetImportExportManager(adapter, scenePresetManager);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw TypeError if adapter is missing', () => {
|
||||
expect(() => {
|
||||
new PresetImportExportManager(null, scenePresetManager);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError if adapter is not an object', () => {
|
||||
expect(() => {
|
||||
new PresetImportExportManager('not-an-object', scenePresetManager);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError if scenePresetManager is missing', () => {
|
||||
expect(() => {
|
||||
new PresetImportExportManager(adapter, null);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError if scenePresetManager is not an object', () => {
|
||||
expect(() => {
|
||||
new PresetImportExportManager(adapter, 'not-an-object');
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// exportAllPresets Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('exportAllPresets', () => {
|
||||
it('should export empty presets when no presets exist', async () => {
|
||||
const result = await manager.exportAllPresets();
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed._version).toBe(1);
|
||||
expect(parsed.worldName).toBe('Test Scene');
|
||||
expect(parsed.exportedAt).toBeTypeOf('number');
|
||||
expect(parsed.presets).toEqual({});
|
||||
});
|
||||
|
||||
it('should export all presets with correct structure', async () => {
|
||||
const preset1 = createScenePreset('Preset 1', { user1: 'active' });
|
||||
const preset2 = createScenePreset('Preset 2', { user2: 'hidden' });
|
||||
scenePresetManager = createMockScenePresetManager([preset1, preset2]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const result = await manager.exportAllPresets();
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed._version).toBe(1);
|
||||
expect(parsed.worldName).toBe('Test Scene');
|
||||
expect(parsed.exportedAt).toBeTypeOf('number');
|
||||
expect(Object.keys(parsed.presets)).toHaveLength(2);
|
||||
expect(parsed.presets['Preset 1']).toBeDefined();
|
||||
expect(parsed.presets['Preset 2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include preset data in export', async () => {
|
||||
const matrix = { user1: 'active', user2: 'hidden' };
|
||||
const preset = createScenePreset('Test Preset', matrix);
|
||||
scenePresetManager = createMockScenePresetManager([preset]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const result = await manager.exportAllPresets();
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.presets['Test Preset'].name).toBe('Test Preset');
|
||||
expect(parsed.presets['Test Preset'].matrix).toEqual(matrix);
|
||||
expect(parsed.presets['Test Preset']._version).toBe(SCENE_PRESET_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// generateExportFilename Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('generateExportFilename', () => {
|
||||
it('should generate filename with world name and timestamp by default', () => {
|
||||
const filename = manager.generateExportFilename('My Campaign');
|
||||
|
||||
expect(filename).toMatch(/^scrying-pool-presets-my_campaign_\d+\.json$/);
|
||||
});
|
||||
|
||||
it('should generate filename without timestamp when disabled', () => {
|
||||
const filename = manager.generateExportFilename('My Campaign', false);
|
||||
|
||||
expect(filename).toBe('scrying-pool-presets-my_campaign.json');
|
||||
});
|
||||
|
||||
it('should use default world name when not provided', () => {
|
||||
const filename = manager.generateExportFilename(undefined, false);
|
||||
|
||||
// Defaults to current scene name from adapter which is "Test Scene"
|
||||
expect(filename).toBe('scrying-pool-presets-test_scene.json');
|
||||
});
|
||||
|
||||
it('should sanitize world name for filesystem safety', () => {
|
||||
const filename = manager.generateExportFilename('My/Campaign:Name*Test?', false);
|
||||
|
||||
// All special characters (/, :, *, ?) are replaced with single underscores
|
||||
expect(filename).toBe('scrying-pool-presets-my_campaign_name_test_.json');
|
||||
});
|
||||
|
||||
it('should handle empty world name', () => {
|
||||
const filename = manager.generateExportFilename('', false);
|
||||
|
||||
// Empty string becomes empty after replace, then falls back to 'world'
|
||||
expect(filename).toBe('scrying-pool-presets-world.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// validateImportData Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('validateImportData', () => {
|
||||
it('should accept valid import data with presets', () => {
|
||||
const data = {
|
||||
_version: 1,
|
||||
worldName: 'Test World',
|
||||
exportedAt: 1234567890,
|
||||
presets: {
|
||||
Preset1: createScenePreset('Preset1', {}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = manager.validateImportData(data);
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should accept valid import data with only required fields', () => {
|
||||
const data = {
|
||||
_version: 1,
|
||||
presets: {},
|
||||
};
|
||||
|
||||
expect(() => manager.validateImportData(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject null data', () => {
|
||||
expect(() => manager.validateImportData(null)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject non-object data', () => {
|
||||
expect(() => manager.validateImportData('string')).toThrow(TypeError);
|
||||
expect(() => manager.validateImportData(123)).toThrow(TypeError);
|
||||
expect(() => manager.validateImportData([])).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject missing _version', () => {
|
||||
const data = { presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject invalid _version type', () => {
|
||||
const data = { _version: '1', presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject unsupported schema version', () => {
|
||||
const data = { _version: 2, presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject missing presets', () => {
|
||||
const data = { _version: 1 };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject presets as array', () => {
|
||||
const data = { _version: 1, presets: [] };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should accept valid worldName if present', () => {
|
||||
const data = { _version: 1, worldName: 'Valid Name', presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid worldName type', () => {
|
||||
const data = { _version: 1, worldName: 123, presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should accept valid exportedAt if present', () => {
|
||||
const data = { _version: 1, exportedAt: 1234567890, presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid exportedAt type', () => {
|
||||
const data = { _version: 1, exportedAt: '1234567890', presets: {} };
|
||||
|
||||
expect(() => manager.validateImportData(data)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// importPresets Tests (Merge Mode)
|
||||
// ========================================================================
|
||||
|
||||
describe('importPresets (merge mode)', () => {
|
||||
it('should reject invalid mode', async () => {
|
||||
await expect(manager.importPresets('{"_version":1,"presets":{}}', 'invalid'))
|
||||
.rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
await expect(manager.importPresets('not valid json', 'merge'))
|
||||
.rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should reject invalid JSON structure', async () => {
|
||||
await expect(manager.importPresets('{"invalid":true}', 'merge'))
|
||||
.rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should merge new presets successfully', async () => {
|
||||
const preset1 = createScenePreset('Existing Preset', { user1: 'active' });
|
||||
scenePresetManager = createMockScenePresetManager([preset1]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const importData = {
|
||||
_version: 1,
|
||||
worldName: 'Import World',
|
||||
exportedAt: 1234567890,
|
||||
presets: {
|
||||
'New Preset': createScenePreset('New Preset', { user2: 'hidden' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'merge');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.replaced).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip duplicate presets in merge mode', async () => {
|
||||
const preset1 = createScenePreset('Existing Preset', { user1: 'active' });
|
||||
scenePresetManager = createMockScenePresetManager([preset1]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const importData = {
|
||||
_version: 1,
|
||||
presets: {
|
||||
'Existing Preset': createScenePreset('Existing Preset', { user2: 'hidden' }),
|
||||
'New Preset': createScenePreset('New Preset', { user3: 'active' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'merge');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.message).toContain('skipped as duplicates');
|
||||
});
|
||||
|
||||
it('should report invalid presets in import but continue with valid ones', async () => {
|
||||
const importData = {
|
||||
_version: 1,
|
||||
presets: {
|
||||
'Valid Preset': createScenePreset('Valid Preset', {}),
|
||||
'Invalid Preset': { _version: 1, name: '' }, // Invalid: empty name
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'merge');
|
||||
|
||||
// With the current implementation, invalid presets are reported in errors
|
||||
// but the operation continues with valid presets
|
||||
expect(result.success).toBe(true); // Valid preset was imported
|
||||
expect(result.added).toBe(1); // One valid preset added
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors.some(e => e.includes('Invalid Preset'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject import exceeding max presets limit', async () => {
|
||||
// Create a manager with many presets
|
||||
const presets = [];
|
||||
for (let i = 0; i < MAX_PRESETS_PER_WORLD; i++) {
|
||||
presets.push(createScenePreset(`Preset ${i}`, {}));
|
||||
}
|
||||
scenePresetManager = createMockScenePresetManager(presets);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const importData = {
|
||||
_version: 1,
|
||||
presets: {
|
||||
'New Preset': createScenePreset('New Preset', {}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'merge');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('would exceed');
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// importPresets Tests (Replace Mode)
|
||||
// ========================================================================
|
||||
|
||||
describe('importPresets (replace mode)', () => {
|
||||
it('should replace all existing presets', async () => {
|
||||
const preset1 = createScenePreset('Existing Preset 1', { user1: 'active' });
|
||||
const preset2 = createScenePreset('Existing Preset 2', { user2: 'active' });
|
||||
scenePresetManager = createMockScenePresetManager([preset1, preset2]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const importData = {
|
||||
_version: 1,
|
||||
presets: {
|
||||
'New Preset 1': createScenePreset('New Preset 1', { user3: 'active' }),
|
||||
'New Preset 2': createScenePreset('New Preset 2', { user4: 'active' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'replace');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.replaced).toBe(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.message).toContain('Replaced all');
|
||||
});
|
||||
|
||||
it('should handle empty import in replace mode', async () => {
|
||||
const preset1 = createScenePreset('Existing Preset', { user1: 'active' });
|
||||
scenePresetManager = createMockScenePresetManager([preset1]);
|
||||
manager = new PresetImportExportManager(adapter, scenePresetManager);
|
||||
|
||||
const importData = {
|
||||
_version: 1,
|
||||
presets: {},
|
||||
};
|
||||
|
||||
const result = await manager.importPresets(JSON.stringify(importData), 'replace');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.replaced).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// downloadExportFile Tests
|
||||
// ========================================================================
|
||||
|
||||
describe('downloadExportFile', () => {
|
||||
let originalCreateObjectURL;
|
||||
let originalRevokeObjectURL;
|
||||
let mockUrl;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCreateObjectURL = URL.createObjectURL;
|
||||
originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
mockUrl = 'blob:mock-url';
|
||||
|
||||
URL.createObjectURL = vi.fn(() => mockUrl);
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
// Mock DOM
|
||||
document.body.appendChild = vi.fn();
|
||||
document.body.removeChild = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should download file with correct name', () => {
|
||||
const jsonString = '{"test": true}';
|
||||
const filename = 'test-export.json';
|
||||
|
||||
// Mock click
|
||||
const mockA = { href: '', download: '', click: vi.fn() };
|
||||
document.createElement = vi.fn(() => mockA);
|
||||
|
||||
manager.downloadExportFile(jsonString, filename);
|
||||
|
||||
expect(mockA.download).toBe(filename);
|
||||
expect(mockA.click).toHaveBeenCalled();
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw TypeError for invalid jsonString', () => {
|
||||
expect(() => {
|
||||
manager.downloadExportFile(null, 'test.json');
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError for invalid filename', () => {
|
||||
expect(() => {
|
||||
manager.downloadExportFile('{}', '');
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user