Story 3.2 done
This commit is contained in:
Vendored
+64
@@ -40,3 +40,67 @@ export const SCENE_PRESET_FIXTURES = Object.freeze({
|
||||
updatedAt: 1700000000000,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Scene flag fixtures for auto-apply configuration.
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
*/
|
||||
export const SCENE_FLAG_AUTO_APPLY_FIXTURES = Object.freeze({
|
||||
// Flag with auto-apply enabled
|
||||
withAutoApplyEnabled: Object.freeze({
|
||||
_version: 1,
|
||||
presets: Object.freeze({
|
||||
"Combat": SCENE_PRESET_FIXTURES.valid,
|
||||
}),
|
||||
autoApply: Object.freeze({
|
||||
enabled: true,
|
||||
presetName: "Combat",
|
||||
preDelay: 1000,
|
||||
}),
|
||||
}),
|
||||
|
||||
// Flag with auto-apply disabled
|
||||
withAutoApplyDisabled: Object.freeze({
|
||||
_version: 1,
|
||||
presets: Object.freeze({
|
||||
"Combat": SCENE_PRESET_FIXTURES.valid,
|
||||
}),
|
||||
autoApply: Object.freeze({
|
||||
enabled: false,
|
||||
presetName: "Combat",
|
||||
preDelay: 500,
|
||||
}),
|
||||
}),
|
||||
|
||||
// Flag without autoApply field (migration case)
|
||||
withoutAutoApply: Object.freeze({
|
||||
_version: 1,
|
||||
presets: Object.freeze({
|
||||
"Combat": SCENE_PRESET_FIXTURES.valid,
|
||||
}),
|
||||
}),
|
||||
|
||||
// Flag with auto-apply enabled but no preset selected
|
||||
withAutoApplyNoPreset: Object.freeze({
|
||||
_version: 1,
|
||||
presets: Object.freeze({}),
|
||||
autoApply: Object.freeze({
|
||||
enabled: true,
|
||||
presetName: null,
|
||||
preDelay: 0,
|
||||
}),
|
||||
}),
|
||||
|
||||
// Flag with maximum pre-delay
|
||||
withMaxPreDelay: Object.freeze({
|
||||
_version: 1,
|
||||
presets: Object.freeze({
|
||||
"Combat": SCENE_PRESET_FIXTURES.valid,
|
||||
}),
|
||||
autoApply: Object.freeze({
|
||||
enabled: true,
|
||||
presetName: "Combat",
|
||||
preDelay: 5000,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -65,6 +65,12 @@ export function createFoundryAdapterMock(overrides = {}) {
|
||||
* createFoundryAdapterMock({ webrtc: { disableTrack: vi.fn(), enableTrack: vi.fn() } })
|
||||
*/
|
||||
webrtc: overrides.webrtc !== undefined ? overrides.webrtc : null,
|
||||
/** i18n surface for localization support */
|
||||
i18n: {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
localize: (key, _data) => key, // Default: return key (no translation)
|
||||
...overrides.i18n,
|
||||
},
|
||||
hooks: {
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { NotificationBus } from '../../../src/notifications/NotificationBus.js';
|
||||
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeAdapter({
|
||||
currentUserId = 'gm-user',
|
||||
isGM = true,
|
||||
verbosity = 'all',
|
||||
users = {},
|
||||
} = {}) {
|
||||
const notifSpy = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const adpt = createFoundryAdapterMock({
|
||||
notifications: notifSpy,
|
||||
users: {
|
||||
get: vi.fn((id) => users[id] ?? { id, name: id }),
|
||||
isGM: () => isGM,
|
||||
current: () => (currentUserId ? { id: currentUserId } : null),
|
||||
...users._overrides,
|
||||
},
|
||||
settings: {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockReturnValue(verbosity),
|
||||
set: vi.fn(),
|
||||
},
|
||||
i18n: {
|
||||
localize: vi.fn((key, data) => {
|
||||
// Simple mock that returns the key with data substituted
|
||||
const messages = {
|
||||
'video-view-manager.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.',
|
||||
'video-view-manager.notifications.personalShowed': 'Your camera is now visible to the table.',
|
||||
'video-view-manager.notifications.gmHid': 'GM hid {name}\'s camera',
|
||||
'video-view-manager.notifications.gmShowed': 'GM showed {name}\'s camera',
|
||||
};
|
||||
let msg = messages[key] ?? key;
|
||||
if (data?.name) {
|
||||
msg = msg.replace('{name}', data.name);
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
},
|
||||
});
|
||||
// expose spy for assertions
|
||||
adpt._notifSpy = notifSpy;
|
||||
return adpt;
|
||||
}
|
||||
|
||||
function makeHookCapture() {
|
||||
const handlers = {};
|
||||
return {
|
||||
stub: {
|
||||
on: vi.fn((event, handler) => {
|
||||
handlers[event] = handler;
|
||||
return Symbol('hookId');
|
||||
}),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
},
|
||||
fire(event, data) {
|
||||
if (handlers[event]) handlers[event](data);
|
||||
},
|
||||
handlers,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('NotificationBus', () => {
|
||||
let hooks;
|
||||
|
||||
beforeEach(() => {
|
||||
hooks = makeHookCapture();
|
||||
vi.stubGlobal('Hooks', hooks.stub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Constructor ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('constructor', () => {
|
||||
it('stores adapter without side effects', () => {
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
expect(bus._adapter).toBe(adapter);
|
||||
expect(Hooks.on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coalesceMap is empty after construction', () => {
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
expect(bus._coalesceMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── init() ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('init()', () => {
|
||||
it('subscribes to scrying-pool:stateChanged hook', () => {
|
||||
const bus = new NotificationBus(makeAdapter());
|
||||
bus.init();
|
||||
expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
|
||||
});
|
||||
|
||||
it('stores the hook id returned by Hooks.on', () => {
|
||||
const adapter = makeAdapter();
|
||||
// Hooks.on stub returns a Symbol per makeHookCapture, check hookId is stored
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
expect(bus._hookId).toBeDefined();
|
||||
expect(bus._hookId).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Personal notifications (AC-2) ──────────────────────────────────────────
|
||||
|
||||
describe('personal notifications — current user is the affected participant', () => {
|
||||
it('fires immediate info when own camera is hidden (verbosity=all)', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith(
|
||||
"GM has hidden your camera. Your portrait is shown to other Participants."
|
||||
);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires immediate info when own camera is shown (not hidden)', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'active',
|
||||
previousState: 'hidden',
|
||||
});
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith(
|
||||
"Your camera is now visible to the table."
|
||||
);
|
||||
});
|
||||
|
||||
it('personal notification fires even when verbosity=silent', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'silent' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('personal notification fires even when verbosity=gm-only and user is not GM', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('personal notification does NOT go through coalescing (fires immediately, no timer)', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
// No timer advance needed — should fire immediately
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
expect(bus._coalesceMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Verbosity filtering — non-personal (AC-4, AC-5) ───────────────────────
|
||||
|
||||
describe('verbosity filtering — other participant changes', () => {
|
||||
it('verbosity=silent blocks notification for non-affected user', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'gm-user', isGM: true, verbosity: 'silent' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'other-player',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verbosity=gm-only blocks notification for non-GM player', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' });
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'other-player',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verbosity=gm-only allows notification for GM user', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
isGM: true,
|
||||
verbosity: 'gm-only',
|
||||
users: { 'other-player': { id: 'other-player', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'other-player',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
|
||||
});
|
||||
|
||||
it('verbosity=all allows notification for non-GM player about other participant', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'player-1',
|
||||
isGM: false,
|
||||
verbosity: 'all',
|
||||
users: { 'other-player': { id: 'other-player', name: 'Bob' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'other-player',
|
||||
state: 'active',
|
||||
previousState: 'hidden',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Bob's camera");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Coalescing timer (AC-3) ────────────────────────────────────────────────
|
||||
|
||||
describe('coalescing — 3s debounce window', () => {
|
||||
it('fires notification after 3000ms debounce window', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
// Before window closes — no notification yet
|
||||
vi.advanceTimersByTime(2_999);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
|
||||
// Window closes — fires
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
|
||||
});
|
||||
|
||||
it('resets debounce window when new change arrives before timer fires', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
// First change at t=0: active → hidden
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
// 1s later — second change arrives (hidden → self-muted), resets window
|
||||
vi.advanceTimersByTime(1_000);
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-1',
|
||||
state: 'self-muted',
|
||||
previousState: 'hidden',
|
||||
});
|
||||
|
||||
// Original 3s from first event (t=3000) should NOT fire — timer was reset
|
||||
vi.advanceTimersByTime(2_001);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
|
||||
// New 3s window from second event fires at t=1000+3000=4000
|
||||
// Net: active → self-muted (not net-zero) → fires "GM showed" (self-muted != 'hidden')
|
||||
// 2 changes total: active→hidden, hidden→self-muted
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Alice's camera (2 changes)");
|
||||
});
|
||||
|
||||
it('coalesces multiple changes into single notification with final state', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
// Three changes in rapid succession
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' });
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
// Only one notification, based on final state, with change count
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera (3 changes)");
|
||||
});
|
||||
|
||||
it('net-zero suppression: no notification when final state equals original state', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
// hide then show — net state unchanged
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' });
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('independent timers per participant', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
users: {
|
||||
'player-1': { id: 'player-1', name: 'Alice' },
|
||||
'player-2': { id: 'player-2', name: 'Bob' },
|
||||
},
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-2', state: 'hidden', previousState: 'active' });
|
||||
|
||||
// t=3001 — player-1 fires
|
||||
vi.advanceTimersByTime(2_001);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
|
||||
|
||||
// t=4001 — player-2 fires
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledTimes(2);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Bob's camera");
|
||||
});
|
||||
|
||||
it('falls back to userId when user name cannot be resolved', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
currentUserId: 'gm-user',
|
||||
// users map returns null for unknown users (default createFoundryAdapterMock: get: () => null)
|
||||
});
|
||||
// Override users.get to return null for player-x
|
||||
adapter.users.get = vi.fn().mockReturnValue(null);
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', {
|
||||
userId: 'player-x',
|
||||
state: 'hidden',
|
||||
previousState: 'active',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid player-x's camera");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Message format (AC-1) ──────────────────────────────────────────────────
|
||||
|
||||
describe('message format', () => {
|
||||
it('uses "GM hid [name]\'s camera" when final state is hidden', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
users: { 'player-1': { id: 'player-1', name: 'Aria' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
vi.advanceTimersByTime(3_001);
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Aria's camera");
|
||||
});
|
||||
|
||||
it('uses "GM showed [name]\'s camera" when final state is not hidden', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
users: { 'player-1': { id: 'player-1', name: 'Aria' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' });
|
||||
vi.advanceTimersByTime(3_001);
|
||||
|
||||
expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Aria's camera");
|
||||
});
|
||||
});
|
||||
|
||||
// ── teardown() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('teardown()', () => {
|
||||
it('unregisters Hooks listener', () => {
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
const storedId = bus._hookId;
|
||||
|
||||
bus.teardown();
|
||||
|
||||
expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', storedId);
|
||||
});
|
||||
|
||||
it('clears hookId after teardown', () => {
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
bus.teardown();
|
||||
expect(bus._hookId).toBeNull();
|
||||
});
|
||||
|
||||
it('cancels pending timers — no notification fires after teardown', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
bus.teardown();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears coalesceMap after teardown', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
expect(bus._coalesceMap.size).toBe(1);
|
||||
|
||||
bus.teardown();
|
||||
expect(bus._coalesceMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('is safe to call teardown before init', () => {
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
expect(() => bus.teardown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Guard: missing userId ──────────────────────────────────────────────────
|
||||
|
||||
describe('guards', () => {
|
||||
it('ignores stateChanged event without userId', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter();
|
||||
const bus = new NotificationBus(adapter);
|
||||
bus.init();
|
||||
|
||||
expect(() => {
|
||||
hooks.fire('scrying-pool:stateChanged', { state: 'hidden', previousState: 'active' });
|
||||
}).not.toThrow();
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
expect(adapter._notifSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null current user gracefully (treats as non-personal)', () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = makeAdapter({ currentUserId: null, isGM: false, verbosity: 'all' });
|
||||
adapter.users.current = () => null;
|
||||
const adapter2 = makeAdapter({
|
||||
currentUserId: null,
|
||||
verbosity: 'all',
|
||||
users: { 'player-1': { id: 'player-1', name: 'Alice' } },
|
||||
});
|
||||
adapter2.users.current = () => null;
|
||||
const bus = new NotificationBus(adapter2);
|
||||
bus.init();
|
||||
|
||||
hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
|
||||
vi.advanceTimersByTime(3_001);
|
||||
|
||||
expect(adapter2._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* ConfirmationBar unit tests.
|
||||
*
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
* 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 { ConfirmationBar } from '../../../../src/ui/gm/ConfirmationBar.js';
|
||||
|
||||
// Test helper: create a mock FoundryAdapter surface
|
||||
function createMockAdapter(overrides = {}) {
|
||||
return {
|
||||
hooks: {
|
||||
on: vi.fn(() => 42),
|
||||
off: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
...overrides.hooks,
|
||||
},
|
||||
i18n: {
|
||||
localize: vi.fn((key) => {
|
||||
// For testing, return strings with placeholders that match ConfirmationBar's .replace() calls
|
||||
const translations = {
|
||||
'video-view-manager.presets.confirmation.applied': 'Preset applied — {name}',
|
||||
'video-view-manager.presets.confirmation.counts': '{hidden} hidden, {visible} visible',
|
||||
'video-view-manager.presets.confirmation.partial-fail': '(some updates pending)',
|
||||
'video-view-manager.presets.confirmation.undo': 'Undo preset apply',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
}),
|
||||
...overrides.i18n,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock VisibilityManager surface
|
||||
function createMockVisibilityManager(overrides = {}) {
|
||||
return {
|
||||
applyMatrix: vi.fn().mockResolvedValue({}),
|
||||
getMatrix: vi.fn(() => ({ _version: 1, matrix: {} })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock SocketHandler surface
|
||||
function createMockSocketHandler(overrides = {}) {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock StripOverlayLayer surface
|
||||
function createMockStripOverlayLayer(overrides = {}) {
|
||||
const mockElement = document.createElement('div');
|
||||
mockElement.className = 'sp-strip__overlay-layer';
|
||||
mockElement.style.cssText = 'position: absolute; inset: 0; pointer-events: none; overflow: visible;';
|
||||
|
||||
return {
|
||||
get element() {
|
||||
return mockElement;
|
||||
},
|
||||
render: vi.fn((content) => {
|
||||
const container = document.createElement('div');
|
||||
container.style.pointerEvents = 'auto';
|
||||
container.innerHTML = content;
|
||||
mockElement.appendChild(container);
|
||||
return container;
|
||||
}),
|
||||
clearAll: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock matrix
|
||||
function createMockMatrix() {
|
||||
return { _version: 1, matrix: { user1: 'active', user2: 'hidden', user3: 'active' } };
|
||||
}
|
||||
|
||||
describe('ConfirmationBar', () => {
|
||||
let adapter;
|
||||
let visibilityManager;
|
||||
let socketHandler;
|
||||
let stripOverlayLayer;
|
||||
let confirmationBar;
|
||||
let mockElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
adapter = createMockAdapter();
|
||||
visibilityManager = createMockVisibilityManager();
|
||||
socketHandler = createMockSocketHandler();
|
||||
|
||||
mockElement = document.createElement('div');
|
||||
mockElement.className = 'scrying-pool__confirmation-bar';
|
||||
mockElement.style.display = 'none';
|
||||
|
||||
stripOverlayLayer = createMockStripOverlayLayer({
|
||||
element: mockElement,
|
||||
render: vi.fn((content) => {
|
||||
mockElement.innerHTML = content;
|
||||
mockElement.style.display = 'block';
|
||||
}),
|
||||
});
|
||||
|
||||
// Create confirmation bar with mock DOM
|
||||
document.body.appendChild(mockElement);
|
||||
|
||||
confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, stripOverlayLayer);
|
||||
confirmationBar.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
confirmationBar?.teardown();
|
||||
if (mockElement && mockElement.parentNode) {
|
||||
mockElement.parentNode.removeChild(mockElement);
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Constructor Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('STORY32-TDD: should accept all required dependencies', () => {
|
||||
expect(() => {
|
||||
new ConfirmationBar(adapter, visibilityManager, socketHandler, stripOverlayLayer);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should store dependencies', () => {
|
||||
expect(confirmationBar._adapter).toBe(adapter);
|
||||
expect(confirmationBar._visibilityManager).toBe(visibilityManager);
|
||||
expect(confirmationBar._socketHandler).toBe(socketHandler);
|
||||
expect(confirmationBar._stripOverlayLayer).toBe(stripOverlayLayer);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should initialize with empty state', () => {
|
||||
expect(confirmationBar._previousMatrix).toBeNull();
|
||||
expect(confirmationBar._dismissTimer).toBeNull();
|
||||
expect(confirmationBar._isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// init() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('init()', () => {
|
||||
it('STORY32-TDD: should register hook listener for scrying-pool:presetApplied', () => {
|
||||
expect(adapter.hooks.on).toHaveBeenCalledWith(
|
||||
'scrying-pool:presetApplied',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should be idempotent', () => {
|
||||
const initialCalls = adapter.hooks.on.mock.calls.length;
|
||||
confirmationBar.init();
|
||||
expect(adapter.hooks.on.mock.calls.length).toBe(initialCalls);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// teardown() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('teardown()', () => {
|
||||
it('STORY32-TDD: should unregister hook listener', () => {
|
||||
confirmationBar.teardown();
|
||||
expect(adapter.hooks.off).toHaveBeenCalledWith(
|
||||
'scrying-pool:presetApplied',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should clear active timer', () => {
|
||||
confirmationBar._dismissTimer = setTimeout(() => {}, 1000);
|
||||
confirmationBar.teardown();
|
||||
expect(confirmationBar._dismissTimer).toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should be idempotent', () => {
|
||||
confirmationBar.teardown();
|
||||
expect(() => confirmationBar.teardown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// show() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('show()', () => {
|
||||
it('STORY32-TDD: should store previous matrix and payload', () => {
|
||||
const matrix = createMockMatrix();
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix,
|
||||
autoApplied: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
expect(confirmationBar._previousMatrix).toEqual(matrix);
|
||||
expect(confirmationBar._lastPayload).toEqual(payload);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should render bar in strip overlay', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
expect(stripOverlayLayer.render).toHaveBeenCalled();
|
||||
expect(confirmationBar._isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should determine variant from payload', () => {
|
||||
// Test default variant
|
||||
const payload1 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
confirmationBar.show(payload1);
|
||||
expect(stripOverlayLayer.render.mock.calls[0][0]).toContain('default');
|
||||
|
||||
// Test amber variant (partial fail)
|
||||
const payload2 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
partialFail: true,
|
||||
};
|
||||
confirmationBar.show(payload2);
|
||||
expect(stripOverlayLayer.render.mock.calls[1][0]).toContain('amber');
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should calculate hidden/visible counts from matrix', () => {
|
||||
const matrix = {
|
||||
_version: 1,
|
||||
matrix: { user1: 'hidden', user2: 'hidden', user3: 'active', user4: 'active' }
|
||||
};
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix,
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
const renderCall = stripOverlayLayer.render.mock.calls[0][0];
|
||||
expect(renderCall).toContain('2 hidden');
|
||||
expect(renderCall).toContain('2 visible');
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should start dismiss timer', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
expect(confirmationBar._dismissTimer).not.toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should use short duration for rapid successive applies', () => {
|
||||
const payload1 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const payload2 = {
|
||||
presetName: 'Theatre',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// First apply
|
||||
confirmationBar.show(payload1);
|
||||
const firstTimer = confirmationBar._dismissTimer;
|
||||
|
||||
// Second apply within 60s - should use short duration
|
||||
vi.advanceTimersByTime(1000);
|
||||
confirmationBar.show(payload2);
|
||||
const secondTimer = confirmationBar._dismissTimer;
|
||||
|
||||
expect(secondTimer).not.toBe(firstTimer);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// hide() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('hide()', () => {
|
||||
beforeEach(() => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
confirmationBar.show(payload);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should clear dismiss timer', () => {
|
||||
confirmationBar.hide();
|
||||
expect(confirmationBar._dismissTimer).toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should clear previous matrix', () => {
|
||||
confirmationBar.hide();
|
||||
expect(confirmationBar._previousMatrix).toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should set isVisible to false', () => {
|
||||
expect(confirmationBar._isVisible).toBe(true);
|
||||
confirmationBar.hide();
|
||||
expect(confirmationBar._isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onUndo() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onUndo()', () => {
|
||||
it('STORY32-TDD: should revert to previous matrix', () => {
|
||||
const previousMatrix = createMockMatrix();
|
||||
confirmationBar._previousMatrix = previousMatrix;
|
||||
|
||||
confirmationBar._onUndo();
|
||||
|
||||
expect(visibilityManager.applyMatrix).toHaveBeenCalledWith(previousMatrix);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should hide the bar', () => {
|
||||
const previousMatrix = createMockMatrix();
|
||||
confirmationBar._previousMatrix = previousMatrix;
|
||||
confirmationBar._isVisible = true;
|
||||
|
||||
confirmationBar._onUndo();
|
||||
|
||||
expect(confirmationBar._isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should do nothing when no previous matrix', () => {
|
||||
confirmationBar._previousMatrix = null;
|
||||
|
||||
expect(() => confirmationBar._onUndo()).not.toThrow();
|
||||
expect(visibilityManager.applyMatrix).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should emit hook for undo notification', () => {
|
||||
const previousMatrix = createMockMatrix();
|
||||
confirmationBar._previousMatrix = previousMatrix;
|
||||
|
||||
confirmationBar._onUndo();
|
||||
|
||||
expect(adapter.hooks.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:presetUndo',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _startDismissTimer() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_startDismissTimer()', () => {
|
||||
it('STORY32-TDD: should use default duration (8000ms)', () => {
|
||||
confirmationBar._startDismissTimer();
|
||||
|
||||
expect(confirmationBar._dismissTimer).not.toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should use short duration (4000ms) when recently active', () => {
|
||||
// Set last applied timestamp to recent
|
||||
confirmationBar._lastAppliedTimestamp = Date.now();
|
||||
confirmationBar._recentApplyCount = 2;
|
||||
|
||||
confirmationBar._startDismissTimer();
|
||||
|
||||
// Should use short duration
|
||||
expect(confirmationBar._dismissTimer).not.toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should call hide on timeout', () => {
|
||||
confirmationBar._startDismissTimer();
|
||||
|
||||
vi.advanceTimersByTime(8000);
|
||||
|
||||
expect(confirmationBar._isVisible).toBe(false);
|
||||
expect(confirmationBar._dismissTimer).toBeNull();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should clear previous timer', () => {
|
||||
confirmationBar._startDismissTimer();
|
||||
const firstTimer = confirmationBar._dismissTimer;
|
||||
|
||||
confirmationBar._startDismissTimer();
|
||||
const secondTimer = confirmationBar._dismissTimer;
|
||||
|
||||
expect(secondTimer).not.toBe(firstTimer);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onPresetApplied() Tests - Hook Handler
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onPresetApplied() [hook handler]', () => {
|
||||
it('STORY32-TDD: should show bar when not visible', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
// Trigger hook directly
|
||||
const handler = adapter.hooks.on.mock.calls.find(
|
||||
call => call[0] === 'scrying-pool:presetApplied'
|
||||
)[1];
|
||||
|
||||
handler(payload);
|
||||
|
||||
expect(confirmationBar._isVisible).toBe(true);
|
||||
expect(stripOverlayLayer.render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should instant-replace when already visible', () => {
|
||||
const payload1 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
const payload2 = {
|
||||
presetName: 'Theatre',
|
||||
matrix: { _version: 1, matrix: { user1: 'hidden' } },
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
const handler = adapter.hooks.on.mock.calls.find(
|
||||
call => call[0] === 'scrying-pool:presetApplied'
|
||||
)[1];
|
||||
|
||||
// First apply
|
||||
handler(payload1);
|
||||
const firstRender = stripOverlayLayer.render.mock.calls.length;
|
||||
|
||||
// Second apply while visible - should instant-replace
|
||||
handler(payload2);
|
||||
|
||||
// Should have rendered again (instant-replace)
|
||||
expect(stripOverlayLayer.render.mock.calls.length).toBeGreaterThan(firstRender);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should track recent apply count', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const handler = adapter.hooks.on.mock.calls.find(
|
||||
call => call[0] === 'scrying-pool:presetApplied'
|
||||
)[1];
|
||||
|
||||
// First apply
|
||||
handler(payload);
|
||||
expect(confirmationBar._recentApplyCount).toBe(1);
|
||||
|
||||
// Second apply
|
||||
handler({ ...payload, timestamp: Date.now() });
|
||||
expect(confirmationBar._recentApplyCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Accessibility Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('STORY32-TDD: should set role and aria attributes on rendered bar', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
const renderCall = stripOverlayLayer.render.mock.calls[0][0];
|
||||
expect(renderCall).toContain('role="status"');
|
||||
expect(renderCall).toContain('aria-live="polite"');
|
||||
// aria-label will contain the i18n keys, but that's ok for testing the attribute exists
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should set aria-label on undo button', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
const renderCall = stripOverlayLayer.render.mock.calls[0][0];
|
||||
// aria-label will contain the i18n key, but that's ok for testing the attribute exists
|
||||
expect(renderCall).toContain('aria-label=');
|
||||
expect(renderCall).toContain('data-action="confirmation-bar-undo"');
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should use correct vocabulary from UX-DR17', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
const renderCall = stripOverlayLayer.render.mock.calls[0][0];
|
||||
// The message will contain i18n keys, but we're testing that it renders
|
||||
expect(renderCall).toContain('sp-confirmation-bar__message');
|
||||
expect(renderCall).toContain('sp-confirmation-bar__undo-btn');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Instant-Replace Rule Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('Instant-Replace Rule', () => {
|
||||
it('STORY32-TDD: should replace without crossfade animation', () => {
|
||||
const payload1 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
const payload2 = {
|
||||
presetName: 'Theatre',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload1);
|
||||
const firstContent = stripOverlayLayer.render.mock.calls[0][0];
|
||||
|
||||
confirmationBar.show(payload2);
|
||||
const secondContent = stripOverlayLayer.render.mock.calls[1][0];
|
||||
|
||||
// Content should be different (new preset)
|
||||
expect(firstContent).not.toEqual(secondContent);
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should maintain single bar instance', () => {
|
||||
const payload1 = {
|
||||
presetName: 'Combat',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
const payload2 = {
|
||||
presetName: 'Theatre',
|
||||
matrix: createMockMatrix(),
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload1);
|
||||
confirmationBar.show(payload2);
|
||||
|
||||
// Should still only have one bar visible at a time
|
||||
expect(confirmationBar._isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('STORY32-TDD: should handle null payload gracefully', () => {
|
||||
expect(() => confirmationBar.show(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should handle undefined matrix', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
expect(() => confirmationBar.show(payload)).not.toThrow();
|
||||
});
|
||||
|
||||
it('STORY32-TDD: should handle empty matrix', () => {
|
||||
const payload = {
|
||||
presetName: 'Combat',
|
||||
matrix: { _version: 1, matrix: {} },
|
||||
autoApplied: true,
|
||||
};
|
||||
|
||||
confirmationBar.show(payload);
|
||||
|
||||
const renderCall = stripOverlayLayer.render.mock.calls[0][0];
|
||||
expect(renderCall).toContain('0 hidden');
|
||||
expect(renderCall).toContain('0 visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe('DirectorsBoard', () => {
|
||||
let stateStore;
|
||||
let controller;
|
||||
let adapter;
|
||||
let scenePresetManager;
|
||||
let board;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -39,8 +40,18 @@ describe('DirectorsBoard', () => {
|
||||
get: vi.fn(() => ({ name: 'Alice', avatar: null })),
|
||||
all: vi.fn(() => [{ id: 'u1' }]),
|
||||
},
|
||||
scenes: {
|
||||
current: vi.fn(() => null),
|
||||
},
|
||||
};
|
||||
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||
scenePresetManager = {
|
||||
list: vi.fn(() => []),
|
||||
save: vi.fn(),
|
||||
load: vi.fn(),
|
||||
_getSceneFlagData: vi.fn(() => null),
|
||||
_getAutoApplyConfig: vi.fn(() => ({ enabled: false, presetName: null, preDelay: 0 })),
|
||||
};
|
||||
board = new DirectorsBoard(stateStore, controller, adapter, scenePresetManager);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -52,10 +63,16 @@ describe('DirectorsBoard', () => {
|
||||
expect(board._hookId).toBeNull();
|
||||
});
|
||||
|
||||
it('stores stateStore, controller, adapter references', () => {
|
||||
it('stores stateStore, controller, adapter, scenePresetManager references', () => {
|
||||
expect(board._stateStore).toBe(stateStore);
|
||||
expect(board._controller).toBe(controller);
|
||||
expect(board._adapter).toBe(adapter);
|
||||
expect(board._scenePresetManager).toBe(scenePresetManager);
|
||||
});
|
||||
|
||||
it('initializes _saveDialog and _loadDialog to null', () => {
|
||||
expect(board._saveDialog).toBeNull();
|
||||
expect(board._loadDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,8 +379,10 @@ describe('DirectorsBoard spotlight', () => {
|
||||
};
|
||||
adapter = {
|
||||
users: { all: vi.fn(() => [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]) },
|
||||
scenes: { current: vi.fn(() => null) },
|
||||
i18n: { localize: vi.fn((key) => key) },
|
||||
};
|
||||
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||
board = new DirectorsBoard(stateStore, controller, adapter, null);
|
||||
board.rendered = false;
|
||||
board.render = vi.fn();
|
||||
});
|
||||
@@ -448,4 +467,142 @@ describe('DirectorsBoard spotlight', () => {
|
||||
expect(spy).toHaveBeenCalledWith('u2');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Preset Save/Load Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_prepareContext() with presets', () => {
|
||||
it('should include presetCount in context', async () => {
|
||||
// Create a board with a scenePresetManager that has presets
|
||||
const presetManagerWithPresets = {
|
||||
list: vi.fn().mockReturnValue([
|
||||
{ name: 'Preset 1' },
|
||||
{ name: 'Preset 2' },
|
||||
]),
|
||||
save: vi.fn(),
|
||||
load: vi.fn(),
|
||||
};
|
||||
const boardWithPresets = new DirectorsBoard(stateStore, controller, adapter, presetManagerWithPresets);
|
||||
|
||||
const context = await boardWithPresets._prepareContext();
|
||||
|
||||
expect(context.presetCount).toBe(2);
|
||||
expect(context.hasPresets).toBe(true);
|
||||
});
|
||||
|
||||
it('should include hasPresets false when no presets', async () => {
|
||||
// Create a board with a scenePresetManager that has no presets
|
||||
const presetManagerNoPresets = {
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
save: vi.fn(),
|
||||
load: vi.fn(),
|
||||
};
|
||||
const boardNoPresets = new DirectorsBoard(stateStore, controller, adapter, presetManagerNoPresets);
|
||||
|
||||
const context = await boardNoPresets._prepareContext();
|
||||
|
||||
expect(context.presetCount).toBe(0);
|
||||
expect(context.hasPresets).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing scenePresetManager gracefully', async () => {
|
||||
const boardWithoutManager = new DirectorsBoard(stateStore, controller, adapter, null);
|
||||
|
||||
const context = await boardWithoutManager._prepareContext();
|
||||
|
||||
expect(context.presetCount).toBe(0);
|
||||
expect(context.hasPresets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onSavePreset()', () => {
|
||||
it('should have _onSavePreset method defined', () => {
|
||||
expect(board._onSavePreset).toBeDefined();
|
||||
expect(typeof board._onSavePreset).toBe('function');
|
||||
});
|
||||
|
||||
it('should have _onLoadPreset method defined', () => {
|
||||
expect(board._onLoadPreset).toBeDefined();
|
||||
expect(typeof board._onLoadPreset).toBe('function');
|
||||
});
|
||||
|
||||
it('should have _closePresetDialogs method defined', () => {
|
||||
expect(board._closePresetDialogs).toBeDefined();
|
||||
expect(typeof board._closePresetDialogs).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('click handler with preset actions', () => {
|
||||
it('should have click handler that processes save-preset action', () => {
|
||||
// The click handler is created in _onRender, so we need to set up the element first
|
||||
const mockElement = {
|
||||
querySelectorAll: vi.fn().mockReturnValue([]),
|
||||
querySelector: vi.fn(() => null),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
prepend: vi.fn(),
|
||||
after: vi.fn(),
|
||||
};
|
||||
board.element = mockElement;
|
||||
board.rendered = true;
|
||||
|
||||
board._onRender(mockElement);
|
||||
|
||||
const clickHandler = board._clickHandler;
|
||||
expect(clickHandler).toBeDefined();
|
||||
|
||||
// Verify the handler processes the action by checking it doesn't throw
|
||||
expect(typeof clickHandler).toBe('function');
|
||||
});
|
||||
|
||||
it('should have _onSavePreset method', () => {
|
||||
expect(board._onSavePreset).toBeDefined();
|
||||
expect(typeof board._onSavePreset).toBe('function');
|
||||
});
|
||||
|
||||
it('should have _onLoadPreset method', () => {
|
||||
expect(board._onLoadPreset).toBeDefined();
|
||||
expect(typeof board._onLoadPreset).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup on close', () => {
|
||||
it('should call _closePresetDialogs on _onClose', async () => {
|
||||
// Spy on the method
|
||||
const closeSpy = vi.spyOn(board, '_closePresetDialogs');
|
||||
|
||||
// Call _onClose
|
||||
await board._onClose({});
|
||||
|
||||
// _closePresetDialogs should be called
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
|
||||
closeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should close save dialog on _closePresetDialogs', () => {
|
||||
// Use the board created in beforeEach
|
||||
const saveDialog = { close: vi.fn().mockResolvedValue({}) };
|
||||
board._saveDialog = saveDialog;
|
||||
board._loadDialog = null;
|
||||
|
||||
board._closePresetDialogs();
|
||||
|
||||
expect(saveDialog.close).toHaveBeenCalled();
|
||||
expect(board._saveDialog).toBeNull();
|
||||
});
|
||||
|
||||
it('should close load dialog on _closePresetDialogs', () => {
|
||||
// Use the board created in beforeEach
|
||||
const loadDialog = { close: vi.fn().mockResolvedValue({}) };
|
||||
board._saveDialog = null;
|
||||
board._loadDialog = loadDialog;
|
||||
|
||||
board._closePresetDialogs();
|
||||
|
||||
expect(loadDialog.close).toHaveBeenCalled();
|
||||
expect(board._loadDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PresetLoadDialog } from '../../../../src/ui/gm/PresetLoadDialog.js';
|
||||
|
||||
// Test helper: create a mock ScenePresetManager surface
|
||||
function createMockScenePresetManager(overrides = {}) {
|
||||
return {
|
||||
save: vi.fn().mockResolvedValue({ _version: 1, name: 'Test Preset', matrix: {}, createdAt: Date.now(), updatedAt: Date.now() }),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
load: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
rename: vi.fn().mockResolvedValue({}),
|
||||
init: vi.fn(),
|
||||
teardown: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock adapter surface
|
||||
function createMockAdapter(overrides = {}) {
|
||||
return {
|
||||
i18n: {
|
||||
localize: vi.fn((key) => key),
|
||||
...overrides.i18n,
|
||||
},
|
||||
notifications: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock preset
|
||||
function createMockPreset(name = 'Test Preset') {
|
||||
return {
|
||||
_version: 1,
|
||||
name,
|
||||
matrix: { user1: 'active', user2: 'hidden' },
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PresetLoadDialog Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('PresetLoadDialog', () => {
|
||||
let scenePresetManager;
|
||||
let adapter;
|
||||
let dialog;
|
||||
|
||||
beforeEach(() => {
|
||||
scenePresetManager = createMockScenePresetManager();
|
||||
adapter = createMockAdapter();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dialog = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Constructor Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('should throw TypeError when scenePresetManager is null', () => {
|
||||
expect(() => new PresetLoadDialog(null, adapter)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when scenePresetManager is not an object', () => {
|
||||
expect(() => new PresetLoadDialog('not an object', adapter)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when adapter is null', () => {
|
||||
expect(() => new PresetLoadDialog(scenePresetManager, null)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when adapter is not an object', () => {
|
||||
expect(() => new PresetLoadDialog(scenePresetManager, 'not an object')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should accept valid dependencies and initialize internal state', () => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog._scenePresetManager).toBe(scenePresetManager);
|
||||
expect(dialog._adapter).toBe(adapter);
|
||||
expect(dialog._presets).toEqual([]);
|
||||
});
|
||||
|
||||
it('should be side-effect-free: no hooks registered in constructor', () => {
|
||||
const originalError = console.error;
|
||||
console.error = vi.fn();
|
||||
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it('should have DEFAULT_OPTIONS defined', () => {
|
||||
expect(PresetLoadDialog.DEFAULT_OPTIONS).toBeDefined();
|
||||
expect(PresetLoadDialog.DEFAULT_OPTIONS.id).toBe('scrying-pool-preset-load-dialog');
|
||||
expect(PresetLoadDialog.DEFAULT_OPTIONS.classes).toEqual(expect.arrayContaining(['scrying-pool', 'preset-load-dialog']));
|
||||
expect(PresetLoadDialog.DEFAULT_OPTIONS.window.title).toBe('Load Scene Preset');
|
||||
});
|
||||
|
||||
it('should have PARTS defined with template', () => {
|
||||
expect(PresetLoadDialog.PARTS).toBeDefined();
|
||||
expect(PresetLoadDialog.PARTS.dialog).toBeDefined();
|
||||
expect(PresetLoadDialog.PARTS.dialog.template).toContain('preset-load-dialog.hbs');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _prepareContext() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_prepareContext()', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
});
|
||||
|
||||
it('should return an object with presets array', async () => {
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(typeof context).toBe('object');
|
||||
expect(context.presets).toBeDefined();
|
||||
expect(Array.isArray(context.presets)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return hasPresets false when no presets exist', async () => {
|
||||
scenePresetManager.list.mockReturnValue([]);
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.load.emptyMessage') return 'No presets available';
|
||||
return key;
|
||||
});
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context.hasPresets).toBe(false);
|
||||
expect(context.emptyMessage).toBe('No presets available');
|
||||
});
|
||||
|
||||
it('should return hasPresets true when presets exist', async () => {
|
||||
const presets = [createMockPreset('Preset 1'), createMockPreset('Preset 2')];
|
||||
scenePresetManager.list.mockReturnValue(presets);
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context.hasPresets).toBe(true);
|
||||
expect(context.presets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should use i18n for labels', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
const translations = {
|
||||
'video-view-manager.presets.load.loadButton': 'Load',
|
||||
'video-view-manager.presets.load.cancelButton': 'Cancel',
|
||||
'video-view-manager.presets.load.title': 'Load Preset',
|
||||
'video-view-manager.presets.load.emptyMessage': 'No presets',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context.loadLabel).toBe('Load');
|
||||
expect(context.cancelLabel).toBe('Cancel');
|
||||
expect(context.title).toBe('Load Preset');
|
||||
expect(context.emptyMessage).toBe('No presets');
|
||||
});
|
||||
|
||||
it('should store presets in internal _presets array', async () => {
|
||||
const presets = [createMockPreset('Preset 1')];
|
||||
scenePresetManager.list.mockReturnValue(presets);
|
||||
|
||||
await dialog._prepareContext();
|
||||
|
||||
expect(dialog._presets).toEqual(presets);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onRender() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onRender()', () => {
|
||||
let mockElement;
|
||||
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
|
||||
mockElement = {
|
||||
querySelector: vi.fn(),
|
||||
querySelectorAll: vi.fn().mockReturnValue([]),
|
||||
addEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
dialog.element = mockElement;
|
||||
dialog.rendered = true;
|
||||
});
|
||||
|
||||
it('should set up load button handlers for each preset', () => {
|
||||
const loadBtn1 = { addEventListener: vi.fn(), dataset: { action: 'load', presetName: 'Preset 1' } };
|
||||
const loadBtn2 = { addEventListener: vi.fn(), dataset: { action: 'load', presetName: 'Preset 2' } };
|
||||
mockElement.querySelectorAll = vi.fn().mockReturnValue([loadBtn1, loadBtn2]);
|
||||
|
||||
dialog._onRender(mockElement);
|
||||
|
||||
expect(loadBtn1.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
expect(loadBtn2.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set up cancel button handler', () => {
|
||||
const cancelBtn = { addEventListener: vi.fn() };
|
||||
mockElement.querySelector = vi.fn((selector) => {
|
||||
if (selector === '[data-action="cancel"]') return cancelBtn;
|
||||
return null;
|
||||
});
|
||||
|
||||
dialog._onRender(mockElement);
|
||||
|
||||
expect(cancelBtn.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set up keyboard handlers', () => {
|
||||
dialog._onRender(mockElement);
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onLoad() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onLoad()', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
scenePresetManager.load = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should throw TypeError when presetName is null', async () => {
|
||||
await expect(dialog._onLoad(null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when presetName is empty string', async () => {
|
||||
await expect(dialog._onLoad('')).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when presetName is not a string', async () => {
|
||||
await expect(dialog._onLoad(123)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should call scenePresetManager.load with the preset name', async () => {
|
||||
await dialog._onLoad('My Preset');
|
||||
|
||||
expect(scenePresetManager.load).toHaveBeenCalledWith('My Preset');
|
||||
});
|
||||
|
||||
it('should close the dialog on successful load', async () => {
|
||||
await dialog._onLoad('My Preset');
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show notification on successful load via adapter.notifications', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.notifications.applied') return 'Applied preset: {name}';
|
||||
return key;
|
||||
});
|
||||
|
||||
await dialog._onLoad('My Preset');
|
||||
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith('Applied preset: My Preset');
|
||||
});
|
||||
|
||||
it('should re-throw TypeError from load', async () => {
|
||||
const error = new TypeError('preset "My Preset" not found');
|
||||
scenePresetManager.load = vi.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(dialog._onLoad('My Preset')).rejects.toThrow(TypeError);
|
||||
expect(dialog.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onCancel() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onCancel()', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should close the dialog', () => {
|
||||
dialog._onCancel();
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when called multiple times', () => {
|
||||
dialog._onCancel();
|
||||
dialog._onCancel();
|
||||
|
||||
expect(dialog.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onKeydown() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onKeydown()', () => {
|
||||
let mockEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
scenePresetManager.load = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
adapter.i18n.localize = vi.fn((key) => key);
|
||||
|
||||
mockEvent = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
key: '',
|
||||
target: {},
|
||||
};
|
||||
});
|
||||
|
||||
it('should handle Escape key to cancel', () => {
|
||||
mockEvent.key = 'Escape';
|
||||
|
||||
dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Enter key on load button', async () => {
|
||||
mockEvent.key = 'Enter';
|
||||
mockEvent.target = { dataset: { action: 'load', presetName: 'My Preset' } };
|
||||
|
||||
await dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(scenePresetManager.load).toHaveBeenCalledWith('My Preset');
|
||||
});
|
||||
|
||||
it('should ignore Enter key on non-load button', async () => {
|
||||
mockEvent.key = 'Enter';
|
||||
mockEvent.target = { dataset: { action: 'other' } };
|
||||
|
||||
await dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(scenePresetManager.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore other keys', () => {
|
||||
mockEvent.key = 'A';
|
||||
|
||||
dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(dialog.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('integration', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetLoadDialog(scenePresetManager, adapter);
|
||||
});
|
||||
|
||||
it('should have all required methods defined', () => {
|
||||
expect(dialog._prepareContext).toBeDefined();
|
||||
expect(dialog._onRender).toBeDefined();
|
||||
expect(dialog._onLoad).toBeDefined();
|
||||
expect(dialog._onCancel).toBeDefined();
|
||||
expect(dialog._onKeydown).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use the correct template path', () => {
|
||||
expect(PresetLoadDialog.PARTS.dialog.template).toBe(
|
||||
'modules/video-view-manager/templates/preset-load-dialog.hbs'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have correct window options', () => {
|
||||
const options = PresetLoadDialog.DEFAULT_OPTIONS;
|
||||
|
||||
expect(options.id).toBe('scrying-pool-preset-load-dialog');
|
||||
expect(options.classes).toContain('scrying-pool');
|
||||
expect(options.classes).toContain('preset-load-dialog');
|
||||
expect(options.window.title).toBe('Load Scene Preset');
|
||||
expect(options.window.resizable).toBe(false);
|
||||
expect(options.position.width).toBe(320);
|
||||
});
|
||||
|
||||
it('should store references to dependencies', () => {
|
||||
expect(dialog._scenePresetManager).toBe(scenePresetManager);
|
||||
expect(dialog._adapter).toBe(adapter);
|
||||
});
|
||||
|
||||
it('should initialize _presets to empty array', () => {
|
||||
expect(dialog._presets).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PresetSaveDialog } from '../../../../src/ui/gm/PresetSaveDialog.js';
|
||||
|
||||
// Test helper: create a mock ScenePresetManager surface
|
||||
function createMockScenePresetManager(overrides = {}) {
|
||||
return {
|
||||
save: vi.fn().mockResolvedValue({ _version: 1, name: 'Test Preset', matrix: {}, createdAt: Date.now(), updatedAt: Date.now() }),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
rename: vi.fn().mockResolvedValue({}),
|
||||
init: vi.fn(),
|
||||
teardown: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock adapter surface
|
||||
function createMockAdapter(overrides = {}) {
|
||||
return {
|
||||
i18n: {
|
||||
localize: vi.fn((key) => key),
|
||||
...overrides.i18n,
|
||||
},
|
||||
notifications: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PresetSaveDialog Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('PresetSaveDialog', () => {
|
||||
let scenePresetManager;
|
||||
let adapter;
|
||||
let dialog;
|
||||
|
||||
beforeEach(() => {
|
||||
scenePresetManager = createMockScenePresetManager();
|
||||
adapter = createMockAdapter();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dialog = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Constructor Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('constructor()', () => {
|
||||
it('should throw TypeError when scenePresetManager is null', () => {
|
||||
expect(() => new PresetSaveDialog(null, adapter)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when scenePresetManager is not an object', () => {
|
||||
expect(() => new PresetSaveDialog('not an object', adapter)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when adapter is null', () => {
|
||||
expect(() => new PresetSaveDialog(scenePresetManager, null)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when adapter is not an object', () => {
|
||||
expect(() => new PresetSaveDialog(scenePresetManager, 'not an object')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should accept valid dependencies and initialize internal state', () => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog._scenePresetManager).toBe(scenePresetManager);
|
||||
expect(dialog._adapter).toBe(adapter);
|
||||
});
|
||||
|
||||
it('should be side-effect-free: no hooks registered in constructor', () => {
|
||||
const originalError = console.error;
|
||||
console.error = vi.fn();
|
||||
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it('should have DEFAULT_OPTIONS defined', () => {
|
||||
expect(PresetSaveDialog.DEFAULT_OPTIONS).toBeDefined();
|
||||
expect(PresetSaveDialog.DEFAULT_OPTIONS.id).toBe('scrying-pool-preset-save-dialog');
|
||||
expect(PresetSaveDialog.DEFAULT_OPTIONS.classes).toEqual(expect.arrayContaining(['scrying-pool', 'preset-save-dialog']));
|
||||
expect(PresetSaveDialog.DEFAULT_OPTIONS.window.title).toBe('Save Scene Preset');
|
||||
});
|
||||
|
||||
it('should have PARTS defined with template', () => {
|
||||
expect(PresetSaveDialog.PARTS).toBeDefined();
|
||||
expect(PresetSaveDialog.PARTS.dialog).toBeDefined();
|
||||
expect(PresetSaveDialog.PARTS.dialog.template).toContain('preset-save-dialog.hbs');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _prepareContext() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_prepareContext()', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
});
|
||||
|
||||
it('should return an object with defaultName property', async () => {
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(typeof context).toBe('object');
|
||||
expect(context.defaultName).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty string as defaultName when no presets exist', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.save.namePlaceholder') return 'Enter preset name';
|
||||
return key;
|
||||
});
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context.defaultName).toBe('');
|
||||
});
|
||||
|
||||
it('should use i18n for labels', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => `Localized: ${key}`);
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(adapter.i18n.localize).toHaveBeenCalled();
|
||||
expect(context).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return all i18n labels', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
const translations = {
|
||||
'video-view-manager.presets.save.saveButton': 'Save',
|
||||
'video-view-manager.presets.save.cancelButton': 'Cancel',
|
||||
'video-view-manager.presets.save.title': 'Save Preset',
|
||||
'video-view-manager.presets.save.nameLabel': 'Preset Name',
|
||||
'video-view-manager.presets.save.namePlaceholder': 'Enter preset name',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
const context = await dialog._prepareContext();
|
||||
|
||||
expect(context.saveLabel).toBe('Save');
|
||||
expect(context.cancelLabel).toBe('Cancel');
|
||||
expect(context.title).toBe('Save Preset');
|
||||
expect(context.nameLabel).toBe('Preset Name');
|
||||
expect(context.namePlaceholder).toBe('Enter preset name');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onRender() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onRender()', () => {
|
||||
let mockForm;
|
||||
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
|
||||
mockForm = {
|
||||
querySelector: vi.fn((selector) => {
|
||||
if (selector === 'form') return mockForm;
|
||||
if (selector === '[name="presetName"]') return { focus: vi.fn(), value: '' };
|
||||
if (selector === '[data-action="cancel"]') return { addEventListener: vi.fn() };
|
||||
return null;
|
||||
}),
|
||||
addEventListener: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
|
||||
dialog.element = mockForm;
|
||||
dialog.rendered = true;
|
||||
});
|
||||
|
||||
it('should cache the name input element', () => {
|
||||
dialog._onRender(mockForm);
|
||||
|
||||
expect(dialog._nameInput).toBeDefined();
|
||||
expect(mockForm.querySelector).toHaveBeenCalledWith('[name="presetName"]');
|
||||
});
|
||||
|
||||
it('should focus the name input field when it exists', () => {
|
||||
const nameInput = { focus: vi.fn() };
|
||||
mockForm.querySelector = vi.fn((selector) => {
|
||||
if (selector === '[name="presetName"]') return nameInput;
|
||||
if (selector === 'form') return mockForm;
|
||||
if (selector === '[data-action="cancel"]') return { addEventListener: vi.fn() };
|
||||
return null;
|
||||
});
|
||||
|
||||
dialog._onRender(mockForm);
|
||||
|
||||
expect(nameInput.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set up form submit handler', () => {
|
||||
dialog._onRender(mockForm);
|
||||
|
||||
expect(mockForm.addEventListener).toHaveBeenCalledWith('submit', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set up cancel button handler', () => {
|
||||
const cancelBtn = { addEventListener: vi.fn() };
|
||||
mockForm.querySelector = vi.fn((selector) => {
|
||||
if (selector === 'form') return mockForm;
|
||||
if (selector === '[name="presetName"]') return { focus: vi.fn(), value: '' };
|
||||
if (selector === '[data-action="cancel"]') return cancelBtn;
|
||||
return null;
|
||||
});
|
||||
|
||||
dialog._onRender(mockForm);
|
||||
|
||||
expect(cancelBtn.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should set up keyboard handlers', () => {
|
||||
dialog._onRender(mockForm);
|
||||
|
||||
expect(mockForm.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onSubmit() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onSubmit()', () => {
|
||||
let mockEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
|
||||
mockEvent = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
target: {
|
||||
querySelector: vi.fn((selector) => {
|
||||
if (selector === '[name="presetName"]') return { value: 'My Preset' };
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should throw TypeError when event is null', async () => {
|
||||
await expect(dialog._onSubmit(null)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should prevent default and stop propagation', async () => {
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
|
||||
await dialog._onSubmit(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw TypeError when preset name input is not found', async () => {
|
||||
mockEvent.target.querySelector = vi.fn(() => null);
|
||||
|
||||
await expect(dialog._onSubmit(mockEvent)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when preset name is empty', async () => {
|
||||
mockEvent.target.querySelector = vi.fn((selector) => {
|
||||
if (selector === '[name="presetName"]') return { value: '' };
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(dialog._onSubmit(mockEvent)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should throw TypeError when preset name is only whitespace', async () => {
|
||||
mockEvent.target.querySelector = vi.fn((selector) => {
|
||||
if (selector === '[name="presetName"]') return { value: ' ' };
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(dialog._onSubmit(mockEvent)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should call scenePresetManager.save with the trimmed preset name', async () => {
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
|
||||
await dialog._onSubmit(mockEvent);
|
||||
|
||||
expect(scenePresetManager.save).toHaveBeenCalledWith('My Preset');
|
||||
});
|
||||
|
||||
it('should close the dialog on successful save', async () => {
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
|
||||
await dialog._onSubmit(mockEvent);
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show notification on successful save via adapter.notifications', async () => {
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({ name: 'My Preset' });
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.notifications.saved') return 'Preset {name} saved!';
|
||||
return key;
|
||||
});
|
||||
|
||||
await dialog._onSubmit(mockEvent);
|
||||
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith('Preset My Preset saved!');
|
||||
});
|
||||
|
||||
it('should re-throw TypeError from save', async () => {
|
||||
const error = new TypeError('a preset with name "My Preset" already exists');
|
||||
scenePresetManager.save = vi.fn().mockRejectedValue(error);
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
|
||||
await expect(dialog._onSubmit(mockEvent)).rejects.toThrow(TypeError);
|
||||
expect(dialog.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-throw max presets error from save', async () => {
|
||||
const error = new TypeError('maximum of 50 presets reached');
|
||||
scenePresetManager.save = vi.fn().mockRejectedValue(error);
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
|
||||
await expect(dialog._onSubmit(mockEvent)).rejects.toThrow(TypeError);
|
||||
expect(dialog.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onCancel() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onCancel()', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should close the dialog', () => {
|
||||
dialog._onCancel();
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when called multiple times', () => {
|
||||
dialog._onCancel();
|
||||
dialog._onCancel();
|
||||
|
||||
expect(dialog.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// _onKeydown() Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('_onKeydown()', () => {
|
||||
let mockEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({});
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
adapter.i18n.localize = vi.fn((key) => key);
|
||||
|
||||
mockEvent = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
key: '',
|
||||
target: { tagName: 'INPUT', form: { querySelector: vi.fn() } },
|
||||
};
|
||||
});
|
||||
|
||||
it('should handle Enter key on input field', async () => {
|
||||
mockEvent.key = 'Enter';
|
||||
mockEvent.target.form.querySelector = vi.fn((selector) => {
|
||||
if (selector === '[name="presetName"]') return { value: 'Test' };
|
||||
return null;
|
||||
});
|
||||
|
||||
await dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(scenePresetManager.save).toHaveBeenCalledWith('Test');
|
||||
});
|
||||
|
||||
it('should handle Escape key to cancel', () => {
|
||||
mockEvent.key = 'Escape';
|
||||
|
||||
dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(dialog.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore other keys', () => {
|
||||
mockEvent.key = 'A';
|
||||
|
||||
dialog._onKeydown(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(scenePresetManager.save).not.toHaveBeenCalled();
|
||||
expect(dialog.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('integration', () => {
|
||||
beforeEach(() => {
|
||||
dialog = new PresetSaveDialog(scenePresetManager, adapter);
|
||||
});
|
||||
|
||||
it('should have all required methods defined', () => {
|
||||
expect(dialog._prepareContext).toBeDefined();
|
||||
expect(dialog._onRender).toBeDefined();
|
||||
expect(dialog._onSubmit).toBeDefined();
|
||||
expect(dialog._onCancel).toBeDefined();
|
||||
expect(dialog._onKeydown).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use the correct template path', () => {
|
||||
expect(PresetSaveDialog.PARTS.dialog.template).toBe(
|
||||
'modules/video-view-manager/templates/preset-save-dialog.hbs'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have correct window options', () => {
|
||||
const options = PresetSaveDialog.DEFAULT_OPTIONS;
|
||||
|
||||
expect(options.id).toBe('scrying-pool-preset-save-dialog');
|
||||
expect(options.classes).toContain('scrying-pool');
|
||||
expect(options.classes).toContain('preset-save-dialog');
|
||||
expect(options.window.title).toBe('Save Scene Preset');
|
||||
expect(options.window.resizable).toBe(false);
|
||||
expect(options.position.width).toBe(320);
|
||||
});
|
||||
|
||||
it('should store references to dependencies', () => {
|
||||
expect(dialog._scenePresetManager).toBe(scenePresetManager);
|
||||
expect(dialog._adapter).toBe(adapter);
|
||||
});
|
||||
|
||||
it('should initialize _nameInput to null', () => {
|
||||
expect(dialog._nameInput).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,666 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Stub foundry global for conditional base class
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Hooks', { on: vi.fn(() => 99), off: vi.fn() });
|
||||
vi.stubGlobal('game', { user: { setFlag: vi.fn(), getFlag: vi.fn(() => null) } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
import { ScenePresetPanel } from '../../../../src/ui/gm/ScenePresetPanel.js';
|
||||
|
||||
describe('ScenePresetPanel', () => {
|
||||
let adapter;
|
||||
let scenePresetManager;
|
||||
let panel;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = {
|
||||
scenes: { current: vi.fn(() => ({ id: 'scene1', name: 'Test Scene' })) },
|
||||
i18n: { localize: vi.fn((key) => key) },
|
||||
notifications: { info: vi.fn() },
|
||||
};
|
||||
scenePresetManager = {
|
||||
list: vi.fn(() => [
|
||||
{ name: 'Preset 1' },
|
||||
{ name: 'Preset 2' },
|
||||
]),
|
||||
_getSceneFlagData: vi.fn(() => ({})),
|
||||
_getAutoApplyConfig: vi.fn(() => ({ enabled: false, presetName: null, preDelay: 0 })),
|
||||
configureAutoApply: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
panel = new ScenePresetPanel(adapter, scenePresetManager);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('is side-effect-free: does not call Hooks.on', () => {
|
||||
expect(Hooks.on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores adapter and scenePresetManager references', () => {
|
||||
expect(panel._adapter).toBe(adapter);
|
||||
expect(panel._scenePresetManager).toBe(scenePresetManager);
|
||||
});
|
||||
|
||||
it('initializes _element to null', () => {
|
||||
expect(panel._element).toBeNull();
|
||||
});
|
||||
|
||||
it('initializes _isOpen to false', () => {
|
||||
expect(panel._isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('initializes _currentScene to null', () => {
|
||||
expect(panel._currentScene).toBeNull();
|
||||
});
|
||||
|
||||
it('initializes handlers to null', () => {
|
||||
expect(panel._clickHandler).toBeNull();
|
||||
expect(panel._changeHandler).toBeNull();
|
||||
expect(panel._inputHandler).toBeNull();
|
||||
});
|
||||
|
||||
it('sets MAX_PREDELAY to 5000', () => {
|
||||
expect(panel._MAX_PREDELAY).toBe(5000);
|
||||
});
|
||||
|
||||
it('sets MIN_PREDELAY to 0', () => {
|
||||
expect(panel._MIN_PREDELAY).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('creates the DOM element', () => {
|
||||
panel.init();
|
||||
expect(panel._element).toBeInstanceOf(HTMLElement);
|
||||
expect(panel._element.className).toBe('directors-board__preset-panel');
|
||||
});
|
||||
|
||||
it('sets role attribute to region', () => {
|
||||
panel.init();
|
||||
expect(panel._element.getAttribute('role')).toBe('region');
|
||||
});
|
||||
|
||||
it('sets aria-label using i18n', () => {
|
||||
panel.init();
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('video-view-manager.scenePresetPanel.title');
|
||||
expect(panel._element.getAttribute('aria-label')).toBe('video-view-manager.scenePresetPanel.title');
|
||||
});
|
||||
|
||||
it('sets aria-expanded to false initially', () => {
|
||||
panel.init();
|
||||
expect(panel._element.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('sets display to none initially', () => {
|
||||
panel.init();
|
||||
expect(panel._element.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('sets up event listeners', () => {
|
||||
panel.init();
|
||||
expect(panel._clickHandler).toBeDefined();
|
||||
expect(panel._inputHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls _refresh() to populate initial content', () => {
|
||||
const refreshSpy = vi.spyOn(panel, '_refresh');
|
||||
panel.init();
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('element getter', () => {
|
||||
it('returns the panel element after init', () => {
|
||||
panel.init();
|
||||
expect(panel.element).toBe(panel._element);
|
||||
});
|
||||
|
||||
it('returns null before init', () => {
|
||||
expect(panel.element).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('opens the panel when closed', () => {
|
||||
panel._isOpen = false;
|
||||
panel._element.style.display = 'none';
|
||||
panel.toggle();
|
||||
expect(panel._isOpen).toBe(true);
|
||||
expect(panel._element.style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('closes the panel when open', () => {
|
||||
panel._isOpen = true;
|
||||
panel._element.style.display = 'block';
|
||||
panel.toggle();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
expect(panel._element.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', () => {
|
||||
panel._element = null;
|
||||
panel.toggle();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('open()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('sets _isOpen to true', () => {
|
||||
panel.open();
|
||||
expect(panel._isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('sets display to block', () => {
|
||||
panel.open();
|
||||
expect(panel._element.style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('sets aria-expanded to true', () => {
|
||||
panel.open();
|
||||
expect(panel._element.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('calls _refresh()', () => {
|
||||
const refreshSpy = vi.spyOn(panel, '_refresh');
|
||||
panel.open();
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', () => {
|
||||
panel._element = null;
|
||||
panel.open();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('sets _isOpen to false', () => {
|
||||
panel._isOpen = true;
|
||||
panel.close();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('sets display to none', () => {
|
||||
panel._element.style.display = 'block';
|
||||
panel.close();
|
||||
expect(panel._element.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('sets aria-expanded to false', () => {
|
||||
panel._element.setAttribute('aria-expanded', 'true');
|
||||
panel.close();
|
||||
expect(panel._element.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', () => {
|
||||
panel._element = null;
|
||||
panel.close();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_refresh()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', async () => {
|
||||
panel._element = null;
|
||||
await panel._refresh();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('builds empty HTML when no scene is current', async () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
await panel._refresh();
|
||||
expect(panel._element.innerHTML).toContain('noScene');
|
||||
});
|
||||
|
||||
it('stores current scene and builds HTML with scene', async () => {
|
||||
const mockScene = { id: 'scene1', name: 'Test Scene' };
|
||||
adapter.scenes.current.mockReturnValue(mockScene);
|
||||
|
||||
await panel._refresh();
|
||||
|
||||
expect(panel._currentScene).toBe(mockScene);
|
||||
expect(scenePresetManager.list).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates toggle aria-pressed state based on auto-apply enabled', async () => {
|
||||
scenePresetManager._getAutoApplyConfig.mockReturnValue({ enabled: true, presetName: null, preDelay: 0 });
|
||||
await panel._refresh();
|
||||
|
||||
const toggle = panel._element.querySelector('[data-action="toggle-auto-apply"]');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildEmptyHtml()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('returns HTML with no scene message', () => {
|
||||
const html = panel._buildEmptyHtml();
|
||||
expect(html).toContain('noScene');
|
||||
expect(html).toContain('directors-board__preset-panel-title');
|
||||
});
|
||||
|
||||
it('uses i18n for message', () => {
|
||||
panel._buildEmptyHtml();
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('video-view-manager.scenePresetPanel.noScene');
|
||||
});
|
||||
|
||||
it('escapes HTML in message', () => {
|
||||
adapter.i18n.localize = vi.fn(() => '<script>alert("xss")</script>');
|
||||
const html = panel._buildEmptyHtml();
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildHtml()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('builds HTML with preset options', () => {
|
||||
const html = panel._buildHtml({
|
||||
enabled: true,
|
||||
presetName: 'Preset 1',
|
||||
preDelay: 1000,
|
||||
presets: [{ name: 'Preset 1' }, { name: 'Preset 2' }],
|
||||
});
|
||||
|
||||
expect(html).toContain('Preset 1');
|
||||
expect(html).toContain('Preset 2');
|
||||
expect(html).toContain('selected');
|
||||
});
|
||||
|
||||
it('includes default option when no preset selected', () => {
|
||||
const html = panel._buildHtml({
|
||||
enabled: false,
|
||||
presetName: null,
|
||||
preDelay: 0,
|
||||
presets: [],
|
||||
});
|
||||
|
||||
expect(html).toContain('selectPreset');
|
||||
expect(html).toContain('selected');
|
||||
});
|
||||
|
||||
it('escapes preset names in options', () => {
|
||||
const html = panel._buildHtml({
|
||||
enabled: false,
|
||||
presetName: null,
|
||||
preDelay: 0,
|
||||
presets: [{ name: '<script>xss</script>' }],
|
||||
});
|
||||
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('includes pre-delay slider with correct value', () => {
|
||||
const html = panel._buildHtml({
|
||||
enabled: false,
|
||||
presetName: null,
|
||||
preDelay: 1500,
|
||||
presets: [],
|
||||
});
|
||||
|
||||
expect(html).toContain('value="1500"');
|
||||
expect(html).toContain('1500ms');
|
||||
});
|
||||
|
||||
it('sets slider min, max, and step', () => {
|
||||
const html = panel._buildHtml({
|
||||
enabled: false,
|
||||
presetName: null,
|
||||
preDelay: 0,
|
||||
presets: [],
|
||||
});
|
||||
|
||||
expect(html).toContain('min="0"');
|
||||
expect(html).toContain('max="5000"');
|
||||
expect(html).toContain('step="100"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_setupEventListeners()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', () => {
|
||||
panel._element = null;
|
||||
panel._clickHandler = null;
|
||||
panel._inputHandler = null;
|
||||
panel._setupEventListeners();
|
||||
// Should not set handlers when element is null
|
||||
expect(panel._clickHandler).toBeNull();
|
||||
expect(panel._inputHandler).toBeNull();
|
||||
});
|
||||
|
||||
it('sets up click handler', () => {
|
||||
panel._setupEventListeners();
|
||||
expect(panel._clickHandler).toBeDefined();
|
||||
expect(typeof panel._clickHandler).toBe('function');
|
||||
});
|
||||
|
||||
it('sets up input handler', () => {
|
||||
panel._setupEventListeners();
|
||||
expect(panel._inputHandler).toBeDefined();
|
||||
expect(typeof panel._inputHandler).toBe('function');
|
||||
});
|
||||
|
||||
it('adds event listeners to element', () => {
|
||||
const addSpy = vi.spyOn(panel._element, 'addEventListener');
|
||||
panel._setupEventListeners();
|
||||
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
expect(addSpy).toHaveBeenCalledWith('input', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('_removeEventListeners()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('is a no-op when element is null', () => {
|
||||
panel._element = null;
|
||||
panel._removeEventListeners();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('removes click handler', () => {
|
||||
const removeSpy = vi.spyOn(panel._element, 'removeEventListener');
|
||||
panel._removeEventListeners();
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
|
||||
it('removes input handler', () => {
|
||||
const removeSpy = vi.spyOn(panel._element, 'removeEventListener');
|
||||
panel._removeEventListeners();
|
||||
expect(removeSpy).toHaveBeenCalledWith('input', expect.any(Function));
|
||||
});
|
||||
|
||||
it('sets handlers to null after removal', () => {
|
||||
panel._removeEventListeners();
|
||||
expect(panel._clickHandler).toBeNull();
|
||||
expect(panel._inputHandler).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onToggleAutoApply()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('is a no-op when no scene is current', async () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
const mockTarget = { checked: true };
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(scenePresetManager.configureAutoApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('configures auto-apply with enabled state', async () => {
|
||||
// Create an actual HTMLInputElement for the check to work
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = true;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
|
||||
expect(scenePresetManager.configureAutoApply).toHaveBeenCalledWith(
|
||||
{ id: 'scene1', name: 'Test Scene' },
|
||||
{ enabled: true, presetName: null, preDelay: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
it('updates toggle aria-pressed state', async () => {
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = true;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(mockTarget.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows notification on enable', async () => {
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = true;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith(
|
||||
'video-view-manager.scenePresetPanel.notifications.enabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows notification on disable', async () => {
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = false;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith(
|
||||
'video-view-manager.scenePresetPanel.notifications.disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts toggle state on error', async () => {
|
||||
scenePresetManager.configureAutoApply.mockRejectedValue(new Error('Test error'));
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = true;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
// After error, the checked state should be reverted to false (was true, error occurred)
|
||||
expect(mockTarget.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('shows error notification on toggle failure', async () => {
|
||||
scenePresetManager.configureAutoApply.mockRejectedValue(new Error('Test error'));
|
||||
const mockTarget = document.createElement('input');
|
||||
mockTarget.type = 'checkbox';
|
||||
mockTarget.checked = true;
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onPresetSelected()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('is a no-op when no scene is current', async () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
const mockTarget = { value: 'Preset 1' };
|
||||
await panel._onPresetSelected(mockTarget);
|
||||
expect(scenePresetManager.configureAutoApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('configures auto-apply with selected preset', async () => {
|
||||
const mockTarget = { value: 'Preset 1' };
|
||||
scenePresetManager._getAutoApplyConfig.mockReturnValue({ enabled: true, presetName: null, preDelay: 0 });
|
||||
|
||||
await panel._onPresetSelected(mockTarget);
|
||||
|
||||
expect(scenePresetManager.configureAutoApply).toHaveBeenCalledWith(
|
||||
{ id: 'scene1', name: 'Test Scene' },
|
||||
{ enabled: true, presetName: 'Preset 1', preDelay: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
it('shows notification when preset is selected', async () => {
|
||||
const mockTarget = { value: 'Preset 1' };
|
||||
await panel._onPresetSelected(mockTarget);
|
||||
expect(adapter.notifications.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null preset selection (clears preset)', async () => {
|
||||
const mockTarget = { value: '' };
|
||||
await panel._onPresetSelected(mockTarget);
|
||||
|
||||
expect(scenePresetManager.configureAutoApply).toHaveBeenCalledWith(
|
||||
{ id: 'scene1', name: 'Test Scene' },
|
||||
expect.objectContaining({ presetName: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onDelayChanged()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
panel._element.innerHTML = '<span class="directors-board__preset-panel-delay-value">1000ms</span>';
|
||||
});
|
||||
|
||||
it('is a no-op when no scene is current', async () => {
|
||||
adapter.scenes.current.mockReturnValue(null);
|
||||
const mockTarget = { value: '1500' };
|
||||
await panel._onDelayChanged(mockTarget);
|
||||
expect(scenePresetManager.configureAutoApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('configures auto-apply with new delay', async () => {
|
||||
const mockTarget = { value: '1500' };
|
||||
await panel._onDelayChanged(mockTarget);
|
||||
|
||||
expect(scenePresetManager.configureAutoApply).toHaveBeenCalledWith(
|
||||
{ id: 'scene1', name: 'Test Scene' },
|
||||
expect.objectContaining({ preDelay: 1500 })
|
||||
);
|
||||
});
|
||||
|
||||
it('updates displayed value', async () => {
|
||||
const mockTarget = { value: '2000' };
|
||||
await panel._onDelayChanged(mockTarget);
|
||||
|
||||
const valueDisplay = panel._element.querySelector('.directors-board__preset-panel-delay-value');
|
||||
expect(valueDisplay.textContent).toBe('2000ms');
|
||||
});
|
||||
|
||||
it('handles invalid numeric value', async () => {
|
||||
const mockTarget = { value: 'invalid' };
|
||||
await panel._onDelayChanged(mockTarget);
|
||||
|
||||
expect(scenePresetManager.configureAutoApply).toHaveBeenCalledWith(
|
||||
{ id: 'scene1', name: 'Test Scene' },
|
||||
expect.objectContaining({ preDelay: 0 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardown()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('removes event listeners', () => {
|
||||
const removeSpy = vi.spyOn(panel, '_removeEventListeners');
|
||||
panel.teardown();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the panel', () => {
|
||||
const closeSpy = vi.spyOn(panel, 'close');
|
||||
panel.teardown();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes element from parent when parentNode exists', () => {
|
||||
// Create a proper mock element with parentNode
|
||||
const mockParent = { removeChild: vi.fn() };
|
||||
const mockElement = document.createElement('div');
|
||||
// In jsdom, parentNode is read-only, so we need to mock the entire scenario differently
|
||||
// Instead, test that teardown calls the right methods without throwing
|
||||
panel._element = mockElement;
|
||||
panel._isOpen = true;
|
||||
|
||||
// Mock parentNode getter
|
||||
Object.defineProperty(mockElement, 'parentNode', {
|
||||
value: mockParent,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
panel.teardown();
|
||||
expect(mockParent.removeChild).toHaveBeenCalledWith(mockElement);
|
||||
});
|
||||
|
||||
it('resets state', () => {
|
||||
panel._element = document.createElement('div');
|
||||
panel._isOpen = true;
|
||||
panel._currentScene = { id: 'scene1' };
|
||||
|
||||
panel.teardown();
|
||||
|
||||
expect(panel._element).toBeNull();
|
||||
expect(panel._isOpen).toBe(false);
|
||||
expect(panel._currentScene).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_escapeHtml()', () => {
|
||||
beforeEach(() => {
|
||||
panel.init();
|
||||
});
|
||||
|
||||
it('returns empty string for null input', () => {
|
||||
expect(panel._escapeHtml(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(panel._escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for non-string input', () => {
|
||||
expect(panel._escapeHtml(123)).toBe('');
|
||||
});
|
||||
|
||||
it('escapes ampersand', () => {
|
||||
expect(panel._escapeHtml('a & b')).toBe('a & b');
|
||||
});
|
||||
|
||||
it('escapes less than', () => {
|
||||
expect(panel._escapeHtml('a < b')).toBe('a < b');
|
||||
});
|
||||
|
||||
it('escapes greater than', () => {
|
||||
expect(panel._escapeHtml('a > b')).toBe('a > b');
|
||||
});
|
||||
|
||||
it('escapes double quotes', () => {
|
||||
expect(panel._escapeHtml('say "hello"')).toBe('say "hello"');
|
||||
});
|
||||
|
||||
it('escapes single quotes', () => {
|
||||
expect(panel._escapeHtml("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
it('escapes multiple special characters', () => {
|
||||
const result = panel._escapeHtml('<script>alert("xss")</script>');
|
||||
expect(result).not.toContain('<');
|
||||
expect(result).not.toContain('>');
|
||||
expect(result).not.toContain('"');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user