// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { NotificationBus } from '../../../src/notifications/NotificationBus.js'; import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeAdapter({ currentUserId = 'gm-user', isGM = true, verbosity = 'all', users = {}, } = {}) { const notifSpy = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; const adpt = createFoundryAdapterMock({ notifications: notifSpy, users: { get: vi.fn((id) => users[id] ?? { id, name: id }), isGM: () => isGM, current: () => (currentUserId ? { id: currentUserId } : null), ...users._overrides, }, settings: { register: vi.fn(), get: vi.fn().mockReturnValue(verbosity), set: vi.fn(), }, i18n: { localize: vi.fn((key, data) => { // Simple mock that returns the key with data substituted const messages = { 'scrying-pool.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.', 'scrying-pool.notifications.personalShowed': 'Your camera is now visible to the table.', 'scrying-pool.notifications.gmHid': 'GM hid {name}\'s camera', 'scrying-pool.notifications.gmShowed': 'GM showed {name}\'s camera', }; let msg = messages[key] ?? key; if (data?.name) { msg = msg.replace('{name}', data.name); } return msg; }), }, }); // expose spy for assertions adpt._notifSpy = notifSpy; return adpt; } function makeHookCapture() { const handlers = {}; return { stub: { on: vi.fn((event, handler) => { handlers[event] = handler; return Symbol('hookId'); }), off: vi.fn(), once: vi.fn(), callAll: vi.fn(), }, fire(event, data) { if (handlers[event]) handlers[event](data); }, handlers, }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('NotificationBus', () => { let hooks; beforeEach(() => { hooks = makeHookCapture(); vi.stubGlobal('Hooks', hooks.stub); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); // ── Constructor ──────────────────────────────────────────────────────────── describe('constructor', () => { it('stores adapter without side effects', () => { const adapter = makeAdapter(); const bus = new NotificationBus(adapter); expect(bus._adapter).toBe(adapter); expect(Hooks.on).not.toHaveBeenCalled(); }); it('coalesceMap is empty after construction', () => { const adapter = makeAdapter(); const bus = new NotificationBus(adapter); expect(bus._coalesceMap.size).toBe(0); }); }); // ── init() ───────────────────────────────────────────────────────────────── describe('init()', () => { it('subscribes to scrying-pool:stateChanged hook', () => { const bus = new NotificationBus(makeAdapter()); bus.init(); expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function)); }); it('stores the hook id returned by Hooks.on', () => { const adapter = makeAdapter(); // Hooks.on stub returns a Symbol per makeHookCapture, check hookId is stored const bus = new NotificationBus(adapter); bus.init(); expect(bus._hookId).toBeDefined(); expect(bus._hookId).not.toBeNull(); }); }); // ── Personal notifications (AC-2) ────────────────────────────────────────── describe('personal notifications — current user is the affected participant', () => { it('fires immediate info when own camera is hidden (verbosity=all)', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); expect(adapter._notifSpy.info).toHaveBeenCalledWith( "GM has hidden your camera. Your portrait is shown to other Participants." ); expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); }); it('fires immediate info when own camera is shown (not hidden)', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden', }); expect(adapter._notifSpy.info).toHaveBeenCalledWith( "Your camera is now visible to the table." ); }); it('personal notification fires even when verbosity=silent', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'silent' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); }); it('personal notification fires even when verbosity=gm-only and user is not GM', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); }); it('personal notification does NOT go through coalescing (fires immediately, no timer)', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); // No timer advance needed — should fire immediately expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); expect(bus._coalesceMap.size).toBe(0); }); }); // ── Verbosity filtering — non-personal (AC-4, AC-5) ─────────────────────── describe('verbosity filtering — other participant changes', () => { it('verbosity=silent blocks notification for non-affected user', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', isGM: true, verbosity: 'silent' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'other-player', state: 'hidden', previousState: 'active', }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); }); it('verbosity=gm-only blocks notification for non-GM player', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'other-player', state: 'hidden', previousState: 'active', }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); }); it('verbosity=gm-only allows notification for GM user', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', isGM: true, verbosity: 'gm-only', users: { 'other-player': { id: 'other-player', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'other-player', state: 'hidden', previousState: 'active', }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera"); }); it('verbosity=all allows notification for non-GM player about other participant', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all', users: { 'other-player': { id: 'other-player', name: 'Bob' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'other-player', state: 'active', previousState: 'hidden', }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Bob's camera"); }); }); // ── Coalescing timer (AC-3) ──────────────────────────────────────────────── describe('coalescing — 3s debounce window', () => { it('fires notification after 3000ms debounce window', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); // Before window closes — no notification yet vi.advanceTimersByTime(2_999); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); // Window closes — fires vi.advanceTimersByTime(2); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera"); }); it('resets debounce window when new change arrives before timer fires', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); // First change at t=0: active → hidden hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active', }); // 1s later — second change arrives (hidden → self-muted), resets window vi.advanceTimersByTime(1_000); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'self-muted', previousState: 'hidden', }); // Original 3s from first event (t=3000) should NOT fire — timer was reset vi.advanceTimersByTime(2_001); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); // New 3s window from second event fires at t=1000+3000=4000 // Net: active → self-muted (not net-zero) → fires "GM showed" (self-muted != 'hidden') // 2 changes total: active→hidden, hidden→self-muted vi.advanceTimersByTime(1_000); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Alice's camera (2 changes)"); }); it('coalesces multiple changes into single notification with final state', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); // Three changes in rapid succession hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' }); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); vi.advanceTimersByTime(3_001); // Only one notification, based on final state, with change count expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera (3 changes)"); }); it('net-zero suppression: no notification when final state equals original state', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); // hide then show — net state unchanged hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); }); it('independent timers per participant', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', users: { 'player-1': { id: 'player-1', name: 'Alice' }, 'player-2': { id: 'player-2', name: 'Bob' }, }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); vi.advanceTimersByTime(1_000); hooks.fire('scrying-pool:stateChanged', { userId: 'player-2', state: 'hidden', previousState: 'active' }); // t=3001 — player-1 fires vi.advanceTimersByTime(2_001); expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera"); // t=4001 — player-2 fires vi.advanceTimersByTime(1_000); expect(adapter._notifSpy.info).toHaveBeenCalledTimes(2); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Bob's camera"); }); it('falls back to userId when user name cannot be resolved', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: 'gm-user', // users map returns null for unknown users (default createFoundryAdapterMock: get: () => null) }); // Override users.get to return null for player-x adapter.users.get = vi.fn().mockReturnValue(null); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-x', state: 'hidden', previousState: 'active', }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid player-x's camera"); }); }); // ── Message format (AC-1) ────────────────────────────────────────────────── describe('message format', () => { it('uses "GM hid [name]\'s camera" when final state is hidden', () => { vi.useFakeTimers(); const adapter = makeAdapter({ users: { 'player-1': { id: 'player-1', name: 'Aria' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Aria's camera"); }); it('uses "GM showed [name]\'s camera" when final state is not hidden', () => { vi.useFakeTimers(); const adapter = makeAdapter({ users: { 'player-1': { id: 'player-1', name: 'Aria' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' }); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Aria's camera"); }); }); // ── teardown() ───────────────────────────────────────────────────────────── describe('teardown()', () => { it('unregisters Hooks listener', () => { const adapter = makeAdapter(); const bus = new NotificationBus(adapter); bus.init(); const storedId = bus._hookId; bus.teardown(); expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', storedId); }); it('clears hookId after teardown', () => { const adapter = makeAdapter(); const bus = new NotificationBus(adapter); bus.init(); bus.teardown(); expect(bus._hookId).toBeNull(); }); it('cancels pending timers — no notification fires after teardown', () => { vi.useFakeTimers(); const adapter = makeAdapter({ users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); bus.teardown(); vi.advanceTimersByTime(5_000); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); }); it('clears coalesceMap after teardown', () => { vi.useFakeTimers(); const adapter = makeAdapter({ users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); const bus = new NotificationBus(adapter); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); expect(bus._coalesceMap.size).toBe(1); bus.teardown(); expect(bus._coalesceMap.size).toBe(0); }); it('is safe to call teardown before init', () => { const adapter = makeAdapter(); const bus = new NotificationBus(adapter); expect(() => bus.teardown()).not.toThrow(); }); }); // ── Guard: missing userId ────────────────────────────────────────────────── describe('guards', () => { it('ignores stateChanged event without userId', () => { vi.useFakeTimers(); const adapter = makeAdapter(); const bus = new NotificationBus(adapter); bus.init(); expect(() => { hooks.fire('scrying-pool:stateChanged', { state: 'hidden', previousState: 'active' }); }).not.toThrow(); vi.advanceTimersByTime(3_001); expect(adapter._notifSpy.info).not.toHaveBeenCalled(); }); it('handles null current user gracefully (treats as non-personal)', () => { vi.useFakeTimers(); const adapter = makeAdapter({ currentUserId: null, isGM: false, verbosity: 'all' }); adapter.users.current = () => null; const adapter2 = makeAdapter({ currentUserId: null, verbosity: 'all', users: { 'player-1': { id: 'player-1', name: 'Alice' } }, }); adapter2.users.current = () => null; const bus = new NotificationBus(adapter2); bus.init(); hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' }); vi.advanceTimersByTime(3_001); expect(adapter2._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera"); }); }); });