Story 3.2 done
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user