diff --git a/.gitignore b/.gitignore index 12a04914a..bb6fbf330 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ Thumbs.db packages/phoenix-ng/projects/phoenix-app/cypress/videos packages/phoenix-ng/projects/phoenix-app/cypress/screenshots packages/phoenix-ng/projects/phoenix-app/cypress/downloads +packages/phoenix-event-display/documentation/js/routes/routes_index.js # Added as per https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored .pnp.* diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index 6283026b0..5556ce8c8 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -39,6 +39,19 @@ export class EventDisplay { private onEventsChange: ((events: any) => void)[] = []; /** Array containing callbacks to be called when the displayed event changes. */ private onDisplayedEventChange: ((nowDisplayingEvent: any) => void)[] = []; + /** Array containing callbacks to be called when an object is selected. */ + private onObjectSelectedCallbacks: ((object: any, data: any) => void)[] = []; + /** Array containing callbacks to be called when an object is deselected. */ + private onObjectDeselectedCallbacks: ((object: any) => void)[] = []; + /** Array containing callbacks to be called when an object is hovered. */ + private onObjectHoveredCallbacks: ((object: any, data: any) => void)[] = []; + /** Array containing callbacks to be called when object hover ends. */ + private onObjectHoverEndCallbacks: ((object: any) => void)[] = []; + /** Array containing callbacks to be called when selection state changes. */ + private onSelectionChangedCallbacks: (( + selectedObjects: any[], + selectionData: any, + ) => void)[] = []; /** Three manager for three.js operations. */ private graphicsLibrary: ThreeManager; /** Info logger for storing event display logs. */ @@ -83,6 +96,10 @@ export class EventDisplay { this.graphicsLibrary.init(configuration); // Initialize the UI with configuration this.ui.init(configuration); + + // Set up selection callbacks for external integrations + this.setupSelectionCallbacks(); + // Set up for the state manager this.getStateManager().setEventDisplay(this); @@ -117,6 +134,11 @@ export class EventDisplay { // Clear accumulated callbacks this.onEventsChange = []; this.onDisplayedEventChange = []; + this.onObjectSelectedCallbacks = []; + this.onObjectDeselectedCallbacks = []; + this.onObjectHoveredCallbacks = []; + this.onObjectHoverEndCallbacks = []; + this.onSelectionChangedCallbacks = []; // Reset singletons for clean view transition this.loadingManager?.reset(); this.stateManager?.resetForViewTransition(); @@ -610,6 +632,158 @@ export class EventDisplay { }; } + /** + * Add a callback to be invoked when an object is selected. + * @param callback Callback receiving the selected object and associated data. + * @returns Unsubscribe function to remove the callback. + */ + public onObjectSelected( + callback: (object: any, data?: any) => void, + ): () => void { + this.onObjectSelectedCallbacks.push(callback); + return () => { + const index = this.onObjectSelectedCallbacks.indexOf(callback); + if (index > -1) { + this.onObjectSelectedCallbacks.splice(index, 1); + } + }; + } + + /** + * Add a callback to be invoked when an object is deselected. + * @param callback Callback receiving the deselected object. + * @returns Unsubscribe function to remove the callback. + */ + public onObjectDeselected(callback: (object: any) => void): () => void { + this.onObjectDeselectedCallbacks.push(callback); + return () => { + const index = this.onObjectDeselectedCallbacks.indexOf(callback); + if (index > -1) { + this.onObjectDeselectedCallbacks.splice(index, 1); + } + }; + } + + /** + * Add a callback to be invoked when an object is hovered. + * @param callback Callback receiving the hovered object and associated data. + * @returns Unsubscribe function to remove the callback. + */ + public onObjectHovered( + callback: (object: any, data?: any) => void, + ): () => void { + this.onObjectHoveredCallbacks.push(callback); + return () => { + const index = this.onObjectHoveredCallbacks.indexOf(callback); + if (index > -1) { + this.onObjectHoveredCallbacks.splice(index, 1); + } + }; + } + + /** + * Add a callback to be invoked when object hover ends. + * @param callback Callback receiving the object that was hovered. + * @returns Unsubscribe function to remove the callback. + */ + public onObjectHoverEnd(callback: (object: any) => void): () => void { + this.onObjectHoverEndCallbacks.push(callback); + return () => { + const index = this.onObjectHoverEndCallbacks.indexOf(callback); + if (index > -1) { + this.onObjectHoverEndCallbacks.splice(index, 1); + } + }; + } + + /** + * Add a callback to be invoked when the selection state changes. + * @param callback Callback receiving the list of selected objects and selection data. + * @returns Unsubscribe function to remove the callback. + */ + public onSelectionChanged( + callback: (selectedObjects: any[], selectionData?: any) => void, + ): () => void { + this.onSelectionChangedCallbacks.push(callback); + return () => { + const index = this.onSelectionChangedCallbacks.indexOf(callback); + if (index > -1) { + this.onSelectionChangedCallbacks.splice(index, 1); + } + }; + } + + /** + * Internal method to fire object selected callbacks. + * Called by the SelectionManager when an object is selected. + * @internal + */ + private fireObjectSelectedCallback(object: any, data?: any) { + this.onObjectSelectedCallbacks.forEach((callback) => + callback(object, data), + ); + } + + /** + * Internal method to fire object deselected callbacks. + * Called by the SelectionManager when an object is deselected. + * @internal + */ + private fireObjectDeselectedCallback(object: any, data?: any) { + this.onObjectDeselectedCallbacks.forEach((callback) => callback(object)); + } + + /** + * Internal method to fire object hovered callbacks. + * Called by the SelectionManager when an object is hovered. + * @internal + */ + private fireObjectHoveredCallback(object: any, data?: any) { + this.onObjectHoveredCallbacks.forEach((callback) => callback(object, data)); + } + + /** + * Internal method to fire object hover end callbacks. + * Called by the SelectionManager when hover ends. + * @internal + */ + private fireObjectHoverEndCallback(object: any, data?: any) { + this.onObjectHoverEndCallbacks.forEach((callback) => callback(object)); + } + + /** + * Internal method to fire selection changed callbacks. + * Called by the SelectionManager when selection state changes. + * @internal + */ + private fireSelectionChangedCallback( + selectedObjects: any[], + selectionData?: any, + ) { + this.onSelectionChangedCallbacks.forEach((callback) => + callback(selectedObjects, selectionData), + ); + } + + /** + * Set up selection callbacks on ThreeManager's SelectionManager. + * This connects the internal fire*Callback methods to the SelectionManager. + * @private + */ + private setupSelectionCallbacks(): void { + this.graphicsLibrary.setSelectionCallbacks( + (object: any, data?: any) => + this.fireObjectSelectedCallback(object, data), + (object: any, data?: any) => + this.fireObjectDeselectedCallback(object, data), + (object: any, data?: any) => this.fireObjectHoveredCallback(object, data), + (object: any, data?: any) => + this.fireObjectHoverEndCallback(object, data), + (selectedObjects: any[], data?: any) => + this.fireSelectionChangedCallback(selectedObjects, data), + ); + } + /** * Get metadata associated to the displayed event (experiment info, time, run, event...). * @returns Metadata of the displayed event. diff --git a/packages/phoenix-event-display/src/managers/three-manager/index.ts b/packages/phoenix-event-display/src/managers/three-manager/index.ts index e485d753c..31b0d0e23 100644 --- a/packages/phoenix-event-display/src/managers/three-manager/index.ts +++ b/packages/phoenix-event-display/src/managers/three-manager/index.ts @@ -1192,6 +1192,53 @@ export class ThreeManager { return this.selectionManager; } + /** + * Set selection callbacks on the SelectionManager for EventDisplay integration. + * @param onObjectSelectedCallback Called when an object is selected + * @param onObjectDeselectedCallback Called when an object is deselected + * @param onObjectHoveredCallback Called when an object is hovered + * @param onObjectHoverEndCallback Called when hover ends + * @param onSelectionChangedCallback Called when selection changes + * @internal Used by EventDisplay to integrate selection callbacks + */ + public setSelectionCallbacks( + onObjectSelectedCallback?: ((object: any, data?: any) => void) | null, + onObjectDeselectedCallback?: ((object: any) => void) | null, + onObjectHoveredCallback?: ((object: any, data?: any) => void) | null, + onObjectHoverEndCallback?: ((object: any) => void) | null, + onSelectionChangedCallback?: + | ((selectedObjects: any[], selectionData?: any) => void) + | null, + ): void { + const selectionManager = this.getSelectionManager(); + + if (onObjectSelectedCallback !== undefined) { + selectionManager.setOnObjectSelectedCallback(onObjectSelectedCallback); + } + if (onObjectDeselectedCallback !== undefined) { + selectionManager.setOnObjectDeselectedCallback( + onObjectDeselectedCallback, + ); + } + if (onObjectHoveredCallback !== undefined) { + selectionManager.setOnObjectHoveredCallback(onObjectHoveredCallback); + } + if (onObjectHoverEndCallback !== undefined) { + selectionManager.setOnObjectHoverEndCallback(onObjectHoverEndCallback); + } + if (onSelectionChangedCallback !== undefined) { + selectionManager.setOnSelectionChangedCallback( + onSelectionChangedCallback + ? (set, arr) => + onSelectionChangedCallback(arr, { + selectionSet: set, + selectionArray: arr, + }) + : null, + ); + } + } + /** * Animates camera position. * @param cameraPosition End position. diff --git a/packages/phoenix-event-display/src/managers/three-manager/selection-manager.ts b/packages/phoenix-event-display/src/managers/three-manager/selection-manager.ts index 45928fcb1..7f1957369 100644 --- a/packages/phoenix-event-display/src/managers/three-manager/selection-manager.ts +++ b/packages/phoenix-event-display/src/managers/three-manager/selection-manager.ts @@ -106,6 +106,28 @@ export class SelectionManager { TO_LOW_SKIP: 45, // FPS above 45 → skip 3 frames TO_MINIMAL_SKIP: 90, // FPS above 55 → skip 1 frame }; + + // External callbacks for integration with EventDisplay and external applications + /** Callback function called when an object is selected */ + private onObjectSelectedCallback: + | ((object: Mesh, data?: any) => void) + | null = null; + /** Callback function called when an object is deselected */ + private onObjectDeselectedCallback: + | ((object: Mesh, data?: any) => void) + | null = null; + /** Callback function called when an object is hovered */ + private onObjectHoveredCallback: ((object: Mesh, data?: any) => void) | null = + null; + /** Callback function called when hover ends */ + private onObjectHoverEndCallback: + | ((object: Mesh, data?: any) => void) + | null = null; + /** Callback function called when selection changes */ + private onSelectionChangedCallback: + | ((selectedObjects: Set, selectedObjectsArray: Mesh[]) => void) + | null = null; + /** * Constructor for the selection manager. */ @@ -527,7 +549,7 @@ export class SelectionManager { // Clear hover outline when drag starts to provide immediate visual feedback this.effectsManager.setHoverOutline(null); - this.hoveredObject = null; + this.setHoveredObject(null); this.currentlyOutlinedObject = null; } } @@ -557,7 +579,7 @@ export class SelectionManager { // Only update hover outline if the target object has changed if (targetObject !== this.hoveredObject) { - this.hoveredObject = targetObject; + this.setHoveredObject(targetObject); // Set hover outline (this is separate from sticky selections) this.effectsManager.setHoverOutline(targetObject); @@ -620,25 +642,31 @@ export class SelectionManager { } if (intersectedObject) { - // Toggle selection of the clicked object - const wasSelected = this.selectedObjects.has(intersectedObject); - const isNowSelected = - this.effectsManager.toggleSelection(intersectedObject); - - if (isNowSelected) { - this.selectedObjects.add(intersectedObject); - } else { - this.selectedObjects.delete(intersectedObject); - } - - // Log the selection/deselection (no info panel update) - this.logSelectionAction(intersectedObject, isNowSelected); + // Use unified selection API so normal interactions and programmatic + // interactions trigger identical callback behavior. + this.toggleObjectSelection(intersectedObject); } else { // Clicked on empty space - clear all selections if (this.selectedObjects.size > 0) { + const previouslySelected = Array.from(this.selectedObjects); this.effectsManager.clearAllSelections(); this.selectedObjects.clear(); + if (this.onObjectDeselectedCallback) { + previouslySelected.forEach((obj) => { + const objectData = { + uuid: obj.uuid, + name: obj.name, + userData: obj.userData, + }; + this.onObjectDeselectedCallback(obj, objectData); + }); + } + + if (this.onSelectionChangedCallback) { + this.onSelectionChangedCallback(this.selectedObjects, []); + } + // Log that selections were cleared this.infoLogger.add('All selections cleared', 'Selection'); } @@ -1046,6 +1074,23 @@ export class SelectionManager { this.effectsManager.selectObject(object); this.selectedObjects.add(object); this.logSelectionAction(object, true); + + // Fire callbacks + if (this.onObjectSelectedCallback) { + const objectData = { + uuid: object.uuid, + name: object.name, + userData: object.userData, + }; + this.onObjectSelectedCallback(object, objectData); + } + if (this.onSelectionChangedCallback) { + this.onSelectionChangedCallback( + this.selectedObjects, + Array.from(this.selectedObjects), + ); + } + return true; } @@ -1062,6 +1107,23 @@ export class SelectionManager { this.effectsManager.deselectObject(object); this.selectedObjects.delete(object); this.logSelectionAction(object, false); + + // Fire callbacks + if (this.onObjectDeselectedCallback) { + const objectData = { + uuid: object.uuid, + name: object.name, + userData: object.userData, + }; + this.onObjectDeselectedCallback(object, objectData); + } + if (this.onSelectionChangedCallback) { + this.onSelectionChangedCallback( + this.selectedObjects, + Array.from(this.selectedObjects), + ); + } + return true; } @@ -1080,6 +1142,37 @@ export class SelectionManager { } } + /** + * Apply intersection result and fire callbacks for hover state. + * This is called when hover state changes. + * @param hoveredObject The newly hovered object, or null if hover ends + */ + public setHoveredObject(hoveredObject: Mesh | null): void { + const previouslyHovered = this.hoveredObject; + + // Update hover state + this.hoveredObject = hoveredObject; + + // Fire hover end callback if we were hovering before + if (previouslyHovered && previouslyHovered !== hoveredObject) { + if (this.onObjectHoverEndCallback) { + this.onObjectHoverEndCallback(previouslyHovered); + } + } + + // Fire hover start callback if we're now hovering + if (hoveredObject && hoveredObject !== previouslyHovered) { + if (this.onObjectHoveredCallback) { + const objectData = { + uuid: hoveredObject.uuid, + name: hoveredObject.name, + userData: hoveredObject.userData, + }; + this.onObjectHoveredCallback(hoveredObject, objectData); + } + } + } + /** * Programmatically clear all selections and reset internal state. * This method is called when event data is cleared to prevent stale references. @@ -1146,6 +1239,97 @@ export class SelectionManager { this.setupOverlayListeners(); } + /** + * Set callback for when an object is selected. + * @param callback Function to call when object is selected, or null to remove. + * @internal Used by EventDisplay to integrate selection callbacks + */ + public setOnObjectSelectedCallback( + callback: ((object: Mesh, data?: any) => void) | null, + ): void { + this.onObjectSelectedCallback = callback; + + // If a selection callback is registered while objects are already selected, + // immediately notify the callback of the current selection state. + if (callback && this.selectedObjects.size > 0) { + this.selectedObjects.forEach((obj) => { + const objectData = { + uuid: obj.uuid, + name: obj.name, + userData: obj.userData, + }; + callback(obj, objectData); + }); + } + } + + /** + * Set callback for when an object is deselected. + * @param callback Function to call when object is deselected, or null to remove. + * @internal Used by EventDisplay to integrate selection callbacks + */ + public setOnObjectDeselectedCallback( + callback: ((object: Mesh, data?: any) => void) | null, + ): void { + this.onObjectDeselectedCallback = callback; + } + + /** + * Set callback for when an object is hovered. + * @param callback Function to call when object is hovered, or null to remove. + * @internal Used by EventDisplay to integrate hover callbacks + */ + public setOnObjectHoveredCallback( + callback: ((object: Mesh, data?: any) => void) | null, + ): void { + this.onObjectHoveredCallback = callback; + + // If a hover callback is registered while an object is already hovered, + // immediately notify the callback of the current hover state. + if (callback && this.hoveredObject) { + const hoverData = { + uuid: this.hoveredObject.uuid, + name: this.hoveredObject.name, + userData: this.hoveredObject.userData, + }; + callback(this.hoveredObject, hoverData); + } + } + + /** + * Set callback for when hover ends. + * @param callback Function to call when hover ends, or null to remove. + * @internal Used by EventDisplay to integrate hover callbacks + */ + public setOnObjectHoverEndCallback( + callback: ((object: Mesh, data?: any) => void) | null, + ): void { + this.onObjectHoverEndCallback = callback; + } + + /** + * Set callback for when selection changes. + * @param callback Function to call when selection changes, or null to remove. + * @internal Used by EventDisplay to integrate selection change callbacks + */ + public setOnSelectionChangedCallback( + callback: + | ((selectedObjects: Set, selectedObjectsArray: Mesh[]) => void) + | null, + ): void { + this.onSelectionChangedCallback = callback; + + // If a selection changed callback is registered while objects are already selected, + // immediately notify the callback of the current selection state. + if (callback && this.selectedObjects.size > 0) { + callback(this.selectedObjects, Array.from(this.selectedObjects)); + } + } + + // ===================================== + // Private methods and event handlers + // ===================================== + /** * Determine if a mouse event came from the overlay canvas. * @param event The mouse event to check diff --git a/packages/phoenix-event-display/src/tests/event-display-callbacks.test.ts b/packages/phoenix-event-display/src/tests/event-display-callbacks.test.ts new file mode 100644 index 000000000..1c6bd709e --- /dev/null +++ b/packages/phoenix-event-display/src/tests/event-display-callbacks.test.ts @@ -0,0 +1,111 @@ +/** + * @jest-environment jsdom + */ +import { EventDisplay } from '../event-display'; + +describe('EventDisplay callback subscriptions', () => { + let eventDisplay: EventDisplay; + + beforeEach(() => { + eventDisplay = Object.create(EventDisplay.prototype) as EventDisplay; + (eventDisplay as any).onObjectSelectedCallbacks = []; + (eventDisplay as any).onObjectDeselectedCallbacks = []; + (eventDisplay as any).onObjectHoveredCallbacks = []; + (eventDisplay as any).onObjectHoverEndCallbacks = []; + (eventDisplay as any).onSelectionChangedCallbacks = []; + }); + + it('should subscribe and unsubscribe object selected callbacks', () => { + const callback = jest.fn(); + const unsubscribe = eventDisplay.onObjectSelected(callback); + + (eventDisplay as any).fireObjectSelectedCallback( + { id: 'obj-1' }, + { uuid: 'u1', name: 'Object 1' }, + ); + + expect(callback).toHaveBeenCalledWith( + { id: 'obj-1' }, + { uuid: 'u1', name: 'Object 1' }, + ); + + unsubscribe(); + + (eventDisplay as any).fireObjectSelectedCallback( + { id: 'obj-2' }, + { uuid: 'u2', name: 'Object 2' }, + ); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should subscribe and unsubscribe object deselected callbacks', () => { + const callback = jest.fn(); + const unsubscribe = eventDisplay.onObjectDeselected(callback); + + (eventDisplay as any).fireObjectDeselectedCallback({ id: 'obj-1' }); + + expect(callback).toHaveBeenCalledWith({ id: 'obj-1' }); + + unsubscribe(); + + (eventDisplay as any).fireObjectDeselectedCallback({ id: 'obj-2' }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should subscribe and unsubscribe object hovered and hover-end callbacks', () => { + const hoveredCallback = jest.fn(); + const hoverEndCallback = jest.fn(); + const unsubscribeHovered = eventDisplay.onObjectHovered(hoveredCallback); + const unsubscribeHoverEnd = eventDisplay.onObjectHoverEnd(hoverEndCallback); + + (eventDisplay as any).fireObjectHoveredCallback( + { id: 'obj-1' }, + { uuid: 'u1', name: 'Object 1' }, + ); + (eventDisplay as any).fireObjectHoverEndCallback({ id: 'obj-1' }); + + expect(hoveredCallback).toHaveBeenCalledWith( + { id: 'obj-1' }, + { uuid: 'u1', name: 'Object 1' }, + ); + expect(hoverEndCallback).toHaveBeenCalledWith({ id: 'obj-1' }); + + unsubscribeHovered(); + unsubscribeHoverEnd(); + + (eventDisplay as any).fireObjectHoveredCallback( + { id: 'obj-2' }, + { uuid: 'u2', name: 'Object 2' }, + ); + (eventDisplay as any).fireObjectHoverEndCallback({ id: 'obj-2' }); + + expect(hoveredCallback).toHaveBeenCalledTimes(1); + expect(hoverEndCallback).toHaveBeenCalledTimes(1); + }); + + it('should subscribe and unsubscribe selection changed callbacks with selectionData', () => { + const callback = jest.fn(); + const unsubscribe = eventDisplay.onSelectionChanged(callback); + + (eventDisplay as any).fireSelectionChangedCallback([{ id: 'obj-1' }], { + selectionSet: new Set([{ id: 'obj-1' }]), + selectionArray: [{ id: 'obj-1' }], + }); + + expect(callback).toHaveBeenCalledWith([{ id: 'obj-1' }], { + selectionSet: new Set([{ id: 'obj-1' }]), + selectionArray: [{ id: 'obj-1' }], + }); + + unsubscribe(); + + (eventDisplay as any).fireSelectionChangedCallback([{ id: 'obj-2' }], { + selectionSet: new Set([{ id: 'obj-2' }]), + selectionArray: [{ id: 'obj-2' }], + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/three-manager/selection-manager.test.ts b/packages/phoenix-event-display/src/tests/managers/three-manager/selection-manager.test.ts index 1721d011c..92be394dd 100644 --- a/packages/phoenix-event-display/src/tests/managers/three-manager/selection-manager.test.ts +++ b/packages/phoenix-event-display/src/tests/managers/three-manager/selection-manager.test.ts @@ -4,6 +4,7 @@ import { InfoLogger } from '../../../helpers/info-logger'; import { EffectsManager } from '../../../managers/three-manager/effects-manager'; import { Object3D, PerspectiveCamera, Scene, Vector2, Vector3 } from 'three'; +import { Mesh, BoxGeometry, MeshBasicMaterial } from 'three'; import { SelectionManager } from '../../../managers/three-manager/selection-manager'; import THREE from '../../helpers/webgl-mock'; @@ -138,4 +139,101 @@ describe('SelectionManager', () => { 'uuid', ); }); + + it('should fire hover callbacks through applyIntersectionResult path', () => { + const hoveredCallback = jest.fn(); + const hoverEndCallback = jest.fn(); + const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); + + selectionManager['effectsManager'] = { + setHoverOutline: jest.fn(), + } as any; + jest + .spyOn(selectionManagerPrivate, 'updateInfoPanelForHover') + .mockImplementation(() => undefined); + + selectionManager.setOnObjectHoveredCallback(hoveredCallback); + selectionManager.setOnObjectHoverEndCallback(hoverEndCallback); + + selectionManager.applyIntersectionResult(mesh); + + expect(hoveredCallback).toHaveBeenCalledWith( + mesh, + expect.objectContaining({ + uuid: mesh.uuid, + name: mesh.name, + userData: mesh.userData, + }), + ); + + selectionManager.applyIntersectionResult(null); + + expect(hoverEndCallback).toHaveBeenCalledWith(mesh); + }); + + it('should fire selection callbacks through handleClick interaction path', () => { + const selectedCallback = jest.fn(); + const deselectedCallback = jest.fn(); + const selectionChangedCallback = jest.fn(); + const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); + + selectionManager['effectsManager'] = { + selectObject: jest.fn(), + deselectObject: jest.fn(), + clearAllSelections: jest.fn(), + } as any; + selectionManager['infoLogger'] = { add: jest.fn() } as any; + + selectionManager.setOnObjectSelectedCallback(selectedCallback); + selectionManager.setOnObjectDeselectedCallback(deselectedCallback); + selectionManager.setOnSelectionChangedCallback(selectionChangedCallback); + + selectionManagerPrivate.currentlyOutlinedObject = mesh; + selectionManagerPrivate.handleClick(); + + expect(selectedCallback).toHaveBeenCalledWith( + mesh, + expect.objectContaining({ + uuid: mesh.uuid, + name: mesh.name, + userData: mesh.userData, + }), + ); + expect(selectionChangedCallback).toHaveBeenCalledWith(expect.any(Set), [ + mesh, + ]); + + selectionManagerPrivate.currentlyOutlinedObject = null; + selectionManagerPrivate.handleClick(); + + expect(deselectedCallback).toHaveBeenCalledWith( + mesh, + expect.objectContaining({ + uuid: mesh.uuid, + name: mesh.name, + userData: mesh.userData, + }), + ); + expect(selectionChangedCallback).toHaveBeenLastCalledWith( + expect.any(Set), + [], + ); + }); + + it('should provide hover data on immediate hover callback subscription', () => { + const hoveredCallback = jest.fn(); + const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); + + selectionManagerPrivate.hoveredObject = mesh; + selectionManager.setOnObjectHoveredCallback(hoveredCallback); + + expect(hoveredCallback).toHaveBeenCalledWith( + mesh, + expect.objectContaining({ + uuid: mesh.uuid, + name: mesh.name, + userData: mesh.userData, + }), + ); + }); }); diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/cms/cms.component.test.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/cms/cms.component.test.ts index efa4ff610..afb1c12b2 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/cms/cms.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/cms/cms.component.test.ts @@ -4,7 +4,7 @@ import { CMSComponent } from './cms.component'; import { EventDisplayService } from 'phoenix-ui-components'; import { CMSLoader } from 'phoenix-event-display'; -describe.skip('CMSComponent', () => { +describe('CMSComponent', () => { let component: CMSComponent; let fixture: ComponentFixture; @@ -22,22 +22,29 @@ describe.skip('CMSComponent', () => { parsePhoenixEvents: jest.fn(), loadingManager: jest.fn().mockReturnThis(), itemLoaded: jest.fn().mockReturnThis(), - getLoadingManager: jest.fn().mockReturnThis(), + getLoadingManager: jest.fn().mockReturnValue({ + addProgressListener: jest.fn(), + addLoadListenerWithCheck: jest.fn(), + }), }; - const mockCMSLoader = {}; - beforeAll(() => { window.fetch = jest.fn(); }); beforeEach(() => { + // Mock CMSLoader + jest + .spyOn(CMSLoader.prototype, 'readIgArchive') + .mockImplementation((url, callback) => { + // Call the callback with empty events to avoid errors + callback([]); + }); + + jest.spyOn(CMSLoader.prototype, 'getAllEventsData').mockReturnValue([]); + TestBed.configureTestingModule({ providers: [ - { - provide: CMSLoader, - useValue: mockCMSLoader, - }, { provide: EventDisplayService, useValue: mockEventDisplay, @@ -51,6 +58,10 @@ describe.skip('CMSComponent', () => { component = fixture.componentInstance; }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -59,6 +70,6 @@ describe.skip('CMSComponent', () => { it('should initialize three.js canvas', () => { jest.spyOn(mockEventDisplay, 'parsePhoenixEvents'); component.ngOnInit(); - expect(document.getElementById('three-canvas')).toBeTruthy(); + expect(mockEventDisplay.init).toHaveBeenCalled(); }); }); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.test.ts index e54425bf9..2024e7812 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.test.ts @@ -14,7 +14,7 @@ import { FileResponse, } from './event-data-explorer-dialog.component'; -describe.skip('EventDataExplorerDialogComponent', () => { +describe('EventDataExplorerDialogComponent', () => { let component: EventDataExplorerDialogComponent; let fixture: ComponentFixture; @@ -23,10 +23,13 @@ describe.skip('EventDataExplorerDialogComponent', () => { }; const mockFileLoaderService = { + makeRequest: jest.fn(), loadEvent: jest.fn(), }; - const mockEventDisplay = {}; + const mockEventDisplay = { + getStateManager: jest.fn(() => mockStateManager), + }; const mockDialogRef = { close: jest.fn(), @@ -54,19 +57,30 @@ describe.skip('EventDataExplorerDialogComponent', () => { }, ]; - beforeAll(() => { - const mockResponse = new Response(JSON.stringify(mockFileResponse), { - status: 200, - }); - jest.spyOn(mockResponse, 'json').mockResolvedValue(mockFileResponse); - jest.spyOn(window, 'fetch').mockResolvedValue(mockResponse); - }); - beforeEach(() => { + jest.clearAllMocks(); + + mockFileLoaderService.makeRequest.mockImplementation( + ( + _url: string, + type: 'json' | 'blob' | 'text', + callback: (data: any) => void, + ) => { + if (type === 'json') { + callback(mockFileResponse); + } + return false; + }, + ); + TestBed.configureTestingModule({ imports: [BrowserAnimationsModule, PhoenixUIModule], declarations: [EventDataExplorerDialogComponent], providers: [ + { + provide: FileLoaderService, + useValue: mockFileLoaderService, + }, { provide: EventDisplayService, useValue: mockEventDisplay, @@ -89,7 +103,7 @@ describe.skip('EventDataExplorerDialogComponent', () => { fixture.detectChanges(); }); - it.only('should create', () => { + it('should create', () => { expect(component).toBeTruthy(); }); @@ -107,35 +121,39 @@ describe.skip('EventDataExplorerDialogComponent', () => { }); it('should load event based on file type', () => { - jest - .spyOn(FileLoaderService.prototype, 'loadEvent') - .mockImplementation( - (_arg1: string, eventDisplay: EventDisplayService) => true, - ); + mockFileLoaderService.loadEvent.mockReturnValue(false); - jest.spyOn(FileLoaderService.prototype, 'loadEvent'); component.loadEvent( new FileEvent('https://example.com/event_data/test.json', false), ); - expect(mockFileLoaderService.loadEvent).toHaveBeenCalled(); + + expect(mockFileLoaderService.loadEvent).toHaveBeenCalledWith( + 'https://example.com/event_data/test.json', + mockEventDisplay, + {}, + ); + expect(mockDialogRef.close).toHaveBeenCalled(); }); it('should load config', () => { - jest - .spyOn(FileLoaderService.prototype, 'makeRequest') - .mockImplementation( - ( - _arg1: string, - _arg2: 'json' | 'blob' | 'text', - onData: (data: any) => void, - ) => { - onData('{}'); - return true; - }, - ); + mockFileLoaderService.makeRequest.mockImplementation( + ( + _url: string, + type: 'json' | 'blob' | 'text', + callback: (data: any) => void, + ) => { + if (type === 'text') { + callback('{}'); + } + return false; + }, + ); + component.loadConfig( new FileEvent('https://example.com/config_data/test.json', false), ); + expect(mockStateManager.loadStateFromJSON).toHaveBeenCalledWith({}); + expect(mockDialogRef.close).toHaveBeenCalled(); }); }); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.ts index e3c9584a8..ebfdcba28 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component.ts @@ -6,7 +6,7 @@ import { FileEvent, } from '../../../file-explorer/file-explorer.component'; import { FileLoaderService } from '../../../../services/file-loader.service'; -import { type EventDataExplorerDialogData } from '../event-data-explorer.component'; +import type { EventDataExplorerDialogData } from '../event-data-explorer.component'; const supportFileTypes = ['json', 'xml']; @@ -27,13 +27,16 @@ export class EventDataExplorerDialogComponent { configFileNode: FileNode; loading = true; error = false; + private dialogData: EventDataExplorerDialogData; constructor( private eventDisplay: EventDisplayService, private fileLoader: FileLoaderService, private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) private dialogData: EventDataExplorerDialogData, + @Inject(MAT_DIALOG_DATA) dialogData: unknown, ) { + this.dialogData = dialogData as EventDataExplorerDialogData; + // Event data fileLoader.makeRequest( this.dialogData.apiURL, diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/file-loader.service.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/file-loader.service.component.test.ts index abc84f042..7e037bd26 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/file-loader.service.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/file-loader.service.component.test.ts @@ -1,23 +1,22 @@ import { FileLoaderService } from './file-loader.service'; import { EventDisplayService } from './event-display.service'; -describe.skip('FileLoaderService', () => { +describe('FileLoaderService', () => { let service: FileLoaderService; let mockEventDisplay: any; - const mockFileLoaderService = { - loadEvent: jest.fn(), - loadJSONEvent: jest.fn(), - loadJiveXMLEvent: jest.fn(), - }; - beforeEach(() => { mockEventDisplay = {}; + service = new FileLoaderService(); }); it('should load event based on file type', () => { - jest - .spyOn(FileLoaderService.prototype, 'makeRequest') + // Mock the loadJSONEvent method + const loadJSONEventSpy = jest + .spyOn(service, 'loadJSONEvent') + .mockImplementation(); + const makeRequestSpy = jest + .spyOn(service, 'makeRequest' as any) .mockImplementation( ( _arg1: string, @@ -28,24 +27,26 @@ describe.skip('FileLoaderService', () => { return true; }, ); - jest.spyOn(FileLoaderService.prototype, 'loadJSONEvent'); + service.loadEvent( 'https://example.com/event_data/test.json', mockEventDisplay, ); - expect(mockFileLoaderService.loadJSONEvent).toHaveBeenCalled(); + expect(loadJSONEventSpy).toHaveBeenCalled(); - jest.spyOn(FileLoaderService.prototype, 'loadJiveXMLEvent'); + // Test loading XML + const loadJiveXMLEventSpy = jest + .spyOn(service, 'loadJiveXMLEvent') + .mockImplementation(); service.loadEvent( 'https://example.com/event_data/test.xml', mockEventDisplay, ); - expect(mockFileLoaderService.loadJiveXMLEvent).toHaveBeenCalled(); + expect(loadJiveXMLEventSpy).toHaveBeenCalled(); - jest.spyOn(FileLoaderService.prototype, 'loadEvent'); - service.reloadLastEvents(mockEventDisplay); - expect(mockFileLoaderService.loadEvent).toHaveBeenCalledWith( - 'https://example.com/event_data/test.xml', - ); + // Clean up + loadJSONEventSpy.mockRestore(); + loadJiveXMLEventSpy.mockRestore(); + makeRequestSpy.mockRestore(); }); });