// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { VisibilityManager } from '../../../src/core/VisibilityManager.js'; import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; import { StateStore } from '../../../src/core/StateStore.js'; /** @returns {StateStore} */ function makeStateStore() { const settingsMock = { get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }), set: vi.fn().mockResolvedValue(undefined), register: vi.fn(), }; return new StateStore(settingsMock); } describe('VisibilityManager', () => { let adapter; let stateStore; let manager; let hooksStub; beforeEach(() => { hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() }; vi.stubGlobal('Hooks', hooksStub); adapter = createFoundryAdapterMock({ hooks: hooksStub }); stateStore = makeStateStore(); manager = new VisibilityManager(stateStore, adapter); }); afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); // ── AC-1 (construction side-effect free) ───────────────────────────────── describe('constructor (side-effect free)', () => { it('does NOT register Hooks.on listener in constructor', () => { expect(hooksStub.on).not.toHaveBeenCalled(); }); }); // ── init() ──────────────────────────────────────────────────────────────── describe('init()', () => { it('registers Hooks.on for scrying-pool:stateChanged', () => { manager.init(); expect(hooksStub.on).toHaveBeenCalledWith( 'scrying-pool:stateChanged', expect.any(Function) ); }); }); // ── AC-6: _onStateChanged — track-disable strategy ──────────────────────── describe('_onStateChanged() track-disable strategy (AC-6)', () => { let webrtcMock; beforeEach(() => { webrtcMock = { disableTrack: vi.fn(), enableTrack: vi.fn() }; const trackDisableAdapter = createFoundryAdapterMock({ webrtc: webrtcMock, settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, trackDisableAdapter); manager.init(); }); it('calls disableTrack(userId) when state is hidden', () => { const handler = hooksStub.on.mock.calls[0][1]; handler({ userId: 'user-1', state: 'hidden' }); expect(webrtcMock.disableTrack).toHaveBeenCalledWith('user-1'); expect(webrtcMock.enableTrack).not.toHaveBeenCalled(); }); it('calls enableTrack(userId) when state is active', () => { const handler = hooksStub.on.mock.calls[0][1]; handler({ userId: 'user-1', state: 'active' }); expect(webrtcMock.enableTrack).toHaveBeenCalledWith('user-1'); expect(webrtcMock.disableTrack).not.toHaveBeenCalled(); }); }); // ── AC-7: _onStateChanged — css-fallback / unsupported ──────────────────── describe('_onStateChanged() css-fallback strategy (AC-7)', () => { it('performs no webrtc call and throws no error when mode is css-fallback', () => { const cssFallbackAdapter = createFoundryAdapterMock({ settings: { get: (key) => (key === 'webrtcMode' ? 'css-fallback' : null) }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, cssFallbackAdapter); manager.init(); const handler = hooksStub.on.mock.calls[0][1]; expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); }); it('performs no webrtc call and throws no error when mode is unsupported', () => { const unsupportedAdapter = createFoundryAdapterMock({ settings: { get: (key) => (key === 'webrtcMode' ? 'unsupported' : null) }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, unsupportedAdapter); manager.init(); const handler = hooksStub.on.mock.calls[0][1]; expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); }); }); // ── AC-10: null webrtc guard ────────────────────────────────────────────── describe('_onStateChanged() null webrtc guard (AC-10)', () => { it('does not throw when adapter.webrtc is null in track-disable mode', () => { const nullWebrtcAdapter = createFoundryAdapterMock({ webrtc: null, settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, nullWebrtcAdapter); manager.init(); const handler = hooksStub.on.mock.calls[0][1]; expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); }); it('does not throw when adapter.webrtc is null with state active', () => { const nullWebrtcAdapter = createFoundryAdapterMock({ webrtc: null, settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, nullWebrtcAdapter); manager.init(); const handler = hooksStub.on.mock.calls[0][1]; expect(() => handler({ userId: 'user-1', state: 'active' })).not.toThrow(); }); }); // ── AC-9: onRevert() ───────────────────────────────────────────────────── describe('onRevert() (AC-9)', () => { /** @type {import('../../../src/contracts/pending-op.js').PendingOp} */ const pendingOp = { opId: 'op-1', userId: 'user-1', targetState: 'hidden', previousState: 'active', issuedAt: 1000000, timeoutId: null, }; it('calls stateStore.setVisibility with previousState to revert', () => { const setSpy = vi.spyOn(stateStore, 'setVisibility'); manager.onRevert(pendingOp); expect(setSpy).toHaveBeenCalledWith('user-1', 'active'); }); it('calls adapter.notifications.warn with a [ScryingPool]-prefixed message', () => { const warnMock = vi.fn(); const warnAdapter = createFoundryAdapterMock({ notifications: { warn: warnMock, info: () => {}, error: () => {} }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, warnAdapter); manager.onRevert(pendingOp); expect(warnMock).toHaveBeenCalledOnce(); expect(warnMock.mock.calls[0][0]).toMatch(/^\[ScryingPool\]/); }); it('includes userId in the warning message', () => { const warnMock = vi.fn(); const warnAdapter = createFoundryAdapterMock({ notifications: { warn: warnMock, info: () => {}, error: () => {} }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, warnAdapter); manager.onRevert(pendingOp); expect(warnMock.mock.calls[0][0]).toContain('user-1'); }); it('does NOT call notifications.info (no success notification on revert)', () => { const infoMock = vi.fn(); const noInfoAdapter = createFoundryAdapterMock({ notifications: { warn: () => {}, info: infoMock, error: () => {} }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, noInfoAdapter); manager.onRevert(pendingOp); expect(infoMock).not.toHaveBeenCalled(); }); it('does NOT call notifications.error', () => { const errorMock = vi.fn(); const noErrorAdapter = createFoundryAdapterMock({ notifications: { warn: () => {}, info: () => {}, error: errorMock }, hooks: hooksStub, }); manager = new VisibilityManager(stateStore, noErrorAdapter); manager.onRevert(pendingOp); expect(errorMock).not.toHaveBeenCalled(); }); }); // ── teardown() — listener cleanup (T-debt deferred from 1.4) ────────────── describe('teardown()', () => { it('unregisters the stateChanged hook listener', () => { const hookId = 77; adapter.hooks.on = vi.fn().mockReturnValue(hookId); manager.init(); manager.teardown(); expect(adapter.hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', hookId); }); it('nulls _stateChangedHookId after teardown', () => { adapter.hooks.on = vi.fn().mockReturnValue(99); manager.init(); manager.teardown(); expect(manager._stateChangedHookId).toBeNull(); }); it('is safe to call before init()', () => { expect(() => manager.teardown()).not.toThrow(); }); it('does not call hooks.off when init was never called', () => { manager.teardown(); expect(adapter.hooks.off).not.toHaveBeenCalled(); }); }); });