Fix Story 2.3 code review findings: remove duplicate ParticipantCard.js, fix lint in ScryingPoolStrip.js
- Delete src/ui/shared/ParticipantCard.js (duplicate of boardUtils.js with conflicting implementations) - Delete tests/unit/ui/shared/ParticipantCard.test.js (tests for deleted file) - Add directorsBoard to global declarations in ScryingPoolStrip.js to fix lint errors Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
+79
-1
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,38 @@
|
|||||||
* Hooks.once('init') → register world settings → construct FoundryAdapter
|
* Hooks.once('init') → register world settings → construct FoundryAdapter
|
||||||
* → StateStore → SocketHandler (queue+drain)
|
* → StateStore → SocketHandler (queue+drain)
|
||||||
* Hooks.once('ready') → hydrate StateStore → probe WebRTC
|
* Hooks.once('ready') → hydrate StateStore → probe WebRTC
|
||||||
* → Story 1.4: VisibilityManager → SocketHandler.setReady()
|
* → VisibilityManager → SocketHandler.setReady()
|
||||||
* → NotificationBus → RoleRenderer → RosterStrip
|
* → ScryingPoolController
|
||||||
* → DirectorsBoard (lazy, GM only)
|
* → 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 { FoundryAdapter } from './src/foundry/FoundryAdapter.js';
|
||||||
import { StateStore } from './src/core/StateStore.js';
|
import { StateStore } from './src/core/StateStore.js';
|
||||||
import { SocketHandler } from './src/core/SocketHandler.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
|
// Module-level references — constructed in init hook, used across hooks
|
||||||
let adapter;
|
let adapter;
|
||||||
let stateStore;
|
let stateStore;
|
||||||
// eslint-disable-next-line no-unused-vars -- used in Story 1.4 (socketHandler.setReady)
|
|
||||||
let socketHandler;
|
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", () => {
|
Hooks.once("init", () => {
|
||||||
console.log("[ScryingPool] init — module loading");
|
console.log("[ScryingPool] init — module loading");
|
||||||
@@ -58,9 +76,74 @@ Hooks.once("init", () => {
|
|||||||
default: true,
|
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
|
// Construct data layer — constructors are side-effect-free
|
||||||
stateStore = new StateStore(adapter.settings);
|
stateStore = new StateStore(adapter.settings);
|
||||||
socketHandler = new SocketHandler(adapter.socket, adapter.hooks);
|
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", () => {
|
Hooks.once("ready", () => {
|
||||||
@@ -78,6 +161,47 @@ Hooks.once("ready", () => {
|
|||||||
console.error('[ScryingPool] Failed to set webrtcMode setting:', err);
|
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
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>|null} Pre-bulk-action snapshot for single-step undo */
|
||||||
|
this._undoSnapshot = null;
|
||||||
|
/** @type {Map<string, string>|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 => `<tr><td>${s.label}</td><td><kbd>${s.binding}</kbd></td></tr>`).join('');
|
||||||
|
const content = `<table class="directors-board__shortcuts-table"><tbody>${rows}</tbody></table>`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<object>}
|
||||||
|
*/
|
||||||
|
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 = `<i class="${iconClass}" aria-hidden="true"></i> ${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 <dialog>-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 <dialog> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, MutationObserver>} */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,116 @@
|
|||||||
// All selectors MUST be scoped under .scrying-pool.
|
/**
|
||||||
// Use --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens allowed.
|
* styles/components/_directors-board.less
|
||||||
// Implemented in story 1.5+.
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,45 @@
|
|||||||
{{!-- Directors Board - primary GM camera-management UI --}}
|
{{!-- Director's Board — GM camera-management overview window --}}
|
||||||
<div class="scrying-pool directors-board" role="region" aria-label="Directors Board" data-component="directors-board"></div>
|
<section class="scrying-pool directors-board__content"
|
||||||
|
role="list"
|
||||||
|
aria-label="{{localize "video-view-manager.directorsBoard.title"}}">
|
||||||
|
|
||||||
|
{{#unless isEmpty}}
|
||||||
|
{{#each participants}}
|
||||||
|
{{> "modules/video-view-manager/templates/participant-card.hbs"}}
|
||||||
|
{{/each}}
|
||||||
|
{{else}}
|
||||||
|
<p class="directors-board__empty" role="listitem">
|
||||||
|
{{localize "video-view-manager.directorsBoard.empty"}}
|
||||||
|
</p>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="directors-board__bulk-bar">
|
||||||
|
<button type="button" class="directors-board__bulk-btn" data-action="show-all">
|
||||||
|
{{localize "video-view-manager.directorsBoard.bulk.showAll"}}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="directors-board__bulk-btn" data-action="hide-all">
|
||||||
|
{{localize "video-view-manager.directorsBoard.bulk.hideAll"}}
|
||||||
|
</button>
|
||||||
|
{{#if hasUndo}}
|
||||||
|
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--undo" data-action="undo">
|
||||||
|
{{localize "video-view-manager.directorsBoard.bulk.undo"}}
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasRestore}}
|
||||||
|
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--restore" data-action="restore-spotlight">
|
||||||
|
{{localize "video-view-manager.directorsBoard.bulk.restore"}}
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
<button type="button" class="directors-board__help-btn" data-action="open-shortcut-panel" aria-label="{{localize "video-view-manager.directorsBoard.shortcuts.openPanel"}}">?</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="directors-board__footer">
|
||||||
|
<button type="button" disabled>
|
||||||
|
{{localize "video-view-manager.directorsBoard.footer.savePreset"}}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled>
|
||||||
|
{{localize "video-view-manager.directorsBoard.footer.loadPreset"}}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
|||||||
@@ -1,2 +1,27 @@
|
|||||||
{{!-- Participant Card - single participant row in the Directors Board --}}
|
{{!-- Participant Card — single participant tile in the Director's Board --}}
|
||||||
<div class="scrying-pool participant-card" role="region" aria-label="Participant Card" data-component="participant-card"></div>
|
<div class="scrying-pool participant-card sp-state-{{state}}{{#if hasPendingOp}} sp-state-pending{{/if}}"
|
||||||
|
role="listitem"
|
||||||
|
aria-label="{{cardAriaLabel}}"
|
||||||
|
data-user-id="{{userId}}"
|
||||||
|
tabindex="0">
|
||||||
|
|
||||||
|
{{!-- Avatar (48px rounded) --}}
|
||||||
|
<div class="participant-card__avatar">
|
||||||
|
<img src="{{avatarSrc}}" alt="Avatar of {{name}}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{!-- Name (12px, 2-line truncate) --}}
|
||||||
|
<p class="participant-card__name">{{name}}</p>
|
||||||
|
|
||||||
|
{{!-- Toggle overlay — revealed on hover; independently keyboard-focusable --}}
|
||||||
|
<button class="participant-card__toggle"
|
||||||
|
type="button"
|
||||||
|
data-action="toggle-participant"
|
||||||
|
data-user-id="{{userId}}"
|
||||||
|
role="button"
|
||||||
|
aria-label="{{toggleAriaLabel}}"
|
||||||
|
tabindex="-1">
|
||||||
|
<i class="fas {{#if isHidden}}fa-eye{{else}}fa-eye-slash{{/if}}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||||
|
|
||||||
|
// Mock ScryingPoolStrip before it's imported so Application global isn't needed
|
||||||
|
vi.mock('../../../src/ui/gm/ScryingPoolStrip.js', () => ({
|
||||||
|
ScryingPoolStrip: vi.fn().mockImplementation(() => ({
|
||||||
|
render: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn(),
|
||||||
|
rendered: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RoleRenderer } from '../../../src/ui/RoleRenderer.js';
|
||||||
|
|
||||||
|
function makeAVTileAdapter() {
|
||||||
|
return {
|
||||||
|
mount: vi.fn(),
|
||||||
|
unmount: vi.fn(),
|
||||||
|
setStateClass: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
onTileRerender: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStateStore() {
|
||||||
|
const states = new Map();
|
||||||
|
return {
|
||||||
|
getState: vi.fn(userId => states.get(userId) ?? 'active'),
|
||||||
|
_states: states,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController() {
|
||||||
|
return {
|
||||||
|
action: vi.fn(),
|
||||||
|
getRevision: vi.fn(() => 0),
|
||||||
|
hasPendingOp: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RoleRenderer', () => {
|
||||||
|
let adapter;
|
||||||
|
let avTileAdapter;
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let renderer;
|
||||||
|
let hooksStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hooksStub = { on: vi.fn(), off: vi.fn(), once: vi.fn(), callAll: vi.fn() };
|
||||||
|
vi.stubGlobal('Hooks', hooksStub);
|
||||||
|
vi.stubGlobal('game', { webrtc: {}, user: { setFlag: vi.fn(), getFlag: vi.fn(() => null) } });
|
||||||
|
|
||||||
|
adapter = createFoundryAdapterMock();
|
||||||
|
avTileAdapter = makeAVTileAdapter();
|
||||||
|
stateStore = makeStateStore();
|
||||||
|
controller = makeController();
|
||||||
|
renderer = new RoleRenderer(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('stores all injected dependencies without side effects', () => {
|
||||||
|
expect(renderer._stateStore).toBe(stateStore);
|
||||||
|
expect(renderer._controller).toBe(controller);
|
||||||
|
expect(renderer._avTileAdapter).toBe(avTileAdapter);
|
||||||
|
expect(renderer._adapter).toBe(adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register any Hooks in constructor', () => {
|
||||||
|
expect(hooksStub.on).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_strip is null before openStrip()', () => {
|
||||||
|
expect(renderer._strip).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('registers scrying-pool:stateChanged hook', () => {
|
||||||
|
renderer.init();
|
||||||
|
expect(hooksStub.on).toHaveBeenCalledWith(
|
||||||
|
'scrying-pool:stateChanged',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers scrying-pool:controllerAction hook', () => {
|
||||||
|
renderer.init();
|
||||||
|
expect(hooksStub.on).toHaveBeenCalledWith(
|
||||||
|
'scrying-pool:controllerAction',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers updateUser hook', () => {
|
||||||
|
renderer.init();
|
||||||
|
expect(hooksStub.on).toHaveBeenCalledWith('updateUser', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_applyAVTileState()', () => {
|
||||||
|
it('calls setStateClass on avTileAdapter with userId and state', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'active');
|
||||||
|
expect(avTileAdapter.setStateClass).toHaveBeenCalledWith('user-1', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts lock-overlay element when state is hidden', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'hidden');
|
||||||
|
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||||
|
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||||
|
expect(el.dataset.spRole).toBe('lock-overlay');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unmounts lock-overlay when state transitions away from hidden', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'active');
|
||||||
|
expect(avTileAdapter.unmount).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts portrait-fallback when state is never-connected', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'never-connected');
|
||||||
|
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||||
|
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||||
|
expect(el.dataset.spRole).toBe('portrait-fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts portrait-fallback when state is cam-lost', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'cam-lost');
|
||||||
|
expect(avTileAdapter.mount).toHaveBeenCalled();
|
||||||
|
const el = avTileAdapter.mount.mock.calls[0][1];
|
||||||
|
expect(el.dataset.spRole).toBe('portrait-fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unmounts portrait-fallback when state is not camera-absent', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'active');
|
||||||
|
expect(avTileAdapter.unmount).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mount any overlay for active state', () => {
|
||||||
|
renderer._applyAVTileState('user-1', 'active');
|
||||||
|
expect(avTileAdapter.mount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stateChanged hook handler', () => {
|
||||||
|
it('calls _applyAVTileState when scrying-pool:stateChanged fires', () => {
|
||||||
|
renderer.init();
|
||||||
|
const spy = vi.spyOn(renderer, '_applyAVTileState');
|
||||||
|
const handler = hooksStub.on.mock.calls.find(
|
||||||
|
c => c[0] === 'scrying-pool:stateChanged'
|
||||||
|
)[1];
|
||||||
|
handler({ userId: 'user-1', state: 'hidden' });
|
||||||
|
expect(spy).toHaveBeenCalledWith('user-1', 'hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles bulk matrix payload gracefully', () => {
|
||||||
|
renderer.init();
|
||||||
|
const handler = hooksStub.on.mock.calls.find(
|
||||||
|
c => c[0] === 'scrying-pool:stateChanged'
|
||||||
|
)[1];
|
||||||
|
// bulk payload has no userId
|
||||||
|
expect(() => handler({ matrix: {}, timestamp: Date.now(), revision: 1 })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openStrip()', () => {
|
||||||
|
it('constructs ScryingPoolStrip lazily on first call', async () => {
|
||||||
|
const { ScryingPoolStrip } = await import('../../../src/ui/gm/ScryingPoolStrip.js');
|
||||||
|
vi.clearAllMocks();
|
||||||
|
renderer.openStrip();
|
||||||
|
expect(ScryingPoolStrip).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses existing strip instance on second call', async () => {
|
||||||
|
const { ScryingPoolStrip } = await import('../../../src/ui/gm/ScryingPoolStrip.js');
|
||||||
|
vi.clearAllMocks();
|
||||||
|
renderer.openStrip();
|
||||||
|
renderer.openStrip();
|
||||||
|
expect(ScryingPoolStrip).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls render on the strip', () => {
|
||||||
|
renderer.openStrip();
|
||||||
|
expect(renderer._strip.render).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closeStrip()', () => {
|
||||||
|
it('calls close on the strip if it exists', () => {
|
||||||
|
renderer.openStrip();
|
||||||
|
const strip = renderer._strip;
|
||||||
|
renderer.closeStrip();
|
||||||
|
expect(strip.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is no-op if strip is not open', () => {
|
||||||
|
expect(() => renderer.closeStrip()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Stub foundry global for conditional base class — must NOT be present at module load time
|
||||||
|
// so the fallback class is used. The module is imported after this comment block.
|
||||||
|
// We only stub `foundry` in specific tests that need runtime foundry calls (none here).
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('Hooks', {
|
||||||
|
on: vi.fn(() => 99),
|
||||||
|
off: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('game', {
|
||||||
|
user: {
|
||||||
|
setFlag: vi.fn(),
|
||||||
|
getFlag: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DirectorsBoard } from '../../../../src/ui/gm/DirectorsBoard.js';
|
||||||
|
|
||||||
|
describe('DirectorsBoard', () => {
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let adapter;
|
||||||
|
let board;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateStore = { getState: vi.fn(() => 'active') };
|
||||||
|
controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false), getRevision: vi.fn(() => 0) };
|
||||||
|
adapter = {
|
||||||
|
users: {
|
||||||
|
get: vi.fn(() => ({ name: 'Alice', avatar: null })),
|
||||||
|
all: vi.fn(() => [{ id: 'u1' }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('is side-effect-free: does not call Hooks.on', () => {
|
||||||
|
expect(Hooks.on).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets _hookId to null initially', () => {
|
||||||
|
expect(board._hookId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores stateStore, controller, adapter references', () => {
|
||||||
|
expect(board._stateStore).toBe(stateStore);
|
||||||
|
expect(board._controller).toBe(controller);
|
||||||
|
expect(board._adapter).toBe(adapter);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('registers scrying-pool:stateChanged hook', () => {
|
||||||
|
board.init();
|
||||||
|
expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the returned hook id in _hookId', () => {
|
||||||
|
board.init();
|
||||||
|
expect(board._hookId).toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teardown()', () => {
|
||||||
|
it('calls Hooks.off with the stored hook id', () => {
|
||||||
|
board.init();
|
||||||
|
board.teardown();
|
||||||
|
expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', 99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets _hookId to null after teardown', () => {
|
||||||
|
board.init();
|
||||||
|
board.teardown();
|
||||||
|
expect(board._hookId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when init was not called', () => {
|
||||||
|
expect(() => board.teardown()).not.toThrow();
|
||||||
|
expect(Hooks.off).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_dispatchToggle()', () => {
|
||||||
|
it('calls controller.action with positional args (active→hidden)', () => {
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board._dispatchToggle('u1');
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls controller.action with targetState active (hidden→active)', () => {
|
||||||
|
stateStore.getState.mockReturnValue('hidden');
|
||||||
|
board._dispatchToggle('u2');
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch if userId is falsy', () => {
|
||||||
|
board._dispatchToggle(null);
|
||||||
|
board._dispatchToggle(undefined);
|
||||||
|
board._dispatchToggle('');
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch if controller reports pending op', () => {
|
||||||
|
controller.hasPendingOp.mockReturnValue(true);
|
||||||
|
board._dispatchToggle('u1');
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to active state when stateStore returns null', () => {
|
||||||
|
stateStore.getState.mockReturnValue(null);
|
||||||
|
board._dispatchToggle('u1');
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggle()', () => {
|
||||||
|
it('calls render({ force: true }) when not rendered', async () => {
|
||||||
|
board._rendered = false;
|
||||||
|
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||||
|
await board.toggle();
|
||||||
|
expect(renderSpy).toHaveBeenCalledWith({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls close() when rendered', async () => {
|
||||||
|
board._rendered = true;
|
||||||
|
const closeSpy = vi.spyOn(board, 'close').mockResolvedValue(undefined);
|
||||||
|
await board.toggle();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_onStateChanged()', () => {
|
||||||
|
it('calls render({ force: true }) when board is rendered', () => {
|
||||||
|
board._rendered = true;
|
||||||
|
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||||
|
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
|
||||||
|
expect(renderSpy).toHaveBeenCalledWith({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call render when board is not rendered', () => {
|
||||||
|
board._rendered = false;
|
||||||
|
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
|
||||||
|
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_prepareContext()', () => {
|
||||||
|
it('returns board context with participants from adapter', async () => {
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.participants).toHaveLength(1);
|
||||||
|
expect(ctx.participants[0].userId).toBe('u1');
|
||||||
|
expect(ctx.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isEmpty=true when adapter has no users', async () => {
|
||||||
|
adapter.users.all.mockReturnValue([]);
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasUndo=false when _undoSnapshot is null', async () => {
|
||||||
|
board._undoSnapshot = null;
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.hasUndo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasUndo=true when _undoSnapshot is set', async () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.hasUndo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasRestore=false when _spotlightSnapshot is null', async () => {
|
||||||
|
board._spotlightSnapshot = null;
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.hasRestore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasRestore=true when _spotlightSnapshot is set', async () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||||
|
const ctx = await board._prepareContext({});
|
||||||
|
expect(ctx.hasRestore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_OPTIONS', () => {
|
||||||
|
it('has correct id', () => {
|
||||||
|
expect(DirectorsBoard.DEFAULT_OPTIONS.id).toBe('scrying-pool-directors-board');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has classes including scrying-pool and directors-board', () => {
|
||||||
|
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('scrying-pool');
|
||||||
|
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('directors-board');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PARTS', () => {
|
||||||
|
it('has a board part with the correct template path', () => {
|
||||||
|
expect(DirectorsBoard.PARTS.board.template).toContain('directors-board.hbs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showAll()', () => {
|
||||||
|
it('calls controller.action with active for each non-ghost user', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'hidden');
|
||||||
|
board.showAll();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(2);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores pre-action snapshot in _undoSnapshot (non-ghost users only)', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
|
||||||
|
stateStore.getState.mockImplementation(id => {
|
||||||
|
if (id === 'u1') return 'hidden';
|
||||||
|
if (id === 'u2') return 'active';
|
||||||
|
return 'ghost';
|
||||||
|
});
|
||||||
|
board.showAll();
|
||||||
|
expect(board._undoSnapshot).toBeInstanceOf(Map);
|
||||||
|
expect(board._undoSnapshot.get('u1')).toBe('hidden');
|
||||||
|
expect(board._undoSnapshot.get('u2')).toBe('active');
|
||||||
|
expect(board._undoSnapshot.has('u3')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _spotlightSnapshot when called', () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||||
|
stateStore.getState.mockReturnValue('hidden');
|
||||||
|
board.showAll();
|
||||||
|
expect(board._spotlightSnapshot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips participants with pending ops', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
stateStore.getState.mockReturnValue('hidden');
|
||||||
|
controller.hasPendingOp.mockImplementation(id => id === 'u1');
|
||||||
|
board.showAll();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when all users are ghost', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||||
|
stateStore.getState.mockReturnValue('ghost');
|
||||||
|
board.showAll();
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hideAll()', () => {
|
||||||
|
it('calls controller.action with hidden for each non-ghost user', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.hideAll();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(2);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores pre-action snapshot in _undoSnapshot', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.hideAll();
|
||||||
|
expect(board._undoSnapshot).toBeInstanceOf(Map);
|
||||||
|
expect(board._undoSnapshot.get('u1')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _spotlightSnapshot when called', () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.hideAll();
|
||||||
|
expect(board._spotlightSnapshot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes ghost-state participants', () => {
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||||
|
board.hideAll();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo()', () => {
|
||||||
|
it('restores participants to snapshot states', () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'active']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.undo();
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _undoSnapshot after use (single-step only)', () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.undo();
|
||||||
|
expect(board._undoSnapshot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when _undoSnapshot is null', () => {
|
||||||
|
board._undoSnapshot = null;
|
||||||
|
board.undo();
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('second undo is unavailable after first (no-op)', () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.undo();
|
||||||
|
board.undo();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips ghost-state participants during undo', () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||||
|
board.undo();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips participants with pending ops during undo', () => {
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'hidden']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
controller.hasPendingOp.mockImplementation(id => id === 'u1');
|
||||||
|
board.undo();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DirectorsBoard spotlight', () => {
|
||||||
|
let stateStore, controller, adapter, board;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('Hooks', { on: vi.fn(() => 1), off: vi.fn(), once: vi.fn() });
|
||||||
|
vi.stubGlobal('game', { keybindings: { register: vi.fn() } });
|
||||||
|
stateStore = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
getAll: vi.fn(() => new Map()),
|
||||||
|
};
|
||||||
|
controller = {
|
||||||
|
action: vi.fn(),
|
||||||
|
hasPendingOp: vi.fn(() => false),
|
||||||
|
getRevision: vi.fn(() => 0),
|
||||||
|
};
|
||||||
|
adapter = {
|
||||||
|
users: { all: vi.fn(() => [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]) },
|
||||||
|
};
|
||||||
|
board = new DirectorsBoard(stateStore, controller, adapter);
|
||||||
|
board.rendered = false;
|
||||||
|
board.render = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe('spotlight(userId)', () => {
|
||||||
|
it('sets focusedId active, all others hidden, captures snapshot, clears undo', () => {
|
||||||
|
stateStore.getState.mockImplementation(id => ({ u1: 'hidden', u2: 'active', u3: 'active' }[id]));
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||||
|
board.spotlight('u1');
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u3', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores pre-spotlight snapshot in _spotlightSnapshot', () => {
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u1' ? 'active' : 'hidden');
|
||||||
|
board.spotlight('u1');
|
||||||
|
expect(board._spotlightSnapshot).toBeInstanceOf(Map);
|
||||||
|
expect(board._spotlightSnapshot.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _undoSnapshot when spotlight is called', () => {
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board._undoSnapshot = new Map([['u1', 'hidden']]);
|
||||||
|
board.spotlight('u2');
|
||||||
|
expect(board._undoSnapshot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes ghost participants from spotlight', () => {
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'active');
|
||||||
|
board.spotlight('u1');
|
||||||
|
const calls = controller.action.mock.calls.map(c => c[1]);
|
||||||
|
expect(calls).not.toContain('u3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreSpotlight()', () => {
|
||||||
|
it('restores participants to pre-spotlight snapshot states', () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'hidden'], ['u2', 'active'], ['u3', 'hidden']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.restoreSpotlight();
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears _spotlightSnapshot after restore', () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'active']]);
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board.restoreSpotlight();
|
||||||
|
expect(board._spotlightSnapshot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when _spotlightSnapshot is null', () => {
|
||||||
|
board._spotlightSnapshot = null;
|
||||||
|
board.restoreSpotlight();
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips ghost participants during restore', () => {
|
||||||
|
board._spotlightSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
|
||||||
|
board.restoreSpotlight();
|
||||||
|
expect(controller.action).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spotlightFocused()', () => {
|
||||||
|
it('is a no-op if no participant is focused', () => {
|
||||||
|
board._focusedUserId = null;
|
||||||
|
board.spotlightFocused();
|
||||||
|
expect(controller.action).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls spotlight() with the currently focused userId', () => {
|
||||||
|
stateStore.getState.mockReturnValue('active');
|
||||||
|
board._focusedUserId = 'u2';
|
||||||
|
const spy = vi.spyOn(board, 'spotlight');
|
||||||
|
board.spotlightFocused();
|
||||||
|
expect(spy).toHaveBeenCalledWith('u2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Stub Application globally before importing ScryingPoolStrip
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('Application', class {
|
||||||
|
static get defaultOptions() { return {}; }
|
||||||
|
constructor() { this.position = { left: 0, top: 0 }; this.rendered = false; }
|
||||||
|
render() {}
|
||||||
|
close() {}
|
||||||
|
activateListeners() {}
|
||||||
|
});
|
||||||
|
vi.stubGlobal('foundry', {
|
||||||
|
utils: {
|
||||||
|
mergeObject: (base, override) => Object.assign({}, base, override),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.stubGlobal('game', {
|
||||||
|
user: {
|
||||||
|
setFlag: vi.fn(),
|
||||||
|
getFlag: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
LABELS,
|
||||||
|
resolveTargetState,
|
||||||
|
buildParticipantList,
|
||||||
|
ScryingPoolStrip,
|
||||||
|
} from '../../../../src/ui/gm/ScryingPoolStrip.js';
|
||||||
|
|
||||||
|
describe('LABELS', () => {
|
||||||
|
it('has HIDE_FROM_TABLE equal to exact canonical string', () => {
|
||||||
|
expect(LABELS.HIDE_FROM_TABLE).toBe('Hide from table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has SHOW_TO_TABLE equal to exact canonical string', () => {
|
||||||
|
expect(LABELS.SHOW_TO_TABLE).toBe('Show to table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has FIRST_TOOLTIP set', () => {
|
||||||
|
expect(LABELS.FIRST_TOOLTIP).toBe('Hide this participant from other players.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is frozen (immutable)', () => {
|
||||||
|
expect(Object.isFrozen(LABELS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTargetState()', () => {
|
||||||
|
it('returns active when current state is hidden', () => {
|
||||||
|
expect(resolveTargetState('hidden')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hidden when current state is active', () => {
|
||||||
|
expect(resolveTargetState('active')).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hidden when current state is self-muted', () => {
|
||||||
|
expect(resolveTargetState('self-muted')).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hidden when current state is cam-lost', () => {
|
||||||
|
expect(resolveTargetState('cam-lost')).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hidden when current state is never-connected', () => {
|
||||||
|
expect(resolveTargetState('never-connected')).toBe('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildParticipantList()', () => {
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let adapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateStore = {
|
||||||
|
getState: vi.fn(userId => userId === 'user-1' ? 'active' : 'hidden'),
|
||||||
|
};
|
||||||
|
controller = {
|
||||||
|
hasPendingOp: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
adapter = {
|
||||||
|
users: {
|
||||||
|
get: vi.fn(userId => ({
|
||||||
|
id: userId,
|
||||||
|
name: `User ${userId}`,
|
||||||
|
avatar: `avatars/${userId}.png`,
|
||||||
|
})),
|
||||||
|
current: vi.fn(() => ({ id: 'user-1' })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct shape for each participant', () => {
|
||||||
|
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
expect(list[0]).toMatchObject({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'User user-1',
|
||||||
|
avatarSrc: 'avatars/user-1.png',
|
||||||
|
state: 'active',
|
||||||
|
stateLabel: 'Active',
|
||||||
|
hasPendingOp: false,
|
||||||
|
isCurrentUser: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isEmpty-compatible empty array for no userIds', () => {
|
||||||
|
const list = buildParticipantList([], stateStore, controller, adapter);
|
||||||
|
expect(list).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets hasPendingOp to true when controller reports pending', () => {
|
||||||
|
controller.hasPendingOp.mockReturnValue(true);
|
||||||
|
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
|
||||||
|
expect(list[0].hasPendingOp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses mystery-man.svg fallback when avatar is null', () => {
|
||||||
|
adapter.users.get.mockReturnValue({ id: 'user-1', name: 'Alice', avatar: null });
|
||||||
|
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
|
||||||
|
expect(list[0].avatarSrc).toBe('icons/svg/mystery-man.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only the current user as isCurrentUser', () => {
|
||||||
|
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
|
||||||
|
expect(list[0].isCurrentUser).toBe(true);
|
||||||
|
expect(list[1].isCurrentUser).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly maps hidden state label', () => {
|
||||||
|
const list = buildParticipantList(['user-2'], stateStore, controller, adapter);
|
||||||
|
expect(list[0].stateLabel).toBe('Hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ScryingPoolStrip', () => {
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let avTileAdapter;
|
||||||
|
let adapter;
|
||||||
|
let strip;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateStore = { getState: vi.fn(() => 'active') };
|
||||||
|
controller = { action: vi.fn(), getRevision: vi.fn(() => 0), hasPendingOp: vi.fn(() => false) };
|
||||||
|
avTileAdapter = { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), disconnect: vi.fn() };
|
||||||
|
adapter = {
|
||||||
|
users: {
|
||||||
|
get: vi.fn(() => ({ id: 'u1', name: 'Alice', avatar: 'av.png' })),
|
||||||
|
all: vi.fn(() => [{ id: 'u1' }]),
|
||||||
|
current: vi.fn(() => ({ id: 'u1' })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultOptions', () => {
|
||||||
|
it('has correct id', () => {
|
||||||
|
expect(ScryingPoolStrip.defaultOptions.id).toBe('scrying-pool-strip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct template path', () => {
|
||||||
|
expect(ScryingPoolStrip.defaultOptions.template).toContain('roster-strip.hbs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not resizable', () => {
|
||||||
|
expect(ScryingPoolStrip.defaultOptions.resizable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popOut is true', () => {
|
||||||
|
expect(ScryingPoolStrip.defaultOptions.popOut).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getData()', () => {
|
||||||
|
it('returns participants array', () => {
|
||||||
|
const data = strip.getData();
|
||||||
|
expect(Array.isArray(data.participants)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isExpanded property', () => {
|
||||||
|
const data = strip.getData();
|
||||||
|
expect(typeof data.isExpanded).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isEmpty true when no participants', () => {
|
||||||
|
adapter.users.all.mockReturnValue([]);
|
||||||
|
const data = strip.getData();
|
||||||
|
expect(data.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isEmpty false when participants exist', () => {
|
||||||
|
const data = strip.getData();
|
||||||
|
expect(data.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { VisibilityBadge } from '../../../../src/ui/player/VisibilityBadge.js';
|
||||||
|
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeAdapter({ userId = 'user-player', isGM = false, firstBadgeEncountered = false } = {}) {
|
||||||
|
const mockUser = {
|
||||||
|
id: userId,
|
||||||
|
getFlag: vi.fn().mockReturnValue(firstBadgeEncountered),
|
||||||
|
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
return createFoundryAdapterMock({
|
||||||
|
users: {
|
||||||
|
current: () => mockUser,
|
||||||
|
isGM: () => isGM,
|
||||||
|
get: () => mockUser,
|
||||||
|
all: () => [mockUser],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAVTileAdapter() {
|
||||||
|
return {
|
||||||
|
mount: vi.fn(),
|
||||||
|
unmount: vi.fn(),
|
||||||
|
setStateClass: vi.fn(),
|
||||||
|
onTileRerender: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStateStore(initialState = 'active') {
|
||||||
|
return {
|
||||||
|
getState: vi.fn().mockReturnValue(initialState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController() {
|
||||||
|
return {
|
||||||
|
hasPendingOp: vi.fn().mockReturnValue(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VisibilityBadge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VisibilityBadge', () => {
|
||||||
|
let adapter;
|
||||||
|
let avTileAdapter;
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let hookHandlers;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
hookHandlers = {};
|
||||||
|
vi.stubGlobal('Hooks', {
|
||||||
|
on: vi.fn((event, handler) => { hookHandlers[event] = handler; }),
|
||||||
|
once: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
callAll: vi.fn(),
|
||||||
|
});
|
||||||
|
adapter = makeAdapter();
|
||||||
|
avTileAdapter = makeAVTileAdapter();
|
||||||
|
stateStore = makeStateStore();
|
||||||
|
controller = makeController();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('stores deps without side effects', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
expect(badge._stateStore).toBe(stateStore);
|
||||||
|
expect(badge._controller).toBe(controller);
|
||||||
|
expect(badge._avTileAdapter).toBe(avTileAdapter);
|
||||||
|
expect(badge._adapter).toBe(adapter);
|
||||||
|
expect(Hooks.on).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('resolves currentUserId from adapter.users.current()', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
expect(badge._currentUserId).toBe('user-player');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes to scrying-pool:stateChanged hook', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts initial badge via avTileAdapter', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
expect(avTileAdapter.mount).toHaveBeenCalledWith('user-player', expect.any(HTMLElement));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers onTileRerender callback', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
expect(avTileAdapter.onTileRerender).toHaveBeenCalledWith('user-player', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops when no currentUserId (null user)', () => {
|
||||||
|
const noUserAdapter = createFoundryAdapterMock({
|
||||||
|
users: { current: () => null, isGM: () => false, get: () => null, all: () => [] },
|
||||||
|
});
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, noUserAdapter);
|
||||||
|
badge.init();
|
||||||
|
expect(badge._currentUserId).toBeNull();
|
||||||
|
expect(avTileAdapter.mount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_createBadgeElement()', () => {
|
||||||
|
it('creates element with correct class and data-sp-role', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const el = badge._createBadgeElement('hidden');
|
||||||
|
expect(el.className).toBe('sp-visibility-badge');
|
||||||
|
expect(el.dataset.spRole).toBe('visibility-badge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets role="status" and aria-live="polite"', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const el = badge._createBadgeElement('hidden');
|
||||||
|
expect(el.getAttribute('role')).toBe('status');
|
||||||
|
expect(el.getAttribute('aria-live')).toBe('polite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-label with state label', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const el = badge._createBadgeElement('hidden');
|
||||||
|
expect(el.getAttribute('aria-label')).toBe('Camera visibility: Hidden from table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-label to "Active" for active state', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const el = badge._createBadgeElement('active');
|
||||||
|
expect(el.getAttribute('aria-label')).toBe('Camera visibility: Active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all state labels correctly', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const cases = [
|
||||||
|
['hidden', 'Hidden from table'],
|
||||||
|
['self-muted', 'Camera paused'],
|
||||||
|
['offline', 'Not connected'],
|
||||||
|
['cam-lost', 'Camera unavailable'],
|
||||||
|
['reconnecting', 'Rejoining view'],
|
||||||
|
['never-connected', 'Not yet connected'],
|
||||||
|
['ghost', 'Leaving'],
|
||||||
|
];
|
||||||
|
for (const [state, expectedLabel] of cases) {
|
||||||
|
const el = badge._createBadgeElement(state);
|
||||||
|
const span = el.querySelector('.sp-visibility-badge__label');
|
||||||
|
expect(span?.textContent).toBe(expectedLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no label text for active state', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
const el = badge._createBadgeElement('active');
|
||||||
|
const span = el.querySelector('.sp-visibility-badge__label');
|
||||||
|
expect(span?.textContent ?? '').toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_onStateChanged()', () => {
|
||||||
|
it('ignores events for other users', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
avTileAdapter.mount.mockClear();
|
||||||
|
badge._onStateChanged({ userId: 'other-user', state: 'hidden' });
|
||||||
|
expect(avTileAdapter.mount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates badge element and re-mounts for own user', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
avTileAdapter.mount.mockClear();
|
||||||
|
badge._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
expect(avTileAdapter.mount).toHaveBeenCalledWith('user-player', expect.any(HTMLElement));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates badge aria-label on state change', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
badge._onStateChanged({ userId: 'user-player', state: 'offline' });
|
||||||
|
expect(badge._badgeEl.getAttribute('aria-label')).toBe('Camera visibility: Not connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers FirstEncounterPanel when firstBadgeEncountered is false', () => {
|
||||||
|
// firstBadgeEncountered = false (default makeAdapter)
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
badge._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
// Panel should be in the DOM
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trigger FirstEncounterPanel when already encountered', () => {
|
||||||
|
const encounteredAdapter = makeAdapter({ firstBadgeEncountered: true });
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, encounteredAdapter);
|
||||||
|
badge.init();
|
||||||
|
badge._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getFirstBadgeEncountered()', () => {
|
||||||
|
it('returns flag value from adapter user', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
expect(badge._getFirstBadgeEncountered()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when flag is set', () => {
|
||||||
|
const encAdapter = makeAdapter({ firstBadgeEncountered: true });
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, encAdapter);
|
||||||
|
badge.init();
|
||||||
|
expect(badge._getFirstBadgeEncountered()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_setFirstBadgeEncountered()', () => {
|
||||||
|
it('calls setFlag on current user', async () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
await badge._setFirstBadgeEncountered();
|
||||||
|
const mockUser = adapter.users.current();
|
||||||
|
expect(mockUser.setFlag).toHaveBeenCalledWith('video-view-manager', 'firstBadgeEncounter', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('badge click handler', () => {
|
||||||
|
it('opens VisibilityDetailsPanel on badge click', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog.sp-visibility-details-panel');
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teardown()', () => {
|
||||||
|
it('calls avTileAdapter.disconnect()', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
badge.teardown();
|
||||||
|
expect(avTileAdapter.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes from Hooks', () => {
|
||||||
|
const badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
badge.teardown();
|
||||||
|
expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FirstEncounterPanel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FirstEncounterPanel (via VisibilityBadge)', () => {
|
||||||
|
let adapter;
|
||||||
|
let avTileAdapter;
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let badge;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
vi.stubGlobal('Hooks', {
|
||||||
|
on: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
callAll: vi.fn(),
|
||||||
|
});
|
||||||
|
adapter = makeAdapter({ firstBadgeEncountered: false });
|
||||||
|
avTileAdapter = makeAVTileAdapter();
|
||||||
|
stateStore = makeStateStore();
|
||||||
|
controller = makeController();
|
||||||
|
badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
// Trigger a state change to show the panel
|
||||||
|
badge._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('show()', () => {
|
||||||
|
it('appends panel to DOM with role="dialog"', () => {
|
||||||
|
const panel = document.querySelector('.sp-first-encounter-panel');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
expect(panel.getAttribute('role')).toBe('dialog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-modal="false"', () => {
|
||||||
|
const panel = document.querySelector('.sp-first-encounter-panel');
|
||||||
|
expect(panel.getAttribute('aria-modal')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains a "Got it" button', () => {
|
||||||
|
const btn = document.querySelector('.sp-first-encounter-panel [data-action="got-it"]');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('10s auto-collapse timer', () => {
|
||||||
|
it('collapses panel after 10s and shows chip', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// Re-trigger with fake timers
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull();
|
||||||
|
vi.advanceTimersByTime(10_001); // fires collapse timer
|
||||||
|
vi.advanceTimersByTime(301); // fires 300ms transition timer
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).toBeNull();
|
||||||
|
expect(document.querySelector('.sp-visibility-chip')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses timer on mouseenter and resumes on mouseleave', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
const panel = document.querySelector('.sp-first-encounter-panel');
|
||||||
|
panel.dispatchEvent(new Event('mouseenter'));
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
// Should NOT have collapsed because timer was paused
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull();
|
||||||
|
panel.dispatchEvent(new Event('mouseleave'));
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses timer on focusin and resumes on focusout', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
const panel = document.querySelector('.sp-first-encounter-panel');
|
||||||
|
panel.dispatchEvent(new Event('focusin'));
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).not.toBeNull();
|
||||||
|
panel.dispatchEvent(new Event('focusout'));
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('"Got it" button', () => {
|
||||||
|
it('dismisses panel from DOM', async () => {
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
const panel = b._firstEncounterPanel;
|
||||||
|
// Directly call _onGotIt to avoid async click handler timing issues
|
||||||
|
await panel._onGotIt();
|
||||||
|
expect(document.querySelector('.sp-first-encounter-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setFirstBadgeEncountered', async () => {
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
const panel = b._firstEncounterPanel;
|
||||||
|
// Directly call _onGotIt to avoid async click handler timing issues
|
||||||
|
await panel._onGotIt();
|
||||||
|
const mockUser = adapter.users.current();
|
||||||
|
expect(mockUser.setFlag).toHaveBeenCalledWith('video-view-manager', 'firstBadgeEncounter', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears timer (no ghost timer after dismissal)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
const panel = b._firstEncounterPanel;
|
||||||
|
// Directly call _onGotIt to avoid async click handler timing issues
|
||||||
|
await panel._onGotIt();
|
||||||
|
// After dismissal, advancing time should NOT cause errors or chip to appear
|
||||||
|
expect(() => vi.advanceTimersByTime(15_000)).not.toThrow();
|
||||||
|
expect(document.querySelector('.sp-visibility-chip')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chip (collapsed state)', () => {
|
||||||
|
it('chip is focusable with role="button"', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
const chip = document.querySelector('.sp-visibility-chip');
|
||||||
|
expect(chip.getAttribute('role')).toBe('button');
|
||||||
|
expect(chip.getAttribute('tabindex')).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chip click opens VisibilityDetailsPanel', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
const chip = document.querySelector('.sp-visibility-chip');
|
||||||
|
chip.click();
|
||||||
|
expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chip Enter keydown opens VisibilityDetailsPanel', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
const chip = document.querySelector('.sp-visibility-chip');
|
||||||
|
chip.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chip Space keydown opens VisibilityDetailsPanel', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
const b = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._onStateChanged({ userId: 'user-player', state: 'hidden' });
|
||||||
|
vi.advanceTimersByTime(10_001);
|
||||||
|
vi.advanceTimersByTime(301);
|
||||||
|
const chip = document.querySelector('.sp-visibility-chip');
|
||||||
|
chip.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
||||||
|
expect(document.querySelector('dialog.sp-visibility-details-panel')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VisibilityDetailsPanel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VisibilityDetailsPanel (via VisibilityBadge)', () => {
|
||||||
|
let adapter;
|
||||||
|
let avTileAdapter;
|
||||||
|
let stateStore;
|
||||||
|
let controller;
|
||||||
|
let badge;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<div class="camera-view" data-user-id="user-player"></div>`;
|
||||||
|
vi.stubGlobal('Hooks', {
|
||||||
|
on: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
callAll: vi.fn(),
|
||||||
|
});
|
||||||
|
adapter = makeAdapter({ firstBadgeEncountered: true });
|
||||||
|
avTileAdapter = makeAVTileAdapter();
|
||||||
|
stateStore = makeStateStore('hidden');
|
||||||
|
controller = makeController();
|
||||||
|
badge = new VisibilityBadge(stateStore, controller, avTileAdapter, adapter);
|
||||||
|
badge.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
// Clean up any open dialogs
|
||||||
|
document.querySelectorAll('dialog').forEach(d => d.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('show()', () => {
|
||||||
|
it('creates a <dialog> element with correct class', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog.sp-visibility-details-panel');
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-modal="true"', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends dialog to document.body', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog.parentNode).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls showModal()', () => {
|
||||||
|
const showModalSpy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {});
|
||||||
|
badge._badgeEl.click();
|
||||||
|
expect(showModalSpy).toHaveBeenCalled();
|
||||||
|
showModalSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('content', () => {
|
||||||
|
it('contains a "Close" button', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const btn = document.querySelector('dialog [data-action="close-details"]');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows state explanation text', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog.textContent).toContain('Hidden from table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses audience list and shows reassurance when state is hidden', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog.textContent).toContain('Other players cannot see your feed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stale data indicator when controller is null', () => {
|
||||||
|
const b = new VisibilityBadge(stateStore, null, avTileAdapter, adapter);
|
||||||
|
b.init();
|
||||||
|
b._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
expect(dialog.textContent).toContain('Data may be outdated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('close handlers', () => {
|
||||||
|
it('"Close" button closes the dialog', () => {
|
||||||
|
const closeSpy = vi.spyOn(HTMLDialogElement.prototype, 'close').mockImplementation(function () {
|
||||||
|
this.dispatchEvent(new Event('close'));
|
||||||
|
});
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const btn = document.querySelector('[data-action="close-details"]');
|
||||||
|
btn.click();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
closeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backdrop click closes dialog', () => {
|
||||||
|
const closeSpy = vi.spyOn(HTMLDialogElement.prototype, 'close').mockImplementation(function () {
|
||||||
|
this.dispatchEvent(new Event('close'));
|
||||||
|
});
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
dialog.dispatchEvent(new MouseEvent('click', { bubbles: false }));
|
||||||
|
// event.target === dialog triggers close
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
closeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes dialog from DOM on close', () => {
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
dialog.dispatchEvent(new Event('close'));
|
||||||
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to trigger element on close', () => {
|
||||||
|
const focusSpy = vi.spyOn(badge._badgeEl, 'focus');
|
||||||
|
badge._badgeEl.click();
|
||||||
|
const dialog = document.querySelector('dialog');
|
||||||
|
dialog.dispatchEvent(new Event('close'));
|
||||||
|
expect(focusSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { AVTileAdapter } from '../../../../src/ui/shared/AVTileAdapter.js';
|
||||||
|
import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js';
|
||||||
|
|
||||||
|
describe('AVTileAdapter', () => {
|
||||||
|
let adapter;
|
||||||
|
let avAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="camera-view" data-user-id="user-1"></div>
|
||||||
|
<div class="camera-view" data-user-id="user-2"></div>
|
||||||
|
`;
|
||||||
|
adapter = createFoundryAdapterMock();
|
||||||
|
avAdapter = new AVTileAdapter(adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
avAdapter.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('stores adapter reference without side effects', () => {
|
||||||
|
expect(avAdapter._adapter).toBe(adapter);
|
||||||
|
expect(avAdapter._observers).toBeInstanceOf(Map);
|
||||||
|
expect(avAdapter._observers.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mount()', () => {
|
||||||
|
it('appends element to the matching AV tile', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.spRole = 'lock-overlay';
|
||||||
|
avAdapter.mount('user-1', el);
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.contains(el)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks mounted element with data-sp-mount attribute', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.spRole = 'lock-overlay';
|
||||||
|
avAdapter.mount('user-1', el);
|
||||||
|
expect(el.dataset.spMount).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and does not throw when tile not found', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const el = document.createElement('div');
|
||||||
|
expect(() => avAdapter.mount('unknown-user', el)).not.toThrow();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[ScryingPool] AVTileAdapter.mount: tile not found for',
|
||||||
|
'unknown-user'
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: calling twice with same element does not duplicate', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.spRole = 'lock-overlay';
|
||||||
|
avAdapter.mount('user-1', el);
|
||||||
|
avAdapter.mount('user-1', el);
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.querySelectorAll('[data-sp-role="lock-overlay"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces existing element with same data-sp-role', () => {
|
||||||
|
const el1 = document.createElement('div');
|
||||||
|
el1.dataset.spRole = 'lock-overlay';
|
||||||
|
el1.textContent = 'first';
|
||||||
|
avAdapter.mount('user-1', el1);
|
||||||
|
|
||||||
|
const el2 = document.createElement('div');
|
||||||
|
el2.dataset.spRole = 'lock-overlay';
|
||||||
|
el2.textContent = 'second';
|
||||||
|
avAdapter.mount('user-1', el2);
|
||||||
|
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
const overlays = tile.querySelectorAll('[data-sp-role="lock-overlay"]');
|
||||||
|
expect(overlays.length).toBe(1);
|
||||||
|
expect(overlays[0].textContent).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends multiple elements with different roles', () => {
|
||||||
|
const el1 = document.createElement('div');
|
||||||
|
el1.dataset.spRole = 'lock-overlay';
|
||||||
|
const el2 = document.createElement('div');
|
||||||
|
el2.dataset.spRole = 'portrait-fallback';
|
||||||
|
avAdapter.mount('user-1', el1);
|
||||||
|
avAdapter.mount('user-1', el2);
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.querySelectorAll('[data-sp-mount]').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect other tiles', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.spRole = 'lock-overlay';
|
||||||
|
avAdapter.mount('user-1', el);
|
||||||
|
const tile2 = document.querySelector('[data-user-id="user-2"]');
|
||||||
|
expect(tile2.querySelectorAll('[data-sp-mount]').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unmount()', () => {
|
||||||
|
it('removes all data-sp-mount children from tile', () => {
|
||||||
|
const el1 = document.createElement('div');
|
||||||
|
el1.dataset.spRole = 'lock-overlay';
|
||||||
|
const el2 = document.createElement('div');
|
||||||
|
el2.dataset.spRole = 'portrait-fallback';
|
||||||
|
avAdapter.mount('user-1', el1);
|
||||||
|
avAdapter.mount('user-1', el2);
|
||||||
|
avAdapter.unmount('user-1');
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.querySelectorAll('[data-sp-mount]').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not remove non-managed children', () => {
|
||||||
|
const native = document.createElement('video');
|
||||||
|
document.querySelector('[data-user-id="user-1"]').appendChild(native);
|
||||||
|
avAdapter.unmount('user-1');
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.contains(native)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is no-op if tile not found', () => {
|
||||||
|
expect(() => avAdapter.unmount('unknown-user')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect other tiles', () => {
|
||||||
|
const el1 = document.createElement('div');
|
||||||
|
el1.dataset.spRole = 'lock-overlay';
|
||||||
|
const el2 = document.createElement('div');
|
||||||
|
el2.dataset.spRole = 'lock-overlay';
|
||||||
|
avAdapter.mount('user-1', el1);
|
||||||
|
avAdapter.mount('user-2', el2);
|
||||||
|
avAdapter.unmount('user-1');
|
||||||
|
const tile2 = document.querySelector('[data-user-id="user-2"]');
|
||||||
|
expect(tile2.querySelectorAll('[data-sp-mount]').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setStateClass()', () => {
|
||||||
|
it('adds sp-state-{stateName} class to tile', () => {
|
||||||
|
avAdapter.setStateClass('user-1', 'hidden');
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
expect(tile.classList.contains('sp-state-hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes all previous sp-state-* classes before adding new', () => {
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
tile.classList.add('sp-state-active');
|
||||||
|
tile.classList.add('sp-state-pending');
|
||||||
|
avAdapter.setStateClass('user-1', 'hidden');
|
||||||
|
expect(tile.classList.contains('sp-state-active')).toBe(false);
|
||||||
|
expect(tile.classList.contains('sp-state-pending')).toBe(false);
|
||||||
|
expect(tile.classList.contains('sp-state-hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all sp-state-* classes when stateName is null', () => {
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
tile.classList.add('sp-state-active');
|
||||||
|
avAdapter.setStateClass('user-1', null);
|
||||||
|
expect(tile.classList.contains('sp-state-active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and does not throw when tile not found', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
expect(() => avAdapter.setStateClass('unknown-user', 'hidden')).not.toThrow();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[ScryingPool] AVTileAdapter.setStateClass: tile not found for',
|
||||||
|
'unknown-user'
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect non-sp-* classes on tile', () => {
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
tile.classList.add('camera-view');
|
||||||
|
tile.classList.add('some-other-class');
|
||||||
|
avAdapter.setStateClass('user-1', 'hidden');
|
||||||
|
expect(tile.classList.contains('camera-view')).toBe(true);
|
||||||
|
expect(tile.classList.contains('some-other-class')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onTileRerender()', () => {
|
||||||
|
it('calls callback when tile children change', async () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
avAdapter.onTileRerender('user-1', cb);
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
tile.appendChild(document.createElement('span'));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
expect(cb).toHaveBeenCalledWith(tile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces existing observer when called again for same userId', async () => {
|
||||||
|
const cb1 = vi.fn();
|
||||||
|
const cb2 = vi.fn();
|
||||||
|
avAdapter.onTileRerender('user-1', cb1);
|
||||||
|
avAdapter.onTileRerender('user-1', cb2);
|
||||||
|
// Only one observer should be active per userId
|
||||||
|
expect(avAdapter._observers.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores one observer per userId', () => {
|
||||||
|
avAdapter.onTileRerender('user-1', vi.fn());
|
||||||
|
avAdapter.onTileRerender('user-2', vi.fn());
|
||||||
|
expect(avAdapter._observers.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is no-op if tile not found', () => {
|
||||||
|
expect(() => avAdapter.onTileRerender('unknown-user', vi.fn())).not.toThrow();
|
||||||
|
expect(avAdapter._observers.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disconnect()', () => {
|
||||||
|
it('stops observers from firing after disconnect', async () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
avAdapter.onTileRerender('user-1', cb);
|
||||||
|
avAdapter.disconnect();
|
||||||
|
const tile = document.querySelector('[data-user-id="user-1"]');
|
||||||
|
tile.appendChild(document.createElement('span'));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears observer map', () => {
|
||||||
|
avAdapter.onTileRerender('user-1', vi.fn());
|
||||||
|
avAdapter.onTileRerender('user-2', vi.fn());
|
||||||
|
avAdapter.disconnect();
|
||||||
|
expect(avAdapter._observers.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is safe to call multiple times', () => {
|
||||||
|
avAdapter.onTileRerender('user-1', vi.fn());
|
||||||
|
expect(() => {
|
||||||
|
avAdapter.disconnect();
|
||||||
|
avAdapter.disconnect();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user