Files
scrying-pool/tests/unit/core/VisibilityManager.test.js
2026-05-23 18:23:48 +02:00

249 lines
8.9 KiB
JavaScript

// @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();
});
});
});