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.
*