Effigy.Tests/VariableTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// Named values in expressions, and a variable used by more than one feature.
///
/// Done means: a variable named once, used in four features, changed once, and all four move.
/// </summary>
public static class VariableTests
{
	public static void Run()
	{
		Section( "variables: #name in an expression" );
		TestResolver();
		TestUnknownIsRefused();
		TestCycleIsRefused();

		Section( "variables: a change moves every feature that refers to it" );
		TestOneVariableFourFeatures();

		Section( "variables: the document round-trips the table and the expressions" );
		TestDocumentRoundTrip();
	}

	static void TestResolver()
	{
		float? Resolve( string name ) => name == "thickness" ? 2f : null;

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

		Check( "#thickness / 2 evaluates against the table", parsed && Almost( value, 1f ),
			parsed ? $"{value}" : "refused" );

		Check( "a bare name is still a constant, not a variable",
			Expression.TryEvaluate( "pi", null, Resolve, out var pi ) && Almost( pi, MathF.PI ) );
	}

	static void TestUnknownIsRefused()
	{
		var parsed = Expression.TryEvaluate( "#nope", null, _ => null, out _ );

		Check( "an unknown #name is refused, so the field holds the last good value", !parsed );
	}

	static void TestCycleIsRefused()
	{
		var table = new List<StudioVariable>
		{
			new( "a", "#b + 1", 4f ),
			new( "b", "#a + 1", 5f ),
		};

		Check( "a cycle does not resolve", !VariableResolver.TryResolve( table, "a", out _ ) );

		VariableResolver.EvaluateAll( table );

		Check( "and EvaluateAll keeps the last good values rather than looping",
			Almost( table[0].Value, 4f ) && Almost( table[1].Value, 5f ),
			$"{table[0].Value}, {table[1].Value}" );
	}

	static void TestOneVariableFourFeatures()
	{
		var studio = new PartStudio();
		studio.SetVariable( "t", "2" );

		for ( var i = 0; i < 4; i++ )
		{
			var box = studio.Add( new PrimitiveFeature() );
			box.SizeX.Expr = "#t";
			box.SizeY.Value = 1f;
			box.SizeZ.Value = 1f;
		}

		studio.Rebuild();

		Check( "all four boxes take the variable's value",
			studio.Bodies.Count == 4
			&& studio.Bodies.TrueForAll( b => Almost( Extent( b.Mesh, 0 ), 2f ) ),
			$"{studio.Bodies.Count} bodies" );

		studio.SetVariable( "t", "4" );
		studio.Rebuild();

		Check( "changing it once moves all four",
			studio.Bodies.TrueForAll( b => Almost( Extent( b.Mesh, 0 ), 4f ) ) );
	}

	static void TestDocumentRoundTrip()
	{
		var studio = new PartStudio();
		studio.SetVariable( "thickness", "2" );
		studio.SetVariable( "gap", "#thickness / 2" );

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Expr = "#thickness";
		box.SizeY.Expr = "#gap";
		box.SizeZ.Value = 1f;

		var back = StudioDocument.Read( StudioDocument.Write( studio ) );
		back.Rebuild();

		Check( "both variables come back",
			back.Variables.Count == 2, $"{back.Variables.Count}" );

		Check( "the expression survived, not the evaluated number",
			back.Features[0] is PrimitiveFeature p
			&& p.SizeX.Expr == "#thickness"
			&& p.SizeY.Expr == "#gap",
			back.Features[0] is PrimitiveFeature q ? $"{q.SizeX.Expr}, {q.SizeY.Expr}" : "not a box" );

		Check( "and rebuild still follows the table",
			back.Bodies.Count == 1
			&& Almost( Extent( back.Bodies[0].Mesh, 0 ), 2f )
			&& Almost( Extent( back.Bodies[0].Mesh, 1 ), 1f ) );
	}

	static float Extent( PolyMesh mesh, int axis )
	{
		var min = float.MaxValue;
		var max = float.MinValue;

		foreach ( var p in mesh.Positions )
		{
			var v = axis == 0 ? p.x : axis == 1 ? p.y : p.z;
			min = MathF.Min( min, v );
			max = MathF.Max( max, v );
		}

		return max - min;
	}

	static bool Almost( float a, float b ) => MathF.Abs( a - b ) < 1e-3f;
}