feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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<string[]> } | null;
|
||||
processNames?: string[];
|
||||
gameMatchResponse?: GameMatchResponse;
|
||||
}
|
||||
|
||||
interface ServiceContext {
|
||||
incomingMessages: Subject<ChatEvent>;
|
||||
peerConnected: Subject<string>;
|
||||
realtime: {
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
sendToPeer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
service: GameActivityService;
|
||||
store: {
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
const currentUser = signal<User | null>(options.currentUser);
|
||||
const allUsers = signal<User[]>(options.allUsers);
|
||||
const incomingMessages = new Subject<ChatEvent>();
|
||||
const peerConnected = new Subject<string>();
|
||||
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<string, string>();
|
||||
|
||||
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()
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user