diff --git a/README.md b/README.md index a490a03c..81196275 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ const netConnectionSettings = { }; const simulariumController = new SimulariumController(); -// todo show correct call of changeFile to load trajectory class Viewer extends React.Component { @@ -66,6 +65,16 @@ class Viewer extends React.Component { } } + public componentDidMount(): void { + // Load a trajectory by calling `changeFile`. + // You can do this here in a lifecycle method or call it later, + // whenever you’re ready to switch files. + simulariumController.changeFile({ + fileName: "actin012_3.h5", + netConnectionSettings: netConnectionSettings, + }); + } + handleTimeChange = (timeData) => { console.log(timeData) } @@ -92,6 +101,7 @@ class Viewer extends React.Component { showPaths={this.state.showPaths} />) } +} ``` ## Run an example app locally diff --git a/examples/src/Components/ConversionForm/index.tsx b/examples/src/Components/ConversionForm/index.tsx index e500466f..44b44857 100644 --- a/examples/src/Components/ConversionForm/index.tsx +++ b/examples/src/Components/ConversionForm/index.tsx @@ -7,7 +7,6 @@ interface InputFormProps { templateData: { [key: string]: any }; type: string; submitFile: (data) => void; - onReturned: () => void; } class InputForm extends React.Component { diff --git a/examples/src/Viewer.tsx b/examples/src/Viewer.tsx index 63ad00a1..5a84cdff 100644 --- a/examples/src/Viewer.tsx +++ b/examples/src/Viewer.tsx @@ -138,7 +138,6 @@ class Viewer extends React.Component { this.handleJsonMeshData = this.handleJsonMeshData.bind(this); this.handleTimeChange = this.handleTimeChange.bind(this); this.loadFile = this.loadFile.bind(this); - this.clearPendingFile = this.clearPendingFile.bind(this); this.convertFile = this.convertFile.bind(this); this.handleResize = this.handleResize.bind(this); this.state = initialState; @@ -341,7 +340,6 @@ class Viewer extends React.Component { fileName ) .then(() => { - this.clearPendingFile(); this.setState({ awaitingConversion: true }); }) .catch((err) => { @@ -360,7 +358,6 @@ class Viewer extends React.Component { this.smoldynInput ) .then(() => { - this.clearPendingFile(); this.setState({ awaitingConversion: true }); }) .catch((err) => { @@ -369,17 +366,17 @@ class Viewer extends React.Component { }); } - public clearPendingFile() { - this.setState({ filePending: null }); - } - public loadFile(trajectoryFile, fileName, geoAssets?) { const simulariumFile = fileName.includes(".simularium") ? trajectoryFile : null; this.setState({ initialPlay: true }); return simulariumController - .handleFileChange(simulariumFile, fileName, geoAssets) + .changeFile({ + simulariumFile, + fileName, + geoAssets, + }) .catch(console.log); } @@ -597,13 +594,11 @@ class Viewer extends React.Component { clientSimulator: true, simulariumFile: { name: fileId, data: null }, }); - simulariumController.changeFile( - { - netConnectionSettings: this.netConnectionSettings, - requestJson: true, - }, - fileId - ); + simulariumController.changeFile({ + fileName: fileId, + netConnectionSettings: this.netConnectionSettings, + requestJson: true, + }); } private handleFileSelect(file: string) { @@ -626,8 +621,17 @@ class Viewer extends React.Component { return; } if (fileId.simulatorType === SimulatorModes.localClientSimulator) { - const clientSim = this.configureLocalClientSimulator(fileId.id); - simulariumController.changeFile(clientSim, fileId.id); + const { clientSimulator } = this.configureLocalClientSimulator( + fileId.id + ); + if (!clientSimulator) { + console.warn("No client simulator implementation found"); + return; + } + simulariumController.changeFile({ + fileName: fileId.id, + clientSimulatorImpl: clientSimulator, + }); this.setState({ clientSimulator: true, }); @@ -640,12 +644,10 @@ class Viewer extends React.Component { this.setState({ simulariumFile: { name: fileId.id, data: null }, }); - simulariumController.changeFile( - { - netConnectionSettings: this.netConnectionSettings, - }, - fileId.id - ); + simulariumController.changeFile({ + fileName: fileId.id, + netConnectionSettings: this.netConnectionSettings, + }); } } @@ -748,7 +750,6 @@ class Viewer extends React.Component { this.convertFile(obj, fileType)} - onReturned={this.clearPendingFile} /> ); } diff --git a/src/simularium/ClientSimulator.ts b/src/Simulator/ClientSimulator.ts similarity index 95% rename from src/simularium/ClientSimulator.ts rename to src/Simulator/ClientSimulator.ts index 1d0b4b12..042112db 100644 --- a/src/simularium/ClientSimulator.ts +++ b/src/Simulator/ClientSimulator.ts @@ -1,19 +1,19 @@ import jsLogger from "js-logger"; import { ILogger } from "js-logger"; +import { ISimulator } from "./ISimulator.js"; +import { + IClientSimulatorImpl, + ClientMessageEnum, +} from "../simularium/index.js"; import { - VisDataMessage, TrajectoryFileInfo, - PlotConfig, - Plot, + VisDataMessage, Metrics, -} from "./types.js"; -import { - ClientMessageEnum, - ClientPlayBackType, - IClientSimulatorImpl, -} from "./localSimulators/IClientSimulatorImpl.js"; -import { ISimulator } from "./ISimulator.js"; + Plot, + PlotConfig, +} from "../simularium/types.js"; +import { ClientSimulatorParams } from "./types.js"; // a ClientSimulator is a ISimulator that is expected to run purely in procedural javascript in the browser client, // with the procedural implementation in a IClientSimulatorImpl @@ -30,7 +30,11 @@ export class ClientSimulator implements ISimulator { public onPlotDataArrive: (msg: Plot[]) => void; public handleError: (error: Error) => void; - public constructor(sim: IClientSimulatorImpl) { + public constructor(params: ClientSimulatorParams) { + const { clientSimulatorImpl } = params; + if (!clientSimulatorImpl) { + throw new Error("ClientSimulator requires a IClientSimulatorImpl"); + } this.logger = jsLogger.get("netconnection"); this.logger.setLevel(jsLogger.DEBUG); @@ -49,7 +53,7 @@ export class ClientSimulator implements ISimulator { this.onPlotDataArrive = () => { /* do nothing */ }; - this.localSimulator = sim; + this.localSimulator = clientSimulatorImpl; } public setTrajectoryFileInfoHandler( @@ -168,7 +172,6 @@ export class ClientSimulator implements ISimulator { public initialize(fileName: string): Promise { const jsonData = { msgType: ClientMessageEnum.ID_VIS_DATA_REQUEST, - mode: ClientPlayBackType.ID_TRAJECTORY_FILE_PLAYBACK, fileName: fileName, }; @@ -205,7 +208,6 @@ export class ClientSimulator implements ISimulator { this.sendSimulationRequest( { msgType: ClientMessageEnum.ID_VIS_DATA_REQUEST, - mode: ClientPlayBackType.ID_TRAJECTORY_FILE_PLAYBACK, frameNumber: startFrameNumber, }, "Request Single Frame" diff --git a/src/simularium/ISimulator.ts b/src/Simulator/ISimulator.ts similarity index 98% rename from src/simularium/ISimulator.ts rename to src/Simulator/ISimulator.ts index e1e3a514..2e00e05e 100644 --- a/src/simularium/ISimulator.ts +++ b/src/Simulator/ISimulator.ts @@ -4,7 +4,7 @@ import { PlotConfig, Plot, Metrics, -} from "./types.js"; +} from "../simularium/types.js"; /** From the caller's perspective, this interface is a contract for a diff --git a/src/simularium/LocalFileSimulator.ts b/src/Simulator/LocalFileSimulator.ts similarity index 92% rename from src/simularium/LocalFileSimulator.ts rename to src/Simulator/LocalFileSimulator.ts index e80093e9..ace5e138 100644 --- a/src/simularium/LocalFileSimulator.ts +++ b/src/Simulator/LocalFileSimulator.ts @@ -8,9 +8,10 @@ import { PlotConfig, Plot, Metrics, -} from "./types.js"; +} from "../simularium/types.js"; import { ISimulator } from "./ISimulator.js"; -import type { ISimulariumFile } from "./ISimulariumFile.js"; +import type { ISimulariumFile } from "../simularium/ISimulariumFile.js"; +import { LocalFileSimulatorParams } from "./types.js"; // a LocalFileSimulator is a ISimulator that plays back the contents of // a drag-n-drop trajectory file (a ISimulariumFile object) @@ -27,8 +28,12 @@ export class LocalFileSimulator implements ISimulator { private playbackIntervalId = 0; private currentPlaybackFrameIndex = 0; - public constructor(simulariumFile: ISimulariumFile) { - this.fileName = ""; + public constructor(params: LocalFileSimulatorParams) { + const { fileName, simulariumFile } = params; + if (!simulariumFile) { + throw new Error("LocalFileSimulator requires a ISimulariumFile in its LocalFileSimulatorParams"); + } + this.fileName = fileName; this.simulariumFile = simulariumFile; this.logger = jsLogger.get("netconnection"); this.logger.setLevel(jsLogger.DEBUG); diff --git a/src/simularium/RemoteSimulator.ts b/src/Simulator/RemoteSimulator.ts similarity index 93% rename from src/simularium/RemoteSimulator.ts rename to src/Simulator/RemoteSimulator.ts index e569a2fa..52d762c2 100644 --- a/src/simularium/RemoteSimulator.ts +++ b/src/Simulator/RemoteSimulator.ts @@ -1,16 +1,15 @@ import jsLogger from "js-logger"; import { ILogger } from "js-logger"; -import { FrontEndError, ErrorLevel } from "./FrontEndError.js"; +import { FrontEndError, ErrorLevel } from "../simularium/FrontEndError.js"; import { NetMessageEnum, MessageEventLike, WebsocketClient, -} from "./WebsocketClient.js"; +} from "../simularium/WebsocketClient.js"; import type { NetMessage, ErrorMessage, - NetConnectionParams, -} from "./WebsocketClient.js"; +} from "../simularium/WebsocketClient.js"; import { ISimulator } from "./ISimulator.js"; import { Metrics, @@ -18,7 +17,8 @@ import { PlotConfig, TrajectoryFileInfoV2, VisDataMessage, -} from "./types.js"; +} from "../simularium/types.js"; +import { RemoteSimulatorParams } from "./types.js"; // a RemoteSimulator is a ISimulator that connects to the Octopus backend server // and plays back a trajectory specified in the NetConnectionParams @@ -34,15 +34,20 @@ export class RemoteSimulator implements ISimulator { private jsonResponse: boolean; public constructor( - netConnectionSettings: NetConnectionParams, - errorHandler?: (error: FrontEndError) => void, - jsonResponse = false + params: RemoteSimulatorParams, + errorHandler?: (error: FrontEndError) => void ) { + const { netConnectionSettings, fileName, requestJson = false } = params; + if (!params.netConnectionSettings || !params.fileName) { + throw new FrontEndError( + "RemoteSimulator requires NetConnectionParams and file name." + ); + } this.webSocketClient = new WebsocketClient( netConnectionSettings, errorHandler ); - this.lastRequestedFile = ""; + this.lastRequestedFile = fileName; this.handleError = errorHandler || (() => { @@ -51,7 +56,7 @@ export class RemoteSimulator implements ISimulator { this.logger = jsLogger.get("netconnection"); this.logger.setLevel(jsLogger.DEBUG); - this.jsonResponse = jsonResponse; + this.jsonResponse = requestJson; this.onTrajectoryFileInfoArrive = () => { /* do nothing */ @@ -87,6 +92,10 @@ export class RemoteSimulator implements ISimulator { this.handleError = handler; } + public isConnectedToRemoteServer(): boolean { + return this.webSocketClient.socketIsValid(); + } + /** * WebSocket Simulation Control */ diff --git a/src/Simulator/SimulatorFactory.ts b/src/Simulator/SimulatorFactory.ts new file mode 100644 index 00000000..63ccf2ea --- /dev/null +++ b/src/Simulator/SimulatorFactory.ts @@ -0,0 +1,33 @@ +import { ClientSimulator } from "./ClientSimulator"; +import { LocalFileSimulator } from "./LocalFileSimulator"; +import { RemoteSimulator } from "./RemoteSimulator"; +import { + SimulatorParams, + RemoteSimulatorParams, + ClientSimulatorParams, + LocalFileSimulatorParams, +} from "./types"; + +export const getSimulatorClassFromParams = (params?: SimulatorParams) => { + if (!params || !params.fileName) { + return { simulatorClass: null, typedParams: null }; + } + if ("netConnectionSettings" in params) { + return { + simulatorClass: RemoteSimulator, + typedParams: params as RemoteSimulatorParams, + }; + } else if ("clientSimulatorImpl" in params) { + return { + simulatorClass: ClientSimulator, + typedParams: params as ClientSimulatorParams, + }; + } else if ("simulariumFile" in params) { + return { + simulatorClass: LocalFileSimulator, + typedParams: params as LocalFileSimulatorParams, + }; + } else { + return { simulatorClass: null, typedParams: null }; + } +}; diff --git a/src/Simulator/types.ts b/src/Simulator/types.ts new file mode 100644 index 00000000..5b05d0c3 --- /dev/null +++ b/src/Simulator/types.ts @@ -0,0 +1,24 @@ +import { NetConnectionParams, IClientSimulatorImpl } from "../simularium"; +import { ISimulariumFile } from "../simularium/ISimulariumFile"; +export interface RemoteSimulatorParams { + fileName: string; + netConnectionSettings?: NetConnectionParams; + requestJson?: boolean; + prefetchFrames?: boolean; +} + +export interface ClientSimulatorParams { + fileName: string; + clientSimulatorImpl?: IClientSimulatorImpl; +} + +export interface LocalFileSimulatorParams { + fileName: string; + simulariumFile?: ISimulariumFile; + geoAssets?: { [key: string]: string }; +} + +export type SimulatorParams = + | RemoteSimulatorParams + | ClientSimulatorParams + | LocalFileSimulatorParams; diff --git a/src/controller/index.ts b/src/controller/index.ts index cfda7f23..e449d10b 100644 --- a/src/controller/index.ts +++ b/src/controller/index.ts @@ -4,7 +4,6 @@ import { v4 as uuidv4 } from "uuid"; import { VisData, RemoteSimulator } from "../simularium/index.js"; import type { NetConnectionParams, - Plot, TrajectoryFileInfo, } from "../simularium/index.js"; import { VisGeometry } from "../visGeometry/index.js"; @@ -13,32 +12,20 @@ import { FILE_STATUS_SUCCESS, FILE_STATUS_FAIL, PlotConfig, - Metrics, } from "../simularium/types.js"; -import { ClientSimulator } from "../simularium/ClientSimulator.js"; -import { IClientSimulatorImpl } from "../simularium/localSimulators/IClientSimulatorImpl.js"; -import { ISimulator } from "../simularium/ISimulator.js"; -import { LocalFileSimulator } from "../simularium/LocalFileSimulator.js"; +import { ISimulator } from "../Simulator/ISimulator.js"; +import { getSimulatorClassFromParams } from "../Simulator/SimulatorFactory.js"; import { FrontEndError } from "../simularium/FrontEndError.js"; -import type { ISimulariumFile } from "../simularium/ISimulariumFile.js"; import { TrajectoryType } from "../constants.js"; import { ConversionClient } from "../simularium/ConversionClient.js"; +import { SimulatorParams } from "../Simulator/types.js"; jsLogger.setHandler(jsLogger.createDefaultHandler()); -// TODO: refine this as part of the public API for initializing the -// controller with a simulator connection -interface SimulatorConnectionParams { - netConnectionSettings?: NetConnectionParams; - clientSimulator?: IClientSimulatorImpl; - simulariumFile?: ISimulariumFile; - geoAssets?: { [key: string]: string }; - requestJson?: boolean; -} - export default class SimulariumController { public simulator?: ISimulator; + private lastNetConnectionConfig: NetConnectionParams | null; private _conversionClient?: ConversionClient; public visData: VisData; public visGeometry: VisGeometry | undefined; @@ -67,6 +54,7 @@ export default class SimulariumController { this.isPaused = false; this.isFileChanging = false; + this.lastNetConnectionConfig = null; this.playBackFile = ""; this.simulator = undefined; this.zoomIn = this.zoomIn.bind(this); @@ -79,71 +67,67 @@ export default class SimulariumController { this.convertTrajectory = this.convertTrajectory.bind(this); this.setCameraType = this.setCameraType.bind(this); this.startSmoldynSim = this.startSmoldynSim.bind(this); - this.cancelCurrentFile = this.cancelCurrentFile.bind(this); - } - - private createSimulatorConnection( - netConnectionConfig?: NetConnectionParams, - clientSimulator?: IClientSimulatorImpl, - localFile?: ISimulariumFile, - geoAssets?: { [key: string]: string }, - requestJson?: boolean - ): void { - if (clientSimulator) { - this.simulator = new ClientSimulator(clientSimulator); - this.simulator.setTrajectoryDataHandler( - this.visData.parseAgentsFromNetData.bind(this.visData) - ); - } else if (localFile) { - this.simulator = new LocalFileSimulator(localFile); - if (this.visGeometry && geoAssets && !isEmpty(geoAssets)) { - this.visGeometry.geometryStore.cacheLocalAssets(geoAssets); - } - this.simulator.setTrajectoryDataHandler( - this.visData.parseAgentsFromFrameData.bind(this.visData) - ); - } else if (netConnectionConfig) { - this.simulator = new RemoteSimulator( - netConnectionConfig, - this.onError, - requestJson - ); - this.simulator.setTrajectoryDataHandler( - this.visData.parseAgentsFromNetData.bind(this.visData) - ); + } + + /** + * @param error a string or an Error object, which can be of type + * "unknown" if passed by a catch block + */ + private handleError(error: unknown | string): void { + let message: string; + + if (typeof error === "string") { + message = error; + } else if (error instanceof Error) { + message = error.message; } else { - // caught in try/catch block, not sent to front end - throw new Error( - "Insufficient data to determine and configure simulator connection" - ); + message = "An unknown error occurred."; } - this.simulator.setTrajectoryFileInfoHandler( - (trajFileInfo: TrajectoryFileInfo) => { - this.handleTrajectoryInfo(trajFileInfo); - } - ); - this.simulator.setMetricsHandler((metrics: Metrics) => - this.handleMetrics(metrics) - ); - this.simulator.setPlotDataHandler((plots: Plot[]) => - this.handlePlotData(plots) + if (this.onError) { + this.onError(new FrontEndError(message)); + } else throw new Error(message); + } + + private createSimulatorConnection(params: SimulatorParams): void { + const { simulatorClass, typedParams } = + getSimulatorClassFromParams(params); + if (!simulatorClass) { + this.handleError("Invalid simulator configuration"); + return; + } + this.lastNetConnectionConfig = + "netConnectionSettings" in params && + params.netConnectionSettings !== undefined + ? params.netConnectionSettings + : null; + if ( + this.visGeometry && + "geoAssets" in params && + !isEmpty(params.geoAssets) + ) { + this.visGeometry.geometryStore.cacheLocalAssets(params.geoAssets); + } + // will throw an error if the params are invalid + this.simulator = new simulatorClass(typedParams, this.onError); + this.simulator.setTrajectoryDataHandler( + this.visData.parseAgentsFromNetData.bind(this.visData) ); + this.simulator.setTrajectoryFileInfoHandler(this.handleTrajectoryInfo); + this.simulator.setMetricsHandler(this.handleMetrics); + this.simulator.setPlotDataHandler(this.handlePlotData); } public get isChangingFile(): boolean { return this.isFileChanging; } - public start(): Promise { + private async start(): Promise { if (!this.simulator) { return Promise.reject(); } - - this.isPaused = false; - this.visData.clearCache(); - - return this.simulator.initialize(this.playBackFile); + await this.simulator.initialize(this.playBackFile); + this.simulator.requestFrame(0); } public time(): number { @@ -151,10 +135,10 @@ export default class SimulariumController { } public stop(): void { + this.pause(); if (this.simulator) { this.simulator.abort(); } - this.simulator = undefined; } public sendUpdate(obj: Record): void { @@ -173,15 +157,22 @@ export default class SimulariumController { } private closeConversionConnection(): void { - this.conversionClient?.disconnect(); - this._conversionClient = undefined; + if (this._conversionClient) { + this._conversionClient.cancelConversion(); + this._conversionClient.disconnect(); + this._conversionClient = undefined; + } } private async setupConversion( netConnectionConfig: NetConnectionParams, fileName: string ): Promise { - this.cancelCurrentFile(fileName); + const reuseSimulator = this.shouldReuseSimulator({ + netConnectionSettings: netConnectionConfig, + fileName, + }); + this.clearFileResources(reuseSimulator); this._conversionClient = new ConversionClient( netConnectionConfig, @@ -189,18 +180,15 @@ export default class SimulariumController { ); this.conversionClient.setOnConversionCompleteHandler(() => { - this.changeFile( - { - netConnectionSettings: netConnectionConfig, - }, - this.playBackFile - ); + this.changeFile({ + netConnectionSettings: netConnectionConfig, + fileName: fileName, + }); this.closeConversionConnection(); }); } public cancelConversion(): void { - this.conversionClient.cancelConversion(); this.closeConversionConnection(); } @@ -231,8 +219,8 @@ export default class SimulariumController { public pause(): void { if (this.simulator) { this.simulator.pause(); - this.isPaused = true; } + this.isPaused = true; } public paused(): boolean { @@ -264,76 +252,73 @@ export default class SimulariumController { } } - public clearFile(): void { + ///// File Changing ///// + + private clearFileResources(reuseSimulator = false): void { + this.closeConversionConnection(); this.stop(); - this.isFileChanging = false; + if (!reuseSimulator) { + this.simulator = undefined; + } this.playBackFile = ""; this.visData.clearForNewTrajectory(); - this.simulator?.abort(); - this.pause(); if (this.visGeometry) { this.visGeometry.clearForNewTrajectory(); this.visGeometry.resetCamera(); } } - public handleFileChange( - simulariumFile: ISimulariumFile, - fileName: string, - geoAssets?: { [key: string]: string } - ): Promise { - if (!fileName.includes(".simularium")) { - throw new Error("File must be a .simularium file"); - } - - if (geoAssets) { - return this.changeFile({ simulariumFile, geoAssets }, fileName); - } else { - return this.changeFile({ simulariumFile }, fileName); - } + public clearFile(): void { + this.isFileChanging = false; + this.clearFileResources(); + } + + // export interface NetConnectionParams { + // serverIp?: string; + // serverPort?: number; + // } + + private shouldReuseSimulator(params: SimulatorParams): boolean { + const newConfig = + "netConnectionSettings" in params + ? params.netConnectionSettings + : undefined; + const lastConfig = this.lastNetConnectionConfig; + + return ( + this.simulator instanceof RemoteSimulator && + this.simulator.isConnectedToRemoteServer() && + lastConfig?.serverIp != null && + lastConfig?.serverPort != null && + newConfig?.serverIp != null && + newConfig?.serverPort != null && + lastConfig.serverIp === newConfig.serverIp && + lastConfig.serverPort === newConfig.serverPort + ); } - public cancelCurrentFile(newFileName: string): void { + public async changeFile(params: SimulatorParams): Promise { + if ( + "simulariumFile" in params && + !params.fileName.includes(".simularium") + ) { + this.handleError("File must be a .simularium file"); + return Promise.reject({ status: FILE_STATUS_FAIL }); + } this.isFileChanging = true; - this.playBackFile = newFileName; - - // calls simulator.abort() - this.stop(); - - this.visData.WaitForFrame(0); - this.visData.clearForNewTrajectory(); - } - - public changeFile( - connectionParams: SimulatorConnectionParams, - // TODO: push newFileName into connectionParams - newFileName: string - ): Promise { - this.cancelCurrentFile(newFileName); - this.createSimulatorConnection( - connectionParams.netConnectionSettings, - connectionParams.clientSimulator, - connectionParams.simulariumFile, - connectionParams.geoAssets, - connectionParams.requestJson - ); - - // start the simulation paused and get first frame - if (this.simulator) { - return this.start() // will reject if no simulator - .then(() => { - if (this.simulator) { - this.simulator.requestFrame(0); - } - }) - .then(() => ({ - status: FILE_STATUS_SUCCESS, - })); + const reuseSimulator = this.shouldReuseSimulator(params); + this.clearFileResources(reuseSimulator); + if (!reuseSimulator) { + this.createSimulatorConnection(params); + } + this.playBackFile = params.fileName; + try { + await this.start(); + return { status: FILE_STATUS_SUCCESS }; + } catch (e) { + this.handleError(e); + return { status: FILE_STATUS_FAIL }; } - - return Promise.reject({ - status: FILE_STATUS_FAIL, - }); } public markFileChangeAsHandled(): void { diff --git a/src/simularium/index.ts b/src/simularium/index.ts index fc55efea..4ec4be69 100644 --- a/src/simularium/index.ts +++ b/src/simularium/index.ts @@ -18,7 +18,7 @@ export type { } from "./SelectionInterface.js"; export { ErrorLevel, FrontEndError } from "./FrontEndError.js"; export { NetMessageEnum } from "./WebsocketClient.js"; -export { RemoteSimulator } from "./RemoteSimulator.js"; +export { RemoteSimulator } from "../Simulator/RemoteSimulator.js"; export { VisData } from "./VisData.js"; export { ThreadUtil } from "./ThreadUtil.js"; export { SelectionInterface } from "./SelectionInterface.js"; diff --git a/src/test/BinaryFile.test.ts b/src/test/BinaryFile.test.ts index 01101f2c..542e8222 100644 --- a/src/test/BinaryFile.test.ts +++ b/src/test/BinaryFile.test.ts @@ -1,56 +1,5 @@ import BinaryFileReader from "../simularium/BinaryFileReader.js"; - -function pad(buf: ArrayBuffer): ArrayBuffer { - if (buf.byteLength % 4 !== 0) { - const newbuf = new ArrayBuffer( - buf.byteLength + (4 - (buf.byteLength % 4)) - ); - new Uint8Array(newbuf).set(new Uint8Array(buf)); - // unnecessary, because ArrayBuffer is initialized to 0 - // new Uint8Array(newbuf, buf.byteLength).fill(0); - return newbuf; - } else return buf; -} -function makeBinary(blocks: ArrayBuffer[], blockTypes: number[]): ArrayBuffer { - const numBlocks = blocks.length; - const headerfixedLen = 16 + 4 + 4 + 4; - const tocLen = 3 * numBlocks * 4; - const headerLen = headerfixedLen + tocLen; - // extra 4 for block size and 4 for type - const blockDataLen = blocks.reduce( - (acc, block) => acc + (block.byteLength + 4 + 4), - 0 - ); - const blockOffsets = [headerLen]; - for (let i = 1; i < numBlocks; i++) { - blockOffsets.push( - blockOffsets[i - 1] + (blocks[i - 1].byteLength + 4 + 4) - ); - } - // enough space for the whole thing - const buffer = new ArrayBuffer(headerfixedLen + tocLen + blockDataLen); - const view = new Uint8Array(buffer); - const view32 = new Uint32Array(buffer); - view.set("SIMULARIUMBINARY".split("").map((c) => c.charCodeAt(0))); - const headerview32 = new Uint32Array(buffer, 16); - headerview32[0] = headerfixedLen + tocLen; - headerview32[1] = 1; - headerview32[2] = numBlocks; - for (let i = 0; i < numBlocks; i++) { - // blockoffset - headerview32[3 + i * 3 + 0] = blockOffsets[i]; - // blocktype - headerview32[3 + i * 3 + 1] = blockTypes[i]; - // blocksize - headerview32[3 + i * 3 + 2] = blocks[i].byteLength + 4 + 4; - // write block itself: - view32[blockOffsets[i] / 4] = blockTypes[i]; - view32[blockOffsets[i] / 4 + 1] = blocks[i].byteLength + 4 + 4; - view.set(new Uint8Array(blocks[i]), blockOffsets[i] + 4 + 4); - } - - return buffer; -} +import { pad, makeBinary } from "./utils.js"; describe("binary simularium files", () => { test("it correctly identifies a binary simularium file signature", () => { diff --git a/src/test/DummyRemoteSimulator.ts b/src/test/DummyRemoteSimulator.ts index fd2e67ad..b23c172f 100644 --- a/src/test/DummyRemoteSimulator.ts +++ b/src/test/DummyRemoteSimulator.ts @@ -1,11 +1,8 @@ -import { - NetConnectionParams, - NetMessage, - NetMessageEnum, -} from "../simularium/WebsocketClient.js"; -import { RemoteSimulator } from "../simularium/RemoteSimulator.js"; +import { NetMessage, NetMessageEnum } from "../simularium/WebsocketClient.js"; +import { RemoteSimulator } from "../Simulator/RemoteSimulator.js"; import { VisDataFrame, VisDataMessage } from "../simularium/types.js"; import { FrontEndError } from "../simularium/FrontEndError.js"; +import { RemoteSimulatorParams } from "../Simulator/types.js"; // Mocks the simularium simulation back-end, w/ latency export class DummyRemoteSimulator extends RemoteSimulator { @@ -16,15 +13,12 @@ export class DummyRemoteSimulator extends RemoteSimulator { public connectLatencyMS: number; public totalDuration: number; public timeStep: number; - private fileName: string; public constructor( - netConnectionSettings: NetConnectionParams, - fileName: string, + params: RemoteSimulatorParams, errorHandler?: (error: FrontEndError) => void ) { - super(netConnectionSettings, errorHandler); - + super(params, errorHandler); this.isStreamingData = false; this.isConnected = false; this.frameCounter = 0; @@ -34,7 +28,7 @@ export class DummyRemoteSimulator extends RemoteSimulator { this.timeStep = 1; this.totalDuration = 99; - this.fileName = fileName; + this.lastRequestedFile = params.fileName; setInterval(this.broadcast.bind(this), 200); } @@ -110,12 +104,15 @@ export class DummyRemoteSimulator extends RemoteSimulator { this.isConnected = false; } + public isConnectedToRemoteServer(): boolean { + return true; + } + public initialize(fileName: string): Promise { - return this.webSocketClient.connectToRemoteServer().then(() => { - this.fileName = fileName; - this.isStreamingData = true; - this.lastRequestedFile = fileName; - }); + this.isConnected = true; + this.isStreamingData = false; + this.lastRequestedFile = fileName; + return Promise.resolve(); } public requestTrajectoryFileInfo(fileName: string): void { diff --git a/src/test/RemoteSimulator.test.ts b/src/test/RemoteSimulator.test.ts index f52a54eb..621b2cc0 100644 --- a/src/test/RemoteSimulator.test.ts +++ b/src/test/RemoteSimulator.test.ts @@ -18,7 +18,10 @@ describe("RemoteSimulator", () => { let simulator; beforeEach(() => { - simulator = new RemoteSimulator(CONNECTION_SETTINGS); + simulator = new RemoteSimulator({ + netConnectionSettings: CONNECTION_SETTINGS, + fileName: "trajectory.sim", + }); }); const createFakeBinary = () => { @@ -82,7 +85,10 @@ describe("RemoteSimulator", () => { test("handleError is called if connectToRemoteServer fails", async () => { const errorHandler = vi.fn(); const simulator = new RemoteSimulator( - CONNECTION_SETTINGS, + { + netConnectionSettings: CONNECTION_SETTINGS, + fileName: "trajectory.sim", + }, errorHandler ); vi.spyOn( diff --git a/src/test/SimulariumController.test.ts b/src/test/SimulariumController.test.ts index 626c228e..a64bc6b2 100644 --- a/src/test/SimulariumController.test.ts +++ b/src/test/SimulariumController.test.ts @@ -1,12 +1,393 @@ +import { describe, expect, beforeEach, vi } from "vitest"; +import TestClientSimulatorImpl from "./TestClientSimulatorImpl"; +import { DummyRemoteSimulator } from "./DummyRemoteSimulator"; +import type { NetConnectionParams } from "../simularium/WebsocketClient"; +import BinaryFileReader from "../simularium/BinaryFileReader"; +import { + ClientSimulatorParams, + LocalFileSimulatorParams, + RemoteSimulatorParams, + SimulatorParams, +} from "../Simulator/types"; +import { ClientSimulator } from "../Simulator/ClientSimulator"; +import { LocalFileSimulator } from "../Simulator/LocalFileSimulator"; +import { FILE_STATUS_FAIL, FILE_STATUS_SUCCESS } from "../simularium/types"; +import { + FrontEndError, + IClientSimulatorImpl, + RemoteSimulator, +} from "../simularium"; +import { TrajectoryType } from "../constants"; import SimulariumController from "../controller"; +import { pad, makeBinary } from "./utils"; -describe("SimulariumController module", () => { - describe("SimulariumController Time", () => { - test("Go to time in cache", () => { - // todo new approach to testing dummy simulator comming in - // changes to simulator params - const controller = new SimulariumController(); - expect(controller).toBeDefined(); +const buffer = makeBinary( + [ + pad( + new TextEncoder().encode( + JSON.stringify({ + msgType: 0, + version: 2, + timeStepSize: 1, + totalSteps: 1, + size: [7, 7, 7], + typeMapping: {}, + }) + ) + ), + pad(new ArrayBuffer(8)), + pad(new TextEncoder().encode(JSON.stringify({ data: { baz: "bat" } }))), + ], + [1, 3, 2] +); +const binarySimFile = new BinaryFileReader(buffer); + +export const LocalFileTestParams: LocalFileSimulatorParams = { + fileName: "local.simularium", + simulariumFile: binarySimFile, +}; + +export const ClientSimTestParams: ClientSimulatorParams = { + fileName: "clientsim", + clientSimulatorImpl: new TestClientSimulatorImpl(), +}; + +const dummyNetConnection: NetConnectionParams = { + serverIp: "0.0.0.0", + serverPort: 1234, +}; + +export const RemoteSimTestParams: RemoteSimulatorParams = { + fileName: "remote", + netConnectionSettings: dummyNetConnection, +}; + +const mockConversionClient = { + setOnConversionCompleteHandler: vi.fn().mockImplementation(() => { + console.log("mocked handler"); + }), + cancelConversion: vi.fn(), + disconnect: vi.fn(), + convertTrajectory: vi.fn().mockResolvedValue(undefined), + sendSmoldynData: vi.fn().mockResolvedValue(undefined), + lastRequestedFile: "", +}; + +vi.mock("../simularium/ConversionClient.js", () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + ConversionClient: vi.fn().mockImplementation(() => mockConversionClient), +})); + +vi.mock("../Simulator/SimulatorFactory", async () => { + const actual = await vi.importActual< + typeof import("../Simulator/SimulatorFactory") + >("../Simulator/SimulatorFactory"); + + return { + ...actual, + getSimulatorClassFromParams: (params?: SimulatorParams) => { + const result = actual.getSimulatorClassFromParams(params); + if (result.simulatorClass === RemoteSimulator) { + return { ...result, simulatorClass: DummyRemoteSimulator }; + } + return result; + }, + }; +}); + +describe("SimulariumController", () => { + let controller: SimulariumController; + let errorHandler: ReturnType; + + beforeEach(() => { + errorHandler = vi.fn(); + controller = new SimulariumController(); + controller.onError = errorHandler; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("changeFile", () => { + test("handles a local file", async () => { + await expect( + controller.changeFile(LocalFileTestParams) + ).resolves.toStrictEqual({ + status: FILE_STATUS_SUCCESS, + }); + + expect(controller.simulator).toBeInstanceOf(LocalFileSimulator); + expect(controller.getFile()).toBe(LocalFileTestParams.fileName); + }); + + test("rejects if file extension is not .simularium for local file", async () => { + await expect( + controller.changeFile({ + fileName: "notSimularium.txt", + simulariumFile: binarySimFile, + }) + ).rejects.toEqual({ status: FILE_STATUS_FAIL }); + }); + + test("calls handleError() if invalid simulator config is encountered", async () => { + await controller + .changeFile({ + fileName: "notSimularium.txt", + simulariumFile: binarySimFile, + }) + .catch(() => { + console.log("error"); + }); + expect(errorHandler).toHaveBeenCalled(); + expect(errorHandler.mock.calls[0][0]).toBeInstanceOf(FrontEndError); + expect(errorHandler.mock.calls[0][0].message).toBe( + "File must be a .simularium file" + ); + }); + + it("returns FILE_STATUS_FAIL and calls onError if initialise rejects", async () => { + const err = new Error("init fail"); + vi.spyOn( + LocalFileSimulator.prototype, + "initialize" + ).mockRejectedValue(err); + + await expect( + controller.changeFile(LocalFileTestParams) + ).resolves.toStrictEqual({ status: FILE_STATUS_FAIL }); + + expect(errorHandler).toHaveBeenCalledWith(err); + }); + + test("handles a client sim", async () => { + await expect( + controller.changeFile(ClientSimTestParams) + ).resolves.toStrictEqual({ + status: FILE_STATUS_SUCCESS, + }); + + expect(controller.simulator).toBeInstanceOf(ClientSimulator); + expect(controller.getFile()).toBe(ClientSimTestParams.fileName); + }); + + test("localSimulator on ClientSimulator should match params", async () => { + await controller.changeFile(ClientSimTestParams); + let clientSim: IClientSimulatorImpl | null = null; + if (controller.simulator instanceof ClientSimulator) { + // @ts-expect-error testing private property + clientSim = controller.simulator.localSimulator; + } + expect(clientSim instanceof TestClientSimulatorImpl).toBe(true); + }); + + test("handles a remote trajectory", async () => { + await expect( + controller.changeFile(RemoteSimTestParams) + ).resolves.toStrictEqual({ + status: FILE_STATUS_SUCCESS, + }); + + expect(controller.simulator).toBeInstanceOf(RemoteSimulator); + expect(controller.getFile()).toBe(RemoteSimTestParams.fileName); + }); + + test("reuses remote simulator if net settings haven't changed", async () => { + await controller.changeFile(RemoteSimTestParams); + const firstSimulator = controller.simulator; + const newParams = { + fileName: "newRemoteFile", + netConnectionSettings: dummyNetConnection, + }; + await controller.changeFile(newParams); + const secondSimulator = controller.simulator; + expect(secondSimulator).toBe(firstSimulator); + }); + + test("does NOT reuse if net settings changed", async () => { + await controller.changeFile(RemoteSimTestParams); + const firstSimulator = controller.simulator; + const newParams: RemoteSimulatorParams = { + fileName: "newRemoteFile", + netConnectionSettings: { + serverIp: "1.2.3.4", + serverPort: 5678, // different port + }, + }; + await controller.changeFile(newParams); + expect(controller.simulator).not.toBe(firstSimulator); + }); + + test("should change from one simulator type to another", async () => { + await controller.changeFile(LocalFileTestParams); + await expect( + controller.changeFile(ClientSimTestParams) + ).resolves.toStrictEqual({ + status: FILE_STATUS_SUCCESS, + }); + + expect(controller.simulator).toBeInstanceOf(ClientSimulator); + expect(controller.getFile()).toBe(ClientSimTestParams.fileName); + }); + + it("initialises new simulator and requests frame 0", async () => { + vi.spyOn( + LocalFileSimulator.prototype, + "initialize" + ).mockResolvedValue(undefined); + const reqSpy = vi + .spyOn(LocalFileSimulator.prototype, "requestFrame") + .mockImplementation(vi.fn()); + + await controller.changeFile(LocalFileTestParams); + + expect(reqSpy).toHaveBeenCalledWith(0); + }); + }); + + describe("convertTrajectory()", () => { + // Because we mocked ConversionClient above, we can check calls + // this doesn't test the internal functionality of ConversionClient + test("sets up conversion client and calls convertTrajectory", async () => { + await controller.convertTrajectory( + dummyNetConnection, + { some: "data" }, + TrajectoryType.SMOLDYN, + "test.simularium" + ); + + expect(controller.conversionClient).toBeTruthy(); + expect( + controller.conversionClient.convertTrajectory + ).toHaveBeenCalledWith( + { some: "data" }, + TrajectoryType.SMOLDYN, + "test.simularium" + ); + }); + + test("calls closeConversionConnection() and resets client on cancelConversion()", async () => { + await controller.convertTrajectory( + dummyNetConnection, + { some: "data" }, + TrajectoryType.SMOLDYN, + "test.simularium" + ); + expect(controller.conversionClient).toBeTruthy(); + controller.cancelConversion(); + expect(mockConversionClient.cancelConversion).toHaveBeenCalled(); + expect(mockConversionClient.disconnect).toHaveBeenCalled(); + expect(() => controller.conversionClient).toThrow( + "Conversion client is not configured." + ); + expect(controller.time()).toBe(-1); + }); + + test("changeFile() cancels any existing conversion and sets new file", async () => { + await controller.convertTrajectory( + dummyNetConnection, + {}, + TrajectoryType.SMOLDYN, + "file.simularium" + ); + await controller.changeFile({ + netConnectionSettings: dummyNetConnection, + fileName: "test", + }); + expect(mockConversionClient.cancelConversion).toHaveBeenCalled(); + expect(controller.getFile()).toBe("test"); + }); + }); + + describe("startSmoldynSim()", () => { + test("sets up conversion client and calls sendSmoldynData", async () => { + await controller.startSmoldynSim( + dummyNetConnection, + "mysim.simularium", + "smoldyn input" + ); + expect(controller.conversionClient).toBeTruthy(); + expect( + controller.conversionClient.sendSmoldynData + ).toHaveBeenCalledWith("mysim.simularium", "smoldyn input"); + }); + + test("calls closeConversionConnection() and resets client on cancelConversion()", async () => { + await controller.startSmoldynSim( + dummyNetConnection, + "mysim.simularium", + "smoldyn input" + ); + expect(controller.conversionClient).toBeTruthy(); + controller.cancelConversion(); + expect(mockConversionClient.cancelConversion).toHaveBeenCalled(); + expect(mockConversionClient.disconnect).toHaveBeenCalled(); + expect(() => controller.conversionClient).toThrow( + "Conversion client is not configured." + ); + expect(controller.time()).toBe(-1); + }); + test("changeFile() cancels any existing conversion and sets new file", async () => { + await controller.startSmoldynSim( + dummyNetConnection, + "mysim.simularium", + "smoldyn input" + ); + await controller.changeFile({ + netConnectionSettings: dummyNetConnection, + fileName: "test", + }); + expect(mockConversionClient.cancelConversion).toHaveBeenCalled(); + expect(controller.getFile()).toBe("test"); + }); + }); + + describe("clearFile()", () => { + test("should close any conversion connection", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(() => controller.conversionClient).toThrow( + "Conversion client is not configured." + ); + }); + test("should clear the simulator", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(controller.simulator).toBeUndefined(); + }); + test("should set the controller to paused", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(controller.paused()).toBe(true); + }); + test("should set time to -1", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(controller.getFile()).toBe(""); + expect(controller.time()).toEqual(-1); + }); + test("should reset playbackfile", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(controller.getFile()).toBe(""); + }); + test("should clear the cache", async () => { + await controller.changeFile(LocalFileTestParams); + controller.clearFile(); + expect(controller.visData.hasLocalCacheForTime(0)).toBe(false); + }); + }); + + describe("goToTime", () => { + test("Go to time in cache", async () => { + await controller.changeFile({ + fileName: "remote", + netConnectionSettings: dummyNetConnection, + }); + controller.gotoTime(2); + setTimeout(() => { + expect(controller.time()).toEqual(2); + }, 500); }); }); }); diff --git a/src/test/TestClientSimulatorImpl.ts b/src/test/TestClientSimulatorImpl.ts new file mode 100644 index 00000000..1b3e3f1e --- /dev/null +++ b/src/test/TestClientSimulatorImpl.ts @@ -0,0 +1,61 @@ +import { DEFAULT_CAMERA_SPEC } from "../constants"; +import { + IClientSimulatorImpl, + VisDataMessage, + TrajectoryFileInfo, + ClientMessageEnum, + EncodedTypeMapping, +} from "../simularium"; + +export default class TestClientSimulatorImpl implements IClientSimulatorImpl { + private frame = 0; + public name = "test"; + + update(_dt: number): VisDataMessage { + this.frame++; + return { + msgType: ClientMessageEnum.ID_VIS_DATA_ARRIVE, + bundleStart: this.frame, + bundleSize: 1, + bundleData: [ + { + data: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0.1, 0], // One single point at origin + frameNumber: this.frame, + time: this.frame, + }, + ], + fileName: "test", + }; + } + + getInfo(): TrajectoryFileInfo { + const typeMapping: EncodedTypeMapping = { 0: { name: `point0` } }; + return { + connId: "hello world", + msgType: ClientMessageEnum.ID_TRAJECTORY_FILE_INFO, + version: 2, + timeStepSize: 1, + totalSteps: 1000, + // bounding volume dimensions + size: { + x: 12, + y: 12, + z: 12, + }, + cameraDefault: DEFAULT_CAMERA_SPEC, + typeMapping: typeMapping, + spatialUnits: { + magnitude: 1, + name: "m", + }, + timeUnits: { + magnitude: 1, + name: "s", + }, + }; + } + + updateSimulationState(_data: Record): void { + // Empty implementation + } +} diff --git a/src/test/utils.ts b/src/test/utils.ts new file mode 100644 index 00000000..0f1f820b --- /dev/null +++ b/src/test/utils.ts @@ -0,0 +1,53 @@ +// utils for making binary data for testing BinaryFileReader and Simularium Controller +export function pad(buf: ArrayBuffer): ArrayBuffer { + if (buf.byteLength % 4 !== 0) { + const newbuf = new ArrayBuffer( + buf.byteLength + (4 - (buf.byteLength % 4)) + ); + new Uint8Array(newbuf).set(new Uint8Array(buf)); + return newbuf; + } else return buf; +} +export function makeBinary( + blocks: ArrayBuffer[], + blockTypes: number[] +): ArrayBuffer { + const numBlocks = blocks.length; + const headerfixedLen = 16 + 4 + 4 + 4; + const tocLen = 3 * numBlocks * 4; + const headerLen = headerfixedLen + tocLen; + // extra 4 for block size and 4 for type + const blockDataLen = blocks.reduce( + (acc, block) => acc + (block.byteLength + 4 + 4), + 0 + ); + const blockOffsets = [headerLen]; + for (let i = 1; i < numBlocks; i++) { + blockOffsets.push( + blockOffsets[i - 1] + (blocks[i - 1].byteLength + 4 + 4) + ); + } + // enough space for the whole thing + const buffer = new ArrayBuffer(headerfixedLen + tocLen + blockDataLen); + const view = new Uint8Array(buffer); + const view32 = new Uint32Array(buffer); + view.set("SIMULARIUMBINARY".split("").map((c) => c.charCodeAt(0))); + const headerview32 = new Uint32Array(buffer, 16); + headerview32[0] = headerfixedLen + tocLen; + headerview32[1] = 1; + headerview32[2] = numBlocks; + for (let i = 0; i < numBlocks; i++) { + // blockoffset + headerview32[3 + i * 3 + 0] = blockOffsets[i]; + // blocktype + headerview32[3 + i * 3 + 1] = blockTypes[i]; + // blocksize + headerview32[3 + i * 3 + 2] = blocks[i].byteLength + 4 + 4; + // write block itself: + view32[blockOffsets[i] / 4] = blockTypes[i]; + view32[blockOffsets[i] / 4 + 1] = blocks[i].byteLength + 4 + 4; + view.set(new Uint8Array(blocks[i]), blockOffsets[i] + 4 + 4); + } + + return buffer; +}