Effigy.Tests/AcceptsTests.cs

Unit test class that verifies Feature.Accepts matches what each Feature.ApplyGeometrySelection actually consumes. It reflects over all Feature subclasses, applies synthesized selections (faces, edges, sketch regions, body picks), and asserts the feature state changes match the declared GeometryKind flags.

Reflection
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Feature.Accepts against what Feature.ApplyGeometrySelection actually does.
///
/// WHY THIS IS A TEST RATHER THAN A READING. Accepts exists to collapse three copies of one fact —
/// the type switch that consumes a selection, the prose the editor paints under the viewport, and
/// the per-dialog pick-mode flags. Collapsing them is only worth anything if the surviving copy is
/// TRUE, and a declaration that quietly disagrees with the code beneath it is worse than the three
/// copies were: the editor would dim a button that works, or offer one that ignores you.
///
/// So nothing here reads the declaration and nods at it. Every feature type is found by reflection,
/// handed a selection of exactly one KIND, and watched: if applying faces changes the feature, it
/// had better say Face, and if it says Face something had better change. Forgetting to declare is
/// not an available mistake, and neither is declaring something that is not true.
///
/// One asymmetry is deliberate and is asserted rather than worked around: a FACE selection also
/// names its face's BODY, so a body-only tool legitimately reacts to a face click by taking the
/// part. That is why the fingerprint below is split in two — the body-selection half and everything
/// else — instead of being one lump that cannot tell those apart.
/// </summary>
public static class AcceptsTests
{
	public static void Run()
	{
		Report.Section( "accepts: the declaration matches what the feature consumes" );
		TestEveryFeatureAgreesWithItself();

		Report.Section( "accepts: a body selection stays a body selection" );
		TestBodyOnlySelection();

		Report.Section( "accepts: the tools a face is worth offering" );
		TestFaceConsumers();
	}

	/// <summary>
	/// For every feature: apply one kind of pick at a time, and require the declaration to predict
	/// whether anything moved.
	/// </summary>
	static void TestEveryFeatureAgreesWithItself()
	{
		var fixture = Fixture( out var body, out var sketchId );
		var faces = new[] { FaceOf( body, v => v.z > 0.99f ) };
		var edges = EdgesOf( body );

		foreach ( var type in FeatureTypes() )
		{
			var declared = Create( type ).Accepts;

			// --- faces --------------------------------------------------------------------------
			var feature = Create( type );
			var before = Fingerprint( feature );
			feature.ApplyGeometrySelection( faces, Array.Empty<string>(), fixture.Bodies );
			var after = Fingerprint( feature );

			Report.Check( $"{type.Name}: face pick is consumed exactly as declared",
				(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.Face ),
				$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );

			// A face names its body, so a body tool takes the part it was pointed at. Onshape does
			// the same thing and it is why clicking a face then Fillet fillets that part.
			Report.Check( $"{type.Name}: the body behind a picked face lands where declared",
				(after.Bodies != before.Bodies) == declared.HasFlag( GeometryKind.Body ),
				$"declared {declared}, bodies {before.Bodies} -> {after.Bodies}" );

			// --- edges --------------------------------------------------------------------------
			feature = Create( type );
			before = Fingerprint( feature );
			feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(),
				fixture.Bodies, edges );
			after = Fingerprint( feature );

			Report.Check( $"{type.Name}: edge pick is consumed exactly as declared",
				(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.Edge ),
				$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );

			// --- a sketch, and one region of it -------------------------------------------------
			feature = Create( type );
			before = Fingerprint( feature );
			feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(),
				fixture.Bodies, null, sketchId, new[] { new Vec2( 0.5f, 0.5f ) } );
			after = Fingerprint( feature );

			Report.Check( $"{type.Name}: sketch pick is consumed exactly as declared",
				(after.Geometry != before.Geometry) == declared.HasFlag( GeometryKind.SketchRegion ),
				$"declared {declared}, geometry {before.Geometry} -> {after.Geometry}" );
		}
	}

	/// <summary>
	/// A part clicked in the Parts list is a body and nothing else. Nothing may invent a face out of
	/// it — the tools that need one still have to ask.
	/// </summary>
	static void TestBodyOnlySelection()
	{
		var fixture = Fixture( out var body, out _ );

		foreach ( var type in FeatureTypes() )
		{
			var declared = Create( type ).Accepts;
			var feature = Create( type );
			var before = Fingerprint( feature );

			feature.ApplyGeometrySelection( Array.Empty<FaceRef>(), new[] { body.Id }, fixture.Bodies );

			var after = Fingerprint( feature );

			Report.Check( $"{type.Name}: takes a picked part only if it declares Body",
				(after.Bodies != before.Bodies) == declared.HasFlag( GeometryKind.Body ),
				$"declared {declared}, bodies {before.Bodies} -> {after.Bodies}" );

			Report.Check( $"{type.Name}: a part alone is not a face",
				after.Geometry == before.Geometry,
				$"geometry {before.Geometry} -> {after.Geometry}" );
		}
	}

	/// <summary>
	/// The list the editor's hint line and the right-click face menu are generated FROM, pinned
	/// here so a tool cannot fall off it silently — which is the exact complaint that started this:
	/// a face was selected and the tool that wanted it was not on the list.
	/// </summary>
	static void TestFaceConsumers()
	{
		var consumers = FeatureTypes()
			.Where( t => Create( t ).Accepts.HasFlag( GeometryKind.Face ) )
			.Select( t => t.Name )
			.ToList();

		foreach ( var expected in new[]
		{
			nameof( SketchFeature ), nameof( PlaneFeature ), nameof( DraftFeature ), nameof( HoleFeature ),
			nameof( FaceMaterialFeature ), nameof( SubdivideFeature ), nameof( ShellFeature ),
			nameof( FilletFeature ), nameof( ChamferFeature ),

			// Extrude joined this list when a mesh face became a profile it can pull. It used to be
			// pinned here as deliberately ABSENT, with a note saying that the day it changed should
			// be a day something said so out loud — this is that, kept rather than deleted.
			nameof( ExtrudeFeature ), nameof( MoveFaceFeature ),
		} )
		{
			Report.Check( $"{expected} is offered a face", consumers.Contains( expected ),
				string.Join( ", ", consumers ) );
		}

		// Revolve is the one that is deliberately NOT here. Spinning a mesh face about an axis is a
		// real operation and nothing has built it, so saying it takes a face would be a lie of the
		// exact kind Accepts exists to stop. This is what says so when that changes.
		Report.Check( "revolve does not claim a face it cannot use yet",
			!consumers.Contains( nameof( RevolveFeature ) ), string.Join( ", ", consumers ) );
	}

	// --- the fingerprint --------------------------------------------------------------------------

	/// <summary>
	/// What a feature is holding, in two halves: the bodies it has been pointed at, and every other
	/// piece of picked geometry on it.
	///
	/// BY REFLECTION over the public fields rather than a switch naming the ones known today. A
	/// switch here would be a fourth copy of the fact this whole exercise exists to have one of, and
	/// it would go stale the same way. Counts are enough — every consuming path fills something that
	/// started empty or sets something that started null.
	/// </summary>
	readonly struct Snapshot
	{
		public Snapshot( string bodies, string geometry )
		{
			Bodies = bodies;
			Geometry = geometry;
		}

		public string Bodies { get; }
		public string Geometry { get; }
	}

	static Snapshot Fingerprint( Feature feature )
	{
		var bodies = string.Join( "/", feature.Parameters
			.OfType<BodySelectionParam>()
			.Select( p => $"{p.Label}:{p.BodyIds.Count}" ) );

		var geometry = new List<string>();

		foreach ( var field in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) )
		{
			var value = field.GetValue( feature );

			switch ( value )
			{
				case null:
					geometry.Add( $"{field.Name}:null" );
					break;

				// The body lists are the other half of the snapshot and must not be counted twice.
				case BodySelectionParam:
					break;

				case FaceRef:
				case EdgeRef:
					geometry.Add( $"{field.Name}:set" );
					break;

				case string text:
					geometry.Add( $"{field.Name}:{text}" );
					break;

				case ICollection collection:
					geometry.Add( $"{field.Name}:{collection.Count}" );
					break;
			}
		}

		return new Snapshot( bodies, string.Join( "/", geometry ) );
	}

	// --- the fixture ------------------------------------------------------------------------------

	/// <summary>A box to pick faces and edges off, and a closed sketch to pick as a profile.</summary>
	static PartStudio Fixture( out Body body, out string sketchId )
	{
		var studio = new PartStudio();

		studio.Add( new PrimitiveFeature() );

		var sketch = studio.Add( new SketchFeature() );
		sketch.Sketch.AddRectangle( new Vec2( 0.25f, 0.25f ), new Vec2( 0.75f, 0.75f ) );
		sketchId = sketch.Id;

		studio.Rebuild();
		body = studio.Bodies[0];

		return studio;
	}

	static FaceRef FaceOf( Body body, Func<Vec3, bool> normal )
	{
		var mesh = body.Mesh;

		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			if ( normal( mesh.FaceNormal( mesh.Faces[i] ).Normal ) )
				return FacePlane.Capture( body, i, mesh.FaceCentroid( mesh.Faces[i] ) );
		}

		throw new InvalidOperationException( "the fixture box has no face pointing that way" );
	}

	static EdgeRef[] EdgesOf( Body body )
	{
		var face = FaceOf( body, v => v.z > 0.99f );

		if ( !FacePlane.TryResolveFace( new[] { body }, face, out var resolved, out var index ) )
			throw new InvalidOperationException( "the fixture's top face did not resolve" );

		return FacePlane.CaptureBoundary( resolved, index ).ToArray();
	}

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