import { Injector, NgZone, runInInjectionContext, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Subject, of } from 'rxjs'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { ServerDirectoryFacade } from '../../server-directory'; import { UsersActions } from '../../../store/users/users.actions'; import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import type { ChatEvent, GameActivity, GameMatchResponse, MatchedGame, User } from '../../../shared-kernel'; import { GameActivityService } from './game-activity.service'; const alice = createUser('alice-id', 'alice-oder', 'Alice'); const bob = createUser('bob-id', 'bob-oder', 'Bob'); const carol = createUser('carol-id', 'carol-oder', 'Carol'); let contexts: ServiceContext[] = []; describe('GameActivityService sync', () => { beforeEach(() => { contexts = []; installLocalStorageMock(); }); afterEach(() => { for (const context of contexts) { context.service.ngOnDestroy(); } }); it('subscribes to incoming activity on browser clients without local process scanning', () => { const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob], electronApi: null }); context.service.start(); context.incomingMessages.next({ type: 'game-activity', fromPeerId: alice.oderId, oderId: alice.oderId, displayName: alice.displayName, gameActivity: createActivity('game-1', 'Deep Rock Galactic') } as ChatEvent); expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({ userId: alice.id, gameActivity: createActivity('game-1', 'Deep Rock Galactic') })); }); it('broadcasts local activity changes to peers already online', async () => { const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe'); const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob], processNames: ['StardewValley.exe'], gameMatchResponse: { games: [matchedGame] } }); context.service.start(); await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'game-activity', oderId: alice.oderId, displayName: alice.displayName, gameActivity: expect.objectContaining({ id: matchedGame.id, name: matchedGame.name, iconUrl: matchedGame.iconUrl, store: matchedGame.store }) }))); }); it('sends current activity directly to peers that connect after the status was set', async () => { const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe'); const context = createServiceContext({ currentUser: alice, allUsers: [ alice, bob, carol ], processNames: ['Hades.exe'], gameMatchResponse: { games: [matchedGame] } }); context.service.start(); await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled()); context.realtime.sendToPeer.mockClear(); context.peerConnected.next(carol.oderId); expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({ type: 'game-activity', oderId: alice.oderId, displayName: alice.displayName, gameActivity: expect.objectContaining({ id: matchedGame.id, name: matchedGame.name, iconUrl: matchedGame.iconUrl, store: matchedGame.store }) })); }); }); interface ServiceContextOptions { currentUser: User; allUsers: User[]; electronApi?: { getRunningProcessNames: () => Promise } | null; processNames?: string[]; gameMatchResponse?: GameMatchResponse; } interface ServiceContext { incomingMessages: Subject; peerConnected: Subject; realtime: { broadcastMessage: ReturnType; sendToPeer: ReturnType; }; service: GameActivityService; store: { dispatch: ReturnType; }; } function createServiceContext(options: ServiceContextOptions): ServiceContext { const currentUser = signal(options.currentUser); const allUsers = signal(options.allUsers); const incomingMessages = new Subject(); const peerConnected = new Subject(); const realtime = { onMessageReceived: incomingMessages.asObservable(), onPeerConnected: peerConnected.asObservable(), broadcastMessage: vi.fn(), sendToPeer: vi.fn() }; const store = { dispatch: vi.fn(), selectSignal: vi.fn((selector: unknown) => { if (selector === selectCurrentUser) { return currentUser; } if (selector === selectAllUsers) { return allUsers; } throw new Error('Unexpected selector requested by GameActivityService test.'); }) }; const electronApi = options.electronApi === undefined ? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) } : options.electronApi; const injector = Injector.create({ providers: [ { provide: ElectronBridgeService, useValue: { getApi: () => electronApi } }, { provide: HttpClient, useValue: { post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] })) } }, { provide: NgZone, useValue: { run: (fn: () => void) => fn(), runOutsideAngular: (fn: () => void) => fn() } }, { provide: RealtimeSessionFacade, useValue: realtime }, { provide: ServerDirectoryFacade, useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' } }, { provide: Store, useValue: store } ] }); const service = runInInjectionContext(injector, () => new GameActivityService()); const context = { incomingMessages, peerConnected, realtime, service, store }; contexts.push(context); return context; } function createUser(id: string, oderId: string, displayName: string): User { return { id, oderId, username: displayName.toLowerCase(), displayName, status: 'online', role: 'member', joinedAt: 1 }; } function createActivity(id: string, name: string): GameActivity { return { id, name, startedAt: 1_000 }; } function createMatchedGame(id: string, name: string, processName: string): MatchedGame { return { id, name, iconUrl: `https://img.example.test/${id}.jpg`, store: { name: 'Steam', slug: 'steam', url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}` }, processName }; } function installLocalStorageMock(): void { const values = new Map(); vi.stubGlobal('localStorage', { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key), clear: () => values.clear() }); }