diff --git a/Cargo.lock b/Cargo.lock index ac62ebd..768d827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3904,7 +3904,7 @@ dependencies = [ [[package]] name = "rcalc" -version = "0.1.21" +version = "0.1.25" dependencies = [ "slint", "slint-build", diff --git a/Cargo.toml b/Cargo.toml index b55fb72..7c9e55d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rcalc" -version = "0.1.21" +version = "0.1.25" edition = "2021" build = "build.rs" diff --git a/dist/rcalc b/dist/rcalc index 37d272e..71faa5c 100755 Binary files a/dist/rcalc and b/dist/rcalc differ diff --git a/src/convert.rs b/src/convert.rs index 8ef71c0..e43cfcb 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -391,7 +391,9 @@ impl Converter { if !ch.is_ascii_hexdigit() { return; } - if self.draft == "0" { + // A synced all-zero draft ("0", "0000") is a placeholder: + // first digit replaces it instead of being swallowed. + if self.draft.chars().all(|c| c == '0') { self.draft = ch.to_string(); } else if self.draft.len() < 4 { self.draft.push(ch); @@ -478,6 +480,11 @@ impl Converter { if t.is_empty() || t == "-" || t == "." || t == "-." { return Ok(()); } + // Exponent being typed: `1E`, `1E+`, `1E-` — intermediate + // state, keep the previous value without flagging an error. + if t.ends_with('E') || t.ends_with("E+") || t.ends_with("E-") { + return Ok(()); + } match t.parse::() { Ok(v) => { self.float_bits = v.to_bits(); @@ -777,8 +784,20 @@ fn format_float(v: f32) -> String { "Inf".into() }; } - let s = format!("{v}"); - s + if v == 0.0 { + // Keep IEEE −0 visible (bits 0x8000_0000). + return if v.is_sign_negative() { + "-0".into() + } else { + "0".into() + }; + } + // Scientific notation for extremes: keeps f32::MAX readable and the + // draft re-parsable (`3.4028235e38`.parse::() works). + if v.abs() < 1e-10 || v.abs() >= 1e16 { + return format!("{v:e}"); + } + format!("{v}") } /// IEEE float bytes in natural order A B C D (big-endian bit pattern), @@ -889,4 +908,71 @@ mod tests { assert_eq!(c.ascii_code(), b'A'); assert_eq!(c.ascii_char_text(), "A"); } + + #[test] + fn float_draft_exponent_in_progress() { + // Typing `1E` is an intermediate state, not an error. + let mut c = Converter::new(); + c.clear_active(); // draft "0" + c.input_char('1'); + c.input_char('E'); + assert_eq!(c.error(), None, "typing `1E` must not flag invalid float"); + c.input_char('5'); + assert_eq!(c.error(), None); + assert_eq!(c.float_value(), 1e5); + } + + #[test] + fn float_draft_exponent_sign_in_progress() { + let mut c = Converter::new(); + c.clear_active(); // draft "0" + c.input_char('1'); + c.input_char('E'); + c.input_char('-'); + assert_eq!(c.error(), None, "typing `1E-` must not flag invalid float"); + c.input_char('9'); + assert_eq!(c.error(), None); + assert_eq!(c.float_value(), 1e-9); + } + + #[test] + fn negative_zero_words() { + // IEEE -0.0 must round-trip through the word view (0x8000 0000). + let mut c = Converter::new(); + c.clear_active(); // draft "0", float +0 + c.input_char('-'); + c.input_char('0'); + assert_eq!(c.words(), (0x8000, 0x0000)); + assert_eq!(c.float_text(), "-0"); + } + + #[test] + fn word_edit_updates_float() { + let mut c = Converter::new(); + c.clear_active(); // float = 0 + c.set_active(ConvField::Word0); + for ch in "3F80".chars() { + c.input_char(ch); + } + assert_eq!(c.float_value(), 1.0); + } + + #[test] + fn huge_float_uses_scientific() { + // f32::MAX plain-decimal display is 39 chars; exponent form is + // readable and re-parsable by the float draft parser. + let t = format_float(f32::MAX); + assert!(t.contains('e'), "expected scientific notation, got {t}"); + assert_eq!(t.parse::().unwrap(), f32::MAX); + + let t = format_float(1e-20); + assert!(t.contains('e'), "expected scientific notation, got {t}"); + assert_eq!(t.parse::().unwrap(), 1e-20); + } + + #[test] + fn negative_zero_displayed() { + assert_eq!(format_float(-0.0), "-0"); + assert_eq!(format_float(0.0), "0"); + } } diff --git a/src/engine.rs b/src/engine.rs index 3bb3b39..fde8dce 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -77,18 +77,27 @@ impl Rational { Self { num: n, den: 1 } } - /// Parse a decimal literal like `12`, `-3.5`, `0.001` into an exact fraction. + /// Parse a decimal literal like `12`, `-3.5`, `0.001`, `1.5e-9` into an + /// exact fraction (exponent shifts by exact powers of ten). fn parse(s: &str) -> Result { let s = s.trim(); if s.is_empty() || s == "." || s == "-" || s == "-." { return Err(CalcError::Invalid); } - let neg = s.starts_with('-'); - let body = if neg { &s[1..] } else { s }; + // Split off an optional exponent: `1e-15`, `2.5E+3`. + let (body, exp) = match s.split_once(['e', 'E']) { + Some((b, e)) => { + let exp: i32 = e.parse().map_err(|_| CalcError::Invalid)?; + (b, exp) + } + None => (s, 0i32), + }; + let neg = body.starts_with('-'); + let b = if neg { &body[1..] } else { body }; - let (int_part, frac_part) = match body.split_once('.') { - Some((a, b)) => (a, b), - None => (body, ""), + let (int_part, frac_part) = match b.split_once('.') { + Some((a, c)) => (a, c), + None => (b, ""), }; if int_part.is_empty() && frac_part.is_empty() { return Err(CalcError::Invalid); @@ -107,6 +116,27 @@ impl Rational { .and_then(|v| v.checked_add(frac_val)) .ok_or(CalcError::Overflow)?; let num = if neg { -num } else { num }; + + // Apply the exponent with exact power-of-ten arithmetic: + // value·10^exp = num·10^exp / 10^frac_digits. + let (num, den) = if exp >= 0 { + let e = exp as usize; + if e >= frac_digits { + ( + num.checked_mul(pow10(e - frac_digits)?) + .ok_or(CalcError::Overflow)?, + 1, + ) + } else { + (num, den / pow10(e)?) + } + } else { + ( + num, + den.checked_mul(pow10((-exp) as usize)?) + .ok_or(CalcError::Overflow)?, + ) + }; Self::new(num, den) } @@ -175,9 +205,14 @@ impl Value { if !v.is_finite() { return Err(CalcError::Overflow); } - // Prefer exact integer when float is (almost) integral. - if v.fract().abs() < 1e-12 && v.abs() < 1e15 { - return Ok(Value::Rat(Rational::from_i128(v.round() as i128))); + if v == 0.0 { + return Ok(Value::Rat(Rational::from_i128(0))); + } + // Prefer exact integer when float is (almost) integral. Never snap a + // sub-unit magnitude to an integer: that turned 1e-16 into `0`. + let r = v.round(); + if r != 0.0 && (v - r).abs() < 1e-12 && v.abs() < 1e15 { + return Ok(Value::Rat(Rational::from_i128(r as i128))); } Ok(Value::Float(v)) } @@ -394,7 +429,7 @@ impl Calculator { self.entry = d.to_string(); } else if self.entry == "-0" { self.entry = format!("-{d}"); - } else if digit_count(&self.entry) < 16 { + } else if digit_count(&self.entry) < 40 && significant_digits(&self.entry) < 16 { self.entry.push(d); } self.entry_value = None; @@ -911,6 +946,19 @@ fn tokenize(src: &str) -> Result)>, CalcError> { while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { i += 1; } + // Optional decimal exponent: `1e-9`, `2.5E+3`. + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } let lit: String = chars[start..i].iter().collect(); let v = Value::from_entry(&lit)?; out.push((Tok::Num, Some(v))); @@ -984,6 +1032,15 @@ fn digit_count(s: &str) -> usize { s.chars().filter(|c| c.is_ascii_digit()).count() } +/// Significant digits (leading zeros don't count) — caps precision, not +/// magnitude, so `0.0000000000000001` (1e-16) can be typed. +fn significant_digits(s: &str) -> usize { + s.chars() + .filter(|c| c.is_ascii_digit()) + .skip_while(|&c| c == '0') + .count() +} + fn gcd(mut a: u128, mut b: u128) -> u128 { while b != 0 { let t = b; @@ -1016,36 +1073,36 @@ fn format_rational(r: Rational) -> String { } fn terminating_decimal(r: Rational) -> Option { + // Terminating iff den = 2^a · 5^b. Digit count after the point is + // k = max(a, b); scale num so the denominator becomes exactly 10^k. let mut den = r.den; + let (mut a, mut b) = (0u32, 0u32); while den % 2 == 0 { den /= 2; + a += 1; } while den % 5 == 0 { den /= 5; + b += 1; } if den != 1 { return None; } - // Scale denominator to a power of 10. - let mut d = r.den; - let mut n = r.num; - let neg = n < 0; - n = n.abs(); - while d % 10 != 0 { - if d % 2 == 0 { - d *= 5; - n *= 5; - } else if d % 5 == 0 { - d *= 2; - n *= 2; - } else { - break; - } + let k = a.max(b); + let mut n = r.num.abs(); + for _ in 0..(k - a) { + n = n.checked_mul(2)?; + } + for _ in 0..(k - b) { + n = n.checked_mul(5)?; + } + let mut d: i128 = 1; + for _ in 0..k { + d = d.checked_mul(10)?; } let int_part = n / d; let mut frac = (n % d).to_string(); - let width = d.to_string().len().saturating_sub(1); - while frac.len() < width { + while frac.len() < k as usize { frac.insert(0, '0'); } // trim trailing zeros @@ -1057,24 +1114,26 @@ fn terminating_decimal(r: Rational) -> Option { } else { format!("{int_part}.{frac}") }; - Some(if neg { format!("-{body}") } else { body }) + Some(if r.num < 0 { format!("-{body}") } else { body }) } fn format_float(v: f64) -> String { - if !v.is_finite() { + if !v.is_finite() || v == 0.0 { return "0".into(); } - // Snap near-integers (guards any float path residue). - if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 { - return format!("{}", v.round() as i64); + // Snap near-integers (guards any float path residue); never for |v| < 1, + // otherwise tiny results like 1e-16 collapse to `0`. + let r = v.round(); + if r != 0.0 && (v - r).abs() < 1e-10 && v.abs() < 1e15 { + return format!("{}", r as i64); } - let s = format!("{:.12}", v); - let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); - if s.is_empty() || s == "-" { - "0".into() - } else { - s + // Very small / large magnitudes: scientific notation keeps the value + // visible and re-parseable (`Rational::parse` understands exponents). + if v.abs() < 1e-10 || v.abs() >= 1e16 { + return format!("{v:e}"); } + // Shortest representation that round-trips back to `v` exactly. + format!("{v}") } impl fmt::Display for Rational { @@ -1173,4 +1232,107 @@ mod tests { let r = Rational::parse("1.25").unwrap(); assert_eq!(r, Rational::new(5, 4).unwrap()); } + + // --- regression tests (rounding / formatting bugs) --- + + #[test] + fn quarter_and_eighth_display() { + // terminating_decimal scaled the fraction wrong: 1/4 displayed as "0.5". + let mut c = Calculator::new(); + c.input_digit('1'); + c.set_op(Op::Div); + c.input_digit('4'); + c.equals(); + assert_eq!(c.display(), "0.25"); + + c.clear_all(); + c.input_digit('1'); + c.set_op(Op::Div); + c.input_digit('8'); + c.equals(); + assert_eq!(c.display(), "0.125"); + } + + #[test] + fn five_hundredths_display() { + // 0.05 must survive exact formatting (was displayed as "0.1"). + let mut c = Calculator::new(); + c.input_digit('0'); + c.input_dot(); + c.input_digit('0'); + c.input_digit('5'); // 0.05 + c.set_op(Op::Mul); + c.input_digit('1'); + c.equals(); + assert_eq!(c.display(), "0.05"); + } + + #[test] + fn tiny_reciprocal_not_zero() { + // 1/9999999999999999 ≈ 1e-16 was snapped to "0" by format_float. + let mut c = Calculator::new(); + for d in "9999999999999999".chars() { + c.input_digit(d); + } + c.reciprocal(); + let disp = c.display(); + assert!(disp != "0", "tiny reciprocal must not display as 0, got {disp}"); + } + + #[test] + fn tiny_result_reusable_standard() { + // Float result in scientific notation must be re-parsable as operand. + let mut c = Calculator::new(); + for d in "9999999999999999".chars() { + c.input_digit(d); + } + c.reciprocal(); + c.set_op(Op::Mul); + c.input_digit('2'); + c.equals(); + let disp = c.display(); + assert!(disp != "0" && disp != "Invalid input", "got {disp}"); + } + + #[test] + fn sqrt_square_roundtrip_tiny() { + // √(1e-30) = 1e-15 was snapped to 0 by Value::from_f64. + let mut c = Calculator::new(); + c.input_dot(); // "0." + for _ in 0..14 { + c.input_digit('0'); + } + c.input_digit('1'); // 0.000000000000001 = 1e-15 + c.square(); + c.sqrt(); + let disp = c.display(); + assert!(disp != "0", "sqrt(1e-30) must not display as 0, got {disp}"); + } + + #[test] + fn rational_parse_exponent() { + // Scientific-notation entries must parse to exact fractions. + let r = Rational::parse("1e-15").unwrap(); + assert_eq!(r, Rational::new(1, 1_000_000_000_000_000).unwrap()); + let r = Rational::parse("1.5e3").unwrap(); + assert_eq!(r, Rational::new(1500, 1).unwrap()); + let r = Rational::parse("2.5e-2").unwrap(); + assert_eq!(r, Rational::new(1, 40).unwrap()); + let r = Rational::parse("-1E+3").unwrap(); + assert_eq!(r, Rational::new(-1000, 1).unwrap()); + assert!(Rational::parse("1e2e3").is_err()); + assert!(Rational::parse("1e").is_err()); + } + + #[test] + fn can_type_one_e_minus_16() { + // Leading zeros must not eat the 16-significant-digit budget. + let mut c = Calculator::new(); + c.input_dot(); + for _ in 0..15 { + c.input_digit('0'); + } + c.input_digit('1'); + assert_eq!(c.display(), "0.0000000000000001"); + } } diff --git a/src/engineering.rs b/src/engineering.rs index 2925e11..680e5ce 100644 --- a/src/engineering.rs +++ b/src/engineering.rs @@ -199,7 +199,7 @@ impl Engineering { self.entry = d.to_string(); } else if self.entry == "-0" { self.entry = format!("-{d}"); - } else if digit_count(&self.entry) < 16 { + } else if digit_count(&self.entry) < 40 && significant_digits(&self.entry) < 16 { self.entry.push(d); } self.sync_expr(); @@ -550,20 +550,31 @@ fn digit_count(s: &str) -> usize { s.chars().filter(|c| c.is_ascii_digit()).count() } +/// Significant digits (leading zeros don't count) — caps precision, not +/// magnitude, so `0.0000000000000001` (1e-16) can be typed. +fn significant_digits(s: &str) -> usize { + s.chars() + .filter(|c| c.is_ascii_digit()) + .skip_while(|&c| c == '0') + .count() +} + fn format_num(v: f64) -> String { - if !v.is_finite() { + if !v.is_finite() || v == 0.0 { return "0".into(); } - if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 { - return format!("{}", v.round() as i64); + // Snap near-integers; never for |v| < 1 (kept 10^−50 displayed as "0"). + let r = v.round(); + if r != 0.0 && (v - r).abs() < 1e-10 && v.abs() < 1e15 { + return format!("{}", r as i64); } - let s = format!("{:.12}", v); - let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); - if s.is_empty() || s == "-" { - "0".into() - } else { - s + // Scientific notation for extremes; `entry.parse::()` and the + // tokenizer both accept it, so the result stays reusable. + if v.abs() < 1e-10 || v.abs() >= 1e16 { + return format!("{v:e}"); } + // Shortest round-trip representation. + format!("{v}") } // --- expression evaluator --- @@ -639,6 +650,20 @@ fn tokenize(src: &str) -> Result, SciError> { while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { i += 1; } + // Optional decimal exponent: `1e-9`, `2.5E+3`. The standalone + // constant `e` stays an Ident (needs no digit right after). + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } let lit: String = chars[start..i].iter().collect(); let v: f64 = lit.parse().map_err(|_| SciError::Invalid)?; out.push(Tok::Num(v)); @@ -858,4 +883,50 @@ mod tests { e.equals(); assert_eq!(e.display(), "1024"); } + + #[test] + fn ten_pow_negative_not_zero() { + // 10 ^ −50 ≈ 1e-50 was snapped to "0" by format_num. + let mut e = Engineering::new(); + e.input_digit('1'); + e.input_digit('0'); + e.set_op("^"); + e.input_digit('5'); + e.input_digit('0'); + e.negate(); + e.equals(); + let disp = e.display(); + assert!(disp != "0", "10^−50 must not display as 0, got {disp}"); + } + + #[test] + fn tiny_float_result_continues_expression() { + // Result in scientific notation must be re-parsable by the tokenizer. + let mut e = Engineering::new(); + e.input_digit('1'); + e.input_digit('0'); + e.set_op("^"); + e.input_digit('1'); + e.input_digit('6'); + e.negate(); + e.equals(); + assert_eq!(e.display(), "1e-16"); + e.set_op("+"); + e.input_digit('1'); + e.equals(); + assert_eq!(e.display(), "1"); + } + + #[test] + fn can_type_one_e_minus_16() { + // Leading zeros must not eat the 16-significant-digit budget. + let mut e = Engineering::new(); + e.input_digit('0'); + e.input_dot(); + for _ in 0..15 { + e.input_digit('0'); + } + e.input_digit('1'); + assert_eq!(e.display(), "0.0000000000000001"); + } } diff --git a/src/programmer.rs b/src/programmer.rs index 3c5683e..da55603 100644 --- a/src/programmer.rs +++ b/src/programmer.rs @@ -699,4 +699,35 @@ mod tests { assert_eq!(p.word_size(), WordSize::Dint); assert_eq!(p.dec_text().replace(' ', ""), "-1"); } + + #[test] + fn shift_count_mods_word_size() { + // Locks hardware-style semantics: shift count is taken modulo the + // word size (1 << 100000000 → 1e8 % 64 == 0 → no shift). + let mut p = Programmer::new(); + p.input_digit('1'); + p.set_op(ProgOp::Lsh); + for ch in "100000000".chars() { + p.input_digit(ch); + } + p.equals(); + assert_eq!(p.current_bits(), 1); + } + + #[test] + fn shift_out_of_word_gives_zero() { + // 0xFFFF_FFFF_FFFF_FFFF Lsh 63 → only bit 0 survives at bit 63. + let mut p = Programmer::new(); + p.set_base(Base::Hex); + for ch in "FFFFFFFFFFFFFFFF".chars() { + p.input_digit(ch); + } + p.set_op(ProgOp::Lsh); + p.set_base(Base::Dec); // type the shift count in decimal + p.input_digit('6'); + p.input_digit('3'); + p.equals(); + // (u64::MAX << 63) & u64::MAX = 0x8000_0000_0000_0000 + assert_eq!(p.current_bits(), 0x8000_0000_0000_0000); + } }