diff --git a/classes/local/parser.php b/classes/local/parser.php index c198b480..eec95421 100644 --- a/classes/local/parser.php +++ b/classes/local/parser.php @@ -33,7 +33,7 @@ class parser { protected array $tokenlist; /** @var int number of (raw) tokens */ - private int $count; + protected int $count; /** @var int position w.r.t. list of (raw) tokens */ private int $position = -1; @@ -98,7 +98,7 @@ private function parse_the_right_thing(token $token) { * * @return void */ - private function check_unbalanced_parens(): void { + protected function check_unbalanced_parens(): void { $parenstack = []; foreach ($this->tokenlist as $token) { $type = $token->type; @@ -399,7 +399,7 @@ private function peek(int $skip = 0): ?token { * * @return token|null */ - private function read_next(): ?token { + protected function read_next(): ?token { $nexttoken = $this->peek(); if ($nexttoken !== self::EOF) { $this->position++; diff --git a/classes/local/shunting_yard.php b/classes/local/shunting_yard.php index da309966..e4324dd8 100644 --- a/classes/local/shunting_yard.php +++ b/classes/local/shunting_yard.php @@ -528,6 +528,130 @@ public static function infix_to_rpn(array $tokens): array { return $output; } + /** + * Translate unit expression from infix into RPN notation via Dijkstra's shunting yard algorithm, + * because this makes evaluation much easier. + * + * @param array $tokens the tokens forming the expression that is to be translated + * @return array + */ + public static function unit_infix_to_rpn($tokens): array { + $output = []; + $opstack = []; + + $lasttoken = null; + $lasttype = null; + $lastvalue = null; + foreach ($tokens as $token) { + $type = $token->type; + $value = $token->value; + + if (!is_null($lasttoken)) { + $lasttype = $lasttoken->type; + $lastvalue = $lasttoken->value; + } + + // Insert inplicit multiplication sign between two consecutive UNIT tokens. + // For accurate error reporting, the row and column number of the implicit + // multiplication token are copied over from the current token which triggered + // the multiplication. + $unitunit = ($lasttype === token::UNIT && $type === token::UNIT); + $unitparen = ($lasttype === token::UNIT && $type === token::OPENING_PAREN); + $parenunit = ($lasttype === token::CLOSING_PAREN && $type === token::UNIT); + $parenparen = ($lasttype === token::CLOSING_PAREN && $type === token::OPENING_PAREN); + if ($unitunit || $unitparen || $parenunit || $parenparen) { + // For backwards compatibility, division will have a lower precedence than multiplication, + // in order for J / m K to be interpreted as J / (m K). Instead of introducing a special + // 'unit multiplication' pseudo-operator, we simply increase the multiplication's precedence + // by one when flushing operators from the opstack. + self::flush_higher_precedence($opstack, self::get_precedence('*') + 1, $output); + $opstack[] = new token(token::OPERATOR, '*', $token->row, $token->column); + } + + // Two consecutive operators are only possible if the unary minus follows exponentiation. + // Note: We do not have to check whether the first of them is exponentiation, because we + // only allow - in the exponent anyway. + if ($type === token::OPERATOR && $lasttype === token::OPERATOR && $value !== '-') { + self::die(get_string('error_unexpectedtoken', 'qtype_formulas', $value), $token); + } + + switch ($type) { + // UNIT tokens go straight to the output queue. + case token::UNIT: + $output[] = $token; + break; + + // Numbers go to the output queue. + case token::NUMBER: + // If the last token was the unary minus, we multiply the number by -1 before + // sending it to the output queue. Afterwards, we can remove the minus from the opstack. + if ($lasttype === token::OPERATOR && $lastvalue === '-') { + $token->value = -$token->value; + array_pop($opstack); + } + $output[] = $token; + break; + + // Opening parentheses go straight to the operator stack. + case token::OPENING_PAREN: + $opstack[] = $token; + break; + + // A closing parenthesis means we flush all operators until we get to the + // matching opening parenthesis. + case token::CLOSING_PAREN: + // A closing parenthesis must not occur immediately after an operator. + if ($lasttype === token::OPERATOR) { + self::die(get_string('error_unexpectedtoken', 'qtype_formulas', $value), $token); + } + self::flush_until_paren($opstack, token::OPENING_PAREN, $output); + break; + + // Deal with all the possible operators... + case token::OPERATOR: + // Operators must not follow an opening parenthesis, except for the unary minus. + if ($lasttype === token::OPENING_PAREN && $value !== '-') { + self::die(get_string('error_unexpectedtoken', 'qtype_formulas', $value), $token); + } + // Before fetching the precedence, we must translate ^ (caret) into **, because + // the ^ operator normally has a different meaning with lower precedence. + if ($value === '^') { + $value = '**'; + } + // Exponents cannot follow a closing parenthesis, because things like (m/s)^2 cannot + // be translated to legacy syntax before "unit arithmetic" is fully implemented. We use + // $token->value instead of $value to have the operator like it was entered. + if ($value === '**' && $lasttype === token::CLOSING_PAREN) { + self::die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + $thisprecedence = self::get_precedence($value); + // We artificially increase the precedence of the division operator, because + // legacy versions used implicit parens around the denominator, e. g. + // the expression J / m K would be interpreted as J / (m * K). This is consistent + // with what tools like Wolfram Alpha do, even though e. g. 1 / 2 3 would be read + // as 3/2 both by Formulas Question and Wolfram Alpha. And even if it were not, it + // is not possible to change that, because it could break existing questions. + if ($value === '*') { + $thisprecedence++; + } + // Flush operators with higher precedence, unless we have a unary minus, because + // it is not left-associative. + if ($value !== '-') { + self::flush_higher_precedence($opstack, $thisprecedence, $output); + } + // Put the operator on the stack. + $opstack[] = $token; + break; + } + + $lasttoken = $token; + } + // After last token, flush opstack. Last token must be either a number (in exponent), + // a closing parenthesis or a unit. + self::flush_all($opstack, $output); + return $output; + } + /** * Stop processing and indicate the human readable position (row/column) where the error occurred. * diff --git a/classes/local/token.php b/classes/local/token.php index 4e34c8c0..922dec20 100644 --- a/classes/local/token.php +++ b/classes/local/token.php @@ -123,6 +123,9 @@ class token { /** @var int used to designate a token storing an end-of-group marker (closing brace) */ const END_GROUP = 4194304; + /** @var int used to designate a token storing a unit */ + const UNIT = 8388608; + /** @var mixed the token's content, will be the name for identifiers */ public $value; diff --git a/classes/local/unit_parser.php b/classes/local/unit_parser.php new file mode 100644 index 00000000..165b7017 --- /dev/null +++ b/classes/local/unit_parser.php @@ -0,0 +1,252 @@ +. + +namespace qtype_formulas\local; + +/** + * Parser for units for qtype_formulas + * + * @package qtype_formulas + * @copyright 2025 Philipp Imhof + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class unit_parser extends parser { + + /** @var array list of used units */ + private array $unitlist = []; + + /** + * Create a unit parser class and have it parse a given input. The input can be given as a string, in + * which case it will first be sent to the lexer. If that step has already been made, the constructor + * also accepts a list of tokens. + * + * @param string|array $tokenlist list of tokens as returned from the lexer or input string + */ + public function __construct($tokenlist) { + // If the input is given as a string, run it through the lexer first. + if (is_string($tokenlist)) { + $lexer = new lexer($tokenlist); + $tokenlist = $lexer->get_tokens(); + } + $this->tokenlist = $tokenlist; + + // The unit_parser might have been called on an empty input. If this is the case, we store an + // empty statement and stop here. + if (empty($this->tokenlist)) { + $this->statements[] = []; + return; + } + + // Check for unbalanced / mismatched parentheses. + $this->check_parens(); + + // Perform basic syntax check, including classification of IDENTIFIER tokens + // to UNIT tokens. + $this->check_syntax(); + + // Run the tokens through an adapted shunting yard algorithm to bring them into + // RPN notation. + $this->statements[] = shunting_yard::unit_infix_to_rpn($this->tokenlist); + } + + /** + * Check if the unit expression respects all syntax constraints, i. e. only valid operators (division, + * multiplication, exponentiation), valid use of parentheses, only one division operator. We only allow + * stuff that can be converted into the legacy syntax, because we still rely on the original unit + * conversion code for now. * + */ + protected function check_syntax(): void { + // Whether we have already seen a slash or a unit and whether we are in an exponent. + $seenslash = false; + $seenunit = false; + $inexponent = false; + foreach ($this->tokenlist as $token) { + // The use of functions is not permitted in units, so all identifiers will be classified + // as UNIT tokens. + if ($token->type === token::IDENTIFIER) { + // If inside an exponent, only numbers (and maybe the unary minus) are allowed. + if ($inexponent) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + // The same unit must not be used more than once. + if ($this->has_unit_been_used($token)) { + $this->die('Unit already used: ' . $token->value, $token); + } + $this->unitlist[] = $token->value; + $token->type = token::UNIT; + $seenunit = true; + continue; + } + + // Do various syntax checks for operators. We do them separately in order to allow + // for more specific error messages, if needed. + if ($token->type === token::OPERATOR) { + // We can only accept an operator if there has been at least one unit before. + if (!$seenunit) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + // The only operators allowed are exponentiation, multiplication, division and the unary minus. + // Note that the caret (^) always means exponentiation in the context of units. + if (!in_array($token->value, ['^', '**', '/', '*', '-'])) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + // The unary minus is only allowed inside an exponent. + if ($token->value === '-' && !$inexponent) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + // Only the unary minus is allowed inside an exponent. + if ($inexponent && $token->value !== '-') { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + if ($token->value === '^' || $token->value === '**') { + $inexponent = true; + } + if ($token->value === '/') { + if ($seenslash) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + $seenslash = true; + } + continue; + } + + // Numbers can only be used as exponents and exponents must always be integers. + if ($token->type === token::NUMBER) { + if (!$inexponent) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + if (intval($token->value) != $token->value) { + $this->die(get_string('error_integerexpected', 'qtype_formulas', $token->value), $token); + } + // Only one number is allowed in an exponent, so after the number the + // exponent must be finished. + $inexponent = false; + continue; + } + + // Parentheses are allowed, but we don't have to do anything with them now. + if (in_array($token->type, [token::OPENING_PAREN, token::CLOSING_PAREN])) { + continue; + } + + // All other tokens are not allowed. + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + + // The last token must be a number, a unit or a closing parenthesis. + $finaltoken = end($this->tokenlist); + if (!in_array($finaltoken->type, [token::UNIT, token::NUMBER, token::CLOSING_PAREN])) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + } + + /** + * Check whether a given unit has already been used. As we currently still use the original code for + * unit conversion stuff, we stick to the same behaviour. Hence, we do not take into account the prefixes, + * i. e. km and m will be considered as different units. + * + * @param token $token token containing the unit + * @return bool + */ + protected function has_unit_been_used(token $token): bool { + return in_array($token->value, $this->unitlist); + } + + /** + * Check whether all parentheses are balanced and whether only round parens are used. + * Otherweise, stop all further processing and output an error message. + * + * @return void + */ + protected function check_parens(): void { + $parenstack = []; + foreach ($this->tokenlist as $token) { + $type = $token->type; + // We only allow round parens. + if (($token->type & token::ANY_PAREN) && !($token->type & token::OPEN_OR_CLOSE_PAREN)) { + $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', $token->value), $token); + } + if ($type === token::OPENING_PAREN) { + $parenstack[] = $token; + } + if ($type === token::CLOSING_PAREN) { + $top = end($parenstack); + // If stack is empty, we have a stray closing paren. + if (!($top instanceof token)) { + $this->die(get_string('error_strayparen', 'qtype_formulas', $token->value), $token); + } + array_pop($parenstack); + } + } + // If the stack of parentheses is not empty now, we have an unmatched opening parenthesis. + if (!empty($parenstack)) { + $unmatched = end($parenstack); + $this->die(get_string('error_parennotclosed', 'qtype_formulas', $unmatched->value), $unmatched); + } + } + + /** + * Translate the given input into a string that can be understood by the legacy unit parser, i. e. + * following all syntax rules. This allows keeping the old unit conversion system in place until + * we are ready to eventually replace it. + * + * @return string + */ + public function get_legacy_unit_string(): string { + $stack = []; + + foreach ($this->statements[0] as $token) { + // Write numbers and units to the stack. + if (in_array($token->type, [token::UNIT, token::NUMBER])) { + $value = $token->value; + if (is_numeric($value) && $value < 0) { + $value = '(' . strval($value) . ')'; + } + $stack[] = $value; + } + + // Operators take arguments from stack and stick them together in the appropriate way. + if ($token->type === token::OPERATOR) { + $op = $token->value; + if ($op === '**') { + $op = '^'; + } + if ($op === '*') { + $op = ' '; + } + $second = array_pop($stack); + $first = array_pop($stack); + // With the new syntax, it is possible to write e.g. (m/s^2)*kg. In older versions, + // everything coming after the / operator will be considered a part of the denominator, + // so the only way to get the kg into the numerator is to reorder the units and + // write them as kg*m/s^2. Long story short: if there is a division, it must come last. + // Note that the syntax currently does not allow more than one /, so we do not need + // a more sophisticated solution. + if (strpos($first, '/') !== false) { + list($second, $first) = [$first, $second]; + } + // Legacy syntax allowed parens around the entire denominator, so we do that unless the + // denominator is just one unit. + if ($op === '/' && !preg_match('/^[A-Za-z]+$/', $second)) { + $second = '(' . $second . ')'; + } + $stack[] = $first . $op . $second; + } + } + + return implode('', $stack); + } +} diff --git a/lang/en/qtype_formulas.php b/lang/en/qtype_formulas.php index abb1f57d..9375cbb0 100644 --- a/lang/en/qtype_formulas.php +++ b/lang/en/qtype_formulas.php @@ -165,6 +165,7 @@ $string['error_import_missing_field'] = 'Import error. Missing field: {$a} '; $string['error_in_answer'] = 'Error in answer #{$a->answerno}: {$a->message}'; $string['error_indexoutofrange'] = 'Evaluation error: index {$a} out of range.'; +$string['error_integerexpected'] = 'Syntax error: integer expected, found {$a} instead.'; $string['error_inv_consec'] = 'When using inv(), the numbers in the list must be consecutive.'; $string['error_inv_integers'] = 'inv() expects all elements of the list to be integers; floats will be truncated.'; $string['error_inv_list'] = 'inv() expects a list.'; diff --git a/question.php b/question.php index b05a05b2..23997955 100644 --- a/question.php +++ b/question.php @@ -36,6 +36,7 @@ use qtype_formulas\local\random_parser; use qtype_formulas\local\parser; use qtype_formulas\local\token; +use qtype_formulas\local\unit_parser; use qtype_formulas\unit_conversion_rules; defined('MOODLE_INTERNAL') || die(); @@ -1481,7 +1482,7 @@ public function grade(array $response, bool $finalsubmit = false): array { // formulas, we must wrap the answers in quotes before we move on. Also, we reset the conversion // factor, because it is not needed for algebraic answers. if ($isalgebraic) { - $response = self::wrap_algebraic_formulas_in_quotes($response); + $response = self::wrap_algebraic_formulas_in_quotes($response); $conversionfactor = 1; } @@ -1572,7 +1573,20 @@ private function is_compatible_unit(string $studentsunit) { $checkunit->assign_default_rules($this->ruleid, $entry[1]); $checkunit->assign_additional_rules($this->otherrule); - $checked = $checkunit->check_convertibility($studentsunit, $this->postunit); + // Use the compatibility layer to parse the unit string and convert it into the old format. + // If parsing fails, we can immediately return false. + try { + $parser = new unit_parser($studentsunit); + $postunitparser = new unit_parser($this->postunit); + } catch (Exception $e) { + // TODO: convert to non-capturing catch + return false; + } + + $checked = $checkunit->check_convertibility( + $parser->get_legacy_unit_string(), + $postunitparser->get_legacy_unit_string(), + ); if ($checked->convertible) { return $checked->cfactor; } diff --git a/questiontype.php b/questiontype.php index 4d26907e..d8de20f5 100644 --- a/questiontype.php +++ b/questiontype.php @@ -31,6 +31,7 @@ use qtype_formulas\local\answer_parser; use qtype_formulas\local\parser; use qtype_formulas\local\token; +use qtype_formulas\local\unit_parser; defined('MOODLE_INTERNAL') || die(); @@ -1228,9 +1229,22 @@ public function check_variables_and_expressions(object $data, array $parts): obj // If a unit has been provided, check whether it can be parsed. if (!empty($part->postunit)) { try { - $unitcheck->parse_targets($part->postunit); + $unitparser = new unit_parser($part->postunit); + $unitcheck->parse_targets($unitparser->get_legacy_unit_string()); } catch (Exception $e) { - $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas'); + $trace = $e->getTraceAsString(); + // If we are coming from the newer code, use the detailed error message without + // the row/column number. Otherwise just use the generic message provided by the + // legacy code. + // TODO: Use str_contains() once we drop support for PHP < 8.0. + if (strstr($trace, 'unit_parser.php') !== false) { + // The error message may contain line and column numbers, but they don't make + // sense in this context, so we'd rather remove them. + $errors["postunit[$i]"] = preg_replace('/([^:]+:)([^:]+:)/', '', $e->getMessage()); + + } else { + $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas'); + } } } diff --git a/tests/question_test.php b/tests/question_test.php index 35242b4f..df1b01af 100644 --- a/tests/question_test.php +++ b/tests/question_test.php @@ -800,6 +800,32 @@ public function test_grade_parts_that_can_be_graded_6(): void { self::assertEquals($expected, $partscores); } + public function test_grade_with_unit_compatibility_layer(): void { + $q = $this->get_test_formulas_question('testmethodsinparts'); + + // We overwrite the postunit. In the first part, we use the old syntax for the model unit and + // the new syntax in the response. In the second part, we use the new syntax for the model + // unit and the old syntax in the response. + $q->parts[0]->postunit = 'm s'; + $q->parts[1]->postunit = 'm*s'; + + $q->start_attempt(new question_attempt_step(), 1); + + // phpcs:ignore Universal.Arrays.DuplicateArrayKey.Found + $response = ['0_' => '40 m*s', '1_0' => '40', '1_1' => 'm s', '2_0' => '40', '3_0' => '40']; + $partscores = $q->grade_parts_that_can_be_graded($response, [], false); + + // The latest $response is correct for all parts #0 and #2. Note that the penalty value will + // be set according to the question data; it does not mean that a deduction occurred. + $expected = [ + '0' => new qbehaviour_adaptivemultipart_part_result('0', 1, 0.3), + '1' => new qbehaviour_adaptivemultipart_part_result('1', 1, 0.3), + '2' => new qbehaviour_adaptivemultipart_part_result('2', 1, 0.3), + '3' => new qbehaviour_adaptivemultipart_part_result('3', 1, 0.3), + ]; + self::assertEquals($expected, $partscores); + } + public function test_get_parts_and_weights_singlenum(): void { $q = $this->get_test_formulas_question('testsinglenum'); diff --git a/tests/questiontype_test.php b/tests/questiontype_test.php index 9778eb35..9b993ca0 100644 --- a/tests/questiontype_test.php +++ b/tests/questiontype_test.php @@ -467,10 +467,18 @@ public static function provide_single_part_data_for_form_validation(): array { 'correctness' => [0 => '1/0'], ], ], + [[], ['postunit' => [0 => 'a/b*c']], + ], + [ + ['postunit[0]' => 'Unit already used: m'], + [ + 'postunit' => [0 => 'm*m'], + ], + ], [ - ['postunit[0]' => get_string('error_unit', 'qtype_formulas')], + ['postunit[0]' => 'Unexpected token: ^'], [ - 'postunit' => [0 => 'a/b*c'], + 'postunit' => [0 => '(m/s)^2'], ], ], [ diff --git a/tests/unit_parser_test.php b/tests/unit_parser_test.php new file mode 100644 index 00000000..1488ab85 --- /dev/null +++ b/tests/unit_parser_test.php @@ -0,0 +1,190 @@ +. + +namespace qtype_formulas; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/type/formulas/questiontype.php'); + +use Exception; +use qtype_formulas\local\unit_parser; + +/** + * Unit tests for the unit_parser class. + * + * @package qtype_formulas + * @category test + * @copyright 2025 Philipp Imhof + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \qtype_formulas\local\unit_parser + * @covers \qtype_formulas\local\shunting_yard + */ +final class unit_parser_test extends \advanced_testcase { + + /** + * Test parsing of unit inputs. + * + * @dataProvider provide_units + */ + public function test_parse_unit($expected, $input): void { + $e = null; + $error = ''; + try { + new unit_parser($input); + } catch (Exception $e) { + $error = $e->getMessage(); + } + + // If we are expecting an error message, the exception object should not be null and + // the message should match, without checking row and column number. + if (!empty($expected) && $expected[0] === '!') { + self::assertNotNull($e); + self::assertStringEndsWith(substr($expected, 1), $error); + } else { + self::assertNull($e); + } + } + + /** + * Test conversion of unit inputs to legacy input format. + * + * @dataProvider provide_units + */ + public function test_get_legacy_unit_string($expected, $input): void { + $e = null; + try { + $parser = new unit_parser($input); + } catch (Exception $e) { + $e->getMessage(); + } + + // If we are not expecting an error, check that the input has been translated as expected. + if (empty($expected) || $expected[0] !== '!') { + self::assertEquals($expected, $parser->get_legacy_unit_string()); + } else { + self::assertNotNull($e); + } + } + + /** + * Data provider for the test functions. For simplicity, we use the same provider + * for valid and invalid expressions. In case of invalid expressions, we put an + * exclamation mark (!) at the start of the error message. + * + * @return array + */ + public static function provide_units(): array { + return [ + ['', ''], + ['J/(m K)', 'J / m K'], + ['J/(m K)', 'J / m*K'], + ['J/(m K)', 'J / (m K)'], + ['J/(m K)', 'J / (m*K)'], + ['m kg/(s^2)', 'm kg/s^2'], + ['m kg/(s^2)', 'm kg / s^2'], + ['m kg/(s^2)', 'm*kg / s^2'], + ['m kg/(s^2)', 'm*(kg / s^2)'], + ['kg m/(s^2)', '(m/s^2)*kg'], + ['kg m/(s^2)', '(m/s^2) kg'], + ['m kg/(s^2)', '(m (kg / s^(2)))'], + ['m K kg/s', 'm (kg / s) K'], + ['s^(-1)', 's^-1'], + ['s^2', 's**2'], + ['s^(-1)', 's**-1'], + ['s^(-1)', 's^(-1)'], + ['s^(-1)', 's**(-1)'], + ['s^(-1)/(m^(-1))', 's**-1 / m**-1'], + ['m', 'm'], + ['m', '(m)'], + ['km', 'km'], + ['m^2', 'm^2'], + ['m^2', 'm^(2)'], + ['m^2', '(m^2)'], + ['m^2', 'm**2'], + ['m^2', '(m**2)'], + ['m^2', 'm**(2)'], + ['m^2', 'm ^ 2'], + ['m^2', 'm ^ (2)'], + ['m^2', 'm ** 2'], + ['m^2', 'm ** (2)'], + ['m^(-2)', 'm^-2'], + ['m^(-2)', '(m^-2)'], + ['m^(-2)', 'm^(-2)'], + ['m^(-2)', 'm ^ -2'], + ['m^(-2)', 'm ^ (-2)'], + ['m/s', 'm/s'], + ['m/s', '(m)/(s)'], + ['m/s', '(m/s)'], + ['m s^(-1)', 'm s^-1'], + ['m s^(-1)', 'm (s^-1)'], + ['m s^(-1)', 'm (s^(-1))'], + ['m s^(-1)', 'm s^(-1)'], + ['m/(s^(-1))', 'm / (s^(-1))'], + ['m/(s^(-1))', 'm / ((s^(-1)))'], + ['kg m/s', 'kg m/s'], + ['kg m/s', 'kg (m/s)'], + ['kg m/s', 'kg*(m/s)'], + ['kg m/s', 'kg*m/s'], + ['kg m/s', '(kg m)/s'], + ['kg m/s', '(kg*m)/s'], + ['kg m s^(-1)', 'kg m s^-1'], + ['kg m^2', 'kg m^2'], + ['kg m^2', 'kg m ^ 2'], + ['kg m s^(-1)', 'kg m s ^ - 1'], + ['!Syntax error: integer expected, found 2.5 instead.', 'm^2.5'], + ["!Unbalanced parenthesis, stray ')' found.", 'm/s)'], + ["!Unbalanced parenthesis, '(' is never closed.", '(m/s'], + ["!Unexpected input: '@'", '@'], + ['!Unexpected token: s', 'm^s'], + ['!Unexpected token: -', 'm*-s'], + ['!Unexpected token: /', 'm/s/K'], + ['!Unexpected token: /', 'm/s * kg/m^2'], + ['!Unexpected token: π', 'm*π'], + ['!Unexpected token: ,', 'm,s'], + ['!Unexpected token: [', '[m/s]'], + ['!Unexpected token: )', '(m*)'], + ['!Unexpected token: +', 'm+km'], + ['!Unexpected token: *', '*'], + ['!Unexpected token: *', '*m'], + ['!Unexpected token: /', '/m'], + ['!Unexpected token: *', 'm*(*s)'], + ['!Unexpected token: *', '(*m)'], + ['!Unexpected token: **', '(**m)'], + ['!Unexpected token: ^', '(^m)'], + ['!Unexpected token: {', '{m/s}'], + ['!Unexpected token: 1', 'm 1/s'], + ['!Unexpected token: 1', '1/s'], + ['!Unexpected token: 1', '1 m/s'], + ['!Unexpected token: 2', '2/s'], + ['!Unexpected token: 2.1', '2.1'], + ['!Unexpected token: ^', '^2'], + ['!Unexpected token: *', '*s'], + ['!Unexpected token: *', 'm* *kg'], + ['!Unexpected token: /', '/s'], + ['!Unexpected token: *', 'm*'], + ['!Unexpected token: /', 'm/'], + ['!Unexpected token: ^', 'm^'], + ['!Unexpected token: /', 'm^(/2)'], + ['!Unexpected token: +', 'm^+2'], + ['!Unexpected token: ^', '(m/s)^2'], + ['!Unexpected token: **', '(m/s)**2'], + ['!Unit already used: m', 'm kg / m'], + ]; + } +}