568 lines
20 KiB
JavaScript
568 lines
20 KiB
JavaScript
// @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");
|
|
});
|
|
});
|
|
});
|