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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ScryingPoolController } from '../../../src/core/ScryingPoolController.js';
|
||||
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||
import { StateStore } from '../../../src/core/StateStore.js';
|
||||
|
||||
/** @returns {{ emit: Function, registerPendingOp: Function, confirmPendingOp: Function, setReady: Function }} */
|
||||
function makeSocketHandler() {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
registerPendingOp: vi.fn(),
|
||||
confirmPendingOp: vi.fn(),
|
||||
setReady: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {StateStore} */
|
||||
function makeStateStore() {
|
||||
const settingsMock = {
|
||||
get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
};
|
||||
return new StateStore(settingsMock);
|
||||
}
|
||||
|
||||
describe('ScryingPoolController', () => {
|
||||
let adapter;
|
||||
let stateStore;
|
||||
let socketHandler;
|
||||
let controller;
|
||||
let hooksStub;
|
||||
|
||||
beforeEach(() => {
|
||||
hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
||||
vi.stubGlobal('Hooks', hooksStub);
|
||||
|
||||
adapter = createFoundryAdapterMock({
|
||||
users: { isGM: () => true },
|
||||
hooks: hooksStub
|
||||
});
|
||||
adapter.socket.on = vi.fn();
|
||||
|
||||
stateStore = makeStateStore();
|
||||
socketHandler = makeSocketHandler();
|
||||
controller = new ScryingPoolController(stateStore, socketHandler, adapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── AC-1: Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('constructor (AC-1)', () => {
|
||||
it('initialises _pendingOps as an empty Map', () => {
|
||||
expect(controller._pendingOps).toBeInstanceOf(Map);
|
||||
expect(controller._pendingOps.size).toBe(0);
|
||||
});
|
||||
|
||||
it('initialises _revisions as an empty Map', () => {
|
||||
expect(controller._revisions).toBeInstanceOf(Map);
|
||||
expect(controller._revisions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT register socket listener in constructor (side-effect free)', () => {
|
||||
expect(adapter.socket.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-1: init() ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('init() (AC-1)', () => {
|
||||
it('registers socket echo listener for scrying-pool.visibility.updated', () => {
|
||||
controller.init();
|
||||
expect(adapter.socket.on).toHaveBeenCalledWith(
|
||||
'scrying-pool.visibility.updated',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-2: action() happy path ─────────────────────────────────────────────
|
||||
|
||||
describe('action() happy path (AC-2)', () => {
|
||||
it('stores a PendingOp in _pendingOps keyed by participantId', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(controller._pendingOps.has('user-1')).toBe(true);
|
||||
expect(controller._pendingOps.get('user-1')).toMatchObject({
|
||||
opId: 'op-1',
|
||||
userId: 'user-1',
|
||||
targetState: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls stateStore.setVisibility with the target state (optimistic update)', () => {
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
||||
});
|
||||
|
||||
it('calls socketHandler.emit with VISIBILITY_SET event and correct payload', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(socketHandler.emit).toHaveBeenCalledWith(
|
||||
'scrying-pool.visibility.set',
|
||||
expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls socketHandler.registerPendingOp with the PendingOp, event, and payload', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
|
||||
'scrying-pool.visibility.set',
|
||||
expect.objectContaining({ opId: 'op-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fires Hooks.callAll scrying-pool:controllerAction with correct payload', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(hooksStub.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:controllerAction',
|
||||
expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'ui', opId: 'op-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets previousState to null-coalesced "never-connected" when participant is new', () => {
|
||||
controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
|
||||
const op = controller._pendingOps.get('new-user');
|
||||
expect(op.previousState).toBe('never-connected');
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-5: non-GM authorization ────────────────────────────────────────────
|
||||
|
||||
describe('action() non-GM authorization (AC-5)', () => {
|
||||
it('warns and silently drops the action when adapter.users.isGM() is false', () => {
|
||||
const nonGmAdapter = createFoundryAdapterMock({
|
||||
users: { isGM: () => false },
|
||||
hooks: hooksStub
|
||||
});
|
||||
nonGmAdapter.socket.on = vi.fn();
|
||||
const playerController = new ScryingPoolController(stateStore, socketHandler, nonGmAdapter);
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
playerController.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[ScryingPool]', expect.stringContaining('non-GM'));
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
expect(socketHandler.emit).not.toHaveBeenCalled();
|
||||
expect(socketHandler.registerPendingOp).not.toHaveBeenCalled();
|
||||
expect(hooksStub.callAll).not.toHaveBeenCalled();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-3: latest-revision-wins guard ─────────────────────────────────────
|
||||
|
||||
describe('action() latest-revision-wins guard (AC-3)', () => {
|
||||
it('silently drops action when baseRevision < confirmed revision', () => {
|
||||
controller._revisions.set('user-1', 5);
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-2', 3); // 3 < 5 → stale
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
expect(socketHandler.emit).not.toHaveBeenCalled();
|
||||
expect(hooksStub.callAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows action when baseRevision equals confirmed revision (not stale)', () => {
|
||||
controller._revisions.set('user-1', 5);
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-2', 5); // 5 == 5 → not stale
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
||||
});
|
||||
|
||||
it('allows action with baseRevision=0 when no revision confirmed yet', () => {
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
|
||||
expect(setSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-4: last-intent guard ───────────────────────────────────────────────
|
||||
|
||||
describe('action() last-intent guard (AC-4)', () => {
|
||||
it('silently drops action when participant is already in targetState', () => {
|
||||
// Seed the state store with the current state
|
||||
stateStore.setVisibility('user-1', 'hidden');
|
||||
vi.clearAllMocks(); // reset all mock call counts
|
||||
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
expect(socketHandler.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows action when targetState differs from current state', () => {
|
||||
stateStore.setVisibility('user-1', 'active');
|
||||
vi.clearAllMocks();
|
||||
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-3', 0);
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-11: echo reconciliation (_onEcho) ──────────────────────────────────
|
||||
|
||||
describe('_onEcho() echo reconciliation (AC-11)', () => {
|
||||
// Helper: call init() and return the captured echo handler
|
||||
function getEchoHandler() {
|
||||
controller.init();
|
||||
return adapter.socket.on.mock.calls[0][1];
|
||||
}
|
||||
|
||||
it('calls socketHandler.confirmPendingOp with the opId', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
|
||||
});
|
||||
|
||||
it('stores the echo revision in _revisions for the userId', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-2', userId: 'user-1', state: 'hidden', revision: 7 });
|
||||
expect(controller._revisions.get('user-1')).toBe(7);
|
||||
});
|
||||
|
||||
it('calls stateStore.setVisibility with the authoritative state', () => {
|
||||
controller.action('ui', 'user-1', 'active', 'op-3', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
echoHandler({ opId: 'op-3', userId: 'user-1', state: 'active', revision: 2 });
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
|
||||
});
|
||||
|
||||
it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-4', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-4', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
|
||||
expect(hooksStub.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:controllerAction',
|
||||
expect.objectContaining({ source: 'echo', participantId: 'user-1', targetState: 'hidden', opId: 'op-4' })
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the participant from _pendingOps after echo', () => {
|
||||
// Register a pending op first
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(controller._pendingOps.has('user-1')).toBe(true);
|
||||
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
|
||||
expect(controller._pendingOps.has('user-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults revision to 0 when echo payload omits revision field', () => {
|
||||
// Register a pending op first (required by new validation)
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
|
||||
expect(controller._revisions.get('user-1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── teardown() — listener cleanup (T-debt deferred from 1.4) ──────────────
|
||||
|
||||
describe('teardown()', () => {
|
||||
it('unregisters socket echo listener', () => {
|
||||
adapter.socket.off = vi.fn();
|
||||
controller.init();
|
||||
const handler = controller._echoHandler;
|
||||
|
||||
controller.teardown();
|
||||
|
||||
expect(adapter.socket.off).toHaveBeenCalledWith(
|
||||
'scrying-pool.visibility.updated',
|
||||
handler
|
||||
);
|
||||
});
|
||||
|
||||
it('unregisters userConnected hook listener', () => {
|
||||
const fakeHookId = 42;
|
||||
adapter.hooks.on = vi.fn().mockReturnValue(fakeHookId);
|
||||
controller.init();
|
||||
|
||||
controller.teardown();
|
||||
|
||||
expect(adapter.hooks.off).toHaveBeenCalledWith('userConnected', fakeHookId);
|
||||
});
|
||||
|
||||
it('clears _pendingOps and _revisions', () => {
|
||||
controller.init();
|
||||
controller._pendingOps.set('u1', {});
|
||||
controller._revisions.set('u1', 5);
|
||||
|
||||
controller.teardown();
|
||||
|
||||
expect(controller._pendingOps.size).toBe(0);
|
||||
expect(controller._revisions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('nulls _echoHandler after teardown', () => {
|
||||
adapter.socket.off = vi.fn();
|
||||
controller.init();
|
||||
controller.teardown();
|
||||
expect(controller._echoHandler).toBeNull();
|
||||
});
|
||||
|
||||
it('is safe to call before init()', () => {
|
||||
adapter.socket.off = vi.fn();
|
||||
expect(() => controller.teardown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── userConnected disconnect cleanup (T-06 debt) ──────────────────────────
|
||||
|
||||
describe('userConnected disconnect cleanup', () => {
|
||||
it('cleans up participant on disconnect', () => {
|
||||
// Capture the userConnected handler
|
||||
let capturedHandler;
|
||||
adapter.hooks.on = vi.fn((event, handler) => {
|
||||
if (event === 'userConnected') capturedHandler = handler;
|
||||
return Symbol();
|
||||
});
|
||||
controller.init();
|
||||
controller._revisions.set('u1', 3);
|
||||
controller._pendingOps.set('u1', {});
|
||||
|
||||
capturedHandler({ id: 'u1' }, false); // user 'u1' disconnected
|
||||
|
||||
expect(controller._revisions.has('u1')).toBe(false);
|
||||
expect(controller._pendingOps.has('u1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not clean up on connect (connected=true)', () => {
|
||||
let capturedHandler;
|
||||
adapter.hooks.on = vi.fn((event, handler) => {
|
||||
if (event === 'userConnected') capturedHandler = handler;
|
||||
return Symbol();
|
||||
});
|
||||
controller.init();
|
||||
controller._revisions.set('u1', 3);
|
||||
|
||||
capturedHandler({ id: 'u1' }, true); // user connected — should not clean up
|
||||
|
||||
expect(controller._revisions.has('u1')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { VisibilityManager } from '../../../src/core/VisibilityManager.js';
|
||||
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||
import { StateStore } from '../../../src/core/StateStore.js';
|
||||
|
||||
/** @returns {StateStore} */
|
||||
function makeStateStore() {
|
||||
const settingsMock = {
|
||||
get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
};
|
||||
return new StateStore(settingsMock);
|
||||
}
|
||||
|
||||
describe('VisibilityManager', () => {
|
||||
let adapter;
|
||||
let stateStore;
|
||||
let manager;
|
||||
let hooksStub;
|
||||
|
||||
beforeEach(() => {
|
||||
hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
||||
vi.stubGlobal('Hooks', hooksStub);
|
||||
|
||||
adapter = createFoundryAdapterMock({ hooks: hooksStub });
|
||||
stateStore = makeStateStore();
|
||||
manager = new VisibilityManager(stateStore, adapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── AC-1 (construction side-effect free) ─────────────────────────────────
|
||||
|
||||
describe('constructor (side-effect free)', () => {
|
||||
it('does NOT register Hooks.on listener in constructor', () => {
|
||||
expect(hooksStub.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── init() ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('init()', () => {
|
||||
it('registers Hooks.on for scrying-pool:stateChanged', () => {
|
||||
manager.init();
|
||||
expect(hooksStub.on).toHaveBeenCalledWith(
|
||||
'scrying-pool:stateChanged',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-6: _onStateChanged — track-disable strategy ────────────────────────
|
||||
|
||||
describe('_onStateChanged() track-disable strategy (AC-6)', () => {
|
||||
let webrtcMock;
|
||||
|
||||
beforeEach(() => {
|
||||
webrtcMock = { disableTrack: vi.fn(), enableTrack: vi.fn() };
|
||||
const trackDisableAdapter = createFoundryAdapterMock({
|
||||
webrtc: webrtcMock,
|
||||
settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, trackDisableAdapter);
|
||||
manager.init();
|
||||
});
|
||||
|
||||
it('calls disableTrack(userId) when state is hidden', () => {
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
handler({ userId: 'user-1', state: 'hidden' });
|
||||
expect(webrtcMock.disableTrack).toHaveBeenCalledWith('user-1');
|
||||
expect(webrtcMock.enableTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls enableTrack(userId) when state is active', () => {
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
handler({ userId: 'user-1', state: 'active' });
|
||||
expect(webrtcMock.enableTrack).toHaveBeenCalledWith('user-1');
|
||||
expect(webrtcMock.disableTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-7: _onStateChanged — css-fallback / unsupported ────────────────────
|
||||
|
||||
describe('_onStateChanged() css-fallback strategy (AC-7)', () => {
|
||||
it('performs no webrtc call and throws no error when mode is css-fallback', () => {
|
||||
const cssFallbackAdapter = createFoundryAdapterMock({
|
||||
settings: { get: (key) => (key === 'webrtcMode' ? 'css-fallback' : null) },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, cssFallbackAdapter);
|
||||
manager.init();
|
||||
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('performs no webrtc call and throws no error when mode is unsupported', () => {
|
||||
const unsupportedAdapter = createFoundryAdapterMock({
|
||||
settings: { get: (key) => (key === 'webrtcMode' ? 'unsupported' : null) },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, unsupportedAdapter);
|
||||
manager.init();
|
||||
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-10: null webrtc guard ──────────────────────────────────────────────
|
||||
|
||||
describe('_onStateChanged() null webrtc guard (AC-10)', () => {
|
||||
it('does not throw when adapter.webrtc is null in track-disable mode', () => {
|
||||
const nullWebrtcAdapter = createFoundryAdapterMock({
|
||||
webrtc: null,
|
||||
settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
|
||||
manager.init();
|
||||
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when adapter.webrtc is null with state active', () => {
|
||||
const nullWebrtcAdapter = createFoundryAdapterMock({
|
||||
webrtc: null,
|
||||
settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
|
||||
manager.init();
|
||||
|
||||
const handler = hooksStub.on.mock.calls[0][1];
|
||||
expect(() => handler({ userId: 'user-1', state: 'active' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC-9: onRevert() ─────────────────────────────────────────────────────
|
||||
|
||||
describe('onRevert() (AC-9)', () => {
|
||||
/** @type {import('../../../src/contracts/pending-op.js').PendingOp} */
|
||||
const pendingOp = {
|
||||
opId: 'op-1',
|
||||
userId: 'user-1',
|
||||
targetState: 'hidden',
|
||||
previousState: 'active',
|
||||
issuedAt: 1000000,
|
||||
timeoutId: null,
|
||||
};
|
||||
|
||||
it('calls stateStore.setVisibility with previousState to revert', () => {
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
manager.onRevert(pendingOp);
|
||||
expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
|
||||
});
|
||||
|
||||
it('calls adapter.notifications.warn with a [ScryingPool]-prefixed message', () => {
|
||||
const warnMock = vi.fn();
|
||||
const warnAdapter = createFoundryAdapterMock({
|
||||
notifications: { warn: warnMock, info: () => {}, error: () => {} },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, warnAdapter);
|
||||
|
||||
manager.onRevert(pendingOp);
|
||||
|
||||
expect(warnMock).toHaveBeenCalledOnce();
|
||||
expect(warnMock.mock.calls[0][0]).toMatch(/^\[ScryingPool\]/);
|
||||
});
|
||||
|
||||
it('includes userId in the warning message', () => {
|
||||
const warnMock = vi.fn();
|
||||
const warnAdapter = createFoundryAdapterMock({
|
||||
notifications: { warn: warnMock, info: () => {}, error: () => {} },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, warnAdapter);
|
||||
|
||||
manager.onRevert(pendingOp);
|
||||
|
||||
expect(warnMock.mock.calls[0][0]).toContain('user-1');
|
||||
});
|
||||
|
||||
it('does NOT call notifications.info (no success notification on revert)', () => {
|
||||
const infoMock = vi.fn();
|
||||
const noInfoAdapter = createFoundryAdapterMock({
|
||||
notifications: { warn: () => {}, info: infoMock, error: () => {} },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, noInfoAdapter);
|
||||
|
||||
manager.onRevert(pendingOp);
|
||||
|
||||
expect(infoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call notifications.error', () => {
|
||||
const errorMock = vi.fn();
|
||||
const noErrorAdapter = createFoundryAdapterMock({
|
||||
notifications: { warn: () => {}, info: () => {}, error: errorMock },
|
||||
hooks: hooksStub,
|
||||
});
|
||||
manager = new VisibilityManager(stateStore, noErrorAdapter);
|
||||
|
||||
manager.onRevert(pendingOp);
|
||||
|
||||
expect(errorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── teardown() — listener cleanup (T-debt deferred from 1.4) ──────────────
|
||||
|
||||
describe('teardown()', () => {
|
||||
it('unregisters the stateChanged hook listener', () => {
|
||||
const hookId = 77;
|
||||
adapter.hooks.on = vi.fn().mockReturnValue(hookId);
|
||||
manager.init();
|
||||
|
||||
manager.teardown();
|
||||
|
||||
expect(adapter.hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', hookId);
|
||||
});
|
||||
|
||||
it('nulls _stateChangedHookId after teardown', () => {
|
||||
adapter.hooks.on = vi.fn().mockReturnValue(99);
|
||||
manager.init();
|
||||
manager.teardown();
|
||||
expect(manager._stateChangedHookId).toBeNull();
|
||||
});
|
||||
|
||||
it('is safe to call before init()', () => {
|
||||
expect(() => manager.teardown()).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not call hooks.off when init was never called', () => {
|
||||
manager.teardown();
|
||||
expect(adapter.hooks.off).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user