form 1 topics added
This commit is contained in:
94
lib/math/fractions.test.ts
Normal file
94
lib/math/fractions.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { gcd, lcm, Fraction } from "./fractions";
|
||||
|
||||
const f = (n: number, d: number) => new Fraction(n, d);
|
||||
|
||||
describe("gcd / lcm", () => {
|
||||
it("computes gcd", () => {
|
||||
expect(gcd(12, 8)).toBe(4);
|
||||
expect(gcd(7, 3)).toBe(1);
|
||||
expect(gcd(0, 5)).toBe(5);
|
||||
expect(gcd(-12, 8)).toBe(4);
|
||||
});
|
||||
|
||||
it("computes lcm", () => {
|
||||
expect(lcm(4, 6)).toBe(12);
|
||||
expect(lcm(3, 5)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fraction sign + simplify", () => {
|
||||
it("keeps the sign on the numerator", () => {
|
||||
expect([f(2, -4).n, f(2, -4).d]).toEqual([-2, 4]);
|
||||
expect([f(-2, -4).n, f(-2, -4).d]).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("reduces to lowest terms", () => {
|
||||
const check = (n: number, d: number, en: number, ed: number) => {
|
||||
const s = f(n, d).simplified();
|
||||
expect([s.n, s.d]).toEqual([en, ed]);
|
||||
};
|
||||
check(5, 10, 1, 2);
|
||||
check(8, 12, 2, 3);
|
||||
check(3, 1, 3, 1);
|
||||
check(2, -4, -1, 2);
|
||||
check(-2, -4, 1, 2);
|
||||
check(0, 5, 0, 1);
|
||||
});
|
||||
|
||||
it("reports whether it is already simplified", () => {
|
||||
expect(f(1, 2).isSimplified).toBe(true);
|
||||
expect(f(5, 10).isSimplified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fraction arithmetic (raw, then .simplified())", () => {
|
||||
const s = (fr: Fraction) => [fr.simplified().n, fr.simplified().d];
|
||||
|
||||
it("adds", () => {
|
||||
expect(s(f(1, 2).plus(f(1, 2)))).toEqual([1, 1]);
|
||||
expect(s(f(2, 10).plus(f(3, 10)))).toEqual([1, 2]);
|
||||
expect(s(f(1, 3).plus(f(1, 4)))).toEqual([7, 12]);
|
||||
});
|
||||
|
||||
it("subtracts", () => {
|
||||
expect(s(f(7, 8).minus(f(6, 8)))).toEqual([1, 8]);
|
||||
expect(s(f(1, 2).minus(f(1, 2)))).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("multiplies", () => {
|
||||
expect(s(f(2, 3).times(f(3, 4)))).toEqual([1, 2]);
|
||||
expect(s(f(1, 2).times(f(1, 2)))).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it("divides (invert and multiply)", () => {
|
||||
expect(s(f(1, 2).dividedBy(f(1, 4)))).toEqual([2, 1]);
|
||||
expect(s(f(3, 4).dividedBy(f(1, 2)))).toEqual([3, 2]);
|
||||
});
|
||||
|
||||
it("handles negative fractions with the sign rules", () => {
|
||||
expect(s(f(-1, 2).plus(f(3, 4)))).toEqual([1, 4]); // -1/2 + 3/4
|
||||
expect(s(f(1, 2).minus(f(-3, 4)))).toEqual([5, 4]); // subtracting a negative adds
|
||||
expect(s(f(-2, 3).times(f(1, 4)))).toEqual([-1, 6]); // different signs -> negative
|
||||
expect(s(f(-2, 3).times(f(-3, 5)))).toEqual([2, 5]); // same signs -> positive
|
||||
expect(s(f(-1, 2).dividedBy(f(1, 4)))).toEqual([-2, 1]); // -1/2 ÷ 1/4
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fraction presentation + equality", () => {
|
||||
it("renders KaTeX, collapsing whole numbers", () => {
|
||||
expect(f(1, 2).toKatex()).toBe("\\frac{1}{2}");
|
||||
expect(f(3, 1).toKatex()).toBe("3");
|
||||
});
|
||||
|
||||
it("renders signed KaTeX with the sign in front", () => {
|
||||
expect(f(-3, 4).toSignedKatex()).toBe("-\\frac{3}{4}");
|
||||
expect(f(3, 4).toSignedKatex()).toBe("\\frac{3}{4}");
|
||||
expect(f(-5, 1).toSignedKatex()).toBe("-5");
|
||||
});
|
||||
|
||||
it("compares by value, not written form", () => {
|
||||
expect(f(5, 10).equals(f(1, 2))).toBe(true);
|
||||
expect(f(1, 2).equals(f(1, 3))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,97 +11,100 @@ export function lcm(a: number, b: number): number {
|
||||
return Math.abs(a * b) / gcd(a, b);
|
||||
}
|
||||
|
||||
export function simplify(num: number, den: number): [number, number] {
|
||||
if (den === 0) return [num, den];
|
||||
const g = gcd(Math.abs(num), Math.abs(den));
|
||||
const sign = den < 0 ? -1 : 1;
|
||||
return [(num / g) * sign, (den / g) * sign];
|
||||
/**
|
||||
* An immutable fraction. Sign is kept on the numerator; arithmetic returns a new
|
||||
* Fraction and does NOT auto-simplify — call `.simplified()` when you want lowest
|
||||
* terms (this lets teaching views show the un-reduced working). Prefer this over
|
||||
* passing `(num, den)` pairs around: positional pairs are easy to mis-order.
|
||||
*/
|
||||
export class Fraction {
|
||||
readonly n: number;
|
||||
readonly d: number;
|
||||
|
||||
constructor(n: number, d: number) {
|
||||
// Canonical sign: keep it on the numerator. A zero denominator is tolerated
|
||||
// (kept as-is) so divide-by-zero in a lesson renders rather than throwing.
|
||||
if (d < 0) {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
this.n = n;
|
||||
this.d = d;
|
||||
}
|
||||
|
||||
static of(n: number, d: number): Fraction {
|
||||
return new Fraction(n, d);
|
||||
}
|
||||
|
||||
get isZero(): boolean {
|
||||
return this.n === 0;
|
||||
}
|
||||
|
||||
plus(o: Fraction): Fraction {
|
||||
return new Fraction(this.n * o.d + o.n * this.d, this.d * o.d);
|
||||
}
|
||||
|
||||
minus(o: Fraction): Fraction {
|
||||
return new Fraction(this.n * o.d - o.n * this.d, this.d * o.d);
|
||||
}
|
||||
|
||||
times(o: Fraction): Fraction {
|
||||
return new Fraction(this.n * o.n, this.d * o.d);
|
||||
}
|
||||
|
||||
dividedBy(o: Fraction): Fraction {
|
||||
return new Fraction(this.n * o.d, this.d * o.n);
|
||||
}
|
||||
|
||||
simplified(): Fraction {
|
||||
if (this.d === 0) return this;
|
||||
if (this.n === 0) return new Fraction(0, 1);
|
||||
const g = gcd(Math.abs(this.n), Math.abs(this.d));
|
||||
return new Fraction(this.n / g, this.d / g);
|
||||
}
|
||||
|
||||
get isSimplified(): boolean {
|
||||
return gcd(Math.abs(this.n), Math.abs(this.d)) === 1;
|
||||
}
|
||||
|
||||
/** Value equality (cross-multiplication), independent of the written form. */
|
||||
equals(o: Fraction): boolean {
|
||||
return this.n * o.d === o.n * this.d;
|
||||
}
|
||||
|
||||
toDecimal(): number {
|
||||
return this.n / this.d;
|
||||
}
|
||||
|
||||
/** [whole, remainderNumerator, denominator] */
|
||||
toMixed(): [number, number, number] {
|
||||
const whole = Math.trunc(this.n / this.d);
|
||||
const remainder = Math.abs(this.n % this.d);
|
||||
return [whole, remainder, this.d];
|
||||
}
|
||||
|
||||
toKatex(): string {
|
||||
if (this.d === 1) return `${this.n}`;
|
||||
return `\\frac{${this.n}}{${this.d}}`;
|
||||
}
|
||||
|
||||
/** Like toKatex, but writes negatives with the sign in front: -\frac{3}{4} rather than \frac{-3}{4}. */
|
||||
toSignedKatex(): string {
|
||||
if (this.d === 1) return `${this.n}`;
|
||||
const sign = this.n < 0 ? "-" : "";
|
||||
return `${sign}\\frac{${Math.abs(this.n)}}{${this.d}}`;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.d === 1 ? `${this.n}` : `${this.n}/${this.d}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function add(
|
||||
n1: number, d1: number,
|
||||
n2: number, d2: number,
|
||||
): [number, number] {
|
||||
const num = n1 * d2 + n2 * d1;
|
||||
const den = d1 * d2;
|
||||
return simplify(num, den);
|
||||
}
|
||||
|
||||
export function subtract(
|
||||
n1: number, d1: number,
|
||||
n2: number, d2: number,
|
||||
): [number, number] {
|
||||
const num = n1 * d2 - n2 * d1;
|
||||
const den = d1 * d2;
|
||||
return simplify(num, den);
|
||||
}
|
||||
|
||||
export function multiply(
|
||||
n1: number, d1: number,
|
||||
n2: number, d2: number,
|
||||
): [number, number] {
|
||||
return simplify(n1 * n2, d1 * d2);
|
||||
}
|
||||
|
||||
export function divide(
|
||||
n1: number, d1: number,
|
||||
n2: number, d2: number,
|
||||
): [number, number] {
|
||||
return simplify(n1 * d2, d1 * n2);
|
||||
}
|
||||
|
||||
export function toDecimal(num: number, den: number): number {
|
||||
return num / den;
|
||||
}
|
||||
|
||||
export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
|
||||
const str = decimal.toFixed(precision);
|
||||
const parts = str.split(".");
|
||||
if (!parts[1]) return [parseInt(parts[0]), 1];
|
||||
const den = Math.pow(10, parts[1].length);
|
||||
const num = Math.round(decimal * den);
|
||||
return simplify(num, den);
|
||||
}
|
||||
|
||||
export function compare(
|
||||
n1: number, d1: number,
|
||||
n2: number, d2: number,
|
||||
): -1 | 0 | 1 {
|
||||
const diff = n1 * d2 - n2 * d1;
|
||||
if (diff > 0) return 1;
|
||||
if (diff < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function fractionOfQuantity(num: number, den: number, quantity: number): number {
|
||||
return (num / den) * quantity;
|
||||
}
|
||||
|
||||
export function wholeFromFraction(num: number, den: number, part: number): number {
|
||||
return (part * den) / num;
|
||||
}
|
||||
|
||||
export function isProper(num: number, den: number): boolean {
|
||||
return Math.abs(num) < Math.abs(den);
|
||||
}
|
||||
|
||||
export function toMixed(num: number, den: number): [number, number, number] {
|
||||
const whole = Math.floor(Math.abs(num) / Math.abs(den));
|
||||
const remainder = Math.abs(num) % Math.abs(den);
|
||||
const sign = (num < 0) !== (den < 0) ? -1 : 1;
|
||||
return [whole * sign, remainder, Math.abs(den)];
|
||||
}
|
||||
|
||||
export function fromMixed(whole: number, num: number, den: number): [number, number] {
|
||||
const sign = whole < 0 ? -1 : 1;
|
||||
return [sign * (Math.abs(whole) * den + num), den];
|
||||
}
|
||||
|
||||
export function isSimplified(num: number, den: number): boolean {
|
||||
return gcd(Math.abs(num), Math.abs(den)) === 1;
|
||||
}
|
||||
|
||||
export function toKatex(num: number, den: number): string {
|
||||
if (den === 1) return `${num}`;
|
||||
return `\\frac{${num}}{${den}}`;
|
||||
/**
|
||||
* Convenience KaTeX formatter for raw (num, den) pairs. Handy in the step-by-step
|
||||
* lesson views, which build up strings from loose numerators/denominators rather
|
||||
* than Fraction objects. For arithmetic, use the Fraction class instead.
|
||||
*/
|
||||
export function toKatex(n: number, d: number): string {
|
||||
return new Fraction(n, d).toKatex();
|
||||
}
|
||||
|
||||
73
lib/math/validation.test.ts
Normal file
73
lib/math/validation.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
checkFractionAnswer,
|
||||
checkIntegerAnswer,
|
||||
checkDecimalAnswer,
|
||||
checkRatioAnswer,
|
||||
checkOrderingAnswer,
|
||||
} from "./validation";
|
||||
|
||||
describe("checkIntegerAnswer", () => {
|
||||
it("solves an exact match", () => {
|
||||
expect(checkIntegerAnswer(6, 6).status).toBe("solved");
|
||||
});
|
||||
it("rejects a wrong value", () => {
|
||||
expect(checkIntegerAnswer(5, 6).status).toBe("wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFractionAnswer", () => {
|
||||
it("solves a fully simplified answer", () => {
|
||||
expect(checkFractionAnswer(1, 2, 1, 2).status).toBe("solved");
|
||||
});
|
||||
|
||||
// Regression: 5/10 is the right value but unsimplified — it must NOT be treated
|
||||
// as fully solved (this is what let the score double-count before the fix).
|
||||
it("flags a correct but unsimplified answer", () => {
|
||||
expect(checkFractionAnswer(5, 10, 1, 2).status).toBe("unsimplified");
|
||||
});
|
||||
|
||||
it("accepts any non-zero denominator for zero", () => {
|
||||
expect(checkFractionAnswer(0, 5, 0, 1).status).toBe("solved");
|
||||
});
|
||||
|
||||
it("rejects a wrong value", () => {
|
||||
expect(checkFractionAnswer(3, 4, 1, 8).status).toBe("wrong");
|
||||
});
|
||||
|
||||
it("rejects a zero denominator", () => {
|
||||
const res = checkFractionAnswer(1, 0, 1, 2);
|
||||
expect(res.status).toBe("wrong");
|
||||
if (res.status === "wrong") expect(res.message).toMatch(/denominator/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDecimalAnswer", () => {
|
||||
it("solves within tolerance", () => {
|
||||
expect(checkDecimalAnswer(3.14, 3.14).status).toBe("solved");
|
||||
});
|
||||
it("rejects outside tolerance", () => {
|
||||
expect(checkDecimalAnswer(3.2, 3.14).status).toBe("wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRatioAnswer", () => {
|
||||
it("solves an already-simplified ratio", () => {
|
||||
expect(checkRatioAnswer([1, 2], [1, 2]).status).toBe("solved");
|
||||
});
|
||||
it("flags an equivalent but unsimplified ratio", () => {
|
||||
expect(checkRatioAnswer([2, 4], [1, 2]).status).toBe("unsimplified");
|
||||
});
|
||||
it("rejects a non-equivalent ratio", () => {
|
||||
expect(checkRatioAnswer([1, 3], [1, 2]).status).toBe("wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkOrderingAnswer", () => {
|
||||
it("solves a correct ascending order", () => {
|
||||
expect(checkOrderingAnswer([-10, -8, -6, -4, -3, 5], [-10, -8, -6, -4, -3, 5]).status).toBe("solved");
|
||||
});
|
||||
it("rejects a wrong order", () => {
|
||||
expect(checkOrderingAnswer([5, -10], [-10, 5]).status).toBe("wrong");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gcd } from "./fractions";
|
||||
import { Fraction } from "./fractions";
|
||||
import { simplifyRatio } from "./ratios";
|
||||
|
||||
/** Parse a strict decimal string (digits and optional single dot). Rejects scientific notation, trailing letters, etc. */
|
||||
@@ -29,9 +29,25 @@ export function parseStrictSignedDecimal(input: string): number | null {
|
||||
return parseFloat(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of checking one submitted answer.
|
||||
* - `solved` — correct and in the required form (fully done)
|
||||
* - `unsimplified` — correct value, but still needs simplifying (a nudge, not done)
|
||||
* - `wrong` — incorrect; `message` explains why
|
||||
*
|
||||
* A single `status` field means callers branch on one thing, instead of an
|
||||
* error-prone `correct`/`simplified` boolean pair.
|
||||
*/
|
||||
export type AnswerResult =
|
||||
| { correct: true; simplified: boolean }
|
||||
| { correct: false; message: string };
|
||||
| { status: "solved" }
|
||||
| { status: "unsimplified" }
|
||||
| { status: "wrong"; message: string };
|
||||
|
||||
export const SOLVED: AnswerResult = { status: "solved" };
|
||||
export const UNSIMPLIFIED: AnswerResult = { status: "unsimplified" };
|
||||
export function wrong(message: string): AnswerResult {
|
||||
return { status: "wrong", message };
|
||||
}
|
||||
|
||||
export function checkFractionAnswer(
|
||||
userNum: number,
|
||||
@@ -41,28 +57,26 @@ export function checkFractionAnswer(
|
||||
requireSimplified: boolean = true,
|
||||
): AnswerResult {
|
||||
if (userDen === 0) {
|
||||
return { correct: false, message: "Denominator cannot be zero" };
|
||||
return wrong("Denominator cannot be zero");
|
||||
}
|
||||
|
||||
// Check mathematical equivalence
|
||||
const isEquivalent = userNum * expectedDen === expectedNum * userDen;
|
||||
const user = new Fraction(userNum, userDen);
|
||||
const expected = new Fraction(expectedNum, expectedDen);
|
||||
|
||||
if (!isEquivalent) {
|
||||
return { correct: false, message: "That's not quite right. Try again!" };
|
||||
if (!user.equals(expected)) {
|
||||
return wrong("That's not quite right. Try again!");
|
||||
}
|
||||
|
||||
// Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI.
|
||||
if (userNum === 0) {
|
||||
return { correct: true, simplified: true };
|
||||
if (user.isZero) {
|
||||
return SOLVED;
|
||||
}
|
||||
|
||||
const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
|
||||
|
||||
if (requireSimplified && !isSimplified) {
|
||||
return { correct: true, simplified: false };
|
||||
if (requireSimplified && !user.isSimplified) {
|
||||
return UNSIMPLIFIED;
|
||||
}
|
||||
|
||||
return { correct: true, simplified: true };
|
||||
return SOLVED;
|
||||
}
|
||||
|
||||
export function checkDecimalAnswer(
|
||||
@@ -71,9 +85,9 @@ export function checkDecimalAnswer(
|
||||
tolerance: number = 0.001,
|
||||
): AnswerResult {
|
||||
if (Math.abs(userValue - expectedValue) <= tolerance) {
|
||||
return { correct: true, simplified: true };
|
||||
return SOLVED;
|
||||
}
|
||||
return { correct: false, message: "That's not quite right. Try again!" };
|
||||
return wrong("That's not quite right. Try again!");
|
||||
}
|
||||
|
||||
export function checkRatioAnswer(
|
||||
@@ -82,34 +96,30 @@ export function checkRatioAnswer(
|
||||
requireSimplified: boolean = true,
|
||||
): AnswerResult {
|
||||
if (userParts.length !== expectedParts.length) {
|
||||
return { correct: false, message: "Check the number of parts in your ratio" };
|
||||
return wrong("Check the number of parts in your ratio");
|
||||
}
|
||||
|
||||
const userSimplified = simplifyRatio(userParts);
|
||||
const expectedSimplified = simplifyRatio(expectedParts);
|
||||
|
||||
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
|
||||
|
||||
if (!isEquivalent) {
|
||||
return { correct: false, message: "That's not quite right. Try again!" };
|
||||
return wrong("That's not quite right. Try again!");
|
||||
}
|
||||
|
||||
if (requireSimplified) {
|
||||
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
|
||||
return { correct: true, simplified: isAlreadySimplified };
|
||||
return isAlreadySimplified ? SOLVED : UNSIMPLIFIED;
|
||||
}
|
||||
|
||||
return { correct: true, simplified: true };
|
||||
return SOLVED;
|
||||
}
|
||||
|
||||
export function checkIntegerAnswer(
|
||||
userValue: number,
|
||||
expectedValue: number,
|
||||
): AnswerResult {
|
||||
if (userValue === expectedValue) {
|
||||
return { correct: true, simplified: true };
|
||||
}
|
||||
return { correct: false, message: "That's not quite right. Try again!" };
|
||||
return userValue === expectedValue ? SOLVED : wrong("That's not quite right. Try again!");
|
||||
}
|
||||
|
||||
export function checkOrderingAnswer(
|
||||
@@ -117,11 +127,8 @@ export function checkOrderingAnswer(
|
||||
expectedOrder: number[],
|
||||
): AnswerResult {
|
||||
if (userOrder.length !== expectedOrder.length) {
|
||||
return { correct: false, message: "Make sure you've ordered all the numbers" };
|
||||
return wrong("Make sure you've ordered all the numbers");
|
||||
}
|
||||
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
|
||||
if (isCorrect) {
|
||||
return { correct: true, simplified: true };
|
||||
}
|
||||
return { correct: false, message: "Check your ordering. Try again!" };
|
||||
return isCorrect ? SOLVED : wrong("Check your ordering. Try again!");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user