mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
feat(navigator): extract useDoorState (TDD) – Task 2
- Add `src/hooks/rooms/widgets/useDoorState.ts`: useBetween-based singleton wrapping DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent; all 5 handlers wrapped in useCallback([]) so their references are stable across useBetween tick() calls and the effect dep-array never triggers re-registration. - Add `src/hooks/rooms/widgets/useDoorState.test.tsx`: 11-case Vitest suite (initial state, 5 event transitions, 2 no-op guards, GetGuestRoomResultEvent doorbell/password paths, reset()). - Extend `src/nitro-renderer.mock.ts`: new MessageEvent base class with callBack/type/getParser; DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent concrete stubs; RoomDataParser.DOORBELL_STATE + PASSWORD_STATE; separate msgListeners map (cleared independently of NitroEvent listeners so useBetween subscriptions survive between test cases); WeakMap wrapper for correct removeMessageEvent; GetCommunication routes to msgListeners. All 11 useDoorState tests pass; full suite 453/456 (3 pre-existing FloorplanCanvasSVG jsdom/SVG-CTM failures unrelated to this task).
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
|
||||||
|
GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser,
|
||||||
|
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { DoorStateType } from '../../../api';
|
||||||
|
import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock';
|
||||||
|
import { useDoorState } from './useDoorState';
|
||||||
|
|
||||||
|
const makeParserlessEvent = (klass: any, parser: any) =>
|
||||||
|
{
|
||||||
|
const ev = new klass();
|
||||||
|
(ev as any).getParser = () => parser;
|
||||||
|
return ev;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useDoorState', () =>
|
||||||
|
{
|
||||||
|
beforeEach(() =>
|
||||||
|
{
|
||||||
|
clearMockEventDispatcher();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the initial NONE snapshot', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
|
||||||
|
expect(result.current.snapshot.roomInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DoorbellMessageEvent with empty userName -> STATE_WAITING', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DoorbellMessageEvent with non-empty userName does NOT change state', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
const before = result.current.snapshot.state;
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: 'someone' }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RoomDoorbellAcceptedEvent (empty userName) -> STATE_ACCEPTED', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(RoomDoorbellAcceptedEvent, { userName: '' }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FlatAccessDeniedMessageEvent (empty userName) -> STATE_NO_ANSWER', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(FlatAccessDeniedMessageEvent, { userName: '' }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_NO_ANSWER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GenericErrorEvent -100002 -> STATE_WRONG_PASSWORD', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: -100002 }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WRONG_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GenericErrorEvent 4010 does NOT touch door state', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
const before = result.current.snapshot.state;
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: 4010 }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetGuestRoomResultEvent with roomForward + DOORBELL_STATE -> START_DOORBELL', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.DOORBELL_STATE };
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||||
|
roomForward: true,
|
||||||
|
isGroupMember: false,
|
||||||
|
data: fakeRoomData
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.START_DOORBELL);
|
||||||
|
expect(result.current.snapshot.roomInfo).toBe(fakeRoomData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetGuestRoomResultEvent with roomForward + PASSWORD_STATE -> START_PASSWORD', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.PASSWORD_STATE };
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||||
|
roomForward: true,
|
||||||
|
isGroupMember: false,
|
||||||
|
data: fakeRoomData
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.START_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetGuestRoomResultEvent with non-bell/password doorMode does NOT change state', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
const before = result.current.snapshot.state;
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||||
|
roomForward: true,
|
||||||
|
isGroupMember: false,
|
||||||
|
data: { ownerName: 'other', doorMode: 99 }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset() returns snapshot to NONE', () =>
|
||||||
|
{
|
||||||
|
const { result } = renderHook(() => useDoorState());
|
||||||
|
act(() =>
|
||||||
|
{
|
||||||
|
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
|
||||||
|
});
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
|
||||||
|
act(() => result.current.reset());
|
||||||
|
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
|
||||||
|
expect(result.current.snapshot.roomInfo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
|
||||||
|
GenericErrorEvent, GetGuestRoomResultEvent,
|
||||||
|
GetSessionDataManager, RoomDataParser,
|
||||||
|
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useBetween } from 'use-between';
|
||||||
|
import { DoorStateType } from '../../../api';
|
||||||
|
import { useMessageEvent } from '../../events';
|
||||||
|
|
||||||
|
export type DoorStateSnapshot = {
|
||||||
|
roomInfo: RoomDataParser | null;
|
||||||
|
state: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
|
||||||
|
|
||||||
|
const useDoorStateStore = () =>
|
||||||
|
{
|
||||||
|
const [ snapshot, setSnapshot ] = useState<DoorStateSnapshot>(INITIAL);
|
||||||
|
|
||||||
|
const handleDoorbell = useCallback((event: DoorbellMessageEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
if(parser.userName && parser.userName.length > 0) return;
|
||||||
|
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAccepted = useCallback((event: RoomDoorbellAcceptedEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
if(parser.userName && parser.userName.length > 0) return;
|
||||||
|
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDenied = useCallback((event: FlatAccessDeniedMessageEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
if(parser.userName && parser.userName.length > 0) return;
|
||||||
|
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenericError = useCallback((event: GenericErrorEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
if(parser.errorCode !== -100002) return;
|
||||||
|
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGuestRoom = useCallback((event: GetGuestRoomResultEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
if(!parser.roomForward) return;
|
||||||
|
if(parser.data.ownerName === GetSessionDataManager().userName) return;
|
||||||
|
if(parser.isGroupMember) return;
|
||||||
|
if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE)
|
||||||
|
{
|
||||||
|
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE)
|
||||||
|
{
|
||||||
|
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, handleDoorbell);
|
||||||
|
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, handleAccepted);
|
||||||
|
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, handleDenied);
|
||||||
|
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, handleGenericError);
|
||||||
|
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, handleGuestRoom);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setSnapshot(INITIAL), []);
|
||||||
|
|
||||||
|
return { snapshot, setSnapshot, reset };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDoorState = () => useBetween(useDoorStateStore);
|
||||||
+102
-7
@@ -43,8 +43,20 @@ export const NitroLogger = {
|
|||||||
|
|
||||||
type Listener = (event: any) => void;
|
type Listener = (event: any) => void;
|
||||||
|
|
||||||
|
// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent.
|
||||||
|
// Cleared by clearMockEventDispatcher() between test cases.
|
||||||
const listeners = new Map<string, Set<Listener>>();
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
// MessageEvent listeners — registered via GetCommunication().registerMessageEvent
|
||||||
|
// (i.e. useMessageEvent). NOT cleared by clearMockEventDispatcher() so that
|
||||||
|
// useBetween-based hooks (which register effects once and persist the
|
||||||
|
// singleton across tests) keep their subscriptions alive throughout the
|
||||||
|
// suite. State isolation between tests is maintained by the useBetween
|
||||||
|
// instance preserving INITIAL values across renders (each test's renderHook
|
||||||
|
// shares the same useBetween singleton — tests that check a specific
|
||||||
|
// post-dispatch state rely on the event changing it, not on a reset).
|
||||||
|
const msgListeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
export const mockEventDispatcher = {
|
export const mockEventDispatcher = {
|
||||||
addEventListener(type: string, handler: Listener)
|
addEventListener(type: string, handler: Listener)
|
||||||
{
|
{
|
||||||
@@ -64,18 +76,23 @@ export const mockEventDispatcher = {
|
|||||||
},
|
},
|
||||||
dispatchEvent(event: { type: string })
|
dispatchEvent(event: { type: string })
|
||||||
{
|
{
|
||||||
|
// Fire NitroEvent listeners first, then MessageEvent listeners.
|
||||||
const bucket = listeners.get(event.type);
|
const bucket = listeners.get(event.type);
|
||||||
|
if(bucket) for(const handler of bucket) handler(event);
|
||||||
|
|
||||||
if(!bucket) return;
|
const msgBucket = msgListeners.get(event.type);
|
||||||
|
if(msgBucket) for(const handler of msgBucket) handler(event);
|
||||||
for(const handler of bucket) handler(event);
|
|
||||||
},
|
},
|
||||||
hasListeners(type: string)
|
hasListeners(type: string)
|
||||||
{
|
{
|
||||||
return (listeners.get(type)?.size ?? 0) > 0;
|
return (listeners.get(type)?.size ?? 0) > 0 ||
|
||||||
|
(msgListeners.get(type)?.size ?? 0) > 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clears only the NitroEvent listener map (GetEventDispatcher / useNitroEvent
|
||||||
|
// registrations). MessageEvent listeners (useMessageEvent / GetCommunication)
|
||||||
|
// are intentionally preserved so useBetween-based hooks stay subscribed.
|
||||||
export const clearMockEventDispatcher = () =>
|
export const clearMockEventDispatcher = () =>
|
||||||
{
|
{
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
@@ -188,7 +205,52 @@ export class NitroSprite extends StubClass {}
|
|||||||
export class NitroTexture extends StubClass {}
|
export class NitroTexture extends StubClass {}
|
||||||
export class NitroSoundEvent extends StubClass {}
|
export class NitroSoundEvent extends StubClass {}
|
||||||
export class NitroEvent extends StubClass {}
|
export class NitroEvent extends StubClass {}
|
||||||
export class MessageEvent extends StubClass {}
|
|
||||||
|
// MessageEvent — stores the handler so GetCommunication (below) can
|
||||||
|
// route dispatches through mockEventDispatcher. Each concrete subclass
|
||||||
|
// exposes a `.type` equal to its constructor name so dispatchEvent
|
||||||
|
// can match registered listeners.
|
||||||
|
export class MessageEvent
|
||||||
|
{
|
||||||
|
private _callBack: Function | null;
|
||||||
|
|
||||||
|
constructor(callBack?: Function)
|
||||||
|
{
|
||||||
|
this._callBack = callBack ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get callBack(): Function | null { return this._callBack; }
|
||||||
|
|
||||||
|
// Each concrete subclass is identified by its class name.
|
||||||
|
public get type(): string { return this.constructor.name; }
|
||||||
|
|
||||||
|
// Concrete subclasses override this; the no-arg construction path used
|
||||||
|
// by makeParserlessEvent in tests leaves it returning null — tests
|
||||||
|
// override it with (ev as any).getParser = () => parser.
|
||||||
|
public getParser(): any { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IMessageEvent-based event classes used by useDoorState
|
||||||
|
//
|
||||||
|
// The real renderer classes take a `callBack` constructor arg and store it
|
||||||
|
// in MessageEvent._callBack. The communication manager later calls
|
||||||
|
// `event.callBack(event)` when the matching packet arrives.
|
||||||
|
//
|
||||||
|
// In tests we construct them with NO args (makeParserlessEvent does
|
||||||
|
// `new klass()`) and override `getParser`. GetCommunication (below)
|
||||||
|
// registers `event.callBack` on mockEventDispatcher under `event.type`
|
||||||
|
// (the class name). When the test calls
|
||||||
|
// `mockEventDispatcher.dispatchEvent(ev)`, listeners for that class name
|
||||||
|
// fire, receiving `ev` — and the implementation reads `ev.getParser()`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class DoorbellMessageEvent extends MessageEvent {}
|
||||||
|
export class RoomDoorbellAcceptedEvent extends MessageEvent {}
|
||||||
|
export class FlatAccessDeniedMessageEvent extends MessageEvent {}
|
||||||
|
export class GenericErrorEvent extends MessageEvent {}
|
||||||
|
export class GetGuestRoomResultEvent extends MessageEvent {}
|
||||||
|
|
||||||
export class RoomEngineObjectEvent extends StubClass {}
|
export class RoomEngineObjectEvent extends StubClass {}
|
||||||
export class CreateLinkEvent extends StubClass {}
|
export class CreateLinkEvent extends StubClass {}
|
||||||
export class EventDispatcher extends StubClass {}
|
export class EventDispatcher extends StubClass {}
|
||||||
@@ -196,7 +258,14 @@ export class AdvancedMap extends StubClass {}
|
|||||||
export class AvatarFigureContainer extends StubClass {}
|
export class AvatarFigureContainer extends StubClass {}
|
||||||
export class Vector3d extends StubClass {}
|
export class Vector3d extends StubClass {}
|
||||||
export class ObjectDataFactory extends StubClass {}
|
export class ObjectDataFactory extends StubClass {}
|
||||||
export class RoomDataParser extends StubClass {}
|
|
||||||
|
// RoomDataParser — real static constants needed by useDoorState and its tests.
|
||||||
|
export class RoomDataParser
|
||||||
|
{
|
||||||
|
static readonly DOORBELL_STATE = 1;
|
||||||
|
static readonly PASSWORD_STATE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
export class RoomModerationSettings extends StubClass {}
|
export class RoomModerationSettings extends StubClass {}
|
||||||
export class StringDataType extends StubClass {}
|
export class StringDataType extends StubClass {}
|
||||||
export class SellablePetPaletteData extends StubClass {}
|
export class SellablePetPaletteData extends StubClass {}
|
||||||
@@ -351,7 +420,33 @@ const stubManager = () =>
|
|||||||
|
|
||||||
export const GetAssetManager = vi.fn(stubManager);
|
export const GetAssetManager = vi.fn(stubManager);
|
||||||
export const GetAvatarRenderManager = vi.fn(stubManager);
|
export const GetAvatarRenderManager = vi.fn(stubManager);
|
||||||
export const GetCommunication = vi.fn(stubManager);
|
// GetCommunication — routes IMessageEvent registration through the
|
||||||
|
// msgListeners map (separate from the NitroEvent listeners map) so that
|
||||||
|
// clearMockEventDispatcher() does NOT wipe these subscriptions. This
|
||||||
|
// keeps useBetween-based hooks (like useDoorState) subscribed across
|
||||||
|
// test cases without needing to recreate the useBetween singleton.
|
||||||
|
//
|
||||||
|
// A WeakMap stores the wrapper fn keyed by the MessageEvent instance so
|
||||||
|
// that removeMessageEvent can remove the exact listener added by
|
||||||
|
// registerMessageEvent.
|
||||||
|
const _msgEventWrappers = new WeakMap<MessageEvent, (ev: any) => void>();
|
||||||
|
|
||||||
|
export const GetCommunication = vi.fn(() => ({
|
||||||
|
registerMessageEvent(event: MessageEvent)
|
||||||
|
{
|
||||||
|
if(!event.callBack) return;
|
||||||
|
const wrapper = (ev: any) => event.callBack!(ev);
|
||||||
|
_msgEventWrappers.set(event, wrapper);
|
||||||
|
let bucket = msgListeners.get(event.type);
|
||||||
|
if(!bucket) { bucket = new Set(); msgListeners.set(event.type, bucket); }
|
||||||
|
bucket.add(wrapper);
|
||||||
|
},
|
||||||
|
removeMessageEvent(event: MessageEvent)
|
||||||
|
{
|
||||||
|
const wrapper = _msgEventWrappers.get(event);
|
||||||
|
if(wrapper) msgListeners.get(event.type)?.delete(wrapper);
|
||||||
|
}
|
||||||
|
}));
|
||||||
export const GetConfiguration = vi.fn(stubManager);
|
export const GetConfiguration = vi.fn(stubManager);
|
||||||
export const GetLocalizationManager = vi.fn(stubManager);
|
export const GetLocalizationManager = vi.fn(stubManager);
|
||||||
export const GetRoomEngine = vi.fn(stubManager);
|
export const GetRoomEngine = vi.fn(stubManager);
|
||||||
|
|||||||
Reference in New Issue
Block a user