Files
scrying-pool/tests/unit/ui/gm/ConfirmationBar.test.js
T
uberwald 5dc9b3b8d4
CI / ci (push) Failing after 7s
Module cleanup and tests
2026-05-24 23:13:45 +02:00

625 lines
20 KiB
JavaScript

/**
* 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 = {
'scrying-pool.presets.confirmation.applied': 'Preset applied — {name}',
'scrying-pool.presets.confirmation.counts': '{hidden} hidden, {visible} visible',
'scrying-pool.presets.confirmation.partial-fail': '(some updates pending)',
'scrying-pool.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');
});
});
});