Story 3.2 done
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
/**
|
||||
* ScenePresetManager unit tests.
|
||||
*
|
||||
* Import rule: tests may import from src/ but test files themselves are not subject to
|
||||
* the src/ import boundary rules (they're in tests/).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ScenePresetManager } from '../../../src/core/ScenePresetManager.js';
|
||||
import { createScenePreset, MAX_PRESETS_PER_WORLD } from '../../../src/contracts/scene-preset.js';
|
||||
import { SOCKET_EVENTS } from '../../../src/contracts/socket-message.js';
|
||||
|
||||
// Test helper: create a mock FoundryAdapter surface
|
||||
/**
|
||||
* Creates a mock adapter with minimal required surfaces for ScenePresetManager testing.
|
||||
* @param {Partial<import('../../../src/foundry/FoundryAdapter.js').FoundryAdapter>} overrides
|
||||
* @returns {import('../../../src/foundry/FoundryAdapter.js').FoundryAdapter}
|
||||
*/
|
||||
function createMockAdapter(overrides = {}) {
|
||||
return {
|
||||
scenes: {
|
||||
current: vi.fn(() => ({ id: 'scene1', getFlag: vi.fn(), setFlag: vi.fn().mockResolvedValue({}) })),
|
||||
...overrides.scenes,
|
||||
},
|
||||
users: {
|
||||
isGM: vi.fn(() => true),
|
||||
current: vi.fn(() => ({ id: 'gm1', name: 'Test GM' })),
|
||||
all: vi.fn(() => [{ id: 'user1' }, { id: 'user2' }]),
|
||||
...overrides.users,
|
||||
},
|
||||
settings: {
|
||||
get: vi.fn((key) => {
|
||||
// Default: auto-apply enabled
|
||||
if (key === 'autoApplyEnabled') return true;
|
||||
return null;
|
||||
}),
|
||||
set: vi.fn().mockResolvedValue({}),
|
||||
...overrides.settings,
|
||||
},
|
||||
hooks: {
|
||||
on: vi.fn(() => 42),
|
||||
off: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
once: vi.fn(),
|
||||
...overrides.hooks,
|
||||
},
|
||||
socket: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides.socket,
|
||||
},
|
||||
notifications: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
...overrides.notifications,
|
||||
},
|
||||
i18n: {
|
||||
localize: vi.fn((key) => key),
|
||||
...overrides.i18n,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock StateStore surface
|
||||
function createMockStateStore(overrides = {}) {
|
||||
return {
|
||||
getState: vi.fn(() => 'active'),
|
||||
getMatrix: vi.fn(() => ({ _version: 1, matrix: { user1: 'active', user2: 'active' } })),
|
||||
setMatrix: vi.fn().mockResolvedValue({}),
|
||||
init: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock SocketHandler surface
|
||||
function createMockSocketHandler(overrides = {}) {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
registerPendingOp: vi.fn(),
|
||||
confirmPendingOp: vi.fn(),
|
||||
setReady: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a valid ScenePreset
|
||||
function createTestPreset(name = 'Test Preset', matrix = { user1: 'active', user2: 'hidden' }) {
|
||||
return createScenePreset(name, matrix);
|
||||
}
|
||||
|
||||
// Test helper: create a scene flag presets object
|
||||
function createSceneFlagPresets(presets = {}) {
|
||||
return { _version: 1, presets };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ScenePresetManager Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ScenePresetManager', () => {
|
||||
let adapter;
|
||||
let stateStore;
|
||||
let socketHandler;
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
adapter = createMockAdapter();
|
||||
stateStore = createMockStateStore();
|
||||
socketHandler = createMockSocketHandler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Constructor Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('should throw TypeError when adapter is null', () => {
|
||||
expect(() => new ScenePresetManager(null, stateStore, socketHandler)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when adapter is not an object', () => {
|
||||
expect(() => new ScenePresetManager('invalid', stateStore, socketHandler)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when stateStore is null', () => {
|
||||
expect(() => new ScenePresetManager(adapter, null, socketHandler)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when stateStore is not an object', () => {
|
||||
expect(() => new ScenePresetManager(adapter, 'invalid', socketHandler)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when socketHandler is null', () => {
|
||||
expect(() => new ScenePresetManager(adapter, stateStore, null)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when socketHandler is not an object', () => {
|
||||
expect(() => new ScenePresetManager(adapter, stateStore, 'invalid')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should accept valid dependencies and initialize internal state', () => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
expect(manager._adapter).toBe(adapter);
|
||||
expect(manager._stateStore).toBe(stateStore);
|
||||
expect(manager._socketHandler).toBe(socketHandler);
|
||||
expect(manager._presetsCache).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should be side-effect-free: no hooks registered in constructor', () => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
expect(adapter.hooks.on).not.toHaveBeenCalled();
|
||||
expect(adapter.socket.on).not.toHaveBeenCalled();
|
||||
expect(socketHandler.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// init() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('init()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
});
|
||||
|
||||
it('should load presets from current scene on init', () => {
|
||||
const mockScene = { id: 'scene1', getFlag: vi.fn().mockReturnValue(null) };
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
manager.init();
|
||||
|
||||
expect(adapter.scenes.current).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing current scene gracefully', () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
|
||||
expect(() => manager.init()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be idempotent: calling init() multiple times should not cause issues', () => {
|
||||
const mockScene = { id: 'scene1', getFlag: vi.fn().mockReturnValue(null) };
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
expect(() => {
|
||||
manager.init();
|
||||
manager.init();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// teardown() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('teardown()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
// Pre-populate cache
|
||||
manager._presetsCache.set('preset1', createTestPreset('preset1'));
|
||||
});
|
||||
|
||||
it('should clear the presets cache', () => {
|
||||
expect(manager._presetsCache.size).toBe(1);
|
||||
manager.teardown();
|
||||
expect(manager._presetsCache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should be idempotent: calling teardown() multiple times should not cause issues', () => {
|
||||
expect(() => {
|
||||
manager.teardown();
|
||||
manager.teardown();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when called before init()', () => {
|
||||
const freshManager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
expect(() => freshManager.teardown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// save() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('save()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is null', async () => {
|
||||
await expect(manager.save(null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is empty string', async () => {
|
||||
await expect(manager.save('')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is not a string', async () => {
|
||||
await expect(manager.save(123)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when max presets (50) is reached', async () => {
|
||||
// Pre-populate with 50 presets
|
||||
const presets = {};
|
||||
for (let i = 0; i < MAX_PRESETS_PER_WORLD; i++) {
|
||||
presets[`preset${i}`] = createTestPreset(`preset${i}`);
|
||||
}
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
const freshManager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
freshManager.init();
|
||||
|
||||
await expect(freshManager.save('new-preset')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should save preset with current visibility matrix', async () => {
|
||||
const matrix = { user1: 'active', user2: 'hidden' };
|
||||
stateStore.getMatrix.mockReturnValue({ _version: 1, matrix });
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
const preset = await manager.save('Combat View');
|
||||
|
||||
expect(preset.name).toBe('Combat View');
|
||||
expect(preset.matrix).toEqual(matrix);
|
||||
expect(mockScene.setFlag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the created preset', async () => {
|
||||
const matrix = { user1: 'active', user2: 'hidden' };
|
||||
stateStore.getMatrix.mockReturnValue({ _version: 1, matrix });
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
const preset = await manager.save('Test Preset');
|
||||
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset.name).toBe('Test Preset');
|
||||
expect(preset._version).toBe(1);
|
||||
expect(preset.createdAt).toBeDefined();
|
||||
expect(preset.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use adapter.i18n.localize for notification messages', async () => {
|
||||
const matrix = { user1: 'active' };
|
||||
stateStore.getMatrix.mockReturnValue({ _version: 1, matrix });
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
adapter.i18n.localize.mockReturnValue('Preset saved');
|
||||
|
||||
await manager.save('Test Preset');
|
||||
|
||||
expect(adapter.i18n.localize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Duplicate Name Handling Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('save() duplicate name handling', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
// Start with empty presets to allow first save to succeed
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets({})),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
});
|
||||
|
||||
it('should detect duplicate preset name', async () => {
|
||||
// First save should succeed
|
||||
stateStore.getMatrix.mockReturnValue({ _version: 1, matrix: { user1: 'active' } });
|
||||
await manager.save('Existing');
|
||||
|
||||
// Second save with same name should fail
|
||||
await expect(manager.save('Existing')).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// load() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('load()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
const presets = {
|
||||
'Test Preset': createTestPreset('Test Preset', { user1: 'active', user2: 'hidden' }),
|
||||
};
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is null', async () => {
|
||||
await expect(manager.load(null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is empty string', async () => {
|
||||
await expect(manager.load('')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when preset not found', async () => {
|
||||
await expect(manager.load('NonExistent')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should load preset and apply its matrix via StateStore.setMatrix()', async () => {
|
||||
await manager.load('Test Preset');
|
||||
|
||||
expect(stateStore.setMatrix).toHaveBeenCalledWith({
|
||||
_version: 1,
|
||||
matrix: { user1: 'active', user2: 'hidden' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit socket message for preset applied', async () => {
|
||||
await manager.load('Test Preset');
|
||||
|
||||
expect(socketHandler.emit).toHaveBeenCalledWith(
|
||||
SOCKET_EVENTS.PRESET_APPLIED,
|
||||
expect.objectContaining({
|
||||
presetName: 'Test Preset',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit notification via adapter.notifications', async () => {
|
||||
adapter.i18n.localize.mockReturnValue('GM applied preset: Test Preset');
|
||||
|
||||
await manager.load('Test Preset');
|
||||
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith('GM applied preset: Test Preset');
|
||||
});
|
||||
|
||||
it('should return without error on successful load', async () => {
|
||||
await expect(manager.load('Test Preset')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// list() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('list()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
});
|
||||
|
||||
it('should return empty array when no scene is active', () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
manager.init();
|
||||
|
||||
const presets = manager.list();
|
||||
expect(presets).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all presets for current scene', () => {
|
||||
const presets = {
|
||||
'Preset 1': createTestPreset('Preset 1'),
|
||||
'Preset 2': createTestPreset('Preset 2'),
|
||||
};
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
const result = manager.list();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Preset 1');
|
||||
expect(result[1].name).toBe('Preset 2');
|
||||
});
|
||||
|
||||
it('should return empty array when no presets exist', () => {
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
const presets = manager.list();
|
||||
expect(presets).toEqual([]);
|
||||
});
|
||||
|
||||
it('should validate and filter invalid presets', () => {
|
||||
const presets = {
|
||||
'Valid': createTestPreset('Valid'),
|
||||
'Invalid': { invalid: true },
|
||||
};
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
const result = manager.list();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Valid');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// delete() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('delete()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is null', async () => {
|
||||
await expect(manager.delete(null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when name is empty string', async () => {
|
||||
await expect(manager.delete('')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should delete preset from scene flag', async () => {
|
||||
const presets = {
|
||||
'To Delete': createTestPreset('To Delete'),
|
||||
'To Keep': createTestPreset('To Keep'),
|
||||
};
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
await manager.delete('To Delete');
|
||||
|
||||
const setFlagCall = mockScene.setFlag.mock.calls[0];
|
||||
expect(setFlagCall[2].presets).toHaveProperty('To Keep');
|
||||
expect(setFlagCall[2].presets).not.toHaveProperty('To Delete');
|
||||
});
|
||||
|
||||
it('should return without error on successful delete', async () => {
|
||||
const presets = { 'To Delete': createTestPreset('To Delete') };
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
await expect(manager.delete('To Delete')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// rename() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('rename()', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler);
|
||||
});
|
||||
|
||||
it('should throw TypeError when oldName is null', async () => {
|
||||
await expect(manager.rename(null, 'New Name')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when newName is null', async () => {
|
||||
await expect(manager.rename('Old Name', null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when oldName not found', async () => {
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets({})),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
await expect(manager.rename('NonExistent', 'New Name')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when newName conflicts with existing preset', async () => {
|
||||
const presets = {
|
||||
'Existing': createTestPreset('Existing'),
|
||||
'Old Name': createTestPreset('Old Name'),
|
||||
};
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
await expect(manager.rename('Old Name', 'Existing')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should rename preset and update timestamps', async () => {
|
||||
const presets = { 'Old Name': createTestPreset('Old Name') };
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
await manager.rename('Old Name', 'New Name');
|
||||
|
||||
const setFlagCall = mockScene.setFlag.mock.calls[0];
|
||||
expect(setFlagCall[2].presets['New Name']).toBeDefined();
|
||||
expect(setFlagCall[2].presets['Old Name']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the renamed preset', async () => {
|
||||
const presets = { 'Old Name': createTestPreset('Old Name') };
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(createSceneFlagPresets(presets)),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
manager.init();
|
||||
|
||||
const preset = await manager.rename('Old Name', 'New Name');
|
||||
expect(preset.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Story 3.2: Auto-Apply Tests
|
||||
// NOTE: These tests are written BEFORE implementation (TDD Red Phase)
|
||||
// They will FAIL until the Story 3.2 implementation is complete.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('constructor() with visibilityManager [Story 3.2]', () => {
|
||||
let visibilityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityManager = {
|
||||
applyMatrix: vi.fn().mockResolvedValue({}),
|
||||
getMatrix: vi.fn(() => ({ _version: 1, matrix: {} })),
|
||||
};
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should accept visibilityManager as 4th parameter', () => {
|
||||
// This test drives the constructor extension for Story 3.2
|
||||
// Currently fails because constructor only accepts 3 parameters
|
||||
expect(() => {
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should store visibilityManager reference', () => {
|
||||
// This test drives the internal field storage
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
const manager = new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager);
|
||||
expect(manager._visibilityManager).toBe(visibilityManager);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should still validate first 3 parameters', () => {
|
||||
// Ensure backward compatibility with validation
|
||||
expect(() => {
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
new ScenePresetManager(null, stateStore, socketHandler, visibilityManager);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSceneActivate() [Story 3.2]', () => {
|
||||
let mockScene;
|
||||
let visibilityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityManager = {
|
||||
applyMatrix: vi.fn().mockResolvedValue({}),
|
||||
getMatrix: vi.fn(() => ({ _version: 1, matrix: {} })),
|
||||
};
|
||||
|
||||
mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn(),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
adapter.settings.get.mockReturnValue(true); // Global auto-apply enabled
|
||||
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager);
|
||||
manager.init();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should do nothing when auto-apply is globally disabled', async () => {
|
||||
adapter.settings.get.mockReturnValue(false);
|
||||
await manager.onSceneActivate(mockScene);
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should do nothing when scene has no auto-apply config', async () => {
|
||||
mockScene.getFlag.mockReturnValue(null);
|
||||
await manager.onSceneActivate(mockScene);
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should do nothing when auto-apply is disabled for this scene', async () => {
|
||||
mockScene.getFlag.mockReturnValue({
|
||||
_version: 1,
|
||||
presets: {},
|
||||
autoApply: { enabled: false, presetName: null, preDelay: 0 }
|
||||
});
|
||||
await manager.onSceneActivate(mockScene);
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should apply preset after pre-delay when auto-apply enabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const preset = createTestPreset('Combat', { user1: 'hidden', user2: 'active' });
|
||||
manager._presetsCache.set('Combat', preset);
|
||||
|
||||
mockScene.getFlag.mockReturnValue({
|
||||
_version: 1,
|
||||
presets: { Combat: preset },
|
||||
autoApply: { enabled: true, presetName: 'Combat', preDelay: 1000 }
|
||||
});
|
||||
|
||||
const promise = manager.onSceneActivate(mockScene);
|
||||
|
||||
// Fast-forward past pre-delay
|
||||
vi.advanceTimersByTime(1000);
|
||||
await promise;
|
||||
|
||||
expect(visibilityManager.applyMatrix).toHaveBeenCalledWith(preset.matrix);
|
||||
expect(socketHandler.emit).toHaveBeenCalledWith(
|
||||
SOCKET_EVENTS.PRESET_APPLIED,
|
||||
expect.objectContaining({ presetName: 'Combat', autoApplied: true })
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should clear pre-delay timer on new scene activation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const preset = createTestPreset('Combat', { user1: 'hidden' });
|
||||
manager._presetsCache.set('Combat', preset);
|
||||
|
||||
const mockScene2 = {
|
||||
id: 'scene2',
|
||||
getFlag: vi.fn().mockReturnValue({
|
||||
_version: 1,
|
||||
presets: {},
|
||||
autoApply: { enabled: true, presetName: 'Combat', preDelay: 5000 }
|
||||
}),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// Start first scene activation
|
||||
const promise1 = manager.onSceneActivate(mockScene);
|
||||
|
||||
// Activate second scene before first timer fires
|
||||
vi.advanceTimersByTime(1000);
|
||||
const promise2 = manager.onSceneActivate(mockScene2);
|
||||
|
||||
// First scene's timer should be cleared
|
||||
vi.advanceTimersByTime(4000);
|
||||
|
||||
// Second scene should be applied
|
||||
vi.advanceTimersByTime(1000);
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(visibilityManager.applyMatrix).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPreset() with auto-apply [Story 3.2]', () => {
|
||||
let visibilityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityManager = {
|
||||
applyMatrix: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager);
|
||||
|
||||
const preset = createTestPreset('Test Preset', { user1: 'active', user2: 'hidden' });
|
||||
manager._presetsCache.set('Test Preset', preset);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should apply preset matrix via visibilityManager', async () => {
|
||||
await manager.applyPreset('Test Preset', { autoApplied: true });
|
||||
expect(visibilityManager.applyMatrix).toHaveBeenCalledWith({ user1: 'active', user2: 'hidden' });
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should emit socket message with autoApplied flag', async () => {
|
||||
await manager.applyPreset('Test Preset', { autoApplied: true });
|
||||
expect(socketHandler.emit).toHaveBeenCalledWith(
|
||||
SOCKET_EVENTS.PRESET_APPLIED,
|
||||
expect.objectContaining({ presetName: 'Test Preset', autoApplied: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should throw when preset not found', async () => {
|
||||
await expect(manager.applyPreset('NonExistent', { autoApplied: true }))
|
||||
.rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureAutoApply() [Story 3.2]', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler, {});
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should update scene flag with auto-apply config', async () => {
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
await manager.configureAutoApply(mockScene, {
|
||||
enabled: true,
|
||||
presetName: 'Combat',
|
||||
preDelay: 1000
|
||||
});
|
||||
|
||||
expect(mockScene.setFlag).toHaveBeenCalledWith(
|
||||
'video-view-manager',
|
||||
'presets',
|
||||
expect.objectContaining({
|
||||
_version: 1,
|
||||
presets: {},
|
||||
autoApply: { enabled: true, presetName: 'Combat', preDelay: 1000 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should validate config parameters', async () => {
|
||||
const mockScene = {
|
||||
id: 'scene1',
|
||||
getFlag: vi.fn().mockReturnValue(null),
|
||||
setFlag: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
// Invalid preDelay (negative)
|
||||
await expect(manager.configureAutoApply(mockScene, {
|
||||
enabled: true,
|
||||
presetName: 'Combat',
|
||||
preDelay: -100
|
||||
})).rejects.toThrow(TypeError);
|
||||
|
||||
// Invalid preDelay (over max)
|
||||
await expect(manager.configureAutoApply(mockScene, {
|
||||
enabled: true,
|
||||
presetName: 'Combat',
|
||||
preDelay: 6000
|
||||
})).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getAutoApplyConfig() [Story 3.2]', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler, {});
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should return default config when autoApply field missing', () => {
|
||||
// _getAutoApplyConfig takes flagData, not scene
|
||||
const flagData = {
|
||||
_version: 1,
|
||||
presets: {}
|
||||
// autoApply field missing
|
||||
};
|
||||
|
||||
const config = manager._getAutoApplyConfig(flagData);
|
||||
expect(config).toEqual({ enabled: false, presetName: null, preDelay: 0 });
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should return stored config when present', () => {
|
||||
// _getAutoApplyConfig takes flagData, not scene
|
||||
const flagData = {
|
||||
_version: 1,
|
||||
presets: {},
|
||||
autoApply: { enabled: true, presetName: 'Combat', preDelay: 500 }
|
||||
};
|
||||
|
||||
const config = manager._getAutoApplyConfig(flagData);
|
||||
expect(config).toEqual({ enabled: true, presetName: 'Combat', preDelay: 500 });
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should return defaults for invalid flagData', () => {
|
||||
const config = manager._getAutoApplyConfig(null);
|
||||
expect(config).toEqual({ enabled: false, presetName: null, preDelay: 0 });
|
||||
|
||||
const config2 = manager._getAutoApplyConfig({});
|
||||
expect(config2).toEqual({ enabled: false, presetName: null, preDelay: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('_applyWithDelay() [Story 3.2]', () => {
|
||||
let visibilityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityManager = {
|
||||
applyMatrix: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Adding 4th parameter for Story 3.2
|
||||
manager = new ScenePresetManager(adapter, stateStore, socketHandler, visibilityManager);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should apply preset after specified delay', async () => {
|
||||
const mockScene = { id: 'scene1' };
|
||||
const preset = createTestPreset('Test', { user1: 'hidden' });
|
||||
manager._presetsCache.set('Test', preset);
|
||||
|
||||
// @ts-expect-error - _applyWithDelay is private, accessing for testing
|
||||
manager._applyWithDelay(mockScene, 'Test', 500);
|
||||
|
||||
// Before delay, not applied
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
|
||||
// After delay, applied
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Need to await any async operations
|
||||
await vi.waitFor(() => {
|
||||
expect(visibilityManager.applyMatrix).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should be cancellable via clear method', async () => {
|
||||
const mockScene = { id: 'scene1' };
|
||||
const preset = createTestPreset('Test', { user1: 'hidden' });
|
||||
manager._presetsCache.set('Test', preset);
|
||||
|
||||
// @ts-expect-error - _applyWithDelay is private, accessing for testing
|
||||
const timerId = manager._applyWithDelay(mockScene, 'Test', 500);
|
||||
|
||||
// Clear before delay
|
||||
clearTimeout(timerId);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user