Story 3.2 done

This commit is contained in:
2026-05-23 18:23:48 +02:00
parent d175f92806
commit a1e8886fce
66 changed files with 18258 additions and 1650 deletions
+624
View File
@@ -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');
});
});
});
+160 -3
View File
@@ -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();
});
});
});
+425
View File
@@ -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([]);
});
});
});
+474
View File
@@ -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();
});
});
});
+666
View File
@@ -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('&lt;script&gt;');
});
});
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('&lt;script&gt;');
});
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 &amp; b');
});
it('escapes less than', () => {
expect(panel._escapeHtml('a < b')).toBe('a &lt; b');
});
it('escapes greater than', () => {
expect(panel._escapeHtml('a > b')).toBe('a &gt; b');
});
it('escapes double quotes', () => {
expect(panel._escapeHtml('say "hello"')).toBe('say &quot;hello&quot;');
});
it('escapes single quotes', () => {
expect(panel._escapeHtml("it's")).toBe("it&#x27;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('"');
});
});
});