diff --git a/.changeset/spotty-mails-cheat.md b/.changeset/spotty-mails-cheat.md new file mode 100644 index 0000000000..399ac45b5b --- /dev/null +++ b/.changeset/spotty-mails-cheat.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-cloze": minor +--- + +adds support for multiple correct answers, case sensitivity, and autofocus. diff --git a/docs/plugins/cloze.md b/docs/plugins/cloze.md index bd95207706..4830e18134 100644 --- a/docs/plugins/cloze.md +++ b/docs/plugins/cloze.md @@ -10,11 +10,13 @@ In addition to the [parameters available in all plugins](../overview/plugins.md# | Parameter | Type | Default Value | Description | | ------------- | -------- | ------------------ | ---------------------------------------- | -| text | string | *undefined* | The cloze text to be displayed. Blanks are indicated by %% signs and automatically replaced by input fields. If there is a correct answer you want the system to check against, it must be typed between the two percentage signs (i.e. % correct solution %). | +| text | string | *undefined* | The cloze text to be displayed. Blanks are indicated by %% signs and automatically replaced by input fields. If there is a correct answer you want the system to check against, it must be typed between the two percentage signs (i.e. % correct solution %). To input multiple correct answers, add a / between each answer (i.e. %correct/alsocorrect%). | | button_text | string | OK | Text of the button participants have to press for finishing the cloze test. | | check_answers | boolean | false | Boolean value indicating if the answers given by participants should be compared against a correct solution given in the text (between % signs) after the button was clicked. If ```true```, answers are checked and in case of differences, the ```mistake_fn``` is called. In this case, the trial does not automatically finish. If ```false```, no checks are performed and the trial automatically ends when clicking the button. | | allow_blanks | boolean | true | Boolean value indicating if the answers given by participants should be checked for completion after the button was clicked. If ```true```, answers are not checked for completion and blank answers are allowed. The trial will then automatically finish upon the clicking the button. If ```false```, answers are checked for completion, and in case there are some fields with missing answers, the ```mistake_fn``` is called. In this case, the trial does not automatically finish. | +| case_sensitivity | boolean | true | Boolean value indicating if the answers given by participants should also be checked to have the right case along with correctness. If set to ```false```, case is disregarded and participants may type in whatever case they please. | | mistake_fn | function | ```function(){}``` | Function called if ```check_answers``` is set to ```true``` and there is a difference between the participant's answers and the correct solution provided in the text, or if ```allow_blanks``` is set to ```false``` and there is at least one field with a blank answer. | +| autofocus | boolean | true | Boolean value indicating if the first input field should be focused when the trial starts. Enabled by default, but may be disabled especially if participants are using screen readers. | ## Data Generated diff --git a/examples/jspsych-cloze.html b/examples/jspsych-cloze.html index d32859b9f6..ef8d67fa13 100644 --- a/examples/jspsych-cloze.html +++ b/examples/jspsych-cloze.html @@ -22,6 +22,14 @@ text: 'The %% is the largest terrestrial mammal. It lives in both %% and %%.' }); + // an example that allows the user to input a solution that doesn't require case sensitivity, and allows multiple responses + timeline.push({ + type: jsPsychCloze, + text: 'The %CASE/door/EyE% is closed.', + check_answers: true, + case_sensitivity: false, + }) + // another example with checking if all the blanks are filled in timeline.push({ type: jsPsychCloze, diff --git a/packages/plugin-cloze/src/index.spec.ts b/packages/plugin-cloze/src/index.spec.ts index a522016a91..045373af89 100644 --- a/packages/plugin-cloze/src/index.spec.ts +++ b/packages/plugin-cloze/src/index.spec.ts @@ -84,6 +84,21 @@ describe("cloze", () => { await expectFinished(); }); + test("ends trial on button click when answers are checked and correct without case sensitivity", async () => { + const { expectFinished } = await startTimeline([ + { + type: cloze, + text: "This is a %cloze% text.", + check_answers: true, + case_sensitivity: false, + }, + ]); + + getInputElementById("input0").value = "CLOZE"; + clickTarget(document.querySelector("#finish_cloze_button")); + await expectFinished(); + }); + test("ends trial on button click when all answers are checked for completion and are complete", async () => { const { expectFinished } = await startTimeline([ { @@ -185,6 +200,27 @@ describe("cloze", () => { await expectFinished(); }); + test("calls mistake function on button click when answers are checked and do not belong to a multiple answer blank", async () => { + const mistakeFn = jest.fn(); + + const { expectFinished } = await startTimeline([ + { + type: cloze, + text: "This is a %cloze/jspsych% text.", + check_answers: true, + mistake_fn: mistakeFn, + }, + ]); + + getInputElementById("input0").value = "not fitting in answer"; + await clickFinishButton(); + expect(mistakeFn).toHaveBeenCalled(); + + getInputElementById("input0").value = "cloze"; + await clickFinishButton(); + await expectFinished(); + }); + test("response data is stored as an array", async () => { const { getData, expectFinished } = await startTimeline([ { diff --git a/packages/plugin-cloze/src/index.ts b/packages/plugin-cloze/src/index.ts index 28c2023564..85de2d24a5 100644 --- a/packages/plugin-cloze/src/index.ts +++ b/packages/plugin-cloze/src/index.ts @@ -6,7 +6,12 @@ const info = { name: "cloze", version: version, parameters: { - /** The cloze text to be displayed. Blanks are indicated by %% signs and automatically replaced by input fields. If there is a correct answer you want the system to check against, it must be typed between the two percentage signs (i.e. % correct solution %). */ + /** + * The cloze text to be displayed. Blanks are indicated by %% signs and automatically replaced by + * input fields. If there is a correct answer you want the system to check against, it must be typed + * between the two percentage signs (i.e. % correct solution %). If you would like to input multiple + * solutions, type a slash between each responses (i.e. %1/2/3%). + */ text: { type: ParameterType.HTML_STRING, default: undefined, @@ -16,24 +21,55 @@ const info = { type: ParameterType.STRING, default: "OK", }, - /** Boolean value indicating if the answers given by participants should be compared against a correct solution given in the text (between % signs) after the button was clicked. If ```true```, answers are checked and in case of differences, the ```mistake_fn``` is called. In this case, the trial does not automatically finish. If ```false```, no checks are performed and the trial automatically ends when clicking the button. */ + /** + * Boolean value indicating if the answers given by participants should be compared + * against a correct solution given in `text` after the submit button was clicked. + * If ```true```, answers are checked and in case of differences, the ```mistake_fn``` + * is called. In this case, the trial does not automatically finish. If ```false```, + * no checks are performed and the trial ends when clicking the submit button. + */ check_answers: { type: ParameterType.BOOL, default: false, }, - /** Boolean value indicating if the answers given by participants should be checked for completion after the button was clicked. If ```true```, answers are not checked for completion and blank answers are allowed. The trial will then automatically finish upon the clicking the button. If ```false```, answers are checked for completion, and in case there are some fields with missing answers, the ```mistake_fn``` is called. In this case, the trial does not automatically finish. */ + /** + * Boolean value indicating if the answers given by participants should be checked for + * completion after the button was clicked. If ```true```, answers are not checked for + * completion and blank answers are allowed. The trial will then automatically finish + * upon the clicking the button. If ```false```, answers are checked for completion, + * and in case there are some fields with missing answers, the ```mistake_fn``` is called. + * In this case, the trial does not automatically finish. + */ allow_blanks: { type: ParameterType.BOOL, default: true, }, - /** Function called if ```check_answers``` is set to ```true``` and there is a difference between the participant's answers and the correct solution provided in the text, or if ```allow_blanks``` is set to ```false``` and there is at least one field with a blank answer. */ + /** Boolean value indicating if the solutions checker must be case sensitive. */ + case_sensitivity: { + type: ParameterType.BOOL, + pretty_name: "Case sensitivity", + default: true, + }, + /** + * Function called if either `check_answers` is `true` or `allow_blanks` is `false` + * and there is a discrepancy between the set answers and the answers provided, or + * if all input fields aren't filled out, respectively. + */ mistake_fn: { type: ParameterType.FUNCTION, default: () => {}, }, + /** + * Boolean value indicating if the first input field should be focused when the trial starts. + * Enabled by default, but may be disabled especially if participants are using screen readers. + */ + autofocus: { + type: ParameterType.BOOL, + default: true, + } }, data: { - /** Answers the partcipant gave. */ + /** Answers the participant gave. */ response: { type: ParameterType.STRING, array: true, @@ -58,7 +94,7 @@ class ClozePlugin implements JsPsychPlugin { var html = '
'; // odd elements are text, even elements are the blanks var elements = trial.text.split("%"); - const solutions = this.getSolutions(trial.text); + const solutions = this.getSolutions(trial.text, trial.case_sensitivity); let solution_counter = 0; for (var i = 0; i < elements.length; i++) { @@ -75,16 +111,18 @@ class ClozePlugin implements JsPsychPlugin { display_element.innerHTML = html; const check = () => { - var answers: String[] = []; + var answers: string[] = []; var answers_correct = true; var answers_filled = true; for (var i = 0; i < solutions.length; i++) { var field = document.getElementById("input" + i) as HTMLInputElement; - answers.push(field.value.trim()); + answers.push( + trial.case_sensitivity ? field.value.trim() : field.value.toLowerCase().trim() + ); if (trial.check_answers) { - if (answers[i] !== solutions[i]) { + if (!solutions[i].includes(answers[i])) { field.style.color = "red"; answers_correct = false; } else { @@ -114,15 +152,19 @@ class ClozePlugin implements JsPsychPlugin { trial.button_text + ""; display_element.querySelector("#finish_cloze_button").addEventListener("click", check); + + if (trial.autofocus) + (display_element.querySelector("#input0") as HTMLElement).focus(); } - private getSolutions(text: string) { - const solutions = []; + private getSolutions(text: string, case_sensitive: boolean): string[][] { + const solutions: string[][] = []; const elements = text.split("%"); - for (let i = 0; i < elements.length; i++) { - if (i % 2 == 1) { - solutions.push(elements[i].trim()); - } + + for (let i = 1; i < elements.length; i += 2) { + solutions.push( + case_sensitive ? elements[i].trim().split("/") : elements[i].toLowerCase().trim().split("/") + ); } return solutions; @@ -144,13 +186,14 @@ class ClozePlugin implements JsPsychPlugin { } private create_simulation_data(trial: TrialType, simulation_options) { - const solutions = this.getSolutions(trial.text); - const responses = []; - for (const word of solutions) { - if (word == "") { - responses.push(this.jsPsych.randomization.randomWords({ exactly: 1 })); + const solutions = this.getSolutions(trial.text, trial.case_sensitivity); + const responses: string[] = []; + for (const wordList of solutions) { + if (wordList.includes("")) { + var word = this.jsPsych.randomization.randomWords({ exactly: 1 }); + responses.push(word[0]); } else { - responses.push(word); + responses.push(wordList[Math.floor(Math.random() * wordList.length)]); } }