From a3e352227df9d3b7b7406d5a1377b9694bb5b97d Mon Sep 17 00:00:00 2001 From: Abue Ammar <14371506+abue-ammar@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:10:50 +0600 Subject: [PATCH] Read thousand, million and billion after a calculator amount (#1182) --- Tests/calc-test.swift | 20 ++++++++++++++++ .../Features/Calculator/Model/CalcToken.swift | 2 +- .../Calculator/Model/CalcTokenizer.swift | 23 +++++++++++++++++++ docs/features/calculator.md | 6 +++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Tests/calc-test.swift b/Tests/calc-test.swift index fc5b0f1d..f4519a2f 100644 --- a/Tests/calc-test.swift +++ b/Tests/calc-test.swift @@ -56,6 +56,26 @@ struct CalcTests { expectDisplay("10k * 2", "20,000") expectBadges("10k", source: "Expression", target: "Result") + // Magnitude words scale the literal before them, spaced or attached, in any case + expectDisplay("13 million", "13,000,000") + expectCopy("1.5 billion", "1500000000") + expectDisplay("2 Thousand + 1", "2,001") + expectDisplay("3million / 2", "1,500,000") + expectDisplay("13 million idr to usd", "720.10 USD") + expectDisplay("1.5 million idr to sgd", "112.17 SGD") + expectDisplay("IDR 13 million to usd", "720.10 USD") + expectDisplay("10% of 2 million", "200,000") + expectExpression("13 million +", "13 million +") + expectBadges("13 million", source: "Expression", target: "Result") + expectNil("million") + expectNil("2 * million") + expectNil("(2 + 3) million") + expectNil("13 millions") + expectNil("13 million2") + expectNil("1e308 billion") + expectDisplay("10 milliseconds to s", "0.01 s") + expectLocalized("1,5 million", "1.500.000", italian) + // Scientific notation input expectDisplay("1e6 + 1", "1,000,001") expectDisplay("1.5e-3 * 2", "0.003") diff --git a/Tinycast/Features/Calculator/Model/CalcToken.swift b/Tinycast/Features/Calculator/Model/CalcToken.swift index c600ea31..45421bd5 100644 --- a/Tinycast/Features/Calculator/Model/CalcToken.swift +++ b/Tinycast/Features/Calculator/Model/CalcToken.swift @@ -2,7 +2,7 @@ import Foundation enum CalcToken: Equatable, Sendable { case number(Double) - /// Shorthand (`10k`, `1e5`), kept distinct so a lone one still earns a card. + /// Shorthand (`10k`, `1e5`, `2 million`), kept distinct so a lone one still earns a card. case compactNumber(Double) /// Radix-prefixed integer literal (0xff / 0b1010 / 0o777), kept exact for base conversion. case intLiteral(UInt64, base: CalcNumberBase) diff --git a/Tinycast/Features/Calculator/Model/CalcTokenizer.swift b/Tinycast/Features/Calculator/Model/CalcTokenizer.swift index 96c9e31a..a00185da 100644 --- a/Tinycast/Features/Calculator/Model/CalcTokenizer.swift +++ b/Tinycast/Features/Calculator/Model/CalcTokenizer.swift @@ -75,6 +75,11 @@ enum CalcTokenizer { guard scaled.isFinite else { return nil } tokens.append(.compactNumber(scaled)) i += 1 + } else if let magnitude = magnitudeWord(chars, after: i) { + let scaled = value * magnitude.scale + guard scaled.isFinite else { return nil } + tokens.append(.compactNumber(scaled)) + i = magnitude.end } else if isShorthand { tokens.append(.compactNumber(value)) } else { @@ -260,6 +265,24 @@ enum CalcTokenizer { return (name, end) } + /// Short scale only: the engine reads canonical English, where a billion is 10⁹. + private static let magnitudes: [String: Double] = ["thousand": 1e3, "million": 1e6, "billion": 1e9] + + /// The scale of a whole magnitude word following a literal (`13 million`), and where it ends. + private static func magnitudeWord( + _ chars: [Unicode.Scalar], after index: Int + ) -> (scale: Double, end: Int)? { + var start = index + while start < chars.count, chars[start].isWhitespace { start += 1 } + var end = start + while end < chars.count, chars[end].isLetter { end += 1 } + guard end > start else { return nil } + if end < chars.count, chars[end].isNumber || chars[end].isCombiningMark { return nil } + guard let scale = magnitudes[String(String.UnicodeScalarView(chars[start.. Bool { let next = index + 1 diff --git a/docs/features/calculator.md b/docs/features/calculator.md index c12c6626..bee12603 100644 --- a/docs/features/calculator.md +++ b/docs/features/calculator.md @@ -264,6 +264,12 @@ An attached `k` is a thousands suffix (`10k` → `10,000`), while whitespace kee (`10 k to c`); the established attached Kelvin conversion form remains valid when the temperature target makes the intent unambiguous (`273.15K to C`). +A whole magnitude word after a literal scales it the same way, spaced or attached and in any case: +`thousand`, `million` and `billion` (`13 million idr to usd`, `1.5 billion`). The set is closed and +short-scale — a billion is 10⁹, since the engine reads canonical English. It scales only the literal it +follows, so `2 * million` and `(2 + 3) million` stay silent, and plurals (`13 millions`) are not read. +Abbreviations are deliberately absent: `m`, `b` and `k` already mean metre, byte and Kelvin. + A **compound unit** (`km/h`, `m³/h`, `mbit/s`, `fl oz`) stays whole only when the table knows its spelling: the tokenizer looks ahead across `/` or whitespace between two alphanumeric runs, folds superscript powers, and keeps them together only if `CalcUnits.byName` resolves the result.