249 lines
8.9 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|