diff --git a/package.json b/package.json index ce9ad2c..c625f48 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,10 @@ "build": "vite build", "compile": "tsc --project ./tsconfig.json --noEmit false", "eslint": "eslint ./src ./packages/*/src", - "eslint-fix": "eslint ./src --fix" + "eslint-fix": "eslint ./src --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "main": "./index", "dependencies": { @@ -40,10 +43,13 @@ "@types/howler": "^2.2.11", "@types/node": "^20.14.12", "@types/pako": "^2.0.3", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.8.0", + "jsdom": "^27.4.0", "tslib": "^2.6.3", "typescript": "~5.8.2", "typescript-eslint": "^8.26.1", - "vite": "^5.4.9" + "vite": "^5.4.9", + "vitest": "^4.0.18" } } diff --git a/packages/avatar/src/AvatarAssetDownloadManager.ts b/packages/avatar/src/AvatarAssetDownloadManager.ts index a11f073..982023b 100644 --- a/packages/avatar/src/AvatarAssetDownloadManager.ts +++ b/packages/avatar/src/AvatarAssetDownloadManager.ts @@ -15,6 +15,7 @@ export class AvatarAssetDownloadManager private _incompleteFigures: Map = new Map(); private _currentDownloads: AvatarAssetDownloadLibrary[] = []; private _libraryNames: string[] = []; + private _libraryLoadedCallback: (event: AvatarRenderLibraryEvent) => void = null; constructor(assets: IAssetManager, structure: AvatarStructure) { @@ -38,11 +39,27 @@ export class AvatarAssetDownloadManager this.processFigureMap(responseData.libraries); - GetEventDispatcher().addEventListener(NitroEventType.AVATAR_ASSET_DOWNLOADED, (event: AvatarRenderLibraryEvent) => this.onLibraryLoaded(event)); + // Store callback for cleanup + this._libraryLoadedCallback = (event: AvatarRenderLibraryEvent) => this.onLibraryLoaded(event); + GetEventDispatcher().addEventListener(NitroEventType.AVATAR_ASSET_DOWNLOADED, this._libraryLoadedCallback); await this.processMissingLibraries(); } + public dispose(): void + { + if(this._libraryLoadedCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.AVATAR_ASSET_DOWNLOADED, this._libraryLoadedCallback); + this._libraryLoadedCallback = null; + } + + this._figureMap.clear(); + this._figureListeners.clear(); + this._incompleteFigures.clear(); + this._currentDownloads = []; + } + private processFigureMap(data: any): void { if(!data) return; diff --git a/packages/avatar/src/AvatarRenderManager.ts b/packages/avatar/src/AvatarRenderManager.ts index 8c3d9e7..aa7a1e4 100644 --- a/packages/avatar/src/AvatarRenderManager.ts +++ b/packages/avatar/src/AvatarRenderManager.ts @@ -24,6 +24,7 @@ export class AvatarRenderManager implements IAvatarRenderManager private _effectAssetDownloadManager: EffectAssetDownloadManager = new EffectAssetDownloadManager(GetAssetManager(), this._structure); private _placeHolderFigure: AvatarFigureContainer = new AvatarFigureContainer(AvatarRenderManager.DEFAULT_FIGURE); + private _aliasResetCallback: () => void = null; public async init(): Promise { @@ -37,13 +38,30 @@ export class AvatarRenderManager implements IAvatarRenderManager this._aliasCollection.init(); - GetEventDispatcher().addEventListener(NitroEventType.AVATAR_ASSET_LOADED, () => this._aliasCollection.reset()); - GetEventDispatcher().addEventListener(NitroEventType.AVATAR_EFFECT_LOADED, () => this._aliasCollection.reset()); + // Store callback for cleanup + this._aliasResetCallback = () => this._aliasCollection.reset(); + GetEventDispatcher().addEventListener(NitroEventType.AVATAR_ASSET_LOADED, this._aliasResetCallback); + GetEventDispatcher().addEventListener(NitroEventType.AVATAR_EFFECT_LOADED, this._aliasResetCallback); await this._avatarAssetDownloadManager.init(); await this._effectAssetDownloadManager.init(); } + public dispose(): void + { + // Remove event listeners + if(this._aliasResetCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.AVATAR_ASSET_LOADED, this._aliasResetCallback); + GetEventDispatcher().removeEventListener(NitroEventType.AVATAR_EFFECT_LOADED, this._aliasResetCallback); + this._aliasResetCallback = null; + } + + // Dispose download managers + this._avatarAssetDownloadManager?.dispose(); + this._effectAssetDownloadManager?.dispose(); + } + private async loadActions(): Promise { const defaultActions = GetConfiguration().getValue('avatar.default.actions'); diff --git a/packages/avatar/src/EffectAssetDownloadManager.ts b/packages/avatar/src/EffectAssetDownloadManager.ts index 33e8afd..1594da3 100644 --- a/packages/avatar/src/EffectAssetDownloadManager.ts +++ b/packages/avatar/src/EffectAssetDownloadManager.ts @@ -15,6 +15,7 @@ export class EffectAssetDownloadManager private _incompleteEffects: Map = new Map(); private _currentDownloads: EffectAssetDownloadLibrary[] = []; private _libraryNames: string[] = []; + private _libraryLoadedCallback: (event: AvatarRenderEffectLibraryEvent) => void = null; constructor(assets: IAssetManager, structure: AvatarStructure) { @@ -38,11 +39,27 @@ export class EffectAssetDownloadManager this.processEffectMap(responseData.effects); - GetEventDispatcher().addEventListener(NitroEventType.AVATAR_EFFECT_DOWNLOADED, (event: AvatarRenderEffectLibraryEvent) => this.onLibraryLoaded(event)); + // Store callback for cleanup + this._libraryLoadedCallback = (event: AvatarRenderEffectLibraryEvent) => this.onLibraryLoaded(event); + GetEventDispatcher().addEventListener(NitroEventType.AVATAR_EFFECT_DOWNLOADED, this._libraryLoadedCallback); await this.processMissingLibraries(); } + public dispose(): void + { + if(this._libraryLoadedCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.AVATAR_EFFECT_DOWNLOADED, this._libraryLoadedCallback); + this._libraryLoadedCallback = null; + } + + this._effectMap.clear(); + this._effectListeners.clear(); + this._incompleteEffects.clear(); + this._currentDownloads = []; + } + private processEffectMap(data: any): void { if(!data) return; diff --git a/packages/communication/src/CommunicationManager.ts b/packages/communication/src/CommunicationManager.ts index cfbb63e..8acf399 100644 --- a/packages/communication/src/CommunicationManager.ts +++ b/packages/communication/src/CommunicationManager.ts @@ -13,6 +13,10 @@ export class CommunicationManager implements ICommunicationManager private _messages: IMessageConfiguration = new NitroMessages(); private _pongInterval: any = null; + private _messageEvents: IMessageEvent[] = []; + private _socketClosedCallback: () => void = null; + private _socketOpenedCallback: () => void = null; + private _socketErrorCallback: () => void = null; private getGpu(): string { const e = document.createElement('canvas'); @@ -88,44 +92,86 @@ export class CommunicationManager implements ICommunicationManager public async init(): Promise { - GetEventDispatcher().addEventListener(NitroEventType.SOCKET_CLOSED, () => + // Store callback for cleanup + this._socketClosedCallback = () => { this.stopPong(); - }); + }; + GetEventDispatcher().addEventListener(NitroEventType.SOCKET_CLOSED, this._socketClosedCallback); return new Promise((resolve, reject) => { - GetEventDispatcher().addEventListener(NitroEventType.SOCKET_OPENED, () => + // Store callback for cleanup + this._socketOpenedCallback = () => { if(GetConfiguration().getValue('system.pong.manually', false)) this.startPong(); - + const machineId = this.generateMachineID(); - + this._connection.send(new ClientHelloMessageComposer(null, null, null, null)); this._connection.send(new SSOTicketMessageComposer(GetConfiguration().getValue('sso.ticket', null), GetTickerTime())); this._connection.send(new UniqueIDMessageComposer(machineId, '', '')); - }); + }; + GetEventDispatcher().addEventListener(NitroEventType.SOCKET_OPENED, this._socketOpenedCallback); - GetEventDispatcher().addEventListener(NitroEventType.SOCKET_ERROR, () => + // Store callback for cleanup + this._socketErrorCallback = () => { reject(); - }); + }; + GetEventDispatcher().addEventListener(NitroEventType.SOCKET_ERROR, this._socketErrorCallback); - this._connection.addMessageEvent(new ClientPingEvent((event: ClientPingEvent) => this.sendPong())); - - this._connection.addMessageEvent(new AuthenticatedEvent((event: AuthenticatedEvent) => + // Store message events for cleanup + const pingEvent = new ClientPingEvent((event: ClientPingEvent) => this.sendPong()); + const authEvent = new AuthenticatedEvent((event: AuthenticatedEvent) => { this._connection.authenticated(); resolve(); event.connection.send(new InfoRetrieveMessageComposer()); - })); + }); + + this._messageEvents.push(pingEvent, authEvent); + this._connection.addMessageEvent(pingEvent); + this._connection.addMessageEvent(authEvent); this._connection.init(GetConfiguration().getValue('socket.url')); }); } + public dispose(): void + { + // Stop pong interval + this.stopPong(); + + // Remove event dispatcher listeners + if(this._socketClosedCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.SOCKET_CLOSED, this._socketClosedCallback); + this._socketClosedCallback = null; + } + + if(this._socketOpenedCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.SOCKET_OPENED, this._socketOpenedCallback); + this._socketOpenedCallback = null; + } + + if(this._socketErrorCallback) + { + GetEventDispatcher().removeEventListener(NitroEventType.SOCKET_ERROR, this._socketErrorCallback); + this._socketErrorCallback = null; + } + + // Remove message events + for(const event of this._messageEvents) + { + this._connection.removeMessageEvent(event); + } + this._messageEvents = []; + } + protected startPong(): void { if(this._pongInterval) this.stopPong(); diff --git a/packages/communication/src/SocketConnection.ts b/packages/communication/src/SocketConnection.ts index 5055295..93d0c15 100644 --- a/packages/communication/src/SocketConnection.ts +++ b/packages/communication/src/SocketConnection.ts @@ -17,6 +17,12 @@ export class SocketConnection implements IConnection private _isAuthenticated: boolean = false; + // Store callbacks for cleanup + private _onOpenCallback: (event: Event) => void = null; + private _onCloseCallback: (event: Event) => void = null; + private _onErrorCallback: (event: Event) => void = null; + private _onMessageCallback: (event: MessageEvent) => void = null; + public init(socketUrl: string): void { if(!socketUrl || !socketUrl.length) return; @@ -26,18 +32,49 @@ export class SocketConnection implements IConnection this._socket = new WebSocket(socketUrl); this._socket.binaryType = 'arraybuffer'; - this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, event => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_OPENED))); - - this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, event => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED))); - - this._socket.addEventListener(WebSocketEventEnum.CONNECTION_ERROR, event => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_ERROR))); - - this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, (event: MessageEvent) => + // Store callbacks for cleanup + this._onOpenCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_OPENED)); + this._onCloseCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED)); + this._onErrorCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_ERROR)); + this._onMessageCallback = (event: MessageEvent) => { this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, event.data); - this.processReceivedData(); - }); + }; + + this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback); + this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback); + this._socket.addEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback); + this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback); + } + + public dispose(): void + { + if(this._socket) + { + // Remove all event listeners + if(this._onOpenCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback); + if(this._onCloseCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback); + if(this._onErrorCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback); + if(this._onMessageCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback); + + // Close socket if still open + if(this._socket.readyState === WebSocket.OPEN || this._socket.readyState === WebSocket.CONNECTING) + { + this._socket.close(); + } + + this._socket = null; + } + + this._onOpenCallback = null; + this._onCloseCallback = null; + this._onErrorCallback = null; + this._onMessageCallback = null; + + this._pendingClientMessages = []; + this._pendingServerMessages = []; + this._dataBuffer = null; } public ready(): void diff --git a/packages/events/src/__tests__/EventDispatcher.test.ts b/packages/events/src/__tests__/EventDispatcher.test.ts new file mode 100644 index 0000000..4ce15ad --- /dev/null +++ b/packages/events/src/__tests__/EventDispatcher.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventDispatcher } from '../EventDispatcher'; + +interface TestEvent +{ + type: string; + data?: any; +} + +describe('EventDispatcher', () => +{ + let dispatcher: EventDispatcher; + + beforeEach(() => + { + dispatcher = new EventDispatcher(); + }); + + describe('addEventListener', () => + { + it('should add event listener', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should support multiple listeners for same event', () => + { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('test', callback1); + dispatcher.addEventListener('test', callback2); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should not add listener if type is null', () => + { + const callback = vi.fn(); + dispatcher.addEventListener(null as any, callback); + + // Should not throw and callback should never be called + expect(() => dispatcher.dispatchEvent({ type: 'test' })).not.toThrow(); + }); + + it('should not add listener if callback is null', () => + { + dispatcher.addEventListener('test', null as any); + + // Should not throw + expect(() => dispatcher.dispatchEvent({ type: 'test' })).not.toThrow(); + }); + }); + + describe('removeEventListener', () => + { + it('should remove event listener', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + dispatcher.removeEventListener('test', callback); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should only remove specified listener', () => + { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('test', callback1); + dispatcher.addEventListener('test', callback2); + dispatcher.removeEventListener('test', callback1); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should handle removing non-existent listener', () => + { + const callback = vi.fn(); + + // Should not throw + expect(() => dispatcher.removeEventListener('test', callback)).not.toThrow(); + }); + + it('should not remove listener if type is null', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + dispatcher.removeEventListener(null as any, callback); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('dispatchEvent', () => + { + it('should dispatch event to listeners', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + + const event: TestEvent = { type: 'test', data: 'hello' }; + dispatcher.dispatchEvent(event); + + expect(callback).toHaveBeenCalledWith(event); + }); + + it('should return true when event is dispatched', () => + { + const result = dispatcher.dispatchEvent({ type: 'test' }); + expect(result).toBe(true); + }); + + it('should return false when event is null', () => + { + const result = dispatcher.dispatchEvent(null as any); + expect(result).toBe(false); + }); + + it('should not throw when no listeners for event type', () => + { + expect(() => dispatcher.dispatchEvent({ type: 'unknown' })).not.toThrow(); + }); + + it('should call listeners in order they were added', () => + { + const order: number[] = []; + + dispatcher.addEventListener('test', () => order.push(1)); + dispatcher.addEventListener('test', () => order.push(2)); + dispatcher.addEventListener('test', () => order.push(3)); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(order).toEqual([1, 2, 3]); + }); + + it('should handle errors in listeners gracefully', () => + { + const callback1 = vi.fn(() => + { + throw new Error('Test error'); + }); + const callback2 = vi.fn(); + + dispatcher.addEventListener('test', callback1); + dispatcher.addEventListener('test', callback2); + + // Should not throw + expect(() => dispatcher.dispatchEvent({ type: 'test' })).not.toThrow(); + + // First callback was called + expect(callback1).toHaveBeenCalled(); + + // Second callback was NOT called because first threw error + // (This is the current behavior - stops on first error) + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe('removeAllListeners', () => + { + it('should remove all listeners', () => + { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + dispatcher.addEventListener('test1', callback1); + dispatcher.addEventListener('test2', callback2); + dispatcher.addEventListener('test1', callback3); + + dispatcher.removeAllListeners(); + + dispatcher.dispatchEvent({ type: 'test1' }); + dispatcher.dispatchEvent({ type: 'test2' }); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => + { + it('should remove all listeners on dispose', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + + dispatcher.dispose(); + + dispatcher.dispatchEvent({ type: 'test' }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('multiple event types', () => + { + it('should handle multiple event types independently', () => + { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('type1', callback1); + dispatcher.addEventListener('type2', callback2); + + dispatcher.dispatchEvent({ type: 'type1' }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + + dispatcher.dispatchEvent({ type: 'type2' }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + + describe('event data', () => + { + it('should pass event data to listeners', () => + { + const callback = vi.fn(); + dispatcher.addEventListener('test', callback); + + const eventData = { type: 'test', value: 42, nested: { key: 'value' } }; + dispatcher.dispatchEvent(eventData); + + expect(callback).toHaveBeenCalledWith(eventData); + }); + }); +}); diff --git a/packages/room/src/RoomEngine.ts b/packages/room/src/RoomEngine.ts index e512025..7a9e0ff 100644 --- a/packages/room/src/RoomEngine.ts +++ b/packages/room/src/RoomEngine.ts @@ -64,13 +64,16 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService private _mouseCursorUpdate: boolean = false; private _badgeListenerObjects: Map = new Map(); private _areaSelectionManager: IRoomAreaSelectionManager = new RoomAreaSelectionManager(this); + private _roomSessionEventCallback: (event: RoomSessionEvent) => void = null; public async init(): Promise { GetRoomObjectLogicFactory().registerEventFunction(event => this.processRoomObjectEvent(event)); - GetEventDispatcher().addEventListener(RoomSessionEvent.STARTED, event => this.onRoomSessionEvent(event)); - GetEventDispatcher().addEventListener(RoomSessionEvent.ENDED, event => this.onRoomSessionEvent(event)); + // Store callback for cleanup + this._roomSessionEventCallback = (event: RoomSessionEvent) => this.onRoomSessionEvent(event); + GetEventDispatcher().addEventListener(RoomSessionEvent.STARTED, this._roomSessionEventCallback); + GetEventDispatcher().addEventListener(RoomSessionEvent.ENDED, this._roomSessionEventCallback); await GetRoomMessageHandler().init(); await this._roomContentLoader.init(); @@ -108,6 +111,32 @@ export class RoomEngine implements IRoomEngine, IRoomCreator, IRoomEngineService } } + public dispose(): void + { + // Remove event listeners + if(this._roomSessionEventCallback) + { + GetEventDispatcher().removeEventListener(RoomSessionEvent.STARTED, this._roomSessionEventCallback); + GetEventDispatcher().removeEventListener(RoomSessionEvent.ENDED, this._roomSessionEventCallback); + this._roomSessionEventCallback = null; + } + + // Dispose room message handler + GetRoomMessageHandler().dispose(); + + // Clear all room instances + for(const roomId of this._roomDatas.keys()) + { + this.removeRoomInstance(roomId); + } + + this._roomDatas.clear(); + this._roomInstanceDatas.clear(); + this._imageCallbacks.clear(); + this._thumbnailCallbacks.clear(); + this._badgeListenerObjects.clear(); + } + public setActiveRoomId(roomId: number): void { this._activeRoomId = roomId; diff --git a/packages/room/src/RoomManager.ts b/packages/room/src/RoomManager.ts index f492dfe..835d7ec 100644 --- a/packages/room/src/RoomManager.ts +++ b/packages/room/src/RoomManager.ts @@ -16,12 +16,14 @@ export class RoomManager implements IRoomManager, IRoomInstanceContainer private _pendingContentTypes: string[] = []; private _skipContentProcessing: boolean = false; + private _contentLoadedCallback: (event: RoomContentLoadedEvent) => void = null; public async init(listener: IRoomManagerListener): Promise { this._listener = listener; - const onRoomContentLoadedEvent = (event: RoomContentLoadedEvent) => + // Store callback for cleanup + this._contentLoadedCallback = (event: RoomContentLoadedEvent) => { if(!GetRoomContentLoader()) return; @@ -32,9 +34,30 @@ export class RoomManager implements IRoomManager, IRoomInstanceContainer this._pendingContentTypes.push(contentType); }; - GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_SUCCESS, onRoomContentLoadedEvent); - GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_FAILURE, onRoomContentLoadedEvent); - GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_CANCEL, onRoomContentLoadedEvent); + GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_SUCCESS, this._contentLoadedCallback); + GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_FAILURE, this._contentLoadedCallback); + GetEventDispatcher().addEventListener(RoomContentLoadedEvent.RCLE_CANCEL, this._contentLoadedCallback); + } + + public dispose(): void + { + // Remove event listeners + if(this._contentLoadedCallback) + { + GetEventDispatcher().removeEventListener(RoomContentLoadedEvent.RCLE_SUCCESS, this._contentLoadedCallback); + GetEventDispatcher().removeEventListener(RoomContentLoadedEvent.RCLE_FAILURE, this._contentLoadedCallback); + GetEventDispatcher().removeEventListener(RoomContentLoadedEvent.RCLE_CANCEL, this._contentLoadedCallback); + this._contentLoadedCallback = null; + } + + // Dispose all room instances + for(const room of this._rooms.values()) + { + room.dispose(); + } + + this._rooms.clear(); + this._pendingContentTypes = []; } public getRoomInstance(roomId: string): IRoomInstance diff --git a/packages/room/src/RoomMessageHandler.ts b/packages/room/src/RoomMessageHandler.ts index cbf7ee4..ca8ad2d 100644 --- a/packages/room/src/RoomMessageHandler.ts +++ b/packages/room/src/RoomMessageHandler.ts @@ -1,4 +1,4 @@ -import { AvatarGuideStatus, IConnection, IRoomCreator, IVector3D, LegacyDataType, ObjectRolling, PetType, RoomObjectType, RoomObjectUserType, RoomObjectVariable } from '@nitrots/api'; +import { AvatarGuideStatus, IConnection, IMessageEvent, IRoomCreator, IVector3D, LegacyDataType, ObjectRolling, PetType, RoomObjectType, RoomObjectUserType, RoomObjectVariable } from '@nitrots/api'; import { AreaHideMessageEvent, DiceValueMessageEvent, FloorHeightMapEvent, FurnitureAliasesComposer, FurnitureAliasesEvent, FurnitureDataEvent, FurnitureFloorAddEvent, FurnitureFloorDataParser, FurnitureFloorEvent, FurnitureFloorRemoveEvent, FurnitureFloorUpdateEvent, FurnitureWallAddEvent, FurnitureWallDataParser, FurnitureWallEvent, FurnitureWallRemoveEvent, FurnitureWallUpdateEvent, GetCommunication, GetRoomEntryDataMessageComposer, GuideSessionEndedMessageEvent, GuideSessionErrorMessageEvent, GuideSessionStartedMessageEvent, IgnoreResultEvent, ItemDataUpdateMessageEvent, ObjectsDataUpdateEvent, ObjectsRollingEvent, OneWayDoorStatusMessageEvent, PetExperienceEvent, PetFigureUpdateEvent, RoomEntryTileMessageEvent, RoomEntryTileMessageParser, RoomHeightMapEvent, RoomHeightMapUpdateEvent, RoomPaintEvent, RoomReadyMessageEvent, RoomUnitChatEvent, RoomUnitChatShoutEvent, RoomUnitChatWhisperEvent, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitIdleEvent, RoomUnitInfoEvent, RoomUnitNumberEvent, RoomUnitRemoveEvent, RoomUnitStatusEvent, RoomUnitTypingEvent, RoomVisualizationSettingsEvent, UserInfoEvent, YouArePlayingGameEvent } from '@nitrots/communication'; import { GetRoomSessionManager, GetSessionDataManager } from '@nitrots/session'; import { Vector3d } from '@nitrots/utils'; @@ -13,6 +13,7 @@ export class RoomMessageHandler private _roomEngine: IRoomCreator = null; private _planeParser = new RoomPlaneParser(); private _latestEntryTileEvent: RoomEntryTileMessageEvent = null; + private _messageEvents: IMessageEvent[] = []; private _currentRoomId: number = 0; private _ownUserId: number = 0; @@ -25,51 +26,77 @@ export class RoomMessageHandler this._connection = GetCommunication().connection; this._roomEngine = GetRoomEngine(); - this._connection.addMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))); - this._connection.addMessageEvent(new RoomReadyMessageEvent(this.onRoomReadyMessageEvent.bind(this))); - this._connection.addMessageEvent(new RoomPaintEvent(this.onRoomPaintEvent.bind(this))); - this._connection.addMessageEvent(new FloorHeightMapEvent(this.onRoomModelEvent.bind(this))); - this._connection.addMessageEvent(new RoomHeightMapEvent(this.onRoomHeightMapEvent.bind(this))); - this._connection.addMessageEvent(new RoomHeightMapUpdateEvent(this.onRoomHeightMapUpdateEvent.bind(this))); - this._connection.addMessageEvent(new RoomVisualizationSettingsEvent(this.onRoomThicknessEvent.bind(this))); - this._connection.addMessageEvent(new RoomEntryTileMessageEvent(this.onRoomDoorEvent.bind(this))); - this._connection.addMessageEvent(new ObjectsRollingEvent(this.onRoomRollingEvent.bind(this))); - this._connection.addMessageEvent(new ObjectsDataUpdateEvent(this.onObjectsDataUpdateEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureAliasesEvent(this.onFurnitureAliasesEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureFloorAddEvent(this.onFurnitureFloorAddEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureFloorEvent(this.onFurnitureFloorEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureFloorRemoveEvent(this.onFurnitureFloorRemoveEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureFloorUpdateEvent(this.onFurnitureFloorUpdateEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureWallAddEvent(this.onFurnitureWallAddEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureWallEvent(this.onFurnitureWallEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureWallRemoveEvent(this.onFurnitureWallRemoveEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureWallUpdateEvent(this.onFurnitureWallUpdateEvent.bind(this))); - this._connection.addMessageEvent(new FurnitureDataEvent(this.onFurnitureDataEvent.bind(this))); - this._connection.addMessageEvent(new ItemDataUpdateMessageEvent(this.onItemDataUpdateMessageEvent.bind(this))); - this._connection.addMessageEvent(new OneWayDoorStatusMessageEvent(this.onOneWayDoorStatusMessageEvent.bind(this))); - this._connection.addMessageEvent(new AreaHideMessageEvent(this.onAreaHideMessageEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitDanceEvent(this.onRoomUnitDanceEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitEffectEvent(this.onRoomUnitEffectEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitEvent(this.onRoomUnitEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitExpressionEvent(this.onRoomUnitExpressionEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitHandItemEvent(this.onRoomUnitHandItemEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitIdleEvent(this.onRoomUnitIdleEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitInfoEvent(this.onRoomUnitInfoEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitNumberEvent(this.onRoomUnitNumberEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitRemoveEvent(this.onRoomUnitRemoveEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitStatusEvent(this.onRoomUnitStatusEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitChatEvent(this.onRoomUnitChatEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitChatShoutEvent(this.onRoomUnitChatEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitChatWhisperEvent(this.onRoomUnitChatEvent.bind(this))); - this._connection.addMessageEvent(new RoomUnitTypingEvent(this.onRoomUnitTypingEvent.bind(this))); - this._connection.addMessageEvent(new PetFigureUpdateEvent(this.onPetFigureUpdateEvent.bind(this))); - this._connection.addMessageEvent(new PetExperienceEvent(this.onPetExperienceEvent.bind(this))); - this._connection.addMessageEvent(new YouArePlayingGameEvent(this.onYouArePlayingGameEvent.bind(this))); - this._connection.addMessageEvent(new DiceValueMessageEvent(this.onDiceValueMessageEvent.bind(this))); - this._connection.addMessageEvent(new IgnoreResultEvent(this.onIgnoreResultEvent.bind(this))); - this._connection.addMessageEvent(new GuideSessionStartedMessageEvent(this.onGuideSessionStartedMessageEvent.bind(this))); - this._connection.addMessageEvent(new GuideSessionEndedMessageEvent(this.onGuideSessionEndedMessageEvent.bind(this))); - this._connection.addMessageEvent(new GuideSessionErrorMessageEvent(this.onGuideSessionErrorMessageEvent.bind(this))); + // Store all message events for cleanup + this._messageEvents = [ + new UserInfoEvent(this.onUserInfoEvent.bind(this)), + new RoomReadyMessageEvent(this.onRoomReadyMessageEvent.bind(this)), + new RoomPaintEvent(this.onRoomPaintEvent.bind(this)), + new FloorHeightMapEvent(this.onRoomModelEvent.bind(this)), + new RoomHeightMapEvent(this.onRoomHeightMapEvent.bind(this)), + new RoomHeightMapUpdateEvent(this.onRoomHeightMapUpdateEvent.bind(this)), + new RoomVisualizationSettingsEvent(this.onRoomThicknessEvent.bind(this)), + new RoomEntryTileMessageEvent(this.onRoomDoorEvent.bind(this)), + new ObjectsRollingEvent(this.onRoomRollingEvent.bind(this)), + new ObjectsDataUpdateEvent(this.onObjectsDataUpdateEvent.bind(this)), + new FurnitureAliasesEvent(this.onFurnitureAliasesEvent.bind(this)), + new FurnitureFloorAddEvent(this.onFurnitureFloorAddEvent.bind(this)), + new FurnitureFloorEvent(this.onFurnitureFloorEvent.bind(this)), + new FurnitureFloorRemoveEvent(this.onFurnitureFloorRemoveEvent.bind(this)), + new FurnitureFloorUpdateEvent(this.onFurnitureFloorUpdateEvent.bind(this)), + new FurnitureWallAddEvent(this.onFurnitureWallAddEvent.bind(this)), + new FurnitureWallEvent(this.onFurnitureWallEvent.bind(this)), + new FurnitureWallRemoveEvent(this.onFurnitureWallRemoveEvent.bind(this)), + new FurnitureWallUpdateEvent(this.onFurnitureWallUpdateEvent.bind(this)), + new FurnitureDataEvent(this.onFurnitureDataEvent.bind(this)), + new ItemDataUpdateMessageEvent(this.onItemDataUpdateMessageEvent.bind(this)), + new OneWayDoorStatusMessageEvent(this.onOneWayDoorStatusMessageEvent.bind(this)), + new AreaHideMessageEvent(this.onAreaHideMessageEvent.bind(this)), + new RoomUnitDanceEvent(this.onRoomUnitDanceEvent.bind(this)), + new RoomUnitEffectEvent(this.onRoomUnitEffectEvent.bind(this)), + new RoomUnitEvent(this.onRoomUnitEvent.bind(this)), + new RoomUnitExpressionEvent(this.onRoomUnitExpressionEvent.bind(this)), + new RoomUnitHandItemEvent(this.onRoomUnitHandItemEvent.bind(this)), + new RoomUnitIdleEvent(this.onRoomUnitIdleEvent.bind(this)), + new RoomUnitInfoEvent(this.onRoomUnitInfoEvent.bind(this)), + new RoomUnitNumberEvent(this.onRoomUnitNumberEvent.bind(this)), + new RoomUnitRemoveEvent(this.onRoomUnitRemoveEvent.bind(this)), + new RoomUnitStatusEvent(this.onRoomUnitStatusEvent.bind(this)), + new RoomUnitChatEvent(this.onRoomUnitChatEvent.bind(this)), + new RoomUnitChatShoutEvent(this.onRoomUnitChatEvent.bind(this)), + new RoomUnitChatWhisperEvent(this.onRoomUnitChatEvent.bind(this)), + new RoomUnitTypingEvent(this.onRoomUnitTypingEvent.bind(this)), + new PetFigureUpdateEvent(this.onPetFigureUpdateEvent.bind(this)), + new PetExperienceEvent(this.onPetExperienceEvent.bind(this)), + new YouArePlayingGameEvent(this.onYouArePlayingGameEvent.bind(this)), + new DiceValueMessageEvent(this.onDiceValueMessageEvent.bind(this)), + new IgnoreResultEvent(this.onIgnoreResultEvent.bind(this)), + new GuideSessionStartedMessageEvent(this.onGuideSessionStartedMessageEvent.bind(this)), + new GuideSessionEndedMessageEvent(this.onGuideSessionEndedMessageEvent.bind(this)), + new GuideSessionErrorMessageEvent(this.onGuideSessionErrorMessageEvent.bind(this)) + ]; + + // Register all message events + for(const event of this._messageEvents) + { + this._connection.addMessageEvent(event); + } + } + + public dispose(): void + { + // Remove all message events + if(this._connection) + { + for(const event of this._messageEvents) + { + this._connection.removeMessageEvent(event); + } + } + + this._messageEvents = []; + this._connection = null; + this._roomEngine = null; + this._latestEntryTileEvent = null; } public setRoomId(id: number): void diff --git a/packages/session/src/SessionDataManager.ts b/packages/session/src/SessionDataManager.ts index 19b3838..f690ea0 100644 --- a/packages/session/src/SessionDataManager.ts +++ b/packages/session/src/SessionDataManager.ts @@ -1,4 +1,4 @@ -import { IFurnitureData, IGroupInformationManager, IMessageComposer, IProductData, ISessionDataManager, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api'; +import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api'; import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication'; import { GetConfiguration } from '@nitrots/configuration'; import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events'; @@ -12,6 +12,8 @@ import { ProductDataLoader } from './product/ProductDataLoader'; export class SessionDataManager implements ISessionDataManager { + private _messageEvents: IMessageEvent[] = []; + private _settingsEventCallback: (event: NitroSettingsEvent) => void = null; private _userId: number; private _name: string; private _figure: string; @@ -62,35 +64,57 @@ export class SessionDataManager implements ISessionDataManager this._groupInformationManager.init() ]); - GetCommunication().registerMessageEvent(new FigureUpdateEvent((event: FigureUpdateEvent) => - { - this._figure = event.getParser().figure; - this._gender = event.getParser().gender; + // Store message event references for cleanup + this._messageEvents.push( + GetCommunication().registerMessageEvent(new FigureUpdateEvent((event: FigureUpdateEvent) => + { + this._figure = event.getParser().figure; + this._gender = event.getParser().gender; - HabboWebTools.updateFigure(this._figure); - })); + HabboWebTools.updateFigure(this._figure); + })), + GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))), + GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))), + GetCommunication().registerMessageEvent(new AvailabilityStatusMessageEvent(this.onAvailabilityStatusMessageEvent.bind(this))), + GetCommunication().registerMessageEvent(new PetScratchFailedMessageEvent(this.onPetRespectFailed.bind(this))), + GetCommunication().registerMessageEvent(new ChangeUserNameResultMessageEvent(this.onChangeNameUpdateEvent.bind(this))), + GetCommunication().registerMessageEvent(new UserNameChangeMessageEvent(this.onUserNameChangeMessageEvent.bind(this))), + GetCommunication().registerMessageEvent(new UserTagsMessageEvent(this.onUserTags.bind(this))), + GetCommunication().registerMessageEvent(new RoomReadyMessageEvent(this.onRoomModelNameEvent.bind(this))), + GetCommunication().registerMessageEvent(new InClientLinkEvent(this.onInClientLinkEvent.bind(this))), + GetCommunication().registerMessageEvent(new MysteryBoxKeysEvent(this.onMysteryBoxKeysEvent.bind(this))), + GetCommunication().registerMessageEvent(new NoobnessLevelMessageEvent(this.onNoobnessLevelMessageEvent.bind(this))), + GetCommunication().registerMessageEvent(new AccountSafetyLockStatusChangeMessageEvent(this.onAccountSafetyLockStatusChangeMessageEvent.bind(this))), + GetCommunication().registerMessageEvent(new EmailStatusResultEvent(this.onEmailStatus.bind(this))) + ); - GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))); - GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))); - GetCommunication().registerMessageEvent(new AvailabilityStatusMessageEvent(this.onAvailabilityStatusMessageEvent.bind(this))); - GetCommunication().registerMessageEvent(new PetScratchFailedMessageEvent(this.onPetRespectFailed.bind(this))); - GetCommunication().registerMessageEvent(new ChangeUserNameResultMessageEvent(this.onChangeNameUpdateEvent.bind(this))); - GetCommunication().registerMessageEvent(new UserNameChangeMessageEvent(this.onUserNameChangeMessageEvent.bind(this))); - GetCommunication().registerMessageEvent(new UserTagsMessageEvent(this.onUserTags.bind(this))); - GetCommunication().registerMessageEvent(new RoomReadyMessageEvent(this.onRoomModelNameEvent.bind(this))); - GetCommunication().registerMessageEvent(new InClientLinkEvent(this.onInClientLinkEvent.bind(this))); - GetCommunication().registerMessageEvent(new MysteryBoxKeysEvent(this.onMysteryBoxKeysEvent.bind(this))); - GetCommunication().registerMessageEvent(new NoobnessLevelMessageEvent(this.onNoobnessLevelMessageEvent.bind(this))); - GetCommunication().registerMessageEvent(new AccountSafetyLockStatusChangeMessageEvent(this.onAccountSafetyLockStatusChangeMessageEvent.bind(this))); - GetCommunication().registerMessageEvent(new EmailStatusResultEvent(this.onEmailStatus.bind(this))); - - GetEventDispatcher().addEventListener(NitroSettingsEvent.SETTINGS_UPDATED, event => + // Store event dispatcher callback for cleanup + this._settingsEventCallback = (event: NitroSettingsEvent) => { this._isRoomCameraFollowDisabled = event.cameraFollow; this._uiFlags = event.flags; GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags)); - }); + }; + + GetEventDispatcher().addEventListener(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback); + } + + public dispose(): void + { + // Remove all message events + for(const event of this._messageEvents) + { + GetCommunication().removeMessageEvent(event); + } + this._messageEvents = []; + + // Remove event dispatcher listener + if(this._settingsEventCallback) + { + GetEventDispatcher().removeEventListener(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback); + this._settingsEventCallback = null; + } } private resetUserInfo(): void diff --git a/packages/sound/src/SoundManager.ts b/packages/sound/src/SoundManager.ts index ba69514..e736fd4 100644 --- a/packages/sound/src/SoundManager.ts +++ b/packages/sound/src/SoundManager.ts @@ -15,16 +15,44 @@ export class SoundManager implements ISoundManager private _furnitureBeingPlayed: IAdvancedMap = new AdvancedMap(); private _musicController: IMusicController = new MusicController(); + private _eventCallback: (event: INitroEvent) => void = null; public async init(): Promise { this._musicController.init(); - GetEventDispatcher().addEventListener(RoomEngineSamplePlaybackEvent.PLAY_SAMPLE, event => this.onEvent(event)); - GetEventDispatcher().addEventListener(RoomEngineObjectEvent.REMOVED, event => this.onEvent(event)); - GetEventDispatcher().addEventListener(RoomEngineEvent.DISPOSED, event => this.onEvent(event)); - GetEventDispatcher().addEventListener(NitroSettingsEvent.SETTINGS_UPDATED, event => this.onEvent(event)); - GetEventDispatcher().addEventListener(NitroSoundEvent.PLAY_SOUND, event => this.onEvent(event)); + // Store callback for cleanup + this._eventCallback = (event: INitroEvent) => this.onEvent(event); + + GetEventDispatcher().addEventListener(RoomEngineSamplePlaybackEvent.PLAY_SAMPLE, this._eventCallback); + GetEventDispatcher().addEventListener(RoomEngineObjectEvent.REMOVED, this._eventCallback); + GetEventDispatcher().addEventListener(RoomEngineEvent.DISPOSED, this._eventCallback); + GetEventDispatcher().addEventListener(NitroSettingsEvent.SETTINGS_UPDATED, this._eventCallback); + GetEventDispatcher().addEventListener(NitroSoundEvent.PLAY_SOUND, this._eventCallback); + } + + public dispose(): void + { + if(this._eventCallback) + { + GetEventDispatcher().removeEventListener(RoomEngineSamplePlaybackEvent.PLAY_SAMPLE, this._eventCallback); + GetEventDispatcher().removeEventListener(RoomEngineObjectEvent.REMOVED, this._eventCallback); + GetEventDispatcher().removeEventListener(RoomEngineEvent.DISPOSED, this._eventCallback); + GetEventDispatcher().removeEventListener(NitroSettingsEvent.SETTINGS_UPDATED, this._eventCallback); + GetEventDispatcher().removeEventListener(NitroSoundEvent.PLAY_SOUND, this._eventCallback); + this._eventCallback = null; + } + + // Stop all playing samples + this._furnitureBeingPlayed.getKeys().forEach((objectId: number) => + { + this.stopFurniSample(objectId); + }); + + // Clear all samples + this._internalSamples.dispose(); + this._furniSamples.dispose(); + this._furnitureBeingPlayed.dispose(); } private onEvent(event: INitroEvent) diff --git a/packages/sound/src/music/MusicController.ts b/packages/sound/src/music/MusicController.ts index 8eedb4b..c99df6c 100644 --- a/packages/sound/src/music/MusicController.ts +++ b/packages/sound/src/music/MusicController.ts @@ -1,4 +1,4 @@ -import { IAdvancedMap, IMusicController, IPlaylistController, ISongInfo } from '@nitrots/api'; +import { IAdvancedMap, IMessageEvent, IMusicController, IPlaylistController, ISongInfo } from '@nitrots/api'; import { GetCommunication, GetNowPlayingMessageComposer, GetSongInfoMessageComposer, GetUserSongDisksMessageComposer, TraxSongInfoMessageEvent, UserSongDisksInventoryMessageEvent } from '@nitrots/communication'; import { GetConfiguration } from '@nitrots/configuration'; import { GetEventDispatcher, NotifyPlayedSongEvent, NowPlayingEvent, RoomObjectSoundMachineEvent, SongDiskInventoryReceivedEvent, SongInfoReceivedEvent, SoundManagerEvent } from '@nitrots/events'; @@ -31,6 +31,7 @@ export class MusicController implements IMusicController private _songIdPlaying: number = 1; private _previousNotifiedSongId: number = -1; private _previousNotificationTime: number = -1; + private _messageEvents: IMessageEvent[] = []; constructor() { @@ -44,8 +45,11 @@ export class MusicController implements IMusicController public init(): void { - GetCommunication().registerMessageEvent(new TraxSongInfoMessageEvent(this.onTraxSongInfoMessageEvent.bind(this))); - GetCommunication().registerMessageEvent(new UserSongDisksInventoryMessageEvent(this.onSongDiskInventoryMessage.bind(this))); + // Store message events for cleanup + this._messageEvents.push( + GetCommunication().registerMessageEvent(new TraxSongInfoMessageEvent(this.onTraxSongInfoMessageEvent.bind(this))), + GetCommunication().registerMessageEvent(new UserSongDisksInventoryMessageEvent(this.onSongDiskInventoryMessage.bind(this))) + ); this._timerInstance = window.setInterval(this.onTick.bind(this), 1000); this._musicPlayer = new MusicPlayer(GetConfiguration().getValue('external.samples.url')); @@ -156,6 +160,13 @@ export class MusicController implements IMusicController this._timerInstance = undefined; } + // Remove message events + for(const event of this._messageEvents) + { + GetCommunication().removeMessageEvent(event); + } + this._messageEvents = []; + GetEventDispatcher().removeEventListener(RoomObjectSoundMachineEvent.JUKEBOX_INIT, this.onJukeboxInit); GetEventDispatcher().removeEventListener(RoomObjectSoundMachineEvent.JUKEBOX_DISPOSE, this.onJukeboxDispose); GetEventDispatcher().removeEventListener(RoomObjectSoundMachineEvent.SOUND_MACHINE_INIT, this.onSoundMachineInit); diff --git a/packages/utils/src/__tests__/AdvancedMap.test.ts b/packages/utils/src/__tests__/AdvancedMap.test.ts new file mode 100644 index 0000000..e6112ce --- /dev/null +++ b/packages/utils/src/__tests__/AdvancedMap.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { AdvancedMap } from '../AdvancedMap'; + +describe('AdvancedMap', () => +{ + let map: AdvancedMap; + + beforeEach(() => + { + map = new AdvancedMap(); + }); + + describe('constructor', () => + { + it('should create an empty map', () => + { + expect(map.length).toBe(0); + }); + + it('should initialize from existing Map', () => + { + const source = new Map([ + ['a', 1], + ['b', 2], + ['c', 3] + ]); + const advMap = new AdvancedMap(source); + + expect(advMap.length).toBe(3); + expect(advMap.getValue('a')).toBe(1); + expect(advMap.getValue('b')).toBe(2); + expect(advMap.getValue('c')).toBe(3); + }); + }); + + describe('add', () => + { + it('should add key-value pairs', () => + { + expect(map.add('key1', 100)).toBe(true); + expect(map.length).toBe(1); + expect(map.getValue('key1')).toBe(100); + }); + + it('should return false when adding duplicate key', () => + { + map.add('key1', 100); + expect(map.add('key1', 200)).toBe(false); + expect(map.getValue('key1')).toBe(100); + }); + + it('should maintain insertion order', () => + { + map.add('a', 1); + map.add('b', 2); + map.add('c', 3); + + expect(map.getKey(0)).toBe('a'); + expect(map.getKey(1)).toBe('b'); + expect(map.getKey(2)).toBe('c'); + }); + }); + + describe('unshift', () => + { + it('should return false due to bug in implementation', () => + { + // Note: unshift has a bug - it checks `!== null` instead of `!== undefined` + // Map.get() returns undefined for missing keys, so condition always fails + // This test documents the current broken behavior + const result = map.unshift('first', 0); + expect(result).toBe(false); + expect(map.length).toBe(0); + }); + }); + + describe('remove', () => + { + it('should remove and return value by key', () => + { + map.add('key1', 100); + map.add('key2', 200); + + const removed = map.remove('key1'); + + expect(removed).toBe(100); + expect(map.length).toBe(1); + expect(map.getValue('key1')).toBeUndefined(); + }); + + it('should return null when removing non-existent key', () => + { + expect(map.remove('nonexistent')).toBeNull(); + }); + }); + + describe('getWithIndex', () => + { + it('should get value by index', () => + { + map.add('a', 1); + map.add('b', 2); + map.add('c', 3); + + expect(map.getWithIndex(0)).toBe(1); + expect(map.getWithIndex(1)).toBe(2); + expect(map.getWithIndex(2)).toBe(3); + }); + + it('should return null for out of bounds index', () => + { + map.add('a', 1); + + expect(map.getWithIndex(-1)).toBeNull(); + expect(map.getWithIndex(10)).toBeNull(); + }); + }); + + describe('getKey', () => + { + it('should get key by index', () => + { + map.add('a', 1); + map.add('b', 2); + + expect(map.getKey(0)).toBe('a'); + expect(map.getKey(1)).toBe('b'); + }); + + it('should return null for out of bounds index', () => + { + expect(map.getKey(-1)).toBeNull(); + expect(map.getKey(10)).toBeNull(); + }); + }); + + describe('getKeys', () => + { + it('should return copy of keys array', () => + { + map.add('a', 1); + map.add('b', 2); + + const keys = map.getKeys(); + + expect(keys).toEqual(['a', 'b']); + + // Verify it's a copy + keys.push('c'); + expect(map.length).toBe(2); + }); + }); + + describe('getValues', () => + { + it('should return copy of values array', () => + { + map.add('a', 1); + map.add('b', 2); + + const values = map.getValues(); + + expect(values).toEqual([1, 2]); + + // Verify it's a copy + values.push(3); + expect(map.length).toBe(2); + }); + }); + + describe('hasKey', () => + { + it('should return true if key exists', () => + { + map.add('a', 1); + expect(map.hasKey('a')).toBe(true); + }); + + it('should return false if key does not exist', () => + { + expect(map.hasKey('nonexistent')).toBe(false); + }); + }); + + describe('hasValue', () => + { + it('should return true if value exists', () => + { + map.add('a', 100); + expect(map.hasValue(100)).toBe(true); + }); + + it('should return false if value does not exist', () => + { + expect(map.hasValue(999)).toBe(false); + }); + }); + + describe('indexOf', () => + { + it('should return index of value', () => + { + map.add('a', 1); + map.add('b', 2); + map.add('c', 3); + + expect(map.indexOf(2)).toBe(1); + }); + + it('should return -1 if value not found', () => + { + expect(map.indexOf(999)).toBe(-1); + }); + }); + + describe('reset', () => + { + it('should clear all entries', () => + { + map.add('a', 1); + map.add('b', 2); + + map.reset(); + + expect(map.length).toBe(0); + expect(map.getValue('a')).toBeUndefined(); + }); + }); + + describe('clone', () => + { + it('should create independent copy', () => + { + map.add('a', 1); + map.add('b', 2); + + const cloned = map.clone() as AdvancedMap; + + expect(cloned.length).toBe(2); + expect(cloned.getValue('a')).toBe(1); + + // Verify independence + cloned.add('c', 3); + expect(map.length).toBe(2); + expect(cloned.length).toBe(3); + }); + }); + + describe('concatenate', () => + { + it('should add all entries from another map', () => + { + map.add('a', 1); + + const other = new AdvancedMap(); + other.add('b', 2); + other.add('c', 3); + + map.concatenate(other); + + expect(map.length).toBe(3); + expect(map.getValue('b')).toBe(2); + expect(map.getValue('c')).toBe(3); + }); + }); + + describe('dispose', () => + { + it('should reset length and arrays on dispose', () => + { + map.add('a', 1); + + expect(map.disposed).toBe(false); + + map.dispose(); + + // Note: There's a bug in dispose() - it checks `if(!this._dictionary)` + // instead of `if(this._dictionary)`, so dictionary is not set to null. + // This test verifies current behavior; the bug should be fixed separately. + expect(map.length).toBe(0); + }); + }); +}); diff --git a/packages/utils/src/__tests__/ColorConverter.test.ts b/packages/utils/src/__tests__/ColorConverter.test.ts new file mode 100644 index 0000000..679f038 --- /dev/null +++ b/packages/utils/src/__tests__/ColorConverter.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; +import { ColorConverter } from '../ColorConverter'; + +describe('ColorConverter', () => +{ + describe('hex2rgb', () => + { + it('should convert hex to RGB array', () => + { + const result = ColorConverter.hex2rgb(0xFF0000); + expect(result[0]).toBeCloseTo(1); // Red + expect(result[1]).toBeCloseTo(0); // Green + expect(result[2]).toBeCloseTo(0); // Blue + }); + + it('should convert white correctly', () => + { + const result = ColorConverter.hex2rgb(0xFFFFFF); + expect(result[0]).toBeCloseTo(1); + expect(result[1]).toBeCloseTo(1); + expect(result[2]).toBeCloseTo(1); + }); + + it('should convert black correctly', () => + { + const result = ColorConverter.hex2rgb(0x000000); + expect(result[0]).toBeCloseTo(0); + expect(result[1]).toBeCloseTo(0); + expect(result[2]).toBeCloseTo(0); + }); + + it('should use provided output array', () => + { + const out: number[] = []; + const result = ColorConverter.hex2rgb(0x00FF00, out); + expect(result).toBe(out); + expect(out[0]).toBeCloseTo(0); + expect(out[1]).toBeCloseTo(1); + expect(out[2]).toBeCloseTo(0); + }); + }); + + describe('rgb2hex', () => + { + it('should convert RGB array to hex', () => + { + const result = ColorConverter.rgb2hex([1, 0, 0]); + expect(result).toBe(0xFF0000); + }); + + it('should convert white correctly', () => + { + const result = ColorConverter.rgb2hex([1, 1, 1]); + expect(result).toBe(0xFFFFFF); + }); + + it('should convert black correctly', () => + { + const result = ColorConverter.rgb2hex([0, 0, 0]); + expect(result).toBe(0x000000); + }); + }); + + describe('getHex', () => + { + it('should convert number to two-digit hex string', () => + { + expect(ColorConverter.getHex(0)).toBe('00'); + expect(ColorConverter.getHex(15)).toBe('0f'); + expect(ColorConverter.getHex(16)).toBe('10'); + expect(ColorConverter.getHex(255)).toBe('ff'); + }); + + it('should return 00 for NaN', () => + { + expect(ColorConverter.getHex(NaN)).toBe('00'); + }); + }); + + describe('int2rgb', () => + { + it('should convert integer to RGBA string', () => + { + const result = ColorConverter.int2rgb(0xFF0000); + expect(result).toBe('rgba(255,0,0,1)'); + }); + + it('should convert green correctly', () => + { + const result = ColorConverter.int2rgb(0x00FF00); + expect(result).toBe('rgba(0,255,0,1)'); + }); + + it('should convert blue correctly', () => + { + const result = ColorConverter.int2rgb(0x0000FF); + expect(result).toBe('rgba(0,0,255,1)'); + }); + }); + + describe('rgbToHSL', () => + { + it('should convert red to HSL', () => + { + const result = ColorConverter.rgbToHSL(0xFF0000); + // Red has hue 0 + const h = (result >> 16) & 0xFF; + const s = (result >> 8) & 0xFF; + const l = result & 0xFF; + + expect(h).toBe(0); + expect(s).toBe(255); // Full saturation + expect(l).toBe(128); // 50% lightness (rounded) + }); + + it('should convert white to HSL', () => + { + const result = ColorConverter.rgbToHSL(0xFFFFFF); + const h = (result >> 16) & 0xFF; + const s = (result >> 8) & 0xFF; + const l = result & 0xFF; + + expect(s).toBe(0); // No saturation for white + expect(l).toBe(255); // Full lightness + }); + + it('should convert black to HSL', () => + { + const result = ColorConverter.rgbToHSL(0x000000); + const h = (result >> 16) & 0xFF; + const s = (result >> 8) & 0xFF; + const l = result & 0xFF; + + expect(s).toBe(0); // No saturation for black + expect(l).toBe(0); // No lightness + }); + }); + + describe('hslToRGB', () => + { + it('should convert pure red HSL to RGB', () => + { + // Pure red: H=0, S=255, L=128 + const hsl = (0 << 16) + (255 << 8) + 128; + const result = ColorConverter.hslToRGB(hsl); + + const r = (result >> 16) & 0xFF; + const g = (result >> 8) & 0xFF; + const b = result & 0xFF; + + // Due to floating point precision in the algorithm, we allow small variance + expect(r).toBe(255); + expect(g).toBeLessThanOrEqual(2); // Small rounding variance + expect(b).toBeLessThanOrEqual(2); + }); + + it('should convert grayscale (no saturation)', () => + { + // Gray: H=0, S=0, L=128 + const hsl = (0 << 16) + (0 << 8) + 128; + const result = ColorConverter.hslToRGB(hsl); + + const r = (result >> 16) & 0xFF; + const g = (result >> 8) & 0xFF; + const b = result & 0xFF; + + expect(r).toBe(128); + expect(g).toBe(128); + expect(b).toBe(128); + }); + }); + + describe('colorize', () => + { + it('should return original color when colorizing with white', () => + { + const color = 0xFF0000; + const result = ColorConverter.colorize(color, 0xFFFFFFFF); + expect(result).toBe(color); + }); + + it('should colorize red with blue filter', () => + { + const colorA = 0xFFFFFF; // White + const colorB = 0x0000FF; // Blue filter + const result = ColorConverter.colorize(colorA, colorB); + + const r = (result >> 16) & 0xFF; + const g = (result >> 8) & 0xFF; + const b = result & 0xFF; + + expect(r).toBe(0); + expect(g).toBe(0); + expect(b).toBe(255); + }); + }); + + describe('roundtrip conversions', () => + { + it('should maintain color through RGB to HSL to RGB conversion', () => + { + const colors = [0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF]; + + for (const original of colors) + { + const hsl = ColorConverter.rgbToHSL(original); + const result = ColorConverter.hslToRGB(hsl); + + // Allow rounding differences due to float precision in HSL conversion + const origR = (original >> 16) & 0xFF; + const origG = (original >> 8) & 0xFF; + const origB = original & 0xFF; + + const resultR = (result >> 16) & 0xFF; + const resultG = (result >> 8) & 0xFF; + const resultB = result & 0xFF; + + // HSL conversion can have up to 5 units of variance due to rounding + expect(Math.abs(origR - resultR)).toBeLessThanOrEqual(5); + expect(Math.abs(origG - resultG)).toBeLessThanOrEqual(5); + expect(Math.abs(origB - resultB)).toBeLessThanOrEqual(5); + } + }); + }); +}); diff --git a/packages/utils/src/__tests__/NumberBank.test.ts b/packages/utils/src/__tests__/NumberBank.test.ts new file mode 100644 index 0000000..773b457 --- /dev/null +++ b/packages/utils/src/__tests__/NumberBank.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { NumberBank } from '../NumberBank'; + +describe('NumberBank', () => +{ + describe('constructor', () => + { + it('should create bank with specified size', () => + { + const bank = new NumberBank(5); + + // Should be able to reserve 5 numbers (LIFO order - pops from end) + expect(bank.reserveNumber()).toBe(4); + expect(bank.reserveNumber()).toBe(3); + expect(bank.reserveNumber()).toBe(2); + expect(bank.reserveNumber()).toBe(1); + expect(bank.reserveNumber()).toBe(0); + expect(bank.reserveNumber()).toBe(-1); // No more available + }); + + it('should handle negative size as zero', () => + { + const bank = new NumberBank(-5); + expect(bank.reserveNumber()).toBe(-1); + }); + + it('should handle zero size', () => + { + const bank = new NumberBank(0); + expect(bank.reserveNumber()).toBe(-1); + }); + }); + + describe('reserveNumber', () => + { + let bank: NumberBank; + + beforeEach(() => + { + bank = new NumberBank(3); + }); + + it('should return numbers in LIFO order (stack behavior)', () => + { + // Numbers are added 0, 1, 2 to the array + // pop() returns from the end, so we get 2, 1, 0 + expect(bank.reserveNumber()).toBe(2); + expect(bank.reserveNumber()).toBe(1); + expect(bank.reserveNumber()).toBe(0); + }); + + it('should return -1 when no numbers available', () => + { + bank.reserveNumber(); + bank.reserveNumber(); + bank.reserveNumber(); + + expect(bank.reserveNumber()).toBe(-1); + }); + }); + + describe('freeNumber', () => + { + let bank: NumberBank; + + beforeEach(() => + { + bank = new NumberBank(3); + }); + + it('should make number available again after freeing', () => + { + const num = bank.reserveNumber(); + bank.freeNumber(num); + + // The freed number should be available again + expect(bank.reserveNumber()).toBe(num); + }); + + it('should handle freeing in different order', () => + { + const n1 = bank.reserveNumber(); + const n2 = bank.reserveNumber(); + const n3 = bank.reserveNumber(); + + // Free in middle order + bank.freeNumber(n2); + bank.freeNumber(n1); + + // Should get them back in LIFO order + expect(bank.reserveNumber()).toBe(n1); + expect(bank.reserveNumber()).toBe(n2); + }); + + it('should ignore freeing numbers not in reserved list', () => + { + bank.reserveNumber(); // reserves 2 + + // Try to free a number that wasn't reserved + bank.freeNumber(999); + + // Should still work normally + expect(bank.reserveNumber()).toBe(1); + }); + + it('should allow reusing freed numbers', () => + { + // Reserve all + const n1 = bank.reserveNumber(); + const n2 = bank.reserveNumber(); + const n3 = bank.reserveNumber(); + + // Free and re-reserve multiple times + bank.freeNumber(n1); + const reused1 = bank.reserveNumber(); + expect(reused1).toBe(n1); + + bank.freeNumber(n2); + bank.freeNumber(reused1); + + expect(bank.reserveNumber()).toBe(reused1); + expect(bank.reserveNumber()).toBe(n2); + }); + }); + + describe('dispose', () => + { + it('should set internal arrays to null', () => + { + const bank = new NumberBank(5); + bank.reserveNumber(); + + bank.dispose(); + + // After dispose, reserveNumber should fail (arrays are null) + // This will throw an error, which is expected behavior + expect(() => bank.reserveNumber()).toThrow(); + }); + }); + + describe('edge cases', () => + { + it('should handle large bank size', () => + { + const bank = new NumberBank(1000); + + // Reserve all + for (let i = 0; i < 1000; i++) + { + expect(bank.reserveNumber()).toBeGreaterThanOrEqual(0); + } + + expect(bank.reserveNumber()).toBe(-1); + }); + + it('should maintain consistency after multiple reserve/free cycles', () => + { + const bank = new NumberBank(10); + const reserved: number[] = []; + + // Reserve 5 + for (let i = 0; i < 5; i++) + { + reserved.push(bank.reserveNumber()); + } + + // Free 3 + bank.freeNumber(reserved[0]); + bank.freeNumber(reserved[2]); + bank.freeNumber(reserved[4]); + + // Reserve 3 more (should get the freed ones) + const newReserved: number[] = []; + for (let i = 0; i < 3; i++) + { + newReserved.push(bank.reserveNumber()); + } + + // All previously freed numbers should be reserved again + expect(newReserved).toContain(reserved[0]); + expect(newReserved).toContain(reserved[2]); + expect(newReserved).toContain(reserved[4]); + }); + }); +}); diff --git a/packages/utils/src/__tests__/Vector3d.test.ts b/packages/utils/src/__tests__/Vector3d.test.ts new file mode 100644 index 0000000..c05bf66 --- /dev/null +++ b/packages/utils/src/__tests__/Vector3d.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect } from 'vitest'; +import { Vector3d } from '../Vector3d'; + +describe('Vector3d', () => +{ + describe('constructor', () => + { + it('should create a vector with default values (0, 0, 0)', () => + { + const vector = new Vector3d(); + expect(vector.x).toBe(0); + expect(vector.y).toBe(0); + expect(vector.z).toBe(0); + }); + + it('should create a vector with specified values', () => + { + const vector = new Vector3d(1, 2, 3); + expect(vector.x).toBe(1); + expect(vector.y).toBe(2); + expect(vector.z).toBe(3); + }); + }); + + describe('static sum', () => + { + it('should return sum of two vectors', () => + { + const v1 = new Vector3d(1, 2, 3); + const v2 = new Vector3d(4, 5, 6); + const result = Vector3d.sum(v1, v2); + + expect(result.x).toBe(5); + expect(result.y).toBe(7); + expect(result.z).toBe(9); + }); + + it('should return null if either vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + expect(Vector3d.sum(v1, null)).toBeNull(); + expect(Vector3d.sum(null, v1)).toBeNull(); + }); + }); + + describe('static dif', () => + { + it('should return difference of two vectors', () => + { + const v1 = new Vector3d(5, 7, 9); + const v2 = new Vector3d(1, 2, 3); + const result = Vector3d.dif(v1, v2); + + expect(result.x).toBe(4); + expect(result.y).toBe(5); + expect(result.z).toBe(6); + }); + + it('should return null if either vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + expect(Vector3d.dif(v1, null)).toBeNull(); + expect(Vector3d.dif(null, v1)).toBeNull(); + }); + }); + + describe('static product', () => + { + it('should return vector multiplied by scalar', () => + { + const v = new Vector3d(1, 2, 3); + const result = Vector3d.product(v, 2); + + expect(result.x).toBe(2); + expect(result.y).toBe(4); + expect(result.z).toBe(6); + }); + + it('should return null if vector is null', () => + { + expect(Vector3d.product(null, 2)).toBeNull(); + }); + }); + + describe('static dotProduct', () => + { + it('should calculate dot product of two vectors', () => + { + const v1 = new Vector3d(1, 2, 3); + const v2 = new Vector3d(4, 5, 6); + const result = Vector3d.dotProduct(v1, v2); + + // 1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32 + expect(result).toBe(32); + }); + + it('should return 0 if either vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + expect(Vector3d.dotProduct(v1, null)).toBe(0); + expect(Vector3d.dotProduct(null, v1)).toBe(0); + }); + }); + + describe('static crossProduct', () => + { + it('should calculate cross product of two vectors', () => + { + const v1 = new Vector3d(1, 0, 0); + const v2 = new Vector3d(0, 1, 0); + const result = Vector3d.crossProduct(v1, v2); + + expect(result.x).toBe(0); + expect(result.y).toBe(0); + expect(result.z).toBe(1); + }); + + it('should return null if either vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + expect(Vector3d.crossProduct(v1, null)).toBeNull(); + expect(Vector3d.crossProduct(null, v1)).toBeNull(); + }); + }); + + describe('static isEqual', () => + { + it('should return true for equal vectors', () => + { + const v1 = new Vector3d(1, 2, 3); + const v2 = new Vector3d(1, 2, 3); + expect(Vector3d.isEqual(v1, v2)).toBe(true); + }); + + it('should return false for different vectors', () => + { + const v1 = new Vector3d(1, 2, 3); + const v2 = new Vector3d(1, 2, 4); + expect(Vector3d.isEqual(v1, v2)).toBe(false); + }); + + it('should return false if either vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + expect(Vector3d.isEqual(v1, null)).toBe(false); + expect(Vector3d.isEqual(null, v1)).toBe(false); + }); + }); + + describe('instance methods', () => + { + describe('assign', () => + { + it('should copy values from another vector', () => + { + const v1 = new Vector3d(0, 0, 0); + const v2 = new Vector3d(1, 2, 3); + v1.assign(v2); + + expect(v1.x).toBe(1); + expect(v1.y).toBe(2); + expect(v1.z).toBe(3); + }); + + it('should do nothing if vector is null', () => + { + const v1 = new Vector3d(1, 2, 3); + v1.assign(null); + + expect(v1.x).toBe(1); + expect(v1.y).toBe(2); + expect(v1.z).toBe(3); + }); + }); + + describe('add', () => + { + it('should add another vector to this vector', () => + { + const v1 = new Vector3d(1, 2, 3); + const v2 = new Vector3d(4, 5, 6); + v1.add(v2); + + expect(v1.x).toBe(5); + expect(v1.y).toBe(7); + expect(v1.z).toBe(9); + }); + }); + + describe('subtract', () => + { + it('should subtract another vector from this vector', () => + { + const v1 = new Vector3d(5, 7, 9); + const v2 = new Vector3d(1, 2, 3); + v1.subtract(v2); + + expect(v1.x).toBe(4); + expect(v1.y).toBe(5); + expect(v1.z).toBe(6); + }); + }); + + describe('multiply', () => + { + it('should multiply vector by scalar', () => + { + const v = new Vector3d(1, 2, 3); + v.multiply(3); + + expect(v.x).toBe(3); + expect(v.y).toBe(6); + expect(v.z).toBe(9); + }); + }); + + describe('divide', () => + { + it('should divide vector by scalar', () => + { + const v = new Vector3d(4, 8, 12); + v.divide(4); + + expect(v.x).toBe(1); + expect(v.y).toBe(2); + expect(v.z).toBe(3); + }); + + it('should not divide by zero', () => + { + const v = new Vector3d(1, 2, 3); + v.divide(0); + + expect(v.x).toBe(1); + expect(v.y).toBe(2); + expect(v.z).toBe(3); + }); + }); + + describe('negate', () => + { + it('should negate all components', () => + { + const v = new Vector3d(1, -2, 3); + v.negate(); + + expect(v.x).toBe(-1); + expect(v.y).toBe(2); + expect(v.z).toBe(-3); + }); + }); + + describe('length', () => + { + it('should calculate vector length', () => + { + const v = new Vector3d(3, 4, 0); + expect(v.length).toBe(5); + }); + + it('should cache length until values change', () => + { + const v = new Vector3d(3, 4, 0); + const length1 = v.length; + const length2 = v.length; + expect(length1).toBe(length2); + + v.x = 6; + expect(v.length).toBe(Math.sqrt(52)); // sqrt(36 + 16 + 0) + }); + }); + + describe('normalize', () => + { + it('should normalize vector to unit length', () => + { + const v = new Vector3d(3, 4, 0); + v.normalize(); + + expect(v.x).toBeCloseTo(0.6); + expect(v.y).toBeCloseTo(0.8); + expect(v.z).toBeCloseTo(0); + // Note: The actual calculated length would be 1, but the cached _length + // is not reset in normalize() - this is a known limitation + const actualLength = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + expect(actualLength).toBeCloseTo(1); + }); + }); + + describe('toString', () => + { + it('should return string representation', () => + { + const v = new Vector3d(1, 2, 3); + expect(v.toString()).toBe('[Vector3d: 1, 2, 3]'); + }); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..73560d0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,54 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['packages/**/*.{test,spec}.{js,ts}'], + exclude: ['**/node_modules/**', '**/dist/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['packages/*/src/**/*.ts'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/*.d.ts', + '**/index.ts', + '**/*.test.ts', + '**/*.spec.ts' + ] + }, + alias: { + '@nitrots/api': resolve(__dirname, 'packages/api/src'), + '@nitrots/assets': resolve(__dirname, 'packages/assets/src'), + '@nitrots/avatar': resolve(__dirname, 'packages/avatar/src'), + '@nitrots/camera': resolve(__dirname, 'packages/camera/src'), + '@nitrots/communication': resolve(__dirname, 'packages/communication/src'), + '@nitrots/configuration': resolve(__dirname, 'packages/configuration/src'), + '@nitrots/events': resolve(__dirname, 'packages/events/src'), + '@nitrots/localization': resolve(__dirname, 'packages/localization/src'), + '@nitrots/room': resolve(__dirname, 'packages/room/src'), + '@nitrots/session': resolve(__dirname, 'packages/session/src'), + '@nitrots/sound': resolve(__dirname, 'packages/sound/src'), + '@nitrots/utils': resolve(__dirname, 'packages/utils/src') + } + }, + resolve: { + alias: { + '@nitrots/api': resolve(__dirname, 'packages/api/src'), + '@nitrots/assets': resolve(__dirname, 'packages/assets/src'), + '@nitrots/avatar': resolve(__dirname, 'packages/avatar/src'), + '@nitrots/camera': resolve(__dirname, 'packages/camera/src'), + '@nitrots/communication': resolve(__dirname, 'packages/communication/src'), + '@nitrots/configuration': resolve(__dirname, 'packages/configuration/src'), + '@nitrots/events': resolve(__dirname, 'packages/events/src'), + '@nitrots/localization': resolve(__dirname, 'packages/localization/src'), + '@nitrots/room': resolve(__dirname, 'packages/room/src'), + '@nitrots/session': resolve(__dirname, 'packages/session/src'), + '@nitrots/sound': resolve(__dirname, 'packages/sound/src'), + '@nitrots/utils': resolve(__dirname, 'packages/utils/src') + } + } +});