400 lines
12 KiB
JavaScript
400 lines
12 KiB
JavaScript
// @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),
|
|
},
|
|
});
|
|
vi.stubGlobal('document', {
|
|
createElement: vi.fn(tag => ({ tag, srcObject: null, autoplay: false, playsInline: false, muted: false, className: '', addEventListener: vi.fn(), remove: vi.fn() })),
|
|
querySelectorAll: vi.fn(() => []),
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('includes hasStreamAccess flag when passed as true (Story 5.1)', () => {
|
|
const list = buildParticipantList(['user-1'], stateStore, controller, adapter, true);
|
|
expect(list[0].hasStreamAccess).toBe(true);
|
|
});
|
|
|
|
it('includes hasStreamAccess flag when passed as false (Story 5.1)', () => {
|
|
const list = buildParticipantList(['user-1'], stateStore, controller, adapter, false);
|
|
expect(list[0].hasStreamAccess).toBe(false);
|
|
});
|
|
|
|
it('defaults hasStreamAccess to false when not provided (Story 5.1)', () => {
|
|
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
|
|
expect(list[0].hasStreamAccess).toBe(false);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('returns hasStreamAccess true when webrtc.getMediaStreamForUser is available (Story 5.1)', () => {
|
|
adapter.webrtc = {
|
|
getMediaStreamForUser: vi.fn(),
|
|
};
|
|
const data = strip.getData();
|
|
expect(data.hasStreamAccess).toBe(true);
|
|
});
|
|
|
|
it('returns hasStreamAccess false when webrtc is null (Story 5.1)', () => {
|
|
adapter.webrtc = null;
|
|
const data = strip.getData();
|
|
expect(data.hasStreamAccess).toBe(false);
|
|
});
|
|
|
|
it('returns hasStreamAccess false when webrtc has no getMediaStreamForUser (Story 5.1)', () => {
|
|
adapter.webrtc = {};
|
|
const data = strip.getData();
|
|
expect(data.hasStreamAccess).toBe(false);
|
|
});
|
|
|
|
it('includes current user when showGMSelfFeed is true', () => {
|
|
adapter.settings = { get: vi.fn(() => true) };
|
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
|
const data = strip.getData();
|
|
expect(data.participants.map(p => p.userId)).toContain('u1');
|
|
expect(data.participants.map(p => p.userId)).toContain('u2');
|
|
});
|
|
|
|
it('excludes current user when showGMSelfFeed is false', () => {
|
|
adapter.settings = { get: vi.fn(() => false) };
|
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
|
const data = strip.getData();
|
|
// u1 is the current user (mocked in beforeEach), should be excluded
|
|
expect(data.participants.map(p => p.userId)).not.toContain('u1');
|
|
expect(data.participants.map(p => p.userId)).toContain('u2');
|
|
});
|
|
|
|
it('includes all users when settings is unavailable (defaults to true)', () => {
|
|
// no adapter.settings — fallback to true
|
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
|
const data = strip.getData();
|
|
expect(data.participants.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('_attachVideoStream() (Story 5.1)', () => {
|
|
let mockVideoContainer;
|
|
let consoleWarnSpy;
|
|
|
|
beforeEach(() => {
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
mockVideoContainer = {
|
|
querySelector: vi.fn(),
|
|
appendChild: vi.fn(),
|
|
};
|
|
adapter.webrtc = {
|
|
getMediaStreamForUser: vi.fn().mockReturnValue(new MediaStream()),
|
|
};
|
|
vi.spyOn(document, 'createElement').mockReturnValue({
|
|
srcObject: null,
|
|
autoplay: false,
|
|
playsInline: false,
|
|
muted: false,
|
|
className: '',
|
|
addEventListener: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('creates video element with stream (Story 5.1)', () => {
|
|
const participantItem = {
|
|
querySelector: vi.fn().mockReturnValue(mockVideoContainer),
|
|
};
|
|
|
|
// Call the method directly
|
|
strip._attachVideoStream('user-1', participantItem);
|
|
|
|
// Check that getMediaStreamForUser was called
|
|
expect(adapter.webrtc.getMediaStreamForUser).toHaveBeenCalledWith('user-1');
|
|
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns early and does not warn when webrtc is not available (Story 5.1)', () => {
|
|
adapter.webrtc = null;
|
|
const participantItem = { querySelector: vi.fn() };
|
|
|
|
strip._attachVideoStream('user-1', participantItem);
|
|
|
|
// Should return early without warning since webrtc check happens first
|
|
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
expect(participantItem.querySelector).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns when no video container found (Story 5.1)', () => {
|
|
const participantItem = {
|
|
querySelector: vi.fn().mockReturnValue(null),
|
|
};
|
|
|
|
strip._attachVideoStream('user-1', participantItem);
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] No video container found for user:',
|
|
'user-1'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('_cleanupVideoStreams() (Story 5.1)', () => {
|
|
let consoleWarnSpy;
|
|
|
|
beforeEach(() => {
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('handles undefined document gracefully', () => {
|
|
const originalDocument = global.document;
|
|
vi.stubGlobal('document', undefined);
|
|
|
|
expect(() => strip._cleanupVideoStreams()).not.toThrow();
|
|
|
|
vi.stubGlobal('document', originalDocument);
|
|
});
|
|
|
|
it('removes video elements and cleans up streams', () => {
|
|
const mockStream = new MediaStream();
|
|
const mockTrack = { stop: vi.fn() };
|
|
const mockVideoElement = {
|
|
srcObject: mockStream,
|
|
remove: vi.fn(),
|
|
};
|
|
// Add getTracks method to the mock stream
|
|
mockStream.getTracks = vi.fn().mockReturnValue([mockTrack]);
|
|
|
|
vi.stubGlobal('document', {
|
|
querySelectorAll: vi.fn().mockReturnValue([mockVideoElement]),
|
|
});
|
|
|
|
strip._cleanupVideoStreams();
|
|
|
|
expect(mockStream.getTracks).toHaveBeenCalled();
|
|
expect(mockTrack.stop).toHaveBeenCalled();
|
|
expect(mockVideoElement.srcObject).toBe(null);
|
|
expect(mockVideoElement.remove).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles video elements without streams gracefully', () => {
|
|
const mockVideoElement = {
|
|
srcObject: null,
|
|
remove: vi.fn(),
|
|
};
|
|
|
|
vi.stubGlobal('document', {
|
|
querySelectorAll: vi.fn().mockReturnValue([mockVideoElement]),
|
|
});
|
|
|
|
expect(() => strip._cleanupVideoStreams()).not.toThrow();
|
|
expect(mockVideoElement.remove).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles empty video element list', () => {
|
|
vi.stubGlobal('document', {
|
|
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
});
|
|
|
|
expect(() => strip._cleanupVideoStreams()).not.toThrow();
|
|
});
|
|
});
|
|
});
|