Fix Story 2.3 code review findings: remove duplicate ParticipantCard.js, fix lint in ScryingPoolStrip.js
- Delete src/ui/shared/ParticipantCard.js (duplicate of boardUtils.js with conflicting implementations) - Delete tests/unit/ui/shared/ParticipantCard.test.js (tests for deleted file) - Add directorsBoard to global declarations in ScryingPoolStrip.js to fix lint errors Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||
|
||||
// Mock ScryingPoolStrip before it's imported so Application global isn't needed
|
||||
vi.mock('../../../src/ui/gm/ScryingPoolStrip.js', () => ({
|
||||
ScryingPoolStrip: vi.fn().mockImplementation(() => ({
|
||||
render: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn(),
|
||||
rendered: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { RoleRenderer } from '../../../src/ui/RoleRenderer.js';
|
||||
|
||||
function makeAVTileAdapter() {
|
||||
return {
|
||||
mount: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
setStateClass: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
onTileRerender: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStateStore() {
|
||||
const states = new Map();
|
||||
return {
|
||||
getState: vi.fn(userId => states.get(userId) ?? 'active'),
|
||||
_states: states,
|
||||
};
|
||||
}
|
||||
|
||||
function makeController() {
|
||||
return {
|
||||
action: vi.fn(),
|
||||
getRevision: vi.fn(() => 0),
|
||||
hasPendingOp: vi.fn(() => false),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoleRenderer', () => {
|
||||
let adapter;
|
||||
let avTileAdapter;
|
||||
let stateStore;
|
||||
let controller;
|
||||
let renderer;
|
||||
let hooksStub;
|
||||
|
||||
beforeEach(() => {
|
||||
hooksStub = { on: vi.fn(), off: vi.fn(), once: vi.fn(), callAll: vi.fn() };
|
||||
vi.stubGlobal('Hooks', hooksStub);
|
||||
vi.stubGlobal('game', { webrtc: {}, user: { setFlag: vi.fn(), getFlag: vi.fn(() => null) } });
|
||||
|
||||
adapter = createFoundryAdapterMock();
|
||||
avTileAdapter = makeAVTileAdapter();
|
||||
stateStore = makeStateStore();
|
||||
controller = makeController();
|
||||
renderer = new RoleRenderer(stateStore, controller, avTileAdapter, adapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('stores all injected dependencies without side effects', () => {
|
||||
expect(renderer._stateStore).toBe(stateStore);
|
||||
expect(renderer._controller).toBe(controller);
|
||||
expect(renderer._avTileAdapter).toBe(avTileAdapter);
|
||||
expect(renderer._adapter).toBe(adapter);
|
||||
});
|
||||
|
||||
it('does not register any Hooks in constructor', () => {
|
||||
expect(hooksStub.on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('_strip is null before openStrip()', () => {
|
||||
expect(renderer._strip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('registers scrying-pool:stateChanged hook', () => {
|
||||
renderer.init();
|
||||
expect(hooksStub.on).toHaveBeenCalledWith(
|
||||
'scrying-pool:stateChanged',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('registers scrying-pool:controllerAction hook', () => {
|
||||
renderer.init();
|
||||
expect(hooksStub.on).toHaveBeenCalledWith(
|
||||
'scrying-pool:controllerAction',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('registers updateUser hook', () => {
|
||||
renderer.init();
|
||||
expect(hooksStub.on).toHaveBeenCalledWith('updateUser', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('_applyAVTileState()', () => {
|
||||
it('calls setStateClass on avTileAdapter with userId and state', () => {
|
||||
renderer._applyAVTileState('user-1', 'active');
|
||||
expect(avTileAdapter.setStateClass).toHaveBeenCalledWith('user-1', 'active');
|
||||
});
|
||||
|
||||
it('mounts lock-overlay element when state is hidden', () => {
|
||||
renderer._applyAVTileState('user-1', 'hidden');
|
||||
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||
expect(el.dataset.spRole).toBe('lock-overlay');
|
||||
});
|
||||
|
||||
it('unmounts lock-overlay when state transitions away from hidden', () => {
|
||||
renderer._applyAVTileState('user-1', 'active');
|
||||
expect(avTileAdapter.unmount).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('mounts portrait-fallback when state is never-connected', () => {
|
||||
renderer._applyAVTileState('user-1', 'never-connected');
|
||||
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||
expect(el.dataset.spRole).toBe('portrait-fallback');
|
||||
});
|
||||
|
||||
it('mounts portrait-fallback when state is cam-lost', () => {
|
||||
renderer._applyAVTileState('user-1', 'cam-lost');
|
||||
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||
expect(el.dataset.spRole).toBe('portrait-fallback');
|
||||
});
|
||||
|
||||
it('unmounts portrait-fallback when state is not camera-absent', () => {
|
||||
renderer._applyAVTileState('user-1', 'active');
|
||||
expect(avTileAdapter.unmount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mount any overlay for active state', () => {
|
||||
renderer._applyAVTileState('user-1', 'active');
|
||||
expect(avTileAdapter.mount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateChanged hook handler', () => {
|
||||
it('calls _applyAVTileState when scrying-pool:stateChanged fires', () => {
|
||||
renderer.init();
|
||||
const spy = vi.spyOn(renderer, '_applyAVTileState');
|
||||
const handler = hooksStub.on.mock.calls.find(
|
||||
c => c[0] === 'scrying-pool:stateChanged'
|
||||
)[1];
|
||||
handler({ userId: 'user-1', state: 'hidden' });
|
||||
expect(spy).toHaveBeenCalledWith('user-1', 'hidden');
|
||||
});
|
||||
|
||||
it('handles bulk matrix payload gracefully', () => {
|
||||
renderer.init();
|
||||
const handler = hooksStub.on.mock.calls.find(
|
||||
c => c[0] === 'scrying-pool:stateChanged'
|
||||
)[1];
|
||||
// bulk payload has no userId
|
||||
expect(() => handler({ matrix: {}, timestamp: Date.now(), revision: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openStrip()', () => {
|
||||
it('constructs ScryingPoolStrip lazily on first call', async () => {
|
||||
const { ScryingPoolStrip } = await import('../../../src/ui/gm/ScryingPoolStrip.js');
|
||||
vi.clearAllMocks();
|
||||
renderer.openStrip();
|
||||
expect(ScryingPoolStrip).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reuses existing strip instance on second call', async () => {
|
||||
const { ScryingPoolStrip } = await import('../../../src/ui/gm/ScryingPoolStrip.js');
|
||||
vi.clearAllMocks();
|
||||
renderer.openStrip();
|
||||
renderer.openStrip();
|
||||
expect(ScryingPoolStrip).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls render on the strip', () => {
|
||||
renderer.openStrip();
|
||||
expect(renderer._strip.render).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeStrip()', () => {
|
||||
it('calls close on the strip if it exists', () => {
|
||||
renderer.openStrip();
|
||||
const strip = renderer._strip;
|
||||
renderer.closeStrip();
|
||||
expect(strip.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is no-op if strip is not open', () => {
|
||||
expect(() => renderer.closeStrip()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Stub foundry global for conditional base class — must NOT be present at module load time
|
||||
// so the fallback class is used. The module is imported after this comment block.
|
||||
// We only stub `foundry` in specific tests that need runtime foundry calls (none here).
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Hooks', {
|
||||
on: vi.fn(() => 99),
|
||||
off: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('game', {
|
||||
user: {
|
||||
setFlag: vi.fn(),
|
||||
getFlag: vi.fn(() => null),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
import { DirectorsBoard } from '../../../../src/ui/gm/DirectorsBoard.js';
|
||||
|
||||
describe('DirectorsBoard', () => {
|
||||
let stateStore;
|
||||
let controller;
|
||||
let adapter;
|
||||
let board;
|
||||
|
||||
beforeEach(() => {
|
||||
stateStore = { getState: vi.fn(() => 'active') };
|
||||
controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false), getRevision: vi.fn(() => 0) };
|
||||
adapter = {
|
||||
users: {
|
||||
get: vi.fn(() => ({ name: 'Alice', avatar: null })),
|
||||
all: vi.fn(() => [{ id: 'u1' }]),
|
||||
},
|
||||
};
|
||||
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('is side-effect-free: does not call Hooks.on', () => {
|
||||
expect(Hooks.on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets _hookId to null initially', () => {
|
||||
expect(board._hookId).toBeNull();
|
||||
});
|
||||
|
||||
it('stores stateStore, controller, adapter references', () => {
|
||||
expect(board._stateStore).toBe(stateStore);
|
||||
expect(board._controller).toBe(controller);
|
||||
expect(board._adapter).toBe(adapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('registers scrying-pool:stateChanged hook', () => {
|
||||
board.init();
|
||||
expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
|
||||
});
|
||||
|
||||
it('stores the returned hook id in _hookId', () => {
|
||||
board.init();
|
||||
expect(board._hookId).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teardown()', () => {
|
||||
it('calls Hooks.off with the stored hook id', () => {
|
||||
board.init();
|
||||
board.teardown();
|
||||
expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', 99);
|
||||
});
|
||||
|
||||
it('sets _hookId to null after teardown', () => {
|
||||
board.init();
|
||||
board.teardown();
|
||||
expect(board._hookId).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when init was not called', () => {
|
||||
expect(() => board.teardown()).not.toThrow();
|
||||
expect(Hooks.off).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_dispatchToggle()', () => {
|
||||
it('calls controller.action with positional args (active→hidden)', () => {
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board._dispatchToggle('u1');
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('calls controller.action with targetState active (hidden→active)', () => {
|
||||
stateStore.getState.mockReturnValue('hidden');
|
||||
board._dispatchToggle('u2');
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('does not dispatch if userId is falsy', () => {
|
||||
board._dispatchToggle(null);
|
||||
board._dispatchToggle(undefined);
|
||||
board._dispatchToggle('');
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch if controller reports pending op', () => {
|
||||
controller.hasPendingOp.mockReturnValue(true);
|
||||
board._dispatchToggle('u1');
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to active state when stateStore returns null', () => {
|
||||
stateStore.getState.mockReturnValue(null);
|
||||
board._dispatchToggle('u1');
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle()', () => {
|
||||
it('calls render({ force: true }) when not rendered', async () => {
|
||||
board._rendered = false;
|
||||
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||
await board.toggle();
|
||||
expect(renderSpy).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it('calls close() when rendered', async () => {
|
||||
board._rendered = true;
|
||||
const closeSpy = vi.spyOn(board, 'close').mockResolvedValue(undefined);
|
||||
await board.toggle();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onStateChanged()', () => {
|
||||
it('calls render({ force: true }) when board is rendered', () => {
|
||||
board._rendered = true;
|
||||
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
|
||||
expect(renderSpy).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it('does not call render when board is not rendered', () => {
|
||||
board._rendered = false;
|
||||
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_prepareContext()', () => {
|
||||
it('returns board context with participants from adapter', async () => {
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.participants).toHaveLength(1);
|
||||
expect(ctx.participants[0].userId).toBe('u1');
|
||||
expect(ctx.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('returns isEmpty=true when adapter has no users', async () => {
|
||||
adapter.users.all.mockReturnValue([]);
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('returns hasUndo=false when _undoSnapshot is null', async () => {
|
||||
board._undoSnapshot = null;
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.hasUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('returns hasUndo=true when _undoSnapshot is set', async () => {
|
||||
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.hasUndo).toBe(true);
|
||||
});
|
||||
|
||||
it('returns hasRestore=false when _spotlightSnapshot is null', async () => {
|
||||
board._spotlightSnapshot = null;
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.hasRestore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns hasRestore=true when _spotlightSnapshot is set', async () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||
const ctx = await board._prepareContext({});
|
||||
expect(ctx.hasRestore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_OPTIONS', () => {
|
||||
it('has correct id', () => {
|
||||
expect(DirectorsBoard.DEFAULT_OPTIONS.id).toBe('scrying-pool-directors-board');
|
||||
});
|
||||
|
||||
it('has classes including scrying-pool and directors-board', () => {
|
||||
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('scrying-pool');
|
||||
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('directors-board');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PARTS', () => {
|
||||
it('has a board part with the correct template path', () => {
|
||||
expect(DirectorsBoard.PARTS.board.template).toContain('directors-board.hbs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAll()', () => {
|
||||
it('calls controller.action with active for each non-ghost user', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
|
||||
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'hidden');
|
||||
board.showAll();
|
||||
expect(controller.action).toHaveBeenCalledTimes(2);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('stores pre-action snapshot in _undoSnapshot (non-ghost users only)', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
|
||||
stateStore.getState.mockImplementation(id => {
|
||||
if (id === 'u1') return 'hidden';
|
||||
if (id === 'u2') return 'active';
|
||||
return 'ghost';
|
||||
});
|
||||
board.showAll();
|
||||
expect(board._undoSnapshot).toBeInstanceOf(Map);
|
||||
expect(board._undoSnapshot.get('u1')).toBe('hidden');
|
||||
expect(board._undoSnapshot.get('u2')).toBe('active');
|
||||
expect(board._undoSnapshot.has('u3')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears _spotlightSnapshot when called', () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||
stateStore.getState.mockReturnValue('hidden');
|
||||
board.showAll();
|
||||
expect(board._spotlightSnapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('skips participants with pending ops', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
stateStore.getState.mockReturnValue('hidden');
|
||||
controller.hasPendingOp.mockImplementation(id => id === 'u1');
|
||||
board.showAll();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('is a no-op when all users are ghost', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||
stateStore.getState.mockReturnValue('ghost');
|
||||
board.showAll();
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideAll()', () => {
|
||||
it('calls controller.action with hidden for each non-ghost user', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.hideAll();
|
||||
expect(controller.action).toHaveBeenCalledTimes(2);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('stores pre-action snapshot in _undoSnapshot', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.hideAll();
|
||||
expect(board._undoSnapshot).toBeInstanceOf(Map);
|
||||
expect(board._undoSnapshot.get('u1')).toBe('active');
|
||||
});
|
||||
|
||||
it('clears _spotlightSnapshot when called', () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.hideAll();
|
||||
expect(board._spotlightSnapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes ghost-state participants', () => {
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||
board.hideAll();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo()', () => {
|
||||
it('restores participants to snapshot states', () => {
|
||||
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'active']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.undo();
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('clears _undoSnapshot after use (single-step only)', () => {
|
||||
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.undo();
|
||||
expect(board._undoSnapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when _undoSnapshot is null', () => {
|
||||
board._undoSnapshot = null;
|
||||
board.undo();
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('second undo is unavailable after first (no-op)', () => {
|
||||
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.undo();
|
||||
board.undo();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips ghost-state participants during undo', () => {
|
||||
board._undoSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
|
||||
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||
board.undo();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('skips participants with pending ops during undo', () => {
|
||||
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'hidden']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
controller.hasPendingOp.mockImplementation(id => id === 'u1');
|
||||
board.undo();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DirectorsBoard spotlight', () => {
|
||||
let stateStore, controller, adapter, board;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Hooks', { on: vi.fn(() => 1), off: vi.fn(), once: vi.fn() });
|
||||
vi.stubGlobal('game', { keybindings: { register: vi.fn() } });
|
||||
stateStore = {
|
||||
getState: vi.fn(),
|
||||
getAll: vi.fn(() => new Map()),
|
||||
};
|
||||
controller = {
|
||||
action: vi.fn(),
|
||||
hasPendingOp: vi.fn(() => false),
|
||||
getRevision: vi.fn(() => 0),
|
||||
};
|
||||
adapter = {
|
||||
users: { all: vi.fn(() => [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]) },
|
||||
};
|
||||
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||
board.rendered = false;
|
||||
board.render = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('spotlight(userId)', () => {
|
||||
it('sets focusedId active, all others hidden, captures snapshot, clears undo', () => {
|
||||
stateStore.getState.mockImplementation(id => ({ u1: 'hidden', u2: 'active', u3: 'active' }[id]));
|
||||
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||
board.spotlight('u1');
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u3', 'hidden', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('stores pre-spotlight snapshot in _spotlightSnapshot', () => {
|
||||
stateStore.getState.mockImplementation(id => id === 'u1' ? 'active' : 'hidden');
|
||||
board.spotlight('u1');
|
||||
expect(board._spotlightSnapshot).toBeInstanceOf(Map);
|
||||
expect(board._spotlightSnapshot.size).toBe(3);
|
||||
});
|
||||
|
||||
it('clears _undoSnapshot when spotlight is called', () => {
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||
board.spotlight('u2');
|
||||
expect(board._undoSnapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes ghost participants from spotlight', () => {
|
||||
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'active');
|
||||
board.spotlight('u1');
|
||||
const calls = controller.action.mock.calls.map(c => c[1]);
|
||||
expect(calls).not.toContain('u3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreSpotlight()', () => {
|
||||
it('restores participants to pre-spotlight snapshot states', () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'hidden'], ['u2', 'active'], ['u3', 'hidden']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.restoreSpotlight();
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it('clears _spotlightSnapshot after restore', () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board.restoreSpotlight();
|
||||
expect(board._spotlightSnapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when _spotlightSnapshot is null', () => {
|
||||
board._spotlightSnapshot = null;
|
||||
board.restoreSpotlight();
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips ghost participants during restore', () => {
|
||||
board._spotlightSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
|
||||
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||
board.restoreSpotlight();
|
||||
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('spotlightFocused()', () => {
|
||||
it('is a no-op if no participant is focused', () => {
|
||||
board._focusedUserId = null;
|
||||
board.spotlightFocused();
|
||||
expect(controller.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls spotlight() with the currently focused userId', () => {
|
||||
stateStore.getState.mockReturnValue('active');
|
||||
board._focusedUserId = 'u2';
|
||||
const spy = vi.spyOn(board, 'spotlight');
|
||||
board.spotlightFocused();
|
||||
expect(spy).toHaveBeenCalledWith('u2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Stub Application globally before importing ScryingPoolStrip
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Application', class {
|
||||
static get defaultOptions() { return {}; }
|
||||
constructor() { this.position = { left: 0, top: 0 }; this.rendered = false; }
|
||||
render() {}
|
||||
close() {}
|
||||
activateListeners() {}
|
||||
});
|
||||
vi.stubGlobal('foundry', {
|
||||
utils: {
|
||||
mergeObject: (base, override) => Object.assign({}, base, override),
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('game', {
|
||||
user: {
|
||||
setFlag: vi.fn(),
|
||||
getFlag: vi.fn(() => null),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
import {
|
||||
LABELS,
|
||||
resolveTargetState,
|
||||
buildParticipantList,
|
||||
ScryingPoolStrip,
|
||||
} from '../../../../src/ui/gm/ScryingPoolStrip.js';
|
||||
|
||||
describe('LABELS', () => {
|
||||
it('has HIDE_FROM_TABLE equal to exact canonical string', () => {
|
||||
expect(LABELS.HIDE_FROM_TABLE).toBe('Hide from table');
|
||||
});
|
||||
|
||||
it('has SHOW_TO_TABLE equal to exact canonical string', () => {
|
||||
expect(LABELS.SHOW_TO_TABLE).toBe('Show to table');
|
||||
});
|
||||
|
||||
it('has FIRST_TOOLTIP set', () => {
|
||||
expect(LABELS.FIRST_TOOLTIP).toBe('Hide this participant from other players.');
|
||||
});
|
||||
|
||||
it('is frozen (immutable)', () => {
|
||||
expect(Object.isFrozen(LABELS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTargetState()', () => {
|
||||
it('returns active when current state is hidden', () => {
|
||||
expect(resolveTargetState('hidden')).toBe('active');
|
||||
});
|
||||
|
||||
it('returns hidden when current state is active', () => {
|
||||
expect(resolveTargetState('active')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('returns hidden when current state is self-muted', () => {
|
||||
expect(resolveTargetState('self-muted')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('returns hidden when current state is cam-lost', () => {
|
||||
expect(resolveTargetState('cam-lost')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('returns hidden when current state is never-connected', () => {
|
||||
expect(resolveTargetState('never-connected')).toBe('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildParticipantList()', () => {
|
||||
let stateStore;
|
||||
let controller;
|
||||
let adapter;
|
||||
|
||||
beforeEach(() => {
|
||||
stateStore = {
|
||||
getState: vi.fn(userId => userId === 'user-1' ? 'active' : 'hidden'),
|
||||
};
|
||||
controller = {
|
||||
hasPendingOp: vi.fn(() => false),
|
||||
};
|
||||
adapter = {
|
||||
users: {
|
||||
get: vi.fn(userId => ({
|
||||
id: userId,
|
||||
name: `User ${userId}`,
|
||||
avatar: `avatars/${userId}.png`,
|
||||
})),
|
||||
current: vi.fn(() => ({ id: 'user-1' })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('returns correct shape for each participant', () => {
|
||||
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
name: 'User user-1',
|
||||
avatarSrc: 'avatars/user-1.png',
|
||||
state: 'active',
|
||||
stateLabel: 'Active',
|
||||
hasPendingOp: false,
|
||||
isCurrentUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isEmpty-compatible empty array for no userIds', () => {
|
||||
const list = buildParticipantList([], stateStore, controller, adapter);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sets hasPendingOp to true when controller reports pending', () => {
|
||||
controller.hasPendingOp.mockReturnValue(true);
|
||||
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
|
||||
expect(list[0].hasPendingOp).toBe(true);
|
||||
});
|
||||
|
||||
it('uses mystery-man.svg fallback when avatar is null', () => {
|
||||
adapter.users.get.mockReturnValue({ id: 'user-1', name: 'Alice', avatar: null });
|
||||
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
|
||||
expect(list[0].avatarSrc).toBe('icons/svg/mystery-man.svg');
|
||||
});
|
||||
|
||||
it('marks only the current user as isCurrentUser', () => {
|
||||
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
|
||||
expect(list[0].isCurrentUser).toBe(true);
|
||||
expect(list[1].isCurrentUser).toBe(false);
|
||||
});
|
||||
|
||||
it('correctly maps hidden state label', () => {
|
||||
const list = buildParticipantList(['user-2'], stateStore, controller, adapter);
|
||||
expect(list[0].stateLabel).toBe('Hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScryingPoolStrip', () => {
|
||||
let stateStore;
|
||||
let controller;
|
||||
let avTileAdapter;
|
||||
let adapter;
|
||||
let strip;
|
||||
|
||||
beforeEach(() => {
|
||||
stateStore = { getState: vi.fn(() => 'active') };
|
||||
controller = { action: vi.fn(), getRevision: vi.fn(() => 0), hasPendingOp: vi.fn(() => false) };
|
||||
avTileAdapter = { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), disconnect: vi.fn() };
|
||||
adapter = {
|
||||
users: {
|
||||
get: vi.fn(() => ({ id: 'u1', name: 'Alice', avatar: 'av.png' })),
|
||||
all: vi.fn(() => [{ id: 'u1' }]),
|
||||
current: vi.fn(() => ({ id: 'u1' })),
|
||||
},
|
||||
};
|
||||
strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter);
|
||||
});
|
||||
|
||||
describe('defaultOptions', () => {
|
||||
it('has correct id', () => {
|
||||
expect(ScryingPoolStrip.defaultOptions.id).toBe('scrying-pool-strip');
|
||||
});
|
||||
|
||||
it('has correct template path', () => {
|
||||
expect(ScryingPoolStrip.defaultOptions.template).toContain('roster-strip.hbs');
|
||||
});
|
||||
|
||||
it('is not resizable', () => {
|
||||
expect(ScryingPoolStrip.defaultOptions.resizable).toBe(false);
|
||||
});
|
||||
|
||||
it('popOut is true', () => {
|
||||
expect(ScryingPoolStrip.defaultOptions.popOut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData()', () => {
|
||||
it('returns participants array', () => {
|
||||
const data = strip.getData();
|
||||
expect(Array.isArray(data.participants)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isExpanded property', () => {
|
||||
const data = strip.getData();
|
||||
expect(typeof data.isExpanded).toBe('boolean');
|
||||
});
|
||||
|
||||
it('returns isEmpty true when no participants', () => {
|
||||
adapter.users.all.mockReturnValue([]);
|
||||
const data = strip.getData();
|
||||
expect(data.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isEmpty false when participants exist', () => {
|
||||
const data = strip.getData();
|
||||
expect(data.isEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
// @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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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('video-view-manager', '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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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('video-view-manager', 'firstBadgeEncounter', true);
|
||||
});
|
||||
|
||||
it('clears timer (no ghost timer after dismissal)', async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||
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 <dialog> element with correct class', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog.sp-visibility-details-panel');
|
||||
expect(dialog).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sets aria-modal="true"', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('appends dialog to document.body', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog.parentNode).toBe(document.body);
|
||||
});
|
||||
|
||||
it('calls showModal()', () => {
|
||||
const showModalSpy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {});
|
||||
badge._badgeEl.click();
|
||||
expect(showModalSpy).toHaveBeenCalled();
|
||||
showModalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
it('contains a "Close" button', () => {
|
||||
badge._badgeEl.click();
|
||||
const btn = document.querySelector('dialog [data-action="close-details"]');
|
||||
expect(btn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows state explanation text', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog.textContent).toContain('Hidden from table');
|
||||
});
|
||||
|
||||
it('suppresses audience list and shows reassurance when state is hidden', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog.textContent).toContain('Other players cannot see your feed');
|
||||
});
|
||||
|
||||
it('shows stale data indicator when controller is null', () => {
|
||||
const b = new VisibilityBadge(stateStore, null, avTileAdapter, adapter);
|
||||
b.init();
|
||||
b._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
expect(dialog.textContent).toContain('Data may be outdated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('close handlers', () => {
|
||||
it('"Close" button closes the dialog', () => {
|
||||
const closeSpy = vi.spyOn(HTMLDialogElement.prototype, 'close').mockImplementation(function () {
|
||||
this.dispatchEvent(new Event('close'));
|
||||
});
|
||||
badge._badgeEl.click();
|
||||
const btn = document.querySelector('[data-action="close-details"]');
|
||||
btn.click();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
closeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('backdrop click closes dialog', () => {
|
||||
const closeSpy = vi.spyOn(HTMLDialogElement.prototype, 'close').mockImplementation(function () {
|
||||
this.dispatchEvent(new Event('close'));
|
||||
});
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
dialog.dispatchEvent(new MouseEvent('click', { bubbles: false }));
|
||||
// event.target === dialog triggers close
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
closeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('removes dialog from DOM on close', () => {
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
dialog.dispatchEvent(new Event('close'));
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns focus to trigger element on close', () => {
|
||||
const focusSpy = vi.spyOn(badge._badgeEl, 'focus');
|
||||
badge._badgeEl.click();
|
||||
const dialog = document.querySelector('dialog');
|
||||
dialog.dispatchEvent(new Event('close'));
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { AVTileAdapter } from '../../../../src/ui/shared/AVTileAdapter.js';
|
||||
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
|
||||
|
||||
describe('AVTileAdapter', () => {
|
||||
let adapter;
|
||||
let avAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="camera-view" data-user-id="user-1"></div>
|
||||
<div class="camera-view" data-user-id="user-2"></div>
|
||||
`;
|
||||
adapter = createFoundryAdapterMock();
|
||||
avAdapter = new AVTileAdapter(adapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
avAdapter.disconnect();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('stores adapter reference without side effects', () => {
|
||||
expect(avAdapter._adapter).toBe(adapter);
|
||||
expect(avAdapter._observers).toBeInstanceOf(Map);
|
||||
expect(avAdapter._observers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mount()', () => {
|
||||
it('appends element to the matching AV tile', () => {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.spRole = 'lock-overlay';
|
||||
avAdapter.mount('user-1', el);
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.contains(el)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks mounted element with data-sp-mount attribute', () => {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.spRole = 'lock-overlay';
|
||||
avAdapter.mount('user-1', el);
|
||||
expect(el.dataset.spMount).toBe('1');
|
||||
});
|
||||
|
||||
it('warns and does not throw when tile not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const el = document.createElement('div');
|
||||
expect(() => avAdapter.mount('unknown-user', el)).not.toThrow();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ScryingPool] AVTileAdapter.mount: tile not found for',
|
||||
'unknown-user'
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('is idempotent: calling twice with same element does not duplicate', () => {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.spRole = 'lock-overlay';
|
||||
avAdapter.mount('user-1', el);
|
||||
avAdapter.mount('user-1', el);
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.querySelectorAll('[data-sp-role="lock-overlay"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('replaces existing element with same data-sp-role', () => {
|
||||
const el1 = document.createElement('div');
|
||||
el1.dataset.spRole = 'lock-overlay';
|
||||
el1.textContent = 'first';
|
||||
avAdapter.mount('user-1', el1);
|
||||
|
||||
const el2 = document.createElement('div');
|
||||
el2.dataset.spRole = 'lock-overlay';
|
||||
el2.textContent = 'second';
|
||||
avAdapter.mount('user-1', el2);
|
||||
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
const overlays = tile.querySelectorAll('[data-sp-role="lock-overlay"]');
|
||||
expect(overlays.length).toBe(1);
|
||||
expect(overlays[0].textContent).toBe('second');
|
||||
});
|
||||
|
||||
it('appends multiple elements with different roles', () => {
|
||||
const el1 = document.createElement('div');
|
||||
el1.dataset.spRole = 'lock-overlay';
|
||||
const el2 = document.createElement('div');
|
||||
el2.dataset.spRole = 'portrait-fallback';
|
||||
avAdapter.mount('user-1', el1);
|
||||
avAdapter.mount('user-1', el2);
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.querySelectorAll('[data-sp-mount]').length).toBe(2);
|
||||
});
|
||||
|
||||
it('does not affect other tiles', () => {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.spRole = 'lock-overlay';
|
||||
avAdapter.mount('user-1', el);
|
||||
const tile2 = document.querySelector('[data-user-id="user-2"]');
|
||||
expect(tile2.querySelectorAll('[data-sp-mount]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmount()', () => {
|
||||
it('removes all data-sp-mount children from tile', () => {
|
||||
const el1 = document.createElement('div');
|
||||
el1.dataset.spRole = 'lock-overlay';
|
||||
const el2 = document.createElement('div');
|
||||
el2.dataset.spRole = 'portrait-fallback';
|
||||
avAdapter.mount('user-1', el1);
|
||||
avAdapter.mount('user-1', el2);
|
||||
avAdapter.unmount('user-1');
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.querySelectorAll('[data-sp-mount]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not remove non-managed children', () => {
|
||||
const native = document.createElement('video');
|
||||
document.querySelector('[data-user-id="user-1"]').appendChild(native);
|
||||
avAdapter.unmount('user-1');
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.contains(native)).toBe(true);
|
||||
});
|
||||
|
||||
it('is no-op if tile not found', () => {
|
||||
expect(() => avAdapter.unmount('unknown-user')).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not affect other tiles', () => {
|
||||
const el1 = document.createElement('div');
|
||||
el1.dataset.spRole = 'lock-overlay';
|
||||
const el2 = document.createElement('div');
|
||||
el2.dataset.spRole = 'lock-overlay';
|
||||
avAdapter.mount('user-1', el1);
|
||||
avAdapter.mount('user-2', el2);
|
||||
avAdapter.unmount('user-1');
|
||||
const tile2 = document.querySelector('[data-user-id="user-2"]');
|
||||
expect(tile2.querySelectorAll('[data-sp-mount]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStateClass()', () => {
|
||||
it('adds sp-state-{stateName} class to tile', () => {
|
||||
avAdapter.setStateClass('user-1', 'hidden');
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
expect(tile.classList.contains('sp-state-hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes all previous sp-state-* classes before adding new', () => {
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
tile.classList.add('sp-state-active');
|
||||
tile.classList.add('sp-state-pending');
|
||||
avAdapter.setStateClass('user-1', 'hidden');
|
||||
expect(tile.classList.contains('sp-state-active')).toBe(false);
|
||||
expect(tile.classList.contains('sp-state-pending')).toBe(false);
|
||||
expect(tile.classList.contains('sp-state-hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears all sp-state-* classes when stateName is null', () => {
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
tile.classList.add('sp-state-active');
|
||||
avAdapter.setStateClass('user-1', null);
|
||||
expect(tile.classList.contains('sp-state-active')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns and does not throw when tile not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(() => avAdapter.setStateClass('unknown-user', 'hidden')).not.toThrow();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ScryingPool] AVTileAdapter.setStateClass: tile not found for',
|
||||
'unknown-user'
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not affect non-sp-* classes on tile', () => {
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
tile.classList.add('camera-view');
|
||||
tile.classList.add('some-other-class');
|
||||
avAdapter.setStateClass('user-1', 'hidden');
|
||||
expect(tile.classList.contains('camera-view')).toBe(true);
|
||||
expect(tile.classList.contains('some-other-class')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTileRerender()', () => {
|
||||
it('calls callback when tile children change', async () => {
|
||||
const cb = vi.fn();
|
||||
avAdapter.onTileRerender('user-1', cb);
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
tile.appendChild(document.createElement('span'));
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(cb).toHaveBeenCalledWith(tile);
|
||||
});
|
||||
|
||||
it('replaces existing observer when called again for same userId', async () => {
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
avAdapter.onTileRerender('user-1', cb1);
|
||||
avAdapter.onTileRerender('user-1', cb2);
|
||||
// Only one observer should be active per userId
|
||||
expect(avAdapter._observers.size).toBe(1);
|
||||
});
|
||||
|
||||
it('stores one observer per userId', () => {
|
||||
avAdapter.onTileRerender('user-1', vi.fn());
|
||||
avAdapter.onTileRerender('user-2', vi.fn());
|
||||
expect(avAdapter._observers.size).toBe(2);
|
||||
});
|
||||
|
||||
it('is no-op if tile not found', () => {
|
||||
expect(() => avAdapter.onTileRerender('unknown-user', vi.fn())).not.toThrow();
|
||||
expect(avAdapter._observers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect()', () => {
|
||||
it('stops observers from firing after disconnect', async () => {
|
||||
const cb = vi.fn();
|
||||
avAdapter.onTileRerender('user-1', cb);
|
||||
avAdapter.disconnect();
|
||||
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||
tile.appendChild(document.createElement('span'));
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears observer map', () => {
|
||||
avAdapter.onTileRerender('user-1', vi.fn());
|
||||
avAdapter.onTileRerender('user-2', vi.fn());
|
||||
avAdapter.disconnect();
|
||||
expect(avAdapter._observers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('is safe to call multiple times', () => {
|
||||
avAdapter.onTileRerender('user-1', vi.fn());
|
||||
expect(() => {
|
||||
avAdapter.disconnect();
|
||||
avAdapter.disconnect();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user