Effigy.Tests/AllFeaturesTests.cs

Unit test suite that exercises every Feature implementation in the Effigy assembly. It creates small PartStudio fixtures, gives features minimal valid inputs where needed, rebuilds the studio, and asserts that features build, publish renderable parameters, and emit useful errors on empty input; it also contains a small boolean provider used during tests.

Reflection
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 )
	{
		// GARMENT, FUR, TRIM AND FABRIC ARE THE CLOTHING TOOLS, and the fixture has no rig, which is
		// exactly the input the garment's own guard is written to refuse. The model these need
		// is a BODY WITH BONES: a spine inside the fixture box is enough for a T-shirt to cover
		// it, and fur and trim both need the garment they attach to to already be there.
		if ( feature is GarmentFeature or FurFeature or TrimFeature or FabricFeature )
		{
			var rig = studio.Rig;
			var root = rig.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -0.9f ), new Vec3( 0, 0, -0.5f ) );
			var pelvis = rig.AddBoneFromPoints( "pelvis", root, new Vec3( 0, 0, -0.5f ), new Vec3( 0, 0, -0.1f ) );
			rig.AddBoneFromPoints( "spine", pelvis, new Vec3( 0, 0, -0.1f ), new Vec3( 0, 0, 0.9f ) );

			// Fur with nothing picked falls back to "every garment": give it one. The default
			// T-shirt on the fixture box is exactly what the Clothing strip produces first.
			if ( feature is FurFeature or TrimFeature or FabricFeature )
			{
				var garment = studio.Add( new GarmentFeature() );
				garment.Name = "Shirt";
				garment.Drape.Value = false;

				// Trim hangs off an OPENING, and Thickness closes a garment into a solid with
				// none. The Clothing bar's own first garment is thickened; a fixture for trim
				// cannot be.
				if ( feature is TrimFeature )
					garment.Thickness.Value = 0f;
			}

			studio.Rebuild();
			return;
		}

		// A SPLINE IS DRAWN, NOT TYPED: its points come from clicks and a fresh one has none, which
		// is a curve in progress rather than a broken one. Give it a path so the tube actually builds,
		// and give Bend along curve a spline to follow, since it refuses with none above it.
		if ( feature is SplineFeature drawn )
			drawn.Points.AddRange( new[] { new Vec3( -2, 0, 2 ), new Vec3( 0, 1, 2.5f ), new Vec3( 2, 0, 3 ), new Vec3( 3, -1, 2 ) } );

		if ( feature is CurveDeformFeature )
		{
			var path = studio.Add( new SplineFeature() );
			path.Tube.Value = false;
			path.Points.AddRange( new[] { new Vec3( -2, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 2, 0, 0 ) } );
		}

		// 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}" );
		}
	}
}