Read thousand, million and billion after a calculator amount (#1182)

This commit is contained in:
Abue Ammar
2026-09-26 22:10:50 +06:00
committed by GitHub
parent a491e7d773
commit a3e352227d
4 changed files with 50 additions and 1 deletions
+20
View File
@@ -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")
@@ -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)
@@ -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..<end])).lowercased()]
else { return nil }
return (scale, end)
}
/// Whether the `k` at `index` is a thousands suffix rather than Kelvin or a unit's head.
private static func isCompactSuffix(_ chars: [Unicode.Scalar], _ index: Int) -> Bool {
let next = index + 1
+6
View File
@@ -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.