diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx new file mode 100644 index 0000000..1176d07 --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -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(); + }); +}); diff --git a/src/hooks/rooms/widgets/useDoorState.ts b/src/hooks/rooms/widgets/useDoorState.ts new file mode 100644 index 0000000..1fadbba --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -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(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, handleDoorbell); + useMessageEvent(RoomDoorbellAcceptedEvent, handleAccepted); + useMessageEvent(FlatAccessDeniedMessageEvent, handleDenied); + useMessageEvent(GenericErrorEvent, handleGenericError); + useMessageEvent(GetGuestRoomResultEvent, handleGuestRoom); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 1cfbfa1..dd5b642 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -43,8 +43,20 @@ export const NitroLogger = { type Listener = (event: any) => void; +// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent. +// Cleared by clearMockEventDispatcher() between test cases. const listeners = new Map>(); +// 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>(); + export const mockEventDispatcher = { addEventListener(type: string, handler: Listener) { @@ -64,18 +76,23 @@ export const mockEventDispatcher = { }, dispatchEvent(event: { type: string }) { + // Fire NitroEvent listeners first, then MessageEvent listeners. const bucket = listeners.get(event.type); + if(bucket) for(const handler of bucket) handler(event); - if(!bucket) return; - - for(const handler of bucket) handler(event); + const msgBucket = msgListeners.get(event.type); + if(msgBucket) for(const handler of msgBucket) handler(event); }, 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 = () => { listeners.clear(); @@ -188,7 +205,52 @@ export class NitroSprite extends StubClass {} export class NitroTexture extends StubClass {} export class NitroSoundEvent 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 CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -196,7 +258,14 @@ export class AdvancedMap extends StubClass {} export class AvatarFigureContainer extends StubClass {} export class Vector3d 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 StringDataType extends StubClass {} export class SellablePetPaletteData extends StubClass {} @@ -351,7 +420,33 @@ const stubManager = () => export const GetAssetManager = 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 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 GetLocalizationManager = vi.fn(stubManager); export const GetRoomEngine = vi.fn(stubManager);