[CmdPal Calculator] Add rand() and randi(). Expand result responses to differentiate between NaN and ParseError (#47725)

## Summary of the Pull Request
This adds `rand()` and `randi()` functions to Command Palette's
Calculator, making it consistent with Run.

It also expands upon the return values from `ToWStringFullPrecision()`,
so NaN, ParseError and +/-infinity results are passed back to the
caller, improving the specificity of the error message display.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #47707
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
The two new functions have been added to **ExprtkEvaluator.cpp**,
alongside `sign()` and `factorial()`. As they need to handle the state
of the RNG, they're slightly more complex in implementation. I used the
Mersenne Twister RNG with a uniform distribution, and the instances are
marked `static thread_local` in case the engine moves to multithreaded
evaluation in the future.

It's possible for the RNG to return a value out of the range of
`double`, and this is caught and `quiet_NaN()` is returned. To prevent
this being caught as a generic parse error, I updated
`ToWStringFullPrecision()` to distinguish between `NaN`, expression
parsing errors and infinity values. This should improve the accuracy of
error messages for other expressions, too.

Finally, I corrected a comment in **CalculateEngine.cs,** which still
referred to the Mages calculation engine. The log/ln mapping is the same
for both engines, so the comment was still accurate except for this
reference.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Unit tests were added to exercise the new functions. All Calculator
tests pass:

<img width="375" height="59" alt="image"
src="https://github.com/user-attachments/assets/5a33e1ed-a4fd-4d53-b9ba-6b44000f1bf4"
/>

Confirmed that error messages are displaying correctly for the
newly-exposed result types:

**Not a number**
<img width="787" height="128" alt="image"
src="https://github.com/user-attachments/assets/8c73dcf6-122b-4af8-bf1a-62284842433a"
/>

<img width="786" height="145" alt="image"
src="https://github.com/user-attachments/assets/fe14338c-1160-4aae-83dd-5ca3491ae59e"
/>

**+/- Infinity**
<img width="898" height="137" alt="image"
src="https://github.com/user-attachments/assets/20cfacda-72a7-44bb-a875-af7be39ee7e2"
/>

**Parser failure**
<img width="607" height="139" alt="image"
src="https://github.com/user-attachments/assets/7d7120b2-a2cf-45b6-ab89-79af4051fa50"
/>

<img width="587" height="140" alt="image"
src="https://github.com/user-attachments/assets/2dc7a365-7ee6-4379-8b3f-47b3912e6891"
/>
This commit is contained in:
Dave Rayment
2026-05-16 19:50:58 +00:00
committed by GitHub
parent 703dc92c04
commit e17454b553
5 changed files with 227 additions and 9 deletions

View File

@@ -186,6 +186,12 @@ xmlutil
# Prefix
pcs
# EXPRTK / C++ MATH
ifunction
isinf
isnan
# User32.SYSTEM_METRICS_INDEX.cs
CLEANBOOT

View File

@@ -5,11 +5,17 @@
#include <sstream>
#include <cmath>
#include <limits>
#include <random>
namespace ExprtkCalculator::internal
{
static double factorial(const double n)
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
// Only allow non-negative integers
if (n < 0.0 || std::floor(n) != n)
{
@@ -20,13 +26,80 @@ namespace ExprtkCalculator::internal
static double sign(const double n)
{
// The sign of NaN is undefined.
if (std::isnan(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
if (n > 0.0) return 1.0;
if (n < 0.0) return -1.0;
return 0.0;
}
// rand(): returns a uniformly distributed random double in [0, 1)
struct rand_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
std::uniform_real_distribution<double> dist;
rand_func() :
exprtk::ifunction<double>(0),
rng(std::random_device{}()),
dist(0.0, 1.0)
{}
inline double operator()() override
{
return dist(rng);
}
};
// randi(n): returns a uniformly distributed random integer in [0, n-1]
struct randi_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
randi_func() :
exprtk::ifunction<double>(1),
rng(std::random_device{}())
{}
inline double operator()(const double& n) override
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
constexpr double maxLongLongAsDouble = static_cast<double>(std::numeric_limits<long long>::max());
if (n < 1.0 || n >= maxLongLongAsDouble)
{
return std::numeric_limits<double>::quiet_NaN();
}
if (std::floor(n) != n)
{
return std::numeric_limits<double>::quiet_NaN();
}
std::uniform_int_distribution<long long> dist(0, static_cast<long long>(n) - 1);
return static_cast<double>(dist(rng));
}
};
std::wstring ToWStringFullPrecision(double value)
{
if (std::isnan(value))
{
return L"NaN";
}
if (std::isinf(value))
{
return value > 0 ? L"inf" : L"-inf";
}
std::wostringstream oss;
oss.imbue(std::locale::classic());
oss << std::fixed << std::setprecision(15) << value;
@@ -47,6 +120,13 @@ namespace ExprtkCalculator::internal
symbol_table.add_function("factorial", factorial);
symbol_table.add_function("sign", sign);
// thread_local ensures each thread has its own RNG instance (seeded once,
// state preserved across calls) without requiring locks.
static thread_local rand_func rand_fn;
static thread_local randi_func randi_fn;
symbol_table.add_function("rand", rand_fn);
symbol_table.add_function("randi", randi_fn);
exprtk::expression<double> expression;
expression.register_symbol_table(symbol_table);
@@ -65,7 +145,7 @@ namespace ExprtkCalculator::internal
parser.settings().disable_all_inequality_ops(); // Disable inequality operators like <, >, <=, >=, !=, etc.
if (!parser.compile(expressionText, expression))
return L"NaN";
return L"ParseError";
return ToWStringFullPrecision(expression.value());
}

View File

@@ -16,6 +16,8 @@ namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
{
private const int IntMax = int.MaxValue;
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
@@ -409,13 +411,136 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
[DataTestMethod]
[DataRow("171!")]
[DataRow("1000!")]
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
public void Interpret_ReturnsOutOfBoundsError_WhenValueOverflowsDecimal(string input)
{
var settings = new Settings();
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.IsFalse(string.IsNullOrEmpty(error));
Assert.AreNotEqual(null, error);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("exp(99999)")]
[DataRow("-exp(99999)")]
public void Interpret_ReturnsOutOfBoundsError_WhenResultIsInfinity(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("1 2")]
public void Interpret_ReturnsExpressionNotCompleteError_WhenExpressionIsInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_expression_not_complete, error);
}
[TestMethod]
public void Interpret_ReturnsNotANumberError_WhenResultIsNaN()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "sqrt(-1)", CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[DataTestMethod]
[DataRow("factorial(-1)")]
[DataRow("factorial(0.5)")]
[DataRow("factorial(sqrt(-1))")]
[DataRow("sign(sqrt(-1))")]
public void Interpret_ReturnsNotANumberError_WhenCustomFunctionArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[TestMethod]
public void Interpret_Rand_ReturnsValueInRange()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "rand()", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "rand() returned no result");
Assert.IsTrue(result.Result >= 0M && result.Result < 1M, $"rand() result {result.Result} was not in [0, 1)");
}
[TestMethod]
public void Interpret_Randi_ReturnsZero_WhenArgIsOne()
{
// randi(1) has only one valid outcome: 0. This test is fully deterministic.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "randi(1)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(1) returned no result");
Assert.AreEqual(0M, result.Result!.Value, "randi(1) must always return 0");
}
[TestMethod]
public void Interpret_Randi_StaysInRange_WhenArgIsTwo()
{
// randi(2) has the smallest non-trivial range [0, 1]. Running many iterations
// gives high confidence both boundary values are reachable.
var settings = new Settings();
for (var i = 0; i < 100; i++)
{
var result = CalculateEngine.Interpret(settings, "randi(2)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(2) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi(2) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value <= 1M, $"randi(2) result {value} was not in [0, 1]");
}
}
[TestMethod]
public void Interpret_Randi_HandlesIntMaxArgument()
{
// Ensures no integer overflow in the C++ cast when the argument is int.MaxValue.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, $"randi({IntMax})", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, $"randi({IntMax}) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi({IntMax}) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value < IntMax, $"randi({IntMax}) result {value} was out of range");
}
[DataTestMethod]
[DataRow("randi(0)")]
[DataRow("randi(0.5)")]
[DataRow("randi(-1)")]
[DataRow("randi(exp(10000))")]
public void Interpret_Randi_ReturnsNotANumberError_WhenArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
}

View File

@@ -51,8 +51,8 @@ public static partial class CalculateEngine
return default;
}
// mages has quirky log representation
// mage has log == ln vs log10
// ExprTK uses log == ln and log10 for base-10, so we remap here
// to match the user-facing names (log → log10, ln log).
// Use regex replacements so optional whitespace between the function name and
// '(' is handled correctly - "log (100)" must map to log10 just like "log(100)"
// does. The negative lookahead prevents "log10" / "log2" from being touched.
@@ -75,12 +75,18 @@ public static partial class CalculateEngine
var result = _calculator.EvaluateExpression(input);
// This could happen for some incorrect queries, like pi(2)
if (result == "NaN")
if (result == "ParseError")
{
error = Properties.Resources.calculator_expression_not_complete;
return default;
}
if (result == "NaN")
{
error = Properties.Resources.calculator_not_a_number;
return default;
}
// If we're out of bounds
if (result is "inf" or "-inf")
{
@@ -90,6 +96,7 @@ public static partial class CalculateEngine
if (string.IsNullOrEmpty(result))
{
error = Properties.Resources.calculator_not_a_number;
return default;
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -14,7 +14,7 @@ public static partial class CalculateHelper
@"^(" +
@"%|" +
@"ceil\s*\(|floor\s*\(|exp\s*\(|max\s*\(|min\s*\(|abs\s*\(|log(?:2|10)?\s*\(|ln\s*\(|sqrt\s*\(|pow\s*\(|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\([^\)]|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\((?=[^\)])|" +
@"sin\s*\(|cos\s*\(|tan\s*\(|arcsin\s*\(|arccos\s*\(|arctan\s*\(|" +
@"sinh\s*\(|cosh\s*\(|tanh\s*\(|arsinh\s*\(|arcosh\s*\(|artanh\s*\(|" +
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */