From 276878d20b37d4e980ac2397026954fc51b0221d Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 14:52:04 -0500 Subject: [PATCH 1/6] INT-76 Refactor and centralize `Compute` --- src/classes/Compute.ts | 17 ++++ src/classes/factories/ScreenPropFactory.ts | 13 +-- src/index.tsx | 43 ++++++--- src/plugin.ts | 2 +- src/view/screens/Loaded/index.tsx | 8 +- src/view/screens/Loading/index.tsx | 100 ++++++++++----------- types/index.d.ts | 5 +- types/jsPsych.d.ts | 5 +- types/props.d.ts | 7 +- types/window.d.ts | 4 + 10 files changed, 121 insertions(+), 83 deletions(-) diff --git a/src/classes/Compute.ts b/src/classes/Compute.ts index fce1c7e..705ce09 100644 --- a/src/classes/Compute.ts +++ b/src/classes/Compute.ts @@ -770,6 +770,9 @@ class Compute { // WebR instance used to run the model script private webR: WebR; + // Status of operation + private ready: boolean; + /** * Default constructor * @constructor @@ -784,6 +787,9 @@ class Compute { } : {} ); + + // Initialize the status of operation + this.ready = false; } /** @@ -815,6 +821,17 @@ class Compute { } await this.webR.evalR(FUNCTIONS); + + // Set the status of operation + this.ready = true; + } + + /** + * Get the status of operation + * @return {boolean} + */ + public isReady(): boolean { + return this.ready; } /** diff --git a/src/classes/factories/ScreenPropFactory.ts b/src/classes/factories/ScreenPropFactory.ts index db78a28..6130b32 100644 --- a/src/classes/factories/ScreenPropFactory.ts +++ b/src/classes/factories/ScreenPropFactory.ts @@ -106,7 +106,7 @@ class ScreenPropFactory implements Factory { // Loaded screen case "loaded": - if (this.trial.loadingType === "social") { + if (this.trial.state === "social") { // Indefinite duration for "social" loading completion returned.duration = 0; @@ -114,7 +114,7 @@ class ScreenPropFactory implements Factory { returned.props = { trial: this.trial.trial, display: this.trial.display, - loadingType: this.trial.loadingType, + state: this.trial.state, handler: this.handler.callback.bind(this.handler), }; } else { @@ -127,7 +127,7 @@ class ScreenPropFactory implements Factory { returned.props = { trial: this.trial.trial, display: this.trial.display, - loadingType: this.trial.loadingType, + state: this.trial.state, }; } break; @@ -144,7 +144,7 @@ class ScreenPropFactory implements Factory { // Loading screen case "loading": - if (this.trial.loadingType === "social") { + if (this.trial.state === "social") { // 1-4 second timeout for "social" state returned.duration = 1000 + (1 + Math.random() * 3) * 1000; } else { @@ -159,8 +159,9 @@ class ScreenPropFactory implements Factory { returned.props = { trial: this.trial.trial, display: this.trial.display, - loadingType: this.trial.loadingType || "default", // Default to "default" type if not specified - fetchData: this.trial.fetchData, + state: this.trial.state || "default", // Default to "default" type if not specified + runComputeSetup: this.trial.runComputeSetup, + runComputeOperation: this.trial.runComputeOperation, handler: this.handler.loading.bind(this.handler), }; break; diff --git a/src/index.tsx b/src/index.tsx index 02d727f..e8301a5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -52,6 +52,9 @@ import { v4 as uuidv4 } from "uuid"; // Import crossplatform API import { Experiment } from "neurocog"; +// Import Compute class +import Compute from "./classes/Compute"; + // Import jsPsych plugins import "jspsych/plugins/jspsych-fullscreen"; import "jspsych/plugins/jspsych-instructions"; @@ -104,6 +107,9 @@ experiment.getState().set("participantDefaultStatus", participantDefaultStatus); experiment.getState().set("partnerLowStatus", partnerLowStatus); experiment.getState().set("partnerHighStatus", partnerHighStatus); +// Setup the Compute instance +window.Compute = new Compute(); + // Timeline setup const timeline: Timeline = []; @@ -130,6 +136,15 @@ if (Flags.isEnabled("enableFullscreen")) { }); } +// Global loading screen, setup WebR +timeline.push({ + type: Configuration.studyName, + display: "loading", + state: "default", + runComputeSetup: true, + runComputeOperation: false, +}); + // Add controls instructions first if using alternate input scheme if (Configuration.manipulations.useButtonInput === true) { timeline.push({ @@ -420,14 +435,14 @@ if (Flags.isEnabled("enableQuestionnaireStatus") === true) { timeline.push({ type: Configuration.studyName, display: "loading", - loadingType: "social", - fetchData: false, + state: "social", + runComputeOperation: false, }); timeline.push({ type: Configuration.studyName, display: "loaded", - loadingType: "social", + state: "social", }); } @@ -458,13 +473,13 @@ if (Configuration.manipulations.enableCyberball === true) { timeline.push({ type: Configuration.studyName, display: "loading", - loadingType: "matchingCyberball", + state: "matchingCyberball", }); timeline.push({ type: Configuration.studyName, display: "loaded", - loadingType: "matchingCyberball", + state: "matchingCyberball", }); timeline.push({ @@ -870,14 +885,14 @@ timeline.push({ timeline.push({ type: Configuration.studyName, display: "loading", - loadingType: "matchingIntentions", - fetchData: false, + state: "matchingIntentions", + runComputeOperation: false, }); timeline.push({ type: Configuration.studyName, display: "loaded", - loadingType: "matchingIntentions", + state: "matchingIntentions", }); // Insert `statusPreview` screen if the participant will be shown their status @@ -1245,14 +1260,14 @@ for (let i = 0; i < dataCollection.length; i++) { timeline.push({ type: Configuration.studyName, display: "loading", - loadingType: "matchingIntentions", - fetchData: true, + state: "matchingIntentions", + runComputeOperation: true, }); timeline.push({ type: Configuration.studyName, display: "loaded", - loadingType: "matchingIntentions", + state: "matchingIntentions", }); // Insert `statusPreview` screen if the participant will be shown their status @@ -1437,14 +1452,14 @@ for (let i = 0; i < dataCollection.length; i++) { timeline.push({ type: Configuration.studyName, display: "loading", - loadingType: "matchingIntentions", - fetchData: false, + state: "matchingIntentions", + runComputeOperation: false, }); timeline.push({ type: Configuration.studyName, display: "loaded", - loadingType: "matchingIntentions", + state: "matchingIntentions", }); // Insert `statusPreview` screen if the participant will be shown their status diff --git a/src/plugin.ts b/src/plugin.ts index 61f9be9..299e34f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -107,7 +107,7 @@ jsPsych.plugins[Configuration.studyName] = (() => { }, description: "Spotlight a UI component with a closeable message", }, - fetchData: { + runComputeOperation: { type: jsPsych.plugins.parameterType.BOOLEAN, pretty_name: "Enable or disable server queries", default: false, diff --git a/src/view/screens/Loaded/index.tsx b/src/view/screens/Loaded/index.tsx index 104ffc9..fec5567 100644 --- a/src/view/screens/Loaded/index.tsx +++ b/src/view/screens/Loaded/index.tsx @@ -38,9 +38,9 @@ const Loaded: FC = (props: Props.Screens.Loaded): ReactEle // Get the current partner avatar const experiment = window.Experiment; const currentPartner = experiment.getState().get("partnerAvatar"); - const loadingType = props.loadingType; + const state = props.state; - if (loadingType === "matchingIntentions") { + if (state === "matchingIntentions") { // Increment the partner avatar value if (experiment.getState().get("refreshPartner") === true) { // Ensure we keep the index in range @@ -73,7 +73,7 @@ const Loaded: FC = (props: Props.Screens.Loaded): ReactEle ); - } else if (loadingType === "matchingCyberball") { + } else if (state === "matchingCyberball") { const partnerAID = generatePartnerID(); const partnerBID = generatePartnerID(); experiment.getState().set("cyberballPartnerAID", partnerAID); @@ -103,7 +103,7 @@ const Loaded: FC = (props: Props.Screens.Loaded): ReactEle ); - } else if (loadingType === "social") { + } else if (state === "social") { return ( <> diff --git a/src/view/screens/Loading/index.tsx b/src/view/screens/Loading/index.tsx index b5c066f..eabbe77 100644 --- a/src/view/screens/Loading/index.tsx +++ b/src/view/screens/Loading/index.tsx @@ -21,15 +21,13 @@ import consola from "consola"; // Grommet UI components import { Box, Heading, Layer, Spinner, WorldMap } from "grommet"; -// Request library -import Compute from "src/classes/Compute"; - /** * @summary Generate a 'Loading' screen presenting a loading indicator and text based on the current state * @param {Props.Screens.Loading} props Component props containing: * - state: {"matchingIntentions" | "matchingCyberball" | "social" | "default"} The loading state to display - * - fetchData?: {boolean} Flag indicating whether to fetch data from server (only for matching state) - * - handler?: {(participantParams: ModelParameters, partnerParams: ModelParameters) => void} Callback to handle model parameters (only for matching state) + * - runComputeSetup?: {boolean} Flag indicating whether to setup the compute instance + * - runComputeOperation?: {boolean} Flag indicating whether to compute participant and partner parameters + * - handler?: {(participantParams: ModelParameters, partnerParams: ModelParameters) => void} Callback to handle model parameters * @return {ReactElement} 'Loading' screen with loading indicator and state-specific status message */ const Loading: FC = ( @@ -39,7 +37,7 @@ const Loading: FC = ( // Get the appropriate text based on the loading type const getLoadingText = (): string => { - switch (props.loadingType) { + switch (props.state) { case "matchingIntentions": return "Finding you a partner..."; case "matchingCyberball": @@ -47,9 +45,9 @@ const Loading: FC = ( case "social": return "Generating relative social standing..."; case "default": - return "Loading..."; + return "Experiment Loading..."; default: - return "Loading..."; + return "Experiment Loading..."; } }; @@ -87,55 +85,55 @@ const Loading: FC = ( } }; - const runMatching = async () => { - // Launch request - if (props.fetchData && props.loadingType === "matchingIntentions") { - // Setup a new 'Compute' instance - const compute = new Compute(); - await compute.setup(); - - // Collate data from 'playerChoice' trials - consola.info(`Collating data...`); - const dataCollection = jsPsych.data - .get() - .filter({ - display: "playerChoice", - }) - .values(); - - consola.debug( - `'dataCollection' containing trials with 'display' = 'playerChoice':`, - dataCollection - ); - - // Format the responses to be sent to the server - const requestResponses = []; - for (const row of dataCollection) { - requestResponses.push({ - ID: "NA", - Trial: row.trial, - ppt1: row.playerPoints_option1, - par1: row.partnerPoints_option1, - ppt2: row.playerPoints_option2, - par2: row.partnerPoints_option2, - Ac: row.selectedOption_player, - Phase: 1, - }); - } - consola.debug(`Request content 'requestResponses':`, requestResponses); + const runComputeSetup = async () => { + await window.Compute.setup(); + consola.success("Compute setup complete"); + }; - // Launch model computation - consola.info(`Requesting partner...`); - await compute.submit(requestResponses, callback); + const runComputeOperation = async () => { + // Collate data from 'playerChoice' trials + consola.info(`Collating data...`); + const dataCollection = jsPsych.data + .get() + .filter({ + display: "playerChoice", + }) + .values(); + + consola.debug( + `'dataCollection' containing trials with 'display' = 'playerChoice':`, + dataCollection + ); + + // Format the responses to be sent to the server + const requestResponses = []; + for (const row of dataCollection) { + requestResponses.push({ + ID: "NA", + Trial: row.trial, + ppt1: row.playerPoints_option1, + par1: row.partnerPoints_option1, + ppt2: row.playerPoints_option2, + par2: row.partnerPoints_option2, + Ac: row.selectedOption_player, + Phase: 1, + }); } + consola.debug(`Request content 'requestResponses':`, requestResponses); + + // Launch model computation + consola.info(`Requesting partner...`); + await window.Compute.submit(requestResponses, callback); }; - // Run the matching process when first displayed (only for matching type) + // Run any computing operations as specified useEffect(() => { - if (props.loadingType === "matchingIntentions") { - runMatching(); + if (props.runComputeOperation && window.Compute.isReady()) { + runComputeOperation(); + } else if (props.runComputeSetup) { + runComputeSetup(); } - }); + }, []); return ( <> diff --git a/types/index.d.ts b/types/index.d.ts index 2c3a46e..ee80d0d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -103,8 +103,9 @@ declare type Trial = { isPractice: boolean; // Whether this is a practice trial // Loading screen configuration (used by Loading screen) - loadingType?: "matchingIntentions" | "matchingCyberball" | "social" | "default"; // Type of loading: "matchingIntentions" (partner matching), "matchingCyberball" (cyberball partners), "social" (status generation), or "default" (generic loading) - fetchData: boolean; // Whether to fetch data from server (only used when loadingType is "matchingIntentions") + state?: "matchingIntentions" | "matchingCyberball" | "social" | "default"; // Type of loading: "matchingIntentions" (partner matching), "matchingCyberball" (cyberball partners), "social" (status generation), or "default" (generic loading) + runComputeSetup?: boolean; // Whether to run WebR setup + runComputeOperation?: boolean; // Whether to fetch data from server (only used when state is "matchingIntentions") // Status preview screen configuration (used by StatusPreview screen) isPartnerHighStatus?: boolean; // Used for the `StatusPreview` screen diff --git a/types/jsPsych.d.ts b/types/jsPsych.d.ts index 759275e..5d5b5f3 100644 --- a/types/jsPsych.d.ts +++ b/types/jsPsych.d.ts @@ -44,8 +44,9 @@ declare type TimelineNode = { }; // Loading and loaded screens - fetchData?: boolean; - loadingType?: "matchingIntentions" | "matchingCyberball" | "social" | "default"; + runComputeSetup?: boolean; + runComputeOperation?: boolean; + state?: "matchingIntentions" | "matchingCyberball" | "social" | "default"; isPartnerHighStatus?: boolean; // Waiting screen diff --git a/types/props.d.ts b/types/props.d.ts index cde9ad4..9b59cfd 100644 --- a/types/props.d.ts +++ b/types/props.d.ts @@ -85,14 +85,15 @@ declare namespace Props { // Loaded screen type Loaded = GenericScreenProps & { - loadingType: "matchingIntentions" | "matchingCyberball" | "social"; + state: "matchingIntentions" | "matchingCyberball" | "social"; handler: () => void; }; // Loading screen type Loading = GenericScreenProps & { - loadingType: "matchingIntentions" | "matchingCyberball" | "social" | "default"; - fetchData?: boolean; + state: "matchingIntentions" | "matchingCyberball" | "social" | "default"; + runComputeSetup?: boolean; + runComputeOperation?: boolean; handler?: ( participantParameters: number[], partnerParameters: number[] diff --git a/types/window.d.ts b/types/window.d.ts index 995d361..37a4889 100644 --- a/types/window.d.ts +++ b/types/window.d.ts @@ -6,9 +6,13 @@ // 'Experiment' jsPsych wrapper library import { Experiment } from "neurocog"; +// Compute class +import Compute from "src/classes/Compute"; + // Add 'Experiment' to the global Window interface declare global { interface Window { Experiment: Experiment; + Compute: Compute; } } From 22832e272059f13f9a9c8239eb630ee63f6e8929 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 15:20:32 -0500 Subject: [PATCH 2/6] INT-76 Record `Compute` operation durations --- src/classes/Compute.ts | 236 +++++++++++++++++++-- src/classes/Handler.ts | 45 ++-- src/classes/factories/ScreenPropFactory.ts | 6 +- src/plugin.ts | 2 + src/view/screens/Loading/index.tsx | 75 +++---- types/index.d.ts | 2 + types/props.d.ts | 5 +- 7 files changed, 300 insertions(+), 71 deletions(-) diff --git a/src/classes/Compute.ts b/src/classes/Compute.ts index 705ce09..f88789e 100644 --- a/src/classes/Compute.ts +++ b/src/classes/Compute.ts @@ -763,6 +763,210 @@ full_data <- read.csv(text = paste0("${MODEL_DATA}")) %>% dplyr::select(-X) precan_df <- precan_partners(full_data) `; +// Collection of test responses +const TEST_RESPONSES = [ + { + ID: "TEST_001", + Trial: 1, + ppt1: 8, + par1: 6, + ppt2: 6, + par2: 8, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_002", + Trial: 2, + ppt1: 9, + par1: 5, + ppt2: 7, + par2: 7, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_003", + Trial: 3, + ppt1: 6, + par1: 8, + ppt2: 8, + par2: 6, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_004", + Trial: 4, + ppt1: 7, + par1: 7, + ppt2: 9, + par2: 5, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_005", + Trial: 5, + ppt1: 5, + par1: 9, + ppt2: 7, + par2: 7, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_006", + Trial: 6, + ppt1: 8, + par1: 6, + ppt2: 6, + par2: 8, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_007", + Trial: 7, + ppt1: 9, + par1: 5, + ppt2: 5, + par2: 9, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_008", + Trial: 8, + ppt1: 6, + par1: 8, + ppt2: 8, + par2: 6, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_009", + Trial: 9, + ppt1: 7, + par1: 7, + ppt2: 9, + par2: 5, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_010", + Trial: 10, + ppt1: 5, + par1: 9, + ppt2: 7, + par2: 7, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_011", + Trial: 11, + ppt1: 8, + par1: 6, + ppt2: 6, + par2: 8, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_012", + Trial: 12, + ppt1: 9, + par1: 5, + ppt2: 5, + par2: 9, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_013", + Trial: 13, + ppt1: 6, + par1: 8, + ppt2: 8, + par2: 6, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_014", + Trial: 14, + ppt1: 7, + par1: 7, + ppt2: 9, + par2: 5, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_015", + Trial: 15, + ppt1: 5, + par1: 9, + ppt2: 7, + par2: 7, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_016", + Trial: 16, + ppt1: 8, + par1: 6, + ppt2: 6, + par2: 8, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_017", + Trial: 17, + ppt1: 9, + par1: 5, + ppt2: 5, + par2: 9, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_018", + Trial: 18, + ppt1: 6, + par1: 8, + ppt2: 8, + par2: 6, + Ac: 2, + Phase: 1 + }, + { + ID: "TEST_019", + Trial: 19, + ppt1: 7, + par1: 7, + ppt2: 9, + par2: 5, + Ac: 1, + Phase: 1 + }, + { + ID: "TEST_020", + Trial: 20, + ppt1: 5, + par1: 9, + ppt2: 7, + par2: 7, + Ac: 2, + Phase: 1 + } +]; + /** * @summary Compute class used to run a model locally in the browser using WebR. */ @@ -800,6 +1004,7 @@ class Compute { // Initialize the WebR instance await this.webR.init(); + // Install required packages if (Configuration.manipulations.useOfflinePackages) { consola.start("Using offline packages..."); try { @@ -809,7 +1014,6 @@ class Compute { consola.error(error); } } else { - // Install required packages and evaluate the script consola.start("Using online packages..."); await this.webR.installPackages([ "matlab", @@ -818,11 +1022,11 @@ class Compute { "dplyr", "logger", ]); + consola.success("Online packages installed successfully"); } + // Evaluate the R script await this.webR.evalR(FUNCTIONS); - - // Set the status of operation this.ready = true; } @@ -839,7 +1043,7 @@ class Compute { * @param {any[]} data Data returned by R functions * @return {any} Data structure containing reformatting model responses */ - private handleResponse(data: any[]): ModelResponse { + private parseResponse(data: any[]): ModelResponse { // Get the participant parameters const participantParameters = data[0].values; @@ -871,27 +1075,23 @@ class Compute { /** * Run the R script with the current user responses * @param {any[]} data request parameters - * @param {function(data: any): void} callback + * @param {boolean} useTestResponses whether to use test responses for debugging */ - public async submit( - data: any[], - callback: (data: any) => void - ): Promise { - const startTime = performance.now(); + public async submit(data: any[], useTestResponses=false): Promise { + if (useTestResponses) { + data = TEST_RESPONSES; + } // Evaluate the R function and pass in user responses - // Note: Need to format JSON with " rather than ' + const startTime = performance.now(); const result = await this.webR.evalR( - `model_wrapper(fromJSON('${JSON.stringify(data)}'))` + `model_wrapper(fromJSON('${JSON.stringify(data)}'))` // Note: Need to format JSON with " rather than ' ); const parsed: WebRDataJsNode = (await result.toJs()) as WebRDataJsNode; + consola.success(`Compute complete after ${Math.round(performance.now() - startTime)}ms`); - // Handle the response from the R script and run the provided callback function - const parameters = this.handleResponse(parsed.values); - callback(parameters); - - const endTime = performance.now(); - consola.info(`Compute complete after ${Math.round(endTime - startTime)}ms`); + // Parse the response from the R script + return this.parseResponse(parsed.values); } } diff --git a/src/classes/Handler.ts b/src/classes/Handler.ts index 593739c..650154d 100644 --- a/src/classes/Handler.ts +++ b/src/classes/Handler.ts @@ -178,28 +178,47 @@ class Handler { /** * Handler called after loading request completed (for matching state) + * @param {boolean} storeParameters whether to store the parameters * @param {number[]} participantParameters generated model * parameters for participant * @param {number[]} partnerParameters generated model parameters for partner + * @param {number} setupDuration duration of the setup operation in ms + * @param {number} operationDuration duration of the operation operation in ms */ public loading( + storeParameters: boolean, participantParameters: number[], - partnerParameters: number[] + partnerParameters: number[], + setupDuration: number, + operationDuration: number ): void { - consola.debug( - "Loading responses:", - participantParameters, - partnerParameters - ); - // Store participant parameters - this.dataframe.server_alpha_ppt = participantParameters[0]; - this.dataframe.server_beta_ppt = participantParameters[1]; + if (storeParameters) { + consola.debug( + "Loading responses:", + participantParameters, + partnerParameters + ); + // Store participant parameters + this.dataframe.server_alpha_ppt = participantParameters[0]; + this.dataframe.server_beta_ppt = participantParameters[1]; + + // Store partner parameters + this.dataframe.server_alpha_par = partnerParameters[0]; + this.dataframe.server_beta_par = partnerParameters[1]; + } - // Store partner parameters - this.dataframe.server_alpha_par = partnerParameters[0]; - this.dataframe.server_beta_par = partnerParameters[1]; + // Store timing data + this.dataframe.setupDuration = 0; + this.dataframe.operationDuration = 0; + if (setupDuration > 0) { + this.dataframe.setupDuration = setupDuration; + } + if (operationDuration > 0) { + this.dataframe.operationDuration = operationDuration; + } - // We don't call the callback on a timer + // Finish trial + this.callback(); } /** diff --git a/src/classes/factories/ScreenPropFactory.ts b/src/classes/factories/ScreenPropFactory.ts index 6130b32..3a7daa8 100644 --- a/src/classes/factories/ScreenPropFactory.ts +++ b/src/classes/factories/ScreenPropFactory.ts @@ -147,9 +147,11 @@ class ScreenPropFactory implements Factory { if (this.trial.state === "social") { // 1-4 second timeout for "social" state returned.duration = 1000 + (1 + Math.random() * 3) * 1000; - } else { - // 10-15 second timeout for "matchingIntentions" and "matchingCyberball" state + } else if (!this.trial.runComputeSetup && !this.trial.runComputeOperation) { + // 10-15 second timeout for all non-compute states returned.duration = 10000 + (1 + Math.random() * 5) * 1000; + } else { + returned.duration = 0; } // Set the timeout callback function diff --git a/src/plugin.ts b/src/plugin.ts index 299e34f..1ddce60 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -140,6 +140,8 @@ jsPsych.plugins[Configuration.studyName] = (() => { realAnswer: trial.answer, correctGuess: NaN, // whether or not the participant guessed correctly trialDuration: NaN, // duration of the trial in ms + setupDuration: NaN, // duration of the setup operation in ms + operationDuration: NaN, // duration of the operation operation in ms // Model parameters server_alpha_ppt: NaN, // parameters generated by server diff --git a/src/view/screens/Loading/index.tsx b/src/view/screens/Loading/index.tsx index eabbe77..51b322b 100644 --- a/src/view/screens/Loading/index.tsx +++ b/src/view/screens/Loading/index.tsx @@ -27,7 +27,7 @@ import { Box, Heading, Layer, Spinner, WorldMap } from "grommet"; * - state: {"matchingIntentions" | "matchingCyberball" | "social" | "default"} The loading state to display * - runComputeSetup?: {boolean} Flag indicating whether to setup the compute instance * - runComputeOperation?: {boolean} Flag indicating whether to compute participant and partner parameters - * - handler?: {(participantParams: ModelParameters, partnerParams: ModelParameters) => void} Callback to handle model parameters + * - handler?: {(participantParams: ModelParameters, partnerParams: ModelParameters, setupDuration: number, operationDuration: number) => void} Callback to handle model parameters * @return {ReactElement} 'Loading' screen with loading indicator and state-specific status message */ const Loading: FC = ( @@ -51,46 +51,15 @@ const Loading: FC = ( } }; - const callback = (data: ModelResponse) => { - // Parse and store the JSON content - try { - // Extract the response data of interest - // Participant data - const participantParameters = data.participantParameters; - - // Partner data - const partnerParameters = data.partnerParameters; - const partnerChoices = data.partnerChoices; - - // Check the specification of the data first, require exactly 54 trials - if (partnerChoices.length > 0) { - // Store the partner choices - experiment.getState().set("partnerChoices", partnerChoices); - - // Store parameters - if (props.handler) { - props.handler(participantParameters, partnerParameters); - } - } else { - consola.warn(`Phase data appears to be incomplete`); - - // If we have an error, we need to end the game - experiment.invokeError(new Error("Incomplete response from server")); - } - } catch (error) { - consola.warn(`Error occurred when extracting content:`, error); - - // If we have an error, we need to end the game - experiment.invokeError(new Error("Error extracting content")); - } - }; - const runComputeSetup = async () => { + const startTime = performance.now(); await window.Compute.setup(); consola.success("Compute setup complete"); + finishLoading(false, [], [], performance.now() - startTime, 0); }; const runComputeOperation = async () => { + const startTime = performance.now(); // Collate data from 'playerChoice' trials consola.info(`Collating data...`); const dataCollection = jsPsych.data @@ -122,8 +91,40 @@ const Loading: FC = ( consola.debug(`Request content 'requestResponses':`, requestResponses); // Launch model computation - consola.info(`Requesting partner...`); - await window.Compute.submit(requestResponses, callback); + consola.info(`Running model computation...`); + const response = await window.Compute.submit(requestResponses, true); + + // Parse and store the JSON content + try { + // Extract the response data of interest + // Participant data + const participantParameters = response.participantParameters; + + // Partner data + const partnerParameters = response.partnerParameters; + const partnerChoices = response.partnerChoices; + + // Check the specification of the data first, require exactly 54 trials + if (partnerChoices.length > 0) { + // Store the partner choices + experiment.getState().set("partnerChoices", partnerChoices); + finishLoading(true, participantParameters, partnerParameters, 0, performance.now() - startTime); + } else { + // If we have an error, we need to end the game + consola.warn(`Phase data appears to be incomplete`); + experiment.invokeError(new Error("Incomplete response from server")); + } + } catch (error) { + // If we have an error, we need to end the game + consola.warn(`Error occurred when extracting content:`, error); + experiment.invokeError(new Error("Error extracting content")); + } + }; + + const finishLoading = (storeParameters: boolean, participantParameters: number[], partnerParameters: number[], setupDuration: number, operationDuration: number) => { + if (props.handler) { + props.handler(storeParameters, participantParameters, partnerParameters, setupDuration, operationDuration); + } }; // Run any computing operations as specified diff --git a/types/index.d.ts b/types/index.d.ts index ee80d0d..9f1284c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -158,6 +158,8 @@ declare type TrialData = { server_beta_ppt: number; server_alpha_par: number; server_beta_par: number; + setupDuration: number; + operationDuration: number; // Signal timestamps signalTimestamps: number[]; diff --git a/types/props.d.ts b/types/props.d.ts index 9b59cfd..d8dfe1f 100644 --- a/types/props.d.ts +++ b/types/props.d.ts @@ -95,8 +95,11 @@ declare namespace Props { runComputeSetup?: boolean; runComputeOperation?: boolean; handler?: ( + storeParameters: boolean, participantParameters: number[], - partnerParameters: number[] + partnerParameters: number[], + setupDuration: number, + operationDuration: number ) => void; }; From ef384c07d989664c206621e1a7f5dec05a44a95a Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 15:27:13 -0500 Subject: [PATCH 3/6] INT-76 Apply timing padding --- src/view/screens/Loading/index.tsx | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/view/screens/Loading/index.tsx b/src/view/screens/Loading/index.tsx index 51b322b..a8bc326 100644 --- a/src/view/screens/Loading/index.tsx +++ b/src/view/screens/Loading/index.tsx @@ -21,6 +21,10 @@ import consola from "consola"; // Grommet UI components import { Box, Heading, Layer, Spinner, WorldMap } from "grommet"; +// Duration variables +const MIN_SETUP_DURATION = 10000; // 10 seconds +const MIN_OPERATION_DURATION = 12000; // 12 seconds + /** * @summary Generate a 'Loading' screen presenting a loading indicator and text based on the current state * @param {Props.Screens.Loading} props Component props containing: @@ -55,7 +59,18 @@ const Loading: FC = ( const startTime = performance.now(); await window.Compute.setup(); consola.success("Compute setup complete"); - finishLoading(false, [], [], performance.now() - startTime, 0); + const endTime = performance.now(); + + // If the setup duration is less than the minimum, wait for the minimum duration + if (endTime - startTime < MIN_SETUP_DURATION) { + const duration = MIN_SETUP_DURATION - (endTime - startTime); + consola.info(`Applying delay of ${duration}ms to complete setup...`); + setTimeout(() => { + finishLoading(false, [], [], endTime - startTime, 0); + }, duration); + } else { + finishLoading(false, [], [], endTime - startTime, 0); + } }; const runComputeOperation = async () => { @@ -108,7 +123,18 @@ const Loading: FC = ( if (partnerChoices.length > 0) { // Store the partner choices experiment.getState().set("partnerChoices", partnerChoices); - finishLoading(true, participantParameters, partnerParameters, 0, performance.now() - startTime); + + // If the operation duration is less than the minimum, wait for the minimum duration + const endTime = performance.now(); + if (endTime - startTime < MIN_OPERATION_DURATION) { + const duration = MIN_OPERATION_DURATION - (endTime - startTime); + consola.info(`Applying delay of ${duration}ms to complete operation...`); + setTimeout(() => { + finishLoading(true, participantParameters, partnerParameters, 0, endTime - startTime); + }, duration); + } else { + finishLoading(true, participantParameters, partnerParameters, 0, endTime - startTime); + } } else { // If we have an error, we need to end the game consola.warn(`Phase data appears to be incomplete`); From 621e1440e937900d6c7113331050fb2ea4058b3b Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 15:48:57 -0500 Subject: [PATCH 4/6] INT-76 Block duplicate `Compute` operations --- src/view/screens/Loading/index.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/view/screens/Loading/index.tsx b/src/view/screens/Loading/index.tsx index a8bc326..afd5db1 100644 --- a/src/view/screens/Loading/index.tsx +++ b/src/view/screens/Loading/index.tsx @@ -13,7 +13,7 @@ */ // React import -import React, { FC, ReactElement, useEffect } from "react"; +import React, { FC, ReactElement, useEffect, useState } from "react"; // Logging library import consola from "consola"; @@ -39,6 +39,9 @@ const Loading: FC = ( ): ReactElement => { const experiment = window.Experiment; + // Safeguard against duplicate processing + const [blockAdditionalProcessing, setBlockAdditionalProcessing] = useState(false); + // Get the appropriate text based on the loading type const getLoadingText = (): string => { switch (props.state) { @@ -147,6 +150,14 @@ const Loading: FC = ( } }; + /** + * Finish the loading process + * @param storeParameters whether to store the parameters + * @param participantParameters generated model parameters for participant + * @param partnerParameters generated model parameters for partner + * @param setupDuration duration of the setup operation in ms + * @param operationDuration duration of the operation operation in ms + */ const finishLoading = (storeParameters: boolean, participantParameters: number[], partnerParameters: number[], setupDuration: number, operationDuration: number) => { if (props.handler) { props.handler(storeParameters, participantParameters, partnerParameters, setupDuration, operationDuration); @@ -155,6 +166,12 @@ const Loading: FC = ( // Run any computing operations as specified useEffect(() => { + if (blockAdditionalProcessing) { + return; + } + + // Block additional processing and run the appropriate operation + setBlockAdditionalProcessing(true); if (props.runComputeOperation && window.Compute.isReady()) { runComputeOperation(); } else if (props.runComputeSetup) { From 006a059f56a5b107482f58a1c0e97b668a8116b5 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 16:05:07 -0500 Subject: [PATCH 5/6] INT-76 Update existing tests --- test/classes/factories/ScreenPropFactory.test.ts | 10 +++++----- test/utils/functions.ts | 6 ++++-- test/view/screens/Loaded.test.tsx | 2 +- test/view/screens/Loading.test.tsx | 14 +++++++------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/test/classes/factories/ScreenPropFactory.test.ts b/test/classes/factories/ScreenPropFactory.test.ts index dad61f8..7cfb31d 100644 --- a/test/classes/factories/ScreenPropFactory.test.ts +++ b/test/classes/factories/ScreenPropFactory.test.ts @@ -102,7 +102,7 @@ test("generate props for Loaded screen", async () => { // Check contents of props expect(generated.props.trial).toBe(1); expect(generated.props.display).toBe("loaded"); - expect(generated.props.loadingType).toBe("matchingIntentions"); + expect(generated.props.state).toBe("matchingIntentions"); expect(generated.props).not.toHaveProperty("handler"); }); @@ -110,8 +110,8 @@ test("generate props for Loading screen with matching type", async () => { // Create a new ScreenPropFactory instance with loading configuration const trialConfig = { ...getTrialConfiguration("loading"), - loadingType: "matchingIntentions" as const, - fetchData: false, + state: "matchingIntentions" as const, + runComputeOperation: false, }; const screenPropFactory = new ScreenPropFactory( trialConfig, @@ -124,8 +124,8 @@ test("generate props for Loading screen with matching type", async () => { // Check contents of props expect(generated.props.trial).toBe(1); expect(generated.props.display).toBe("loading"); - expect(generated.props.loadingType).toBe("matchingIntentions"); - expect(generated.props.fetchData).toBe(false); + expect(generated.props.state).toBe("matchingIntentions"); + expect(generated.props.runComputeOperation).toBe(false); expect(generated.props).toHaveProperty("handler"); }); diff --git a/test/utils/functions.ts b/test/utils/functions.ts index 95847fb..54927a6 100644 --- a/test/utils/functions.ts +++ b/test/utils/functions.ts @@ -32,9 +32,9 @@ export const getTrialConfiguration = (display: Display): Trial => { avatar: 0, answer: "Option 1", isPractice: false, - fetchData: false, + runComputeOperation: false, mode: "facilitator", - loadingType: "matchingIntentions", + state: "matchingIntentions", }; }; @@ -81,6 +81,8 @@ export const getHandler = (display: Display): Handler => { server_beta_par: NaN, server_alpha_ppt: NaN, server_beta_ppt: NaN, + setupDuration: 0, + operationDuration: 0, signalTimestamps: [], cyberballTossCount: NaN, cyberballParticipantTossCount: NaN, diff --git a/test/view/screens/Loaded.test.tsx b/test/view/screens/Loaded.test.tsx index 58ffdb5..85a84f3 100644 --- a/test/view/screens/Loaded.test.tsx +++ b/test/view/screens/Loaded.test.tsx @@ -36,7 +36,7 @@ test("loads and displays Loaded screen", async () => { const props: Props.Screens.Loaded = { trial: 0, display: "loaded", - loadingType: "matchingIntentions", + state: "matchingIntentions", handler: () => { return; }, }; render(); diff --git a/test/view/screens/Loading.test.tsx b/test/view/screens/Loading.test.tsx index 8649c6e..c67fed6 100644 --- a/test/view/screens/Loading.test.tsx +++ b/test/view/screens/Loading.test.tsx @@ -40,8 +40,8 @@ test("loads and displays Loading screen with matching type", async () => { const props: Props.Screens.Loading = { trial: 0, display: "loading", - loadingType: "matchingIntentions", - fetchData: false, + state: "matchingIntentions", + runComputeOperation: false, handler: (participantParameters, partnerParameters) => { console.info(participantParameters, partnerParameters); }, @@ -57,7 +57,7 @@ test("loads and displays Loading screen with social type", async () => { const props: Props.Screens.Loading = { trial: 0, display: "loading", - loadingType: "social", + state: "social", }; render(); @@ -72,12 +72,12 @@ test("loads and displays Loading screen with default type", async () => { const props: Props.Screens.Loading = { trial: 0, display: "loading", - loadingType: "default", + state: "default", }; render(); await waitFor(() => { - expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByText("Experiment Loading...")).toBeInTheDocument(); }); }); @@ -85,11 +85,11 @@ test("loads and displays Loading screen with default type when no type specified const props: Props.Screens.Loading = { trial: 0, display: "loading", - loadingType: "default", + state: "default", }; render(); await waitFor(() => { - expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByText("Experiment Loading...")).toBeInTheDocument(); }); }); From 92cc793c7d607b032c309992e81f3e8fbc577b5a Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Thu, 25 Sep 2025 16:12:17 -0500 Subject: [PATCH 6/6] INT-76 Add `Compute` test file --- test/classes/Compute.test.ts | 611 +++++++++++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 test/classes/Compute.test.ts diff --git a/test/classes/Compute.test.ts b/test/classes/Compute.test.ts new file mode 100644 index 0000000..3c27cf5 --- /dev/null +++ b/test/classes/Compute.test.ts @@ -0,0 +1,611 @@ +/** + * @file 'Compute' class tests + * @author Henry Burgess + */ + +// Import the Compute class +import Compute from "src/classes/Compute"; + +// Import configuration +import { Configuration } from "src/configuration"; + +// Mock WebR +import { WebR } from "webr"; +jest.mock("webr"); + +// Mock consola with LogLevel +jest.mock("consola", () => ({ + LogLevel: { + Verbose: 0, + Error: 1, + }, + start: jest.fn(), + success: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +// Mock performance API +const mockPerformance = { + now: jest.fn(() => Date.now()), +}; +Object.defineProperty(window, 'performance', { + value: mockPerformance, + writable: true, +}); + +// Mock WebRDataJsNode +const mockWebRDataJsNode = { + values: [ + { + values: [1.5, 2.3, 0.8, 1.2] // participant parameters + }, + { + values: ["10.5 -15.2"] // partner parameters as string + }, + { + values: [ + { + values: [8, 6, 6, 8, 8, 6, 6, 8, 8, 6] // ppt1 values + }, + { + values: [6, 8, 8, 6, 6, 8, 8, 6, 6, 8] // par1 values + }, + { + values: [6, 8, 8, 6, 6, 8, 8, 6, 6, 8] // ppt2 values + }, + { + values: [8, 6, 6, 8, 8, 6, 6, 8, 8, 6] // par2 values + }, + { + values: [1, 2, 1, 2, 1, 2, 1, 2, 1, 2] // Ac values + } + ] + } + ] +}; + +// Mock WebR instance +const mockWebRInstance = { + init: jest.fn().mockResolvedValue(undefined), + evalR: jest.fn().mockResolvedValue({ + toJs: jest.fn().mockResolvedValue(mockWebRDataJsNode) + }), + installPackages: jest.fn().mockResolvedValue(undefined), +}; + +// Mock WebR constructor +(WebR as jest.MockedClass).mockImplementation(() => mockWebRInstance as unknown as WebR); + +// Import consola after mocking +import consola from "consola"; + +// Get the mocked consola +const mockConsola = consola as jest.Mocked; + +describe('Compute', () => { + let compute: Compute; + let originalConfiguration: typeof Configuration.manipulations; + + beforeEach(() => { + // Store original configuration + originalConfiguration = { ...Configuration.manipulations }; + + // Reset all mocks + jest.clearAllMocks(); + mockPerformance.now.mockReturnValue(1000); + + // Reset WebR instance mock + Object.assign(mockWebRInstance, { + init: jest.fn().mockResolvedValue(undefined), + evalR: jest.fn().mockResolvedValue({ + toJs: jest.fn().mockResolvedValue(mockWebRDataJsNode) + }), + installPackages: jest.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + // Restore original configuration + Configuration.manipulations = originalConfiguration; + }); + + describe('Constructor', () => { + test('should initialize with offline packages when useOfflinePackages is true', () => { + Configuration.manipulations.useOfflinePackages = true; + + compute = new Compute(); + + expect(WebR).toHaveBeenCalledWith({ + repoUrl: "http://localhost:8080/packages", + baseUrl: "http://localhost:8080/webr-0.4.2/" + }); + expect(compute.isReady()).toBe(false); + }); + + test('should initialize with empty config when useOfflinePackages is false', () => { + Configuration.manipulations.useOfflinePackages = false; + + compute = new Compute(); + + expect(WebR).toHaveBeenCalledWith({}); + expect(compute.isReady()).toBe(false); + }); + + test('should initialize ready state as false', () => { + compute = new Compute(); + expect(compute.isReady()).toBe(false); + }); + }); + + describe('Setup Method', () => { + beforeEach(() => { + compute = new Compute(); + }); + + test('should setup successfully with offline packages', async () => { + Configuration.manipulations.useOfflinePackages = true; + + await compute.setup(); + + expect(mockWebRInstance.init).toHaveBeenCalledTimes(1); + expect(mockWebRInstance.evalR).toHaveBeenCalledTimes(2); // INSTALL_PACKAGES + FUNCTIONS + expect(mockConsola.start).toHaveBeenCalledWith("Using offline packages..."); + expect(mockConsola.success).toHaveBeenCalledWith("Offline packages installed successfully"); + expect(compute.isReady()).toBe(true); + }); + + test('should setup successfully with online packages', async () => { + Configuration.manipulations.useOfflinePackages = false; + + await compute.setup(); + + expect(mockWebRInstance.init).toHaveBeenCalledTimes(1); + expect(mockWebRInstance.installPackages).toHaveBeenCalledWith([ + "matlab", + "jsonlite", + "doParallel", + "dplyr", + "logger", + ]); + expect(mockWebRInstance.evalR).toHaveBeenCalledTimes(1); // Only FUNCTIONS + expect(mockConsola.start).toHaveBeenCalledWith("Using online packages..."); + expect(mockConsola.success).toHaveBeenCalledWith("Online packages installed successfully"); + expect(compute.isReady()).toBe(true); + }); + + test('should handle offline package installation errors', async () => { + Configuration.manipulations.useOfflinePackages = true; + const error = new Error('Package installation failed'); + mockWebRInstance.evalR.mockRejectedValueOnce(error); + + await compute.setup(); + + expect(mockConsola.error).toHaveBeenCalledWith(error); + expect(compute.isReady()).toBe(true); // Should still be ready after error handling + }); + + test('should handle online package installation errors', async () => { + Configuration.manipulations.useOfflinePackages = false; + const error = new Error('Online package installation failed'); + mockWebRInstance.installPackages.mockRejectedValueOnce(error); + + await expect(compute.setup()).rejects.toThrow('Online package installation failed'); + expect(compute.isReady()).toBe(false); + }); + + test('should handle WebR initialization errors', async () => { + const error = new Error('WebR initialization failed'); + mockWebRInstance.init.mockRejectedValueOnce(error); + + await expect(compute.setup()).rejects.toThrow('WebR initialization failed'); + expect(compute.isReady()).toBe(false); + }); + }); + + describe('Submit Method', () => { + beforeEach(async () => { + compute = new Compute(); + await compute.setup(); + }); + + test('should submit with test responses successfully', async () => { + const result = await compute.submit([], true); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[') + ); + expect(mockConsola.success).toHaveBeenCalledWith( + expect.stringContaining('Compute complete after') + ); + + expect(result).toEqual({ + participantParameters: [1.5, 2.3, 0.8, 1.2], + partnerParameters: [10.5, -15.2], + partnerChoices: expect.arrayContaining([ + expect.objectContaining({ + ppt1: expect.any(Number), + par1: expect.any(Number), + ppt2: expect.any(Number), + par2: expect.any(Number), + Ac: expect.any(Number), + }) + ]) + }); + }); + + test('should submit with real data successfully', async () => { + const realData = [ + { ID: "REAL_001", Trial: 1, ppt1: 5, par1: 7, ppt2: 8, par2: 6, Ac: 1, Phase: 1 }, + { ID: "REAL_002", Trial: 2, ppt1: 6, par1: 6, ppt2: 7, par2: 7, Ac: 2, Phase: 1 } + ]; + + const result = await compute.submit(realData, false); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[{"ID":"REAL_001"') + ); + expect(result).toBeDefined(); + expect(result.participantParameters).toBeDefined(); + expect(result.partnerParameters).toBeDefined(); + expect(result.partnerChoices).toBeDefined(); + }); + + test('should handle WebR evaluation errors', async () => { + const error = new Error('R evaluation failed'); + mockWebRInstance.evalR.mockRejectedValueOnce(error); + + await expect(compute.submit([], true)).rejects.toThrow('R evaluation failed'); + }); + + test('should handle toJs conversion errors', async () => { + const error = new Error('toJs conversion failed'); + mockWebRInstance.evalR.mockResolvedValueOnce({ + toJs: jest.fn().mockRejectedValue(error) + }); + + await expect(compute.submit([], true)).rejects.toThrow('toJs conversion failed'); + }); + + test('should measure execution time correctly', async () => { + mockPerformance.now + .mockReturnValueOnce(1000) // Start time + .mockReturnValueOnce(2500); // End time + + await compute.submit([], true); + + expect(mockConsola.success).toHaveBeenCalledWith('Compute complete after 1500ms'); + }); + + test('should handle empty data array', async () => { + const result = await compute.submit([], false); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[]\'))') + ); + expect(result).toBeDefined(); + }); + + test('should handle large data arrays', async () => { + const largeData = Array.from({ length: 100 }, (_, i) => ({ + ID: `LARGE_${i}`, + Trial: i + 1, + ppt1: Math.floor(Math.random() * 10) + 1, + par1: Math.floor(Math.random() * 10) + 1, + ppt2: Math.floor(Math.random() * 10) + 1, + par2: Math.floor(Math.random() * 10) + 1, + Ac: Math.floor(Math.random() * 2) + 1, + Phase: 1 + })); + + const result = await compute.submit(largeData, false); + + expect(result).toBeDefined(); + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[{"ID":"LARGE_0"') + ); + }); + }); + + describe('ParseResponse Method (Private)', () => { + beforeEach(async () => { + compute = new Compute(); + await compute.setup(); + }); + + test('should parse response with valid data structure', async () => { + const result = await compute.submit([], true); + + expect(result.participantParameters).toEqual([1.5, 2.3, 0.8, 1.2]); + expect(result.partnerParameters).toEqual([10.5, -15.2]); + expect(result.partnerChoices).toHaveLength(10); + expect(result.partnerChoices[0]).toEqual({ + ppt1: 8, + par1: 6, + ppt2: 6, + par2: 8, + Ac: 1 + }); + }); + + test('should handle malformed partner parameters string', async () => { + const malformedData = { + values: [ + { values: [1.5, 2.3, 0.8, 1.2] }, + { values: ["invalid string"] }, // Invalid partner parameters + mockWebRDataJsNode.values[2] // Valid partner choices + ] + }; + + // Create a new mock for this specific test + const mockEvalR = jest.fn().mockResolvedValue({ + toJs: jest.fn().mockResolvedValue(malformedData) + }); + + // Temporarily replace the mock + const originalEvalR = mockWebRInstance.evalR; + mockWebRInstance.evalR = mockEvalR; + + const result = await compute.submit([], true); + + // Restore original mock + mockWebRInstance.evalR = originalEvalR; + + expect(result.partnerParameters).toEqual([NaN, NaN]); // Should handle parseFloat failure for both words + }); + + test('should handle empty partner choices', async () => { + const emptyChoicesData = { + values: [ + { values: [1.5, 2.3, 0.8, 1.2] }, + { values: ["10.5 -15.2"] }, + { + values: [ + { values: [] }, // Empty arrays + { values: [] }, + { values: [] }, + { values: [] }, + { values: [] } + ] + } + ] + }; + + mockWebRInstance.evalR.mockResolvedValueOnce({ + toJs: jest.fn().mockResolvedValue(emptyChoicesData) + }); + + const result = await compute.submit([], true); + + expect(result.partnerChoices).toEqual([]); + }); + }); + + describe('Performance Scenarios', () => { + beforeEach(async () => { + compute = new Compute(); + await compute.setup(); + }); + + test('should handle high hardware spec conditions', async () => { + // Simulate high-performance hardware with fast execution + mockPerformance.now + .mockReturnValueOnce(1000) + .mockReturnValueOnce(1050); // 50ms execution time + + const result = await compute.submit([], true); + + expect(mockConsola.success).toHaveBeenCalledWith('Compute complete after 50ms'); + expect(result).toBeDefined(); + }); + + test('should handle low hardware spec conditions', async () => { + // Simulate low-performance hardware with slow execution + mockPerformance.now + .mockReturnValueOnce(1000) + .mockReturnValueOnce(10000); // 9 second execution time + + const result = await compute.submit([], true); + + expect(mockConsola.success).toHaveBeenCalledWith('Compute complete after 9000ms'); + expect(result).toBeDefined(); + }); + + test('should handle memory constraints', async () => { + // Simulate memory pressure by creating large data + const memoryIntensiveData = Array.from({ length: 1000 }, (_, i) => ({ + ID: `MEMORY_${i}`, + Trial: i + 1, + ppt1: Math.floor(Math.random() * 10) + 1, + par1: Math.floor(Math.random() * 10) + 1, + ppt2: Math.floor(Math.random() * 10) + 1, + par2: Math.floor(Math.random() * 10) + 1, + Ac: Math.floor(Math.random() * 2) + 1, + Phase: 1, + // Add extra data to simulate memory pressure + extraData: 'x'.repeat(1000) + })); + + const result = await compute.submit(memoryIntensiveData, false); + + expect(result).toBeDefined(); + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[{"ID":"MEMORY_0"') + ); + }); + + test('should handle concurrent requests', async () => { + // Simulate multiple concurrent requests + const promises = Array.from({ length: 5 }, (_, i) => + compute.submit([{ ID: `CONCURRENT_${i}`, Trial: 1, ppt1: 5, par1: 7, ppt2: 8, par2: 6, Ac: 1, Phase: 1 }], false) + ); + + const results = await Promise.all(promises); + + expect(results).toHaveLength(5); + results.forEach(result => { + expect(result).toBeDefined(); + expect(result.participantParameters).toBeDefined(); + expect(result.partnerParameters).toBeDefined(); + expect(result.partnerChoices).toBeDefined(); + }); + }); + }); + + describe('Error Handling and Edge Cases', () => { + beforeEach(async () => { + compute = new Compute(); + await compute.setup(); + }); + + test('should handle undefined data', async () => { + const result = await compute.submit(undefined as unknown as unknown[], false); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'undefined\'))') + ); + expect(result).toBeDefined(); + }); + + test('should handle null data', async () => { + const result = await compute.submit(null as unknown as unknown[], false); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'null\'))') + ); + expect(result).toBeDefined(); + }); + + test('should handle non-array data', async () => { + const result = await compute.submit({ invalid: 'data' } as unknown as unknown[], false); + + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'{"invalid":"data"}\'))') + ); + expect(result).toBeDefined(); + }); + + test('should handle data with missing required fields', async () => { + const incompleteData = [ + { ID: "INCOMPLETE_001", Trial: 1 }, // Missing required fields + { Trial: 2, ppt1: 5, par1: 7, ppt2: 8, par2: 6, Ac: 1, Phase: 1 } // Missing ID + ]; + + const result = await compute.submit(incompleteData, false); + + expect(result).toBeDefined(); + expect(mockWebRInstance.evalR).toHaveBeenCalledWith( + expect.stringContaining('model_wrapper(fromJSON(\'[{"ID":"INCOMPLETE_001"') + ); + }); + + test('should handle WebR not initialized', async () => { + const uninitializedCompute = new Compute(); + + // Mock WebR to throw an error when not initialized + mockWebRInstance.evalR.mockRejectedValueOnce(new Error('WebR not initialized')); + + await expect(uninitializedCompute.submit([], true)).rejects.toThrow('WebR not initialized'); + }); + + test('should handle invalid JSON in R response', async () => { + const invalidJsonData = { + values: [ + { values: [1.5, 2.3, 0.8, 1.2] }, + { values: ["invalid json response"] }, + { values: "not an array" } // Invalid structure + ] + }; + + mockWebRInstance.evalR.mockResolvedValueOnce({ + toJs: jest.fn().mockResolvedValue(invalidJsonData) + }); + + await expect(compute.submit([], true)).rejects.toThrow(); + }); + }); + + describe('Configuration Variations', () => { + test('should work with different offline package configurations', async () => { + Configuration.manipulations.useOfflinePackages = true; + + const compute1 = new Compute(); + await compute1.setup(); + + Configuration.manipulations.useOfflinePackages = false; + + const compute2 = new Compute(); + await compute2.setup(); + + expect(compute1.isReady()).toBe(true); + expect(compute2.isReady()).toBe(true); + }); + + test('should handle configuration changes after instantiation', async () => { + compute = new Compute(); + + // Change configuration after instantiation + Configuration.manipulations.useOfflinePackages = !Configuration.manipulations.useOfflinePackages; + + await compute.setup(); + expect(compute.isReady()).toBe(true); + }); + }); + + describe('Integration Tests', () => { + test('should complete full workflow from setup to submit', async () => { + compute = new Compute(); + + // Verify initial state + expect(compute.isReady()).toBe(false); + + // Setup + await compute.setup(); + expect(compute.isReady()).toBe(true); + + // Submit with test data + const result = await compute.submit([], true); + + // Verify result structure + expect(result).toHaveProperty('participantParameters'); + expect(result).toHaveProperty('partnerParameters'); + expect(result).toHaveProperty('partnerChoices'); + expect(Array.isArray(result.participantParameters)).toBe(true); + expect(Array.isArray(result.partnerParameters)).toBe(true); + expect(Array.isArray(result.partnerChoices)).toBe(true); + }); + + test('should handle multiple setup calls', async () => { + compute = new Compute(); + + await compute.setup(); + expect(compute.isReady()).toBe(true); + + // Second setup call should not break anything + await compute.setup(); + expect(compute.isReady()).toBe(true); + + // Should still work after multiple setups + const result = await compute.submit([], true); + expect(result).toBeDefined(); + }); + + test('should handle rapid successive submissions', async () => { + compute = new Compute(); + await compute.setup(); + + const submissions = Array.from({ length: 10 }, (_, i) => + compute.submit([{ ID: `RAPID_${i}`, Trial: 1, ppt1: 5, par1: 7, ppt2: 8, par2: 6, Ac: 1, Phase: 1 }], false) + ); + + const results = await Promise.all(submissions); + + expect(results).toHaveLength(10); + results.forEach(result => { + expect(result).toBeDefined(); + }); + }); + }); +});