diff --git a/htdocs/js/MatchList/matchlist.scss b/htdocs/js/MatchList/matchlist.scss new file mode 100644 index 0000000000..9a834a1850 --- /dev/null +++ b/htdocs/js/MatchList/matchlist.scss @@ -0,0 +1,35 @@ +.match-list-container { + display: flex; + flex-wrap: wrap; + gap: 2rem; + align-items: center; + justify-content: space-evenly; + + .match-list-questions, + .match-list-choices { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .match-list-question, + .match-list-choice { + display: flex; + gap: 0.5rem; + } + + &.match-list-align-top, + .match-list-align-top { + align-items: flex-start; + } + + &.match-list-align-middle, + .match-list-align-middle { + align-items: center; + } + + &.match-list-align-bottom, + .match-list-align-bottom { + align-items: flex-end; + } +} diff --git a/htdocs/js/Problem/problem.scss b/htdocs/js/Problem/problem.scss index 21bb2e7753..48a9a7da4e 100644 --- a/htdocs/js/Problem/problem.scss +++ b/htdocs/js/Problem/problem.scss @@ -272,6 +272,7 @@ .checkboxes-container, .applet-container, .graphtool-outer-container, + .match-list-questions, .ww-feedback-container { position: relative; width: fit-content; diff --git a/macros/parsers/parserMatchList.pl b/macros/parsers/parserMatchList.pl new file mode 100644 index 0000000000..cd74f86754 --- /dev/null +++ b/macros/parsers/parserMatchList.pl @@ -0,0 +1,606 @@ + +=head1 NAME + +parserMatchList.pl - Matching list questions compatible with MathObjects, +MultiAnswer objects, and PGML. + +=head1 DESCRIPTION + +This file implements a matching list object that is compatible with MathObjects, +MultiAnswer objects, and with PGML. It builds a list of questions, each paired +with a drop-down menu (via L) of labelled choices, and takes +care of collecting the distinct answer choices, adding any extra (incorrect) +choices, randomizing the order in which the questions and choices are displayed, +and laying out the questions and answer choices in a two column table. + +To create a MatchList object, use + + $ml = MatchList( + [ + [ question1, answer1 ], + [ question2, answer2 ], + ... + ], + options + ); + +where each C<[ question, answer ]> pair gives the text of a question and the +text of its correct answer. Answers that are repeated across questions are only +listed once among the answer choices, and questions can share the same answer. + +A question may instead be given as C<< { label => [ question, answer ] } >>, to +explicitly set the label used for that question (see C below), +overriding whatever label it would otherwise get from its position among the +displayed questions. For example: + + $ml = MatchList( + [ + [ question1, answer1 ], + { label2 => [ question2, answer2 ] }, + [ question3, answer3 ], + ] + ); + +always labels the second question C, wherever it ends up being displayed +among the possible randomly ordered questions. + +The C are taken from the following list: + +=over + +=item C [ choice, ... ] >>> + +Extra answer choices to add to the list of answer choices that are not the +correct answer to any of the questions. Default: C<[]> + +=item C [ choice, ... ] >>> + +Answer choices (either among the questions' answers, the C choices, or +new choices) that should always be forced to appear at the end of the list of +answer choices, in the order given, and excluded from the randomization of the +other choices. This is typically used for a choice like "None of the above". +Default: C<[]> + +=item C 'random' or 'fixed' >>> + +Whether the questions are displayed in a random order or in the order given. +Default: C + +=item C 'random' or 'fixed' >>> + +Whether the answer choices (other than those in C) are displayed in a +random order or in the order given (correct answers in the order of their +questions, followed by the C choices). Default: C + +=item C "123", "abc", "ABC", "roman", "Roman", or [label1, ...] >>> + +Determines the labels used for the questions (e.g. the "1" in "1. question +text"), assigned after the questions have been put in the order they will be +displayed. If the value is C<"123"> the questions are labeled with numbers (the +default), C<"abc"> or C<"ABC"> label them with lower or upper case letters, and +C<"roman"> or C<"Roman"> label them with lower or upper case Roman numerals. The +C<[label1, ...]> form gives the labels directly, one for each question, in the +order the questions are displayed. + +Any question given as C<< { label => [ question, answer ] } >> uses that label +instead, regardless of this option. + +=item C 0 or 1 >>> + +Whether the question labels are shown at all. Default: 1 + +=item C string >>> + +A format string used when displaying a question's label. It is an C +string that contains C<%s> where the label should go. Default: +C<"${BBOLD}%s.${EBOLD}">, which produces the label in bold followed by a period. + +=item C "123", "abc", "ABC", "roman", "Roman", or [label1, ...] >>> + +Determines the labels used for the answer choices (e.g. the "A" in "A. answer +text"), assigned after the choices have been put in the order they will be +displayed. Accepts the same values as C above, and defaults to +C<"ABC">. + +=item C string >>> + +A format string used when displaying an answer choice's label. Accepts the same +kind of C string as C above, and has the same +default. + +=item C [ value1, ... ] >>> + +The values submitted as the student's answer for each choice (as opposed to the +label or the choice text itself). The same values will be used for every +question's drop down menu (since every question offers the same choices). This +is passed on to the C created for each question (see the C +option of C). The values are given in the same order that the +choices are given to the C constructor (the question answers, +followed by any C answers, followed by any new choices in C). If a +value isn't given for a particular choice (or if not enough values are given for +this option), then the choice's label is used as its value. Default: C<[]> + +=item C { option => value, ... } >>> + +Options to pass along to the C object (see C) that is +created for each question, e.g. C<< { placeholder => '?' } >>. Default: C<{}> + +=item C 'start', 'middle', or 'end' >>> + +Where the drop down menu is placed within a question's row, relative to the +question label and the question text. C places it before the question +label (e.g. "[menu] 1. question text"), C places it between the question +label and the question text (e.g. "1. [menu] question text"), and C places +it after the question text (e.g. "1. question text [menu]"). Default: C + +=item C 'top', 'middle', or 'bottom' >>> + +The vertical alignment of the questions and choices. Note that this does not +affect alignment when the window width is narrow and the choices are below the +questions. Default: C + +=item C 'top', 'middle', or 'bottom' >>> + +The vertical alignment of the contents of the C
that contains a question. +Default: C + +=item C 'top', 'middle', or 'bottom' >>> + +The vertical alignment of the contents of the C
that contains a choice. +Default: C + +=back + +To insert the match list (a two column table of questions with their drop down +menus, and answer choices) into the problem text, use + + BEGIN_PGML + Match each question with its answer. + + [_]*{$ml} + END_PGML + +Note that a C is an answer array, and so the starred PGML answer rule +version must be used. Furthermore, that means that the questions are graded +together, and so one feedback button will be shown for the C when +answers are processed. + +When C is 0 and C is 1, the number of questions +answered correctly is reported in the answer's message, e.g. "4 of 6 questions +correct.", without revealing which ones. Note that C and +C are set to the value of C<$showPartialCorrectAnswers> by +default, and that is 1 by default. So the default behavior is to show +per-question hints which state specifically which questions are wrong (e.g. +"Your second answer is incorrect, Your fourth answer is incorrect"). Pass +C<< showHints => 0 >> via C to get the count instead: + + [_]*{$ml}{ cmp_options => { showHints => 0 } } + +Once a C object is created, the following methods are also available: + +=over + +=item C<< $ml->questions >> + +A reference to the array of question strings, in the order they are displayed. + +=item C<< $ml->choices >> + +A reference to the array of answer choice strings, in the order they are +displayed. + +=item C<< $ml->questionLabels >> + +A reference to the array of labels for the questions, in display order. + +=item C<< $ml->choiceLabels >> + +A reference to the array of labels corresponding for the answer choices, in +display order. + +=item C<< $ml->dropDown($i) >> + +The C object (see C) for the C-th question (with 0 +being the first question). + +=item C<< $ml->answer($i) >> + +The textual answer for the C<$i>-th question (with 0 being the first question). + +=item C<< $ml->originalIndex($i) >> + +The index, in the list of C<[ question, answer ]> pairs originally passed to +C, of the question displayed at position C<$i> (with 0 being the +first question displayed). + +=back + +A MatchList object is a C whose value is the list of the correct +answer letters for its questions (in question order), and C<< $ml->length >> +gives the number of questions. So, for example, to show the correct answers in +a solution, simply insert the MatchList itself: + + BEGIN_PGML_SOLUTION + The correct answers are [$ml]. + END_PGML_SOLUTION + +=cut + +loadMacros('parserPopUp.pl'); + +sub _parserMatchList_init { + main::PG_restricted_eval('sub MatchList { parser::MatchList->new(@_) }'); + return; +} + +package parser::MatchList; +our @ISA = ('Value::List'); + +sub new { + my ($invocant, @options) = @_; + my $class = ref($invocant) || $invocant; + shift @options if Value::isContext($options[0]); # remove context, if given (a MatchList builds its context below) + my $qa = shift @options; + Value::Error(q{A MatchList's first argument should be a list of [ question, answer ] pairs}) + unless ref($qa) eq 'ARRAY'; + + my @rows; + for my $entry (@$qa) { + if (ref($entry) eq 'HASH') { + my ($label) = keys %$entry; + Value::Error(q{A MatchList's questions and answers should be given as [ question, answer ] } + . q{pairs, or { label => [ question, answer ] }}) + unless defined($label) && ref($entry->{$label}) eq 'ARRAY' && @{ $entry->{$label} } == 2; + push(@rows, [ $label, @{ $entry->{$label} } ]); + } else { + Value::Error(q{A MatchList's questions and answers should be given as [ question, answer ] } + . q{pairs, or { label => [ question, answer ] }}) + unless ref($entry) eq 'ARRAY' && @$entry == 2; + push(@rows, [ undef, @$entry ]); + } + } + + my %options; + main::set_default_options( + \%options, + extra => [], + last => [], + questionOrder => 'random', + choiceOrder => 'random', + questionLabels => '123', + displayQuestionLabels => 1, + questionLabelFormat => "${main::BBOLD}%s.${main::EBOLD}", + choiceLabels => 'ABC', + choiceLabelFormat => "${main::BBOLD}%s.${main::EBOLD}", + values => [], + dropDownOptions => {}, + dropdownPosition => 'start', + verticalAlign => 'middle', + questionVerticalAlign => 'middle', + choiceVerticalAlign => 'middle', + @options + ); + + # Make a context in which arbitrary strings can be entered. + my $context = Parser::Context->getCopy('Numeric'); + $context->{name} = 'MatchList'; + $context->parens->clear; + $context->variables->clear; + $context->constants->clear; + $context->operators->clear; + $context->functions->clear; + $context->strings->clear; + $context->{pattern}{number} = '^$'; + $context->variables->{patterns} = {}; + $context->strings->{patterns}{'.*'} = [ -20, 'str' ]; + $context->{parser}{String} = 'parser::PopUp::String'; + $context->update; + + my $self = bless { %options, qa => \@rows }, $class; + + $self->orderChoices; + $self->orderQuestions; + $self->buildQuestionLabels; + $self->buildDropDowns; + + my $matchList = $class->SUPER::new($context, map { $_->value } @{ $self->{dropDowns} }); + $matchList->{$_} = $self->{$_} for keys %$self; + $matchList->{format_options} = [ open => '', close => '', sep => ', ' ]; + + return $matchList; +} + +# Convert a label type into a reference to an array of $n labels. +sub buildLabelArray { + my ($self, $type, $n) = @_; + my @roman = + qw(i ii iii iv v vi vii viii ix x xi xii xiii xiv xv xvi xvii xviii xix xx xxi xxii xxiii xxiv xxv xxvi); + return [ map { lc($_) } @main::ALPHABET[ 0 .. $n - 1 ] ] if $type eq 'abc'; + return [ @main::ALPHABET[ 0 .. $n - 1 ] ] if uc($type) eq 'ABC'; + return [ @roman[ 0 .. $n - 1 ] ] if $type eq 'roman'; + return [ map { uc($_) } @roman[ 0 .. $n - 1 ] ] if uc($type) eq 'ROMAN'; + return [@$type] if ref($type) eq 'ARRAY'; + return [ 1 .. $n ]; +} + +sub type { return 'List'; } + +sub string { + my ($self, @rest) = @_; + return join(', ', map { $_->string(@rest) } @{ $self->data }); +} + +sub TeX { + my ($self, @rest) = @_; + return join(', ', map { $_->TeX(@rest) } @{ $self->data }); +} + +# Put the questions (and their answers) into the order they will be displayed, and record +# the indices in the original list passed to MatchList. +sub orderQuestions { + my $self = shift; + my @qa = map { [ $_, $self->{qa}[$_] ] } 0 .. $#{ $self->{qa} }; + @qa = map { splice(@qa, $main::PG_random_generator->random(0, $#qa), 1) } 0 .. $#qa + if $self->{questionOrder} eq 'random'; + $self->{originalIndices} = [ map { $_->[0] } @qa ]; + $self->{qa} = [ map { $_->[1] } @qa ]; + return; +} + +# Compute the question labels, overridden by any explicit per-question label +# from a { label => [ question, answer ] } entry, in question display order. +sub buildQuestionLabels { + my $self = shift; + my $n = @{ $self->{qa} }; + my $labels = $self->buildLabelArray($self->{questionLabels}, $n); + for my $i (0 .. $n - 1) { + $labels->[$i] = $self->{qa}[$i][0] if defined $self->{qa}[$i][0]; + } + $self->{questionLabels} = $labels; + return; +} + +# Collect the distinct answer choices, move the "last" choices to the end, and randomize the rest. Also record the +# original index of each choice. +sub orderChoices { + my $self = shift; + + my (@choices, %seen); + for my $row (@{ $self->{qa} }) { + push(@choices, $row->[2]) unless $seen{ $row->[2] }++; + } + for my $choice (@{ $self->{extra} }) { + push(@choices, $choice) unless $seen{$choice}++; + } + my %originalIndex = map { $choices[$_] => $_ } 0 .. $#choices; + + my @last; + for my $choice (@{ $self->{last} }) { + next if grep { $_ eq $choice } @last; + push(@last, $choice); + $originalIndex{$choice} //= scalar(keys %originalIndex); + } + my %isLast = map { $_ => 1 } @last; + @choices = grep { !$isLast{$_} } @choices; + + @choices = map { splice(@choices, $main::PG_random_generator->random(0, $#choices), 1) } 0 .. $#choices + if $self->{choiceOrder} eq 'random' && @choices; + + push(@choices, @last); + + $self->{choices} = \@choices; + $self->{choiceOriginalIndices} = [ map { $originalIndex{$_} } @choices ]; + $self->{choiceLabels} = $self->buildLabelArray($self->{choiceLabels}, scalar(@choices)); + + return; +} + +# Build a DropDown menu (of the answer choices) for each question. +sub buildDropDowns { + my $self = shift; + + my %index; + for (0 .. $#{ $self->{choices} }) { $index{ $self->{choices}[$_] } //= $_ } + + # Remap the `values` option into the displayed choice order, for use by every DropDown. + my @values = map { $self->{values}[$_] } @{ $self->{choiceOriginalIndices} }; + + my (@questions, @dropDowns); + for my $row (@{ $self->{qa} }) { + my (undef, $question, $answer) = @$row; + Value::Error(qq{The answer "$answer" is not among the MatchList's answer choices}) + unless defined $index{$answer}; + push(@questions, $question); + push( + @dropDowns, + main::DropDown( + $self->{choiceLabels}, $index{$answer}, + values => \@values, + %{ $self->{dropDownOptions} } + ) + ); + } + + $self->{questions} = \@questions; + $self->{dropDowns} = \@dropDowns; + + return; +} + +sub questions { my $self = shift; return $self->{questions} } +sub choices { my $self = shift; return $self->{choices} } +sub questionLabels { my $self = shift; return $self->{questionLabels} } +sub choiceLabels { my $self = shift; return $self->{choiceLabels} } +sub answer { my ($self, $i) = @_; return $self->{qa}[$i][2]; } + +sub dropDown { + my ($self, $i) = @_; + return $self->{dropDowns}[$i]; +} + +# The 0-based index, in the list of [ question, answer ] pairs originally passed to MatchList, +# of the question displayed at the 0-based position $i. +sub originalIndex { + my ($self, $i) = @_; + return $self->{originalIndices}[$i]; +} + +sub cmp_defaults { + my $self = shift; + return ($self->SUPER::cmp_defaults(@_), ordered => 1, entry_type => 'answer'); +} + +sub cmp_collect { + my ($self, $ans) = @_; + $ans->{preview_latex_string} = $ans->{preview_text_string} = ''; + my $OK = $self->ans_collect($ans); + $ans->{student_ans} = $self->format_matrix($ans->{student_array}, @{ $self->{format_options} }, tth_delims => 1); + return 0 unless $OK; + + $ans->{student_value} = $ans->{student_formula} = eval { + $self->Package('List') + ->new($self->context, + map { Value::isFormula($_) ? Parser::Evaluate($_) // $_ : $_ } @{ $ans->{student_array}[0] }); + }; + + if (!defined($ans->{student_value}) || $self->context->{error}{flag}) { + Parser::reportEvalError($@); + $self->cmp_error($ans); + return 0; + } + $ans->{preview_text_string} = $ans->{student_ans}; + $ans->{preview_latex_string} = $ans->{student_value}->TeX; + return 1; +} + +# Report how many questions were answered correctly when this is not an answer preview, showHints is off, partial credit +# is on, and not all answers are correct. Note that when showHints is on messages stating exactly which answers are +# incorrect are reported. +sub cmp_equal { + my ($self, $ans) = @_; + $self->SUPER::cmp_equal($ans); + return + if $ans->{isPreview} + || Value::List::getOption($ans, 'showHints') + || !Value::List::getOption($ans, 'partialCredit'); + my $numCorrect = int($ans->{score} * $self->length + 0.5); + return if $numCorrect >= $self->length; + my $message = main::maketext('[_1] of [_2] answers correct.', $numCorrect, $self->length); + $ans->{ans_message} = $ans->{ans_message} ? "$ans->{ans_message}\n$message" : $message; + return; +} + +our $answerPrefix = 'MaTcHlIsT'; + +sub ans_array { my ($self, @options) = @_; return $self->MATCHLIST(0, '', @options); } +sub named_ans_array { my ($self, @options) = @_; return $self->MATCHLIST(0, @options); } +sub named_ans_array_extension { my ($self, @options) = @_; return $self->MATCHLIST(1, @options); } + +sub MATCHLIST { + my ($self, $extend, $name, $size, %options) = @_; + + main::ADD_CSS_FILE('js/MatchList/matchlist.css', 0); + + main::RECORD_IMPLICIT_ANS_NAME($name = main::NEW_ANS_NAME()) unless $name; + my $ename = "${answerPrefix}_${name}"; + $self->{ans_name} = $ename; + $self->{ans_rows} = 1; + $self->{ans_cols} = $self->length; + + my $answer_group_name = delete($options{answer_group_name}) // $name; + + my $aria_label_prefix = delete($options{aria_label}) // main::generate_aria_label($name); + + my $lastCellName = $self->length > 1 ? Value::ANS_NAME($ename, 0, $self->length - 1) : $name; + + my @questionRows; + for (0 .. $self->length - 1) { + my $aria_label = $aria_label_prefix . main::maketext('question [_1] ', $_ + 1); + my $ansRule = $_ == 0 + ? ( + $extend + ? $self->dropDown(0)->named_ans_rule_extension( + $name, $size, + answer_group_name => $answer_group_name, + aria_label => $aria_label, + %options + ) + : $self->dropDown(0)->named_ans_rule($name, $size, aria_label => $aria_label, %options) + ) + : $self->dropDown($_)->named_ans_rule_extension( + Value::ANS_NAME($ename, 0, $_), $size, + answer_group_name => $answer_group_name, + aria_label => $aria_label, + %options + ); + + # Remove the data-feedback attributes added by DropDown to take back control of feedback placement. + $ansRule =~ s/\s*data-feedback-insert-(?:element|method)="[^"]*"//g; + + my $question = $self->{questions}[$_]; + my $label = + $self->{displayQuestionLabels} ? sprintf($self->{questionLabelFormat}, $self->{questionLabels}[$_]) : ''; + push( + @questionRows, + join( + ' ', + grep {length} ( + $self->{dropdownPosition} eq 'end' ? ($label, $question, $ansRule) + : $self->{dropdownPosition} eq 'middle' ? ($label, $ansRule, $question) + : ($ansRule, $label, $question) + ) + ) + ); + } + + my @choiceRows; + for (0 .. $#{ $self->{choices} }) { + push(@choiceRows, sprintf($self->{choiceLabelFormat}, $self->{choiceLabels}[$_]) . ' ' . $self->{choices}[$_]); + } + + return main::MODES( + TeX => '\parbox{0.55\linewidth}{' + . join('\vskip\baselineskip ', @questionRows) + . '}\hfill\parbox{0.25\linewidth}{' + . join('\vskip\baselineskip ', @choiceRows) . '}', + HTML => main::tag( + 'div', + class => "match-list-container match-list-align-$self->{verticalAlign}", + main::tag( + 'div', + class => 'match-list-questions', + data_feedback_insert_element => $lastCellName, + data_feedback_insert_method => 'append_content', + data_feedback_btn_add_class => 'ms-3', + join( + '', + map { + main::tag( + 'div', + class => "match-list-question match-list-align-$self->{questionVerticalAlign}", + $_ + ) + } @questionRows + ) + ) + . main::tag( + 'div', + class => 'match-list-choices', + join( + '', + map { + main::tag( + 'div', + class => "match-list-choice match-list-align-$self->{choiceVerticalAlign}", + $_ + ) + } @choiceRows + ) + ) + ), + PTX => main::tag('ol', join('', map { main::tag('li', $_) } @questionRows)) + . main::tag('ol', join('', map { main::tag('li', $_) } @choiceRows)), + ); +} + +1; diff --git a/macros/parsers/parserMultipleChoice.pl b/macros/parsers/parserMultipleChoice.pl index 68b50bbf0f..be084a0bc4 100644 --- a/macros/parsers/parserMultipleChoice.pl +++ b/macros/parsers/parserMultipleChoice.pl @@ -27,6 +27,9 @@ =head1 DESCRIPTION sub _parserMultipleChoice_init { } -loadMacros("parserPopUp.pl", "parserCheckboxList.pl", "parserRadioButtons.pl", "parserRadioMultiAnswer.pl"); +loadMacros( + 'parserPopUp.pl', 'parserCheckboxList.pl', 'parserRadioButtons.pl', 'parserRadioMultiAnswer.pl', + 'parserMatchList.pl' +); 1; diff --git a/t/macros/parserMatchList.t b/t/macros/parserMatchList.t new file mode 100644 index 0000000000..95cc86b352 --- /dev/null +++ b/t/macros/parserMatchList.t @@ -0,0 +1,424 @@ +#!/usr/bin/env perl + +=head1 parserMatchList + +Test the MatchList object from parserMatchList.pl. + +=cut + +use Test2::V0 '!E', { E => 'EXISTS' }; + +die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; +do "$ENV{PG_ROOT}/t/build_PG_envir.pl"; + +loadMacros('parserMatchList.pl'); + +$main::PG_random_generator = PGrandom->new(); + +subtest 'Basic construction and accessors' => sub { + $main::PG_random_generator->srand(1234); + my $ml = MatchList( + [ [ 'Question a?', 'Answer a' ], [ 'Question b?', 'Answer b' ], [ 'Question c?', 'Answer c' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + + is $ml->length, 3, 'one entry per question'; + is $ml->questions, [ 'Question a?', 'Question b?', 'Question c?' ], 'questions in the order given'; + is $ml->choices, [ 'Answer a', 'Answer b', 'Answer c' ], 'choices in the order given'; + is $ml->questionLabels, [ 1, 2, 3 ], 'default question labels are numbers'; + is $ml->choiceLabels, [ 'A', 'B', 'C' ], 'default choice labels are letters'; + + for (0 .. 2) { + is $ml->originalIndex($_), $_, "originalIndex($_) is the identity when questionOrder is fixed"; + isa_ok $ml->dropDown($_), ['parser::PopUp'], "dropDown($_) is a PopUp/DropDown object"; + } + + is $ml->dropDown(0)->value, 'A', 'question a is matched with choice A'; + is $ml->dropDown(1)->value, 'B', 'question b is matched with choice B'; + is $ml->dropDown(2)->value, 'C', 'question c is matched with choice C'; + + ok(Value::isValue($ml), 'a MatchList is a MathObject Value'); + is $ml->type, 'List', 'a MatchList reports its type as List'; +}; + +subtest 'Randomization preserves the question/answer correspondence' => sub { + my @qa = ([ 'Q0', 'A0' ], [ 'Q1', 'A1' ], [ 'Q2', 'A2' ], [ 'Q3', 'A3' ]); + + for my $seed (1 .. 10) { + $main::PG_random_generator->srand($seed); + my $ml = MatchList([ map { [@$_] } @qa ]); + + for my $i (0 .. $ml->length - 1) { + my $orig = $ml->originalIndex($i); + my $letter = $ml->dropDown($i)->value; + my ($choiceIndex) = grep { $ml->choiceLabels->[$_] eq $letter } 0 .. $#{ $ml->choiceLabels }; + + is $ml->questions->[$i], $qa[$orig][0], "seed $seed: question $i is the question at originalIndex($i)"; + is $ml->choices->[$choiceIndex], $qa[$orig][1], + "seed $seed: question ${i}'s marked choice is its original answer"; + } + } +}; + +subtest 'Duplicate answers share one choice' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'Same' ], [ 'Q1', 'Same' ], [ 'Q2', 'Different' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + is $ml->choices, [ 'Same', 'Different' ], 'duplicate answers collapse to a single choice'; + is $ml->dropDown(0)->value, $ml->dropDown(1)->value, 'both questions with the same answer share a choice'; +}; + +subtest 'extra and last options' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed', + extra => [ 'Extra1', 'Extra2' ], + last => ['None of the above'], + ); + is $ml->choices, [ 'A0', 'A1', 'Extra1', 'Extra2', 'None of the above' ], + 'extra choices follow the answers, and last choices are forced to the end'; + + for my $seed (1 .. 10) { + $main::PG_random_generator->srand($seed); + my $ml = MatchList([ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], last => ['None of the above']); + is $ml->choices->[-1], 'None of the above', "seed $seed: the 'last' choice is always displayed last"; + } +}; + +subtest 'Constructor validation' => sub { + like dies { MatchList('not an array reference') }, + qr/first argument should be a list of \[ question, answer \] pairs/, + 'the first argument must be an array reference'; + + like dies { MatchList(['not a pair']) }, qr/questions and answers should be given as/, + 'each entry must be a [ question, answer ] pair or a { label => [...] } hash'; + + like dies { MatchList([ ['only one element'] ]) }, qr/questions and answers should be given as/, + 'each pair must have exactly two elements'; + + like dies { MatchList([ { label => 'not an array reference' } ]) }, + qr/questions and answers should be given as/, + 'a { label => ... } entry must have a [ question, answer ] pair as its value'; +}; + +subtest 'Explicit per-question label' => sub { + $main::PG_random_generator->srand(7); + my $ml = MatchList([ [ 'Q0', 'A0' ], { X => [ 'Q1', 'A1' ] }, [ 'Q2', 'A2' ] ], questionOrder => 'fixed'); + is $ml->questionLabels->[0], 1, 'the first question keeps the default label'; + is $ml->questionLabels->[1], 'X', 'the explicitly labeled question uses its given label'; + is $ml->questionLabels->[2], 3, 'the third question keeps the default label'; +}; + +subtest 'questionLabels and choiceLabels schemes' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ map { [ "Q$_", "A$_" ] } 0 .. 3 ], + questionOrder => 'fixed', + choiceOrder => 'fixed', + questionLabels => 'roman', + choiceLabels => 'abc', + ); + is $ml->questionLabels, [qw(i ii iii iv)], 'roman numeral question labels'; + is $ml->choiceLabels, [qw(a b c d)], 'lower case choice labels'; + + my $ml2 = MatchList( + [ map { [ "Q$_", "A$_" ] } 0 .. 2 ], + questionOrder => 'fixed', + questionLabels => [ '(a)', '(b)', '(c)' ], + ); + is $ml2->questionLabels, [ '(a)', '(b)', '(c)' ], 'a custom array of question labels is used directly'; +}; + +subtest 'values option' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed', + values => [ 'val0', 'val1' ], + ); + is $ml->dropDown(0)->value, 'val0', 'a custom value is used for the first choice'; + is $ml->dropDown(1)->value, 'val1', 'a custom value is used for the second choice'; + is $ml->choiceLabels, [ 'A', 'B' ], 'the displayed labels are unaffected by custom values'; + + my $mlPartial = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed', + values => ['val0'], + ); + is $mlPartial->dropDown(0)->value, 'val0', 'the given value is used'; + is $mlPartial->dropDown(1)->value, 'B', 'a missing value falls back to the choice label'; + + for my $seed (1 .. 10) { + $main::PG_random_generator->srand($seed); + my $ml = MatchList([ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], values => [ 'val-for-A0', 'val-for-A1' ],); + for my $i (0 .. $ml->length - 1) { + my $expected = $ml->originalIndex($i) == 0 ? 'val-for-A0' : 'val-for-A1'; + is $ml->dropDown($i)->value, $expected, "seed $seed: a value stays attached to its choice"; + } + } +}; + +sub extension_name { my ($ml, $i) = @_; return Value::ANS_NAME($ml->{ans_name}, 0, $i) } + +subtest 'a MatchList is graded as a single answer array, all or nothing by default' => sub { + local $main::showPartialCorrectAnswers = 0; + + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ], [ 'Q2', 'A2' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + $ml->ans_array; + my $ans = $ml->cmp; + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(1)->value, + extension_name($ml, 2) => $ml->dropDown(2)->value, + }; + is $ans->evaluate($ml->dropDown(0)->value)->{score}, 1, 'every question correct gives full credit'; + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(0)->value, # wrong + extension_name($ml, 2) => $ml->dropDown(2)->value, + }; + is $ans->evaluate($ml->dropDown(0)->value)->{score}, 0, 'one wrong question gives no credit by default'; +}; + +subtest 'partialCredit passed as a cmp option' => sub { + local $main::showPartialCorrectAnswers = 0; + + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ], [ 'Q2', 'A2' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + $ml->ans_array; + my $ans = $ml->cmp(partialCredit => 1); + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(0)->value, # wrong + extension_name($ml, 2) => $ml->dropDown(2)->value, + }; + my $result = $ans->evaluate($ml->dropDown(0)->value); + is $result->{score}, 2 / 3, + 'one wrong question gives partial credit when requested, even though showPartialCorrectAnswers is off'; + is $result->{ans_message}, '2 of 3 answers correct.', + 'the message reports the count correct, without revealing which individual question was wrong'; + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(0)->value, # wrong + extension_name($ml, 2) => $ml->dropDown(0)->value, # wrong + }; + is $ans->evaluate($ml->dropDown(0)->value)->{score}, 1 / 3, 'only one right question gives partial credit'; +}; + +subtest 'the number of answers correct is reported only when showHints is off' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ], [ 'Q2', 'A2' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + $ml->ans_array; + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(0)->value, # wrong + extension_name($ml, 2) => $ml->dropDown(2)->value, + }; + + # With showHints on, the per-question hints already say specifically which question is wrong, + # so the count would be redundant, and is not added. + my $withHints = $ml->cmp(partialCredit => 1)->evaluate($ml->dropDown(0)->value); + like $withHints->{ans_message}, qr/Your second answer is incorrect/, + 'a per-question hint is shown when enabled'; + unlike $withHints->{ans_message}, qr/answers correct/, 'the count is not added when hints are already shown'; + + my $withoutHints = $ml->cmp(showHints => 0, partialCredit => 1)->evaluate($ml->dropDown(0)->value); + is $withoutHints->{ans_message}, '2 of 3 answers correct.', + 'with hints disabled, the count is reported instead, without revealing which question was wrong'; +}; + +subtest 'the number correct is never reported for a preview' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ], [ 'Q2', 'A2' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + $ml->ans_array; + my $ans = $ml->cmp(showHints => 0); + + local $main::inputs_ref = { + extension_name($ml, 1) => $ml->dropDown(0)->value, # wrong + extension_name($ml, 2) => $ml->dropDown(2)->value, + previewAnswers => 1, + }; + my $result = $ans->evaluate($ml->dropDown(0)->value); + ok $result->{isPreview}, 'this evaluation is recognized as a preview'; + is $result->{ans_message}, '', 'a preview reveals nothing about how many questions are correct'; +}; + +subtest 'a MatchList works as a MultiAnswer sub-answer' => sub { + loadMacros('parserMultiAnswer.pl'); + + # A previous subtest may have left the ambient context set to a MatchList's restrictive context. + # Re-establish the base Numeric context to isolate this test. + main::Context('Numeric'); + + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + my $multi = MultiAnswer(main::Real(3), $ml); + + $multi->ans_rule; + $multi->ans_array; + my @cmp = $multi->cmp; + is scalar(@cmp), 2, 'one evaluator for the plain answer, one for the whole MatchList'; + + local $main::inputs_ref = { extension_name($ml, 1) => $ml->dropDown(1)->value }; + is $cmp[0]->evaluate('3')->{score}, 1, 'the plain answer is graded normally'; + is $cmp[1]->evaluate($ml->dropDown(0)->value)->{score}, 1, 'the MatchList sub-answer is graded correctly'; +}; + +subtest 'string and TeX show the correct answers' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ], [ 'Q1', 'A1' ] ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + is $ml->string, 'A, B', 'string is the comma separated list of correct answer letters'; + + local $main::displayMode = 'TeX'; + is $ml->TeX, '\text{A}, \text{B}', 'TeX is the comma separated list of correct answer letters'; +}; + +subtest 'ans_rule and named_ans_rule fall back to a plain (unusable) answer blank' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList([ [ 'Q0', 'A0' ] ], questionOrder => 'fixed'); + unlike $ml->ans_rule, qr/1\.<\/strong>/i, 'default question label format'; + like $html, qr/A\.<\/strong>/i, 'default choice label format'; +}; + +subtest 'feedback is designated on the questions container' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ map { [ "Question $_?", "Answer $_" ] } 0 .. 2 ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + my $html = $ml->ans_array; + + my @selectNames = $html =~ /]*\bname="([^"]*)"/g; + my $lastSelectName = $selectNames[-1]; + + is(scalar(() = $html =~ /data-feedback-insert-element/g), + 1, 'only one element in the whole widget is marked as a feedback target'); + like $html, qr/
]*\bclass="match-list-questions") + (?=[^>]*\bdata-feedback-insert-element="\Q$lastSelectName\E") + (?=[^>]*\bdata-feedback-insert-method="append(?:_|&\#95;)content") + [^>]*>/x, 'the feedback target is the questions container, naming the last drop down, using append_content'; +}; + +subtest 'each drop down gets a distinct aria label' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ map { [ "Question $_?", "Answer $_" ] } 0 .. 2 ], + questionOrder => 'fixed', + choiceOrder => 'fixed' + ); + my $html = $ml->ans_array; + my @labels = $html =~ /aria-label="([^"]*)"/g; + is scalar(@labels), 3, 'one aria label per question'; + my %seen; + is scalar(grep { !$seen{$_}++ } @labels), 3, 'every question has a distinct aria label'; +}; + +subtest 'displayQuestionLabels => 0 hides the question label only' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Question zero?', 'Answer zero' ] ], + questionOrder => 'fixed', + displayQuestionLabels => 0 + ); + my $html = $ml->ans_array; + unlike $html, qr/1\.<\/strong>/i, 'no question label is shown'; + like $html, qr/Question zero\?/, 'the question text is still shown'; +}; + +subtest 'questionLabelFormat and choiceLabelFormat' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList( + [ [ 'Q0', 'A0' ] ], + questionOrder => 'fixed', + questionLabelFormat => '(%s)', + choiceLabelFormat => '[%s]', + ); + my $html = $ml->ans_array; + like $html, qr/\(1\)/, 'custom question label format is used'; + like $html, qr/\[A\]/, 'custom choice label format is used'; +}; + +subtest 'dropdownPosition' => sub { + for my $case ( + [ 'start', qr/.*1\.<\/strong>.*Question 0/si ], + [ 'middle', qr/1\.<\/strong>.*.*Question 0/si ], + [ 'end', qr/1\.<\/strong>.*Question 0.*/si ], + ) + { + my ($position, $pattern) = @$case; + $main::PG_random_generator->srand(1); + my $ml = MatchList([ [ 'Question 0?', 'A0' ] ], questionOrder => 'fixed', dropdownPosition => $position); + like $ml->ans_array, $pattern, "dropdownPosition => '$position'"; + } +}; + +subtest 'TeX and PTX display modes' => sub { + $main::PG_random_generator->srand(1); + my $ml = MatchList([ [ 'Q0', 'A0' ] ], questionOrder => 'fixed'); + + { + local $main::displayMode = 'TeX'; + like $ml->ans_array, qr/\\parbox/, 'TeX mode renders a two column parbox layout'; + } + { + local $main::displayMode = 'PTX'; + like $ml->ans_array, qr/
    /, 'PTX mode renders an ordered list'; + } +}; + +done_testing; diff --git a/tutorial/sample-problems/Misc/MatchList.pg b/tutorial/sample-problems/Misc/MatchList.pg new file mode 100644 index 0000000000..6da0deb9c2 --- /dev/null +++ b/tutorial/sample-problems/Misc/MatchList.pg @@ -0,0 +1,68 @@ +## DESCRIPTION +## Matching question template +## ENDDESCRIPTION + +## DBsubject(WeBWorK) +## DBchapter(WeBWorK tutorial) +## DBsection(WeBWorK tutorial) +## Date(09/16/2026) +## Institution(Missouri Western State University) +## Author(Glenn Rice) +## KEYWORDS('matching', 'template') + +#:% name = Matching Problem (MatchList) +#:% type = Sample +#:% categories = [multiple choice, misc] + +#:% section = preamble +#: The PODLINK('parserMatchList.pl') macro is used to build the matching list, +#: including its drop down menu answers. +DOCUMENT(); + +loadMacros('PGstandard.pl', 'PGML.pl', 'parserMatchList.pl', 'PGcourse.pl'); + +#:% section = setup +#: Create a list of 6 questions and answers, 2 extra answers, and a +#: 'None of the above' answer that will be forced to be last. See the +#: PODLINK('parserMatchList.pl') macro for more details. + +$ml = MatchList( + [ + [ 'Question a?', 'Answer a' ], + [ 'Question b?', 'Answer b' ], + [ 'Question c?', 'Answer c' ], + [ 'Question d?', 'Answer d' ], + [ 'Question e?', 'Answer e' ], + [ 'Question f?', 'Answer f' ], + ], + extra => [ 'Extra answer 1', 'Extra answer 2' ], + last => ['None of the above'], +); + +#:% section = statement +#: A `MatchList` is inserted into PGML using the answer array syntax `[_]*{$ml}`. +#: This inserts the questions on the left (each with its drop down menu answer +#: blank) and the answer choices on the right. On narrow screens the answers +#: will be below the questions. When a hard copy of the problem is generated two +#: side by side columns are used instead. +#: +#: Adding `showHints => 0` to `cmp_options` disables the messages that state +#: which answers are incorrect. So partial credit is still awarded for the +#: answers that are correct, but the student is not told specifically which +#: answers those are. Note that you could instead set +#: `$showPartialCorrectAnswers = 0` to disable both partial credit and hints +#: that state which answers are incorrect. +BEGIN_PGML +Match each question with its answer. + +[_]*{$ml}{ cmp_options => { showHints => 0 } } +END_PGML + +#:% section = solution +#: Show the correct answers. This is not a proper solution, but demonstrates how +#: the correct answers for a `MatchList` can be displayed in PGML. +BEGIN_PGML_SOLUTION +The correct answers are [$ml]. +END_PGML_SOLUTION + +ENDDOCUMENT(); diff --git a/tutorial/sample-problems/Misc/Matching.pg b/tutorial/sample-problems/Misc/Matching.pg deleted file mode 100644 index 94c6a01e94..0000000000 --- a/tutorial/sample-problems/Misc/Matching.pg +++ /dev/null @@ -1,124 +0,0 @@ -## DESCRIPTION -## Matching question template -## ENDDESCRIPTION - -## KEYWORDS('matching', 'template') - -## DBsubject(WeBWorK) -## DBchapter(WeBWorK tutorial) -## DBsection(PGML tutorial 2015) -## Date(06/01/2015) -## Institution(Hope College) -## Author(Paul Pearson) - -#:% name = Matching Problem -#:% type = Sample -#:% categories = [multiple choice, misc] - -#:% section = preamble -#: The PODLINK('PGchoicemacros.pl') macro is used to construct the list of -#: multiple choice items, and the custom problem grader fluid from -#: PODLINK('PGgraders.pl') is used for incremental grading. -DOCUMENT(); - -loadMacros( - 'PGstandard.pl', 'PGML.pl', - 'PGchoicemacros.pl', 'PGgraders.pl', - 'unionTables.pl', 'PGcourse.pl' -); - -#:% section = setup -#: Withhold feedback when answers are submitted by setting -#: `$showPartialCorrectAnswers = 0;`. -#: -#: This problem uses an incremental grader called the -#: `custom_problem_grader_fluid`. With this problem grader, the number of -#: correct answers `[2, 4, 6]` and their corresponding scores `[0.3, 0.6, 1]` -#: must be specified. The last entry in the `grader_numright` array must be the -#: total number of questions asked, and the last entry in the `grader_scores` -#: array must be 1 (otherwise nobody can earn full credit!). The grader message -#: can also be customized by setting the value of `grader_message` to the -#: desired custom message. -#: -#: If a grader is desired that awards full credit when all questions are correct -#: and no credit otherwise, use the commented out standard problem grader code -#: instead. -#: -#: Create a list of 6 questions and answers, 2 extra answers, and a -#: 'None of the above' answer that will be made last with `makeLast`. -#: So the pop up list must have 9 entries A through I. -#: -#: As an alternative, see PROBLINK('MatchingAlt.pg') for another way to write -#: a matching problem. -$showPartialCorrectAnswers = 0; - -# Incremental grader -install_problem_grader(~~&custom_problem_grader_fluid); -$ENV{grader_numright} = [ 2, 4, 6 ]; -$ENV{grader_scores} = [ 0.3, 0.6, 1 ]; -$ENV{grader_message} = - 'You can earn ' - . '30% partial credit for 2 - 3 correct answers, and ' - . '60% partial credit for 4 - 5 correct answers.'; - -# All or nothing grader -# install_problem_grader(~~&std_problem_grader); - -# Create a matching list and use pop ups -$ml = new_match_list(); -$ml->rf_print_q(~~&pop_up_list_print_q); -$ml->ra_pop_up_list([ - 'No answer' => '?', - 'A' => 'A', - 'B' => 'B', - 'C' => 'C', - 'D' => 'D', - 'E' => 'E', - 'F' => 'F', - 'G' => 'G', - 'H' => 'H', - 'I' => 'I', -]); - -# Add correct questions and answers -$ml->qa( - 'Question a?', - 'Answer a', - 'Question b?', - 'Answer b', - 'Question c?', - 'Answer c', - 'Question d?', - 'Answer d', - 'Question e?', - 'Answer e', - 'Question f?', - 'Answer f', -); -$ml->choose(6); - -# Add extra incorrect answers -$ml->extra('Extra answer 1', 'Extra answer 2',); -$ml->choose_extra(2); - -$ml->makeLast('None of the above'); - -#:% section = statement -#: The `ColumnMatchTable` method is provided by the macro file -#: PODLINK('unionTables.pl'). -BEGIN_PGML -Match each question with its answer. - -[@ ColumnMatchTable($ml) @]*** -END_PGML - -#:% section = answer -ANS(str_cmp($ml->ra_correct_ans)); - -#:% section = solution -#: Extract the correct answers from the `MatchList` object and reformat. -BEGIN_PGML_SOLUTION -The correct answers are [@ join(', ', @{ $ml->ra_correct_ans() }) @]. -END_PGML_SOLUTION - -ENDDOCUMENT(); diff --git a/tutorial/sample-problems/Misc/MatchingAlt.pg b/tutorial/sample-problems/Misc/MatchingAlt.pg deleted file mode 100644 index b1a5692309..0000000000 --- a/tutorial/sample-problems/Misc/MatchingAlt.pg +++ /dev/null @@ -1,159 +0,0 @@ -## DESCRIPTION -## Matching question template -## ENDDESCRIPTION - -## DBsubject(WeBWorK) -## DBchapter(WeBWorK tutorial) -## DBsection(WeBWorK tutorial) -## Date(05/23/2023) -## Institution(Missouri Western State University) -## Author(Glenn Rice) -## KEYWORDS('matching', 'template') - -#:% name = Matching Problem (Alternate) -#:% type = Sample -#:% categories = [multiple choice, misc] - -#:% section = preamble -#: The PODLINK('parserPopUp.pl') macro is used to create drop down menus for -#: answers, and the custom problem grader fluid from PODLINK('PGgraders.pl') is -#: used for incremental grading. -DOCUMENT(); - -loadMacros( - 'PGstandard.pl', 'PGML.pl', 'parserPopUp.pl', 'PGgraders.pl', - 'PGcourse.pl' -); - -#:% section = setup -#: Withhold feedback when answers are submitted by setting -#: `$showPartialCorrectAnswers = 0;`. -#: -#: This problem uses an incremental grader called the -#: `custom_problem_grader_fluid`. With this problem grader, the number of -#: correct answers `[2, 4, 6]` and their corresponding scores `[0.3, 0.6, 1]` -#: must be specified. The last entry in the `grader_numright` array must be the -#: total number of questions asked, and the last entry in the `grader_scores` -#: array must be 1 (otherwise nobody can earn full credit!). The grader message -#: can also be customized by setting the value of `grader_message` to the -#: desired custom message. -#: -#: If a grader is desired that awards full credit when all questions are correct -#: and no credit otherwise, use the commented out `std_problem_grader` code -#: instead. -#: -#: The default `avg_problem_grader` could also be used by not using a custom -#: problem grader at all to give credit for the parts that are correct. -#: The percent score will be 100 * (number correct) / (number of questions) with -#: the default grader. -#: -#: Create a list of 6 questions and answers, 2 extra answers, and a -#: 'None of the above' answer that will be force to be last. -$showPartialCorrectAnswers = 0; - -# Incremental grader -install_problem_grader(~~&custom_problem_grader_fluid); -$ENV{grader_numright} = [ 2, 4, 6 ]; -$ENV{grader_scores} = [ 0.3, 0.6, 1 ]; -$ENV{grader_message} = - 'You can earn ' - . '30% partial credit for 2 - 3 correct answers, and ' - . '60% partial credit for 4 - 5 correct answers.'; - -# All or nothing grader -# install_problem_grader(~~&std_problem_grader); - -# Create a list of questions and answers. -@q_and_a = ( - [ 'Question a?', 'Answer a' ], - [ 'Question b?', 'Answer b' ], - [ 'Question c?', 'Answer c' ], - [ 'Question d?', 'Answer d' ], - [ 'Question e?', 'Answer e' ], - [ 'Question f?', 'Answer f' ], -); - -# Add extra incorrect answers -@extra_a = ('Extra answer 1', 'Extra answer 2'); - -# Randomize the questions. -@q_and_a = map { splice(@q_and_a, random(0, $#q_and_a)) } 0 .. $#q_and_a; - -# Choose a random order to display the answers (including the incorrect answers). -push(@answers, (map { $_->[1] } @q_and_a), @extra_a); -@indices = (0 .. $#answers); -@shuffle = map { splice(@indices, random(0, $#indices), 1) } 0 .. $#indices; - -# Create an inverse map back to the original indices. -@inversion[@shuffle] = (0 .. $#shuffle); - -# Add an answer that is always last. It is also incorrect in this case. -push(@answers, 'None of the above'); -push(@shuffle, scalar(@shuffle)); - -@answer_dropdowns = - map { DropDown([ @ALPHABET[ 0 .. $#answers ] ], $inversion[$_]) } - 0 .. $#q_and_a; - -#:% section = statement -#: Inject style into the page header to nicely show the questions on the left -#: and the answers on the right. On narrow screens the answers will be below -#: the questions. -#: -#: In the problem text a `div` with the CSS class defined in the style snippet -#: wraps the questions and answers. Inside that the questions are in the first -#: inner `div`, and the answers in the second inner `div`. -#: -#: When a hard copy of the problem is generated two side by side `\parboxes` are -#: used instead. -#: -#: The HTML and TeX are inserted into the problem using the `PGML` tag syntax. -#: -#: Both the questions and answers are added as `PGML` parsed strings. -HEADER_TEXT(MODES(TeX => '', HTML => < -.two-column { - display: flex; - flex-wrap: wrap; - gap: 2rem; - align-items: center; - justify-content: space-evenly; -} - -END_STYLE - -BEGIN_PGML -Match each question with its answer. - -[< - [< - [@ join( - "\n\n", - map { - '[_]{$answer_dropdowns[' . $_ . ']} ' - . '*' . ($_ + 1) . '.* ' - . '[$q_and_a[' . $_ . '][0]]' - } 0 .. $#q_and_a - ) @]** - >]{ [ 'div' ] }{ [ '\\parbox{0.55\\linewidth}{', '}' ] } - [@ MODES(TeX => '\\hfill', HTML => '') @]* - [< - [@ join( - "\n\n", - map { - '*' . $ALPHABET[($_)] . '.* [$answers[$shuffle[' . $_ . ']]]' - } 0 .. $#answers - ) @]** - >]{ [ 'div' ] }{ [ '\\parbox{0.25\\linewidth}{', '}' ] } ->]{ [ 'div', class => 'two-column' ] } -END_PGML - -#:% section = solution -#: Show the letter indices for the correct answers as a comma separated list. -#: Use the `inversion` mapping to find the correct original indices. -BEGIN_PGML_SOLUTION -The correct answers are -[@ join(', ', map { $ALPHABET[($inversion[($_)])] } 0 .. $#q_and_a) @]*. -END_PGML_SOLUTION - -ENDDOCUMENT();