625 lines
20 KiB
JavaScript
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 = {
|
|
'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');
|
|
});
|
|
});
|
|
});
|