feat: add calculator and unit conversion

This commit is contained in:
2026-10-13 17:48:00 +02:00
parent 400917de3b
commit a57cbc219c
5 changed files with 567 additions and 25 deletions
+104
View File
@@ -0,0 +1,104 @@
import ConjureKit
import Foundation
import Testing
@Test
func calculatorHonorsPrecedenceUnaryAndRightAssociativePowers() {
let examples: [(String, String)] = [
("2 + 3 * 4", "14"), ("(2 + 3) * 4", "20"),
("2^3^2", "512"), ("-2^2", "-4"), ("(-2)^2", "4"),
("2^-2", "0.25"), ("--2 + +3", "5"),
("8 ÷ 2 × 3", "12"), ("2x3", "6"), ("2 x (3 + 1)", "8"),
("10 % 3", "1"), ("10 mod -3", "1"),
]
for (query, expected) in examples {
let result = Calculator.evaluate(query)
#expect(result?.displayValue == expected, "\(query)")
#expect(result?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorSupportsFunctionsPercentagesAndScientificNotation() {
let examples: [(String, String)] = [
("sqrt(81) + abs(-3)", "12"), ("round(2.5) + floor(2.9) + ceil(2.1)", "8"),
("sin(pi/2) + cos(0) + tan(0)", "2"), ("log(100) + ln(e)", "3"),
("50%", "0.5"), ("200 * 10%", "20"), ("50% + 2", "2.5"),
("52% of 900", "468"), ("20% off 80", "64"), ("20% off (80 + 20)", "80"),
(".5e2 + 1E-2", "50.01"), ("π - pi", "0"),
("pi", "3.14159265359"), ("e", "2.71828182846"), ("1 / 3", "0.333333333333"),
]
for (query, expected) in examples {
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorConvertsDimensionsAndTemperatureOffsets() throws {
let examples: [(String, Double, String)] = [
("10 km to mi", 6.21371192237, "mi"), ("10ft in m", 3.048, "m"),
("29 inches to cm", 73.66, "cm"), ("2 * 5 cm to inches", 3.93700787402, "in"),
("1 kilograms to pounds", 2.20462262185, "lb"), ("16oz to lb", 1, "lb"),
("32 f to c", 0, "°C"), ("23C to F", 73.4, "°F"),
("100°C to °F", 212, "°F"), ("0 c to kelvin", 273.15, "K"),
("2 hours in minutes", 120, "min"), ("1 week as seconds", 604800, "s"),
("1000 milliseconds to s", 1, "s"),
("36 km/h to m/s", 10, "m/s"), ("60 mph to km/h", 96.56064, "km/h"),
("1 square m to square cm", 10000, "cm²"), ("144in² to ft²", 1, "ft²"),
("2 liters to ml", 2000, "mL"), ("1 US gallon to l", 3.785411784, "L"),
("1 tbsp to tsp", 3, "tsp"), ("1 cup to fl oz", 8, "fl oz"),
]
for (query, expected, unit) in examples {
let result = try #require(Calculator.evaluate(query), "\(query)")
let copied = try #require(result.copyValue, "\(query): \(result.displayValue)")
#expect(copied.hasSuffix(" \(unit)"), "\(query)")
let value = try #require(Double(copied.dropLast(unit.count + 1)), "\(query)")
// Foundation's built-in physical-unit coefficients have limited decimal precision.
#expect(abs(value - expected) < max(1e-9, abs(expected) * 1e-5), "\(query): \(copied)")
}
#expect(Calculator.evaluate("32 f to c")?.copyValue == "0 °C")
#expect(Calculator.evaluate("0 c to f")?.copyValue == "32 °F")
}
@Test
func calculatorDistinguishesDecimalBinaryAndBitsFromBytes() {
let examples: [(String, String)] = [
("1 GB to MB", "1000 MB"), ("1GB to B", "1000000000 B"),
("1 GiB to MiB", "1024 MiB"), ("1 KiB to B", "1024 B"),
("1 MB to KiB", "976.5625 KiB"), ("8b to B", "1 B"),
("1 B to b", "8 b"), ("1 Mb to MB", "0.125 MB"), ("8 bits to bytes", "1 B"),
("1 kilobyte to bytes", "1000 B"), ("1 mebibyte to KiB", "1024 KiB"),
("8 megabits to MB", "1 MB"),
]
for (query, expected) in examples {
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorLeavesApplicationQueriesAndIncompleteInputAlone() {
for query in ["", "Safari", "Xcode", "1Password", "Calculator", "123", "-123", "1e3",
"2 +", "sqrt(", "2^", "1e+", "(1+2", "2 3", "2 ** 3", "pi app", "sin 2",
"alert(1)", "foo(2)", "1/0 Safari", "10 km to", "10 km to widgets", "2 of 3", "20 off 80"] {
#expect(Calculator.evaluate(query) == nil, "\(query)")
}
}
@Test
func calculatorReportsRecognizedErrorsWithoutCopyableResults() throws {
for query in ["1/0", "1 mod 0", "sqrt(-1)", "ln(0)", "log(-2)", "(-2)^0.5",
"1e308 * 1e308", "1e999 + 1", "1e308 TB to B", "10 m to kg", "1 GB to seconds"] {
let result = try #require(Calculator.evaluate(query), "\(query)")
#expect(result.copyValue == nil, "\(query)")
}
}
@Test
func calculatorBoundsLiveSearchInputAndRecursiveGrammar() {
#expect(Calculator.evaluate(String(repeating: "(", count: 70) + "1" + String(repeating: ")", count: 70)) == nil)
#expect(Calculator.evaluate(String(repeating: "(", count: 16) + "1" + String(repeating: ")", count: 16)) == nil)
#expect(Calculator.evaluate(String(repeating: "-", count: 70) + "1 + 2") == nil)
#expect(Calculator.evaluate(String(repeating: "2^", count: 70) + "2") == nil)
#expect(Calculator.evaluate(String(repeating: "1+", count: 300) + "1") == nil)
#expect(Calculator.evaluate(String(repeating: "(", count: 15) + "1+2" + String(repeating: ")", count: 15))?.copyValue == "3")
}