form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View 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);
});
});

View File

@@ -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();
}

View 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");
});
});

View File

@@ -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!");
}