Effigy.Tests/AllFeaturesTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Every feature tool, exercised with its defaults.
///
/// The complaint this answers is "make sure all the feature tools actually function". A toolbar
/// button that adds a feature which then errors, or silently produces nothing, is indistinguishable
/// from a broken button - and finding out which of twelve tools misbehaves by clicking each one in
/// the editor is exactly the loop that was costing evenings.
///
/// Feature types are found by REFLECTION rather than listed, so a new one is covered the moment it
/// is written. Forgetting to add a test is not an available mistake.
///
/// Each tool gets the input it needs to be meaningful - body features get a body, sketch features
/// get a closed sketch - and is then judged on the only thing that matters at this level: with
/// sensible input and untouched defaults, it either produces geometry or explains itself.
/// </summary>
public static class AllFeaturesTests
{
	public static void Run()
	{
		Report.Section( "every feature: builds with its defaults" );
		TestEveryFeatureBuilds();

		Report.Section( "every feature: declares parameters the UI can render" );
		TestParametersAreRenderable();

		Report.Section( "every feature: says something useful when it has no input" );
		TestEmptyStudioErrors();

		Report.Section( "revolve: the axis has to clear the profile" );
		TestRevolveNeedsAnAxisClearOfTheProfile();
	}

	/// <summary>
	/// Revolve's axis defaults to a line through the SKETCH ORIGIN, and people draw around the
	/// origin - so the very first press of the Revolve button on a typical sketch fails.
	///
	/// The failure is correct: you cannot revolve a profile through its own axis. What matters is
	/// that it says so usefully, with the numbers, because "move it to one side" alone leaves you
	/// guessing which side and how far. The real fix is an axis SELECTION in the dialog, the way
	/// Extrude selects a face - not something the kernel can do.
	/// </summary>
	static void TestRevolveNeedsAnAxisClearOfTheProfile()
	{
		var studio = new PartStudio();
		var sketch = studio.Add( new SketchFeature() );

		// Centred on the origin, which is where the default axis runs.
		sketch.Sketch.AddRectangle( new Vec2( -0.5f, -0.5f ), new Vec2( 0.5f, 0.5f ) );

		var revolve = studio.Add( new RevolveFeature() );
		studio.Rebuild();

		Report.Check( "a profile straddling the default axis fails rather than building nonsense",
			revolve.Error is not null, "built anyway" );

		Report.Check( "and the message says how far the profile reaches either side",
			revolve.Error is not null && revolve.Error.Contains( "0.5" ), revolve.Error ?? "" );

		// Moved clear of the axis, the same feature builds.
		var moved = new PartStudio();
		var clear = moved.Add( new SketchFeature() );
		clear.Sketch.AddRectangle( new Vec2( 1f, 1f ), new Vec2( 2f, 2f ) );

		var ok = moved.Add( new RevolveFeature() );
		moved.Rebuild();

		Report.Check( "the same revolve builds once the profile clears the axis",
			ok.Error is null && moved.Bodies.Count == 1, ok.Error ?? $"{moved.Bodies.Count} bodies" );
	}

	static List<Type> FeatureTypes() => typeof( Feature ).Assembly
		.GetTypes()
		.Where( t => t.IsSubclassOf( typeof( Feature ) ) && !t.IsAbstract )
		.OrderBy( t => t.Name, StringComparer.Ordinal )
		.ToList();

	static Feature Create( Type t ) => (Feature)Activator.CreateInstance( t );

	/// <summary>
	/// Hand a freshly created feature the kind of input that has no parameter to set it.
	///
	/// Defaults cover most tools, and the studio from WithInput covers the rest — a body to act on,
	/// a sketch to consume. A face assignment needs neither: it needs PICKED FACES, which arrive
	/// from a click in the viewport and have no default that could be meaningful. Giving it one face
	/// of the box is this harness keeping its own promise that every tool is judged on its real path
	/// rather than on its input guard. The guard itself is covered by the empty-studio test.
	/// </summary>
	static void GivePickedInput( Feature feature, PartStudio studio )
	{
		// Import is the one feature that needs a FILE rather than a body or a sketch. Bytes in
		// memory, not a temp path: the harness has no document to sit a sidecar next to, and a
		// path would leave a file behind.
		if ( feature is ImportFeature import )
		{
			import.LoadMesh( System.Text.Encoding.UTF8.GetBytes( ObjWriter.Write( Primitives.Box(), "box" ) ) );
			return;
		}

		// Sweep and loft are the two features that need a SECOND sketch to mean anything — a
		// path to follow, or another section to skin to. The shared fixture deliberately holds
		// one sketch, because every other feature resolves "the most recent sketch" and adding
		// another to the fixture would quietly change what they all build. Handing it to the two
		// features that ask for it keeps that blast radius at zero.
		if ( feature is SweepFeature or LoftFeature )
		{
			var second = studio.Add( new SketchFeature() );
			second.Plane.Index = 1; // Front (XZ), so it crosses the fixture sketch rather than lying on it

			if ( feature is SweepFeature )
			{
				// An open two-segment path for the profile to travel along.
				var a = second.Sketch.AddPoint( 1.5f, 0f );
				var b = second.Sketch.AddPoint( 1.5f, 3f );
				var c = second.Sketch.AddPoint( 3f, 5f );

				second.Sketch.Add( new SketchLine( a, b ) );
				second.Sketch.Add( new SketchLine( b, c ) );
			}
			else
			{
				// A second closed section, offset from the first so the loft has somewhere to go.
				second.PlaneOffset.Value = 3f;
				second.Sketch.AddRectangle( new Vec2( 1f, 1f ), new Vec2( 2.5f, 2.5f ) );
			}

			studio.Rebuild();
			return;
		}

		// BOOLEAN IS THE ONE FEATURE THAT NEEDS A SECOND BODY, for the same reason sweep and loft
		// need a second sketch: the fixture deliberately holds one of each, and putting a second
		// box in it for everyone would change what Shell, Draft and every body-selecting feature
		// build. It also has to be POINTED at that body — an unpicked tool is a refusal by design,
		// because "empty means every body" would ask the tool to cut itself.
		if ( feature is BooleanFeature boolean )
		{
			var tool = studio.Add( new PrimitiveFeature() );
			tool.SizeX.Value = 1f;
			tool.SizeY.Value = 1f;
			tool.SizeZ.Value = 1f;
			tool.Position.Value = new Vec3( 1f, 0f, 0f );

			studio.Rebuild();
			boolean.Tools.BodyIds.Add( tool.Id + "b0" );
			return;
		}

		if ( studio.Bodies.Count == 0 )
			return;

		var body = studio.Bodies[0];
		var mesh = body.Mesh;

		// Draft wants WALLS, not the top: a face looking straight along the pull has no horizontal
		// component to lean, which is a refusal rather than a small effect. Picking the top here
		// would test the guard instead of the operation.
		if ( feature is DraftFeature draft )
		{
			for ( var i = 0; i < mesh.Faces.Count; i++ )
			{
				if ( MathF.Abs( mesh.FaceNormal( mesh.Faces[i] ).Normal.z ) < 0.01f )
					draft.Faces.Add( FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) ) );
			}

			return;
		}

		// A hole is drilled into a face along that face's own normal, so any face will do - the top
		// is the one a person would pick.
		if ( feature is HoleFeature hole )
		{
			for ( var i = 0; i < mesh.Faces.Count; i++ )
			{
				if ( mesh.FaceNormal( mesh.Faces[i] ).Normal.z > 0.99f )
				{
					hole.Faces.Add( FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) ) );
					return;
				}
			}

			return;
		}

		// Move face wants a face like the three above, and the TOP is the one a person would grab.
		if ( feature is MoveFaceFeature move )
		{
			for ( var i = 0; i < mesh.Faces.Count; i++ )
			{
				if ( mesh.FaceNormal( mesh.Faces[i] ).z > 0.99f )
				{
					move.Faces.Add( FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) ) );
					return;
				}
			}

			return;
		}

		if ( feature is not FaceMaterialFeature material )
			return;

		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			if ( mesh.FaceNormal( mesh.Faces[i] ).z > 0.99f )
			{
				material.Faces.Add( FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) ) );
				return;
			}
		}
	}

	/// <summary>
	/// Just enough boolean for the sweep: hands back the target untouched.
	///
	/// It is not a CSG and does not pretend to be. What this sweep asks of a feature is that it runs
	/// its own path and produces no error, and for Hole that path ends in a call to the provider.
	/// Whether the engine's real boolean makes the right hole is the engine's question, and
	/// HoleTests is where the shape of the tool is checked.
	/// </summary>
	sealed class SweepBoolean : IMeshBoolean
	{
		public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
		{
			result = target.Clone();
			error = null;
			return true;
		}
	}

	/// <summary>A studio holding a box and a closed sketch above it, so any feature added next has
	/// something real to act on.</summary>
	static PartStudio WithInput()
	{
		var studio = new PartStudio();

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 2f;
		box.SizeY.Value = 2f;
		box.SizeZ.Value = 2f;

		var sketch = studio.Add( new SketchFeature() );

		// Held clear of the origin on both axes. Revolve's default axis runs through the sketch
		// origin, so a profile straddling it cannot be revolved at all - that is correct behaviour
		// and is pinned separately in TestRevolveNeedsAnAxisClearOfTheProfile. Here the point is to
		// exercise each tool's real path rather than its input guard.
		sketch.Sketch.AddRectangle( new Vec2( 1f, 1f ), new Vec2( 2f, 2f ) );

		studio.Rebuild();
		return studio;
	}

	static void TestEveryFeatureBuilds()
	{
		foreach ( var type in FeatureTypes() )
		{
			var studio = WithInput();
			var bodiesBefore = studio.Bodies.Count;

			var feature = Create( type );
			GivePickedInput( feature, studio );
			studio.Add( feature );

			RebuildReport report;

			// HOLE AND BOOLEAN ARE THE TWO FEATURES HERE THAT CANNOT BUILD WITHOUT A PROVIDER —
			// one takes material away, the other combines two solids, and both need the surface
			// recomputed. Installed only around this rebuild and only for those two: a provider
			// left in place for the whole sweep would change what every other feature does with a
			// Remove it never asked for.
			var previousProvider = MeshBoolean.Provider;

			if ( feature is HoleFeature or BooleanFeature )
				MeshBoolean.Provider = new SweepBoolean();

			try
            {
				report = studio.Rebuild();
			}
			catch ( Exception e )
			{
				// Feature.Run catches what Execute throws, so anything escaping to here is a fault
				// in the studio itself and is worth failing loudly on.
				Report.Check( $"{type.Name} does not throw out of Rebuild", false, e.Message );
				continue;
			}
			finally
			{
				MeshBoolean.Provider = previousProvider;
			}

			var ownError = feature.Error;

			Report.Check( $"{type.Name} builds with defaults", ownError is null,
				ownError ?? "" );

			if ( ownError is not null )
				continue;

			// It ran. It must also have DONE something - produced a body, changed one, or (for
			// Sketch and Plane, which build no geometry at all) published something a later feature
			// can consume. A tool that runs clean and changes nothing is the case that reads as a
			// dead button.
			var didSomething = feature is SketchFeature or PlaneFeature
				|| studio.Bodies.Count != bodiesBefore
				|| studio.Bodies.Any( b => b.Mesh.FaceCount > 0 );

			Report.Check( $"{type.Name} actually affects the model", didSomething );
		}
	}

	/// <summary>
	/// The dialog renders one row per parameter and knows a fixed set of types. A parameter of a
	/// type it does not handle renders as nothing at all, which looks like a missing control.
	/// </summary>
	static void TestParametersAreRenderable()
	{
		// Kept in step with EffigyFeatureDialog.BuildParamRow by hand, because the editor cannot be
		// referenced from here. If a new IParam type is added, this fails and says so.
		var renderable = new[]
		{
			typeof( FloatParam ), typeof( IntParam ), typeof( BoolParam ),
			typeof( ChoiceParam ), typeof( Vec3Param ), typeof( BodySelectionParam ),
			typeof( StringParam ),
		};

		foreach ( var type in FeatureTypes() )
		{
			var feature = Create( type );
			var unknown = feature.Parameters
				.Select( p => p.GetType() )
				.Where( t => !renderable.Contains( t ) )
				.Distinct()
				.ToList();

			Report.Check( $"{type.Name}'s parameters are all renderable",
				unknown.Count == 0, string.Join( ", ", unknown.Select( t => t.Name ) ) );

			// Every parameter needs a label, or the dialog draws a nameless control.
			var unlabelled = feature.Parameters.Count( p => string.IsNullOrWhiteSpace( p.Label ) );

			Report.Check( $"{type.Name}'s parameters are all labelled", unlabelled == 0,
				$"{unlabelled} without a label" );
		}
	}

	/// <summary>
	/// Added to an empty studio, a feature that needs input must fail with a message that says what
	/// is missing. "Object reference not set" is the failure mode this exists to prevent.
	/// </summary>
	static void TestEmptyStudioErrors()
	{
		foreach ( var type in FeatureTypes() )
		{
			var studio = new PartStudio();
			var feature = Create( type );
			studio.Add( feature );
			studio.Rebuild();

			if ( feature.Error is null )
				continue;   // it coped with nothing to work on, which is fine

			var diagnostic = feature.Diagnostic;
			var message = feature.Error;

			var useful = diagnostic is not null
				&& !string.IsNullOrEmpty( diagnostic.Cause )
				&& diagnostic.Remedies.Count > 0
				&& !message.Contains( "Object reference" )
				&& !message.Contains( "Index was out of range" )
				&& !message.Contains( "NullReference" );

			Report.Check( $"{type.Name}'s empty-studio error has a cause and a remedy", useful,
				$"{message} | cause={diagnostic?.Cause} | remedies={diagnostic?.Remedies.Count ?? 0}" );
		}
	}
}