feat(engine): add units, currency, datetime, variables, and functions modules

Extracted and integrated unique feature modules from Epic 2-6 branches:

- units/: 200+ unit conversions across 14 categories, SI prefixes
  (nano-tera), CSS/screen units, binary vs decimal data, custom units
- currency/: fiat (180+ currencies, cached rates, offline fallback),
  crypto (63 coins, CoinGecko), symbol recognition, rate caching
- datetime/: date/time math, 150+ city timezone mappings (chrono-tz),
  business day calculations, unix timestamps, relative expressions
- variables/: line references (lineN, #N, prev/ans), section
  aggregators (sum/total/avg/min/max/count), subtotals, autocomplete
- functions/: trig, log, combinatorics, financial, rounding,
  list operations (min/max/gcd/lcm), video timecodes

585 tests passing across workspace.
This commit is contained in:
C. Cassel
2026-03-17 09:01:13 -04:00
committed by C. Cassel
parent 6a8fecd03e
commit 68fa54615a
41 changed files with 11601 additions and 12 deletions

View File

@@ -0,0 +1,241 @@
//! Factorial and combinatorics: factorial, nPr, nCr.
//!
//! Uses arbitrary-precision internally via u128 for intermediate products
//! to handle factorials up to ~34. For truly large factorials (100!), callers
//! should use `factorial_bigint` which returns a string. The f64-based
//! `factorial` registered here will overflow gracefully to `f64::INFINITY`
//! for n > 170 (standard IEEE 754 limit).
use super::{FunctionError, FunctionRegistry};
/// Compute n! as f64. Returns +Infinity when n > 170.
fn factorial_f64(n: f64) -> Result<f64, FunctionError> {
if n < 0.0 || n.fract() != 0.0 {
return Err(FunctionError::new(
"Factorial is only defined for non-negative integers",
));
}
let n = n as u64;
let mut result: f64 = 1.0;
for i in 2..=n {
result *= i as f64;
}
Ok(result)
}
fn factorial_fn(args: &[f64]) -> Result<f64, FunctionError> {
factorial_f64(args[0])
}
/// Compute nPr = n! / (n-k)! as f64.
fn permutation_fn(args: &[f64]) -> Result<f64, FunctionError> {
let n = args[0];
let k = args[1];
if n.fract() != 0.0 || k.fract() != 0.0 {
return Err(FunctionError::new("nPr requires integer arguments"));
}
if n < 0.0 || k < 0.0 {
return Err(FunctionError::new("nPr requires non-negative arguments"));
}
let n = n as u64;
let k = k as u64;
if k > n {
return Ok(0.0);
}
let mut result: f64 = 1.0;
for i in 0..k {
result *= (n - i) as f64;
}
Ok(result)
}
/// Compute nCr = n! / (k! * (n-k)!) as f64.
fn combination_fn(args: &[f64]) -> Result<f64, FunctionError> {
let n = args[0];
let k = args[1];
if n.fract() != 0.0 || k.fract() != 0.0 {
return Err(FunctionError::new("nCr requires integer arguments"));
}
if n < 0.0 || k < 0.0 {
return Err(FunctionError::new("nCr requires non-negative arguments"));
}
let n = n as u64;
let mut k = k as u64;
if k > n {
return Ok(0.0);
}
// Optimise: C(n,k) == C(n, n-k)
if k > n - k {
k = n - k;
}
let mut result: f64 = 1.0;
for i in 0..k {
result *= (n - i) as f64;
result /= (i + 1) as f64;
}
Ok(result)
}
/// Register combinatorics functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_fixed("factorial", 1, factorial_fn);
reg.register_fixed("nPr", 2, permutation_fn);
reg.register_fixed("nCr", 2, combination_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- factorial ---
#[test]
fn factorial_zero_is_one() {
let v = reg().call("factorial", &[0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn factorial_one_is_one() {
let v = reg().call("factorial", &[1.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn factorial_five_is_120() {
let v = reg().call("factorial", &[5.0]).unwrap();
assert!((v - 120.0).abs() < 1e-10);
}
#[test]
fn factorial_ten_is_3628800() {
let v = reg().call("factorial", &[10.0]).unwrap();
assert!((v - 3_628_800.0).abs() < 1e-6);
}
#[test]
fn factorial_20() {
let v = reg().call("factorial", &[20.0]).unwrap();
// 20! = 2432902008176640000
assert!((v - 2_432_902_008_176_640_000.0).abs() < 1e3);
}
#[test]
fn factorial_negative_error() {
let err = reg().call("factorial", &[-3.0]).unwrap_err();
assert!(err.message.contains("non-negative integers"));
}
#[test]
fn factorial_non_integer_error() {
let err = reg().call("factorial", &[3.5]).unwrap_err();
assert!(err.message.contains("non-negative integers"));
}
// --- nPr ---
#[test]
fn npr_10_3_is_720() {
let v = reg().call("nPr", &[10.0, 3.0]).unwrap();
assert!((v - 720.0).abs() < 1e-10);
}
#[test]
fn npr_5_5_is_120() {
let v = reg().call("nPr", &[5.0, 5.0]).unwrap();
assert!((v - 120.0).abs() < 1e-10);
}
#[test]
fn npr_5_0_is_1() {
let v = reg().call("nPr", &[5.0, 0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn npr_0_0_is_1() {
let v = reg().call("nPr", &[0.0, 0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn npr_k_greater_than_n_is_zero() {
let v = reg().call("nPr", &[5.0, 7.0]).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn npr_negative_error() {
let err = reg().call("nPr", &[-1.0, 2.0]).unwrap_err();
assert!(err.message.contains("non-negative"));
}
#[test]
fn npr_non_integer_error() {
let err = reg().call("nPr", &[5.5, 2.0]).unwrap_err();
assert!(err.message.contains("integer"));
}
// --- nCr ---
#[test]
fn ncr_10_3_is_120() {
let v = reg().call("nCr", &[10.0, 3.0]).unwrap();
assert!((v - 120.0).abs() < 1e-10);
}
#[test]
fn ncr_5_2_is_10() {
let v = reg().call("nCr", &[5.0, 2.0]).unwrap();
assert!((v - 10.0).abs() < 1e-10);
}
#[test]
fn ncr_5_0_is_1() {
let v = reg().call("nCr", &[5.0, 0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn ncr_5_5_is_1() {
let v = reg().call("nCr", &[5.0, 5.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn ncr_k_greater_than_n_is_zero() {
let v = reg().call("nCr", &[5.0, 7.0]).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn ncr_0_0_is_1() {
let v = reg().call("nCr", &[0.0, 0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn ncr_negative_error() {
let err = reg().call("nCr", &[-1.0, 2.0]).unwrap_err();
assert!(err.message.contains("non-negative"));
}
#[test]
fn ncr_non_integer_error() {
let err = reg().call("nCr", &[5.5, 2.0]).unwrap_err();
assert!(err.message.contains("integer"));
}
}

View File

@@ -0,0 +1,184 @@
//! Financial functions: compound interest and mortgage payment.
//!
//! ## compound_interest(principal, rate, periods)
//!
//! Returns `principal * (1 + rate)^periods`.
//! - `principal` — initial investment / loan amount
//! - `rate` — interest rate per period (e.g. 0.05 for 5%)
//! - `periods` — number of compounding periods
//!
//! ## mortgage_payment(principal, annual_rate, years)
//!
//! Returns the monthly payment for a fixed-rate mortgage using the standard
//! amortization formula:
//!
//! M = P * [r(1+r)^n] / [(1+r)^n - 1]
//!
//! where `r` = `annual_rate / 12` and `n` = `years * 12`.
//!
//! If the rate is 0, returns `principal / (years * 12)`.
use super::{FunctionError, FunctionRegistry};
/// compound_interest(principal, rate, periods)
fn compound_interest_fn(args: &[f64]) -> Result<f64, FunctionError> {
let principal = args[0];
let rate = args[1];
let periods = args[2];
if rate < -1.0 {
return Err(FunctionError::new(
"Interest rate must be >= -1 (i.e. at most a 100% loss per period)",
));
}
Ok(principal * (1.0 + rate).powf(periods))
}
/// mortgage_payment(principal, annual_rate, years)
fn mortgage_payment_fn(args: &[f64]) -> Result<f64, FunctionError> {
let principal = args[0];
let annual_rate = args[1];
let years = args[2];
if principal < 0.0 {
return Err(FunctionError::new("Principal must be non-negative"));
}
if annual_rate < 0.0 {
return Err(FunctionError::new("Annual rate must be non-negative"));
}
if years <= 0.0 {
return Err(FunctionError::new("Loan term must be positive"));
}
let n = years * 12.0; // total monthly payments
if annual_rate == 0.0 {
// No interest — just divide evenly.
return Ok(principal / n);
}
let r = annual_rate / 12.0; // monthly rate
let factor = (1.0 + r).powf(n);
let payment = principal * (r * factor) / (factor - 1.0);
Ok(payment)
}
/// Register financial functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_fixed("compound_interest", 3, compound_interest_fn);
reg.register_fixed("mortgage_payment", 3, mortgage_payment_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- compound_interest ---
#[test]
fn compound_interest_basic() {
// $1000 at 5% for 10 years => 1000 * 1.05^10 = 1628.89...
let v = reg()
.call("compound_interest", &[1000.0, 0.05, 10.0])
.unwrap();
assert!((v - 1628.894627).abs() < 0.01);
}
#[test]
fn compound_interest_zero_rate() {
let v = reg()
.call("compound_interest", &[1000.0, 0.0, 10.0])
.unwrap();
assert!((v - 1000.0).abs() < 1e-10);
}
#[test]
fn compound_interest_zero_periods() {
let v = reg()
.call("compound_interest", &[1000.0, 0.05, 0.0])
.unwrap();
assert!((v - 1000.0).abs() < 1e-10);
}
#[test]
fn compound_interest_one_period() {
let v = reg()
.call("compound_interest", &[1000.0, 0.1, 1.0])
.unwrap();
assert!((v - 1100.0).abs() < 1e-10);
}
#[test]
fn compound_interest_negative_rate_too_low() {
let err = reg()
.call("compound_interest", &[1000.0, -1.5, 1.0])
.unwrap_err();
assert!(err.message.contains("rate"));
}
// --- mortgage_payment ---
#[test]
fn mortgage_payment_standard() {
// $200,000 at 6% annual for 30 years => ~$1199.10/month
let v = reg()
.call("mortgage_payment", &[200_000.0, 0.06, 30.0])
.unwrap();
assert!((v - 1199.10).abs() < 0.02);
}
#[test]
fn mortgage_payment_zero_rate() {
// $120,000 at 0% for 10 years => $1000/month
let v = reg()
.call("mortgage_payment", &[120_000.0, 0.0, 10.0])
.unwrap();
assert!((v - 1000.0).abs() < 1e-10);
}
#[test]
fn mortgage_payment_short_term() {
// $12,000 at 12% annual for 1 year => ~$1066.19/month
let v = reg()
.call("mortgage_payment", &[12_000.0, 0.12, 1.0])
.unwrap();
assert!((v - 1066.19).abs() < 0.02);
}
#[test]
fn mortgage_payment_negative_principal_error() {
let err = reg()
.call("mortgage_payment", &[-1000.0, 0.05, 10.0])
.unwrap_err();
assert!(err.message.contains("Principal"));
}
#[test]
fn mortgage_payment_negative_rate_error() {
let err = reg()
.call("mortgage_payment", &[1000.0, -0.05, 10.0])
.unwrap_err();
assert!(err.message.contains("rate"));
}
#[test]
fn mortgage_payment_zero_years_error() {
let err = reg()
.call("mortgage_payment", &[1000.0, 0.05, 0.0])
.unwrap_err();
assert!(err.message.contains("term"));
}
#[test]
fn mortgage_payment_arity_error() {
let err = reg()
.call("mortgage_payment", &[1000.0, 0.05])
.unwrap_err();
assert!(err.message.contains("expects 3 argument"));
}
}

View File

@@ -0,0 +1,223 @@
//! Variadic list operations: min, max, gcd, lcm.
//!
//! All accept 1 or more arguments. `gcd` and `lcm` require integer arguments.
use super::{FunctionError, FunctionRegistry};
fn min_fn(args: &[f64]) -> Result<f64, FunctionError> {
// We already know args.len() >= 1 from the variadic guard.
let mut m = args[0];
for &v in &args[1..] {
if v < m {
m = v;
}
}
Ok(m)
}
fn max_fn(args: &[f64]) -> Result<f64, FunctionError> {
let mut m = args[0];
for &v in &args[1..] {
if v > m {
m = v;
}
}
Ok(m)
}
/// GCD of two non-negative integers using Euclidean algorithm.
fn gcd_pair(mut a: i64, mut b: i64) -> i64 {
a = a.abs();
b = b.abs();
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
/// LCM of two non-negative integers.
fn lcm_pair(a: i64, b: i64) -> i64 {
if a == 0 || b == 0 {
return 0;
}
(a.abs() / gcd_pair(a, b)) * b.abs()
}
fn gcd_fn(args: &[f64]) -> Result<f64, FunctionError> {
for &v in args {
if v.fract() != 0.0 {
return Err(FunctionError::new("gcd requires integer arguments"));
}
}
let mut result = args[0] as i64;
for &v in &args[1..] {
result = gcd_pair(result, v as i64);
}
Ok(result as f64)
}
fn lcm_fn(args: &[f64]) -> Result<f64, FunctionError> {
for &v in args {
if v.fract() != 0.0 {
return Err(FunctionError::new("lcm requires integer arguments"));
}
}
let mut result = args[0] as i64;
for &v in &args[1..] {
result = lcm_pair(result, v as i64);
}
Ok(result as f64)
}
/// Register list-operation functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_variadic("min", 1, min_fn);
reg.register_variadic("max", 1, max_fn);
reg.register_variadic("gcd", 1, gcd_fn);
reg.register_variadic("lcm", 1, lcm_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- min ---
#[test]
fn min_single() {
let v = reg().call("min", &[42.0]).unwrap();
assert!((v - 42.0).abs() < 1e-10);
}
#[test]
fn min_two() {
let v = reg().call("min", &[3.0, 7.0]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn min_many() {
let v = reg().call("min", &[10.0, 3.0, 7.0, 1.0, 5.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn min_negative() {
let v = reg().call("min", &[-5.0, -2.0, -10.0]).unwrap();
assert!((v - (-10.0)).abs() < 1e-10);
}
#[test]
fn min_no_args_error() {
let err = reg().call("min", &[]).unwrap_err();
assert!(err.message.contains("at least 1"));
}
// --- max ---
#[test]
fn max_single() {
let v = reg().call("max", &[42.0]).unwrap();
assert!((v - 42.0).abs() < 1e-10);
}
#[test]
fn max_two() {
let v = reg().call("max", &[3.0, 7.0]).unwrap();
assert!((v - 7.0).abs() < 1e-10);
}
#[test]
fn max_many() {
let v = reg().call("max", &[10.0, 3.0, 7.0, 1.0, 50.0]).unwrap();
assert!((v - 50.0).abs() < 1e-10);
}
#[test]
fn max_no_args_error() {
let err = reg().call("max", &[]).unwrap_err();
assert!(err.message.contains("at least 1"));
}
// --- gcd ---
#[test]
fn gcd_two_numbers() {
let v = reg().call("gcd", &[12.0, 8.0]).unwrap();
assert!((v - 4.0).abs() < 1e-10);
}
#[test]
fn gcd_three_numbers() {
let v = reg().call("gcd", &[12.0, 8.0, 6.0]).unwrap();
assert!((v - 2.0).abs() < 1e-10);
}
#[test]
fn gcd_coprime() {
let v = reg().call("gcd", &[7.0, 13.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn gcd_with_zero() {
let v = reg().call("gcd", &[0.0, 5.0]).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn gcd_single() {
let v = reg().call("gcd", &[42.0]).unwrap();
assert!((v - 42.0).abs() < 1e-10);
}
#[test]
fn gcd_non_integer_error() {
let err = reg().call("gcd", &[3.5, 2.0]).unwrap_err();
assert!(err.message.contains("integer"));
}
// --- lcm ---
#[test]
fn lcm_two_numbers() {
let v = reg().call("lcm", &[4.0, 6.0]).unwrap();
assert!((v - 12.0).abs() < 1e-10);
}
#[test]
fn lcm_three_numbers() {
let v = reg().call("lcm", &[4.0, 6.0, 10.0]).unwrap();
assert!((v - 60.0).abs() < 1e-10);
}
#[test]
fn lcm_with_zero() {
let v = reg().call("lcm", &[0.0, 5.0]).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn lcm_single() {
let v = reg().call("lcm", &[42.0]).unwrap();
assert!((v - 42.0).abs() < 1e-10);
}
#[test]
fn lcm_coprime() {
let v = reg().call("lcm", &[7.0, 13.0]).unwrap();
assert!((v - 91.0).abs() < 1e-10);
}
#[test]
fn lcm_non_integer_error() {
let err = reg().call("lcm", &[3.5, 2.0]).unwrap_err();
assert!(err.message.contains("integer"));
}
}

View File

@@ -0,0 +1,249 @@
//! Logarithmic, exponential, and root functions.
//!
//! - `ln` — natural logarithm (base e)
//! - `log` — common logarithm (base 10)
//! - `log2` — binary logarithm (base 2)
//! - `exp` — e raised to a power
//! - `pow` — base raised to an exponent (2 args)
//! - `sqrt` — square root
//! - `cbrt` — cube root
use super::{FunctionError, FunctionRegistry};
fn ln_fn(args: &[f64]) -> Result<f64, FunctionError> {
let x = args[0];
if x <= 0.0 {
return Err(FunctionError::new(
"Argument out of domain for ln (must be positive)",
));
}
Ok(x.ln())
}
fn log_fn(args: &[f64]) -> Result<f64, FunctionError> {
let x = args[0];
if x <= 0.0 {
return Err(FunctionError::new(
"Argument out of domain for log (must be positive)",
));
}
Ok(x.log10())
}
fn log2_fn(args: &[f64]) -> Result<f64, FunctionError> {
let x = args[0];
if x <= 0.0 {
return Err(FunctionError::new(
"Argument out of domain for log2 (must be positive)",
));
}
Ok(x.log2())
}
fn exp_fn(args: &[f64]) -> Result<f64, FunctionError> {
Ok(args[0].exp())
}
fn pow_fn(args: &[f64]) -> Result<f64, FunctionError> {
Ok(args[0].powf(args[1]))
}
fn sqrt_fn(args: &[f64]) -> Result<f64, FunctionError> {
let x = args[0];
if x < 0.0 {
return Err(FunctionError::new(
"Argument out of domain for sqrt (must be non-negative)",
));
}
Ok(x.sqrt())
}
fn cbrt_fn(args: &[f64]) -> Result<f64, FunctionError> {
Ok(args[0].cbrt())
}
/// Register all logarithmic/exponential/root functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_fixed("ln", 1, ln_fn);
reg.register_fixed("log", 1, log_fn);
reg.register_fixed("log2", 1, log2_fn);
reg.register_fixed("exp", 1, exp_fn);
reg.register_fixed("pow", 2, pow_fn);
reg.register_fixed("sqrt", 1, sqrt_fn);
reg.register_fixed("cbrt", 1, cbrt_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- ln ---
#[test]
fn ln_one_is_zero() {
let v = reg().call("ln", &[1.0]).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn ln_e_is_one() {
let v = reg().call("ln", &[std::f64::consts::E]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn ln_zero_domain_error() {
let err = reg().call("ln", &[0.0]).unwrap_err();
assert!(err.message.contains("out of domain"));
}
#[test]
fn ln_negative_domain_error() {
let err = reg().call("ln", &[-1.0]).unwrap_err();
assert!(err.message.contains("out of domain"));
}
// --- log (base 10) ---
#[test]
fn log_100_is_2() {
let v = reg().call("log", &[100.0]).unwrap();
assert!((v - 2.0).abs() < 1e-10);
}
#[test]
fn log_1000_is_3() {
let v = reg().call("log", &[1000.0]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn log_negative_domain_error() {
let err = reg().call("log", &[-1.0]).unwrap_err();
assert!(err.message.contains("out of domain"));
}
// --- log2 ---
#[test]
fn log2_256_is_8() {
let v = reg().call("log2", &[256.0]).unwrap();
assert!((v - 8.0).abs() < 1e-10);
}
#[test]
fn log2_one_is_zero() {
let v = reg().call("log2", &[1.0]).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn log2_negative_domain_error() {
let err = reg().call("log2", &[-5.0]).unwrap_err();
assert!(err.message.contains("out of domain"));
}
// --- exp ---
#[test]
fn exp_zero_is_one() {
let v = reg().call("exp", &[0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn exp_one_is_e() {
let v = reg().call("exp", &[1.0]).unwrap();
assert!((v - std::f64::consts::E).abs() < 1e-10);
}
// --- pow ---
#[test]
fn pow_2_10_is_1024() {
let v = reg().call("pow", &[2.0, 10.0]).unwrap();
assert!((v - 1024.0).abs() < 1e-10);
}
#[test]
fn pow_3_0_is_1() {
let v = reg().call("pow", &[3.0, 0.0]).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
// --- sqrt ---
#[test]
fn sqrt_144_is_12() {
let v = reg().call("sqrt", &[144.0]).unwrap();
assert!((v - 12.0).abs() < 1e-10);
}
#[test]
fn sqrt_2_approx() {
let v = reg().call("sqrt", &[2.0]).unwrap();
assert!((v - std::f64::consts::SQRT_2).abs() < 1e-10);
}
#[test]
fn sqrt_zero_is_zero() {
let v = reg().call("sqrt", &[0.0]).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn sqrt_negative_domain_error() {
let err = reg().call("sqrt", &[-4.0]).unwrap_err();
assert!(err.message.contains("out of domain"));
}
// --- cbrt ---
#[test]
fn cbrt_27_is_3() {
let v = reg().call("cbrt", &[27.0]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn cbrt_8_is_2() {
let v = reg().call("cbrt", &[8.0]).unwrap();
assert!((v - 2.0).abs() < 1e-10);
}
#[test]
fn cbrt_neg_8_is_neg_2() {
let v = reg().call("cbrt", &[-8.0]).unwrap();
assert!((v - (-2.0)).abs() < 1e-10);
}
// --- composition ---
#[test]
fn ln_exp_roundtrip() {
let r = reg();
let inner = r.call("exp", &[5.0]).unwrap();
let v = r.call("ln", &[inner]).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn exp_ln_roundtrip() {
let r = reg();
let inner = r.call("ln", &[10.0]).unwrap();
let v = r.call("exp", &[inner]).unwrap();
assert!((v - 10.0).abs() < 1e-10);
}
#[test]
fn sqrt_pow_roundtrip() {
let r = reg();
let inner = r.call("pow", &[3.0, 2.0]).unwrap();
let v = r.call("sqrt", &[inner]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,321 @@
//! Function registry and dispatch for CalcPad math functions.
//!
//! Provides a [`FunctionRegistry`] that maps function names to typed
//! implementations across all function categories (trig, logarithmic,
//! combinatorics, financial, rounding, list ops, timecodes).
pub mod combinatorics;
pub mod financial;
pub mod list_ops;
pub mod logarithmic;
pub mod rounding;
pub mod timecodes;
pub mod trig;
use std::collections::HashMap;
/// Error type returned by function evaluation.
#[derive(Debug, Clone, PartialEq)]
pub struct FunctionError {
pub message: String,
}
impl FunctionError {
pub fn new(msg: impl Into<String>) -> Self {
Self {
message: msg.into(),
}
}
}
impl std::fmt::Display for FunctionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for FunctionError {}
/// Angle mode for trigonometric functions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AngleMode {
Radians,
Degrees,
}
impl Default for AngleMode {
fn default() -> Self {
AngleMode::Radians
}
}
/// The signature of a function: how many args it accepts and how to call it.
#[derive(Clone)]
enum FnImpl {
/// Fixed-arity function (e.g. sin takes 1 arg, pow takes 2).
Fixed {
arity: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
},
/// Variadic function that accepts 1..N args (e.g. min, max, gcd, lcm).
Variadic {
min_args: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
},
/// Angle-aware trig function (1 arg + angle mode + force-degrees flag).
Trig {
func: fn(f64, AngleMode, bool) -> Result<f64, FunctionError>,
},
/// Variable-arity function with a known range (e.g. round takes 1 or 2).
RangeArity {
min_args: usize,
max_args: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
},
/// Timecode function that operates on string-like frame values.
Timecode {
func: fn(&[f64]) -> Result<f64, FunctionError>,
},
}
/// Central registry mapping function names to their implementations.
pub struct FunctionRegistry {
functions: HashMap<String, FnImpl>,
}
impl FunctionRegistry {
/// Build a new registry pre-loaded with all built-in functions.
pub fn new() -> Self {
let mut reg = Self {
functions: HashMap::new(),
};
trig::register(&mut reg);
logarithmic::register(&mut reg);
combinatorics::register(&mut reg);
financial::register(&mut reg);
rounding::register(&mut reg);
list_ops::register(&mut reg);
timecodes::register(&mut reg);
reg
}
// ---- registration helpers (called by sub-modules) ----
pub(crate) fn register_trig(
&mut self,
name: &str,
func: fn(f64, AngleMode, bool) -> Result<f64, FunctionError>,
) {
self.functions
.insert(name.to_string(), FnImpl::Trig { func });
}
pub(crate) fn register_fixed(
&mut self,
name: &str,
arity: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
) {
self.functions
.insert(name.to_string(), FnImpl::Fixed { arity, func });
}
pub(crate) fn register_variadic(
&mut self,
name: &str,
min_args: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
) {
self.functions
.insert(name.to_string(), FnImpl::Variadic { min_args, func });
}
pub(crate) fn register_range_arity(
&mut self,
name: &str,
min_args: usize,
max_args: usize,
func: fn(&[f64]) -> Result<f64, FunctionError>,
) {
self.functions.insert(
name.to_string(),
FnImpl::RangeArity {
min_args,
max_args,
func,
},
);
}
#[allow(dead_code)]
pub(crate) fn register_timecode(
&mut self,
name: &str,
func: fn(&[f64]) -> Result<f64, FunctionError>,
) {
self.functions
.insert(name.to_string(), FnImpl::Timecode { func });
}
// ---- dispatch ----
/// Returns true if `name` is a registered function.
pub fn has_function(&self, name: &str) -> bool {
self.functions.contains_key(name)
}
/// Returns true if `name` is a trig function (needs angle mode).
pub fn is_trig(&self, name: &str) -> bool {
matches!(self.functions.get(name), Some(FnImpl::Trig { .. }))
}
/// Call a trig function with the given argument, angle mode, and
/// force-degrees flag.
pub fn call_trig(
&self,
name: &str,
arg: f64,
mode: AngleMode,
force_degrees: bool,
) -> Result<f64, FunctionError> {
match self.functions.get(name) {
Some(FnImpl::Trig { func }) => func(arg, mode, force_degrees),
Some(_) => Err(FunctionError::new(format!(
"{} is not a trigonometric function",
name
))),
None => Err(FunctionError::new(format!("Unknown function: {}", name))),
}
}
/// Call any non-trig function with a slice of evaluated arguments.
pub fn call(&self, name: &str, args: &[f64]) -> Result<f64, FunctionError> {
match self.functions.get(name) {
Some(FnImpl::Fixed { arity, func }) => {
if args.len() != *arity {
return Err(FunctionError::new(format!(
"{} expects {} argument(s), got {}",
name,
arity,
args.len()
)));
}
func(args)
}
Some(FnImpl::Variadic { min_args, func }) => {
if args.len() < *min_args {
return Err(FunctionError::new(format!(
"{} requires at least {} argument(s), got {}",
name,
min_args,
args.len()
)));
}
func(args)
}
Some(FnImpl::RangeArity {
min_args,
max_args,
func,
}) => {
if args.len() < *min_args || args.len() > *max_args {
return Err(FunctionError::new(format!(
"{} expects {}-{} argument(s), got {}",
name,
min_args,
max_args,
args.len()
)));
}
func(args)
}
Some(FnImpl::Trig { func }) => {
// Convenience: if called via `call()`, default to radians, no force-degrees.
if args.len() != 1 {
return Err(FunctionError::new(format!(
"{} expects 1 argument, got {}",
name,
args.len()
)));
}
func(args[0], AngleMode::Radians, false)
}
Some(FnImpl::Timecode { func }) => func(args),
None => Err(FunctionError::new(format!("Unknown function: {}", name))),
}
}
/// List all registered function names (sorted alphabetically).
pub fn function_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
}
impl Default for FunctionRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_contains_all_categories() {
let reg = FunctionRegistry::new();
// Trig
assert!(reg.has_function("sin"));
assert!(reg.has_function("cos"));
assert!(reg.has_function("tanh"));
// Logarithmic
assert!(reg.has_function("ln"));
assert!(reg.has_function("log"));
assert!(reg.has_function("sqrt"));
// Combinatorics
assert!(reg.has_function("factorial"));
assert!(reg.has_function("nPr"));
assert!(reg.has_function("nCr"));
// Financial
assert!(reg.has_function("compound_interest"));
assert!(reg.has_function("mortgage_payment"));
// Rounding
assert!(reg.has_function("round"));
assert!(reg.has_function("floor"));
assert!(reg.has_function("ceil"));
// List
assert!(reg.has_function("min"));
assert!(reg.has_function("max"));
assert!(reg.has_function("gcd"));
assert!(reg.has_function("lcm"));
// Timecodes
assert!(reg.has_function("tc_to_frames"));
assert!(reg.has_function("frames_to_tc"));
}
#[test]
fn trig_dispatch_works() {
let reg = FunctionRegistry::new();
assert!(reg.is_trig("sin"));
let val = reg
.call_trig("sin", 0.0, AngleMode::Radians, false)
.unwrap();
assert!((val - 0.0).abs() < 1e-10);
}
#[test]
fn call_unknown_function_returns_error() {
let reg = FunctionRegistry::new();
let err = reg.call("nonexistent_fn", &[1.0]).unwrap_err();
assert!(err.message.contains("Unknown function"));
}
#[test]
fn arity_mismatch_returns_error() {
let reg = FunctionRegistry::new();
let err = reg.call("sqrt", &[1.0, 2.0]).unwrap_err();
assert!(err.message.contains("expects 1 argument"));
}
}

View File

@@ -0,0 +1,191 @@
//! Rounding functions: round, floor, ceil, round_to.
//!
//! - `round(x)` — round to the nearest integer (half rounds away from 0)
//! - `round(x, n)` — round to n decimal places
//! - `floor(x)` — round toward negative infinity
//! - `ceil(x)` — round toward positive infinity
//! - `round_to(x, step)` — round x to the nearest multiple of step
use super::{FunctionError, FunctionRegistry};
fn floor_fn(args: &[f64]) -> Result<f64, FunctionError> {
Ok(args[0].floor())
}
fn ceil_fn(args: &[f64]) -> Result<f64, FunctionError> {
Ok(args[0].ceil())
}
fn round_fn(args: &[f64]) -> Result<f64, FunctionError> {
let value = args[0];
if args.len() == 1 {
return Ok(value.round());
}
let decimals = args[1];
if decimals.fract() != 0.0 || decimals < 0.0 {
return Err(FunctionError::new(
"round decimal places must be a non-negative integer",
));
}
let factor = 10f64.powi(decimals as i32);
Ok((value * factor).round() / factor)
}
/// Round x to the nearest multiple of step.
/// `round_to(17, 5)` => 15, `round_to(18, 5)` => 20.
fn round_to_fn(args: &[f64]) -> Result<f64, FunctionError> {
let x = args[0];
let step = args[1];
if step == 0.0 {
return Err(FunctionError::new(
"round_to step must be non-zero",
));
}
Ok((x / step).round() * step)
}
/// Register rounding functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_fixed("floor", 1, floor_fn);
reg.register_fixed("ceil", 1, ceil_fn);
reg.register_range_arity("round", 1, 2, round_fn);
reg.register_fixed("round_to", 2, round_to_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- floor ---
#[test]
fn floor_positive_fraction() {
let v = reg().call("floor", &[3.7]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn floor_negative_fraction() {
let v = reg().call("floor", &[-3.2]).unwrap();
assert!((v - (-4.0)).abs() < 1e-10);
}
#[test]
fn floor_integer_unchanged() {
let v = reg().call("floor", &[5.0]).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn floor_zero() {
let v = reg().call("floor", &[0.0]).unwrap();
assert!(v.abs() < 1e-10);
}
// --- ceil ---
#[test]
fn ceil_positive_fraction() {
let v = reg().call("ceil", &[3.2]).unwrap();
assert!((v - 4.0).abs() < 1e-10);
}
#[test]
fn ceil_negative_fraction() {
let v = reg().call("ceil", &[-3.7]).unwrap();
assert!((v - (-3.0)).abs() < 1e-10);
}
#[test]
fn ceil_integer_unchanged() {
let v = reg().call("ceil", &[5.0]).unwrap();
assert!((v - 5.0).abs() < 1e-10);
}
#[test]
fn ceil_zero() {
let v = reg().call("ceil", &[0.0]).unwrap();
assert!(v.abs() < 1e-10);
}
// --- round ---
#[test]
fn round_half_up() {
let v = reg().call("round", &[2.5]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn round_1_5() {
let v = reg().call("round", &[1.5]).unwrap();
assert!((v - 2.0).abs() < 1e-10);
}
#[test]
fn round_negative() {
let v = reg().call("round", &[-1.5]).unwrap();
// Rust's f64::round rounds half away from zero, so -1.5 => -2.0
assert!((v - (-2.0)).abs() < 1e-10);
}
#[test]
fn round_with_decimal_places() {
let v = reg().call("round", &[3.456, 2.0]).unwrap();
assert!((v - 3.46).abs() < 1e-10);
}
#[test]
fn round_with_zero_places() {
let v = reg().call("round", &[3.456, 0.0]).unwrap();
assert!((v - 3.0).abs() < 1e-10);
}
#[test]
fn round_with_one_place() {
let v = reg().call("round", &[1.234, 1.0]).unwrap();
assert!((v - 1.2).abs() < 1e-10);
}
#[test]
fn round_negative_decimal_places_error() {
let err = reg().call("round", &[3.456, -1.0]).unwrap_err();
assert!(err.message.contains("non-negative"));
}
// --- round_to (nearest N) ---
#[test]
fn round_to_nearest_5() {
let v = reg().call("round_to", &[17.0, 5.0]).unwrap();
assert!((v - 15.0).abs() < 1e-10);
}
#[test]
fn round_to_nearest_5_up() {
let v = reg().call("round_to", &[18.0, 5.0]).unwrap();
assert!((v - 20.0).abs() < 1e-10);
}
#[test]
fn round_to_nearest_10() {
let v = reg().call("round_to", &[84.0, 10.0]).unwrap();
assert!((v - 80.0).abs() < 1e-10);
}
#[test]
fn round_to_nearest_0_25() {
let v = reg().call("round_to", &[3.3, 0.25]).unwrap();
assert!((v - 3.25).abs() < 1e-10);
}
#[test]
fn round_to_zero_step_error() {
let err = reg().call("round_to", &[10.0, 0.0]).unwrap_err();
assert!(err.message.contains("non-zero"));
}
}

View File

@@ -0,0 +1,366 @@
//! Video timecode arithmetic.
//!
//! Timecodes represent positions in video as `HH:MM:SS:FF` where FF is a
//! frame count within the current second. The number of frames per second
//! (fps) determines the range of FF (0..fps-1).
//!
//! ## Functions
//!
//! - `tc_to_frames(hours, minutes, seconds, frames, fps)` — convert a
//! timecode to a total frame count.
//! - `frames_to_tc(total_frames, fps)` — convert total frames back to a
//! packed timecode value `HH * 1_000_000 + MM * 10_000 + SS * 100 + FF`
//! for easy extraction of components.
//! - `tc_add_frames(hours, minutes, seconds, frames, fps, add_frames)` —
//! add (or subtract) a number of frames to a timecode and return the new
//! total frame count.
//!
//! Common fps values: 24, 25, 29.97 (NTSC drop-frame), 30, 48, 60.
//!
//! For now we work in non-drop-frame (NDF) mode. Drop-frame support can be
//! added later.
use super::{FunctionError, FunctionRegistry};
/// Convert a timecode (H, M, S, F, fps) to total frame count.
fn tc_to_frames_fn(args: &[f64]) -> Result<f64, FunctionError> {
if args.len() != 5 {
return Err(FunctionError::new(
"tc_to_frames expects 5 arguments: hours, minutes, seconds, frames, fps",
));
}
let hours = args[0];
let minutes = args[1];
let seconds = args[2];
let frames = args[3];
let fps = args[4];
validate_timecode_components(hours, minutes, seconds, frames, fps)?;
let fps_i = fps as u64;
let total = (hours as u64) * 3600 * fps_i
+ (minutes as u64) * 60 * fps_i
+ (seconds as u64) * fps_i
+ (frames as u64);
Ok(total as f64)
}
/// Convert total frames to a packed timecode: HH*1_000_000 + MM*10_000 + SS*100 + FF.
/// Returns the packed value. Also returns components via the packed encoding.
fn frames_to_tc_fn(args: &[f64]) -> Result<f64, FunctionError> {
if args.len() != 2 {
return Err(FunctionError::new(
"frames_to_tc expects 2 arguments: total_frames, fps",
));
}
let total = args[0];
let fps = args[1];
if total < 0.0 || total.fract() != 0.0 {
return Err(FunctionError::new(
"total_frames must be a non-negative integer",
));
}
if fps <= 0.0 || fps.fract() != 0.0 {
return Err(FunctionError::new(
"fps must be a positive integer",
));
}
let total = total as u64;
let fps_i = fps as u64;
let ff = total % fps_i;
let rem = total / fps_i;
let ss = rem % 60;
let rem = rem / 60;
let mm = rem % 60;
let hh = rem / 60;
// Pack into a single number: HH_MM_SS_FF
let packed = hh * 1_000_000 + mm * 10_000 + ss * 100 + ff;
Ok(packed as f64)
}
/// Add frames to a timecode and return new total frame count.
fn tc_add_frames_fn(args: &[f64]) -> Result<f64, FunctionError> {
if args.len() != 6 {
return Err(FunctionError::new(
"tc_add_frames expects 6 arguments: hours, minutes, seconds, frames, fps, add_frames",
));
}
let hours = args[0];
let minutes = args[1];
let seconds = args[2];
let frames = args[3];
let fps = args[4];
let add_frames = args[5];
validate_timecode_components(hours, minutes, seconds, frames, fps)?;
let fps_i = fps as u64;
let total = (hours as u64) * 3600 * fps_i
+ (minutes as u64) * 60 * fps_i
+ (seconds as u64) * fps_i
+ (frames as u64);
let new_total = total as i64 + add_frames as i64;
if new_total < 0 {
return Err(FunctionError::new(
"Resulting timecode would be negative",
));
}
Ok(new_total as f64)
}
fn validate_timecode_components(
hours: f64,
minutes: f64,
seconds: f64,
frames: f64,
fps: f64,
) -> Result<(), FunctionError> {
if fps <= 0.0 || fps.fract() != 0.0 {
return Err(FunctionError::new("fps must be a positive integer"));
}
if hours < 0.0 || hours.fract() != 0.0 {
return Err(FunctionError::new(
"hours must be a non-negative integer",
));
}
if minutes < 0.0 || minutes >= 60.0 || minutes.fract() != 0.0 {
return Err(FunctionError::new(
"minutes must be an integer in 0..59",
));
}
if seconds < 0.0 || seconds >= 60.0 || seconds.fract() != 0.0 {
return Err(FunctionError::new(
"seconds must be an integer in 0..59",
));
}
if frames < 0.0 || frames >= fps || frames.fract() != 0.0 {
return Err(FunctionError::new(format!(
"frames must be an integer in 0..{} (fps={})",
fps as u64 - 1,
fps as u64,
)));
}
Ok(())
}
/// Register timecode functions.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_fixed("tc_to_frames", 5, tc_to_frames_fn);
reg.register_fixed("frames_to_tc", 2, frames_to_tc_fn);
reg.register_fixed("tc_add_frames", 6, tc_add_frames_fn);
}
#[cfg(test)]
mod tests {
use super::*;
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- tc_to_frames ---
#[test]
fn tc_to_frames_zero() {
// 00:00:00:00 at 24fps => 0 frames
let v = reg()
.call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 24.0])
.unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_one_second_24fps() {
// 00:00:01:00 at 24fps => 24 frames
let v = reg()
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 24.0])
.unwrap();
assert!((v - 24.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_one_minute_24fps() {
// 00:01:00:00 at 24fps => 1440 frames
let v = reg()
.call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 24.0])
.unwrap();
assert!((v - 1440.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_one_hour_24fps() {
// 01:00:00:00 at 24fps => 86400 frames
let v = reg()
.call("tc_to_frames", &[1.0, 0.0, 0.0, 0.0, 24.0])
.unwrap();
assert!((v - 86400.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_mixed() {
// 01:02:03:04 at 24fps => 1*3600*24 + 2*60*24 + 3*24 + 4 = 86400 + 2880 + 72 + 4 = 89356
let v = reg()
.call("tc_to_frames", &[1.0, 2.0, 3.0, 4.0, 24.0])
.unwrap();
assert!((v - 89356.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_25fps() {
// 00:00:01:00 at 25fps => 25 frames
let v = reg()
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 25.0])
.unwrap();
assert!((v - 25.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_30fps() {
// 00:00:01:00 at 30fps => 30 frames
let v = reg()
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 30.0])
.unwrap();
assert!((v - 30.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_60fps() {
// 00:01:00:00 at 60fps => 3600 frames
let v = reg()
.call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 60.0])
.unwrap();
assert!((v - 3600.0).abs() < 1e-10);
}
#[test]
fn tc_to_frames_invalid_minutes() {
let err = reg()
.call("tc_to_frames", &[0.0, 60.0, 0.0, 0.0, 24.0])
.unwrap_err();
assert!(err.message.contains("minutes"));
}
#[test]
fn tc_to_frames_invalid_frames() {
// Frames >= fps is invalid
let err = reg()
.call("tc_to_frames", &[0.0, 0.0, 0.0, 24.0, 24.0])
.unwrap_err();
assert!(err.message.contains("frames"));
}
#[test]
fn tc_to_frames_invalid_fps() {
let err = reg()
.call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 0.0])
.unwrap_err();
assert!(err.message.contains("fps"));
}
// --- frames_to_tc ---
#[test]
fn frames_to_tc_zero() {
let v = reg().call("frames_to_tc", &[0.0, 24.0]).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_one_second() {
// 24 frames at 24fps => 00:00:01:00 => packed 100
let v = reg().call("frames_to_tc", &[24.0, 24.0]).unwrap();
assert!((v - 100.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_one_minute() {
// 1440 frames at 24fps => 00:01:00:00 => packed 10000
let v = reg().call("frames_to_tc", &[1440.0, 24.0]).unwrap();
assert!((v - 10000.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_one_hour() {
// 86400 frames at 24fps => 01:00:00:00 => packed 1000000
let v = reg().call("frames_to_tc", &[86400.0, 24.0]).unwrap();
assert!((v - 1_000_000.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_mixed() {
// 89356 frames at 24fps => 01:02:03:04 => packed 1020304
let v = reg().call("frames_to_tc", &[89356.0, 24.0]).unwrap();
assert!((v - 1_020_304.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_roundtrip() {
let r = reg();
// Convert to frames then back
let frames = r
.call("tc_to_frames", &[2.0, 30.0, 15.0, 12.0, 30.0])
.unwrap();
let packed = r.call("frames_to_tc", &[frames, 30.0]).unwrap();
// 02:30:15:12 => packed 2301512
assert!((packed - 2_301_512.0).abs() < 1e-10);
}
#[test]
fn frames_to_tc_negative_error() {
let err = reg().call("frames_to_tc", &[-1.0, 24.0]).unwrap_err();
assert!(err.message.contains("non-negative"));
}
// --- tc_add_frames ---
#[test]
fn tc_add_frames_simple() {
let r = reg();
// 00:00:00:00 at 24fps + 48 frames => 48
let v = r
.call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, 48.0])
.unwrap();
assert!((v - 48.0).abs() < 1e-10);
}
#[test]
fn tc_add_frames_subtract() {
let r = reg();
// 00:00:02:00 at 24fps = 48 frames, subtract 24 => 24
let v = r
.call("tc_add_frames", &[0.0, 0.0, 2.0, 0.0, 24.0, -24.0])
.unwrap();
assert!((v - 24.0).abs() < 1e-10);
}
#[test]
fn tc_add_frames_negative_result_error() {
let r = reg();
let err = r
.call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, -1.0])
.unwrap_err();
assert!(err.message.contains("negative"));
}
#[test]
fn tc_add_frames_cross_minute_boundary() {
let r = reg();
// 00:00:59:23 at 24fps + 1 frame
let base = r
.call("tc_to_frames", &[0.0, 0.0, 59.0, 23.0, 24.0])
.unwrap();
let v = r
.call("tc_add_frames", &[0.0, 0.0, 59.0, 23.0, 24.0, 1.0])
.unwrap();
assert!((v - (base + 1.0)).abs() < 1e-10);
// Verify the result converts to 00:01:00:00
let packed = r.call("frames_to_tc", &[v, 24.0]).unwrap();
assert!((packed - 10000.0).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,255 @@
//! Trigonometric functions: sin, cos, tan, asin, acos, atan, sinh, cosh, tanh.
//!
//! All trig functions are angle-mode aware. When `AngleMode::Degrees` is active
//! (or when `force_degrees` is true), inputs to forward trig functions are
//! converted from degrees to radians, and outputs of inverse trig functions
//! are converted from radians to degrees. Hyperbolic functions ignore angle mode.
use super::{AngleMode, FunctionError, FunctionRegistry};
const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
fn to_radians(value: f64, mode: AngleMode, force_degrees: bool) -> f64 {
if force_degrees || mode == AngleMode::Degrees {
value * DEG_TO_RAD
} else {
value
}
}
fn from_radians(value: f64, mode: AngleMode) -> f64 {
if mode == AngleMode::Degrees {
value * RAD_TO_DEG
} else {
value
}
}
// --- forward trig ---
fn sin_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
Ok(to_radians(arg, mode, force_deg).sin())
}
fn cos_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
Ok(to_radians(arg, mode, force_deg).cos())
}
fn tan_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
Ok(to_radians(arg, mode, force_deg).tan())
}
// --- inverse trig ---
fn asin_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
if arg < -1.0 || arg > 1.0 {
return Err(FunctionError::new(
"Argument out of domain for asin (must be between -1 and 1)",
));
}
Ok(from_radians(arg.asin(), mode))
}
fn acos_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
if arg < -1.0 || arg > 1.0 {
return Err(FunctionError::new(
"Argument out of domain for acos (must be between -1 and 1)",
));
}
Ok(from_radians(arg.acos(), mode))
}
fn atan_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
Ok(from_radians(arg.atan(), mode))
}
// --- hyperbolic (angle-mode independent) ---
fn sinh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
Ok(arg.sinh())
}
fn cosh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
Ok(arg.cosh())
}
fn tanh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
Ok(arg.tanh())
}
/// Register all trig functions into the given registry.
pub fn register(reg: &mut FunctionRegistry) {
reg.register_trig("sin", sin_fn);
reg.register_trig("cos", cos_fn);
reg.register_trig("tan", tan_fn);
reg.register_trig("asin", asin_fn);
reg.register_trig("acos", acos_fn);
reg.register_trig("atan", atan_fn);
reg.register_trig("sinh", sinh_fn);
reg.register_trig("cosh", cosh_fn);
reg.register_trig("tanh", tanh_fn);
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, SQRT_2};
fn reg() -> FunctionRegistry {
FunctionRegistry::new()
}
// --- radians mode (default) ---
#[test]
fn sin_zero_radians() {
let r = reg();
let v = r.call_trig("sin", 0.0, AngleMode::Radians, false).unwrap();
assert!((v - 0.0).abs() < 1e-10);
}
#[test]
fn sin_pi_radians() {
let r = reg();
let v = r.call_trig("sin", PI, AngleMode::Radians, false).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn cos_zero_is_one() {
let r = reg();
let v = r.call_trig("cos", 0.0, AngleMode::Radians, false).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn tan_zero_is_zero() {
let r = reg();
let v = r.call_trig("tan", 0.0, AngleMode::Radians, false).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn asin_one_is_pi_over_2() {
let r = reg();
let v = r.call_trig("asin", 1.0, AngleMode::Radians, false).unwrap();
assert!((v - FRAC_PI_2).abs() < 1e-10);
}
#[test]
fn acos_one_is_zero() {
let r = reg();
let v = r.call_trig("acos", 1.0, AngleMode::Radians, false).unwrap();
assert!(v.abs() < 1e-10);
}
#[test]
fn atan_one_is_pi_over_4() {
let r = reg();
let v = r.call_trig("atan", 1.0, AngleMode::Radians, false).unwrap();
assert!((v - FRAC_PI_4).abs() < 1e-10);
}
// --- degrees mode ---
#[test]
fn sin_90_degrees_is_one() {
let r = reg();
let v = r.call_trig("sin", 90.0, AngleMode::Degrees, false).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn cos_zero_degrees_is_one() {
let r = reg();
let v = r.call_trig("cos", 0.0, AngleMode::Degrees, false).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn acos_half_degrees_is_60() {
let r = reg();
let v = r
.call_trig("acos", 0.5, AngleMode::Degrees, false)
.unwrap();
assert!((v - 60.0).abs() < 1e-10);
}
#[test]
fn atan_one_degrees_is_45() {
let r = reg();
let v = r
.call_trig("atan", 1.0, AngleMode::Degrees, false)
.unwrap();
assert!((v - 45.0).abs() < 1e-10);
}
// --- force-degrees override (radians mode, but degree symbol present) ---
#[test]
fn sin_45_force_degrees_in_rad_mode() {
let r = reg();
let v = r.call_trig("sin", 45.0, AngleMode::Radians, true).unwrap();
assert!((v - SQRT_2 / 2.0).abs() < 1e-6);
}
#[test]
fn tan_45_force_degrees() {
let r = reg();
let v = r.call_trig("tan", 45.0, AngleMode::Radians, true).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
// --- hyperbolic ---
#[test]
fn sinh_one() {
let r = reg();
let v = r.call_trig("sinh", 1.0, AngleMode::Radians, false).unwrap();
assert!((v - 1.1752011936438014).abs() < 1e-6);
}
#[test]
fn cosh_zero_is_one() {
let r = reg();
let v = r.call_trig("cosh", 0.0, AngleMode::Radians, false).unwrap();
assert!((v - 1.0).abs() < 1e-10);
}
#[test]
fn tanh_zero_is_zero() {
let r = reg();
let v = r.call_trig("tanh", 0.0, AngleMode::Radians, false).unwrap();
assert!(v.abs() < 1e-10);
}
// --- domain errors ---
#[test]
fn asin_out_of_domain() {
let r = reg();
let err = r
.call_trig("asin", 2.0, AngleMode::Radians, false)
.unwrap_err();
assert!(err.message.contains("out of domain"));
}
#[test]
fn asin_negative_out_of_domain() {
let r = reg();
let err = r
.call_trig("asin", -2.0, AngleMode::Radians, false)
.unwrap_err();
assert!(err.message.contains("out of domain"));
}
#[test]
fn acos_out_of_domain() {
let r = reg();
let err = r
.call_trig("acos", 2.0, AngleMode::Radians, false)
.unwrap_err();
assert!(err.message.contains("out of domain"));
}
}