diff --git a/lang/en.json b/lang/en.json index c5d0a55..efa996e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,3 +1,81 @@ { - "video-view-manager": {} + "video-view-manager": { + "badge": { + "state": { + "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" + } + }, + "firstEncounterPanel": { + "title": "Your camera visibility changed.", + "body": "Audio continues normally.", + "gotIt": "Got it" + }, + "detailsPanel": { + "close": "Close", + "audienceSuppressed": "Other players cannot see your feed", + "staleData": "Data may be outdated", + "audioReassurance": "Your audio is active for all participants." + }, + "notifications": { + "gmHid": "GM hid {name}'s camera", + "gmShowed": "GM showed {name}'s camera", + "personalHidden": "GM has hidden your camera. Your portrait is shown to other Participants.", + "personalShowed": "Your camera is now visible to the table." + }, + "settings": { + "notificationVerbosity": { + "label": "Notification Verbosity", + "hint": "Controls which camera-state notifications you see. 'All' shows every change; 'GM Only' shows changes only to the GM and affected participant; 'Silent' suppresses all notifications except your own camera changes.", + "choices": { + "all": "All", + "gm-only": "GM Only", + "silent": "Silent" + } + } + }, + "directorsBoard": { + "title": "Director's Board", + "empty": "No participants connected.", + "openButton": "Open Director's Board", + "footer": { + "savePreset": "Save Preset…", + "loadPreset": "Load Preset…" + }, + "bulk": { + "showAll": "Show All", + "hideAll": "Hide All", + "undo": "Undo", + "restore": "Restore", + "spotlight": "Spotlight" + }, + "shortcuts": { + "title": "Keyboard Shortcuts", + "openBoard": "Open/Close Board", + "showAll": "Show All Participants", + "hideAll": "Hide All Participants", + "spotlight": "Spotlight Focused Participant", + "openPanel": "Open Shortcut Reference" + } + }, + "keybindings": { + "showAll": { + "name": "Show All Participants", + "hint": "Sets all non-ghost participant feeds to visible" + }, + "hideAll": { + "name": "Hide All Participants", + "hint": "Sets all non-ghost participant feeds to hidden" + }, + "spotlightParticipant": { + "name": "Spotlight Focused Participant", + "hint": "Shows the focused participant and hides all others" + } + } + } } diff --git a/module.js b/module.js index 78dfe4e..e4b526b 100644 --- a/module.js +++ b/module.js @@ -9,20 +9,38 @@ * Hooks.once('init') → register world settings → construct FoundryAdapter * → StateStore → SocketHandler (queue+drain) * Hooks.once('ready') → hydrate StateStore → probe WebRTC - * → Story 1.4: VisibilityManager → SocketHandler.setReady() - * → NotificationBus → RoleRenderer → RosterStrip - * → DirectorsBoard (lazy, GM only) + * → VisibilityManager → SocketHandler.setReady() + * → ScryingPoolController + * → AVTileAdapter → RoleRenderer → openStrip (GM only) + * → VisibilityBadge (player only) — Story 1.6 + * → NotificationBus (all clients) — Story 2.1 + * → Story 2.2: DirectorsBoard (lazy, GM only) */ import { FoundryAdapter } from './src/foundry/FoundryAdapter.js'; import { StateStore } from './src/core/StateStore.js'; import { SocketHandler } from './src/core/SocketHandler.js'; +import { VisibilityManager } from './src/core/VisibilityManager.js'; +import { ScryingPoolController } from './src/core/ScryingPoolController.js'; +import { AVTileAdapter } from './src/ui/shared/AVTileAdapter.js'; +import { RoleRenderer } from './src/ui/RoleRenderer.js'; +import { VisibilityBadge } from './src/ui/player/VisibilityBadge.js'; +import { NotificationBus } from './src/notifications/NotificationBus.js'; +import { DirectorsBoard } from './src/ui/gm/DirectorsBoard.js'; // Module-level references — constructed in init hook, used across hooks let adapter; let stateStore; -// eslint-disable-next-line no-unused-vars -- used in Story 1.4 (socketHandler.setReady) let socketHandler; +let visibilityManager; +let scryingPoolController; +let avTileAdapter; +let roleRenderer; +let visibilityBadge; +let notificationBus; +let directorsBoard; +/** @type {boolean} Flag to prevent duplicate scene control button addition */ +let directorsBoardButtonAdded = false; Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); @@ -58,9 +76,74 @@ Hooks.once("init", () => { default: true, }); + // Story 2.1: per-user notification verbosity preference (client-scoped) + adapter.settings.register("notificationVerbosity", { + scope: "client", + config: true, + type: String, + choices: { + all: "All", + "gm-only": "GM Only", + silent: "Silent", + }, + default: "all", + }); + // Construct data layer — constructors are side-effect-free stateStore = new StateStore(adapter.settings); socketHandler = new SocketHandler(adapter.socket, adapter.hooks); + + // Story 2.2: GM-only keyboard shortcut to open/close Director's Board + game.keybindings.register('scrying-pool', 'openDirectorsBoard', { + name: "Open/Close Director's Board", + hint: "Toggles the Director's Board window", + editable: [{ key: 'KeyV', modifiers: ['Control', 'Shift'] }], + restricted: true, // GM only + onDown: () => directorsBoard?.toggle(), + }); + + // Story 2.3: Bulk-action keybindings (GM only, migrated to scrying-pool namespace) + game.keybindings.register('scrying-pool', 'showAll', { + name: game.i18n.localize('video-view-manager.keybindings.showAll.name'), + hint: game.i18n.localize('video-view-manager.keybindings.showAll.hint'), + editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }], + restricted: true, + onDown: () => directorsBoard?.showAll(), + }); + game.keybindings.register('scrying-pool', 'hideAll', { + name: game.i18n.localize('video-view-manager.keybindings.hideAll.name'), + hint: game.i18n.localize('video-view-manager.keybindings.hideAll.hint'), + editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }], + restricted: true, + onDown: () => directorsBoard?.hideAll(), + }); + game.keybindings.register('scrying-pool', 'spotlightParticipant', { + name: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.name'), + hint: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.hint'), + editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }], + restricted: true, + onDown: () => directorsBoard?.spotlightFocused(), + }); + + // Story 2.2: Inject GM-only sidebar button via scene controls hook. + // Uses the strip footer CTA pattern if getSceneControlButtons API is unavailable. + Hooks.on('getSceneControlButtons', (controls) => { + // Prevent duplicate button addition + if (directorsBoardButtonAdded) return; + if (!game.user?.isGM) return; + const tokenGroup = controls.find?.(c => c.name === 'token'); + if (!tokenGroup?.tools) return; + // Check if button already exists + if (tokenGroup.tools.some(t => t.name === 'directors-board')) return; + tokenGroup.tools.push({ + name: 'directors-board', + title: "Director's Board", + icon: 'fas fa-border-all', + onClick: () => directorsBoard?.toggle(), + button: true, + }); + directorsBoardButtonAdded = true; + }); }); Hooks.once("ready", () => { @@ -78,6 +161,47 @@ Hooks.once("ready", () => { console.error('[ScryingPool] Failed to set webrtcMode setting:', err); }); - // Story 1.4: construct VisibilityManager and call socketHandler.setReady(visibilityManager) + // Wire core managers — construct both before setReady so handler can reference both + visibilityManager = new VisibilityManager(stateStore, adapter); + scryingPoolController = new ScryingPoolController(stateStore, socketHandler, adapter); + + // Set up composite handler for SocketHandler timeout callbacks + // This allows cleanup of ScryingPoolController._pendingOps when onRevert fires + socketHandler.setReady({ + onRevert: (pendingOp) => { + visibilityManager.onRevert(pendingOp); + scryingPoolController.cleanupPendingOp(pendingOp.userId); + } + }); + + // Initialize both managers — must come after setReady so queue is drained + // before echo listener is registered (prevents early echo loss) + try { + visibilityManager.init(); + scryingPoolController.init(); + + // Story 1.5: AV tile integration + GM control UI + avTileAdapter = new AVTileAdapter(adapter); + roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter); + roleRenderer.init(); + if (adapter.users.isGM() && game.webrtc !== null) { + roleRenderer.openStrip(); + } + if (!adapter.users.isGM()) { + visibilityBadge = new VisibilityBadge(stateStore, scryingPoolController, avTileAdapter, adapter); + visibilityBadge.init(); + } + // Story 2.1: NotificationBus — runs for all clients (GM and players) + notificationBus = new NotificationBus(adapter); + notificationBus.init(); + // Story 2.2: DirectorsBoard (lazy, GM only) + if (adapter.users.isGM()) { + directorsBoard = new DirectorsBoard(stateStore, scryingPoolController, adapter); + directorsBoard.init(); + } + } catch (err) { + console.error('[ScryingPool] Module initialization failed:', err); + throw err; // Re-throw to prevent module from loading in broken state + } }); diff --git a/src/ui/gm/DirectorsBoard.js b/src/ui/gm/DirectorsBoard.js new file mode 100644 index 0000000..fa0d09c --- /dev/null +++ b/src/ui/gm/DirectorsBoard.js @@ -0,0 +1,458 @@ +// @ts-nocheck +/* global Dialog */ +import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js'; +import { generateOpId } from '../../utils/uuid.js'; + +// Conditional base class — test environment lacks foundry globals. +// At module load time in tests, foundry is undefined → fallback class is used. + +/** @private */ +const _AppBase = + typeof foundry !== 'undefined' && + foundry.applications?.api?.HandlebarsApplicationMixin && + foundry.applications?.api?.ApplicationV2 + ? foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.api.ApplicationV2 + ) + : class _FallbackApp { + static DEFAULT_OPTIONS = {}; + static PARTS = {}; + get rendered() { return this._rendered ?? false; } + set rendered(v) { this._rendered = v; } + get element() { return this._element ?? null; } + async render() { this._rendered = true; } + async close() { this._rendered = false; } + async _prepareContext() { return {}; } + _onRender() {} + _onClose() {} + _onPosition() {} + }; + +/** + * Floating GM-only Director's Board window. + * Displays all connected participants as a seating-chart grid with per-participant + * visibility toggle. Extends ApplicationV2 via HandlebarsApplicationMixin. + */ +export class DirectorsBoard extends _AppBase { + static DEFAULT_OPTIONS = { + id: 'scrying-pool-directors-board', + classes: ['scrying-pool', 'directors-board'], + window: { title: "Director's Board", resizable: true }, + position: { width: 400, height: 300 }, + }; + + static PARTS = { + board: { + template: 'modules/video-view-manager/templates/directors-board.hbs', + }, + }; + + /** + * @param {object} stateStore + * @param {object} controller + * @param {object} adapter + * @param {object} [options] + */ + constructor(stateStore, controller, adapter, options = {}) { + super(options); + this._stateStore = stateStore; + this._controller = controller; + this._adapter = adapter; + this._hookId = null; + /** @type {Map|null} Pre-bulk-action snapshot for single-step undo */ + this._undoSnapshot = null; + /** @type {Map|null} Pre-spotlight snapshot for restore */ + this._spotlightSnapshot = null; + /** @type {string|null} Currently keyboard-focused participant userId */ + this._focusedUserId = null; + /** @type {Function|null} Click handler reference for cleanup */ + this._clickHandler = null; + /** @type {Function|null} Focusin handler reference for cleanup */ + this._focusinHandler = null; + /** @type {Function|null} Keydown handler reference for cleanup */ + this._keydownHandler = null; + + // Load saved position from user flags + this._loadPosition(); + } + + /** Loads saved window position from GM user flag. */ + _loadPosition() { + try { + const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState'); + if (saved?.open === true && saved.left != null && saved.top != null) { + this.options.position = { + left: saved.left, + top: saved.top, + width: saved.width ?? 400, + height: saved.height ?? 300, + }; + } + } catch (err) { + console.error('[ScryingPool] Failed to load directors board position:', err); + } + } + + /** Registers the stateChanged hook listener. Call once from module.js ready hook. */ + init() { + this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data)); + } + + /** Unregisters the stateChanged hook listener. */ + teardown() { + if (this._hookId !== null) { + Hooks.off('scrying-pool:stateChanged', this._hookId); + this._hookId = null; + } + } + + /** Opens the board if closed; closes it if open (singleton toggle behaviour). */ + async toggle() { + if (this.rendered) { + await this.close(); + } else { + await this.render({ force: true }); + } + } + + /** + * Sets all non-ghost participants to `active`. Stores pre-action snapshot for undo. + * FR-12: ghost participants excluded. + */ + showAll() { + this._executeBulk('active'); + } + + /** + * Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo. + * FR-12: ghost participants excluded. + */ + hideAll() { + this._executeBulk('hidden'); + } + + /** + * Internal bulk-action executor for showAll/hideAll. + * Captures a pre-action snapshot then dispatches per-participant actions. + * Uses single getState call per user to avoid race conditions. + * @param {'active'|'hidden'} targetState + * @private + */ + _executeBulk(targetState) { + const users = this._adapter.users.all(); + + // Get all user states in a single pass to avoid race conditions + const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); + + // Filter to non-ghost users and capture snapshot atomically + const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); + + // Capture pre-action snapshot (single-step undo) - use the states we already fetched + this._undoSnapshot = new Map(nonGhost.map(u => [u.id, userStates.get(u.id)])); + // Bulk supersedes spotlight restore + this._spotlightSnapshot = null; + + for (const u of nonGhost) { + if (this._controller.hasPendingOp?.(u.id)) continue; + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(u.id) ?? 0; + this._controller.action('board', u.id, targetState, opId, baseRevision); + } + if (this.rendered) this.render({ force: true }); + } + + /** + * Single-step undo: restores participants to their pre-bulk-action states. + * No-op if no snapshot exists. Ghost participants are skipped. + */ + undo() { + if (!this._undoSnapshot) return; + const snapshot = this._undoSnapshot; + this._undoSnapshot = null; + for (const [userId, targetState] of snapshot) { + // Check current state to avoid restoring ghost users that have transitioned + if (this._stateStore.getState(userId) === 'ghost') continue; + if (this._controller.hasPendingOp?.(userId)) continue; + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(userId) ?? 0; + this._controller.action('board', userId, targetState, opId, baseRevision); + } + if (this.rendered) this.render({ force: true }); + } + + /** + * Spotlights a single participant: sets them `active`, all others `hidden`. + * Captures a pre-spotlight snapshot and clears any undo snapshot. + * Ghost participants are excluded from all operations. + * @param {string} userId - The participant to spotlight + */ + spotlight(userId) { + // Guard: validate userId exists and is not null/undefined + if (!userId) return; + + const users = this._adapter.users.all(); + + // Get all user states in a single pass to avoid race conditions + const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); + + // Filter to non-ghost users + const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); + + // Check if the requested userId is valid (exists in non-ghost list) + const validUserIds = new Set(nonGhost.map(u => u.id)); + if (!validUserIds.has(userId)) { + console.warn(`[ScryingPool] spotlight: userId "${userId}" not found or is ghost`); + return; + } + + // Capture pre-spotlight snapshot for ALL users (including ghost for completeness) + this._spotlightSnapshot = new Map(users.map(u => [u.id, userStates.get(u.id)])); + this._undoSnapshot = null; + + for (const u of nonGhost) { + if (this._controller.hasPendingOp?.(u.id)) continue; + const targetState = u.id === userId ? 'active' : 'hidden'; + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(u.id) ?? 0; + this._controller.action('board', u.id, targetState, opId, baseRevision); + } + if (this.rendered) this.render({ force: true }); + } + + /** + * Restores participants to their pre-spotlight states. + * No-op if no spotlight snapshot exists. Ghost participants are skipped. + */ + restoreSpotlight() { + if (!this._spotlightSnapshot) return; + const snapshot = this._spotlightSnapshot; + this._spotlightSnapshot = null; + for (const [userId, targetState] of snapshot) { + // Check current state to avoid restoring ghost users that have transitioned + if (this._stateStore.getState(userId) === 'ghost') continue; + if (this._controller.hasPendingOp?.(userId)) continue; + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(userId) ?? 0; + this._controller.action('board', userId, targetState, opId, baseRevision); + } + if (this.rendered) this.render({ force: true }); + } + + /** + * Spotlights the currently focused participant (keyboard shortcut target). + * No-op if no participant is focused. + */ + spotlightFocused() { + if (!this._focusedUserId) return; + this.spotlight(this._focusedUserId); + } + + /** @inheritdoc */ + async _prepareContext() { + const base = buildBoardContext(this._stateStore, this._controller, this._adapter); + return { + ...base, + hasUndo: this._undoSnapshot !== null, + hasRestore: this._spotlightSnapshot !== null, + }; + } + + /** + * ApplicationV2 lifecycle — sets up event delegation on every render. + * Removes old listeners first to prevent memory leaks. + * @inheritdoc + */ + _onRender(context, options) { + super._onRender?.(context, options); + const root = this.element; + if (!root) return; + + // Remove old listeners if they exist (fixes memory leak and broken listeners after reopen) + if (this._clickHandler) { + root.removeEventListener('click', this._clickHandler); + } + if (this._focusinHandler) { + root.removeEventListener('focusin', this._focusinHandler); + } + if (this._keydownHandler) { + root.removeEventListener('keydown', this._keydownHandler); + } + + // Create new bound handlers + this._clickHandler = (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + e.stopPropagation(); + switch (btn.dataset.action) { + case 'toggle-participant': this._dispatchToggle(btn.dataset.userId); break; + case 'show-all': this.showAll(); break; + case 'hide-all': this.hideAll(); break; + case 'undo': this.undo(); break; + case 'restore-spotlight': this.restoreSpotlight(); break; + case 'open-shortcut-panel': this._openShortcutPanel(); break; + } + }; + this._focusinHandler = (e) => { + const card = e.target.closest('[data-user-id]'); + this._focusedUserId = card?.dataset?.userId ?? null; + }; + this._keydownHandler = (e) => this._onKeydown(e); + + // Add new listeners + root.addEventListener('click', this._clickHandler); + root.addEventListener('focusin', this._focusinHandler); + root.addEventListener('keydown', this._keydownHandler); + } + + /** + * Keyboard navigation within the participant grid. + * ArrowLeft/Right/Up/Down move focus; Space/Enter toggles the focused card. + * `?` opens the shortcut panel; Ctrl+Shift+P spotlights focused card. + * @param {KeyboardEvent} e + */ + _onKeydown(e) { + const cards = [...(this.element?.querySelectorAll('[data-user-id]') ?? [])]; + if (cards.length === 0) return; + const current = document.activeElement; + const idx = cards.indexOf(current); + + // Guard against negative index (focus from non-card element) + if (idx < 0) return; + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + cards[(idx + 1) % cards.length]?.focus(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + cards[(idx - 1 + cards.length) % cards.length]?.focus(); + } else if ((e.key === 'Enter' || e.key === ' ') && current?.dataset?.userId) { + e.preventDefault(); + this._dispatchToggle(current.dataset.userId); + } else if (e.key === '?') { + e.preventDefault(); + this._openShortcutPanel(); + } else if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { + e.preventDefault(); + this.spotlightFocused(); + } + } + + /** + * Dispatches a visibility toggle for a participant through the controller. + * Matches FR-1: always goes through controller.action(), never direct setState. + * @param {string} userId + */ + _dispatchToggle(userId) { + if (!userId) return; + if (this._controller.hasPendingOp?.(userId)) return; + const currentState = this._stateStore.getState(userId) ?? 'active'; + const targetState = resolveToggleTarget(currentState); + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(userId) ?? 0; + this._controller.action('board', userId, targetState, opId, baseRevision); + } + + /** + * Hook handler — re-renders the board when a participant state changes. + * @param {object} data + */ + _onStateChanged(data) { + // Suppress unused parameter warning - data is intentionally unused + void data; + if (this.rendered) { + this.render({ force: true }); + } + } + + /** + * Opens a Dialog showing all Director's Board keyboard shortcuts and their current bindings. + * Reads from game.keybindings.bindings when available, falling back to defaults. + */ + _openShortcutPanel() { + try { + const localize = (key) => game.i18n?.localize(key) ?? key; + + const getBinding = (actionKey) => { + // Check both namespaces due to migration from video-view-manager to scrying-pool + const namespaces = ['scrying-pool', 'video-view-manager']; + for (const ns of namespaces) { + const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`); + if (bindings?.[0]) { + const b = bindings[0]; + const mods = (b.modifiers ?? []).join('+'); + return mods ? `${mods}+${b.key}` : b.key; + } + } + return null; + }; + + const shortcuts = [ + { label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' }, + { label: localize('video-view-manager.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' }, + { label: localize('video-view-manager.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' }, + { label: localize('video-view-manager.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' }, + ]; + + const rows = shortcuts.map(s => `${s.label}${s.binding}`).join(''); + const content = `${rows}
`; + + if (typeof Dialog !== 'undefined') { + new Dialog({ + title: localize('video-view-manager.directorsBoard.shortcuts.title'), + content, + buttons: { close: { label: 'Close' } }, + default: 'close', + }).render(true); + } + } catch (err) { + console.error('[ScryingPool] Failed to open shortcut panel:', err); + } + } + + /** + * ApplicationV2 lifecycle — clean up event listeners when closed. + * @inheritdoc + */ + async _onClose(options) { + await super._onClose?.(options); + + // Clean up event listeners to prevent memory leaks + if (this._clickHandler) { + this.element?.removeEventListener('click', this._clickHandler); + this._clickHandler = null; + } + if (this._focusinHandler) { + this.element?.removeEventListener('focusin', this._focusinHandler); + this._focusinHandler = null; + } + if (this._keydownHandler) { + this.element?.removeEventListener('keydown', this._keydownHandler); + this._keydownHandler = null; + } + + this._savePosition({ open: false }); + } + + /** + * ApplicationV2 lifecycle — save window position when repositioned. + * @inheritdoc + */ + _onPosition(position) { + super._onPosition?.(position); + const { left, top, width, height } = position; + this._savePosition({ left, top, width, height, open: true }); + } + + /** + * Persists position/open state to GM user flag. + * @private + * @param {object} state + */ + _savePosition(state) { + try { + game.user?.setFlag('video-view-manager', 'directorsBoardState', state); + } catch (err) { + console.error('[ScryingPool] Failed to save directors board position:', err); + } + } +} diff --git a/src/ui/gm/ScryingPoolStrip.js b/src/ui/gm/ScryingPoolStrip.js new file mode 100644 index 0000000..ede01e2 --- /dev/null +++ b/src/ui/gm/ScryingPoolStrip.js @@ -0,0 +1,433 @@ +// @ts-nocheck +/* global Application, directorsBoard */ +import { generateOpId } from '../../utils/uuid.js'; + +/** + * Canonical action labels — never use inline string literals. + * @type {Readonly<{HIDE_FROM_TABLE: string, SHOW_TO_TABLE: string, FIRST_TOOLTIP: string}>} + */ +export const LABELS = Object.freeze({ + HIDE_FROM_TABLE: 'Hide from table', + SHOW_TO_TABLE: 'Show to table', + FIRST_TOOLTIP: 'Hide this participant from other players.', +}); + +/** + * Resolves the target state for a hide/show toggle action. + * @param {string} currentState + * @returns {'hidden'|'active'} + */ +export function resolveTargetState(currentState) { + return currentState === 'hidden' ? 'active' : 'hidden'; +} + +/** + * Builds a participant list array for getData(). + * @param {string[]} userIds + * @param {object} stateStore + * @param {object} controller + * @param {object} adapter + * @returns {Array} + */ +export function buildParticipantList(userIds, stateStore, controller, adapter) { + return userIds.map(userId => { + const user = adapter.users.get(userId) ?? { name: userId, avatar: null }; + const state = stateStore.getState(userId) ?? 'active'; + return { + userId, + name: user.name ?? userId, + avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg', + state, + stateLabel: _stateLabel(state), + hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false, + isCurrentUser: adapter.users.current?.()?.id === userId, + }; + }); +} + +/** + * Returns a human-readable label for a state string. + * @param {string} state + * @returns {string} + */ +function _stateLabel(state) { + const LABELS_MAP = { + active: 'Active', + hidden: 'Hidden', + 'self-muted': 'Self-muted', + offline: 'Offline', + 'cam-lost': 'Camera lost', + reconnecting: 'Reconnecting', + 'never-connected': 'Never connected', + ghost: 'Ghost', + }; + return LABELS_MAP[state] ?? state; +} + +// Use conditional base class for test compatibility (typeof check is no-undef safe) +const _AppBase = + typeof Application !== 'undefined' + ? Application + : class _FallbackApplication { + static get defaultOptions() { + return {}; + } + render() {} + close() {} + get rendered() { + return false; + } + }; + +/** + * GM-only floating control strip showing all connected participants. + * Extends Foundry's Application base class. + * Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14. + */ +export class ScryingPoolStrip extends _AppBase { + /** @inheritdoc */ + static get defaultOptions() { + const base = + typeof foundry !== 'undefined' && foundry.utils?.mergeObject + ? foundry.utils.mergeObject(super.defaultOptions, {}) + : super.defaultOptions ?? {}; + return Object.assign({}, base, { + id: 'scrying-pool-strip', + template: 'modules/video-view-manager/templates/roster-strip.hbs', + popOut: true, + resizable: false, + title: 'Scrying Pool', + classes: ['scrying-pool-strip'], + }); + } + + /** + * @param {object} stateStore + * @param {object} controller + * @param {object} avTileAdapter + * @param {object} adapter + * @param {object} [options] + */ + constructor(stateStore, controller, avTileAdapter, adapter, options = {}) { + super(options); + this._stateStore = stateStore; + this._controller = controller; + this._avTileAdapter = avTileAdapter; + this._adapter = adapter; + this._isExpanded = true; + /** @type {ActionPopover|null} */ + this._activePopover = null; + } + + /** @inheritdoc */ + getData() { + const savedState = + typeof game !== 'undefined' + ? game.user?.getFlag?.('video-view-manager', 'stripState') + : null; + if (savedState?.expanded !== undefined) { + this._isExpanded = savedState.expanded; + } + + const showFirstOpenTip = + typeof game !== 'undefined' && + !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); + + const userIds = this._adapter.users.all + ? this._adapter.users.all().map(u => u.id) + : []; + + const participants = buildParticipantList( + userIds, + this._stateStore, + this._controller, + this._adapter + ); + + return { + participants, + isExpanded: this._isExpanded, + isEmpty: participants.length === 0, + showFirstOpenTip, + }; + } + + /** @inheritdoc */ + activateListeners(html) { + super.activateListeners(html); + const el = html instanceof HTMLElement ? html : html[0]; + + el.querySelectorAll('[data-action="open-popover"]').forEach(btn => { + const userId = btn.dataset.userId; + btn.addEventListener('click', e => { + e.stopPropagation(); + this._openPopover(userId, btn); + }); + btn.addEventListener('contextmenu', e => { + e.preventDefault(); + e.stopPropagation(); + this._openContextMenu(userId, btn, e); + }); + }); + + const toggle = el.querySelector('[data-action="toggle-expanded"]'); + if (toggle) { + toggle.addEventListener('click', () => this._toggleExpanded()); + } + + // Director's Board CTA button (fallback for sidebar API) + const directorsBoardBtn = el.querySelector('[data-action="open-directors-board"]'); + if (directorsBoardBtn) { + directorsBoardBtn.addEventListener('click', () => { + if (typeof directorsBoard !== 'undefined' && directorsBoard) { + directorsBoard.toggle(); + } + }); + } + + // First open tip: set flag so it doesn't show again + const isFirstOpen = + typeof game !== 'undefined' && + !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); + if (isFirstOpen) { + game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true); + } + } + + /** @inheritdoc */ + async close(options = {}) { + if (this._activePopover) { + this._activePopover.close('superseded'); + this._activePopover = null; + } + if (typeof game !== 'undefined') { + game.user?.setFlag?.('video-view-manager', 'stripState', { + left: this.position?.left, + top: this.position?.top, + open: false, + expanded: this._isExpanded, + }); + } + return super.close(options); + } + + /** + * Toggles the expanded/collapsed state of the strip. + */ + _toggleExpanded() { + this._isExpanded = !this._isExpanded; + if (typeof game !== 'undefined') { + game.user?.setFlag?.('video-view-manager', 'stripState', { + left: this.position?.left, + top: this.position?.top, + open: true, + expanded: this._isExpanded, + }); + } + this.render(true); + } + + /** + * Opens an ActionPopover for the given participant. Supersedes any open popover. + * @param {string} participantId + * @param {HTMLElement} anchorEl + */ + _openPopover(participantId, anchorEl) { + if (this._activePopover) { + this._activePopover.close('superseded'); + } + const state = this._stateStore.getState(participantId); + const hasPending = this._controller.hasPendingOp?.(participantId) ?? false; + const popover = new ActionPopover( + participantId, + state, + anchorEl, + hasPending, + (pid, targetState) => this._dispatchAction(pid, targetState) + ); + popover.open(); + this._activePopover = popover; + anchorEl.setAttribute('aria-pressed', 'true'); + popover.onClose(() => { + anchorEl.setAttribute('aria-pressed', 'false'); + anchorEl.focus?.(); + if (this._activePopover === popover) this._activePopover = null; + }); + } + + /** + * Opens a native context menu for hide/show actions. + * @param {string} participantId + * @param {HTMLElement} anchorEl + * @param {MouseEvent} event + */ + _openContextMenu(participantId, anchorEl, event) { + const currentState = this._stateStore.getState(participantId); + const isHidden = currentState === 'hidden'; + const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE; + const iconClass = isHidden ? 'fas fa-eye' : 'fas fa-eye-slash'; + + const menu = document.createElement('div'); + menu.className = 'sp-context-menu'; + menu.setAttribute('role', 'menu'); + + const item = document.createElement('button'); + item.className = 'sp-context-menu__item'; + item.setAttribute('role', 'menuitem'); + item.innerHTML = ` ${label}`; + item.addEventListener('click', () => { + this._dispatchAction(participantId, isHidden ? 'active' : 'hidden'); + menu.remove(); + }); + + menu.appendChild(item); + document.body.appendChild(menu); + + menu.style.position = 'fixed'; + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + + const dismiss = e => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener('click', dismiss); + } + }; + setTimeout(() => document.addEventListener('click', dismiss), 0); + } + + /** + * Dispatches a visibility change action through the controller. + * @param {string} participantId + * @param {string} [explicitTargetState] - if omitted, toggle from current state + */ + _dispatchAction(participantId, explicitTargetState) { + const currentState = this._stateStore.getState(participantId); + const targetState = explicitTargetState ?? resolveTargetState(currentState); + const opId = generateOpId(); + const baseRevision = this._controller.getRevision?.(participantId) ?? 0; + this._controller.action('strip', participantId, targetState, opId, baseRevision); + } +} + +/** + * Native -based per-participant action popover. + * Not exported — internal to the gm/ layer. + */ +class ActionPopover { + /** + * @param {string} participantId + * @param {string} currentState + * @param {HTMLElement} anchorEl + * @param {boolean} hasPendingOp + * @param {function(string, string): void} onAction + */ + constructor(participantId, currentState, anchorEl, hasPendingOp, onAction) { + this._participantId = participantId; + this._currentState = currentState; + this._anchorEl = anchorEl; + this._hasPendingOp = hasPendingOp; + this._onAction = onAction; + this._onCloseCb = null; + this._dialog = this._build(); + } + + /** + * Builds the DOM element. + * @returns {HTMLDialogElement} + */ + _build() { + const isHidden = this._currentState === 'hidden'; + const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE; + + const dialog = document.createElement('dialog'); + dialog.className = 'sp-action-popover'; + dialog.setAttribute('aria-modal', 'true'); + + const cta = document.createElement('button'); + cta.className = 'sp-action-popover__cta'; + cta.dataset.action = 'primary-cta'; + cta.textContent = label; + + if (this._hasPendingOp) { + cta.disabled = true; + cta.setAttribute('aria-disabled', 'true'); + } + + // First-time tooltip (localStorage-based) + const hasSeenTooltip = typeof localStorage !== 'undefined' + ? localStorage.getItem('scrying-pool.firstHideTooltip') + : '1'; + if (!hasSeenTooltip) { + cta.title = LABELS.FIRST_TOOLTIP; + cta.addEventListener('mouseenter', () => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem('scrying-pool.firstHideTooltip', '1'); + } + }, { once: true }); + } + + cta.addEventListener('click', () => { + const targetState = isHidden ? 'active' : 'hidden'; + this._onAction(this._participantId, targetState); + this.close('action'); + }); + + dialog.appendChild(cta); + + dialog.addEventListener('cancel', e => { + e.preventDefault(); + this.close('escape'); + }); + + dialog.addEventListener('click', e => { + if (e.target === dialog) { + this.close('backdrop'); + } + }); + + return dialog; + } + + /** + * Opens the popover dialog, positions it near the anchor, and focuses the CTA. + */ + open() { + document.body.appendChild(this._dialog); + + const rect = this._anchorEl.getBoundingClientRect(); + this._dialog.style.position = 'fixed'; + this._dialog.style.left = `${rect.right + 4}px`; + this._dialog.style.top = `${rect.top}px`; + this._dialog.style.margin = '0'; + + if (typeof this._dialog.showModal === 'function') { + this._dialog.showModal(); + } + + const cta = this._dialog.querySelector('[data-action="primary-cta"]'); + cta?.focus?.(); + } + + /** + * Closes the popover dialog and invokes the onClose callback. + * @param {string} [reason] + */ + close(reason) { + if (this._dialog.parentNode) { + if (typeof this._dialog.close === 'function') { + this._dialog.close(reason ?? ''); + } + this._dialog.remove(); + } + this._onCloseCb?.(); + } + + /** + * Registers a callback for when the popover closes. + * @param {function(): void} cb + */ + onClose(cb) { + this._onCloseCb = cb; + } +} diff --git a/src/ui/shared/AVTileAdapter.js b/src/ui/shared/AVTileAdapter.js new file mode 100644 index 0000000..a36638a --- /dev/null +++ b/src/ui/shared/AVTileAdapter.js @@ -0,0 +1,111 @@ +/** + * Adapts the AVTileAdapter to manage overlays on FoundryVTT AV camera tiles. + * Mounts/unmounts managed child elements, applies state CSS classes, + * and observes tile re-renders so overlays survive DOM replacement. + */ +export class AVTileAdapter { + /** + * @param {object} adapter - Foundry adapter (injected dependency) + */ + constructor(adapter) { + this._adapter = adapter; + /** @type {Map} */ + this._observers = new Map(); + } + + /** + * Resolves the AV camera tile element for a given user ID. + * @param {string} userId + * @returns {HTMLElement|null} + */ + _getTile(userId) { + return ( + document.querySelector(`.camera-view[data-user-id="${userId}"]`) ?? + document.querySelector(`[data-user-id="${userId}"]`) + ); + } + + /** + * Mounts an overlay element onto the AV tile for the given user. + * If an element with the same data-sp-role already exists, it is replaced. + * The element is marked with data-sp-mount="1" for lifecycle tracking. + * @param {string} userId + * @param {HTMLElement} el - Must have dataset.spRole set + */ + mount(userId, el) { + const tile = this._getTile(userId); + if (!tile) { + console.warn('[ScryingPool] AVTileAdapter.mount: tile not found for', userId); + return; + } + const role = el.dataset.spRole; + if (role) { + const existing = tile.querySelector(`[data-sp-role="${role}"]`); + if (existing) existing.remove(); + } + el.dataset.spMount = '1'; + tile.appendChild(el); + } + + /** + * Removes all managed (data-sp-mount) children from the AV tile for the given user. + * @param {string} userId + */ + unmount(userId) { + const tile = this._getTile(userId); + if (!tile) return; + tile.querySelectorAll('[data-sp-mount]').forEach(el => el.remove()); + } + + /** + * Sets the current state CSS class on the AV tile. + * All previous sp-state-* classes are removed before the new one is applied. + * Pass null to clear without adding any state class. + * @param {string} userId + * @param {string|null} stateName + */ + setStateClass(userId, stateName) { + const tile = this._getTile(userId); + if (!tile) { + console.warn('[ScryingPool] AVTileAdapter.setStateClass: tile not found for', userId); + return; + } + const toRemove = Array.from(tile.classList).filter(c => c.startsWith('sp-state-')); + toRemove.forEach(c => tile.classList.remove(c)); + if (stateName) { + tile.classList.add(`sp-state-${stateName}`); + } + } + + /** + * Registers a callback invoked whenever the AV tile's children change. + * Replaces any previously registered observer for the same userId. + * Used to re-mount overlays after Foundry re-renders the tile DOM. + * @param {string} userId + * @param {function(HTMLElement): void} callback + */ + onTileRerender(userId, callback) { + const tile = this._getTile(userId); + if (!tile) return; + + const existing = this._observers.get(userId); + if (existing) { + existing.disconnect(); + } + + const observer = new MutationObserver(() => { + callback(tile); + }); + observer.observe(tile, { childList: true }); + this._observers.set(userId, observer); + } + + /** + * Disconnects all active MutationObservers and clears the observer map. + * Safe to call multiple times. + */ + disconnect() { + this._observers.forEach(obs => obs.disconnect()); + this._observers.clear(); + } +} diff --git a/src/utils/boardUtils.js b/src/utils/boardUtils.js new file mode 100644 index 0000000..047c340 --- /dev/null +++ b/src/utils/boardUtils.js @@ -0,0 +1,51 @@ +// @ts-nocheck +/** + * boardUtils.js + * Shared utility functions for participant board operations. + * Can be imported by both src/ui/ and other modules without violating import boundaries. + */ + +/** + * Resolves the target state for a visibility toggle action. + * active → hidden, hidden → active, ghost → ghost (no toggle) + * @param {string} currentState + * @returns {string} Target state + */ +export function resolveToggleTarget(currentState) { + if (currentState === 'ghost') return 'ghost'; + if (currentState === 'hidden') return 'active'; + return 'hidden'; +} + +/** + * Builds context for a single participant in the Director's Board. + * @param {object} stateStore + * @param {string} userId + * @returns {object} Participant context + */ +export function buildSimpleParticipantContext(stateStore, userId) { + const state = stateStore.getState(userId); + return { + userId, + state: state ?? 'active', + isGhost: state === 'ghost', + }; +} + +/** + * Builds context for the Director's Board grid. + * @param {object} stateStore + * @param {object} controller + * @param {object} adapter + * @returns {object} Board context + */ +export function buildBoardContext(stateStore, controller, adapter) { + try { + const users = adapter.users.all?.() ?? []; + const participants = users.map(u => buildSimpleParticipantContext(stateStore, u.id ?? u)); + return { participants, isEmpty: participants.length === 0 }; + } catch (err) { + console.error('[ScryingPool] buildBoardContext failed:', err); + return { participants: [], isEmpty: true }; + } +} diff --git a/styles/components/_directors-board.less b/styles/components/_directors-board.less index 08bc1ef..017fd1d 100644 --- a/styles/components/_directors-board.less +++ b/styles/components/_directors-board.less @@ -1,3 +1,116 @@ -// All selectors MUST be scoped under .scrying-pool. -// Use --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens allowed. -// Implemented in story 1.5+. +/** + * styles/components/_directors-board.less + * + * Layout for the Director's Board ApplicationV2 window. + * All selectors scoped under .scrying-pool. + * Uses --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens. + */ + +// The ApplicationV2 window root already has .scrying-pool .directors-board applied +// via DEFAULT_OPTIONS.classes. The content section and footer live inside PARTS. + +.scrying-pool.directors-board { + + // ── Participant grid ────────────────────────────────────────────────────── + .directors-board__content { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + padding: 12px; + overflow-y: auto; + flex: 1 1 auto; + list-style: none; + margin: 0; + } + + // ── Empty state ──────────────────────────────────────────────────────────── + .directors-board__empty { + grid-column: 1 / -1; + text-align: center; + color: var(--sp-text-muted); + font-size: 13px; + padding: 24px 0; + margin: 0; + } + + // ── Bulk-action bar ──────────────────────────────────────────────────────── + .directors-board__bulk-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-top: 1px solid var(--sp-border); + flex-shrink: 0; + flex-wrap: wrap; + } + + .directors-board__bulk-btn { + font-size: 12px; + background: var(--sp-accent, #4a6f9c); + color: #fff; + border: none; + border-radius: 3px; + padding: 4px 10px; + cursor: pointer; + transition: opacity 0.15s; + + &:hover { opacity: 0.85; } + &:active { opacity: 0.7; } + + // Undo — secondary style + &--undo { + background: transparent; + color: var(--sp-text-muted); + border: 1px solid var(--sp-border); + + &:hover { color: var(--sp-text, inherit); border-color: currentColor; } + } + + // Restore — spotlight accent (distinct from Undo) + &--restore { + background: var(--sp-spotlight-accent, #7b4fa6); + } + } + + // ── Help / shortcut-panel button ─────────────────────────────────────────── + .directors-board__help-btn { + margin-left: auto; + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--sp-border); + background: transparent; + color: var(--sp-text-muted); + font-size: 12px; + font-weight: bold; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:hover { background: var(--sp-accent, #4a6f9c); color: #fff; border-color: transparent; } + } + + // ── Footer (disabled preset actions) ───────────────────────────────────── + .directors-board__footer { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--sp-border); + flex-shrink: 0; + + button { + flex: 1; + font-size: 12px; + color: var(--sp-text-muted); + background: transparent; + border: 1px solid var(--sp-border); + border-radius: 3px; + padding: 4px 8px; + cursor: not-allowed; + opacity: 0.5; + } + } +} diff --git a/templates/directors-board.hbs b/templates/directors-board.hbs index 1e342cd..892e5cf 100644 --- a/templates/directors-board.hbs +++ b/templates/directors-board.hbs @@ -1,2 +1,45 @@ -{{!-- Directors Board - primary GM camera-management UI --}} -
+{{!-- Director's Board — GM camera-management overview window --}} +
+ + {{#unless isEmpty}} + {{#each participants}} + {{> "modules/video-view-manager/templates/participant-card.hbs"}} + {{/each}} + {{else}} +

+ {{localize "video-view-manager.directorsBoard.empty"}} +

+ {{/unless}} + +
+ +
+ + + {{#if hasUndo}} + + {{/if}} + {{#if hasRestore}} + + {{/if}} + +
+ +
+ + +
diff --git a/templates/participant-card.hbs b/templates/participant-card.hbs index c46efb1..585764a 100644 --- a/templates/participant-card.hbs +++ b/templates/participant-card.hbs @@ -1,2 +1,27 @@ -{{!-- Participant Card - single participant row in the Directors Board --}} -
+{{!-- Participant Card — single participant tile in the Director's Board --}} +
+ + {{!-- Avatar (48px rounded) --}} +
+ Avatar of {{name}} +
+ + {{!-- Name (12px, 2-line truncate) --}} +

{{name}}

+ + {{!-- Toggle overlay — revealed on hover; independently keyboard-focusable --}} + + +
diff --git a/tests/unit/ui/RoleRenderer.test.js b/tests/unit/ui/RoleRenderer.test.js new file mode 100644 index 0000000..4c697f3 --- /dev/null +++ b/tests/unit/ui/RoleRenderer.test.js @@ -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(); + }); + }); +}); diff --git a/tests/unit/ui/gm/DirectorsBoard.test.js b/tests/unit/ui/gm/DirectorsBoard.test.js new file mode 100644 index 0000000..684e27e --- /dev/null +++ b/tests/unit/ui/gm/DirectorsBoard.test.js @@ -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'); + }); + }); +}); diff --git a/tests/unit/ui/gm/ScryingPoolStrip.test.js b/tests/unit/ui/gm/ScryingPoolStrip.test.js new file mode 100644 index 0000000..3988612 --- /dev/null +++ b/tests/unit/ui/gm/ScryingPoolStrip.test.js @@ -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); + }); + }); +}); diff --git a/tests/unit/ui/player/VisibilityBadge.test.js b/tests/unit/ui/player/VisibilityBadge.test.js new file mode 100644 index 0000000..068fe63 --- /dev/null +++ b/tests/unit/ui/player/VisibilityBadge.test.js @@ -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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 = `
`; + 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 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(); + }); + }); +}); diff --git a/tests/unit/ui/shared/AVTileAdapter.test.js b/tests/unit/ui/shared/AVTileAdapter.test.js new file mode 100644 index 0000000..fc58a40 --- /dev/null +++ b/tests/unit/ui/shared/AVTileAdapter.test.js @@ -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 = ` +
+
+ `; + 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(); + }); + }); +});