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:
2026-05-23 16:28:53 +02:00
parent e31badf865
commit d175f92806
13 changed files with 2357 additions and 79 deletions
@@ -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);
});
});
});