// @ts-nocheck import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Stub foundry global for conditional base class — must NOT be present at module load time // so the fallback class is used. The module is imported after this comment block. // We only stub `foundry` in specific tests that need runtime foundry calls (none here). 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 { DirectorsBoard } from '../../../../src/ui/gm/DirectorsBoard.js'; describe('DirectorsBoard', () => { let stateStore; let controller; let adapter; let scenePresetManager; let board; beforeEach(() => { stateStore = { getState: vi.fn(() => 'active') }; controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false), getRevision: vi.fn(() => 0) }; adapter = { users: { get: vi.fn(() => ({ name: 'Alice', avatar: null })), all: vi.fn(() => [{ id: 'u1' }]), }, scenes: { current: vi.fn(() => null), }, }; 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', () => { it('is side-effect-free: does not call Hooks.on', () => { expect(Hooks.on).not.toHaveBeenCalled(); }); it('sets _hookId to null initially', () => { expect(board._hookId).toBeNull(); }); 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(); }); it('has DEFAULT_OPTIONS with position', () => { expect(DirectorsBoard.DEFAULT_OPTIONS.position).toEqual({ width: 400, height: 300, }); }); }); describe('_loadPosition()', () => { // Helper to create a board with options for testing _loadPosition function createBoardWithOptions(options = {}) { return new DirectorsBoard( stateStore, controller, adapter, scenePresetManager, undefined, options ); } it('loads position from user flag when saved state has open=true', () => { const savedState = { open: true, left: 100, top: 200, width: 500, height: 600, }; game.user.getFlag.mockReturnValue(savedState); const boardWithOptions = createBoardWithOptions({ position: {} }); boardWithOptions._loadPosition(); expect(game.user.getFlag).toHaveBeenCalledWith( 'video-view-manager', 'directorsBoardState' ); // Position should be merged into options.position (not replaced) expect(boardWithOptions.options.position).toEqual({ left: 100, top: 200, width: 500, height: 600, }); }); it('uses defaults when saved state has no width/height', () => { const savedState = { open: true, left: 100, top: 200, }; game.user.getFlag.mockReturnValue(savedState); const boardWithOptions = createBoardWithOptions({ position: {} }); boardWithOptions._loadPosition(); expect(boardWithOptions.options.position).toEqual({ left: 100, top: 200, width: 400, height: 300, }); }); it('does not modify position when saved state is null', () => { game.user.getFlag.mockReturnValue(null); const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } }); const originalPosition = boardWithOptions.options.position; boardWithOptions._loadPosition(); expect(boardWithOptions.options.position).toBe(originalPosition); }); it('does not modify position when open is false', () => { game.user.getFlag.mockReturnValue({ open: false, left: 100, top: 200 }); const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } }); const originalPosition = boardWithOptions.options.position; boardWithOptions._loadPosition(); expect(boardWithOptions.options.position).toBe(originalPosition); }); it('does not modify position when left is null', () => { game.user.getFlag.mockReturnValue({ open: true, left: null, top: 200 }); const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } }); const originalPosition = boardWithOptions.options.position; boardWithOptions._loadPosition(); expect(boardWithOptions.options.position).toBe(originalPosition); }); it('does not throw when options is undefined', () => { game.user.getFlag.mockReturnValue({ open: true, left: 100, top: 200 }); // Board created without options (fallback class doesn't set options) expect(() => board._loadPosition()).not.toThrow(); }); it('handles errors gracefully and logs to console', () => { game.user.getFlag.mockImplementation(() => { throw new Error('Flag read error'); }); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } }); const originalPosition = boardWithOptions.options.position; boardWithOptions._loadPosition(); expect(consoleSpy).toHaveBeenCalledWith( '[ScryingPool] Failed to load directors board position:', expect.any(Error) ); expect(boardWithOptions.options.position).toBe(originalPosition); }); }); describe('init()', () => { it('registers scrying-pool:stateChanged hook', () => { board.init(); expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function)); }); it('stores the returned hook id in _hookId', () => { board.init(); expect(board._hookId).toBe(99); }); }); describe('teardown()', () => { it('calls Hooks.off with the stored hook id', () => { board.init(); board.teardown(); expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', 99); }); it('sets _hookId to null after teardown', () => { board.init(); board.teardown(); expect(board._hookId).toBeNull(); }); it('is a no-op when init was not called', () => { expect(() => board.teardown()).not.toThrow(); expect(Hooks.off).not.toHaveBeenCalled(); }); }); describe('_dispatchToggle()', () => { it('calls controller.action with positional args (active→hidden)', () => { stateStore.getState.mockReturnValue('active'); board._dispatchToggle('u1'); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); }); it('calls controller.action with targetState active (hidden→active)', () => { stateStore.getState.mockReturnValue('hidden'); board._dispatchToggle('u2'); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number)); }); it('does not dispatch if userId is falsy', () => { board._dispatchToggle(null); board._dispatchToggle(undefined); board._dispatchToggle(''); expect(controller.action).not.toHaveBeenCalled(); }); it('does not dispatch if controller reports pending op', () => { controller.hasPendingOp.mockReturnValue(true); board._dispatchToggle('u1'); expect(controller.action).not.toHaveBeenCalled(); }); it('defaults to active state when stateStore returns null', () => { stateStore.getState.mockReturnValue(null); board._dispatchToggle('u1'); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); }); }); describe('toggle()', () => { it('calls render({ force: true }) when not rendered', async () => { board._rendered = false; const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined); await board.toggle(); expect(renderSpy).toHaveBeenCalledWith({ force: true }); }); it('calls close() when rendered', async () => { board._rendered = true; const closeSpy = vi.spyOn(board, 'close').mockResolvedValue(undefined); await board.toggle(); expect(closeSpy).toHaveBeenCalled(); }); }); describe('_onStateChanged()', () => { it('calls render({ force: true }) when board is rendered', () => { board._rendered = true; const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined); board._onStateChanged({ userId: 'u1', newState: 'hidden' }); expect(renderSpy).toHaveBeenCalledWith({ force: true }); }); it('does not call render when board is not rendered', () => { board._rendered = false; const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined); board._onStateChanged({ userId: 'u1', newState: 'hidden' }); expect(renderSpy).not.toHaveBeenCalled(); }); }); describe('_prepareContext()', () => { it('returns board context with participants from adapter', async () => { const ctx = await board._prepareContext({}); expect(ctx.participants).toHaveLength(1); expect(ctx.participants[0].userId).toBe('u1'); expect(ctx.isEmpty).toBe(false); }); it('returns isEmpty=true when adapter has no users', async () => { adapter.users.all.mockReturnValue([]); const ctx = await board._prepareContext({}); expect(ctx.isEmpty).toBe(true); }); it('returns hasUndo=false when _undoSnapshot is null', async () => { board._undoSnapshot = null; const ctx = await board._prepareContext({}); expect(ctx.hasUndo).toBe(false); }); it('returns hasUndo=true when _undoSnapshot is set', async () => { board._undoSnapshot = new Map([['u1', 'hidden']]); const ctx = await board._prepareContext({}); expect(ctx.hasUndo).toBe(true); }); it('returns hasRestore=false when _spotlightSnapshot is null', async () => { board._spotlightSnapshot = null; const ctx = await board._prepareContext({}); expect(ctx.hasRestore).toBe(false); }); it('returns hasRestore=true when _spotlightSnapshot is set', async () => { board._spotlightSnapshot = new Map([['u1', 'active']]); const ctx = await board._prepareContext({}); expect(ctx.hasRestore).toBe(true); }); }); describe('DEFAULT_OPTIONS', () => { it('has correct id', () => { expect(DirectorsBoard.DEFAULT_OPTIONS.id).toBe('scrying-pool-directors-board'); }); it('has classes including scrying-pool and directors-board', () => { expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('scrying-pool'); expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('directors-board'); }); }); describe('PARTS', () => { it('has a board part with the correct template path', () => { expect(DirectorsBoard.PARTS.board.template).toContain('directors-board.hbs'); }); }); describe('showAll()', () => { it('calls controller.action with active for each non-ghost user', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]); stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'hidden'); board.showAll(); expect(controller.action).toHaveBeenCalledTimes(2); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number)); expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything()); }); it('stores pre-action snapshot in _undoSnapshot (non-ghost users only)', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]); stateStore.getState.mockImplementation(id => { if (id === 'u1') return 'hidden'; if (id === 'u2') return 'active'; return 'ghost'; }); board.showAll(); expect(board._undoSnapshot).toBeInstanceOf(Map); expect(board._undoSnapshot.get('u1')).toBe('hidden'); expect(board._undoSnapshot.get('u2')).toBe('active'); expect(board._undoSnapshot.has('u3')).toBe(false); }); it('clears _spotlightSnapshot when called', () => { board._spotlightSnapshot = new Map([['u1', 'active']]); adapter.users.all.mockReturnValue([{ id: 'u1' }]); stateStore.getState.mockReturnValue('hidden'); board.showAll(); expect(board._spotlightSnapshot).toBeNull(); }); it('skips participants with pending ops', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); stateStore.getState.mockReturnValue('hidden'); controller.hasPendingOp.mockImplementation(id => id === 'u1'); board.showAll(); expect(controller.action).toHaveBeenCalledTimes(1); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number)); }); it('is a no-op when all users are ghost', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }]); stateStore.getState.mockReturnValue('ghost'); board.showAll(); expect(controller.action).not.toHaveBeenCalled(); }); }); describe('hideAll()', () => { it('calls controller.action with hidden for each non-ghost user', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); stateStore.getState.mockReturnValue('active'); board.hideAll(); expect(controller.action).toHaveBeenCalledTimes(2); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number)); }); it('stores pre-action snapshot in _undoSnapshot', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }]); stateStore.getState.mockReturnValue('active'); board.hideAll(); expect(board._undoSnapshot).toBeInstanceOf(Map); expect(board._undoSnapshot.get('u1')).toBe('active'); }); it('clears _spotlightSnapshot when called', () => { board._spotlightSnapshot = new Map([['u1', 'active']]); adapter.users.all.mockReturnValue([{ id: 'u1' }]); stateStore.getState.mockReturnValue('active'); board.hideAll(); expect(board._spotlightSnapshot).toBeNull(); }); it('excludes ghost-state participants', () => { adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active'); board.hideAll(); expect(controller.action).toHaveBeenCalledTimes(1); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); }); }); describe('undo()', () => { it('restores participants to snapshot states', () => { board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'active']]); stateStore.getState.mockReturnValue('active'); board.undo(); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number)); }); it('clears _undoSnapshot after use (single-step only)', async () => { board._undoSnapshot = new Map([['u1', 'hidden']]); stateStore.getState.mockReturnValue('active'); await board.undo(); expect(board._undoSnapshot).toBeNull(); }); it('is a no-op when _undoSnapshot is null', () => { board._undoSnapshot = null; board.undo(); expect(controller.action).not.toHaveBeenCalled(); }); it('second undo is unavailable after first (no-op)', async () => { board._undoSnapshot = new Map([['u1', 'hidden']]); stateStore.getState.mockReturnValue('active'); await board.undo(); board.undo(); expect(controller.action).toHaveBeenCalledTimes(1); }); it('skips ghost-state participants during undo', () => { board._undoSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]); stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active'); board.undo(); expect(controller.action).toHaveBeenCalledTimes(1); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number)); }); it('skips participants with pending ops during undo', () => { board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'hidden']]); stateStore.getState.mockReturnValue('active'); controller.hasPendingOp.mockImplementation(id => id === 'u1'); board.undo(); expect(controller.action).toHaveBeenCalledTimes(1); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number)); }); }); }); describe('DirectorsBoard spotlight', () => { let stateStore, controller, adapter, board; beforeEach(() => { vi.stubGlobal('Hooks', { on: vi.fn(() => 1), off: vi.fn(), once: vi.fn() }); vi.stubGlobal('game', { keybindings: { register: vi.fn() } }); stateStore = { getState: vi.fn(), getAll: vi.fn(() => new Map()), }; controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false), getRevision: vi.fn(() => 0), }; 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, null); board.rendered = false; board.render = vi.fn(); }); afterEach(() => vi.unstubAllGlobals()); describe('spotlight(userId)', () => { it('sets focusedId active, all others hidden, captures snapshot, clears undo', () => { stateStore.getState.mockImplementation(id => ({ u1: 'hidden', u2: 'active', u3: 'active' }[id])); board._undoSnapshot = new Map([['u1', 'hidden']]); board.spotlight('u1'); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u3', 'hidden', expect.any(String), expect.any(Number)); }); it('stores pre-spotlight snapshot in _spotlightSnapshot', () => { stateStore.getState.mockImplementation(id => id === 'u1' ? 'active' : 'hidden'); board.spotlight('u1'); expect(board._spotlightSnapshot).toBeInstanceOf(Map); expect(board._spotlightSnapshot.size).toBe(3); }); it('clears _undoSnapshot when spotlight is called', () => { stateStore.getState.mockReturnValue('active'); board._undoSnapshot = new Map([['u1', 'hidden']]); board.spotlight('u2'); expect(board._undoSnapshot).toBeNull(); }); it('excludes ghost participants from spotlight', () => { stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'active'); board.spotlight('u1'); const calls = controller.action.mock.calls.map(c => c[1]); expect(calls).not.toContain('u3'); }); }); describe('restoreSpotlight()', () => { it('restores participants to pre-spotlight snapshot states', () => { board._spotlightSnapshot = new Map([['u1', 'hidden'], ['u2', 'active'], ['u3', 'hidden']]); stateStore.getState.mockReturnValue('active'); board.restoreSpotlight(); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number)); expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number)); }); it('clears _spotlightSnapshot after restore', async () => { board._spotlightSnapshot = new Map([['u1', 'active']]); stateStore.getState.mockReturnValue('active'); await board.restoreSpotlight(); expect(board._spotlightSnapshot).toBeNull(); }); it('is a no-op when _spotlightSnapshot is null', () => { board._spotlightSnapshot = null; board.restoreSpotlight(); expect(controller.action).not.toHaveBeenCalled(); }); it('skips ghost participants during restore', () => { board._spotlightSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]); stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active'); board.restoreSpotlight(); expect(controller.action).toHaveBeenCalledTimes(1); expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number)); }); }); describe('spotlightFocused()', () => { it('is a no-op if no participant is focused', () => { board._focusedUserId = null; board.spotlightFocused(); expect(controller.action).not.toHaveBeenCalled(); }); it('calls spotlight() with the currently focused userId', () => { stateStore.getState.mockReturnValue('active'); board._focusedUserId = 'u2'; const spy = vi.spyOn(board, 'spotlight'); board.spotlightFocused(); 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(); }); }); });