851 lines
33 KiB
Markdown
851 lines
33 KiB
Markdown
# Edge Case Hunter Review Prompt - Story 1-5 Group 1 (Core Logic)
|
|
|
|
**Story:** 1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration
|
|
**Group:** Core Logic (Group 1 of 4)
|
|
**Files:** ScryingPoolController.js, VisibilityManager.js, and their tests
|
|
**Diff lines:** 804
|
|
**Project path:** /home/morr/work/foundryvtt/video-view-manager
|
|
|
|
---
|
|
|
|
## YOUR ROLE: Edge Case Hunter
|
|
|
|
You are an **Edge Case Hunter** code reviewer. You receive the diff below AND read access to the project repository at `/home/morr/work/foundryvtt/video-view-manager`. Your job is to find edge cases, boundary conditions, and unusual scenarios that the code doesn't handle properly.
|
|
|
|
### Rules:
|
|
- You have read access to the project directory
|
|
- You have access to the diff below
|
|
- You have NO access to the spec file (blind to requirements)
|
|
- You MUST find at least 5 edge case issues
|
|
- Focus on: null/undefined handling, empty collections, concurrent access, extreme values, error paths, timing issues, state transitions
|
|
|
|
### Method:
|
|
1. Read the diff carefully
|
|
2. Explore the project codebase to understand the broader context
|
|
3. Walk every branching path in the code
|
|
4. Check boundary conditions for all inputs and loops
|
|
5. Identify unhandled edge cases
|
|
|
|
### Output Format:
|
|
Output findings as a Markdown list. Each finding:
|
|
```markdown
|
|
- **EC-XX: [One-line title]** — [Code location] — [Edge case scenario] — [Impact]
|
|
```
|
|
|
|
Classify by type:
|
|
- **EC-NULL**: Missing null/undefined checks
|
|
- **EC-BOUNDARY**: Off-by-one, empty collections, extreme values
|
|
- **EC-CONCURRENCY**: Race conditions, async issues
|
|
- **EC-STATE**: Invalid state transitions, inconsistent state
|
|
- **EC-ERROR**: Unhandled exceptions, error swallowing
|
|
- **EC-TIMING**: Timeout issues, ordering problems
|
|
|
|
---
|
|
|
|
## DIFF TO REVIEW
|
|
|
|
diff --git a/src/core/ScryingPoolController.js b/src/core/ScryingPoolController.js
|
|
new file mode 100644
|
|
index 0000000..fc013f0
|
|
--- /dev/null
|
|
+++ b/src/core/ScryingPoolController.js
|
|
@@ -0,0 +1,181 @@
|
|
+/**
|
|
+ * ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates.
|
|
+ *
|
|
+ * Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp
|
|
+ * lifecycle, optimistic setVisibility, socket emit, and echo reconciliation.
|
|
+ *
|
|
+ * Import rule: may only import from src/contracts/ and src/utils/.
|
|
+ * Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
|
+ *
|
|
+ * @module core/ScryingPoolController
|
|
+ */
|
|
+
|
|
+import { createPendingOp } from '../contracts/pending-op.js';
|
|
+import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js';
|
|
+
|
|
+/**
|
|
+ * Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation.
|
|
+ */
|
|
+export class ScryingPoolController {
|
|
+ /**
|
|
+ * @param {import('./StateStore.js').StateStore} stateStore
|
|
+ * @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler
|
|
+ * @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter
|
|
+ */
|
|
+ constructor(stateStore, socketHandler, adapter) {
|
|
+ this._stateStore = stateStore;
|
|
+ this._socketHandler = socketHandler;
|
|
+ this._adapter = adapter;
|
|
+ /** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} participantId → PendingOp */
|
|
+ this._pendingOps = new Map();
|
|
+ /** @type {Map<string, number>} participantId → last-confirmed revision */
|
|
+ this._revisions = new Map();
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Registers the socket echo listener.
|
|
+ * Called from module.js Hooks.once('ready') — NOT from constructor.
|
|
+ */
|
|
+ init() {
|
|
+ this._adapter.socket.on(
|
|
+ SOCKET_EVENTS.VISIBILITY_UPDATED,
|
|
+ (payload) => this._onEcho(/** @type {any} */ (payload))
|
|
+ );
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Returns the last confirmed revision for a participant (0 if unknown).
|
|
+ * @param {string} participantId
|
|
+ * @returns {number}
|
|
+ */
|
|
+ getRevision(participantId) {
|
|
+ return this._revisions.get(participantId) ?? 0;
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Returns true if a pending op is currently in-flight for the given participant.
|
|
+ * @param {string} participantId
|
|
+ * @returns {boolean}
|
|
+ */
|
|
+ hasPendingOp(participantId) {
|
|
+ return this._pendingOps.has(participantId);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Cleans up a pending operation by userId.
|
|
+ * Called by SocketHandler timeout callback via composite handler in module.js.
|
|
+ * @param {string} userId
|
|
+ */
|
|
+ cleanupPendingOp(userId) {
|
|
+ this._pendingOps.delete(userId);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Processes a GM visibility toggle request.
|
|
+ * Guards: isGM, latest-revision-wins, last-intent (idempotent).
|
|
+ *
|
|
+ * @param {string} source - Who triggered the action (e.g. 'ui', 'preset').
|
|
+ * @param {string} participantId - Target userId.
|
|
+ * @param {string} targetState - Desired VisibilityState.
|
|
+ * @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI).
|
|
+ * @param {number} baseRevision - StateStore revision at time of intent.
|
|
+ */
|
|
+ action(source, participantId, targetState, opId, baseRevision) {
|
|
+ // 0. Input validation
|
|
+ if (!participantId || typeof participantId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId');
|
|
+ return;
|
|
+ }
|
|
+ if (!targetState || typeof targetState !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState');
|
|
+ return;
|
|
+ }
|
|
+ if (!opId || typeof opId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId');
|
|
+ return;
|
|
+ }
|
|
+ if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ // 1. Authorization
|
|
+ if (!this._adapter.users.isGM()) {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ // 2. Latest-revision-wins guard
|
|
+ const currentRevision = this._revisions.get(participantId) ?? 0;
|
|
+ if (baseRevision < currentRevision) return;
|
|
+
|
|
+ // 3. Last-intent guard (idempotent)
|
|
+ const currentState = this._stateStore.getState(participantId);
|
|
+ if (currentState === targetState) return;
|
|
+
|
|
+ // 4. Register PendingOp
|
|
+ const previousState = currentState ?? 'never-connected';
|
|
+ const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
|
|
+ this._pendingOps.set(participantId, pendingOp);
|
|
+
|
|
+ // 5. Optimistic state update
|
|
+ this._stateStore.setVisibility(participantId, targetState);
|
|
+
|
|
+ // 6. Socket emit
|
|
+ const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
|
|
+ this._socketHandler.emit(msg.event, msg.payload);
|
|
+
|
|
+ // 7. Start acknowledgement timer
|
|
+ this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
|
|
+
|
|
+ // 8. Notify UI subscribers
|
|
+ try {
|
|
+ this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId });
|
|
+ } catch (hookErr) {
|
|
+ console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Processes an authoritative echo from the socket server.
|
|
+ * Confirms the pending op, updates revision, and sets the authoritative state.
|
|
+ * @private
|
|
+ * @param {{ opId: string, userId: string, state: string, revision?: number }} payload
|
|
+ */
|
|
+ _onEcho(payload) {
|
|
+ // Validate payload fields
|
|
+ if (!payload || typeof payload !== 'object') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload');
|
|
+ return;
|
|
+ }
|
|
+ const { opId, userId, state, revision } = payload;
|
|
+ if (!opId || typeof opId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId');
|
|
+ return;
|
|
+ }
|
|
+ if (!userId || typeof userId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId');
|
|
+ return;
|
|
+ }
|
|
+ if (!state || typeof state !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ this._socketHandler.confirmPendingOp(opId);
|
|
+ this._revisions.set(userId, revision ?? 0);
|
|
+ this._pendingOps.delete(userId);
|
|
+ this._stateStore.setVisibility(userId, state);
|
|
+
|
|
+ try {
|
|
+ this._adapter.hooks.callAll('scrying-pool:controllerAction', {
|
|
+ participantId: userId,
|
|
+ targetState: state,
|
|
+ source: 'echo',
|
|
+ opId,
|
|
+ });
|
|
+ } catch (hookErr) {
|
|
+ console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr);
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/src/core/VisibilityManager.js b/src/core/VisibilityManager.js
|
|
new file mode 100644
|
|
index 0000000..0e465f2
|
|
--- /dev/null
|
|
+++ b/src/core/VisibilityManager.js
|
|
@@ -0,0 +1,104 @@
|
|
+/**
|
|
+ * VisibilityManager — WebRTC strategy applier and SocketHandler revert handler.
|
|
+ *
|
|
+ * Listens to `scrying-pool:stateChanged` hook events (emitted by StateStore) and
|
|
+ * applies the appropriate webrtcMode strategy:
|
|
+ * - 'track-disable' + non-null adapter.webrtc → call disableTrack / enableTrack
|
|
+ * - 'css-fallback' / 'unsupported' / null webrtc → no-op (CSS handled by RoleRenderer)
|
|
+ *
|
|
+ * Also implements onRevert(pendingOp) for SocketHandler timeout callbacks.
|
|
+ *
|
|
+ * Import rule: may only import from src/contracts/ and src/utils/.
|
|
+ * Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
|
+ *
|
|
+ * @module core/VisibilityManager
|
|
+ */
|
|
+
|
|
+/**
|
|
+ * Applies webrtcMode strategy on state changes and reverts failed operations.
|
|
+ */
|
|
+export class VisibilityManager {
|
|
+ /**
|
|
+ * @param {import('./StateStore.js').StateStore} stateStore
|
|
+ * @param {{ settings: { get(key: string): unknown }, webrtc: { disableTrack(userId: string): void, enableTrack(userId: string): void } | null, notifications: { warn(msg: string): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void } }} adapter
|
|
+ */
|
|
+ constructor(stateStore, adapter) {
|
|
+ this._stateStore = stateStore;
|
|
+ this._adapter = adapter;
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Registers the Hooks.on('scrying-pool:stateChanged') listener.
|
|
+ * Called from module.js Hooks.once('ready') — NOT from constructor.
|
|
+ */
|
|
+ init() {
|
|
+ this._adapter.hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(/** @type {any} */ (data)));
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Handles a state change by applying the webrtcMode strategy.
|
|
+ * css-fallback / unsupported → no-op (CSS applied by RoleRenderer in Story 1.5).
|
|
+ * track-disable + non-null webrtc → disable/enable the participant's track.
|
|
+ * Always safe with null adapter.webrtc (OQ-1 spike result for v14).
|
|
+ *
|
|
+ * @private
|
|
+ * @param {{ userId: string, state: string }} data
|
|
+ */
|
|
+ _onStateChanged(data) {
|
|
+ const { userId, state } = data;
|
|
+ // Input validation
|
|
+ if (!userId || typeof userId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid userId');
|
|
+ return;
|
|
+ }
|
|
+ if (!state || typeof state !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid state');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ const mode = this._adapter.settings.get('webrtcMode');
|
|
+ if (mode !== 'track-disable' || !this._adapter.webrtc) return;
|
|
+ if (state === 'hidden') {
|
|
+ this._adapter.webrtc.disableTrack(userId);
|
|
+ } else {
|
|
+ this._adapter.webrtc.enableTrack(userId);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * Called by SocketHandler after retry exhaustion — reverts the optimistic state
|
|
+ * and notifies the GM that the operation could not be confirmed.
|
|
+ *
|
|
+ * @param {{ userId: string, previousState: string, opId: string }} pendingOp
|
|
+ */
|
|
+ onRevert(pendingOp) {
|
|
+ // Input validation
|
|
+ if (!pendingOp || typeof pendingOp !== 'object') {
|
|
+ console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid pendingOp');
|
|
+ return;
|
|
+ }
|
|
+ const { userId, previousState } = pendingOp;
|
|
+ if (!userId || typeof userId !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid userId in pendingOp');
|
|
+ return;
|
|
+ }
|
|
+ if (!previousState || typeof previousState !== 'string') {
|
|
+ console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid previousState in pendingOp');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ try {
|
|
+ this._stateStore.setVisibility(userId, previousState);
|
|
+ } catch (err) {
|
|
+ console.error('[ScryingPool] VisibilityManager.onRevert: setVisibility failed', err);
|
|
+ }
|
|
+
|
|
+ try {
|
|
+ this._adapter.notifications.warn(
|
|
+ `[ScryingPool] Visibility change for ${userId} could not be confirmed — reverting to ${previousState}`
|
|
+ );
|
|
+ } catch (err) {
|
|
+ console.error('[ScryingPool] VisibilityManager.onRevert: notification failed', err);
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/tests/unit/core/ScryingPoolController.test.js b/tests/unit/core/ScryingPoolController.test.js
|
|
new file mode 100644
|
|
index 0000000..eb4f4ad
|
|
--- /dev/null
|
|
+++ b/tests/unit/core/ScryingPoolController.test.js
|
|
@@ -0,0 +1,277 @@
|
|
+// @ts-nocheck
|
|
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
+import { ScryingPoolController } from '../../../src/core/ScryingPoolController.js';
|
|
+import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
|
+import { StateStore } from '../../../src/core/StateStore.js';
|
|
+
|
|
+/** @returns {{ emit: Function, registerPendingOp: Function, confirmPendingOp: Function, setReady: Function }} */
|
|
+function makeSocketHandler() {
|
|
+ return {
|
|
+ emit: vi.fn(),
|
|
+ registerPendingOp: vi.fn(),
|
|
+ confirmPendingOp: vi.fn(),
|
|
+ setReady: vi.fn(),
|
|
+ };
|
|
+}
|
|
+
|
|
+/** @returns {StateStore} */
|
|
+function makeStateStore() {
|
|
+ const settingsMock = {
|
|
+ get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
|
|
+ set: vi.fn().mockResolvedValue(undefined),
|
|
+ register: vi.fn(),
|
|
+ };
|
|
+ return new StateStore(settingsMock);
|
|
+}
|
|
+
|
|
+describe('ScryingPoolController', () => {
|
|
+ let adapter;
|
|
+ let stateStore;
|
|
+ let socketHandler;
|
|
+ let controller;
|
|
+ let hooksStub;
|
|
+
|
|
+ beforeEach(() => {
|
|
+ hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
|
+ vi.stubGlobal('Hooks', hooksStub);
|
|
+
|
|
+ adapter = createFoundryAdapterMock({
|
|
+ users: { isGM: () => true },
|
|
+ hooks: hooksStub
|
|
+ });
|
|
+ adapter.socket.on = vi.fn();
|
|
+
|
|
+ stateStore = makeStateStore();
|
|
+ socketHandler = makeSocketHandler();
|
|
+ controller = new ScryingPoolController(stateStore, socketHandler, adapter);
|
|
+ });
|
|
+
|
|
+ afterEach(() => {
|
|
+ vi.unstubAllGlobals();
|
|
+ vi.clearAllMocks();
|
|
+ });
|
|
+
|
|
+ // ── AC-1: Construction ────────────────────────────────────────────────────
|
|
+
|
|
+ describe('constructor (AC-1)', () => {
|
|
+ it('initialises _pendingOps as an empty Map', () => {
|
|
+ expect(controller._pendingOps).toBeInstanceOf(Map);
|
|
+ expect(controller._pendingOps.size).toBe(0);
|
|
+ });
|
|
+
|
|
+ it('initialises _revisions as an empty Map', () => {
|
|
+ expect(controller._revisions).toBeInstanceOf(Map);
|
|
+ expect(controller._revisions.size).toBe(0);
|
|
+ });
|
|
+
|
|
+ it('does NOT register socket listener in constructor (side-effect free)', () => {
|
|
+ expect(adapter.socket.on).not.toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-1: init() ─────────────────────────────────────────────────────────
|
|
+
|
|
+ describe('init() (AC-1)', () => {
|
|
+ it('registers socket echo listener for scrying-pool.visibility.updated', () => {
|
|
+ controller.init();
|
|
+ expect(adapter.socket.on).toHaveBeenCalledWith(
|
|
+ 'scrying-pool.visibility.updated',
|
|
+ expect.any(Function)
|
|
+ );
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-2: action() happy path ─────────────────────────────────────────────
|
|
+
|
|
+ describe('action() happy path (AC-2)', () => {
|
|
+ it('stores a PendingOp in _pendingOps keyed by participantId', () => {
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(controller._pendingOps.has('user-1')).toBe(true);
|
|
+ expect(controller._pendingOps.get('user-1')).toMatchObject({
|
|
+ opId: 'op-1',
|
|
+ userId: 'user-1',
|
|
+ targetState: 'hidden',
|
|
+ });
|
|
+ });
|
|
+
|
|
+ it('calls stateStore.setVisibility with the target state (optimistic update)', () => {
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
|
+ });
|
|
+
|
|
+ it('calls socketHandler.emit with VISIBILITY_SET event and correct payload', () => {
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(socketHandler.emit).toHaveBeenCalledWith(
|
|
+ 'scrying-pool.visibility.set',
|
|
+ expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0 })
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('calls socketHandler.registerPendingOp with the PendingOp, event, and payload', () => {
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
|
|
+ expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
|
|
+ 'scrying-pool.visibility.set',
|
|
+ expect.objectContaining({ opId: 'op-1' })
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('fires Hooks.callAll scrying-pool:controllerAction with correct payload', () => {
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(hooksStub.callAll).toHaveBeenCalledWith(
|
|
+ 'scrying-pool:controllerAction',
|
|
+ expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'ui', opId: 'op-1' })
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('sets previousState to null-coalesced "never-connected" when participant is new', () => {
|
|
+ controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
|
|
+ const op = controller._pendingOps.get('new-user');
|
|
+ expect(op.previousState).toBe('never-connected');
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-5: non-GM authorization ────────────────────────────────────────────
|
|
+
|
|
+ describe('action() non-GM authorization (AC-5)', () => {
|
|
+ it('warns and silently drops the action when adapter.users.isGM() is false', () => {
|
|
+ const nonGmAdapter = createFoundryAdapterMock({
|
|
+ users: { isGM: () => false },
|
|
+ hooks: hooksStub
|
|
+ });
|
|
+ nonGmAdapter.socket.on = vi.fn();
|
|
+ const playerController = new ScryingPoolController(stateStore, socketHandler, nonGmAdapter);
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
+
|
|
+ playerController.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+
|
|
+ expect(warnSpy).toHaveBeenCalledWith('[ScryingPool]', expect.stringContaining('non-GM'));
|
|
+ expect(setSpy).not.toHaveBeenCalled();
|
|
+ expect(socketHandler.emit).not.toHaveBeenCalled();
|
|
+ expect(socketHandler.registerPendingOp).not.toHaveBeenCalled();
|
|
+ expect(hooksStub.callAll).not.toHaveBeenCalled();
|
|
+
|
|
+ warnSpy.mockRestore();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-3: latest-revision-wins guard ─────────────────────────────────────
|
|
+
|
|
+ describe('action() latest-revision-wins guard (AC-3)', () => {
|
|
+ it('silently drops action when baseRevision < confirmed revision', () => {
|
|
+ controller._revisions.set('user-1', 5);
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-2', 3); // 3 < 5 → stale
|
|
+
|
|
+ expect(setSpy).not.toHaveBeenCalled();
|
|
+ expect(socketHandler.emit).not.toHaveBeenCalled();
|
|
+ expect(hooksStub.callAll).not.toHaveBeenCalled();
|
|
+ });
|
|
+
|
|
+ it('allows action when baseRevision equals confirmed revision (not stale)', () => {
|
|
+ controller._revisions.set('user-1', 5);
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-2', 5); // 5 == 5 → not stale
|
|
+
|
|
+ expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
|
+ });
|
|
+
|
|
+ it('allows action with baseRevision=0 when no revision confirmed yet', () => {
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+
|
|
+ expect(setSpy).toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-4: last-intent guard ───────────────────────────────────────────────
|
|
+
|
|
+ describe('action() last-intent guard (AC-4)', () => {
|
|
+ it('silently drops action when participant is already in targetState', () => {
|
|
+ // Seed the state store with the current state
|
|
+ stateStore.setVisibility('user-1', 'hidden');
|
|
+ vi.clearAllMocks(); // reset all mock call counts
|
|
+
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
|
|
+
|
|
+ expect(setSpy).not.toHaveBeenCalled();
|
|
+ expect(socketHandler.emit).not.toHaveBeenCalled();
|
|
+ });
|
|
+
|
|
+ it('allows action when targetState differs from current state', () => {
|
|
+ stateStore.setVisibility('user-1', 'active');
|
|
+ vi.clearAllMocks();
|
|
+
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-3', 0);
|
|
+
|
|
+ expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-11: echo reconciliation (_onEcho) ──────────────────────────────────
|
|
+
|
|
+ describe('_onEcho() echo reconciliation (AC-11)', () => {
|
|
+ // Helper: call init() and return the captured echo handler
|
|
+ function getEchoHandler() {
|
|
+ controller.init();
|
|
+ return adapter.socket.on.mock.calls[0][1];
|
|
+ }
|
|
+
|
|
+ it('calls socketHandler.confirmPendingOp with the opId', () => {
|
|
+ const echoHandler = getEchoHandler();
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
|
+ expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
|
|
+ });
|
|
+
|
|
+ it('stores the echo revision in _revisions for the userId', () => {
|
|
+ const echoHandler = getEchoHandler();
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 7 });
|
|
+ expect(controller._revisions.get('user-1')).toBe(7);
|
|
+ });
|
|
+
|
|
+ it('calls stateStore.setVisibility with the authoritative state', () => {
|
|
+ const echoHandler = getEchoHandler();
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'active', revision: 2 });
|
|
+
|
|
+ expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
|
|
+ });
|
|
+
|
|
+ it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
|
|
+ const echoHandler = getEchoHandler();
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
|
+
|
|
+ expect(hooksStub.callAll).toHaveBeenCalledWith(
|
|
+ 'scrying-pool:controllerAction',
|
|
+ expect.objectContaining({ source: 'echo', participantId: 'user-1', targetState: 'hidden', opId: 'op-1' })
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('removes the participant from _pendingOps after echo', () => {
|
|
+ // Register a pending op first
|
|
+ controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
|
+ expect(controller._pendingOps.has('user-1')).toBe(true);
|
|
+
|
|
+ const echoHandler = getEchoHandler();
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
|
+
|
|
+ expect(controller._pendingOps.has('user-1')).toBe(false);
|
|
+ });
|
|
+
|
|
+ it('defaults revision to 0 when echo payload omits revision field', () => {
|
|
+ const echoHandler = getEchoHandler();
|
|
+ echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
|
|
+ expect(controller._revisions.get('user-1')).toBe(0);
|
|
+ });
|
|
+ });
|
|
+});
|
|
diff --git a/tests/unit/core/VisibilityManager.test.js b/tests/unit/core/VisibilityManager.test.js
|
|
new file mode 100644
|
|
index 0000000..36df70f
|
|
--- /dev/null
|
|
+++ b/tests/unit/core/VisibilityManager.test.js
|
|
@@ -0,0 +1,218 @@
|
|
+// @ts-nocheck
|
|
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
+import { VisibilityManager } from '../../../src/core/VisibilityManager.js';
|
|
+import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
|
+import { StateStore } from '../../../src/core/StateStore.js';
|
|
+
|
|
+/** @returns {StateStore} */
|
|
+function makeStateStore() {
|
|
+ const settingsMock = {
|
|
+ get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
|
|
+ set: vi.fn().mockResolvedValue(undefined),
|
|
+ register: vi.fn(),
|
|
+ };
|
|
+ return new StateStore(settingsMock);
|
|
+}
|
|
+
|
|
+describe('VisibilityManager', () => {
|
|
+ let adapter;
|
|
+ let stateStore;
|
|
+ let manager;
|
|
+ let hooksStub;
|
|
+
|
|
+ beforeEach(() => {
|
|
+ hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
|
+ vi.stubGlobal('Hooks', hooksStub);
|
|
+
|
|
+ adapter = createFoundryAdapterMock({ hooks: hooksStub });
|
|
+ stateStore = makeStateStore();
|
|
+ manager = new VisibilityManager(stateStore, adapter);
|
|
+ });
|
|
+
|
|
+ afterEach(() => {
|
|
+ vi.unstubAllGlobals();
|
|
+ vi.clearAllMocks();
|
|
+ });
|
|
+
|
|
+ // ── AC-1 (construction side-effect free) ─────────────────────────────────
|
|
+
|
|
+ describe('constructor (side-effect free)', () => {
|
|
+ it('does NOT register Hooks.on listener in constructor', () => {
|
|
+ expect(hooksStub.on).not.toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── init() ────────────────────────────────────────────────────────────────
|
|
+
|
|
+ describe('init()', () => {
|
|
+ it('registers Hooks.on for scrying-pool:stateChanged', () => {
|
|
+ manager.init();
|
|
+ expect(hooksStub.on).toHaveBeenCalledWith(
|
|
+ 'scrying-pool:stateChanged',
|
|
+ expect.any(Function)
|
|
+ );
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-6: _onStateChanged — track-disable strategy ────────────────────────
|
|
+
|
|
+ describe('_onStateChanged() track-disable strategy (AC-6)', () => {
|
|
+ let webrtcMock;
|
|
+
|
|
+ beforeEach(() => {
|
|
+ webrtcMock = { disableTrack: vi.fn(), enableTrack: vi.fn() };
|
|
+ const trackDisableAdapter = createFoundryAdapterMock({
|
|
+ webrtc: webrtcMock,
|
|
+ settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, trackDisableAdapter);
|
|
+ manager.init();
|
|
+ });
|
|
+
|
|
+ it('calls disableTrack(userId) when state is hidden', () => {
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ handler({ userId: 'user-1', state: 'hidden' });
|
|
+ expect(webrtcMock.disableTrack).toHaveBeenCalledWith('user-1');
|
|
+ expect(webrtcMock.enableTrack).not.toHaveBeenCalled();
|
|
+ });
|
|
+
|
|
+ it('calls enableTrack(userId) when state is active', () => {
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ handler({ userId: 'user-1', state: 'active' });
|
|
+ expect(webrtcMock.enableTrack).toHaveBeenCalledWith('user-1');
|
|
+ expect(webrtcMock.disableTrack).not.toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-7: _onStateChanged — css-fallback / unsupported ────────────────────
|
|
+
|
|
+ describe('_onStateChanged() css-fallback strategy (AC-7)', () => {
|
|
+ it('performs no webrtc call and throws no error when mode is css-fallback', () => {
|
|
+ const cssFallbackAdapter = createFoundryAdapterMock({
|
|
+ settings: { get: (key) => (key === 'webrtcMode' ? 'css-fallback' : null) },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, cssFallbackAdapter);
|
|
+ manager.init();
|
|
+
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
|
+ });
|
|
+
|
|
+ it('performs no webrtc call and throws no error when mode is unsupported', () => {
|
|
+ const unsupportedAdapter = createFoundryAdapterMock({
|
|
+ settings: { get: (key) => (key === 'webrtcMode' ? 'unsupported' : null) },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, unsupportedAdapter);
|
|
+ manager.init();
|
|
+
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-10: null webrtc guard ──────────────────────────────────────────────
|
|
+
|
|
+ describe('_onStateChanged() null webrtc guard (AC-10)', () => {
|
|
+ it('does not throw when adapter.webrtc is null in track-disable mode', () => {
|
|
+ const nullWebrtcAdapter = createFoundryAdapterMock({
|
|
+ webrtc: null,
|
|
+ settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
|
|
+ manager.init();
|
|
+
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
|
|
+ });
|
|
+
|
|
+ it('does not throw when adapter.webrtc is null with state active', () => {
|
|
+ const nullWebrtcAdapter = createFoundryAdapterMock({
|
|
+ webrtc: null,
|
|
+ settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
|
|
+ manager.init();
|
|
+
|
|
+ const handler = hooksStub.on.mock.calls[0][1];
|
|
+ expect(() => handler({ userId: 'user-1', state: 'active' })).not.toThrow();
|
|
+ });
|
|
+ });
|
|
+
|
|
+ // ── AC-9: onRevert() ─────────────────────────────────────────────────────
|
|
+
|
|
+ describe('onRevert() (AC-9)', () => {
|
|
+ /** @type {import('../../../src/contracts/pending-op.js').PendingOp} */
|
|
+ const pendingOp = {
|
|
+ opId: 'op-1',
|
|
+ userId: 'user-1',
|
|
+ targetState: 'hidden',
|
|
+ previousState: 'active',
|
|
+ issuedAt: 1000000,
|
|
+ timeoutId: null,
|
|
+ };
|
|
+
|
|
+ it('calls stateStore.setVisibility with previousState to revert', () => {
|
|
+ const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
|
+ manager.onRevert(pendingOp);
|
|
+ expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
|
|
+ });
|
|
+
|
|
+ it('calls adapter.notifications.warn with a [ScryingPool]-prefixed message', () => {
|
|
+ const warnMock = vi.fn();
|
|
+ const warnAdapter = createFoundryAdapterMock({
|
|
+ notifications: { warn: warnMock, info: () => {}, error: () => {} },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, warnAdapter);
|
|
+
|
|
+ manager.onRevert(pendingOp);
|
|
+
|
|
+ expect(warnMock).toHaveBeenCalledOnce();
|
|
+ expect(warnMock.mock.calls[0][0]).toMatch(/^\[ScryingPool\]/);
|
|
+ });
|
|
+
|
|
+ it('includes userId in the warning message', () => {
|
|
+ const warnMock = vi.fn();
|
|
+ const warnAdapter = createFoundryAdapterMock({
|
|
+ notifications: { warn: warnMock, info: () => {}, error: () => {} },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, warnAdapter);
|
|
+
|
|
+ manager.onRevert(pendingOp);
|
|
+
|
|
+ expect(warnMock.mock.calls[0][0]).toContain('user-1');
|
|
+ });
|
|
+
|
|
+ it('does NOT call notifications.info (no success notification on revert)', () => {
|
|
+ const infoMock = vi.fn();
|
|
+ const noInfoAdapter = createFoundryAdapterMock({
|
|
+ notifications: { warn: () => {}, info: infoMock, error: () => {} },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, noInfoAdapter);
|
|
+
|
|
+ manager.onRevert(pendingOp);
|
|
+
|
|
+ expect(infoMock).not.toHaveBeenCalled();
|
|
+ });
|
|
+
|
|
+ it('does NOT call notifications.error', () => {
|
|
+ const errorMock = vi.fn();
|
|
+ const noErrorAdapter = createFoundryAdapterMock({
|
|
+ notifications: { warn: () => {}, info: () => {}, error: errorMock },
|
|
+ hooks: hooksStub,
|
|
+ });
|
|
+ manager = new VisibilityManager(stateStore, noErrorAdapter);
|
|
+
|
|
+ manager.onRevert(pendingOp);
|
|
+
|
|
+ expect(errorMock).not.toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+});
|