Files
uberwald 5dc9b3b8d4
CI / ci (push) Failing after 7s
Module cleanup and tests
2026-05-24 23:13:45 +02:00

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");
});
});
});