mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
vi.mock('@bufbuild/protobuf', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@bufbuild/protobuf')>()),
|
|
fromBinary: vi.fn(),
|
|
toBinary: vi.fn().mockReturnValue(new Uint8Array()),
|
|
hasExtension: vi.fn().mockReturnValue(false),
|
|
getExtension: vi.fn(),
|
|
setExtension: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../events/game', () => ({
|
|
GameEvents: [],
|
|
}));
|
|
|
|
vi.mock('../events/room', () => ({
|
|
RoomEvents: [],
|
|
}));
|
|
|
|
vi.mock('../events/session', () => ({
|
|
SessionEvents: [],
|
|
}));
|
|
|
|
import { create, fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
|
|
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
|
|
|
|
import { ProtobufService } from './ProtobufService';
|
|
import { GameEvents } from '../events/game';
|
|
import { RoomEvents } from '../events/room';
|
|
import { SessionEvents } from '../events/session';
|
|
|
|
import type {
|
|
AdminCommand,
|
|
GameCommand,
|
|
GameEvent,
|
|
ModeratorCommand,
|
|
Response,
|
|
RoomCommand,
|
|
RoomEvent,
|
|
SessionCommand,
|
|
SessionEvent,
|
|
} from '@app/generated';
|
|
import {
|
|
CommandContainerSchema,
|
|
ResponseSchema,
|
|
ServerMessageSchema,
|
|
ServerMessage_MessageType,
|
|
} from '@app/generated';
|
|
|
|
type ProtobufInternal = ProtobufService & {
|
|
cmdId: number;
|
|
pendingCommands: Map<number, (response: Response) => void>;
|
|
processGameEvent(container: unknown, extra?: unknown): void;
|
|
processRoomEvent(event: unknown): void;
|
|
processSessionEvent(event: unknown): void;
|
|
processServerResponse(response: unknown): void;
|
|
};
|
|
|
|
let mockSocket: { isOpen: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockSocket = {
|
|
isOpen: vi.fn().mockReturnValue(true),
|
|
send: vi.fn(),
|
|
};
|
|
|
|
// Reset event registries
|
|
(GameEvents as any).length = 0;
|
|
(RoomEvents as any).length = 0;
|
|
(SessionEvents as any).length = 0;
|
|
});
|
|
|
|
describe('ProtobufService', () => {
|
|
// Mock extensions for send*Command tests — @bufbuild/protobuf is fully mocked so these are never invoked
|
|
const sessionExt = {} as GenExtension<SessionCommand, Record<string, never>>;
|
|
const roomExt = {} as GenExtension<RoomCommand, Record<string, never>>;
|
|
const gameExt = {} as GenExtension<GameCommand, Record<string, never>>;
|
|
const moderatorExt = {} as GenExtension<ModeratorCommand, Record<string, never>>;
|
|
const adminExt = {} as GenExtension<AdminCommand, Record<string, never>>;
|
|
|
|
describe('resetCommands', () => {
|
|
it('resets cmdId and pendingCommands', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendSessionCommand(sessionExt, vi.fn());
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
service.resetCommands();
|
|
expect((service as ProtobufInternal).cmdId).toBe(0);
|
|
expect((service as ProtobufInternal).pendingCommands).toEqual(new Map());
|
|
});
|
|
});
|
|
|
|
describe('sendCommand', () => {
|
|
it('increments cmdId and stores callback', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendCommand(create(CommandContainerSchema), cb);
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
expect((service as ProtobufInternal).pendingCommands.get(1)).toBe(cb);
|
|
});
|
|
|
|
it('sends encoded data when socket is OPEN', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
mockSocket.isOpen.mockReturnValue(true);
|
|
service.sendCommand(create(CommandContainerSchema), vi.fn());
|
|
expect(mockSocket.send).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not register callback or increment cmdId when transport is closed', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
mockSocket.isOpen.mockReturnValue(false);
|
|
const cb = vi.fn();
|
|
service.sendCommand(create(CommandContainerSchema), cb);
|
|
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
expect((service as ProtobufInternal).cmdId).toBe(0);
|
|
expect((service as ProtobufInternal).pendingCommands.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('sendSessionCommand', () => {
|
|
it('stores callback and increments cmdId', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendSessionCommand(sessionExt, {});
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
expect((service as ProtobufInternal).pendingCommands.get(1)).toBeTypeOf('function');
|
|
});
|
|
|
|
it('invokes onResponse with raw response when the pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendSessionCommand(sessionExt, {}, { onResponse: cb });
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
storedCb(create(ResponseSchema));
|
|
|
|
expect(cb).toHaveBeenCalledWith(create(ResponseSchema));
|
|
});
|
|
|
|
it('does not throw when no callback is provided and pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendSessionCommand(sessionExt, {});
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
expect(() => storedCb(create(ResponseSchema))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('sendRoomCommand', () => {
|
|
it('stores callback and increments cmdId', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendRoomCommand(42, roomExt, {});
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
});
|
|
|
|
it('invokes onResponse with raw response when the pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendRoomCommand(42, roomExt, {}, { onResponse: cb });
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
storedCb(create(ResponseSchema));
|
|
|
|
expect(cb).toHaveBeenCalledWith(create(ResponseSchema));
|
|
});
|
|
|
|
it('does not throw when no callback is provided and pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendRoomCommand(42, roomExt, {});
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
expect(() => storedCb(create(ResponseSchema))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('sendGameCommand', () => {
|
|
it('stores callback and increments cmdId', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendGameCommand(7, gameExt, {});
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
});
|
|
|
|
it('invokes onResponse with raw response when the pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendGameCommand(7, gameExt, {}, { onResponse: cb });
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
storedCb(create(ResponseSchema));
|
|
|
|
expect(cb).toHaveBeenCalledWith(create(ResponseSchema));
|
|
});
|
|
|
|
it('does not throw when no callback is provided and pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendGameCommand(7, gameExt, {});
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
expect(() => storedCb(create(ResponseSchema))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('sendModeratorCommand', () => {
|
|
it('stores callback and increments cmdId', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendModeratorCommand(moderatorExt, {});
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
});
|
|
|
|
it('invokes onResponse with raw response when the pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendModeratorCommand(moderatorExt, {}, { onResponse: cb });
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
storedCb(create(ResponseSchema));
|
|
|
|
expect(cb).toHaveBeenCalledWith(create(ResponseSchema));
|
|
});
|
|
|
|
it('does not throw when no callback is provided and pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendModeratorCommand(moderatorExt, {});
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
expect(() => storedCb(create(ResponseSchema))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('sendAdminCommand', () => {
|
|
it('stores callback and increments cmdId', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendAdminCommand(adminExt, {});
|
|
expect((service as ProtobufInternal).cmdId).toBe(1);
|
|
});
|
|
|
|
it('invokes onResponse with raw response when the pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
service.sendAdminCommand(adminExt, {}, { onResponse: cb });
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
storedCb(create(ResponseSchema));
|
|
|
|
expect(cb).toHaveBeenCalledWith(create(ResponseSchema));
|
|
});
|
|
|
|
it('does not throw when no callback is provided and pending command is triggered', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
service.sendAdminCommand(adminExt, {});
|
|
|
|
const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!;
|
|
expect(() => storedCb(create(ResponseSchema))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('handleMessageEvent', () => {
|
|
it('routes RESPONSE message to processServerResponse', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const cb = vi.fn();
|
|
(service as ProtobufInternal).cmdId = 1;
|
|
(service as ProtobufInternal).pendingCommands.set(1, cb);
|
|
|
|
vi.mocked(fromBinary).mockReturnValue(
|
|
create(ServerMessageSchema, {
|
|
messageType: ServerMessage_MessageType.RESPONSE,
|
|
response: create(ResponseSchema, { cmdId: BigInt(1) }),
|
|
})
|
|
);
|
|
|
|
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
|
|
expect(cb).toHaveBeenCalledWith(expect.objectContaining({ cmdId: BigInt(1) }));
|
|
expect((service as ProtobufInternal).pendingCommands.get(1)).toBeUndefined();
|
|
});
|
|
|
|
it('routes ROOM_EVENT message', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const processRoomEvent = vi.spyOn(service as ProtobufInternal, 'processRoomEvent');
|
|
|
|
vi.mocked(fromBinary).mockReturnValue(
|
|
create(ServerMessageSchema, {
|
|
messageType: ServerMessage_MessageType.ROOM_EVENT,
|
|
})
|
|
);
|
|
|
|
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
|
|
expect(processRoomEvent).toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes SESSION_EVENT message', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const processSessionEvent = vi.spyOn(service as ProtobufInternal, 'processSessionEvent');
|
|
|
|
vi.mocked(fromBinary).mockReturnValue(
|
|
create(ServerMessageSchema, {
|
|
messageType: ServerMessage_MessageType.SESSION_EVENT,
|
|
})
|
|
);
|
|
|
|
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
|
|
expect(processSessionEvent).toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes GAME_EVENT_CONTAINER message', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const processGameEvent = vi.spyOn(service as ProtobufInternal, 'processGameEvent');
|
|
|
|
vi.mocked(fromBinary).mockReturnValue(
|
|
create(ServerMessageSchema, {
|
|
messageType: ServerMessage_MessageType.GAME_EVENT_CONTAINER,
|
|
})
|
|
);
|
|
|
|
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
|
|
expect(processGameEvent).toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs unknown message types (default case)', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
vi.mocked(fromBinary).mockReturnValue(
|
|
create(ServerMessageSchema, {
|
|
messageType: 999,
|
|
})
|
|
);
|
|
|
|
service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent);
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('does nothing when decoded message is null', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(fromBinary).mockReturnValue(null!);
|
|
expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow();
|
|
});
|
|
|
|
it('catches and logs decode errors', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.mocked(fromBinary).mockImplementation(() => {
|
|
throw new Error('decode error');
|
|
});
|
|
expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow();
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('processGameEvent', () => {
|
|
it('returns early when container has no eventList', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(false);
|
|
(service as ProtobufInternal).processGameEvent(null, {});
|
|
expect(hasExtension).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches to a GameEvents handler when hasExtension returns true', () => {
|
|
const handler = vi.fn();
|
|
const mockExt = {} as GenExtension<GameEvent, unknown>;
|
|
const payload = { someData: 1 };
|
|
|
|
(GameEvents as any).push([mockExt, handler]);
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(true);
|
|
vi.mocked(getExtension).mockReturnValue(payload);
|
|
|
|
(service as ProtobufInternal).processGameEvent({
|
|
gameId: 42,
|
|
eventList: [{ playerId: 5 }],
|
|
}, {});
|
|
|
|
expect(handler).toHaveBeenCalledWith(payload, expect.objectContaining({ gameId: 42, playerId: 5 }));
|
|
});
|
|
|
|
it('defaults gameId and playerId to -1 when undefined', () => {
|
|
const handler = vi.fn();
|
|
const mockExt = {} as GenExtension<GameEvent, unknown>;
|
|
const payload = { someData: 1 };
|
|
|
|
(GameEvents as any).push([mockExt, handler]);
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(true);
|
|
vi.mocked(getExtension).mockReturnValue(payload);
|
|
|
|
(service as ProtobufInternal).processGameEvent({
|
|
gameId: undefined,
|
|
eventList: [{ playerId: undefined }],
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledWith(payload, expect.objectContaining({ gameId: -1, playerId: -1 }));
|
|
});
|
|
});
|
|
|
|
describe('processServerResponse', () => {
|
|
it('returns early when response is undefined', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
(service as ProtobufInternal).pendingCommands.set(1, vi.fn());
|
|
(service as ProtobufInternal).processServerResponse(undefined);
|
|
expect((service as ProtobufInternal).pendingCommands.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('processRoomEvent', () => {
|
|
it('returns early when event is undefined', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(false);
|
|
(service as ProtobufInternal).processRoomEvent(undefined);
|
|
expect(hasExtension).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches to a RoomEvents handler when hasExtension returns true', () => {
|
|
const handler = vi.fn();
|
|
const mockExt = {} as GenExtension<RoomEvent, unknown>;
|
|
const payload = { roomData: 1 };
|
|
|
|
(RoomEvents as any).push([mockExt, handler]);
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(true);
|
|
vi.mocked(getExtension).mockReturnValue(payload);
|
|
|
|
const event = { roomId: 10 };
|
|
(service as ProtobufInternal).processRoomEvent(event);
|
|
|
|
expect(handler).toHaveBeenCalledWith(payload, event);
|
|
});
|
|
});
|
|
|
|
describe('processSessionEvent', () => {
|
|
it('returns early when event is undefined', () => {
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(false);
|
|
(service as ProtobufInternal).processSessionEvent(undefined);
|
|
expect(hasExtension).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches to a SessionEvents handler when hasExtension returns true', () => {
|
|
const handler = vi.fn();
|
|
const mockExt = {} as GenExtension<SessionEvent, unknown>;
|
|
const payload = { sessionData: 1 };
|
|
|
|
(SessionEvents as any).push([mockExt, handler]);
|
|
const service = new ProtobufService(mockSocket);
|
|
vi.mocked(hasExtension).mockReturnValue(true);
|
|
vi.mocked(getExtension).mockReturnValue(payload);
|
|
|
|
(service as ProtobufInternal).processSessionEvent({ sessionId: 7 });
|
|
|
|
expect(handler).toHaveBeenCalledWith(payload, undefined);
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
// ── Real protobuf round-trip test ─────────────────────────────────────────────
|
|
// This describe block does NOT mock @bufbuild/protobuf so it exercises real
|
|
// binary serialization. It proves that the schemas ProtobufService uses
|
|
// survive a toBinary → fromBinary cycle without data loss.
|
|
describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => {
|
|
it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => {
|
|
const { create, toBinary, fromBinary: realFromBinary } =
|
|
await vi.importActual<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
|
|
const { CommandContainerSchema } =
|
|
await vi.importActual<typeof import('@app/generated')>('@app/generated');
|
|
|
|
const original = create(CommandContainerSchema, { cmdId: BigInt(42) });
|
|
const bytes = toBinary(CommandContainerSchema, original);
|
|
const decoded = realFromBinary(CommandContainerSchema, bytes);
|
|
|
|
expect(decoded.cmdId).toBe(BigInt(42));
|
|
});
|
|
|
|
it('ServerMessage RESPONSE round-trips with cmdId and responseCode', async () => {
|
|
const { create, toBinary, fromBinary: realFromBinary } =
|
|
await vi.importActual<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
|
|
const { ServerMessageSchema, ServerMessage_MessageType, ResponseSchema, Response_ResponseCode } =
|
|
await vi.importActual<typeof import('@app/generated')>('@app/generated');
|
|
|
|
const response = create(ResponseSchema, {
|
|
cmdId: BigInt(7),
|
|
responseCode: Response_ResponseCode.RespOk,
|
|
});
|
|
const msg = create(ServerMessageSchema, {
|
|
messageType: ServerMessage_MessageType.RESPONSE,
|
|
response,
|
|
});
|
|
|
|
const bytes = toBinary(ServerMessageSchema, msg);
|
|
const decoded = realFromBinary(ServerMessageSchema, bytes);
|
|
|
|
expect(decoded.messageType).toBe(ServerMessage_MessageType.RESPONSE);
|
|
expect(decoded.response?.cmdId).toBe(BigInt(7));
|
|
expect(decoded.response?.responseCode).toBe(Response_ResponseCode.RespOk);
|
|
});
|
|
|
|
it('SessionCommand with extension round-trips through CommandContainer', async () => {
|
|
const { create, toBinary, fromBinary: realFromBinary, setExtension, getExtension: realGetExtension } =
|
|
await vi.importActual<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
|
|
const {
|
|
CommandContainerSchema, SessionCommandSchema,
|
|
Command_Ping_ext, Command_PingSchema,
|
|
} = await vi.importActual<typeof import('@app/generated')>('@app/generated');
|
|
|
|
const pingCmd = create(Command_PingSchema, {});
|
|
const sesCmd = create(SessionCommandSchema, {});
|
|
setExtension(sesCmd, Command_Ping_ext, pingCmd);
|
|
|
|
const container = create(CommandContainerSchema, {
|
|
cmdId: BigInt(1),
|
|
sessionCommand: [sesCmd],
|
|
});
|
|
|
|
const bytes = toBinary(CommandContainerSchema, container);
|
|
const decoded = realFromBinary(CommandContainerSchema, bytes);
|
|
|
|
expect(decoded.cmdId).toBe(BigInt(1));
|
|
expect(decoded.sessionCommand).toHaveLength(1);
|
|
|
|
const decodedPing = realGetExtension(decoded.sessionCommand[0], Command_Ping_ext);
|
|
expect(decodedPing).toBeDefined();
|
|
});
|
|
});
|