ソースを参照

Add fractions..

Gregory Huxley 6 日 前
親
コミット
7fe48d7027
5 ファイル変更、548 行追加、54 行削除
  1. 11 1
      README.md
  2. 8 1
      TK2.php
  3. 126 2
      answerChecker2.php
  4. 63 50
      fraction/src/Fraction.php
  5. 340 0
      questionGenerators/math/Fractions.php

+ 11 - 1
README.md

@@ -70,14 +70,24 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | elements (periodic table) | 150,000 |
 | elements (periodic table) | 150,000 |
 | multiplication | 200,000 |
 | multiplication | 200,000 |
 | division | 300,000 |
 | division | 300,000 |
+| fractions (switched off until you turn it on) | 350,000 |
 | anagrams | 4,000,000 |
 | anagrams | 4,000,000 |
 
 
-Worded problems and the geography/history/health subjects exist or are stubbed but are switched
+Worded problems, fractions and the geography/history/health subjects exist or are stubbed but are switched
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 
 
+**Fractions** (`fractions`, unlocking at 350,000 once switched on) use the `fraction/` folder, a copy
+of the open-source `phospr/fraction` library (MIT licence, lightly modified). The questions are
+simplifying, adding and subtracting (same denominator, then one a multiple of the other, then
+unrelated), multiplying, dividing, writing an improper fraction as a mixed number, and "3/4 of 20". The
+harder kinds appear as the student's accuracy grows. Answers can be typed as `3/4`, a mixed number
+(`1 1/2`), a whole number, or an exact decimal (`0.75`), but must be in their simplest form: a right
+value that could be simplified is marked wrong with a hint, as is an improper fraction when a mixed
+number was asked for.
+
 **Answers.** The expected answer never goes to the browser. The generator stores it in the
 **Answers.** The expected answer never goes to the browser. The generator stores it in the
 `tkPendingAnswer` table (`TkAnswerStore.php`) and the form carries a random single-use token
 `tkPendingAnswer` table (`TkAnswerStore.php`) and the form carries a random single-use token
 (`checkerData`). Answering consumes the token, so a changed token, a stale browser tab, a
 (`checkerData`). Answering consumes the token, so a changed token, a stale browser tab, a

+ 8 - 1
TK2.php

@@ -26,6 +26,7 @@
  *  - someone with neither the student nor the teacher class now sees a
  *  - someone with neither the student nor the teacher class now sees a
  *    message instead of an empty page
  *    message instead of an empty page
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
+ *  - new fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *    worded_* subjects were mapped to the spelling generator and are gone
  *    worded_* subjects were mapped to the spelling generator and are gone
  */
  */
@@ -42,7 +43,8 @@ class TK2 {
             ['multiplication', 1, 200000], // Numeracy times'
             ['multiplication', 1, 200000], // Numeracy times'
             ['division', 1, 300000], // Numeracy deviding
             ['division', 1, 300000], // Numeracy deviding
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
-            ['word_problems', 0, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['word_problems', 1, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['fractions', 1, 350000], // Fractions: change the 0 to a 1 to switch them on
         ],
         ],
         // English
         // English
         [['spelling', 1, 0], // Plain spelling
         [['spelling', 1, 0], // Plain spelling
@@ -171,6 +173,11 @@ class TK2 {
                 $questionGen = new numeracy2($this->data['user'], $this->data['selectedSubject']);
                 $questionGen = new numeracy2($this->data['user'], $this->data['selectedSubject']);
                 $questionGen->getNumeracy();
                 $questionGen->getNumeracy();
                 break;
                 break;
+            case 'fractions':
+                require_once __DIR__ . '/questionGenerators/math/fractions.php';
+                $questionGen = new fractions($this->data['user']);
+                $questionGen->getFractions();
+                break;
             case 'word_problems':
             case 'word_problems':
                 require_once __DIR__ . '/questionGenerators/math/math_word_problems.php';
                 require_once __DIR__ . '/questionGenerators/math/math_word_problems.php';
                 $questionGen = new math_word_problems($this->data['user']);
                 $questionGen = new math_word_problems($this->data['user']);

+ 126 - 2
answerChecker2.php

@@ -19,6 +19,10 @@
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
  *  - removed the broken, unused shortCode() and findInList()
  *  - a wrong answer takes a minute off but never below zero
  *  - a wrong answer takes a minute off but never below zero
+ *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
+ *    or an exact decimal (0.75); it must be in its simplest form, and a right value that
+ *    isn't simplified (or isn't a mixed number when one was asked for) is marked wrong
+ *    with a hint
  *  - a correct answer gets a friendlier, randomised message: a cheer, the answer
  *  - a correct answer gets a friendlier, randomised message: a cheer, the answer
  *    worked out, the play minutes earned, and a streak of correct answers (the
  *    worked out, the play minutes earned, and a streak of correct answers (the
  *    old "Congratulations" strings at messages[...][1] are no longer used)
  *    old "Congratulations" strings at messages[...][1] are no longer used)
@@ -33,6 +37,7 @@ class answerChecker2 {
         'multiplication' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'multiplication' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'division' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'division' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'rounding' => ['Round to the nearest 5 cents. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for rounding to the nearest 5 cents '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Round {QUESTION} to the nearest 5 cents'"],
         'rounding' => ['Round to the nearest 5 cents. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for rounding to the nearest 5 cents '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Round {QUESTION} to the nearest 5 cents'"],
+        'fractions' => ['Fractions: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'word_problems' => ['{QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'word_problems' => ['{QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'anagrams' => ['Unjumble the letters to find the word. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Unjumble the letters '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Unjumble the letters in {QUESTION}'"],
         'anagrams' => ['Unjumble the letters to find the word. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Unjumble the letters '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Unjumble the letters in {QUESTION}'"],
         'generalKnowledge' => [],
         'generalKnowledge' => [],
@@ -69,6 +74,9 @@ class answerChecker2 {
             case 'anagrams':
             case 'anagrams':
                 $this->checkAnagrams();
                 $this->checkAnagrams();
                 break;
                 break;
+            case 'fractions':
+                $this->checkFractions();
+                break;
             case 'generalKnowledge':
             case 'generalKnowledge':
             case 'elements':
             case 'elements':
                 $this->checkGeneric('elements');
                 $this->checkGeneric('elements');
@@ -120,6 +128,120 @@ class answerChecker2 {
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
     }
     }
 
 
+    /**
+     * Check a fractions answer. Any correct form is accepted (3/4, a mixed
+     * number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
+     * to be in its simplest form, and a mixed number if one was asked for.
+     */
+    private function checkFractions() {
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
+        $answer = $this->loadPending();
+        if ($answer === null) {
+            return;
+        }
+
+        $format = isset($answer['format']) ? $answer['format'] : 'simplest';
+        $verdict = $this->fractionVerdict((string) $answer['answer1'], $format, $usersAnswer);
+
+        $hint = '';
+        if ($verdict === 'unsimplified') {
+            $hint = "That's the right value, but it can be written more simply. ";
+        } elseif ($verdict === 'wrongform') {
+            $hint = "That's the right value, but write it as a mixed number, like 2 1/3. ";
+        }
+
+        $this->finishCheck('fractions', $verdict === 'correct', $answer['answer1'], $usersAnswer, $this->questionText($answer, 'fractions'), $hint);
+    }
+
+    /**
+     * @param string $expectedText The right answer in its simplest form ("3/4", "1 1/2", "7").
+     * @param string $format       'simplest', or 'mixed' when a mixed number was asked for.
+     * @param string $given        What the student typed.
+     * @return string 'correct', 'unsimplified', 'wrongform' or 'wrong'
+     */
+    private function fractionVerdict($expectedText, $format, $given) {
+        require_once __DIR__ . '/fraction/src/Fraction.php';
+
+        $typed = $this->parseFractionAnswer($given);
+        if ($typed === null) {
+            return 'wrong';
+        }
+
+        try {
+            $expected = Fraction::fromString($expectedText);
+        } catch (InvalidArgumentException $e) {
+            return 'wrong';
+        }
+
+        if (!$typed['value']->isSameValueAs($expected)) {
+            return 'wrong';
+        }
+        if ($format === 'mixed' && $typed['form'] !== 'mixed') {
+            return 'wrongform';
+        }
+        if (!$typed['lowest']) {
+            return 'unsimplified';
+        }
+        return 'correct';
+    }
+
+    /**
+     * Read what the student typed as a fraction.
+     *
+     * @return array|null ['value' => Fraction, 'form' => whole|fraction|mixed|decimal,
+     *                     'lowest' => bool (in simplest form)], or null if it isn't a number
+     */
+    private function parseFractionAnswer($text) {
+        $text = trim(preg_replace('/\s+/', ' ', (string) $text));
+
+        try {
+            if (preg_match('#^(\d{1,9})$#', $text, $m)) {
+                return ['value' => new Fraction((int) $m[1]), 'form' => 'whole', 'lowest' => true];
+            }
+
+            if (preg_match('#^(\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
+                $n = (int) $m[1];
+                $d = (int) $m[2];
+                if ($d < 1) {
+                    return null;
+                }
+                return ['value' => new Fraction($n, $d), 'form' => 'fraction', 'lowest' => ($n > 0 && $this->gcdInt($n, $d) === 1)];
+            }
+
+            if (preg_match('#^(\d{1,9}) (\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
+                $w = (int) $m[1];
+                $n = (int) $m[2];
+                $d = (int) $m[3];
+                if ($d < 1) {
+                    return null;
+                }
+                $value = (new Fraction($w))->add(new Fraction($n, $d));
+                return ['value' => $value, 'form' => 'mixed', 'lowest' => ($n > 0 && $n < $d && $this->gcdInt($n, $d) === 1)];
+            }
+
+            if (preg_match('#^(\d{0,9})\.(\d{1,8})$#', $text, $m)) {
+                // Exact: 0.29 is 29/100. (Fraction::fromFloat() goes through floating point.)
+                $digits = ($m[1] === '' ? '0' : $m[1]) . $m[2];
+                return ['value' => new Fraction((int) $digits, (int) pow(10, strlen($m[2]))), 'form' => 'decimal', 'lowest' => true];
+            }
+        } catch (InvalidArgumentException $e) {
+            return null;
+        }
+
+        return null;
+    }
+
+    private function gcdInt($a, $b) {
+        $a = abs($a);
+        $b = abs($b);
+        while ($b > 0) {
+            $t = $a % $b;
+            $a = $b;
+            $b = $t;
+        }
+        return $a;
+    }
+
     /**
     /**
      * Check the answer for spelling
      * Check the answer for spelling
      */
      */
@@ -154,7 +276,7 @@ class answerChecker2 {
      * the score, write the report row, render the result. $correct is the one
      * the score, write the report row, render the result. $correct is the one
      * decision used for all three.
      * decision used for all three.
      */
      */
-    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question) {
+    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question, $hint = '') {
         $expected = (string) $expected;
         $expected = (string) $expected;
         $messages = $this->messages[$subject];
         $messages = $this->messages[$subject];
 
 
@@ -172,7 +294,7 @@ class answerChecker2 {
                 '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
                 '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
                 '{QUESTION}'    => htmlspecialchars($question),
                 '{QUESTION}'    => htmlspecialchars($question),
             ];
             ];
-            $text = strtr($messages[2], $safe) . "<br/>";
+            $text = $hint . strtr($messages[2], $safe) . "<br/>";
             $title = ucfirst($subject) . ' answer check';
             $title = ucfirst($subject) . ' answer check';
         }
         }
 
 
@@ -242,6 +364,8 @@ class answerChecker2 {
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                 }
                 }
                 return 'You unjumbled it: <b>' . $e . '</b>!';
                 return 'You unjumbled it: <b>' . $e . '</b>!';
+            case 'fractions':
+                return '<b>' . htmlspecialchars($question) . ' = ' . $e . '</b>';
             case 'word_problems':
             case 'word_problems':
                 return 'The answer was <b>' . $e . '</b>.';
                 return 'The answer was <b>' . $e . '</b>.';
             case 'spelling':
             case 'spelling':

+ 63 - 50
fraction/src/Fraction.php

@@ -1,25 +1,30 @@
 <?php
 <?php
-
 /*
 /*
  * This file is part of the Phospr Fraction package.
  * This file is part of the Phospr Fraction package.
  *
  *
  * For the full copyright and license information, please view the LICENSE
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  * file that was distributed with this source code.
+ *
+ * Local changes for TimeKeeper (the class is used without Composer or
+ * namespaces, so it lives in the global namespace):
+ *  - the input checks that had been commented out are back. Without them
+ *    new Fraction(1, 0) and $fraction->divide(new Fraction(0)) divided by
+ *    zero inside simplify() (a fatal DivisionByZeroError on PHP 8), a
+ *    negative denominator was accepted, and Fraction::fromString('nonsense')
+ *    quietly returned null, which then crashed the next ->add() call
+ *  - they throw the built-in InvalidArgumentException, so the separate
+ *    Exception/ classes are no longer needed
+ *  - fromFloat() rounded down: 0.29 * 100 is 28.999999999999996, so
+ *    Fraction::fromFloat(0.29) gave 28/100 (7/25). It rounds to the nearest whole now
  */
  */
 
 
-//namespace Phospr;
-
-//use InvalidArgumentException;
-//use Phospr\Exception\Fraction\InvalidDenominatorException;
-//use Phospr\Exception\Fraction\InvalidNumeratorException;
-
 /**
 /**
  * Fraction
  * Fraction
  *
  *
  * Representation of a fraction, e.g. 3/4, 76/123, etc.
  * Representation of a fraction, e.g. 3/4, 76/123, etc.
  *
  *
  * @author Tom Haskins-Vaughan <tom@tomhv.uk>
  * @author Tom Haskins-Vaughan <tom@tomhv.uk>
- * @since  0.1.0
+ * @since 0.1.0
  */
  */
 class Fraction
 class Fraction
 {
 {
@@ -48,10 +53,13 @@ class Fraction
      * __construct
      * __construct
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @param integer $numerator
      * @param integer $numerator
      * @param integer $denominator
      * @param integer $denominator
+     *
+     * @throws InvalidArgumentException if the numerator or denominator is not
+     *                                  an integer, or the denominator is not above zero
      */
      */
     public function __construct($numerator, $denominator = 1)
     public function __construct($numerator, $denominator = 1)
     {
     {
@@ -60,21 +68,21 @@ class Fraction
         );
         );
 
 
         if (!is_int($numerator)) {
         if (!is_int($numerator)) {
-            //throw new InvalidNumeratorException(
-            //    'Numerator must be an integer'
-            //);
+            throw new InvalidArgumentException(
+                'Numerator must be an integer'
+            );
         }
         }
 
 
         if (!is_int($denominator)) {
         if (!is_int($denominator)) {
-            //throw new InvalidDenominatorException(
-            //    'Denominator must be an integer'
-            //);
+            throw new InvalidArgumentException(
+                'Denominator must be an integer'
+            );
         }
         }
 
 
         if ((int) $denominator < 1) {
         if ((int) $denominator < 1) {
-            //throw new InvalidDenominatorException(
-            //    'Denominator must be an integer greater than zero'
-            //);
+            throw new InvalidArgumentException(
+                'Denominator must be an integer greater than zero'
+            );
         }
         }
 
 
         if (0 == $numerator) {
         if (0 == $numerator) {
@@ -137,7 +145,7 @@ class Fraction
      * __toString
      * __toString
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return string
      * @return string
      */
      */
@@ -179,7 +187,7 @@ class Fraction
      * Get numerator
      * Get numerator
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return integer
      * @return integer
      */
      */
@@ -192,7 +200,7 @@ class Fraction
      * Get denominator
      * Get denominator
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return integer
      * @return integer
      */
      */
@@ -207,7 +215,7 @@ class Fraction
      * e.g. transform 2/4 into 1/2
      * e.g. transform 2/4 into 1/2
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return integer
      * @return integer
      */
      */
@@ -223,7 +231,7 @@ class Fraction
      * Get greatest common divisor
      * Get greatest common divisor
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return integer
      * @return integer
      */
      */
@@ -258,7 +266,7 @@ class Fraction
      * Multiply this fraction by a given fraction
      * Multiply this fraction by a given fraction
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @param Fraction $fraction
      * @param Fraction $fraction
      *
      *
@@ -276,11 +284,13 @@ class Fraction
      * Divide this fraction by a given fraction
      * Divide this fraction by a given fraction
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @param Fraction $fraction
      * @param Fraction $fraction
      *
      *
      * @return Fraction
      * @return Fraction
+     *
+     * @throws InvalidArgumentException when dividing by zero
      */
      */
     public function divide(Fraction $fraction)
     public function divide(Fraction $fraction)
     {
     {
@@ -298,7 +308,7 @@ class Fraction
      * Add this fraction to a given fraction
      * Add this fraction to a given fraction
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @param Fraction $fraction
      * @param Fraction $fraction
      *
      *
@@ -308,7 +318,6 @@ class Fraction
     {
     {
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
             + ($fraction->getNumerator() * $this->getDenominator());
             + ($fraction->getNumerator() * $this->getDenominator());
-
         $denominator = $this->getDenominator()
         $denominator = $this->getDenominator()
             * $fraction->getDenominator();
             * $fraction->getDenominator();
 
 
@@ -319,7 +328,7 @@ class Fraction
      * Subtract a given fraction from this fraction
      * Subtract a given fraction from this fraction
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @param Fraction $fraction
      * @param Fraction $fraction
      *
      *
@@ -329,7 +338,6 @@ class Fraction
     {
     {
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
             - ($fraction->getNumerator() * $this->getDenominator());
             - ($fraction->getNumerator() * $this->getDenominator());
-
         $denominator = $this->getDenominator()
         $denominator = $this->getDenominator()
             * $fraction->getDenominator();
             * $fraction->getDenominator();
 
 
@@ -340,7 +348,7 @@ class Fraction
      * Whether or not this fraction is an integer
      * Whether or not this fraction is an integer
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return boolean
      * @return boolean
      */
      */
@@ -353,11 +361,13 @@ class Fraction
      * Create from float
      * Create from float
      *
      *
      * @author Christopher Tatro <c.m.tatro@gmail.com>
      * @author Christopher Tatro <c.m.tatro@gmail.com>
-     * @since  0.2.0
+     * @since 0.2.0
      *
      *
      * @param float $float
      * @param float $float
      *
      *
-     * return Fraction
+     * @return Fraction
+     *
+     * @throws InvalidArgumentException if it is not a number
      */
      */
     public static function fromFloat($float)
     public static function fromFloat($float)
     {
     {
@@ -366,9 +376,9 @@ class Fraction
         }
         }
 
 
         if (!is_numeric($float)) {
         if (!is_numeric($float)) {
-            //throw new InvalidArgumentException(
-            //    'Argument passed is not a numeric value.'
-            //);
+            throw new InvalidArgumentException(
+                'Argument passed is not a numeric value.'
+            );
         }
         }
 
 
         // Make sure the float is a float not scientific notation.
         // Make sure the float is a float not scientific notation.
@@ -379,10 +389,11 @@ class Fraction
         $denominator = strstr($float, '.');
         $denominator = strstr($float, '.');
 
 
         // Pad a one with zeros for the length of the decimal places
         // Pad a one with zeros for the length of the decimal places
-        // ie  0.1 = 10; 0.02 = 100; 0.01234 = 100000;
+        // ie 0.1 = 10; 0.02 = 100; 0.01234 = 100000;
         $denominator = (int) str_pad('1', strlen($denominator), '0');
         $denominator = (int) str_pad('1', strlen($denominator), '0');
+
         // Multiply to get rid of the decimal places.
         // Multiply to get rid of the decimal places.
-        $numerator = (int) ($float*$denominator);
+        $numerator = (int) round($float*$denominator);
 
 
         return new self($numerator, $denominator);
         return new self($numerator, $denominator);
     }
     }
@@ -390,18 +401,20 @@ class Fraction
     /**
     /**
      * Create from string, e.g.
      * Create from string, e.g.
      *
      *
-     *     * 1/3
-     *     * 1/20
-     *     * 40
-     *     * 3 4/5
-     *     * 20 34/67
+     *  * 1/3
+     *  * 1/20
+     *  * 40
+     *  * 3 4/5
+     *  * 20 34/67
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.4.0
+     * @since 0.4.0
      *
      *
      * @param string $string
      * @param string $string
      *
      *
-     * return Fraction
+     * @return Fraction
+     *
+     * @throws InvalidArgumentException if the string isn't a fraction
      */
      */
     public static function fromString($string)
     public static function fromString($string)
     {
     {
@@ -426,17 +439,17 @@ class Fraction
             }
             }
         }
         }
 
 
-        //throw new InvalidArgumentException(sprintf(
-        //    'Cannot parse "%s"',
-        //    $string
-        //));
+        throw new InvalidArgumentException(sprintf(
+            'Cannot parse "%s"',
+            $string
+        ));
     }
     }
 
 
     /**
     /**
      * Get value as float
      * Get value as float
      *
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      *
      * @return float
      * @return float
      */
      */
@@ -469,4 +482,4 @@ class Fraction
 
 
         return true;
         return true;
     }
     }
-}
+}

+ 340 - 0
questionGenerators/math/Fractions.php

@@ -0,0 +1,340 @@
+<?php
+
+/**
+ * Fractions ("fractions"): simplify, add, subtract, multiply, divide, mixed
+ * numbers and "a fraction of a number".
+ *
+ * Built on the Fraction class in fraction/src (it does the arithmetic and
+ * puts every answer in its simplest form). The questions available grow with
+ * the student's level (accuracy this month, scaled up over their first 30
+ * answers):
+ *
+ *   from the start  simplify, add/subtract with the same denominator,
+ *                   a fraction of a number
+ *   level 20+       improper fraction to mixed number, add/subtract where one
+ *                   denominator is a multiple of the other
+ *   level 45+       add/subtract with different denominators, multiply
+ *   level 70+       divide
+ *
+ * The expected answer is stored server-side (TkAnswerStore); the checker
+ * (answerChecker2::checkFractions) accepts any correct form but wants the
+ * answer in its simplest form.
+ */
+require_once dirname(__DIR__, 2) . '/fraction/src/Fraction.php';
+
+class fractions {
+
+    const SUBJECT = 'fractions';
+
+    private $user;
+    private $level = 0; // 0-100: how hard to make the questions
+
+    /**
+     * @param string $user User to supply the question for.
+     */
+    public function __construct($user) {
+        $this->user = $user;
+    }
+
+    /**
+     * Make a question, show it, and remember it as the student's open question.
+     */
+    public function getFractions() {
+        $problem = $this->getProblem();
+        $questionHtml = $this->htmlify($problem);
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
+
+        e107::getRender()->tablerender('Fractions', $questionHtml);
+    }
+
+    /**
+     * Make a question without showing it.
+     *
+     * @return array ['answer1' => string, 'question' => string, 'type' => 'fractions',
+     *                'format' => 'simplest'|'mixed', 'instruction' => string]
+     */
+    public function getProblem() {
+        $this->level = $this->difficulty();
+
+        $kinds = ['simplify', 'sameDenominators', 'fractionOf'];
+        if ($this->level >= 20) {
+            $kinds[] = 'mixedNumber';
+            $kinds[] = 'oneDenominatorMultiple';
+        }
+        if ($this->level >= 45) {
+            $kinds[] = 'unlikeDenominators';
+            $kinds[] = 'multiply';
+        }
+        if ($this->level >= 70) {
+            $kinds[] = 'divide';
+        }
+
+        $kind = $kinds[array_rand($kinds)];
+        return $this->$kind();
+    }
+
+    /**
+     * How hard to make the questions, 0-100: last month's accuracy, scaled up
+     * over the first 30 answers.
+     *
+     * @return float
+     */
+    private function difficulty() {
+        $db = e107::getDb();
+        $stats = $db->retrieve(
+            "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
+             FROM `" . MPREFIX . "tkReport`
+             WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+             AND username = '" . $db->escape($this->user) . "' AND subject = '" . self::SUBJECT . "'"
+        );
+
+        if (!is_array($stats) || (int) $stats['count'] === 0) {
+            return 0.0;
+        }
+
+        $sample = min(1.0, ((int) $stats['count']) / 30);
+        return max(0.0, min(100.0, (float) $stats['percent'])) * $sample;
+    }
+
+    /**
+     * A random whole number from $min up to $maxAtFull. At level 0 the top of
+     * the range is a quarter of the way there; at level 100 it is all of it.
+     */
+    private function num($min, $maxAtFull) {
+        $span = max(0, $maxAtFull - $min);
+        $max = $min + (int) ceil($span * (0.25 + 0.75 * $this->level / 100));
+        return mt_rand($min, max($min, $max));
+    }
+
+    private function gcd($a, $b) {
+        $a = abs($a);
+        $b = abs($b);
+        while ($b > 0) {
+            $t = $a % $b;
+            $a = $b;
+            $b = $t;
+        }
+        return $a;
+    }
+
+    /**
+     * A numerator from 1 to $d - 1 that shares no factor with $d.
+     */
+    private function coprimeNumerator($d) {
+        for ($i = 0; $i < 50; $i++) {
+            $n = mt_rand(1, $d - 1);
+            if ($this->gcd($n, $d) === 1) {
+                return $n;
+            }
+        }
+        return 1;
+    }
+
+    private function problem($question, Fraction $answer, $instruction, $format = 'simplest') {
+        return [
+            'answer1' => (string) $answer,
+            'question' => $question,
+            'type' => self::SUBJECT,
+            'format' => $format,
+            'instruction' => $instruction,
+        ];
+    }
+
+    // ---- the questions
+
+    /** Simplify 6/8 -> 3/4 */
+    private function simplify() {
+        $d = $this->num(3, 12);
+        $n = $this->coprimeNumerator($d);
+        $k = $this->num(2, 6);
+
+        return $this->problem(
+            'Simplify ' . ($n * $k) . '/' . ($d * $k),
+            new Fraction($n, $d),
+            'Write this fraction in its simplest form.'
+        );
+    }
+
+    /** 3/8 + 1/8, 5/9 - 2/9 */
+    private function sameDenominators() {
+        $d = $this->num(4, 12);
+
+        if (mt_rand(0, 1) === 1) {
+            if ($this->level >= 20) {
+                $a = mt_rand(1, $d - 1);
+                $b = mt_rand(1, $d - 1);          // the total can be more than 1
+            } else {
+                $a = mt_rand(1, $d - 2);
+                $b = mt_rand(1, $d - 1 - $a);     // the total stays under 1
+            }
+            $answer = (new Fraction($a, $d))->add(new Fraction($b, $d));
+            $question = $a . '/' . $d . ' + ' . $b . '/' . $d;
+        } else {
+            $a = mt_rand(2, $d - 1);
+            $b = mt_rand(1, $a - 1);
+            $answer = (new Fraction($a, $d))->subtract(new Fraction($b, $d));
+            $question = $a . '/' . $d . ' − ' . $b . '/' . $d;
+        }
+
+        return $this->problem($question, $answer, 'Work it out and write your answer in its simplest form.');
+    }
+
+    /** 3/4 of 20 -> 15 */
+    private function fractionOf() {
+        $d = $this->num(2, 10);
+        $n = mt_rand(1, $d - 1);
+        $k = $this->num(2, 12);
+
+        return $this->problem(
+            $n . '/' . $d . ' of ' . ($d * $k),
+            new Fraction($n * $k),
+            'Work it out. Your answer is a whole number.'
+        );
+    }
+
+    /** 17/5 as a mixed number -> 3 2/5 */
+    private function mixedNumber() {
+        $d = $this->num(2, 10);
+        $r = $this->coprimeNumerator($d);
+        $whole = $this->num(1, 6);
+        $n = $whole * $d + $r;
+
+        return $this->problem(
+            $n . '/' . $d . ' as a mixed number',
+            new Fraction($n, $d),
+            'Write this improper fraction as a mixed number, like 2 1/3.',
+            'mixed'
+        );
+    }
+
+    /** 1/2 + 3/8: one denominator is a multiple of the other */
+    private function oneDenominatorMultiple() {
+        for ($i = 0; $i < 20; $i++) {
+            $d1 = $this->num(2, 6);
+            $d2 = $d1 * $this->num(2, 4);
+            $first = [mt_rand(1, $d1 - 1), $d1];
+            $second = [mt_rand(1, $d2 - 1), $d2];
+            if (mt_rand(0, 1) === 1) {
+                list($first, $second) = [$second, $first];
+            }
+
+            $problem = $this->addOrSubtract($first, $second);
+            if ($problem !== null) {
+                return $problem;
+            }
+        }
+        return $this->addOrSubtract($first, $second, true);
+    }
+
+    /** 2/3 + 1/4: different denominators, neither a multiple of the other */
+    private function unlikeDenominators() {
+        for ($i = 0; $i < 20; $i++) {
+            $d1 = $this->num(2, 8);
+            $d2 = $this->num(2, 8);
+            if ($d1 === $d2 || $d1 % $d2 === 0 || $d2 % $d1 === 0) {
+                continue;
+            }
+            $first = [mt_rand(1, $d1 - 1), $d1];
+            $second = [mt_rand(1, $d2 - 1), $d2];
+
+            $problem = $this->addOrSubtract($first, $second);
+            if ($problem !== null) {
+                return $problem;
+            }
+        }
+        return $this->addOrSubtract([1, 3], [1, 4], true);
+    }
+
+    /** 2/3 × 3/4 */
+    private function multiply() {
+        $b = $this->num(2, 9);
+        $d = $this->num(2, 9);
+        $a = mt_rand(1, $b - 1);
+        $c = mt_rand(1, $d - 1);
+
+        return $this->problem(
+            $a . '/' . $b . ' × ' . $c . '/' . $d,
+            (new Fraction($a, $b))->multiply(new Fraction($c, $d)),
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    /** 1/2 ÷ 1/4 */
+    private function divide() {
+        $b = $this->num(2, 9);
+        $d = $this->num(2, 9);
+        $a = mt_rand(1, $b - 1);
+        $c = mt_rand(1, $d - 1);
+
+        return $this->problem(
+            $a . '/' . $b . ' ÷ ' . $c . '/' . $d,
+            (new Fraction($a, $b))->divide(new Fraction($c, $d)),
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    /**
+     * Add or subtract two fractions, each given as [numerator, denominator].
+     * For a subtraction the larger fraction goes first so the answer is above
+     * zero; two equal fractions give null (nothing to subtract).
+     *
+     * @return array|null
+     */
+    private function addOrSubtract(array $first, array $second, $forceAdd = false) {
+        $add = $forceAdd || (mt_rand(0, 1) === 1);
+
+        if (!$add) {
+            $left = $first[0] * $second[1];
+            $right = $second[0] * $first[1];
+            if ($left === $right) {
+                return null;
+            }
+            if ($left < $right) {
+                list($first, $second) = [$second, $first];
+            }
+        }
+
+        $one = new Fraction($first[0], $first[1]);
+        $two = new Fraction($second[0], $second[1]);
+
+        if ($add) {
+            $answer = $one->add($two);
+            $operator = ' + ';
+        } else {
+            $answer = $one->subtract($two);
+            $operator = ' − ';
+        }
+
+        return $this->problem(
+            $first[0] . '/' . $first[1] . $operator . $second[0] . '/' . $second[1],
+            $answer,
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    private function htmlify($problem) {
+        // Expected answer stays on the server; the form only carries a token.
+        $token = TkAnswerStore::issue($this->user, $problem);
+
+        $frm = e107::getForm();
+
+        $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
+        $fString .= $frm->label('<h4>Fractions</h4>') . "</br>\n";
+        $fString .= $frm->label(htmlspecialchars($problem['instruction'])) . "\n";
+        $fString .= '</br>';
+        $fString .= '<p style="font-size:140%;font-weight:bold;">' . htmlspecialchars($problem['question']) . '</p>';
+        $fString .= '<p class="smalltext">Type fractions like 3/4 and mixed numbers like 2 1/3.</p>';
+        $fString .= $frm->text('a1', '', 20, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
+        $fString .= '</br>' . "\n";
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
+        $fString .= '</br></br>';
+
+        return $fString;
+    }
+}