// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { VisibilityBadge } from '../../../../src/ui/player/VisibilityBadge.js'; import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js'; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function makeAdapter({ userId = 'user-player', isGM = false, firstBadgeEncountered = false } = {}) { const mockUser = { id: userId, getFlag: vi.fn().mockReturnValue(firstBadgeEncountered), setFlag: vi.fn().mockResolvedValue(undefined), }; return createFoundryAdapterMock({ users: { current: () => mockUser, isGM: () => isGM, get: () => mockUser, all: () => [mockUser], }, }); } function makeAVTileAdapter() { return { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), onTileRerender: vi.fn(), disconnect: vi.fn(), }; } function makeStateStore(initialState = 'active') { return { getState: vi.fn().mockReturnValue(initialState), }; } function makeController() { return { hasPendingOp: vi.fn().mockReturnValue(false), }; } // --------------------------------------------------------------------------- // VisibilityBadge // --------------------------------------------------------------------------- describe('VisibilityBadge', () => { let adapter; let avTileAdapter; let stateStore; let controller; let hookHandlers; beforeEach(() => { document.body.innerHTML = `
`; hookHandlers = {}; vi.stubGlobal('Hooks', { on: vi.fn((event, handler) => { hookHandlers[event] = handler; }), once: vi.fn(), off: vi.fn(), callAll: vi.fn(), }); adapter = makeAdapter(); avTileAdapter = makeAVTileAdapter(); stateStore = makeStateStore(); controller = makeController(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('constructor', () => { it('stores deps without side effects', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); expect(badge._stateStore).toBe(stateStore); expect(badge._controller).toBe(controller); expect(badge._avTileAdapter).toBe(avTileAdapter); expect(badge._adapter).toBe(adapter); expect(Hooks.on).not.toHaveBeenCalled(); }); }); describe('init()', () => { it('resolves currentUserId from adapter.users.current()', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); expect(badge._currentUserId).toBe('user-player'); }); it('subscribes to scrying-pool:stateChanged hook', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function)); }); it('mounts initial badge via avTileAdapter', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); expect(avTileAdapter.mount).toHaveBeenCalledWith('user-player', expect.any(HTMLElement)); }); it('registers onTileRerender callback', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); expect(avTileAdapter.onTileRerender).toHaveBeenCalledWith('user-player', expect.any(Function)); }); it('no-ops when no currentUserId (null user)', () => { const noUserAdapter = createFoundryAdapterMock({ users: { current: () => null, isGM: () => false, get: () => null, all: () => [] }, }); const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, noUserAdapter); badge.init(); expect(badge._currentUserId).toBeNull(); expect(avTileAdapter.mount).not.toHaveBeenCalled(); }); }); describe('_createBadgeElement()', () => { it('creates element with correct class and data-sp-role', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const el = badge._createBadgeElement('hidden'); expect(el.className).toBe('sp-visibility-badge'); expect(el.dataset.spRole).toBe('visibility-badge'); }); it('sets role="status" and aria-live="polite"', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const el = badge._createBadgeElement('hidden'); expect(el.getAttribute('role')).toBe('status'); expect(el.getAttribute('aria-live')).toBe('polite'); }); it('sets aria-label with state label', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const el = badge._createBadgeElement('hidden'); expect(el.getAttribute('aria-label')).toBe('Camera visibility: Hidden from table'); }); it('sets aria-label to "Active" for active state', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const el = badge._createBadgeElement('active'); expect(el.getAttribute('aria-label')).toBe('Camera visibility: Active'); }); it('renders all state labels correctly', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const cases = [ ['hidden', 'Hidden from table'], ['self-muted', 'Camera paused'], ['offline', 'Not connected'], ['cam-lost', 'Camera unavailable'], ['reconnecting', 'Rejoining view'], ['never-connected', 'Not yet connected'], ['ghost', 'Leaving'], ]; for (const [state, expectedLabel] of cases) { const el = badge._createBadgeElement(state); const span = el.querySelector('.sp-visibility-badge__label'); expect(span?.textContent).toBe(expectedLabel); } }); it('renders no label text for active state', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); const el = badge._createBadgeElement('active'); const span = el.querySelector('.sp-visibility-badge__label'); expect(span?.textContent ?? '').toBe(''); }); }); describe('_onStateChanged()', () => { it('ignores events for other users', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); avTileAdapter.mount.mockClear(); badge._onStateChanged({ userId: 'other-user', state: 'hidden' }); expect(avTileAdapter.mount).not.toHaveBeenCalled(); }); it('updates badge element and re-mounts for own user', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); avTileAdapter.mount.mockClear(); badge._onStateChanged({ userId: 'user-player', state: 'hidden' }); expect(avTileAdapter.mount).toHaveBeenCalledWith('user-player', expect.any(HTMLElement)); }); it('updates badge aria-label on state change', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); badge._onStateChanged({ userId: 'user-player', state: 'offline' }); expect(badge._badgeEl.getAttribute('aria-label')).toBe('Camera visibility: Not connected'); }); it('triggers FirstEncounterPanel when firstBadgeEncountered is false', () => { // firstBadgeEncountered = false (default makeAdapter) const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); badge._onStateChanged({ userId: 'user-player', state: 'hidden' }); // Panel should be in the DOM expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull(); }); it('does NOT trigger FirstEncounterPanel when already encountered', () => { const encounteredAdapter = makeAdapter({ firstBadgeEncountered: true }); const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, encounteredAdapter); badge.init(); badge._onStateChanged({ userId: 'user-player', state: 'hidden' }); expect(document.querySelector('.sp-first-encounter-panel')).toBeNull(); }); }); describe('_getFirstBadgeEncountered()', () => { it('returns flag value from adapter user', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); expect(badge._getFirstBadgeEncountered()).toBe(false); }); it('returns true when flag is set', () => { const encAdapter = makeAdapter({ firstBadgeEncountered: true }); const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, encAdapter); badge.init(); expect(badge._getFirstBadgeEncountered()).toBe(true); }); }); describe('_setFirstBadgeEncountered()', () => { it('calls setFlag on current user', async () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); await badge._setFirstBadgeEncountered(); const mockUser = adapter.users.current(); expect(mockUser.setFlag).toHaveBeenCalledWith('scrying-pool', 'firstBadgeEncounter', true); }); }); describe('badge click handler', () => { it('opens VisibilityDetailsPanel on badge click', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); badge._badgeEl.click(); const dialog = document.querySelector('dialog.sp-visibility-details-panel'); expect(dialog).not.toBeNull(); }); }); describe('teardown()', () => { it('calls avTileAdapter.disconnect()', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); badge.teardown(); expect(avTileAdapter.disconnect).toHaveBeenCalled(); }); it('unsubscribes from Hooks', () => { const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); badge.teardown(); expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function)); }); }); }); // --------------------------------------------------------------------------- // FirstEncounterPanel // --------------------------------------------------------------------------- describe('FirstEncounterPanel (via VisibilityBadge)', () => { let adapter; let avTileAdapter; let stateStore; let controller; let badge; beforeEach(() => { document.body.innerHTML = ``; vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn(), off: vi.fn(), callAll: vi.fn(), }); adapter = makeAdapter({ firstBadgeEncountered: false }); avTileAdapter = makeAVTileAdapter(); stateStore = makeStateStore(); controller = makeController(); badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); // Trigger a state change to show the panel badge._onStateChanged({ userId: 'user-player', state: 'hidden' }); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); describe('show()', () => { it('appends panel to DOM with role="dialog"', () => { const panel = document.querySelector('.sp-first-encounter-panel'); expect(panel).not.toBeNull(); expect(panel.getAttribute('role')).toBe('dialog'); }); it('sets aria-modal="false"', () => { const panel = document.querySelector('.sp-first-encounter-panel'); expect(panel.getAttribute('aria-modal')).toBe('false'); }); it('contains a "Got it" button', () => { const btn = document.querySelector('.sp-first-encounter-panel [data-action="got-it"]'); expect(btn).not.toBeNull(); }); }); describe('10s auto-collapse timer', () => { it('collapses panel after 10s and shows chip', () => { vi.useFakeTimers(); // Re-trigger with fake timers document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull(); vi.advanceTimersByTime(10_001); // fires collapse timer vi.advanceTimersByTime(301); // fires 300ms transition timer expect(document.querySelector('.sp-first-encounter-panel')).toBeNull(); expect(document.querySelector('.sp-visibility-chip')).not.toBeNull(); }); it('pauses timer on mouseenter and resumes on mouseleave', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); const panel = document.querySelector('.sp-first-encounter-panel'); panel.dispatchEvent(new Event('mouseenter')); vi.advanceTimersByTime(10_001); // Should NOT have collapsed because timer was paused expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull(); panel.dispatchEvent(new Event('mouseleave')); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); expect(document.querySelector('.sp-first-encounter-panel')).toBeNull(); }); it('pauses timer on focusin and resumes on focusout', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); const panel = document.querySelector('.sp-first-encounter-panel'); panel.dispatchEvent(new Event('focusin')); vi.advanceTimersByTime(10_001); expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull(); panel.dispatchEvent(new Event('focusout')); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); expect(document.querySelector('.sp-first-encounter-panel')).toBeNull(); }); }); describe('"Got it" button', () => { it('dismisses panel from DOM', async () => { document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); const panel = b._firstEncounterPanel; // Directly call _onGotIt to avoid async click handler timing issues await panel._onGotIt(); expect(document.querySelector('.sp-first-encounter-panel')).toBeNull(); }); it('calls setFirstBadgeEncountered', async () => { const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); const panel = b._firstEncounterPanel; // Directly call _onGotIt to avoid async click handler timing issues await panel._onGotIt(); const mockUser = adapter.users.current(); expect(mockUser.setFlag).toHaveBeenCalledWith('scrying-pool', 'firstBadgeEncounter', true); }); it('clears timer (no ghost timer after dismissal)', async () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); const panel = b._firstEncounterPanel; // Directly call _onGotIt to avoid async click handler timing issues await panel._onGotIt(); // After dismissal, advancing time should NOT cause errors or chip to appear expect(() => vi.advanceTimersByTime(15_000)).not.toThrow(); expect(document.querySelector('.sp-visibility-chip')).toBeNull(); }); }); describe('chip (collapsed state)', () => { it('chip is focusable with role="button"', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); const chip = document.querySelector('.sp-visibility-chip'); expect(chip.getAttribute('role')).toBe('button'); expect(chip.getAttribute('tabindex')).toBe('0'); }); it('chip click opens VisibilityDetailsPanel', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); const chip = document.querySelector('.sp-visibility-chip'); chip.click(); expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull(); }); it('chip Enter keydown opens VisibilityDetailsPanel', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); const chip = document.querySelector('.sp-visibility-chip'); chip.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull(); }); it('chip Space keydown opens VisibilityDetailsPanel', () => { vi.useFakeTimers(); document.body.innerHTML = ``; const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); b.init(); b._onStateChanged({ userId: 'user-player', state: 'hidden' }); vi.advanceTimersByTime(10_001); vi.advanceTimersByTime(301); const chip = document.querySelector('.sp-visibility-chip'); chip.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull(); }); }); }); // --------------------------------------------------------------------------- // VisibilityDetailsPanel // --------------------------------------------------------------------------- describe('VisibilityDetailsPanel (via VisibilityBadge)', () => { let adapter; let avTileAdapter; let stateStore; let controller; let badge; beforeEach(() => { document.body.innerHTML = ``; vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn(), off: vi.fn(), callAll: vi.fn(), }); adapter = makeAdapter({ firstBadgeEncountered: true }); avTileAdapter = makeAVTileAdapter(); stateStore = makeStateStore('hidden'); controller = makeController(); badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter); badge.init(); }); afterEach(() => { vi.unstubAllGlobals(); // Clean up any open dialogs document.querySelectorAll('dialog').forEach(d => d.remove()); }); describe('show()', () => { it('creates a