From e2de3478d2eedf6729abce516981dd02e5bec5ce Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Wed, 28 Jan 2026 23:41:16 +0100 Subject: [PATCH 1/4] feat!: improve unit systems and quantity additions --- docs/guide-units.md | 140 +++++++--- src/index.ts | 2 + src/quantities/alternatives.ts | 23 +- src/quantities/mutations.ts | 248 ++++++++++-------- src/quantities/numeric.ts | 148 ++++++++++- src/types.ts | 19 ++ src/units/compatibility.ts | 34 ++- src/units/conversion.ts | 150 ++++++++++- src/units/definitions.ts | 28 +- src/utils/render_helpers.ts | 64 +++++ .../__snapshots__/recipe_parsing.test.ts.snap | 7 +- test/quantities_alternatives.test.ts | 33 ++- test/quantities_mutations.test.ts | 202 +++++++++++--- test/recipe_parsing.test.ts | 24 +- test/render_helpers.test.ts | 73 +++++- test/units_conversion.test.ts | 100 ++++++- test/units_definitions.test.ts | 2 +- test/units_numeric.test.ts | 165 +++++++++++- test/utils_numeric.test.ts | 3 +- 19 files changed, 1240 insertions(+), 225 deletions(-) diff --git a/docs/guide-units.md b/docs/guide-units.md index 380f38a..12c3fa4 100644 --- a/docs/guide-units.md +++ b/docs/guide-units.md @@ -6,42 +6,6 @@ outline: deep When adding quantities of [referenced ingredients](/guide-extensions.html#reference-to-an-existing-ingredient) together for the ingredients list (i.e the [ingredients](/api/classes/Recipe.html#ingredients) properties of a `Recipe`), the parser tries its best to add apples to apples. -## Conversion rules - -The conversion behavior depends on the unit systems involved and whether a `unit system` is specified in the recipe metadata: - -1. **Same system** → The largest unit of that system is used. Example: `1%kg` + `100%g` becomes `1.1%kg` - -2. **Recipe has `unit system` metadata** → Convert to the specified system using the unit that supports it. Example with `unit system: UK`: `1%cup` + `1%fl-oz` becomes `1.1%cup` (using UK measurements) - -3. **One unit is metric (no context)** → Convert to the metric unit. Example: `1%lb` + `500%g` becomes `953.592%g` - -4. **Both units are ambiguous (no context)** → Default to US system, use larger unit. Example: `1%cup` + `1%fl-oz` becomes `1.125%cup` (US) - -5. **Different non-metric systems (no context)** → Convert to metric. Example: `1%go` + `1%cup` becomes `0.417%l` - -6. **Incompatible units** (e.g., text values, or volume and mass) → Quantities won't be added and will be kept separate. - -## Specifying a unit system - -You can specify a unit system in your recipe metadata to control how ambiguous units are resolved: - -```cooklang ---- -unit system: UK ---- -Add @water{1%cup} and some more @&water{1%fl-oz} -``` - -Valid values (case insensitive) are: `metric`, `US`, `UK`, `JP` (see [Unit Reference Table](#unit-reference-table) below) - -## Ambiguous units - -Some units like `cup`, `tsp`, and `tbsp` have different sizes depending on the measurement system. These are marked as **ambiguous** and have system-specific conversion factors in the `toBaseBySystem` column. - -When no `unit system` is specified: -- Units with a **metric** definition (like `tsp`, `tbsp`) default to metric -- Units without a metric definition (like `cup`, `pint`) default to US ## Unit reference table @@ -93,3 +57,107 @@ The following table shows all recognized units: | Name | Type | System | Aliases | To Base (default) | To Base by System | | ----- | ----- | ------ | ---------- | ----------------- | ----------------- | | piece | count | metric | pieces, pc | 1 | | + +## Ambiguous units + +Some units like `cup`, `tsp`, and `tbsp` have different sizes depending on the measurement system. These are marked as **ambiguous** and have system-specific conversion factors in the `toBaseBySystem` column. + +## Specifying a unit system + +You can specify a unit system in your recipe metadata to control how ambiguous units are resolved: + +```cooklang +--- +unit system: UK +--- +Add @water{1%cup} and some more @&water{1%fl-oz} +``` + +Valid values (case insensitive) are: `metric`, `US`, `UK`, `JP` (see [Unit Reference Table](#unit-reference-table) above) + +When no `unit system` is specified: +- Units with a **metric** definition (like `tsp`, `tbsp`) default to metric +- Units without a metric definition (like `cup`, `pint`) default to US + +## Adding quantities + +When quantities are added together (e.g., from [referenced ingredients](/guide-extensions.html#reference-to-an-existing-ingredient)), the parser selects the most appropriate unit for the result. This does **not** apply to individual quantities—`@flour{500%g}` will always parse as `500 g`. + +### System selection + +The target system depends on the input units and recipe metadata: + +1. **Recipe has `unit system` metadata** → Use the specified system. Example with `unit system: UK`: `1%cup` + `1%fl-oz` becomes `11%fl-oz` + +Otherwise: + +2. **One unit is metric** → Convert to metric. Example: `1%lb` + `500%g` becomes `954%g` + +3. **Both units are ambiguous and US-compatible** → Use US system. Example: `1%cup` + `1%fl-oz` becomes `9%fl-oz` + +4. **Different non-metric systems** → Convert to metric. Example: `1%go` + `1%cup` becomes `417%ml` + +5. **Incompatible units** (e.g., text values, or volume and mass) → Quantities won't be added and will be kept separate. + +### Unit selection algorithm + +Once the system is determined, the best unit is selected based on: + +1. **Candidates units**: + - Units that belong to that system are considered potential candidates for best unit. The JP system also includes all the metric units. Certain units are disabled as not commonly used, by setting `isBestUnit` to false (default: true) + - The units of the input quantities are restored into that list, as they are actually already used in the recipe. + +2. **Valid range**: A value is considered "in range" for a unit if: + - It's between 1 and the unit's `maxValue` (default: 999), OR + - It's less than 1 but can be approximated as a fraction (for units with fractions enabled) + +::: info Example: fraction-aware selection +With US units, a value of 1.7 ml (~0.345 tsp) will select `tsp` because: +- 0.345 ≈ 1/3, which is a valid fraction (denominator 3 is allowed) +- `tsp` has `fractions.enabled: true` +- Therefore 0.345 tsp is considered "in range" and is the smallest valid option +::: + +3. **Selection priority** (among in-range candidates): + - Smallest integer in the input unit family. Examples: + - `1 cup + 1 cup` -> `2 cup` and not 1 pint + - `0.5 pint + 0.5 pint` -> `1 pint` and not 2 cup + - `2 cup + 1 pint` -> `2 pint` and not 4 cup + - Smallest integers in any compatible family + - Smallest non-integer value in range + +4. **Fallback**: If no candidate is in range, the unit closest to the valid range is selected. This is in particular used for potential edge cases with values above 999 liters or 999 gallons. + +### Per-unit configuration + +Each unit can have custom configuration: + +| Config | Description | Default | +| ------ | ----------- | ------- | +| `isBestUnit` | Whether a unit is eligible for best unit | true | +| `maxValue` | Maximum value before upgrading to a larger unit | 999 | +| `fractions.enabled` | Whether to approximate decimals as fractions | false | +| `fractions.denominators` | Allowed denominators for fraction approximation | [2, 3, 4, 8] | +| `fractions.maxWhole` | Maximum whole number in mixed fraction | 4 | + +Complete configuration for all units. + +| Unit | maxValue | fractions.enabled | fractions.denominators | isBestUnit | +| ------ | -------- | ----------------- | ---------------------- | ---------- | +| g | 999 | — | — | ✓ | +| kg | — | — | — | ✓ | +| oz | 31 | ✓ | [2, 3, 4, 8] | ✓ | +| lb | — | ✓ | [2, 3, 4, 8] | ✓ | +| ml | 999 | — | — | ✓ | +| cl | — | — | — | — | +| dl | — | — | — | — | +| l | — | — | — | ✓ | +| go | 10 | — | — | ✓ | +| tsp | 5 | ✓ | [2, 3, 4] | ✓ | +| tbsp | 4 | ✓ | [2, 3, 4] | ✓ | +| fl-oz | 15 | ✓ | [2, 3, 4, 8] | ✓ | +| cup | 4 | ✓ | [2, 3, 4, 8] | ✓ | +| pint | 3 | ✓ | [2, 3, 4, 8] | — | +| quart | 3 | ✓ | [2, 3, 4, 8] | — | +| gallon | — | ✓ | [2, 3, 4, 8] | ✓ | +| piece | 999 | — | — | ✓ | diff --git a/src/index.ts b/src/index.ts index a03d0b4..c825460 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { import { isAlternativeSelected, isGroupedItem, + renderFractionAsVulgar, formatNumericValue, formatSingleValue, formatQuantity, @@ -42,6 +43,7 @@ import { export { isAlternativeSelected, isGroupedItem, + renderFractionAsVulgar, formatNumericValue, formatSingleValue, formatQuantity, diff --git a/src/quantities/alternatives.ts b/src/quantities/alternatives.ts index e1e5992..807fbef 100644 --- a/src/quantities/alternatives.ts +++ b/src/quantities/alternatives.ts @@ -303,6 +303,7 @@ export function addQuantitiesOrGroups( export function regroupQuantitiesAndExpandEquivalents( sum: QuantityWithUnitDef | FlatAndGroup, unitsLists: QuantityWithUnitDef[][], + system?: SpecificUnitSystem, ): (QuantityWithExtendedUnit | MaybeNestedOrGroup)[] { const sumQuantities = isAndGroup(sum) ? sum.and : [sum]; const result: ( @@ -341,9 +342,21 @@ export function regroupQuantitiesAndExpandEquivalents( } return main.reduce((acc, v) => { const mainInList = findCompatibleQuantityWithinList(list, v)!; + // If the sum unit differs from the original list unit (e.g., cups → gallon after best-unit upgrade), + // we need to convert the sum value to the equivalent amount in the original unit before scaling. + const conversionRatio = getBaseUnitRatio(v, mainInList); + const valueInOriginalUnit = Big(getAverageValue(v.quantity)).times( + conversionRatio, + ); const newValue: QuantityWithExtendedUnit = { quantity: multiplyQuantityValue( - v.quantity, + { + type: "fixed", + value: { + type: "decimal", + decimal: valueInOriginalUnit.toNumber(), + }, + }, Big(getAverageValue(equiv.quantity)).div( getAverageValue(mainInList.quantity), ), @@ -352,7 +365,7 @@ export function regroupQuantitiesAndExpandEquivalents( if (equiv.unit && !isNoUnit(equiv.unit)) { newValue.unit = { name: equiv.unit.name }; } - return addQuantities(acc, newValue); + return addQuantities(acc, newValue, system); }, initialValue); }); @@ -396,7 +409,11 @@ export function addEquivalentsAndSimplify( // Step 1+2+3: find equivalents, reduce groups and add quantities const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system); // Step 4: regroup and expand equivalents per group - const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists); + const regrouped = regroupQuantitiesAndExpandEquivalents( + sum, + unitsLists, + system, + ); if (regrouped.length === 1) { return toPlainUnit(regrouped[0]!); } else { diff --git a/src/quantities/mutations.ts b/src/quantities/mutations.ts index 84f5ebf..993162f 100644 --- a/src/quantities/mutations.ts +++ b/src/quantities/mutations.ts @@ -12,15 +12,15 @@ import type { MaybeNestedAndGroup, SpecificUnitSystem, } from "../types"; -import { - units, - normalizeUnit, - resolveUnit, - isNoUnit, -} from "../units/definitions"; -import { getToBase } from "../units/conversion"; +import { normalizeUnit, resolveUnit, isNoUnit } from "../units/definitions"; +import { getToBase, findBestUnit } from "../units/conversion"; import { areUnitsConvertible } from "../units/compatibility"; -import { addNumericValues, multiplyQuantityValue } from "./numeric"; +import { + addNumericValues, + getNumericValue, + formatOutputValue, + getAverageValue, +} from "./numeric"; import { CannotAddTextValueError, IncompatibleUnitsError } from "../errors"; import { isAndGroup, isOrGroup, isQuantity } from "../utils/type_guards"; @@ -69,30 +69,6 @@ export function normalizeAllUnits( } } -/** - * Convert a quantity value from one unit to another. - * - * @param value - The quantity value to convert - * @param def - The source unit definition - * @param targetDef - The target unit definition - * @param system - Optional system context for resolving ambiguous units - * @returns The converted quantity value - */ -export const convertQuantityValue = ( - value: FixedValue | Range, - def: UnitDefinition, - targetDef: UnitDefinition, - system?: SpecificUnitSystem, -): FixedValue | Range => { - if (def.name === targetDef.name) return value; - - const sourceToBase = getToBase(def, system); - const targetToBase = getToBase(targetDef, system); - const factor = sourceToBase / targetToBase; - - return multiplyQuantityValue(value, factor); -}; - /** * Get the default / neutral quantity which can be provided to addQuantity * for it to return the other value as result @@ -155,6 +131,13 @@ export function addQuantityValues( /** * Adds two quantities, returning the result in the most appropriate unit. * + * The "best unit" is selected based on: + * 1. Filter candidates to units where `isBestUnit !== false` + * 2. Use per-unit `maxValue` thresholds (prefer largest unit where value ≥ 1 and ≤ maxValue) + * 3. Prefer integers in input unit family + * 4. Prefer integers in any unit family + * 5. If no integers, prefer smallest value in range + * * @param q1 - The first quantity * @param q2 - The second quantity * @param system - Optional system context for resolving ambiguous units @@ -202,13 +185,28 @@ export function addQuantities( return addQuantityValuesAndSetUnit(v1, v2, q1.unit); // Prefer q1's unit } - // Case 3: the two quantities have the exact same unit + // Case 3: the two quantities have the exact same unit (or both no unit) + if (!q1.unit && !q2.unit) { + return addQuantityValuesAndSetUnit(v1, v2, q1.unit); + } if ( - (!q1.unit && !q2.unit) || - (q1.unit && - q2.unit && - q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) + q1.unit && + q2.unit && + q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase() ) { + // Same unit - check if we should upgrade to a larger unit (e.g., 1200g → 1.2kg) + if (unit1Def) { + // Known unit type - use findBestUnit to potentially upgrade + const effectiveSystem = + system ?? + (["metric", "JP"].includes(unit1Def.system) + ? (unit1Def.system as "metric" | "JP") + : "US"); + return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [ + unit1Def, + ]); + } + // Unknown unit type - just add values, keep unit return addQuantityValuesAndSetUnit(v1, v2, q1.unit); } @@ -225,84 +223,41 @@ export function addQuantities( // Determine the effective system for conversion of ambiguous units let effectiveSystem = system; - // If no system provided, try to infer from non-ambiguous unit + // If no system provided, infer based on the input units: + // 1. Prefer metric if either unit is metric + // 2. If both are ambiguous and US-compatible, use US + // 3. Default to metric // v8 ignore else -- @preserve if (!effectiveSystem) { - if (unit1Def.system !== "ambiguous") { - effectiveSystem = unit1Def.system; - } else if (unit2Def.system !== "ambiguous") { - effectiveSystem = unit2Def.system; + if (unit1Def.system === "metric" || unit2Def.system === "metric") { + effectiveSystem = "metric"; + } else { + // TODO remove if v8 marker if JP is augmented with more than one unit */ + // v8 ignore if -- @preserve + if (unit1Def.system === "JP" && unit2Def.system === "JP") { + effectiveSystem = "JP"; + } else { + // Check if both units are US-compatible + const unit1SupportsUS = + unit1Def.system === "US" || + (unit1Def.system === "ambiguous" && + unit1Def.toBaseBySystem && + "US" in unit1Def.toBaseBySystem); + const unit2SupportsUS = + unit2Def.system === "US" || + (unit2Def.system === "ambiguous" && + unit2Def.toBaseBySystem && + "US" in unit2Def.toBaseBySystem); + effectiveSystem = + unit1SupportsUS && unit2SupportsUS ? "US" : "metric"; + } } - // If both are ambiguous, effectiveSystem remains undefined and we use defaults } - let targetUnitDef: UnitDefinition; - let conversionSystem: SpecificUnitSystem | undefined; - - // Resolve effective systems for each unit - const sys1 = - unit1Def.system === "ambiguous" - ? unit1Def.toBaseBySystem?.[effectiveSystem!] !== undefined - ? effectiveSystem - : undefined - : unit1Def.system; - - const sys2 = - unit2Def.system === "ambiguous" - ? unit2Def.toBaseBySystem?.[effectiveSystem!] !== undefined - ? effectiveSystem - : undefined - : unit2Def.system; - - if (sys1 === sys2 && sys1 !== undefined) { - // Case 4.1: Same system - use larger unit of that system - const toBase1 = getToBase(unit1Def, sys1); - const toBase2 = getToBase(unit2Def, sys1); - targetUnitDef = toBase1 >= toBase2 ? unit1Def : unit2Def; - conversionSystem = sys1; - } else if (system !== undefined) { - // Case 4.2: Context system is set - use the unit that supports it (or larger if both do) - const unit1SupportsSystem = - unit1Def.system === system || - (unit1Def.system === "ambiguous" && - unit1Def.toBaseBySystem?.[system] !== undefined); - - targetUnitDef = unit1SupportsSystem ? unit1Def : unit2Def; - conversionSystem = system; - } else if (sys1 === "metric" || sys2 === "metric") { - // Case 4.3: No context system, but one unit is metric - use that metric unit - targetUnitDef = sys1 === "metric" ? unit1Def : unit2Def; - conversionSystem = "metric"; - } else if (sys1 === undefined && sys2 === undefined) { - // Case 4.4: Both units are ambiguous with no context - default to US, use larger unit - const toBase1 = getToBase(unit1Def, "US"); - const toBase2 = getToBase(unit2Def, "US"); - targetUnitDef = toBase1 >= toBase2 ? unit1Def : unit2Def; - conversionSystem = "US"; - } else { - // Case 4.5: Different non-metric systems (e.g., JP + US/UK) - convert to metric - const metricUnits = units.filter( - (u) => u.type === unit1Def.type && u.system === "metric", - ); - targetUnitDef = metricUnits[metricUnits.length - 1]!; - conversionSystem = "metric"; - } - - const convertedV1 = convertQuantityValue( - v1, + return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [ unit1Def, - targetUnitDef, - conversionSystem, - ); - const convertedV2 = convertQuantityValue( - v2, unit2Def, - targetUnitDef, - conversionSystem, - ); - const targetUnit: Unit = { name: targetUnitDef.name }; - - return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit); + ]); } // Case 5: the two quantities have different units of unknown type @@ -312,6 +267,83 @@ export function addQuantities( ); } +/** + * Helper function to add two quantities and find the best unit for the result. + */ +function addAndFindBestUnit( + v1: FixedValue | Range, + v2: FixedValue | Range, + unit1Def: UnitDefinition, + unit2Def: UnitDefinition, + system: SpecificUnitSystem, + inputUnits: UnitDefinition[], +): QuantityWithExtendedUnit { + // Convert both values to base units and sum + const toBase1 = getToBase(unit1Def, system); + const toBase2 = getToBase(unit2Def, system); + + // Get the sum in base units + let sumInBase: number; + if (v1.type === "fixed" && v2.type === "fixed") { + const val1 = getNumericValue(v1.value as DecimalValue | FractionValue); + const val2 = getNumericValue(v2.value as DecimalValue | FractionValue); + sumInBase = val1 * toBase1 + val2 * toBase2; + } else { + // Handle ranges by using average for best unit selection + const avg1 = getAverageValue(v1) as number; + const avg2 = getAverageValue(v2) as number; + sumInBase = avg1 * toBase1 + avg2 * toBase2; + } + + // Find the best unit + const { unit: bestUnit, value: bestValue } = findBestUnit( + sumInBase, + unit1Def.type, + system, + inputUnits, + ); + + // Format the value (uses fractions if unit supports them) + const formattedValue = formatOutputValue(bestValue, bestUnit); + + // Handle ranges: scale the range to the best unit + if (v1.type === "range" || v2.type === "range") { + const r1 = + v1.type === "range" + ? v1 + : { type: "range" as const, min: v1.value, max: v1.value }; + const r2 = + v2.type === "range" + ? v2 + : { type: "range" as const, min: v2.value, max: v2.value }; + + const minInBase = + getNumericValue(r1.min as DecimalValue | FractionValue) * toBase1 + + getNumericValue(r2.min as DecimalValue | FractionValue) * toBase2; + const maxInBase = + getNumericValue(r1.max as DecimalValue | FractionValue) * toBase1 + + getNumericValue(r2.max as DecimalValue | FractionValue) * toBase2; + + const bestToBase = getToBase(bestUnit, system); + const minValue = minInBase / bestToBase; + const maxValue = maxInBase / bestToBase; + + return { + quantity: { + type: "range", + min: formatOutputValue(minValue, bestUnit), + max: formatOutputValue(maxValue, bestUnit), + }, + unit: { name: bestUnit.name }, + }; + } + + return { + quantity: { type: "fixed", value: formattedValue }, + unit: { name: bestUnit.name }, + }; +} + export function toPlainUnit( quantity: | QuantityWithExtendedUnit diff --git a/src/quantities/numeric.ts b/src/quantities/numeric.ts index f7f9b24..ba3b6c9 100644 --- a/src/quantities/numeric.ts +++ b/src/quantities/numeric.ts @@ -1,5 +1,18 @@ import Big from "big.js"; -import type { DecimalValue, FractionValue, FixedValue, Range } from "../types"; +import type { + DecimalValue, + FractionValue, + FixedValue, + Range, + UnitDefinition, +} from "../types"; + +/** Default allowed denominators for fraction approximation */ +export const DEFAULT_DENOMINATORS = [2, 3, 4, 8]; +/** Default accuracy tolerance for fraction approximation (5%) */ +export const DEFAULT_FRACTION_ACCURACY = 0.05; +/** Default maximum whole number in mixed fraction before falling back to decimal */ +export const DEFAULT_MAX_WHOLE = 4; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -28,6 +41,75 @@ export function simplifyFraction( } } +/** + * Approximates a decimal value as a fraction within a given accuracy tolerance. + * Returns an improper fraction (e.g., 1.25 → \{ num: 5, den: 4 \}) or null if no good match. + * + * @param value - The decimal value to approximate + * @param denominators - Allowed denominators (default: [2, 3, 4, 8]) + * @param accuracy - Maximum relative error tolerance (default: 0.05 = 5%) + * @param maxWhole - Maximum whole number part before returning null (default: 4) + * @returns FractionValue if a good approximation exists, null otherwise + */ +export function approximateFraction( + value: number, + denominators: number[] = DEFAULT_DENOMINATORS, + accuracy: number = DEFAULT_FRACTION_ACCURACY, + maxWhole: number = DEFAULT_MAX_WHOLE, +): FractionValue | null { + // Only handle positive values + if (value <= 0 || !Number.isFinite(value)) { + return null; + } + + // Check if whole part exceeds maxWhole + const wholePart = Math.floor(value); + if (wholePart > maxWhole) { + return null; + } + + // If value is very close to an integer, return null (use decimal instead) + const fractionalPart = value - wholePart; + if (fractionalPart < 1e-4) { + return null; + } + + let bestFraction: { num: number; den: number; error: number } | null = null; + + for (const den of denominators) { + // Find the numerator that gives the closest approximation + const exactNum = value * den; + const roundedNum = Math.round(exactNum); + + // Skip if this would give 0 numerator + if (roundedNum === 0) continue; + + const approximatedValue = roundedNum / den; + const relativeError = Math.abs(approximatedValue - value) / value; + + // Check if within accuracy tolerance + if (relativeError <= accuracy) { + // Prefer smaller denominators (they come first in the array) + // and smaller error for same denominator + if (!bestFraction || relativeError < bestFraction.error) { + bestFraction = { num: roundedNum, den, error: relativeError }; + } + } + } + + if (!bestFraction) { + return null; + } + + // Simplify the fraction + const commonDivisor = gcd(bestFraction.num, bestFraction.den); + return { + type: "fraction", + num: bestFraction.num / commonDivisor, + den: bestFraction.den / commonDivisor, + }; +} + export function getNumericValue(v: DecimalValue | FractionValue): number { if (v.type === "decimal") { return v.decimal; @@ -97,15 +179,71 @@ export function addNumericValues( } } +/** + * Rounds a numeric value to the specified number of significant digits. + * If the integer part has 4+ digits, preserves the full integer (rounds to nearest integer). + * @param v - The value to round (decimal or fraction) + * @param precision - Number of significant digits (default 3) + * @returns A DecimalValue with the rounded result + */ export const toRoundedDecimal = ( v: DecimalValue | FractionValue, precision: number = 3, ): DecimalValue => { const value = v.type === "decimal" ? v.decimal : v.num / v.den; - return { - type: "decimal", - decimal: Math.round(value * 10 ** precision) / 10 ** precision, - }; + + // Handle zero specially + if (value === 0) { + return { type: "decimal", decimal: 0 }; + } + + const absValue = Math.abs(value); + + // If integer part has 4+ digits, round to nearest integer + if (absValue >= 1000) { + return { type: "decimal", decimal: Math.round(value) }; + } + + // Calculate the order of magnitude for significant digits + const magnitude = Math.floor(Math.log10(absValue)); + const scale = Math.pow(10, precision - 1 - magnitude); + const rounded = Math.round(value * scale) / scale; + + return { type: "decimal", decimal: rounded }; +}; + +/** + * Formats a numeric value for output, using fractions if the unit supports them + * and the value can be well-approximated as a fraction, otherwise as a rounded decimal. + * + * @param value - The decimal value to format + * @param unitDef - The unit definition (to check fraction config) + * @param precision - Number of significant digits for decimal rounding (default 3) + * @returns A DecimalValue or FractionValue + */ +export const formatOutputValue = ( + value: number, + unitDef: UnitDefinition, + precision: number = 3, +): DecimalValue | FractionValue => { + // Check if unit has fractions enabled + if (unitDef.fractions?.enabled) { + const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS; + const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE; + + const fraction = approximateFraction( + value, + denominators, + DEFAULT_FRACTION_ACCURACY, + maxWhole, + ); + if (fraction) { + return fraction; + } + } + + // Fall back to rounded decimal + return toRoundedDecimal({ type: "decimal", decimal: value }, precision); }; export function multiplyQuantityValue( diff --git a/src/types.ts b/src/types.ts index 955e493..7fba107 100644 --- a/src/types.ts +++ b/src/types.ts @@ -820,6 +820,25 @@ export interface UnitDefinition extends Unit { toBase: number; /** For ambiguous units: conversion factors for each possible system */ toBaseBySystem?: ToBaseBySystem; + /** Whether this unit is a candidate for "best unit" selection (default: true) */ + isBestUnit?: boolean; + /** Maximum value before upgrading to a larger unit (default: 999) */ + maxValue?: number; + /** Fraction display configuration */ + fractions?: UnitFractionConfig; +} + +/** + * Configuration for fraction display on a unit + * @category Types + */ +export interface UnitFractionConfig { + /** Whether to approximate decimals as fractions for this unit */ + enabled: boolean; + /** Allowed denominators (default: [2, 3, 4, 8]) */ + denominators?: number[]; + /** Maximum whole number in mixed fraction before falling back to decimal (default: 4) */ + maxWhole?: number; } /** diff --git a/src/units/compatibility.ts b/src/units/compatibility.ts index 6f38aee..50ee14d 100644 --- a/src/units/compatibility.ts +++ b/src/units/compatibility.ts @@ -1,4 +1,8 @@ -import type { UnitDefinition, UnitDefinitionLike } from "../types"; +import type { + SpecificUnitSystem, + UnitDefinition, + UnitDefinitionLike, +} from "../types"; /** * Check if two unit-like objects are compatible for grouping. @@ -61,3 +65,31 @@ export function areUnitsConvertible( // Same type = compatible (cross-system conversion is allowed) return u1.type === u2.type; } + +/** + * Check if a unit is compatible with a given system. + * - Metric units are compatible with metric + * - Ambiguous units are compatible if they have toBaseBySystem entry for the system + * - Units of the specified system are always compatible + */ +export function isUnitCompatibleWithSystem( + unit: UnitDefinition, + system: SpecificUnitSystem, +): boolean { + if (unit.system === system) return true; + if (unit.system === "ambiguous") { + // Ambiguous units with toBaseBySystem are compatible only with systems they support + /* v8 ignore else -- @preserve */ + if (unit.toBaseBySystem) { + return system in unit.toBaseBySystem; + } + // Ambiguous units without specific system support are compatible with metric by default + /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */ + if (system === "metric") return true; + } + /* v8 ignore else -- @preserve */ + if (unit.system === "metric" && system === "JP") { + return true; + } + return false; +} diff --git a/src/units/conversion.ts b/src/units/conversion.ts index 73e0aa7..e4cffb2 100644 --- a/src/units/conversion.ts +++ b/src/units/conversion.ts @@ -1,7 +1,153 @@ import Big from "big.js"; import type { QuantityWithUnitDef } from "../types"; -import { getAverageValue } from "../quantities/numeric"; -import { UnitDefinition, SpecificUnitSystem } from "../types"; +import { + getAverageValue, + approximateFraction, + DEFAULT_DENOMINATORS, + DEFAULT_FRACTION_ACCURACY, + DEFAULT_MAX_WHOLE, +} from "../quantities/numeric"; +import { UnitDefinition, SpecificUnitSystem, UnitType } from "../types"; +import { isUnitCompatibleWithSystem } from "./compatibility"; +import { units } from "./definitions"; + +const EPSILON = 0.01; +const DEFAULT_MAX_VALUE = 999; + +/** + * Check if a value is "close enough" to an integer (within epsilon). + */ +function isCloseToInteger(value: number): boolean { + return Math.abs(value - Math.round(value)) < EPSILON; +} + +/** + * Get the maximum value threshold for a unit. + * Beyond this value, we should upgrade to a larger unit. + */ +function getMaxValue(unit: UnitDefinition): number { + return unit.maxValue ?? DEFAULT_MAX_VALUE; +} + +/** + * Check if a value is in the valid range for a unit. + * A value is valid if: + * - It's \>= 1 AND \<= maxValue, OR + * - It's \< 1 AND can be approximated as a fraction (for units with fractions enabled) + */ +function isValueInRange(value: number, unit: UnitDefinition): boolean { + const maxValue = getMaxValue(unit); + + // Standard range: 1 to maxValue + if (value >= 1 && value <= maxValue) { + return true; + } + + // Fraction range: values < 1 that can be approximated as fractions + if (value > 0 && value < 1 && unit.fractions?.enabled) { + const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS; + const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE; + const fraction = approximateFraction( + value, + denominators, + DEFAULT_FRACTION_ACCURACY, + maxWhole, + ); + return fraction !== null; + } + + return false; +} + +/** + * Find the best unit for displaying a quantity. + * + * Algorithm: + * 1. Get all candidate units of the same type that are compatible with the system + * 2. Filter to candidates where value \>= 1 and value \<= maxValue (per-unit threshold) + * 3. Only consider units with isBestUnit !== false + * 4. Score: prefer integers in input family → integers in any family → smallest in range + * 5. If none in range, pick the one closest to the range + * + * @param valueInBase - The value in base units (e.g., grams for mass, mL for volume) + * @param unitType - The type of unit (mass, volume, count) + * @param system - The system to use for conversion (metric, US, UK, JP) + * @param inputUnits - The original input units (used as preferred "family") + * @returns The best unit definition and the converted value + */ +export function findBestUnit( + valueInBase: number, + unitType: UnitType, + system: SpecificUnitSystem, + inputUnits: UnitDefinition[], +): { unit: UnitDefinition; value: number } { + const inputUnitNames = new Set(inputUnits.map((u) => u.name)); + // Get all candidate units of the same type compatible with the system, including input units + const candidates = units.filter( + (u) => + u.type === unitType && + isUnitCompatibleWithSystem(u, system) && + (u.isBestUnit !== false || inputUnitNames.has(u.name)), + ); + + /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */ + if (candidates.length === 0) { + // Fallback: shouldn't happen, but return first input unit + const fallbackUnit = inputUnits[0]!; + return { + unit: fallbackUnit, + value: valueInBase / getToBase(fallbackUnit, system), + }; + } + /* v8 ignore stop */ + + // Calculate value for each candidate + const candidatesWithValues = candidates.map((unit) => ({ + unit, + value: valueInBase / getToBase(unit, system), + })); + + // Filter to valid range (including fraction-representable values), only for best-unit candidates + const inRange = candidatesWithValues.filter((c) => + isValueInRange(c.value, c.unit), + ); + + if (inRange.length > 0) { + // First priority: integers in input family + const integersInInputFamily = inRange.filter( + (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name), + ); + if (integersInInputFamily.length > 0) { + // Return smallest integer in input family + return integersInInputFamily.sort((a, b) => a.value - b.value)[0]!; + } + + // Second priority: integers in any family (prefer system-appropriate units) + const integersAny = inRange.filter((c) => isCloseToInteger(c.value)); + if (integersAny.length > 0) { + // Sort by value + return integersAny.sort((a, b) => a.value - b.value)[0]!; + } + + // Third priority: smallest value in range (prioritizing input family) + return inRange.sort((a, b) => { + // Prioritize input family + const aInFamily = inputUnitNames.has(a.unit.name) ? 0 : 1; + const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1; + if (aInFamily !== bInFamily) return aInFamily - bInFamily; + // Then by smallest value + return a.value - b.value; + })[0]!; + } + + return candidatesWithValues.sort((a, b) => { + const aMaxValue = getMaxValue(a.unit); + const bMaxValue = getMaxValue(b.unit); + const aDistance = a.value < 1 ? 1 - a.value : a.value - aMaxValue; + const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue; + return aDistance - bDistance; + })[0]!; +} export function getUnitRatio(q1: QuantityWithUnitDef, q2: QuantityWithUnitDef) { const q1Value = getAverageValue(q1.quantity); diff --git a/src/units/definitions.ts b/src/units/definitions.ts index 5c974ef..ad99368 100644 --- a/src/units/definitions.ts +++ b/src/units/definitions.ts @@ -9,6 +9,7 @@ export const units: UnitDefinition[] = [ system: "metric", aliases: ["gram", "grams", "grammes"], toBase: 1, + maxValue: 999, }, { name: "kg", @@ -25,6 +26,8 @@ export const units: UnitDefinition[] = [ aliases: ["ounce", "ounces"], toBase: 28.3495, // default: US (same as UK) toBaseBySystem: { US: 28.3495, UK: 28.3495 }, + maxValue: 31, // 16 oz = 1 lb, allow a bit more + fractions: { enabled: true }, }, { name: "lb", @@ -33,6 +36,7 @@ export const units: UnitDefinition[] = [ aliases: ["pound", "pounds"], toBase: 453.592, // default: US (same as UK) toBaseBySystem: { US: 453.592, UK: 453.592 }, + fractions: { enabled: true }, }, // Volume (Metric) @@ -42,6 +46,7 @@ export const units: UnitDefinition[] = [ system: "metric", aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"], toBase: 1, + maxValue: 999, }, { name: "cl", @@ -49,6 +54,7 @@ export const units: UnitDefinition[] = [ system: "metric", aliases: ["centiliter", "centiliters", "centilitre", "centilitres"], toBase: 10, + isBestUnit: false, // exists but not a "best" candidate }, { name: "dl", @@ -56,6 +62,7 @@ export const units: UnitDefinition[] = [ system: "metric", aliases: ["deciliter", "deciliters", "decilitre", "decilitres"], toBase: 100, + isBestUnit: false, // exists but not a "best" candidate }, { name: "l", @@ -72,6 +79,7 @@ export const units: UnitDefinition[] = [ system: "JP", aliases: ["gou", "goo", "合", "rice cup"], toBase: 180, + maxValue: 10, }, // Volume (Ambiguous: metric/US/UK) @@ -81,7 +89,9 @@ export const units: UnitDefinition[] = [ system: "ambiguous", aliases: ["teaspoon", "teaspoons"], toBase: 5, // default: metric - toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919 }, + toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 }, + maxValue: 5, // 3 tsp = 1 tbsp (but allow a bit more) + fractions: { enabled: true, denominators: [2, 3, 4] }, }, { name: "tbsp", @@ -89,7 +99,9 @@ export const units: UnitDefinition[] = [ system: "ambiguous", aliases: ["tablespoon", "tablespoons"], toBase: 15, // default: metric - toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758 }, + toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 }, + maxValue: 4, // ~16 tbsp = 1 cup + fractions: { enabled: true, denominators: [2, 3, 4] }, }, // Volume (Ambiguous: US/UK only) @@ -100,6 +112,8 @@ export const units: UnitDefinition[] = [ aliases: ["fluid ounce", "fluid ounces"], toBase: 29.5735, // default: US toBaseBySystem: { US: 29.5735, UK: 28.4131 }, + maxValue: 15, // 8 fl-oz ~ 1 cup, allow more + fractions: { enabled: true }, }, { name: "cup", @@ -108,6 +122,8 @@ export const units: UnitDefinition[] = [ aliases: ["cups"], toBase: 236.588, // default: US toBaseBySystem: { US: 236.588, UK: 284.131 }, + maxValue: 15, // upgrade to gallons above 15 cups + fractions: { enabled: true }, }, { name: "pint", @@ -116,6 +132,9 @@ export const units: UnitDefinition[] = [ aliases: ["pints"], toBase: 473.176, // default: US toBaseBySystem: { US: 473.176, UK: 568.261 }, + maxValue: 3, // 2 pints = 1 quart + fractions: { enabled: true }, + isBestUnit: false, // exists but not a "best" candidate }, { name: "quart", @@ -124,6 +143,9 @@ export const units: UnitDefinition[] = [ aliases: ["quarts"], toBase: 946.353, // default: US toBaseBySystem: { US: 946.353, UK: 1136.52 }, + maxValue: 3, // 4 quarts = 1 gallon + fractions: { enabled: true }, + isBestUnit: false, // exists but not a "best" candidate }, { name: "gallon", @@ -132,6 +154,7 @@ export const units: UnitDefinition[] = [ aliases: ["gallons"], toBase: 3785.41, // default: US toBaseBySystem: { US: 3785.41, UK: 4546.09 }, + fractions: { enabled: true }, }, // Count units (no conversion, but recognized as a type) @@ -141,6 +164,7 @@ export const units: UnitDefinition[] = [ system: "metric", aliases: ["pieces", "pc"], toBase: 1, + maxValue: 999, }, ]; diff --git a/src/utils/render_helpers.ts b/src/utils/render_helpers.ts index 1683248..abca511 100644 --- a/src/utils/render_helpers.ts +++ b/src/utils/render_helpers.ts @@ -16,10 +16,68 @@ import { Recipe } from "../classes/recipe"; // Quantity Formatting Helpers // ============================================================================ +/** + * Map of common fractions to their Unicode vulgar fraction characters. + */ +const VULGAR_FRACTIONS: Record = { + "1/2": "½", + "1/3": "⅓", + "2/3": "⅔", + "1/4": "¼", + "3/4": "¾", + "1/8": "⅛", + "3/8": "⅜", + "5/8": "⅝", + "7/8": "⅞", +}; + +/** + * Render a fraction using Unicode vulgar fraction characters when available. + * Handles improper fractions by extracting the whole part (e.g., 5/4 → "1¼"). + * + * @param num - The numerator + * @param den - The denominator + * @returns The fraction as a string, using vulgar characters if available + * @category Helpers + * + * @example + * ```typescript + * renderFractionAsVulgar(1, 2); // "½" + * renderFractionAsVulgar(3, 4); // "¾" + * renderFractionAsVulgar(5, 4); // "1¼" + * renderFractionAsVulgar(7, 3); // "2⅓" + * renderFractionAsVulgar(2, 5); // "2/5" (no vulgar character available) + * ``` + */ +export function renderFractionAsVulgar(num: number, den: number): string { + // Handle improper fractions (num >= den) + const wholePart = Math.floor(num / den); + const remainder = num % den; + + if (remainder === 0) { + // Exact integer + return String(wholePart); + } + + const fractionKey = `${remainder}/${den}`; + const vulgar = VULGAR_FRACTIONS[fractionKey]; + + if (wholePart > 0) { + // Mixed fraction: whole part + fractional part + return vulgar + ? `${wholePart}${vulgar}` + : `${wholePart} ${remainder}/${den}`; + } + + // Proper fraction only + return vulgar ?? `${num}/${den}`; +} + /** * Format a numeric value (decimal or fraction) to a string. * * @param value - The decimal or fraction value to format + * @param useVulgar - Whether to use Unicode vulgar fraction characters (default: false) * @returns The formatted string representation * @category Helpers * @@ -27,14 +85,20 @@ import { Recipe } from "../classes/recipe"; * ```typescript * formatNumericValue({ type: "decimal", decimal: 1.5 }); // "1.5" * formatNumericValue({ type: "fraction", num: 1, den: 2 }); // "1/2" + * formatNumericValue({ type: "fraction", num: 1, den: 2 }, true); // "½" + * formatNumericValue({ type: "fraction", num: 5, den: 4 }, true); // "1¼" * ``` */ export function formatNumericValue( value: DecimalValue | FractionValue, + useVulgar: boolean = true, ): string { if (value.type === "decimal") { return String(value.decimal); } + if (useVulgar) { + return renderFractionAsVulgar(value.num, value.den); + } return `${value.num}/${value.den}`; } diff --git a/test/__snapshots__/recipe_parsing.test.ts.snap b/test/__snapshots__/recipe_parsing.test.ts.snap index 2ab4e85..6cefb81 100644 --- a/test/__snapshots__/recipe_parsing.test.ts.snap +++ b/test/__snapshots__/recipe_parsing.test.ts.snap @@ -337,11 +337,12 @@ Recipe { "quantity": { "type": "fixed", "value": { - "decimal": 0.438, - "type": "decimal", + "den": 2, + "num": 7, + "type": "fraction", }, }, - "unit": "cup", + "unit": "fl-oz", }, ], "usedAsPrimary": true, diff --git a/test/quantities_alternatives.test.ts b/test/quantities_alternatives.test.ts index fb53a27..e23b9a8 100644 --- a/test/quantities_alternatives.test.ts +++ b/test/quantities_alternatives.test.ts @@ -47,7 +47,7 @@ describe("getEquivalentUnitsLists", () => { [ qWithUnitDef(1, "small"), qWithUnitDef(1, "cup"), - qWithUnitDef(1.333, "large", true), + qWithUnitDef(1.33, "large", true), qWithUnitDef(0.667, "pack"), ], ]); @@ -165,7 +165,7 @@ describe("addQuantitiesOrGroups", () => { it("should pass single quantities transparently", () => { const quantity: QuantityWithExtendedUnit = q(1, "kg"); const result = addQuantitiesOrGroups([quantity]); - expect(result.sum).toEqual({ + expect(result.sum).toMatchObject({ ...q(1, "kg"), unit: { name: "kg", @@ -204,7 +204,7 @@ describe("addQuantitiesOrGroups", () => { }; const { sum } = addQuantitiesOrGroups([or1, or2]); - expect(sum).toEqual(qWithUnitDef(3.333, "large")); + expect(sum).toEqual(qWithUnitDef(3.33, "large")); }); it("should handle OR groups with different normalizable units", () => { const or1: FlatOrGroup = { @@ -288,8 +288,19 @@ describe("addEquivalentsAndSimplify", () => { const or2: FlatOrGroup = { or: [q(2, "small"), q(1, "cup")], }; + // 1.5 + 1 = 2.5 cups → 5/2 as fraction (cup has fractions enabled) expect(addEquivalentsAndSimplify([or1, or2])).toEqual({ - or: [qPlain(3.333, "large"), qPlain(5, "small"), qPlain(2.5, "cup")], + or: [ + qPlain(3.33, "large"), + qPlain(5, "small"), + { + quantity: { + type: "fixed", + value: { type: "fraction", num: 5, den: 2 }, + }, + unit: "cup", + }, + ], }); }); it("accepts units of the same type but different system as alternative", () => { @@ -299,8 +310,11 @@ describe("addEquivalentsAndSimplify", () => { const or2: FlatOrGroup = { or: [q(1, "pint"), q(473, "mL")], }; + // 10 cups + 1 pint = ~12 cups + // Total base = 10*236.588 + 473.176 = 2839ml + // Best unit selection prefers non-metric when system is US (inferred from ambiguous units) expect(addEquivalentsAndSimplify([or1, or2])).toEqual({ - or: [qPlain(12, "cup"), qPlain(2839.2, "mL")], + or: [qPlain(12, "cup"), qPlain(2.84, "l")], }); }); it("correctly take integer-protected units into account", () => { @@ -310,10 +324,17 @@ describe("addEquivalentsAndSimplify", () => { const or2: FlatOrGroup = { or: [q(2, "small"), q(1, "cup")], }; + // 1.5 + 1 = 2.5 cups → 5/2 as fraction (cup has fractions enabled) expect(addEquivalentsAndSimplify([or1, or2])).toEqual({ or: [ { and: [qPlain(2, "large"), qPlain(2, "small")] }, - qPlain(2.5, "cup"), + { + quantity: { + type: "fixed", + value: { type: "fraction", num: 5, den: 2 }, + }, + unit: "cup", + }, ], }); }); diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts index 4097fb2..f69f9ae 100644 --- a/test/quantities_mutations.test.ts +++ b/test/quantities_mutations.test.ts @@ -12,11 +12,13 @@ import { import { CannotAddTextValueError, IncompatibleUnitsError } from "../src/errors"; import { AndGroup, + FixedValue, FlatAndGroup, FlatOrGroup, MaybeNestedAndGroup, QuantityWithExtendedUnit, QuantityWithPlainUnit, + Range, } from "../src/types"; describe("extendAllUnits", () => { @@ -105,6 +107,7 @@ describe("normalizeAllUnits", () => { system: "metric", aliases: ["gram", "grams", "grammes"], toBase: 1, + maxValue: 999, }, }; expect(normalized).toEqual(expected); @@ -148,6 +151,10 @@ describe("normalizeAllUnits", () => { aliases: ["cups"], toBase: 236.588, toBaseBySystem: { US: 236.588, UK: 284.131 }, + fractions: { + enabled: true, + }, + maxValue: 15, }, }, { @@ -163,7 +170,12 @@ describe("normalizeAllUnits", () => { system: "ambiguous", aliases: ["tablespoon", "tablespoons"], toBase: 15, - toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758 }, + toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 }, + fractions: { + denominators: [2, 3, 4], + enabled: true, + }, + maxValue: 4, }, }, { @@ -183,6 +195,7 @@ describe("normalizeAllUnits", () => { "cc", ], toBase: 1, + maxValue: 999, }, }, ], @@ -225,6 +238,11 @@ describe("addQuantityValues", () => { }); it("should add a fixed and a range value", () => { + const result: Range = { + type: "range", + min: { type: "decimal", decimal: 4 }, + max: { type: "decimal", decimal: 5 }, + }; expect( addQuantityValues( { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -234,11 +252,17 @@ describe("addQuantityValues", () => { max: { type: "decimal", decimal: 4 }, }, ), - ).toEqual({ - type: "range", - min: { type: "decimal", decimal: 4 }, - max: { type: "decimal", decimal: 5 }, - }); + ).toEqual(result); + expect( + addQuantityValues( + { + type: "range", + min: { type: "decimal", decimal: 3 }, + max: { type: "decimal", decimal: 4 }, + }, + { type: "fixed", value: { type: "decimal", decimal: 1 } }, + ), + ).toEqual(result); }); it("should throw an error if one of the value is a text value", () => { @@ -408,7 +432,9 @@ describe("addQuantities", () => { }); }); - it("should add compatible imperial units and convert to largest", () => { + it("should add compatible imperial units and prefer integer result", () => { + // 1 lb + 8 oz = 24 oz = 1.5 lb + // oz maxValue is 31, so 24oz is kept expect( addQuantities( { @@ -421,12 +447,21 @@ describe("addQuantities", () => { }, ), ).toEqual({ - quantity: { type: "fixed", value: { type: "decimal", decimal: 1.5 } }, - unit: { name: "lb" }, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 24 }, + }, + unit: { name: "oz" }, }); }); - it("should add compatible metric and non-metric units, converting to largest metric if no context provided", () => { + it("should add compatible metric and non-metric units, preferring metric", () => { + // 1 lb = 453.592g, 500g + 453.592g = 953.592g + // No system provided, one unit is metric → effectiveSystem = metric + // lb is not compatible with metric system, so only metric units are candidates + // g: 953.592 (in range, not integer) + // kg: 0.954 (below range) + // Best: g at 954 (rounded) const result1 = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -437,14 +472,12 @@ describe("addQuantities", () => { unit: { name: "g" }, }, ); - const resultUnit = { name: "g" }; - expect(result1.unit).toEqual(resultUnit); - const resultQuantity = { + expect(result1.unit).toEqual({ name: "g" }); + expect(result1.quantity).toEqual({ type: "fixed", - value: { type: "decimal", decimal: 953.592 }, - }; - expect(result1.quantity).toEqual(resultQuantity); - // Also works the other way around + value: { type: "decimal", decimal: 954 }, + }); + // Also works the other way around (same result) const result2 = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, @@ -455,11 +488,19 @@ describe("addQuantities", () => { unit: { name: "lb" }, }, ); - expect(result2.unit).toEqual(resultUnit); - expect(result2.quantity).toEqual(resultQuantity); + expect(result2.unit).toEqual({ name: "g" }); + expect(result2.quantity).toEqual({ + type: "fixed", + value: { type: "decimal", decimal: 954 }, + }); }); - it("should convert sum of two non-metric units to metric if no context system provided", () => { + it("should add non-US-compatible units and return metric", () => { + // 1 go = 180ml (JP system), 1 cup = 236.588ml (US default) + // go is JP (not US-compatible), so effectiveSystem falls back to metric + // Total = 180 + 236.588 = 416.588ml in base + // With metric system: + // ml: 417 (in range, dl/cl are deprioritized) const result1 = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -470,15 +511,14 @@ describe("addQuantities", () => { unit: { name: "cup" }, }, ); - const resultUnit = { name: "l" }; + const resultUnit = { name: "ml" }; expect(result1.unit).toEqual(resultUnit); - // 180ml (go) + 236.588ml (cup defaulting to US)= 416.588ml = 0.416588l - const resultQuantity = { + const resultQuantity: FixedValue = { type: "fixed", - value: { type: "decimal", decimal: 0.417 }, + value: { type: "decimal", decimal: 417 }, }; expect(result1.quantity).toEqual(resultQuantity); - // Also works the other way around + // Swap order: same result const result2 = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -493,7 +533,45 @@ describe("addQuantities", () => { expect(result2.quantity).toEqual(resultQuantity); }); + it("should add JP units and return JP", () => { + const result1 = addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "go" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "go" }, + }, + ); + const resultUnit = { name: "go" }; + expect(result1.unit).toEqual(resultUnit); + const resultQuantity: FixedValue = { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }; + expect(result1.quantity).toEqual(resultQuantity); + // Swap order: same result + const result2 = addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "go" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "go" }, + }, + ); + expect(result2.unit).toEqual(resultUnit); + expect(result2.quantity).toEqual(resultQuantity); + }); + it("should convert ambiguous units to supported context system if provided", () => { + // 1 US cup = 236.588ml, 1 US fl-oz = 29.5735ml + // Total = 266.1615ml in base + // Input units: cup, fl-oz + // cup: 266.1615/236.588 = 1.125 (in range, not integer) + // fl-oz: 266.1615/29.5735 = 9 (integer! preferred) const result = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -505,15 +583,20 @@ describe("addQuantities", () => { }, "US", ); - expect(result.unit).toEqual({ name: "cup" }); - // 1 cup + 0.125 cup + expect(result.unit).toEqual({ name: "fl-oz" }); expect(result.quantity).toEqual({ type: "fixed", - value: { type: "decimal", decimal: 1.125 }, + value: { type: "decimal", decimal: 9 }, }); }); it("should convert the sum of ambiguous and metric units to supported context system if provided", () => { + // 1 US cup = 236.588ml, 100ml + // Total = 336.588ml in base + // Input units: cup (US), ml + // cup: 336.588/236.588 = 1.42 (in range) + // ml: 336.588 (in range) + // Smallest in range from input: 1.42 cup → 11/8 as fraction (1.375, within 5%) const result1 = addQuantities( { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, @@ -525,14 +608,11 @@ describe("addQuantities", () => { }, "US", ); - const resultUnit = { name: "cup" }; - expect(result1.unit).toEqual(resultUnit); - // 1 cup + 0.423 cup - const resultQuantity = { + expect(result1.unit).toEqual({ name: "cup" }); + expect(result1.quantity).toEqual({ type: "fixed", - value: { type: "decimal", decimal: 1.423 }, - }; - expect(result1.quantity).toEqual(resultQuantity); + value: { type: "fraction", num: 11, den: 8 }, + }); // Also works the other way around const result2 = addQuantities( { @@ -546,8 +626,29 @@ describe("addQuantities", () => { "US", ); - expect(result2.unit).toEqual(resultUnit); - expect(result2.quantity).toEqual(resultQuantity); + expect(result2.unit).toEqual({ name: "cup" }); + expect(result2.quantity).toEqual({ + type: "fixed", + value: { type: "fraction", num: 11, den: 8 }, + }); + }); + + it("should choose the best unit when the result of outside the range of the input unit", () => { + const result = addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 700 } }, + unit: { name: "g" }, + }, + ); + expect(result.unit).toEqual({ name: "kg" }); + expect(result.quantity).toEqual({ + type: "fixed", + value: { type: "decimal", decimal: 1.2 }, + }); }); it("should handle text quantities", () => { @@ -685,6 +786,33 @@ describe("addQuantities", () => { unit: { name: "tsp" }, }); }); + + it("should add ranges with different units", () => { + // Range (100-200g) + fixed 1kg = range (1100-1200g) = range (1.1-1.2 kg) + expect( + addQuantities( + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 100 }, + max: { type: "decimal", decimal: 200 }, + }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 1.1 }, + max: { type: "decimal", decimal: 1.2 }, + }, + unit: { name: "kg" }, + }); + }); }); describe("getDefaultQuantityValue + addQuantities", () => { diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index 3ad6347..57f96e4 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -660,7 +660,7 @@ describe("parse function", () => { }); }); - it("should add quantities and convert to metric", () => { + it("should add quantities and prefer metric", () => { const recipe = ` Add @butter{1%lb}. Then add some more @&butter{250%g}. @@ -669,16 +669,17 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(1); const butter = result.ingredients[0]!; expect(butter.name).toBe("butter"); + // 1 lb + 250g = 453.592g + 250g = 703.592g + // No system provided, one unit is metric → prefer metric expect(butter.quantities).toEqual([ { quantity: { type: "fixed", - value: { type: "decimal", decimal: 703.592 }, + value: { type: "decimal", decimal: 704 }, }, unit: "g", }, ]); - // TODO: 700g would be more elegant }); it("should throw an error if referenced ingredient does not exist", () => { @@ -2134,15 +2135,17 @@ Add @water{1%cup} and some more @&water{1%fl-oz} `; const result = new Recipe(recipe); expect(result.ingredients).toHaveLength(1); + // UK: 1 cup (284.131ml) + 1 fl-oz (28.4131ml) = 312.544ml + // fl-oz: 312.544/28.4131 = 11 (integer preferred) const ing: Ingredient = { name: "water", quantities: [ { quantity: { type: "fixed", - value: { type: "decimal", decimal: 1.1 }, + value: { type: "decimal", decimal: 11 }, }, - unit: "cup", + unit: "fl-oz", }, ], usedAsPrimary: true, @@ -2155,15 +2158,17 @@ Add @water{1%cup} and some more @&water{1%fl-oz} `; const result = new Recipe(recipe); expect(result.ingredients).toHaveLength(1); + // US: 1 cup (236.588ml) + 1 fl-oz (29.5735ml) = 266.162ml + // fl-oz: 266.162/29.5735 = 9 (integer preferred) const ing: Ingredient = { name: "water", quantities: [ { quantity: { type: "fixed", - value: { type: "decimal", decimal: 1.125 }, + value: { type: "decimal", decimal: 9 }, }, - unit: "cup", + unit: "fl-oz", }, ], usedAsPrimary: true, @@ -2176,15 +2181,16 @@ Add @water{1%tbsp} and some more @&water{100%mL} `; const result = new Recipe(recipe); expect(result.ingredients).toHaveLength(1); + // 1 tbsp (15ml) + 100ml = 115ml (integer preferred) const ing: Ingredient = { name: "water", quantities: [ { quantity: { type: "fixed", - value: { type: "decimal", decimal: 7.667 }, + value: { type: "decimal", decimal: 115 }, }, - unit: "tbsp", + unit: "ml", }, ], usedAsPrimary: true, diff --git a/test/render_helpers.test.ts b/test/render_helpers.test.ts index ab89405..163bd78 100644 --- a/test/render_helpers.test.ts +++ b/test/render_helpers.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { + renderFractionAsVulgar, formatNumericValue, formatSingleValue, formatQuantity, @@ -30,6 +31,47 @@ import { recipeWithGroupedAlternatives, } from "./fixtures/recipes"; +// ============================================================================ +// renderFractionAsVulgar +// ============================================================================ + +describe("renderFractionAsVulgar", () => { + it("should render common fractions as vulgar characters", () => { + expect(renderFractionAsVulgar(1, 2)).toBe("½"); + expect(renderFractionAsVulgar(1, 3)).toBe("⅓"); + expect(renderFractionAsVulgar(2, 3)).toBe("⅔"); + expect(renderFractionAsVulgar(1, 4)).toBe("¼"); + expect(renderFractionAsVulgar(3, 4)).toBe("¾"); + expect(renderFractionAsVulgar(1, 8)).toBe("⅛"); + expect(renderFractionAsVulgar(3, 8)).toBe("⅜"); + expect(renderFractionAsVulgar(5, 8)).toBe("⅝"); + expect(renderFractionAsVulgar(7, 8)).toBe("⅞"); + }); + + it("should fall back to plain text for uncommon fractions", () => { + expect(renderFractionAsVulgar(2, 5)).toBe("2/5"); + expect(renderFractionAsVulgar(3, 7)).toBe("3/7"); + }); + + it("should handle improper fractions (mixed numbers)", () => { + expect(renderFractionAsVulgar(5, 4)).toBe("1¼"); + expect(renderFractionAsVulgar(7, 3)).toBe("2⅓"); + expect(renderFractionAsVulgar(11, 8)).toBe("1⅜"); + expect(renderFractionAsVulgar(9, 4)).toBe("2¼"); + }); + + it("should handle improper fractions without vulgar characters", () => { + expect(renderFractionAsVulgar(7, 5)).toBe("1 2/5"); + expect(renderFractionAsVulgar(10, 7)).toBe("1 3/7"); + }); + + it("should handle exact integers from improper fractions", () => { + expect(renderFractionAsVulgar(4, 2)).toBe("2"); + expect(renderFractionAsVulgar(9, 3)).toBe("3"); + expect(renderFractionAsVulgar(8, 4)).toBe("2"); + }); +}); + // ============================================================================ // formatNumericValue // ============================================================================ @@ -42,7 +84,30 @@ describe("formatNumericValue", () => { it("should format fraction values", () => { const fraction: FractionValue = { type: "fraction", num: 1, den: 2 }; - expect(formatNumericValue(fraction)).toBe("1/2"); + expect(formatNumericValue(fraction)).toBe("½"); + }); + + it("should format fraction values with vulgar characters by default", () => { + expect(formatNumericValue({ type: "fraction", num: 1, den: 2 })).toBe("½"); + expect(formatNumericValue({ type: "fraction", num: 3, den: 4 })).toBe("¾"); + expect(formatNumericValue({ type: "fraction", num: 5, den: 4 })).toBe("1¼"); + }); + + it("should format fraction values with vulgar characters when useVulgar is false", () => { + expect( + formatNumericValue({ type: "fraction", num: 1, den: 2 }, false), + ).toBe("1/2"); + expect( + formatNumericValue({ type: "fraction", num: 3, den: 4 }, false), + ).toBe("3/4"); + expect( + formatNumericValue({ type: "fraction", num: 5, den: 4 }, false), + ).toBe("5/4"); + }); + + it("should not affect decimal values by default", () => { + const decimal: DecimalValue = { type: "decimal", decimal: 1.5 }; + expect(formatNumericValue(decimal)).toBe("1.5"); }); }); @@ -63,7 +128,7 @@ describe("formatSingleValue", () => { it("should format fraction values", () => { const fraction: FractionValue = { type: "fraction", num: 3, den: 4 }; - expect(formatSingleValue(fraction)).toBe("3/4"); + expect(formatSingleValue(fraction)).toBe("¾"); }); }); @@ -103,7 +168,7 @@ describe("formatQuantity", () => { min: { type: "fraction", num: 1, den: 4 }, max: { type: "fraction", num: 1, den: 2 }, }; - expect(formatQuantity(range)).toBe("1/4-1/2"); + expect(formatQuantity(range)).toBe("¼-½"); }); it("should format range with mixed types", () => { @@ -112,7 +177,7 @@ describe("formatQuantity", () => { min: { type: "decimal", decimal: 1 }, max: { type: "fraction", num: 3, den: 2 }, }; - expect(formatQuantity(range)).toBe("1-3/2"); + expect(formatQuantity(range)).toBe("1-1½"); }); }); diff --git a/test/units_conversion.test.ts b/test/units_conversion.test.ts index 4d668f7..15013d5 100644 --- a/test/units_conversion.test.ts +++ b/test/units_conversion.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest"; import { qWithUnitDef } from "./mocks/quantity"; import Big from "big.js"; -import { getUnitRatio } from "../src/units/conversion"; +import { getUnitRatio, findBestUnit } from "../src/units/conversion"; +import { normalizeUnit } from "../src/units/definitions"; describe("getUnitRatio", () => { it("should return the correct ratio for numerical values", () => { @@ -30,3 +31,100 @@ describe("getUnitRatio", () => { ).toThrowError(); }); }); + +describe("findBestUnit", () => { + it("should prefer the smallest integer in input family", () => { + // 0.5 pint + 0.5 pint = 1 pint or 2 cup + const pintDef = normalizeUnit("pint")!; + const cupDef = normalizeUnit("cup")!; + const result = findBestUnit(473.2, "volume", "US", [pintDef]); + expect(result.unit.name).toBe("pint"); + expect(result.value).toBeCloseTo(1); + // 1 cup + 1 cup = 1 pint or 2 cup + const result2 = findBestUnit(473.2, "volume", "US", [cupDef]); + expect(result2.unit.name).toBe("cup"); + expect(result2.value).toBeCloseTo(2); + //2 cup + 1 pint = 2 pint or 4 cup + const result3 = findBestUnit(946.4, "volume", "US", [pintDef, cupDef]); + expect(result3.unit.name).toBe("pint"); + expect(result3.value).toBeCloseTo(2); + }); + it("should prefer integers in input family only if within human range", () => { + // 240ml = 16tbsp (integer, in input family, but tbsp maxValue is 4, so excluded) + const tbspDef = normalizeUnit("tbsp")!; + const mlDef = normalizeUnit("ml")!; + const result = findBestUnit(240, "volume", "metric", [tbspDef, mlDef]); + expect(result.unit.name).toBe("ml"); + expect(result.value).toBe(240); + }); + + it("should prefer the smallest integer of all integers in any family over non-integers in input family", () => { + // 236.6 ml = ~1 cup or ~8 fl-oz + const mLDef = normalizeUnit("mL")!; + const result = findBestUnit(236.6, "volume", "US", [mLDef]); + expect(result.unit.name).toBe("cup"); + expect(result.value).toBeCloseTo(1); + const goDef = normalizeUnit("go")!; + const result2 = findBestUnit(15, "volume", "metric", [goDef]); + expect(result2.unit.name).toBe("tbsp"); + expect(result2.value).toBeCloseTo(1); + const result3 = findBestUnit(14.787, "volume", "US", [goDef]); + expect(result3.unit.name).toBe("tbsp"); + expect(result3.value).toBeCloseTo(1); + const result4 = findBestUnit(360, "volume", "JP", [goDef]); + expect(result4.unit.name).toBe("go"); + expect(result4.value).toBe(2); + }); + + it("should prefer the smallest integer when multiple candidates", () => { + const flozDef = normalizeUnit("fl-oz")!; + const cupDef = normalizeUnit("cup")!; + // 236.6 ml = ~1 cup or ~8 fl-oz + const result = findBestUnit(236.6, "volume", "US", [flozDef, cupDef]); + expect(result.unit.name).toBe("cup"); + expect(result.value).toBeCloseTo(1); + }); + + it("should handle non-integer values in range", () => { + const mlDef = normalizeUnit("ml")!; + const result = findBestUnit(1.5, "volume", "metric", [mlDef]); + expect(result.unit.name).toBe("ml"); + expect(result.value).toBe(1.5); + }); + + it("should handle large values outside default max value for largest unit in system", () => { + // 10,000,000ml = 10,000L (> 999) + const mlDef = normalizeUnit("ml")!; + const result = findBestUnit(10000000, "volume", "metric", [mlDef]); + expect(result.unit.name).toBe("l"); + expect(result.value).toBe(10000); + + // 1,980ml = 11 go > max 10. Defaults to other metric units + const goDef = normalizeUnit("go")!; + const result2 = findBestUnit(1980, "volume", "JP", [goDef]); + expect(result2.unit.name).toBe("l"); + expect(result2.value).toBe(1.98); + }); + + it("should consider fraction-representable values as in range for US units", () => { + // 1.7ml with US system = ~0.345 tsp ≈ 1/3 tsp + // tsp has fractions enabled, so 0.345 is considered in range (fraction approximation) + // ml would be 1.7 (also in range), but tsp at ~1/3 is selected as smallest in range + const flozDef = normalizeUnit("fl-oz")!; + const result = findBestUnit(1.7, "volume", "US", [flozDef]); + expect(result.unit.name).toBe("tsp"); + expect(result.value - 1 / 3).toBeLessThan((0.05 * 1) / 3); // within 5% accuracy + }); + + it("should fall back to non-fraction units when value cannot be approximated", () => { + // 0.3ml with US system = ~0.06 tsp (too small for fraction approximation, min is 1/8 = 0.125) + // ml: 0.3 (not in standard range, but ml doesn't have fractions enabled) + // No candidate in range, so closest to range is selected + const flozDef = normalizeUnit("fl-oz")!; + const result = findBestUnit(0.3, "volume", "US", [flozDef]); + // 0.3 ml is closest to range (distance to 1 is 0.7) + // vs tsp at 0.06 (distance to 1 is 0.94) + expect(result.unit.name).toBe("tsp"); + expect(result.value).toBeCloseTo(0.06, 2); + }); +}); diff --git a/test/units_definitions.test.ts b/test/units_definitions.test.ts index c3d5e92..9531b95 100644 --- a/test/units_definitions.test.ts +++ b/test/units_definitions.test.ts @@ -25,7 +25,7 @@ describe("normalizeUnit", () => { describe("resolveUnit", () => { it("should add various properties from the corresponding canonical definition but preserve the name", () => { expect(resolveUnit("g").name).toBe("g"); - expect(resolveUnit("gram")).toEqual({ + expect(resolveUnit("gram")).toMatchObject({ name: "gram", type: "mass", system: "metric", diff --git a/test/units_numeric.test.ts b/test/units_numeric.test.ts index fde4546..112e790 100644 --- a/test/units_numeric.test.ts +++ b/test/units_numeric.test.ts @@ -1,24 +1,177 @@ import { describe, it, expect } from "vitest"; -import { toRoundedDecimal } from "../src/quantities/numeric"; +import { + approximateFraction, + formatOutputValue, + toRoundedDecimal, +} from "../src/quantities/numeric"; describe("toRoundedDecimal", () => { - it("should round decimal values with 3 digits if precision not specified", () => { + it("should round decimal values to 3 significant digits by default", () => { + // 1.23456 rounded to 3 sig figs = 1.23 expect(toRoundedDecimal({ type: "decimal", decimal: 1.23456 })).toEqual({ type: "decimal", - decimal: 1.235, + decimal: 1.23, + }); + // 12.3456 rounded to 3 sig figs = 12.3 + expect(toRoundedDecimal({ type: "decimal", decimal: 12.3456 })).toEqual({ + type: "decimal", + decimal: 12.3, + }); + // 123.456 rounded to 3 sig figs = 123 + expect(toRoundedDecimal({ type: "decimal", decimal: 123.456 })).toEqual({ + type: "decimal", + decimal: 123, + }); + // 0.0123456 rounded to 3 sig figs = 0.0123 + expect(toRoundedDecimal({ type: "decimal", decimal: 0.0123456 })).toEqual({ + type: "decimal", + decimal: 0.0123, }); }); - it("should round decimal values to specified precision", () => { + it("should round decimal values to specified significant digits", () => { + // 1.23456 rounded to 2 sig figs = 1.2 expect(toRoundedDecimal({ type: "decimal", decimal: 1.23456 }, 2)).toEqual({ type: "decimal", - decimal: 1.23, + decimal: 1.2, + }); + // 1.23456 rounded to 4 sig figs = 1.235 + expect(toRoundedDecimal({ type: "decimal", decimal: 1.23456 }, 4)).toEqual({ + type: "decimal", + decimal: 1.235, + }); + }); + it("should preserve integers with 4+ digits fully", () => { + // 1234.5 should become 1235 (preserving integer portion) + expect(toRoundedDecimal({ type: "decimal", decimal: 1234.5 })).toEqual({ + type: "decimal", + decimal: 1235, + }); + // 12345.6 should become 12346 + expect(toRoundedDecimal({ type: "decimal", decimal: 12345.6 })).toEqual({ + type: "decimal", + decimal: 12346, + }); + // Already an integer - keep it + expect(toRoundedDecimal({ type: "decimal", decimal: 1107 })).toEqual({ + type: "decimal", + decimal: 1107, }); }); - it("should round fraction values with 3 digits if precision not specifiedn", () => { + it("should round fraction values to 3 significant digits by default", () => { + // 1/3 = 0.333... rounded to 3 sig figs = 0.333 expect(toRoundedDecimal({ type: "fraction", num: 1, den: 3 })).toEqual({ type: "decimal", decimal: 0.333, }); }); + it("should handle zero", () => { + expect(toRoundedDecimal({ type: "decimal", decimal: 0 })).toEqual({ + type: "decimal", + decimal: 0, + }); + }); +}); + +describe("approximateFraction", () => { + it("should approximate 0.5 as 1/2", () => { + expect(approximateFraction(0.5)).toEqual({ + type: "fraction", + num: 1, + den: 2, + }); + }); + + it("should approximate 1.5 as 3/2", () => { + expect(approximateFraction(1.5)).toEqual({ + type: "fraction", + num: 3, + den: 2, + }); + }); + + it("should approximate 0.25 as 1/4", () => { + expect(approximateFraction(0.25)).toEqual({ + type: "fraction", + num: 1, + den: 4, + }); + }); + + it("should approximate 0.333... as 1/3", () => { + expect(approximateFraction(1 / 3)).toEqual({ + type: "fraction", + num: 1, + den: 3, + }); + }); + + it("should return null for negative values", () => { + expect(approximateFraction(-0.5)).toBeNull(); + }); + + it("should return null for zero", () => { + expect(approximateFraction(0)).toBeNull(); + }); + + it("should return null for infinity", () => { + expect(approximateFraction(Infinity)).toBeNull(); + }); + + it("should return null for whole numbers (no fractional part)", () => { + expect(approximateFraction(2)).toBeNull(); + expect(approximateFraction(5.0001)).toBeNull(); // effectively integer + }); + + it("should return null when whole part exceeds maxWhole", () => { + expect(approximateFraction(5.5)).toBeNull(); // default maxWhole is 4 + expect(approximateFraction(10.25)).toBeNull(); + }); + + it("should return null when value cannot be approximated within tolerance", () => { + expect(approximateFraction(0.1)).toBeNull(); + }); + + it("should respect custom denominators", () => { + expect(approximateFraction(0.1, [10])).toEqual({ + type: "fraction", + num: 1, + den: 10, + }); + }); +}); + +describe("formatOutputValue", () => { + const unitWithFractions = { + name: "cup", + type: "volume" as const, + system: "ambiguous" as const, + toBase: 236.588, + aliases: ["cups"], + fractions: { enabled: true }, + }; + + const unitWithoutFractions = { + name: "mL", + type: "volume" as const, + system: "metric" as const, + toBase: 1, + aliases: ["ml"], + }; + + it("should return fraction when unit supports fractions and value is approximable", () => { + const result = formatOutputValue(1.5, unitWithFractions); + expect(result).toEqual({ type: "fraction", num: 3, den: 2 }); + }); + + it("should return decimal when unit does not support fractions", () => { + const result = formatOutputValue(1.5, unitWithoutFractions); + expect(result).toEqual({ type: "decimal", decimal: 1.5 }); + }); + + it("should return decimal when value cannot be approximated as fraction", () => { + // 0.15 cannot be approximated with the default denominators + const result = formatOutputValue(0.15, unitWithFractions); + expect(result.type).toBe("decimal"); + }); }); diff --git a/test/utils_numeric.test.ts b/test/utils_numeric.test.ts index 1ab97d6..50ca67f 100644 --- a/test/utils_numeric.test.ts +++ b/test/utils_numeric.test.ts @@ -147,11 +147,12 @@ describe("multiplyNumericValue", () => { value: { type: "decimal", decimal: 5 }, }; const factor = Big(1).div(3); + // 5/3 = 1.6666... rounded to 3 sig figs = 1.67 expect(multiplyQuantityValue(val, factor)).toEqual({ type: "fixed", value: { type: "decimal", - decimal: 1.667, + decimal: 1.67, }, }); }); From 22a05b8eb441937b49ee224964d03df4afea17ea Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Thu, 29 Jan 2026 01:10:56 +0100 Subject: [PATCH 2/4] fix(units): define more common denominators --- src/quantities/numeric.ts | 2 +- src/units/definitions.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/quantities/numeric.ts b/src/quantities/numeric.ts index ba3b6c9..7fce120 100644 --- a/src/quantities/numeric.ts +++ b/src/quantities/numeric.ts @@ -8,7 +8,7 @@ import type { } from "../types"; /** Default allowed denominators for fraction approximation */ -export const DEFAULT_DENOMINATORS = [2, 3, 4, 8]; +export const DEFAULT_DENOMINATORS = [2, 3, 4]; /** Default accuracy tolerance for fraction approximation (5%) */ export const DEFAULT_FRACTION_ACCURACY = 0.05; /** Default maximum whole number in mixed fraction before falling back to decimal */ diff --git a/src/units/definitions.ts b/src/units/definitions.ts index ad99368..0c9ef5f 100644 --- a/src/units/definitions.ts +++ b/src/units/definitions.ts @@ -27,7 +27,7 @@ export const units: UnitDefinition[] = [ toBase: 28.3495, // default: US (same as UK) toBaseBySystem: { US: 28.3495, UK: 28.3495 }, maxValue: 31, // 16 oz = 1 lb, allow a bit more - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2] }, }, { name: "lb", @@ -36,7 +36,7 @@ export const units: UnitDefinition[] = [ aliases: ["pound", "pounds"], toBase: 453.592, // default: US (same as UK) toBaseBySystem: { US: 453.592, UK: 453.592 }, - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2, 4] }, }, // Volume (Metric) @@ -91,7 +91,7 @@ export const units: UnitDefinition[] = [ toBase: 5, // default: metric toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 }, maxValue: 5, // 3 tsp = 1 tbsp (but allow a bit more) - fractions: { enabled: true, denominators: [2, 3, 4] }, + fractions: { enabled: true, denominators: [2, 3, 4, 8] }, }, { name: "tbsp", @@ -101,7 +101,7 @@ export const units: UnitDefinition[] = [ toBase: 15, // default: metric toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 }, maxValue: 4, // ~16 tbsp = 1 cup - fractions: { enabled: true, denominators: [2, 3, 4] }, + fractions: { enabled: true }, }, // Volume (Ambiguous: US/UK only) @@ -113,7 +113,7 @@ export const units: UnitDefinition[] = [ toBase: 29.5735, // default: US toBaseBySystem: { US: 29.5735, UK: 28.4131 }, maxValue: 15, // 8 fl-oz ~ 1 cup, allow more - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2] }, }, { name: "cup", @@ -133,7 +133,7 @@ export const units: UnitDefinition[] = [ toBase: 473.176, // default: US toBaseBySystem: { US: 473.176, UK: 568.261 }, maxValue: 3, // 2 pints = 1 quart - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2] }, isBestUnit: false, // exists but not a "best" candidate }, { @@ -144,7 +144,7 @@ export const units: UnitDefinition[] = [ toBase: 946.353, // default: US toBaseBySystem: { US: 946.353, UK: 1136.52 }, maxValue: 3, // 4 quarts = 1 gallon - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2] }, isBestUnit: false, // exists but not a "best" candidate }, { @@ -154,7 +154,7 @@ export const units: UnitDefinition[] = [ aliases: ["gallons"], toBase: 3785.41, // default: US toBaseBySystem: { US: 3785.41, UK: 4546.09 }, - fractions: { enabled: true }, + fractions: { enabled: true, denominators: [2] }, }, // Count units (no conversion, but recognized as a type) From 418cf6852a2059fb1cdea7ca18b2f33a3ee0c559 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Thu, 29 Jan 2026 01:13:41 +0100 Subject: [PATCH 3/4] chore(tests): update broken tests --- test/quantities_mutations.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts index f69f9ae..1b616cc 100644 --- a/test/quantities_mutations.test.ts +++ b/test/quantities_mutations.test.ts @@ -172,7 +172,6 @@ describe("normalizeAllUnits", () => { toBase: 15, toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 }, fractions: { - denominators: [2, 3, 4], enabled: true, }, maxValue: 4, @@ -611,7 +610,7 @@ describe("addQuantities", () => { expect(result1.unit).toEqual({ name: "cup" }); expect(result1.quantity).toEqual({ type: "fixed", - value: { type: "fraction", num: 11, den: 8 }, + value: { type: "decimal", decimal: 1.42 }, }); // Also works the other way around const result2 = addQuantities( @@ -629,7 +628,7 @@ describe("addQuantities", () => { expect(result2.unit).toEqual({ name: "cup" }); expect(result2.quantity).toEqual({ type: "fixed", - value: { type: "fraction", num: 11, den: 8 }, + value: { type: "decimal", decimal: 1.42 }, }); }); From 7c35d8b6ebf98c6c09569a92e130b3cd27849eec Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Thu, 29 Jan 2026 02:56:03 +0100 Subject: [PATCH 4/4] fix(scaleBy): apply best unit to scaled recipes too --- src/classes/recipe.ts | 24 ++++++ src/quantities/mutations.ts | 94 +++++++++++++++++++++++ test/quantities_mutations.test.ts | 111 +++++++++++++++++++++++++++ test/recipe_scaling.test.ts | 122 ++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+) diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index 37c2a4b..089eed0 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -56,6 +56,7 @@ import { toPlainUnit, toExtendedUnit, flattenPlainUnitGroup, + applyBestUnit, } from "../quantities/mutations"; import { resolveUnit } from "../units/definitions"; import Big from "big.js"; @@ -1157,6 +1158,9 @@ export class Recipe { originalServings = 1; } + // Get unit system for best unit optimization (if set) + const unitSystem = this.unitSystem; + function scaleAlternativesBy( alternatives: IngredientAlternative[], factor: number | Big, @@ -1198,6 +1202,26 @@ export class Recipe { }, ); } + + // Apply best unit optimization (infers system from unit if unitSystem not set) + // Apply to primary + const optimizedPrimary = applyBestUnit( + { + quantity: alternative.itemQuantity.quantity, + unit: alternative.itemQuantity.unit, + }, + unitSystem, + ); + alternative.itemQuantity.quantity = optimizedPrimary.quantity; + alternative.itemQuantity.unit = optimizedPrimary.unit; + + // Apply to equivalents + if (alternative.itemQuantity.equivalents) { + alternative.itemQuantity.equivalents = + alternative.itemQuantity.equivalents.map((eq) => + applyBestUnit(eq, unitSystem), + ); + } } } } diff --git a/src/quantities/mutations.ts b/src/quantities/mutations.ts index 993162f..fde97f6 100644 --- a/src/quantities/mutations.ts +++ b/src/quantities/mutations.ts @@ -524,3 +524,97 @@ export const flattenPlainUnitGroup = ( ]; } }; + +/** + * Apply the best unit to a quantity based on its value and unit system. + * Converts the quantity to base units, finds the best unit for display, + * and returns a new quantity with the best unit. + * + * @param q - The quantity to optimize + * @param system - The unit system to use for finding the best unit. If not provided, + * the system is inferred from the unit (metric/JP stay as-is, others default to US). + * @returns A new quantity with the best unit, or the original if no conversion possible + */ +export function applyBestUnit( + q: QuantityWithExtendedUnit, + system?: SpecificUnitSystem, +): QuantityWithExtendedUnit { + // Skip if no unit or text value + if (!q.unit?.name) { + return q; + } + + const unitDef = resolveUnit(q.unit.name); + + // Skip if unit type is "other" (not convertible) + if (unitDef.type === "other") { + return q; + } + + // Get the value - skip if text + if (q.quantity.type === "fixed" && q.quantity.value.type === "text") { + return q; + } + + const avgValue = getAverageValue(q.quantity); + if (typeof avgValue !== "number") { + return q; + } + + // Determine effective system: use provided system, or infer from unit + const effectiveSystem: SpecificUnitSystem = + system ?? + (["metric", "JP"].includes(unitDef.system) + ? (unitDef.system as "metric" | "JP") + : "US"); + + // Convert to base units + const toBase = getToBase(unitDef, effectiveSystem); + const valueInBase = avgValue * toBase; + + // Find the best unit + const { unit: bestUnit, value: bestValue } = findBestUnit( + valueInBase, + unitDef.type, + effectiveSystem, + [unitDef], + ); + + // Get canonical name of the original unit for comparison + const originalCanonicalName = normalizeUnit(q.unit.name)?.name ?? q.unit.name; + + // If same unit (by canonical name match), no change needed - preserve original unit name + if (bestUnit.name === originalCanonicalName) { + return q; + } + + // Format the value for the best unit + const formattedValue = formatOutputValue(bestValue, bestUnit); + + // Handle ranges: scale to the best unit + if (q.quantity.type === "range") { + const bestToBase = getToBase(bestUnit, effectiveSystem); + const minValue = + (getNumericValue(q.quantity.min) * toBase) / bestToBase; + const maxValue = + (getNumericValue(q.quantity.max) * toBase) / bestToBase; + + return { + quantity: { + type: "range", + min: formatOutputValue(minValue, bestUnit), + max: formatOutputValue(maxValue, bestUnit), + }, + unit: { name: bestUnit.name }, + }; + } + + // Fixed value + return { + quantity: { + type: "fixed", + value: formattedValue, + }, + unit: { name: bestUnit.name }, + }; +} diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts index 1b616cc..83940de 100644 --- a/test/quantities_mutations.test.ts +++ b/test/quantities_mutations.test.ts @@ -8,6 +8,7 @@ import { normalizeAllUnits, toExtendedUnit, flattenPlainUnitGroup, + applyBestUnit, } from "../src/quantities/mutations"; import { CannotAddTextValueError, IncompatibleUnitsError } from "../src/errors"; import { @@ -1221,3 +1222,113 @@ describe("flattenPlainUnitGroup", () => { ]); }); }); + +describe("applyBestUnit", () => { + it("should return original quantity when no unit", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + }; + expect(applyBestUnit(q)).toBe(q); + }); + + it("should return original quantity when unit type is 'other'", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "clove" }, + }; + expect(applyBestUnit(q)).toBe(q); + }); + + it("should return original quantity when value is text", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "text", text: "some" } }, + unit: { name: "g" }, + }; + expect(applyBestUnit(q)).toBe(q); + }); + + it("should convert to best unit (g -> kg)", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1000 } }, + unit: { name: "g" }, + }; + const result = applyBestUnit(q); + expect(result).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }); + }); + + it("should preserve original unit name when best unit is the same (mL stays mL)", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "mL" }, + }; + const result = applyBestUnit(q); + expect(result).toBe(q); + expect(result.unit?.name).toBe("mL"); + }); + + it("should preserve unit alias when best unit is the same (cups stays cups)", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: { name: "cups" }, + }; + const result = applyBestUnit(q); + expect(result).toBe(q); + expect(result.unit?.name).toBe("cups"); + }); + + it("should infer metric system from unit when system not provided", () => { + // 1000g should become 1kg (metric system inferred) + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1000 } }, + unit: { name: "g" }, + }; + const result = applyBestUnit(q); + expect(result.unit?.name).toBe("kg"); + expect(result.quantity).toEqual({ + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }); + }); + + it("should infer US system from ambiguous unit when system not provided", () => { + // Ambiguous units (like cup) default to US system + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 16 } }, + unit: { name: "tbsp" }, + }; + const result = applyBestUnit(q); + // 16 tbsp = 1 cup in US system + expect(result.unit?.name).toBe("cup"); + expect(result.quantity.type).toBe("fixed"); + }); + + it("should use provided system for conversion", () => { + const q: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1000 } }, + unit: { name: "ml" }, + }; + const result = applyBestUnit(q, "metric"); + expect(result.unit?.name).toBe("l"); + }); + + it("should handle ranges correctly", () => { + const q: QuantityWithExtendedUnit = { + quantity: { + type: "range", + min: { type: "decimal", decimal: 500 }, + max: { type: "decimal", decimal: 1500 }, + }, + unit: { name: "g" }, + }; + const result = applyBestUnit(q); + expect(result.unit?.name).toBe("kg"); + expect(result.quantity).toEqual({ + type: "range", + min: { type: "decimal", decimal: 0.5 }, + max: { type: "decimal", decimal: 1.5 }, + }); + }); +}); diff --git a/test/recipe_scaling.test.ts b/test/recipe_scaling.test.ts index aee5ba0..686cf26 100644 --- a/test/recipe_scaling.test.ts +++ b/test/recipe_scaling.test.ts @@ -644,3 +644,125 @@ Add {{sauce:100%g}} of sauce. }); }); }); + +describe("scaleBy with best unit optimization", () => { + it("should apply best unit when scaling with unitSystem set (metric)", () => { + const recipe = new Recipe(` +--- +servings: 1 +unit system: metric +--- +Add @flour{100%g}. + `); + + // Scale by 10x - 100g * 10 = 1000g = 1kg + const scaledRecipe = recipe.scaleBy(10); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }); + }); + + it("should apply best unit to equivalents when scaling", () => { + const recipe = new Recipe(` +--- +servings: 1 +unit system: metric +--- +Add @flour{100%g|1%cup}. + `); + + // Scale by 10x - 100g * 10 = 1000g = 1kg, 1cup * 10 = 10 cups stays (no better unit) + const scaledRecipe = recipe.scaleBy(10); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }); + // Equivalent (cup) optimized within metric system context + expect(item.alternatives[0]!.itemQuantity!.equivalents![0]).toMatchObject({ + quantity: { type: "fixed", value: { type: "decimal" } }, + unit: { name: "l" }, + }); + }); + + it("should apply best unit even when unitSystem is not set (infers from unit)", () => { + const recipe = new Recipe(` +--- +servings: 1 +--- +Add @flour{100%g}. + `); + + // Scale by 10x - 100g * 10 = 1000g = 1kg (infers metric system from g unit) + const scaledRecipe = recipe.scaleBy(10); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }); + }); + + it("should apply best unit to range quantities", () => { + const recipe = new Recipe(` +--- +servings: 1 +unit system: metric +--- +Add @flour{100-200%g}. + `); + + // Scale by 10x - range becomes 1000-2000g = 1-2kg + const scaledRecipe = recipe.scaleBy(10); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }, + unit: { name: "kg" }, + }); + }); + + it("should leave text quantities unchanged", () => { + const recipe = new Recipe(` +--- +servings: 1 +unit system: metric +--- +Add @flour{some%g}. + `); + + const scaledRecipe = recipe.scaleBy(2); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { type: "fixed", value: { type: "text", text: "some" } }, + unit: { name: "g" }, + }); + }); + + it("should leave non-convertible units unchanged", () => { + const recipe = new Recipe(` +--- +servings: 1 +unit system: metric +--- +Add @eggs{5%piece}. + `); + + const scaledRecipe = recipe.scaleBy(2); + const step = scaledRecipe.sections[0]!.content[0]! as Step; + const item = step.items.find((i) => i.type === "ingredient") as IngredientItem; + expect(item.alternatives[0]!.itemQuantity).toMatchObject({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 10 } }, + unit: { name: "piece" }, + }); + }); +});