Story 3.2 done
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
// @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 = {
|
||||
'video-view-manager.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.',
|
||||
'video-view-manager.notifications.personalShowed': 'Your camera is now visible to the table.',
|
||||
'video-view-manager.notifications.gmHid': 'GM hid {name}\'s camera',
|
||||
'video-view-manager.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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user