Skip to content
4 changes: 4 additions & 0 deletions changelog/fix-grade-quiz-auto-false-question-id
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix quiz grade being deflated when submitted answers include question IDs that are not a valid 'question' post type. PHP's loose `0 == false` comparison caused such IDs to be silently treated as zero-grade questions, potentially corrupting the per-question grade record. They are now skipped entirely.
2 changes: 1 addition & 1 deletion includes/class-sensei-grading-user-quiz.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public function display() {
}

$question_grade_total = Sensei()->question->get_question_grade( $question_id );
$quiz_grade_total += $question_grade_total;
$quiz_grade_total += (int) $question_grade_total;

$right_answer = get_post_meta( $question_id, '_question_right_answer', true );
$user_answer_content = Sensei()->quiz->get_user_question_answer( $lesson_id, $question_id, $user_id );
Expand Down
9 changes: 9 additions & 0 deletions includes/class-sensei-grading.php
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,15 @@ public static function grade_quiz_auto( $quiz_id = 0, $submitted = array(), $tot
// check if the question is autogradable, either by type, or because the grade is 0
$question_type = Sensei()->question->get_question_type( $question_id );
$achievable_grade = Sensei()->question->get_question_grade( $question_id );

// get_question_grade() returns false for non-'question' post types (e.g. deleted
// posts or multiple_question containers). Strict check must come before the loose
// 0 == comparison below, because PHP evaluates 0 == false as true, which would
// silently drop a correctly-answered question from grade_total.
if ( false === $achievable_grade ) {
continue;
}

// Question has a zero grade, so skip grading
if ( 0 == $achievable_grade ) {
$all_question_grades[ $question_id ] = $achievable_grade;
Expand Down
4 changes: 2 additions & 2 deletions includes/class-sensei-question.php
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ public function get_question_type( $question_id ) {
*
* @param int $question_id
*
* @return int $question_grade | bool
* @return int|false
*/
public function get_question_grade( $question_id ) {

Expand Down Expand Up @@ -1009,7 +1009,7 @@ public static function the_answer_feedback( $question_id ) {

$correct_answer = $show_correct_answers && ! $answer_correct ? self::get_correct_answer( $question_id ) : false;

$grade = Sensei()->view_helper->format_question_points( $answer_grade . '/' . $question_grade );
$grade = Sensei()->view_helper->format_question_points( $answer_grade . '/' . (int) $question_grade );

/**
* Filter the learner grade displayed.
Expand Down
2 changes: 1 addition & 1 deletion includes/class-sensei-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ public static function sensei_get_quiz_total( $quiz_id = 0 ) {
$question_grade = 0;
foreach ( $questions as $question ) {
$question_grade = Sensei()->question->get_question_grade( $question->ID );
$quiz_total += $question_grade;
$quiz_total += (int) $question_grade;
}
}

Expand Down
75 changes: 75 additions & 0 deletions tests/unit-tests/test-class-grading.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ public function gradeGapFillQuestions() {
}

/**
* Test grade_gap_fill_question with regex patterns.
*
* @dataProvider gradeGapFillQuestions
* @covers Sensei_Grading::grade_gap_fill_question
* @since 1.9.18
Expand Down Expand Up @@ -854,6 +856,79 @@ private function getTestQuestion( $question_type ) {
return Sensei()->lesson->lesson_save_question( $question );
}

/**
* Tests that grade_quiz_auto() ignores question IDs whose post type is not
* 'question' (e.g. deleted posts or multiple_question containers) instead of
* silently treating them as zero-grade via PHP's loose 0 == false comparison.
*
* Before the fix, get_question_grade() returns false for a non-'question' post type,
* but `0 == false` evaluated to true in PHP, so the ID fell through to the zero-grade
* branch and was stored with grade false via set_user_grades(). After the fix the
* strict false === $achievable_grade guard skips the ID entirely (continue), so it
* does not appear in the stored per-question grades and the percentage reflects only
* valid questions.
*
* @covers Sensei_Grading::grade_quiz_auto
*/
public function testGradeQuizAuto_InvalidQuestionIdInSubmitted_IsSkippedAndNotStoredInGrades(): void {
/* Arrange. */
$user_id = wp_create_user( 'grading_invalid_q', 'pass', 'grading_invalid_q@example.com' );
$lesson_id = $this->factory->lesson->create();
$quiz_id = $this->factory->quiz->create( array( 'post_parent' => $lesson_id ) );
update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
update_post_meta( $quiz_id, '_quiz_grade_type', 'auto' );

// One valid boolean question where 'true' is the correct answer (grade = 1 by default).
$question_args = $this->factory->question->get_sample_question_data( 'boolean' );
$question_args['quiz_id'] = $quiz_id;
$question_args['post_author'] = get_post( $quiz_id )->post_author;
$question_args['question_right_answer_boolean'] = 'true';
$valid_question_id = Sensei()->lesson->lesson_save_question( $question_args );

// Precondition: the valid question must exist as a 'question' post type.
$this->assertSame( 'question', get_post_type( $valid_question_id ), 'Precondition: valid question must be of post type "question".' );

// A regular post (not a 'question' post type): get_question_grade() returns false for it.
$invalid_question_id = $this->factory->post->create();
$this->assertNotSame( 'question', get_post_type( $invalid_question_id ), 'Precondition: invalid question must NOT be of post type "question".' );
$this->assertFalse( Sensei()->question->get_question_grade( $invalid_question_id ), 'Precondition: get_question_grade() must return false for the invalid ID.' );

wp_set_current_user( $user_id );
Sensei_Utils::user_start_lesson( $user_id, $lesson_id );

// Both IDs are submitted — the valid one answered correctly, the invalid one with anything.
$submitted = array(
$valid_question_id => 'true',
$invalid_question_id => 'true',
);
Sensei_Quiz::save_user_answers( $submitted, array(), $lesson_id, $user_id );

/* Act. */
$grade = Sensei_Grading::grade_quiz_auto( $quiz_id, $submitted, 0, 'auto' );

/* Assert. */

// The returned grade must be 100 — the valid question was answered correctly and the
// invalid ID must not inflate the denominator or corrupt the numerator.
$this->assertSame( 100, (int) $grade, 'grade_quiz_auto() should return 100 when the only valid question is answered correctly.' );

// The valid question must appear in stored grades with a passing mark.
$stored_grades = Sensei()->quiz->get_user_grades( $lesson_id, $user_id );
$this->assertIsArray( $stored_grades, 'get_user_grades() should return an array.' );
$this->assertArrayHasKey(
$valid_question_id,
$stored_grades,
'The valid question ID must be stored in per-question grades.'
);

// The invalid ID must not appear in stored grades at all.
$this->assertArrayNotHasKey(
$invalid_question_id,
$stored_grades,
'An invalid question ID (non-\'question\' post type) must not be stored in per-question grades.'
);
}

/**
* Add lesson status for a given student.
*
Expand Down
Loading