diff --git a/.changeset/add-survey-multi-choice-response-index.md b/.changeset/add-survey-multi-choice-response-index.md new file mode 100644 index 0000000000..838cb820be --- /dev/null +++ b/.changeset/add-survey-multi-choice-response-index.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-survey-multi-choice": minor +--- + +Add `response_index` to survey-multi-choice trial data to record selected option indices and disambiguate duplicate option labels. diff --git a/contributors.md b/contributors.md index 17acdf5fe0..f3712ca20c 100644 --- a/contributors.md +++ b/contributors.md @@ -28,6 +28,7 @@ The following people have contributed to the development of jsPsych by writing c * Andy Heusser - https://github.com/andrewheusser * Angus Hughes - https://github.com/awhug * jade - https://github.com/jadeddelta +* Stephen M. Jerge - https://github.com/stephenmjerge * Gustavo Juantorena - https://github.com/GEJ1 * Chris Jungerius - https://github.com/cjungerius * George Kachergis - https://github.com/kachergis @@ -72,4 +73,4 @@ The following people have contributed to the development of jsPsych by writing c * Shaobin Jiang - https://github.com/Shaobin-Jiang * Haotian Tu - https://github.com/thtTNT * Joshua Unrau - https://github.com/joshunrau -* Victor Zhang - https://github.com/vzhang03 \ No newline at end of file +* Victor Zhang - https://github.com/vzhang03 diff --git a/docs/plugins/survey-multi-choice.md b/docs/plugins/survey-multi-choice.md index 5e533c8d6e..ce2728391c 100644 --- a/docs/plugins/survey-multi-choice.md +++ b/docs/plugins/survey-multi-choice.md @@ -22,7 +22,8 @@ In addition to the [default data collected by all plugins](../overview/plugins.m Name | Type | Value -----|------|------ -response | object | An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as the name of the option label selected (string). If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. | +response | object | An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as the name of the option label selected (string). If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. Unanswered questions are recorded as empty strings. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. | +response_index | array | An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. | rt | numeric | The response time in milliseconds for the participant to make a response. The time is measured from when the questions first appear on the screen until the participant's response(s) are submitted. | question_order | array | An array with the order of questions. For example `[2,0,1]` would indicate that the first question was `trial.questions[2]` (the third item in the `questions` parameter), the second question was `trial.questions[0]`, and the final question was `trial.questions[1]`. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. | diff --git a/packages/plugin-survey-multi-choice/src/index.spec.ts b/packages/plugin-survey-multi-choice/src/index.spec.ts index cb952e14fe..7923ced3db 100644 --- a/packages/plugin-survey-multi-choice/src/index.spec.ts +++ b/packages/plugin-survey-multi-choice/src/index.spec.ts @@ -1,47 +1,54 @@ import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; - import { initJsPsych } from "jspsych"; + import surveyMultiChoice from "."; jest.useFakeTimers(); -const getInputElement = ( - choiceId: number, - value: string, +const getInputElement = (choiceId: number, value: string, displayElement: HTMLElement) => + displayElement.querySelector( + `#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]` + ) as HTMLInputElement; + +const getInputElementByIndex = ( + choiceId: number, + optionIndex: number, displayElement: HTMLElement ) => displayElement.querySelector( - `#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]` + `#jspsych-survey-multi-choice-response-${choiceId}-${optionIndex}` ) as HTMLInputElement; describe("survey-multi-choice plugin", () => { test("properly ends when has sibling form", async () => { - - const container = document.createElement('div') - const outerForm = document.createElement('form') - outerForm.id = 'outer_form' - container.appendChild(outerForm) - const innerDiv = document.createElement('div') - innerDiv.id = 'target_id'; + const container = document.createElement("div"); + const outerForm = document.createElement("form"); + outerForm.id = "outer_form"; + container.appendChild(outerForm); + const innerDiv = document.createElement("div"); + innerDiv.id = "target_id"; container.appendChild(innerDiv); - document.body.appendChild(container) - const jsPsychInst = initJsPsych({ display_element: innerDiv }) + document.body.appendChild(container); + const jsPsychInst = initJsPsych({ display_element: innerDiv }); const options = ["a", "b", "c"]; - const { displayElement, expectFinished } = await startTimeline([ - { - type: surveyMultiChoice, - questions: [ - { prompt: "Q0", options }, - { prompt: "Q1", options }, - ] - }, - ], jsPsychInst); + const { displayElement, expectFinished } = await startTimeline( + [ + { + type: surveyMultiChoice, + questions: [ + { prompt: "Q0", options }, + { prompt: "Q1", options }, + ], + }, + ], + jsPsychInst + ); getInputElement(0, "a", displayElement).checked = true; await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next")); await expectFinished(); - }) + }); test("data are logged with the right question when randomize order is true", async () => { var scale = ["a", "b", "c", "d", "e"]; @@ -76,6 +83,42 @@ describe("survey-multi-choice plugin", () => { expect(surveyData.Q3).toBe("d"); expect(surveyData.Q4).toBe("e"); }); + + test("records response_index for duplicate options", async () => { + const options = ["Little", "", "", "Much"]; + const { getData, expectFinished, displayElement } = await startTimeline([ + { + type: surveyMultiChoice, + questions: [{ prompt: "How much", options, required: false }], + }, + ]); + + getInputElementByIndex(0, 2, displayElement).checked = true; + + await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next")); + await expectFinished(); + + const surveyData = getData().values()[0]; + expect(surveyData.response.Q0).toBe(""); + expect(surveyData.response_index[0]).toBe(2); + }); + + test("records -1 in response_index for unanswered questions", async () => { + const options = ["Little", "", "", "Much"]; + const { getData, expectFinished, displayElement } = await startTimeline([ + { + type: surveyMultiChoice, + questions: [{ prompt: "How much", options, required: false }], + }, + ]); + + await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next")); + await expectFinished(); + + const surveyData = getData().values()[0]; + expect(surveyData.response.Q0).toBe(""); + expect(surveyData.response_index[0]).toBe(-1); + }); }); describe("survey-multi-choice plugin simulation", () => { @@ -97,11 +140,16 @@ describe("survey-multi-choice plugin simulation", () => { await expectFinished(); - const surveyData = getData().values()[0].response; - const all_valid = Object.entries(surveyData).every((x) => { + const surveyData = getData().values()[0]; + const all_valid = Object.entries(surveyData.response).every((x) => { return scale.includes(x[1] as string); }); expect(all_valid).toBe(true); + expect(surveyData.response_index).toHaveLength(scale.length); + const indices_valid = surveyData.response_index.every( + (index) => Number.isInteger(index) && index >= 0 && index < scale.length + ); + expect(indices_valid).toBe(true); }); test("visual mode works", async () => { @@ -129,10 +177,15 @@ describe("survey-multi-choice plugin simulation", () => { await expectFinished(); - const surveyData = getData().values()[0].response; - const all_valid = Object.entries(surveyData).every((x) => { + const surveyData = getData().values()[0]; + const all_valid = Object.entries(surveyData.response).every((x) => { return scale.includes(x[1] as string); }); expect(all_valid).toBe(true); + expect(surveyData.response_index).toHaveLength(scale.length); + const indices_valid = surveyData.response_index.every( + (index) => Number.isInteger(index) && index >= 0 && index < scale.length + ); + expect(indices_valid).toBe(true); }); }); diff --git a/packages/plugin-survey-multi-choice/src/index.ts b/packages/plugin-survey-multi-choice/src/index.ts index 49624a3342..95f5a63d4a 100644 --- a/packages/plugin-survey-multi-choice/src/index.ts +++ b/packages/plugin-survey-multi-choice/src/index.ts @@ -82,6 +82,11 @@ const info = { response: { type: ParameterType.OBJECT, }, + /** An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. */ + response_index: { + type: ParameterType.INT, + array: true, + }, /** The response time in milliseconds for the participant to make a response. The time is measured from when the questions first appear on the screen until the participant's response(s) are submitted. */ rt: { type: ParameterType.INT, @@ -111,10 +116,9 @@ const plugin_id_name = "jspsych-survey-multi-choice"; class SurveyMultiChoicePlugin implements JsPsychPlugin { static info = info; - constructor(private jsPsych: JsPsych) { } + constructor(private jsPsych: JsPsych) {} trial(display_element: HTMLElement, trial: TrialType) { - const trial_form_id = `${plugin_id_name}_form`; var html = ""; @@ -164,7 +168,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { question_classes.push(`${plugin_id_name}-horizontal`); } - html += `
`; + html += `
`; // add question text html += `

${question.prompt}`; @@ -186,7 +192,7 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { html += `

`; @@ -196,7 +202,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { } // add submit button - html += ``; + html += ``; html += ""; // render @@ -212,12 +220,16 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { // create object to hold responses var question_data = {}; + var response_index = []; for (var i = 0; i < trial.questions.length; i++) { var match = display_element.querySelector(`#${plugin_id_name}-${i}`); var id = "Q" + i; - var val: String; - if (match.querySelector("input[type=radio]:checked") !== null) { - val = match.querySelector("input[type=radio]:checked").value; + var val: String = ""; + var selected_index = -1; + var checked = match.querySelector("input[type=radio]:checked"); + if (checked !== null) { + val = checked.value; + selected_index = Number(checked.dataset.optionIndex); } else { val = ""; } @@ -228,11 +240,13 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { } obje[name] = val; Object.assign(question_data, obje); + response_index.push(selected_index); } // save data var trial_data = { rt: response_time, response: question_data, + response_index: response_index, question_order: question_order, }; @@ -260,16 +274,21 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { private create_simulation_data(trial: TrialType, simulation_options) { const question_data = {}; + const response_index = []; let rt = 1000; - for (const q of trial.questions) { - const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; - question_data[name] = this.jsPsych.randomization.sampleWithoutReplacement(q.options, 1)[0]; + for (let i = 0; i < trial.questions.length; i++) { + const q = trial.questions[i]; + const name = q.name ? q.name : `Q${i}`; + const option_index = this.jsPsych.randomization.randomInt(0, q.options.length - 1); + question_data[name] = q.options[option_index]; + response_index.push(option_index); rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true); } const default_data = { response: question_data, + response_index: response_index, rt: rt, question_order: trial.randomize_question_order ? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()]) @@ -298,13 +317,17 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin { load_callback(); const answers = Object.entries(data.response); + const response_index = Array.isArray(data.response_index) ? data.response_index : []; for (let i = 0; i < answers.length; i++) { + let option_index = response_index[i]; + if (typeof option_index !== "number" || option_index < 0) { + option_index = trial.questions[i].options.indexOf(answers[i][1]); + } + if (option_index < 0) { + continue; + } this.jsPsych.pluginAPI.clickTarget( - display_element.querySelector( - `#${plugin_id_name}-response-${i}-${trial.questions[i].options.indexOf( - answers[i][1] - )}` - ), + display_element.querySelector(`#${plugin_id_name}-response-${i}-${option_index}`), ((data.rt - 1000) / answers.length) * (i + 1) ); }