/** * 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'); }); }); });