Effigy.Tests/ExpressionTests.cs
using System;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// The numeric-field expression evaluator, run for real.
///
/// These cases were originally checked by transliterating the grammar into another language and
/// exercising it there, because there was no way to compile C# on the machine that wrote it. That
/// is a decent sanity check and it is not the same thing as running the code. Now that the kernel
/// builds here, they are assertions against the actual parser.
///
/// The REJECTIONS matter at least as much as the acceptances. A field that quietly evaluates a
/// typo is worse than one that refuses it, because the wrong number goes into the model and looks
/// deliberate.
/// </summary>
public static class ExpressionTests
{
	public static void Run()
	{
		Report.Section( "expressions: values, operators and functions" );
		TestAccepted();

		Report.Section( "expressions: precedence" );
		TestPrecedence();

		Report.Section( "expressions: units" );
		TestUnits();

		Report.Section( "expressions: things that must be refused" );
		TestRejected();

		Report.Section( "expressions: formatting a value back out" );
		TestFormat();

		Report.Section( "expressions: #names through a resolver" );
		TestHashNames();
	}

	static void TestHashNames()
	{
		float? Resolve( string name ) => name == "t" ? 8f : null;

		var parsed = Expression.TryEvaluate( "#t / 2", null, Resolve, out var value );

		Report.Check( "#t / 2 = 4", parsed && MathF.Abs( value - 4f ) < 1e-4f,
			parsed ? $"got {value}" : "refused" );

		Report.Check( "#missing with no table is refused",
			!Expression.TryEvaluate( "#t", null, null, out _ ) );
	}

	static void Ok( string text, float expected, string unit = null )
	{
		var parsed = Expression.TryEvaluate( text, unit, out var value );

		Report.Check( $"\"{text}\"{(unit is null ? "" : $" [{unit}]")} = {expected}",
			parsed && MathF.Abs( value - expected ) < 1e-4f,
			parsed ? $"got {value}" : "refused" );
	}

	static void Refused( string text, string unit = null )
	{
		var parsed = Expression.TryEvaluate( text, unit, out var value );

		Report.Check( $"\"{text}\"{(unit is null ? "" : $" [{unit}]")} is refused",
			!parsed, parsed ? $"accepted as {value}" : null );
	}

	static void TestAccepted()
	{
		Ok( "4", 4f );
		Ok( ".5", 0.5f );
		Ok( " 2 + 3 ", 5f );
		Ok( "10/4", 2.5f );
		Ok( "1/8", 0.125f );                     // the case a slider cannot express
		Ok( "2*(3+4)", 14f );
		Ok( "-(3+4)", -7f );
		Ok( "1e3", 1000f );
		Ok( "sqrt(2)*10", 14.142136f );
		Ok( "max(3,7)", 7f );
		Ok( "min(3,7)", 3f );
		Ok( "round(2.6)", 3f );
		Ok( "ceil(2.1)", 3f );
		Ok( "floor(2.9)", 2f );
		Ok( "abs(0-5)", 5f );
		Ok( "pow(2,10)", 1024f );
		Ok( "2*pi", 6.2831853f );

		// Trig is in DEGREES - with no unit system, that is what someone typing into a CAD field
		// means, and Onshape's own trig cannot be ambiguous because its angles carry units.
		Ok( "sin(30)", 0.5f );
		Ok( "cos(60)", 0.5f );
		Ok( "atan2(1,1)", 45f );
	}

	static void TestPrecedence()
	{
		// Getting these wrong is silent: the expression still evaluates, to the wrong number.
		Ok( "-2^2", -4f );        // unary minus binds looser than the power
		Ok( "2^-1", 0.5f );       // ...but the exponent still takes a sign
		Ok( "2^3^2", 512f );      // right-associative, not (2^3)^2 = 64
		Ok( "1+2*3", 7f );
		Ok( "(1+2)*3", 9f );
	}

	static void TestUnits()
	{
		Ok( "45deg", 45f, "deg" );
		Ok( "1rad", 57.29578f, "deg" );
		Ok( "90°", 90f, "deg" );
		Ok( "45", 45f, "deg" );                  // bare number takes the field's own unit

		// Lengths are dimensionless in this kernel, so a unit on one is refused rather than
		// silently ignored - storing 5 for "5mm" would be a worse lie than refusing it.
		Refused( "5mm" );
		Refused( "45deg" );
		Refused( "90°" );
	}

	static void TestRejected()
	{
		Refused( "" );
		Refused( "   " );
		Refused( "2 3" );          // two numbers is a typo, not a 2
		Refused( "1/" );           // mid-keystroke: hold the last good value
		Refused( "1/0" );
		Refused( "(1+2" );
		Refused( "*3" );
		Refused( "2**3" );
		Refused( "bar" );
		Refused( "foo(2)" );
		Refused( "max(1)" );       // wrong arity
		Refused( "sqrt(-1)" );     // NaN must not reach the model
		Refused( "2e" );           // not scientific notation, and not 2*e either
		Refused( "2pi" );          // no implicit multiplication - a typo, not 2*pi
		Refused( "3sqrt(4)" );
	}

	static void TestFormat()
	{
		Report.Check( "an integral value formats without trailing zeros", Expression.Format( 4f ) == "4",
			Expression.Format( 4f ) );

		Report.Check( "a fractional value keeps its decimals", Expression.Format( 0.125f ) == "0.125",
			Expression.Format( 0.125f ) );

		Report.Check( "a negative value round-trips",
			Expression.TryEvaluate( Expression.Format( -2.5f ), null, out var back )
			&& MathF.Abs( back + 2.5f ) < 1e-6f );

		// Whatever comes out has to go back in, or the field fights the user every time it
		// rewrites itself.
		var roundTripped = true;

		foreach ( var v in new[] { 0f, 1f, -1f, 0.125f, 1234.5f, -0.0625f } )
		{
			if ( !Expression.TryEvaluate( Expression.Format( v ), null, out var r ) || MathF.Abs( r - v ) > 1e-4f )
				roundTripped = false;
		}

		Report.Check( "every formatted value parses back to itself", roundTripped );
	}
}