diff --git a/lib/moodlelib.php b/lib/moodlelib.php index cd0eacbcce4..54ba41b0386 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -7838,14 +7838,23 @@ function moodle_setlocale($locale='') { /** * Count words in a string. * - * Words are defined as things between whitespace. + * Words are defined as things between whitespace. Developments have tried to ensure that this + * method gives the same results as Libre Office, MS Word, etc. However, word-counting rules are + * subtle, and not identical between languages, so there may be differences in non-English languages. * * @category string * @param string $string The text to be searched for words. May be HTML. - * @param int|null $format + * @param int|null $format a FORMAT_... constant. In the API this is optional, + * but really, it is required to get accurate results, so should be passed. * @return int The count of words in the specified string */ function count_words($string, $format = null) { + // If format is plain, remove < characters that are attached to non-HTML words. + if ($format === null || $format == FORMAT_PLAIN) { + // Remove < that is attached to a word but doesn't form a valid HTML tag. + // This matches < followed by word characters that don't have a closing >. + $string = preg_replace('/(\w|^|\s)<(?![^<>]*>)(?=\w)/u', '$1', $string); + } // Before stripping tags, add a space after the close tag of anything that is not obviously inline. // Also, br is a special case because it definitely delimits a word, but has no close tag. $string = preg_replace('~ diff --git a/lib/tests/moodlelib_test.php b/lib/tests/moodlelib_test.php index 8d088a87236..26143a8d6cf 100644 --- a/lib/tests/moodlelib_test.php +++ b/lib/tests/moodlelib_test.php @@ -3897,7 +3897,7 @@ EOF; * @dataProvider count_words_testcases * @param int $expectedcount number of words in $string. * @param string $string the test string to count the words of. - * @param int|null $format + * @param int|null $format FORMAT_... constant to pass to count_words. */ public function test_count_words(int $expectedcount, string $string, $format = null): void { $this->assertEquals($expectedcount, count_words($string, $format), @@ -3957,8 +3957,12 @@ EOT; [1, 'ab', FORMAT_HTML], [1, 'ab', FORMAT_MOODLE], [1, 'ab', FORMAT_MARKDOWN], - [1, 'aa pokus'], + [3, 'aa pokus'], [2, 'aa pokus', FORMAT_HTML], + [3, 'x < 1', FORMAT_PLAIN], + [3, 'quam justocheck_input_word_count($response['answer'])) { + if ($this->check_input_word_count($response['answer'], $response['answerformat'] ?? FORMAT_PLAIN)) { return false; } } @@ -180,7 +180,7 @@ class qtype_essay_question extends question_with_responses { if ($this->is_complete_response($response)) { return ''; } - return $this->check_input_word_count($response['answer']); + return $this->check_input_word_count($response['answer'], $response['answerformat'] ?? FORMAT_PLAIN); } public function is_gradable_response(array $response) { @@ -262,10 +262,11 @@ class qtype_essay_question extends question_with_responses { * Check the input word count and return a message to user * when the number of words are outside the boundary settings. * - * @param string $responsestring - * @return string|null + * @param string $responsestring the student's response to count the words in. + * @param int $responseformat the FORMAT_... constant for what format $responsestring is. + * @return string|null null if the word-count is in range, otherwise a string message about how it is not. .*/ - private function check_input_word_count($responsestring) { + private function check_input_word_count(string $responsestring, int $responseformat) { if (!$this->responserequired) { return null; } @@ -275,7 +276,7 @@ class qtype_essay_question extends question_with_responses { } // Count the number of words in the response string. - $count = count_words($responsestring); + $count = count_words($responsestring, $responseformat); if ($this->maxwordlimit && $count > $this->maxwordlimit) { return get_string('maxwordlimitboundary', 'qtype_essay', ['limit' => $this->maxwordlimit, 'count' => $count]); @@ -306,7 +307,7 @@ class qtype_essay_question extends question_with_responses { return ''; } - $count = count_words($response['answer']); + $count = count_words($response['answer'], $response['answerformat'] ?? FORMAT_PLAIN); if ($this->maxwordlimit && $count > $this->maxwordlimit) { return get_string('wordcounttoomuch', 'qtype_essay', ['limit' => $this->maxwordlimit, 'count' => $count]); diff --git a/question/type/essay/tests/question_test.php b/question/type/essay/tests/question_test.php index 2bca5ca0111..80c8222525c 100644 --- a/question/type/essay/tests/question_test.php +++ b/question/type/essay/tests/question_test.php @@ -26,12 +26,12 @@ require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** - * Unit tests for the matching question definition class. + * Unit tests for the essay question definition class. * - * @package qtype_essay - * @copyright 2009 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @covers \qtype_essay_question + * @package qtype_essay + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \qtype_essay_question */ final class question_test extends \advanced_testcase { public function test_get_question_summary(): void { @@ -401,6 +401,21 @@ final class question_test extends \advanced_testcase { $this->assertEquals($expected, $actual); } + /** + * Test that a less-than symbol attached to a word ('<1.') in plain text + * is handled correctly and doesn't trigger HTML validation errors. + */ + public function test_get_validation_error_plain_text_with_html_chars(): void { + $question = \test_question_maker::make_an_essay_question(); + $question->responseformat = 'plain'; + $question->responserequired = 1; + $question->minwordlimit = 2; + $question->maxwordlimit = 2; + + $response = ['answer' => 'x <1.', 'answerformat' => FORMAT_PLAIN]; + $this->assertEquals('', $question->get_validation_error($response)); + } + /** * Data provider for get_validation_error test. * @@ -437,6 +452,31 @@ final class question_test extends \advanced_testcase { $this->assertEquals($expected, $question->get_word_count_message_for_review($response)); } + /** + * Test word count calculation when input contains less-than symbols. + * + * Verifies that words with leading less-than symbols (like '<1.' or 'responseformat = 'plain'; + $question->responserequired = 1; + $question->minwordlimit = 2; + $question->maxwordlimit = 2; + + $expected = 'Word count: 2'; + $response = ['answer' => 'x <1.', 'answerformat' => FORMAT_PLAIN]; + $this->assertEquals($expected, $question->get_word_count_message_for_review($response)); + + $expected = 'Word count: 8, more than the limit of 2 words.'; + $response = [ + 'answer' => 'What about an FORMAT_PLAIN, + ]; + $this->assertEquals($expected, $question->get_word_count_message_for_review($response)); + } + /** * Data provider for test_get_word_count_message_for_review. *