Skip to content

Commit bc259f8

Browse files
authored
Merge pull request #3645 from stephenmjerge/fix-survey-multi-choice-response-index
Add response_index to survey-multi-choice data
2 parents 7adb82b + c18d82f commit bc259f8

4 files changed

Lines changed: 127 additions & 45 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jspsych/plugin-survey-multi-choice": minor
3+
---
4+
5+
Add `response_index` to survey-multi-choice trial data to record selected option indices and disambiguate duplicate option labels.

docs/plugins/survey-multi-choice.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
2222

2323
Name | Type | Value
2424
-----|------|------
25-
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. |
25+
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. |
26+
response_index | array | An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. |
2627
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. |
2728
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. |
2829

packages/plugin-survey-multi-choice/src/index.spec.ts

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,54 @@
11
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";
2-
32
import { initJsPsych } from "jspsych";
3+
44
import surveyMultiChoice from ".";
55

66
jest.useFakeTimers();
77

8-
const getInputElement = (
9-
choiceId: number,
10-
value: string,
8+
const getInputElement = (choiceId: number, value: string, displayElement: HTMLElement) =>
9+
displayElement.querySelector(
10+
`#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]`
11+
) as HTMLInputElement;
12+
13+
const getInputElementByIndex = (
14+
choiceId: number,
15+
optionIndex: number,
1116
displayElement: HTMLElement
1217
) =>
1318
displayElement.querySelector(
14-
`#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]`
19+
`#jspsych-survey-multi-choice-response-${choiceId}-${optionIndex}`
1520
) as HTMLInputElement;
1621

1722
describe("survey-multi-choice plugin", () => {
1823
test("properly ends when has sibling form", async () => {
19-
20-
const container = document.createElement('div')
21-
const outerForm = document.createElement('form')
22-
outerForm.id = 'outer_form'
23-
container.appendChild(outerForm)
24-
const innerDiv = document.createElement('div')
25-
innerDiv.id = 'target_id';
24+
const container = document.createElement("div");
25+
const outerForm = document.createElement("form");
26+
outerForm.id = "outer_form";
27+
container.appendChild(outerForm);
28+
const innerDiv = document.createElement("div");
29+
innerDiv.id = "target_id";
2630
container.appendChild(innerDiv);
27-
document.body.appendChild(container)
28-
const jsPsychInst = initJsPsych({ display_element: innerDiv })
31+
document.body.appendChild(container);
32+
const jsPsychInst = initJsPsych({ display_element: innerDiv });
2933
const options = ["a", "b", "c"];
3034

31-
const { displayElement, expectFinished } = await startTimeline([
32-
{
33-
type: surveyMultiChoice,
34-
questions: [
35-
{ prompt: "Q0", options },
36-
{ prompt: "Q1", options },
37-
]
38-
},
39-
], jsPsychInst);
35+
const { displayElement, expectFinished } = await startTimeline(
36+
[
37+
{
38+
type: surveyMultiChoice,
39+
questions: [
40+
{ prompt: "Q0", options },
41+
{ prompt: "Q1", options },
42+
],
43+
},
44+
],
45+
jsPsychInst
46+
);
4047

4148
getInputElement(0, "a", displayElement).checked = true;
4249
await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
4350
await expectFinished();
44-
})
51+
});
4552

4653
test("data are logged with the right question when randomize order is true", async () => {
4754
var scale = ["a", "b", "c", "d", "e"];
@@ -76,6 +83,42 @@ describe("survey-multi-choice plugin", () => {
7683
expect(surveyData.Q3).toBe("d");
7784
expect(surveyData.Q4).toBe("e");
7885
});
86+
87+
test("records response_index for duplicate options", async () => {
88+
const options = ["Little", "", "", "Much"];
89+
const { getData, expectFinished, displayElement } = await startTimeline([
90+
{
91+
type: surveyMultiChoice,
92+
questions: [{ prompt: "How much", options, required: false }],
93+
},
94+
]);
95+
96+
getInputElementByIndex(0, 2, displayElement).checked = true;
97+
98+
await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
99+
await expectFinished();
100+
101+
const surveyData = getData().values()[0];
102+
expect(surveyData.response.Q0).toBe("");
103+
expect(surveyData.response_index[0]).toBe(2);
104+
});
105+
106+
test("records -1 in response_index for unanswered questions", async () => {
107+
const options = ["Little", "", "", "Much"];
108+
const { getData, expectFinished, displayElement } = await startTimeline([
109+
{
110+
type: surveyMultiChoice,
111+
questions: [{ prompt: "How much", options, required: false }],
112+
},
113+
]);
114+
115+
await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
116+
await expectFinished();
117+
118+
const surveyData = getData().values()[0];
119+
expect(surveyData.response.Q0).toBe("");
120+
expect(surveyData.response_index[0]).toBe(-1);
121+
});
79122
});
80123

81124
describe("survey-multi-choice plugin simulation", () => {
@@ -97,11 +140,16 @@ describe("survey-multi-choice plugin simulation", () => {
97140

98141
await expectFinished();
99142

100-
const surveyData = getData().values()[0].response;
101-
const all_valid = Object.entries(surveyData).every((x) => {
143+
const surveyData = getData().values()[0];
144+
const all_valid = Object.entries(surveyData.response).every((x) => {
102145
return scale.includes(x[1] as string);
103146
});
104147
expect(all_valid).toBe(true);
148+
expect(surveyData.response_index).toHaveLength(scale.length);
149+
const indices_valid = surveyData.response_index.every(
150+
(index) => Number.isInteger(index) && index >= 0 && index < scale.length
151+
);
152+
expect(indices_valid).toBe(true);
105153
});
106154

107155
test("visual mode works", async () => {
@@ -129,10 +177,15 @@ describe("survey-multi-choice plugin simulation", () => {
129177

130178
await expectFinished();
131179

132-
const surveyData = getData().values()[0].response;
133-
const all_valid = Object.entries(surveyData).every((x) => {
180+
const surveyData = getData().values()[0];
181+
const all_valid = Object.entries(surveyData.response).every((x) => {
134182
return scale.includes(x[1] as string);
135183
});
136184
expect(all_valid).toBe(true);
185+
expect(surveyData.response_index).toHaveLength(scale.length);
186+
const indices_valid = surveyData.response_index.every(
187+
(index) => Number.isInteger(index) && index >= 0 && index < scale.length
188+
);
189+
expect(indices_valid).toBe(true);
137190
});
138191
});

packages/plugin-survey-multi-choice/src/index.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ const info = <const>{
8282
response: {
8383
type: ParameterType.OBJECT,
8484
},
85+
/** An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. */
86+
response_index: {
87+
type: ParameterType.INT,
88+
array: true,
89+
},
8590
/** 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. */
8691
rt: {
8792
type: ParameterType.INT,
@@ -111,10 +116,9 @@ const plugin_id_name = "jspsych-survey-multi-choice";
111116
class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
112117
static info = info;
113118

114-
constructor(private jsPsych: JsPsych) { }
119+
constructor(private jsPsych: JsPsych) {}
115120

116121
trial(display_element: HTMLElement, trial: TrialType<Info>) {
117-
118122
const trial_form_id = `${plugin_id_name}_form`;
119123

120124
var html = "";
@@ -164,7 +168,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
164168
question_classes.push(`${plugin_id_name}-horizontal`);
165169
}
166170

167-
html += `<div id="${plugin_id_name}-${question_id}" class="${question_classes.join(" ")}" data-name="${question.name}">`;
171+
html += `<div id="${plugin_id_name}-${question_id}" class="${question_classes.join(
172+
" "
173+
)}" data-name="${question.name}">`;
168174

169175
// add question text
170176
html += `<p class="${plugin_id_name}-text survey-multi-choice">${question.prompt}`;
@@ -186,7 +192,7 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
186192
html += `
187193
<div id="${option_id_name}" class="${plugin_id_name}-option">
188194
<label class="${plugin_id_name}-text" for="${input_id}">
189-
<input type="radio" name="${input_name}" id="${input_id}" value="${question.options[j]}" ${required_attr} />
195+
<input type="radio" name="${input_name}" id="${input_id}" value="${question.options[j]}" data-option-index="${j}" ${required_attr} />
190196
${question.options[j]}
191197
</label>
192198
</div>`;
@@ -196,7 +202,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
196202
}
197203

198204
// add submit button
199-
html += `<input type="submit" id="${plugin_id_name}-next" class="${plugin_id_name} jspsych-btn"${trial.button_label ? ' value="' + trial.button_label + '"' : ""} />`;
205+
html += `<input type="submit" id="${plugin_id_name}-next" class="${plugin_id_name} jspsych-btn"${
206+
trial.button_label ? ' value="' + trial.button_label + '"' : ""
207+
} />`;
200208
html += "</form>";
201209

202210
// render
@@ -212,12 +220,16 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
212220

213221
// create object to hold responses
214222
var question_data = {};
223+
var response_index = [];
215224
for (var i = 0; i < trial.questions.length; i++) {
216225
var match = display_element.querySelector(`#${plugin_id_name}-${i}`);
217226
var id = "Q" + i;
218-
var val: String;
219-
if (match.querySelector("input[type=radio]:checked") !== null) {
220-
val = match.querySelector<HTMLInputElement>("input[type=radio]:checked").value;
227+
var val: String = "";
228+
var selected_index = -1;
229+
var checked = match.querySelector<HTMLInputElement>("input[type=radio]:checked");
230+
if (checked !== null) {
231+
val = checked.value;
232+
selected_index = Number(checked.dataset.optionIndex);
221233
} else {
222234
val = "";
223235
}
@@ -228,11 +240,13 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
228240
}
229241
obje[name] = val;
230242
Object.assign(question_data, obje);
243+
response_index.push(selected_index);
231244
}
232245
// save data
233246
var trial_data = {
234247
rt: response_time,
235248
response: question_data,
249+
response_index: response_index,
236250
question_order: question_order,
237251
};
238252

@@ -260,16 +274,21 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
260274

261275
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
262276
const question_data = {};
277+
const response_index = [];
263278
let rt = 1000;
264279

265-
for (const q of trial.questions) {
266-
const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`;
267-
question_data[name] = this.jsPsych.randomization.sampleWithoutReplacement(q.options, 1)[0];
280+
for (let i = 0; i < trial.questions.length; i++) {
281+
const q = trial.questions[i];
282+
const name = q.name ? q.name : `Q${i}`;
283+
const option_index = this.jsPsych.randomization.randomInt(0, q.options.length - 1);
284+
question_data[name] = q.options[option_index];
285+
response_index.push(option_index);
268286
rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true);
269287
}
270288

271289
const default_data = {
272290
response: question_data,
291+
response_index: response_index,
273292
rt: rt,
274293
question_order: trial.randomize_question_order
275294
? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()])
@@ -298,13 +317,17 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
298317
load_callback();
299318

300319
const answers = Object.entries(data.response);
320+
const response_index = Array.isArray(data.response_index) ? data.response_index : [];
301321
for (let i = 0; i < answers.length; i++) {
322+
let option_index = response_index[i];
323+
if (typeof option_index !== "number" || option_index < 0) {
324+
option_index = trial.questions[i].options.indexOf(answers[i][1]);
325+
}
326+
if (option_index < 0) {
327+
continue;
328+
}
302329
this.jsPsych.pluginAPI.clickTarget(
303-
display_element.querySelector(
304-
`#${plugin_id_name}-response-${i}-${trial.questions[i].options.indexOf(
305-
answers[i][1]
306-
)}`
307-
),
330+
display_element.querySelector(`#${plugin_id_name}-response-${i}-${option_index}`),
308331
((data.rt - 1000) / answers.length) * (i + 1)
309332
);
310333
}

0 commit comments

Comments
 (0)