Effigy.Tests/ConstraintToolTests.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy.Tests;

/// <summary>
/// Turning a selection into a constraint.
///
/// The solver has been able to satisfy eleven kinds of rule for several sessions and there has been
/// no way to add one in the editor, so it only ever ran on what the drawing inference happened to
/// put there. ConstraintTools is the missing half, and it is all rules and no drawing — which is
/// where the mistakes are, and why it can be tested without a viewport.
///
/// These check three things in order: that the right things are offered for a given selection, that
/// applying one and solving actually moves the geometry the way the label promised, and that the
/// same rule cannot be added twice however it is phrased.
/// </summary>
public static class ConstraintToolTests
{
	public static void Run()
	{
		Report.Section( "constraint tools: what a selection allows" );
		TestOffers();

		Report.Section( "constraint tools: applying one does what the label says" );
		TestApplied();

		Report.Section( "constraint tools: dimensions open on the truth" );
		TestMeasured();

		Report.Section( "constraint tools: the same rule is not offered twice" );
		TestDuplicates();

		Report.Section( "constraint tools: what a circle's radius really is" );
		TestCircleRadius();

		Report.Section( "constraint tools: the six kinds that had no way in" );
		TestTheUnreachableSix();

		Report.Section( "constraint tools: a rule that cannot hold is taken back out" );
		TestRefused();

		Report.Section( "constraint tools: marks to draw on the sketch" );
		TestMarkers();

		Report.Section( "constraint tools: finding what holds a point" );
		TestTouching();
	}

	static void TestOffers()
	{
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 1 ) );

		var one = Labels( sketch, new SketchSelection( null, new[] { line.Id } ) );

		Report.Check( "one line offers horizontal, vertical and a length",
			one.SequenceEqual( new[] { "Horizontal", "Length", "Vertical" } ), string.Join( ", ", one ) );

		var second = sketch.AddLine( new Vec2( 0, 3 ), new Vec2( 4, 5 ) );

		var two = Labels( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) );

		Report.Check( "two lines offer the pair rules",
			two.SequenceEqual( new[] { "Angle", "Equal length", "Parallel", "Perpendicular" } ),
			string.Join( ", ", two ) );

		var points = new SketchSelection( new[] { line.Start, second.Start } );
		var pair = Labels( sketch, points );

		Report.Check( "two points offer coincident, distance and the two alignments",
			pair.SequenceEqual( new[] { "Coincident", "Distance", "Horizontal", "Vertical" } ),
			string.Join( ", ", pair ) );

		// A POINT AND A LINE, not three loose points. Three points also describe "one lies on the
		// other two", and then which one is the point is a guess dressed up as a convention.
		//
		// Midpoint takes the same selection, being point-on-line said more exactly.
		var onLine = Labels( sketch, new SketchSelection( new[] { second.Start }, new[] { line.Id } ) );

		Report.Check( "a point and a line offer point-on-line and midpoint",
			onLine.SequenceEqual( new[] { "Midpoint", "Point on line" } ), string.Join( ", ", onLine ) );

		var mirror = Labels( sketch,
			new SketchSelection( new[] { second.Start, second.End }, new[] { line.Id } ) );

		Report.Check( "two points and a line offer symmetric",
			mirror.SequenceEqual( new[] { "Symmetric" } ), string.Join( ", ", mirror ) );

		Report.Check( "nothing selected offers nothing",
			ConstraintTools.Offers( sketch, new SketchSelection() ).Count == 0 );

		Report.Check( "a selection that means nothing offers nothing",
			ConstraintTools.Offers( sketch,
				new SketchSelection( new[] { line.Start, line.End, second.Start } ) ).Count == 0 );
	}

	/// <summary>
	/// The part that matters: apply the offer, solve, and check the geometry obeys.
	///
	/// A test that only checked which constraint object came out would pass just as well if the
	/// point indices in it were wrong, and wrong indices are the single most likely mistake in the
	/// whole file.
	/// </summary>
	static void TestApplied()
	{
		// HORIZONTAL on a line that is not.
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 2 ) );

		Apply( sketch, new SketchSelection( null, new[] { line.Id } ), "Horizontal" );

		var solved = SketchSolver.Solve( sketch );

		Report.Check( "horizontal converges", solved.Converged, $"residual {solved.Residual:0.000000}" );

		Report.Check( "and the line comes out level",
			MathF.Abs( sketch.Points[line.Start].y - sketch.Points[line.End].y ) < 1e-3f,
			$"ends at y {sketch.Points[line.Start].y:0.####} and {sketch.Points[line.End].y:0.####}" );

		// PERPENDICULAR between two lines that are not.
		var corner = new Sketch();
		var a = corner.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
		var b = corner.AddLine( new Vec2( 0, 0 ), new Vec2( 3, 1 ) );

		Apply( corner, new SketchSelection( null, new[] { a.Id, b.Id } ), "Perpendicular" );
		SketchSolver.Solve( corner );

		var u = corner.Points[a.End] - corner.Points[a.Start];
		var v = corner.Points[b.End] - corner.Points[b.Start];

		Report.Check( "perpendicular gives a right angle",
			MathF.Abs( Vec2.Dot( u.Normal, v.Normal ) ) < 1e-3f,
			$"dot {Vec2.Dot( u.Normal, v.Normal ):0.#####}" );

		// DISTANCE, driven to a number the sketch was not.
		var span = new Sketch();
		var line2 = span.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );

		var offer = ConstraintTools.Offers( span, new SketchSelection( null, new[] { line2.Id } ) )
			.Single( o => o.Label == "Length" );

		offer.Value = 10f;
		ConstraintTools.Apply( span, offer );
		SketchSolver.Solve( span );

		Report.Check( "a driven length reaches the number it was given",
			MathF.Abs( (span.Points[line2.End] - span.Points[line2.Start]).Length - 10f ) < 1e-3f,
			$"came out {(span.Points[line2.End] - span.Points[line2.Start]).Length:0.####}" );

		// POINT ON LINE, with the right point of the three.
		var onto = new Sketch();
		var rail = onto.AddLine( new Vec2( 0, 0 ), new Vec2( 10, 0 ) );
		var loose = onto.AddPoint( new Vec2( 5, 4 ) );

		Apply( onto, new SketchSelection( new[] { loose }, new[] { rail.Id } ), "Point on line" );

		var landed = SketchSolver.Solve( onto );

		Report.Check( "point on line converges", landed.Converged, $"residual {landed.Residual:0.000000}" );

		// AGAINST THE LINE WHERE IT ENDED UP, not against where it started. The first version of this
		// checked that the point's y reached zero and the rail's y stayed there, and both failed: an
		// under-constrained sketch is free to meet a new rule by moving EITHER side of it, and least
		// squares moves both. That is correct — the rule says the point is on the line, and says
		// nothing about which of them has to give — so the oracle is the relation, not the fixture's
		// assumption about who moves.
		Report.Check( "the point ends up on the line",
			DistanceToLine( onto, loose, rail.Start, rail.End ) < 1e-3f,
			$"{DistanceToLine( onto, loose, rail.Start, rail.End ):0.######} off it" );

		// It did have to move: a test that passed on an untouched sketch would prove nothing.
		Report.Check( "and something actually moved to get there",
			(onto.Points[loose] - new Vec2( 5, 4 )).Length > 0.1f
			|| (onto.Points[rail.End] - new Vec2( 10, 0 )).Length > 0.1f );

		// SYMMETRIC about a line.
		var mirror = new Sketch();
		var axis = mirror.AddLine( new Vec2( 0, -5 ), new Vec2( 0, 5 ) );
		var left = mirror.AddPoint( new Vec2( -3, 1 ) );
		var right = mirror.AddPoint( new Vec2( 4, 2 ) );

		Apply( mirror, new SketchSelection( new[] { left, right }, new[] { axis.Id } ), "Symmetric" );

		var mirrored = SketchSolver.Solve( mirror );

		Report.Check( "symmetric converges", mirrored.Converged, $"residual {mirrored.Residual:0.000000}" );

		// Same lesson as above, and it bit harder here: the AXIS is free to rotate too, so checking
		// that the two x values cancel assumes an axis that stayed vertical. What symmetry actually
		// means is two things — their midpoint lies on the axis, and the line between them crosses it
		// square — and both are true wherever the axis ended up.
		var midpoint = (mirror.Points[left] + mirror.Points[right]) * 0.5f;
		var toAxis = DistanceToLine( midpoint, mirror.Points[axis.Start], mirror.Points[axis.End] );

		Report.Check( "the pair's midpoint sits on the axis", toAxis < 1e-3f, $"{toAxis:0.######} off it" );

		var across = (mirror.Points[right] - mirror.Points[left]).Normal;
		var along = (mirror.Points[axis.End] - mirror.Points[axis.Start]).Normal;

		Report.Check( "and the line between them crosses it square",
			MathF.Abs( Vec2.Dot( across, along ) ) < 1e-3f,
			$"dot {Vec2.Dot( across, along ):0.#####}" );

		// EQUAL RADIUS on two arcs.
		var arcs = new Sketch();
		var c1 = arcs.AddPoint( new Vec2( 0, 0 ) );
		var s1 = arcs.AddPoint( new Vec2( 2, 0 ) );
		var e1 = arcs.AddPoint( new Vec2( 0, 2 ) );
		var c2 = arcs.AddPoint( new Vec2( 10, 0 ) );
		var s2 = arcs.AddPoint( new Vec2( 15, 0 ) );
		var e2 = arcs.AddPoint( new Vec2( 10, 5 ) );

		var arcA = arcs.Add( new SketchArc( c1, s1, e1 ) );
		var arcB = arcs.Add( new SketchArc( c2, s2, e2 ) );

		Apply( arcs, new SketchSelection( null, new[] { arcA.Id, arcB.Id } ), "Equal radius" );
		SketchSolver.Solve( arcs );

		var r1 = (arcs.Points[s1] - arcs.Points[c1]).Length;
		var r2 = (arcs.Points[s2] - arcs.Points[c2]).Length;

		Report.Check( "equal radius makes two arcs the same size",
			MathF.Abs( r1 - r2 ) < 1e-3f, $"{r1:0.####} and {r2:0.####}" );
	}

	static void TestMeasured()
	{
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 1, 1 ), new Vec2( 4, 5 ) );

		var length = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id } ) )
			.Single( o => o.Label == "Length" );

		// A DIMENSION OPENS ON THE TRUTH. Showing zero and making the user type what the sketch
		// already is turns "lock this where it is" — which is most dimensions — into measuring by
		// hand, and any rounding they do silently moves the geometry.
		Report.Check( "a length dimension is pre-filled with the current length",
			MathF.Abs( length.Value - 5f ) < 1e-4f, $"{length.Value:0.#####}" );

		Report.Check( "and is marked as taking a number", length.NeedsValue );

		Report.Check( "with no unit, since it is a length", length.Unit == "" );

		var second = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 0, 3 ) );

		var angle = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) )
			.Single( o => o.Label == "Angle" );

		// (3,4) against (0,1) is 90 - 53.13 = 36.87 degrees.
		Report.Check( "an angle dimension is pre-filled with the current angle",
			MathF.Abs( angle.Value - 36.8699f ) < 1e-2f, $"{angle.Value:0.####}" );

		Report.Check( "and says it is in degrees", angle.Unit == "deg" );

		Report.Check( "a plain rule carries no value",
			ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) )
				.Single( o => o.Label == "Parallel" ).NeedsValue == false );

		// Applying without touching the value locks what is there — the sketch must not move.
		var before = sketch.Points[line.End];
		ConstraintTools.Apply( sketch, length );
		SketchSolver.Solve( sketch );

		Report.Check( "applying a measured dimension unchanged leaves the sketch where it was",
			(sketch.Points[line.End] - before).Length < 1e-3f );
	}

	static void TestDuplicates()
	{
		var sketch = new Sketch();
		var a = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
		var b = sketch.AddLine( new Vec2( 0, 2 ), new Vec2( 4, 3 ) );

		var selection = new SketchSelection( null, new[] { a.Id, b.Id } );

		Apply( sketch, selection, "Parallel" );

		Report.Check( "the rule is on the sketch", sketch.Constraints.Count == 1 );

		Report.Check( "and is no longer offered",
			!Labels( sketch, selection ).Contains( "Parallel" ),
			string.Join( ", ", Labels( sketch, selection ) ) );

		// ORDER-INSENSITIVE. "A parallel to B" and "B parallel to A" are one rule, and offering the
		// second is how a sketch quietly acquires the redundancy that makes the NEXT dimension
		// appear to do nothing.
		var swapped = new SketchSelection( null, new[] { b.Id, a.Id } );

		Report.Check( "nor is it offered with the two lines the other way round",
			!Labels( sketch, swapped ).Contains( "Parallel" ),
			string.Join( ", ", Labels( sketch, swapped ) ) );

		Report.Check( "applying it again is refused",
			!ConstraintTools.Apply( sketch, new ConstraintOffer
			{
				Kind = SketchConstraintKind.Parallel,
				Constraint = new SketchConstraint( SketchConstraintKind.Parallel, b.Start, b.End, a.Start, a.End ),
			} ) );

		Report.Check( "so the sketch still holds one", sketch.Constraints.Count == 1 );

		// Reversing one line's own endpoints is the same segment too.
		Report.Check( "nor with one line's ends reversed",
			ConstraintTools.Has( sketch,
				new SketchConstraint( SketchConstraintKind.Parallel, a.End, a.Start, b.Start, b.End ) ) );

		// SYMMETRIC IS THE EXCEPTION, and has to be. Its first pair is what gets mirrored and its
		// second is the mirror; swapping them is a different rule, not the same one phrased twice.
		var mirror = new Sketch();
		var axis = mirror.AddLine( new Vec2( 0, -5 ), new Vec2( 0, 5 ) );
		var p = mirror.AddPoint( new Vec2( -3, 0 ) );
		var q = mirror.AddPoint( new Vec2( 3, 0 ) );

		mirror.Constraints.Add( new SketchConstraint( SketchConstraintKind.Symmetric, p, q, axis.Start, axis.End ) );

		Report.Check( "swapping a symmetric constraint's pairs is a different rule",
			!ConstraintTools.Has( mirror,
				new SketchConstraint( SketchConstraintKind.Symmetric, axis.Start, axis.End, p, q ) ) );

		Report.Check( "but the same rule is still recognised",
			ConstraintTools.Has( mirror,
				new SketchConstraint( SketchConstraintKind.Symmetric, q, p, axis.End, axis.Start ) ) );
	}

	/// <summary>
	/// A circle's radius is a stored float, not two points, so the solver has nothing to act on and
	/// no radius constraint is offered for one. That is a real limitation and the honest thing is to
	/// say so here rather than to offer a control that silently does nothing.
	/// </summary>
	static void TestCircleRadius()
	{
		var sketch = new Sketch();
		var circle = sketch.AddCircle( new Vec2( 0, 0 ), 3f );

		Report.Check( "a circle offers no radius constraint",
			ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { circle.Id } ) ).Count == 0 );

		// An ARC does, because its radius is the distance between two of its points.
		var arcSketch = new Sketch();
		var centre = arcSketch.AddPoint( new Vec2( 0, 0 ) );
		var start = arcSketch.AddPoint( new Vec2( 3, 0 ) );
		var end = arcSketch.AddPoint( new Vec2( 0, 3 ) );
		var arc = arcSketch.Add( new SketchArc( centre, start, end ) );

		var offers = ConstraintTools.Offers( arcSketch, new SketchSelection( null, new[] { arc.Id } ) );

		Report.Check( "an arc does", offers.Count == 2 && offers[0].Label == "Radius",
			string.Join( ", ", offers.Select( o => o.Label ) ) );

		Report.Check( "pre-filled with the radius it has", MathF.Abs( offers[0].Value - 3f ) < 1e-4f );

		// RADIUS AND DIAMETER ARE ONE RULE WRITTEN TWO WAYS. Both are offered while the arc is
		// undimensioned, and the number each opens on is its own.
		Report.Check( "and a diameter alongside it", offers[1].Label == "Diameter" );

		Report.Check( "opening on twice the radius", MathF.Abs( offers[1].Value - 6f ) < 1e-4f,
			$"{offers[1].Value:0.###}" );

		offers[0].Value = 5f;
		ConstraintTools.Apply( arcSketch, offers[0] );
		SketchSolver.Solve( arcSketch );

		Report.Check( "and driving it moves the arc",
			MathF.Abs( (arcSketch.Points[start] - arcSketch.Points[centre]).Length - 5f ) < 1e-3f,
			$"{(arcSketch.Points[start] - arcSketch.Points[centre]).Length:0.####}" );

		// The implicit arc invariant keeps the far end with it — an arc whose ends sit at different
		// distances from its centre is not an arc.
		Report.Check( "taking the other end with it",
			MathF.Abs( (arcSketch.Points[end] - arcSketch.Points[centre]).Length - 5f ) < 1e-3f,
			$"{(arcSketch.Points[end] - arcSketch.Points[centre]).Length:0.####}" );

		// AND NOW NEITHER IS OFFERED. A sketch carrying both would solve and then report
		// redundancy, which is a puzzling way to be told the arc was already dimensioned.
		var after = Labels( arcSketch, new SketchSelection( null, new[] { arc.Id } ) );

		Report.Check( "a dimensioned arc is offered neither of them again",
			after.Count == 0, string.Join( ", ", after ) );
	}

	/// <summary>
	/// The six kinds that solved, marked up and round-tripped through the file with no selection
	/// anywhere that produced one.
	///
	/// OFFERED AND THEN APPLIED, in that order and never only the first. What a selection allows is
	/// a table, and a table reads as correct while the point indices inside it are wrong; only the
	/// geometry moving proves each constraint's slots were filled in the order it reads them.
	/// </summary>
	static void TestTheUnreachableSix()
	{
		// --- fix ------------------------------------------------------------------------------
		var fixSketch = new Sketch();
		var bar = fixSketch.AddLine( new Vec2( 2, 3 ), new Vec2( 6, 3 ) );

		var single = Labels( fixSketch, new SketchSelection( new[] { bar.Start } ) );

		Report.Check( "one point on its own offers a fix",
			single.SequenceEqual( new[] { "Fix" } ), string.Join( ", ", single ) );

		Apply( fixSketch, new SketchSelection( new[] { bar.Start } ), "Fix" );

		// A FIX IS THE ONE RULE WHOSE VALUE IS A POSITION. Carrying only the x through the offer
		// would nail the point to the right column of a sketch and to y = 0.
		Report.Check( "a fix remembers both coordinates",
			MathF.Abs( fixSketch.Constraints[0].Value - 2f ) < 1e-4f
			&& MathF.Abs( fixSketch.Constraints[0].ValueY - 3f ) < 1e-4f,
			$"{fixSketch.Constraints[0].Value:0.##}, {fixSketch.Constraints[0].ValueY:0.##}" );

		fixSketch.Points[bar.Start] = new Vec2( 9, 9 );
		SketchSolver.Solve( fixSketch, bar.End );

		Report.Check( "and drags the point back where it was fixed",
			(fixSketch.Points[bar.Start] - new Vec2( 2, 3 )).Length < 1e-3f,
			$"({fixSketch.Points[bar.Start].x:0.##},{fixSketch.Points[bar.Start].y:0.##})" );

		// --- midpoint -------------------------------------------------------------------------
		var mid = new Sketch();
		var rail = mid.AddLine( new Vec2( 0, 0 ), new Vec2( 10, 0 ) );
		var free = mid.AddPoint( new Vec2( 1, 4 ) );

		Apply( mid, new SketchSelection( new[] { free }, new[] { rail.Id } ), "Midpoint" );
		SketchSolver.Solve( mid, rail.Start );

		// Against the line WHEREVER IT ENDED UP, not against (5,0): the solver is free to move the
		// line as well as the point, and the rule is about the relation rather than the place.
		Report.Check( "midpoint puts the point half way along the line",
			(mid.Points[free] - (mid.Points[rail.Start] + mid.Points[rail.End]) * 0.5f).Length < 1e-3f,
			$"({mid.Points[free].x:0.##},{mid.Points[free].y:0.##})" );

		// --- concentric -----------------------------------------------------------------------
		//
		// The one new rule a CIRCLE can take part in: it contributes its centre to the solve and
		// nothing else, which is all a concentricity needs.
		var conc = new Sketch();
		var outer = conc.AddCircle( new Vec2( 0, 0 ), 3f );
		var inner = conc.AddCircle( new Vec2( 4, 1 ), 1.5f );

		var both = Labels( conc, new SketchSelection( null, new[] { outer.Id, inner.Id } ) );

		Report.Check( "two circles offer concentric and nothing else",
			both.SequenceEqual( new[] { "Concentric" } ), string.Join( ", ", both ) );

		Apply( conc, new SketchSelection( null, new[] { outer.Id, inner.Id } ), "Concentric" );
		SketchSolver.Solve( conc, outer.Center );

		Report.Check( "and applying it brings their centres together",
			(conc.Points[inner.Center] - conc.Points[outer.Center]).Length < 1e-3f,
			$"{(conc.Points[inner.Center] - conc.Points[outer.Center]).Length:0.####}" );

		// --- a line tangent to an arc -----------------------------------------------------------
		var tangent = new Sketch();
		var hub = tangent.AddPoint( new Vec2( 0, 0 ) );
		var rim = tangent.AddPoint( new Vec2( 2, 0 ) );
		var tail = tangent.AddPoint( new Vec2( 0, 2 ) );
		var curve = tangent.Add( new SketchArc( hub, rim, tail ) );
		var edge = tangent.AddLine( new Vec2( -4, 3 ), new Vec2( 4, 3 ) );

		var lineAndArc = Labels( tangent, new SketchSelection( null, new[] { edge.Id, curve.Id } ) );

		Report.Check( "a line and an arc offer a tangency",
			lineAndArc.SequenceEqual( new[] { "Tangent" } ), string.Join( ", ", lineAndArc ) );

		Apply( tangent, new SketchSelection( null, new[] { edge.Id, curve.Id } ), "Tangent" );
		SketchSolver.Solve( tangent, hub );

		var reach = (tangent.Points[rim] - tangent.Points[hub]).Length;
		var standoff = DistanceToLine( tangent, hub, edge.Start, edge.End );

		Report.Check( "and the line ends up exactly a radius from the centre",
			MathF.Abs( standoff - reach ) < 1e-3f, $"{standoff:0.####} against a radius of {reach:0.####}" );

		// --- two arcs tangent to each other -----------------------------------------------------
		var pairSketch = new Sketch();
		var leftHub = pairSketch.AddPoint( new Vec2( 0, 0 ) );
		var leftRim = pairSketch.AddPoint( new Vec2( 2, 0 ) );
		var leftTail = pairSketch.AddPoint( new Vec2( 0, 2 ) );
		var left = pairSketch.Add( new SketchArc( leftHub, leftRim, leftTail ) );

		var rightHub = pairSketch.AddPoint( new Vec2( 6, 0 ) );
		var rightRim = pairSketch.AddPoint( new Vec2( 7, 0 ) );
		var rightTail = pairSketch.AddPoint( new Vec2( 6, 1 ) );
		var right = pairSketch.Add( new SketchArc( rightHub, rightRim, rightTail ) );

		var twoArcs = Labels( pairSketch, new SketchSelection( null, new[] { left.Id, right.Id } ) );

		Report.Check( "two arcs offer equal radius, concentric and a tangency",
			twoArcs.SequenceEqual( new[] { "Concentric", "Equal radius", "Tangent" } ),
			string.Join( ", ", twoArcs ) );

		Apply( pairSketch, new SketchSelection( null, new[] { left.Id, right.Id } ), "Tangent" );
		SketchSolver.Solve( pairSketch, leftHub );

		var apart = (pairSketch.Points[rightHub] - pairSketch.Points[leftHub]).Length;
		var leftRadius = (pairSketch.Points[leftRim] - pairSketch.Points[leftHub]).Length;
		var rightRadius = (pairSketch.Points[rightRim] - pairSketch.Points[rightHub]).Length;

		Report.Check( "two arcs drawn apart are made to touch on the outside",
			MathF.Abs( apart - (leftRadius + rightRadius) ) < 1e-3f,
			$"{apart:0.###} apart against radii of {leftRadius:0.###} and {rightRadius:0.###}" );

		// WHICH TANGENCY IS READ OFF THE SKETCH. A small arc drawn inside a big one is asking for
		// the internal kind, and offering the external one would shove it out through the wall.
		var nested = new Sketch();
		var bigHub = nested.AddPoint( new Vec2( 0, 0 ) );
		var bigRim = nested.AddPoint( new Vec2( 5, 0 ) );
		var bigTail = nested.AddPoint( new Vec2( 0, 5 ) );
		var big = nested.Add( new SketchArc( bigHub, bigRim, bigTail ) );

		var smallHub = nested.AddPoint( new Vec2( 1, 0 ) );
		var smallRim = nested.AddPoint( new Vec2( 2, 0 ) );
		var smallTail = nested.AddPoint( new Vec2( 1, 1 ) );
		var small = nested.Add( new SketchArc( smallHub, smallRim, smallTail ) );

		var selection = new SketchSelection( null, new[] { big.Id, small.Id } );
		var internalOffer = ConstraintTools.Offers( nested, selection ).Single( o => o.Label == "Tangent" );

		Report.Check( "an arc drawn inside another is offered the internal tangency",
			internalOffer.Value != 0f, $"{internalOffer.Value}" );

		ConstraintTools.Apply( nested, internalOffer );

		// The flag lives in Value, which Apply writes from the offer - so this is also the check
		// that a plain rule's value survives the trip through the offer rather than being zeroed.
		Report.Check( "and the rule keeps that flag when it lands on the sketch",
			nested.Constraints.Single().Value != 0f );

		SketchSolver.Solve( nested, bigHub );

		var gap = (nested.Points[smallHub] - nested.Points[bigHub]).Length;
		var bigRadius = (nested.Points[bigRim] - nested.Points[bigHub]).Length;
		var smallRadius = (nested.Points[smallRim] - nested.Points[smallHub]).Length;

		Report.Check( "so it touches from the inside rather than being pushed out",
			MathF.Abs( gap - MathF.Abs( bigRadius - smallRadius ) ) < 1e-3f,
			$"{gap:0.###} apart against radii of {bigRadius:0.###} and {smallRadius:0.###}" );
	}

	static void TestTouching()
	{
		var sketch = new Sketch();
		var a = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
		var b = sketch.AddLine( new Vec2( 4, 0 ), new Vec2( 4, 3 ) );

		Apply( sketch, new SketchSelection( null, new[] { a.Id } ), "Horizontal" );
		Apply( sketch, new SketchSelection( null, new[] { b.Id } ), "Vertical" );
		Apply( sketch, new SketchSelection( new[] { a.End, b.Start } ), "Coincident" );

		Report.Check( "three rules on the sketch", sketch.Constraints.Count == 3 );

		var atCorner = ConstraintTools.Touching( sketch, a.End );

		Report.Check( "the corner point is held by the horizontal and the coincidence",
			atCorner.Count == 2, $"{atCorner.Count}" );

		var onFirst = ConstraintTools.Touching( sketch, a );

		Report.Check( "the first line is held by two rules", onFirst.Count == 2, $"{onFirst.Count}" );

		Report.Check( "a point nothing holds comes back empty",
			ConstraintTools.Touching( sketch, b.End ).Count == 1,
			$"{ConstraintTools.Touching( sketch, b.End ).Count}" );

		// Removing one is how a UI undoes a rule, and the sketch has to keep solving after it.
		sketch.Constraints.Remove( atCorner[0] );

		Report.Check( "the sketch still solves with one taken away",
			SketchSolver.Solve( sketch ).Converged );
	}

	/// <summary>
	/// A contradictory constraint must leave NOTHING behind — not the rule, and not the positions
	/// the failed solve dragged the sketch through on its way to giving up.
	///
	/// The second half is the part that is easy to get wrong. Removing the rule and solving again
	/// converges to *a* valid answer, which need not be the one that was on screen a moment ago, so
	/// the user watches their geometry shift in response to an operation that reported failure.
	/// </summary>
	static void TestRefused()
	{
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );

		var length = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id } ) )
			.Single( o => o.Label == "Length" );

		length.Value = 6f;

		Report.Check( "a first dimension applies", ConstraintTools.ApplyAndSolve( sketch, length ).Applied );

		var settled = sketch.Points.Select( p => p ).ToList();

		// A second length on the same line, saying something else. Both cannot be true.
		var contradiction = new ConstraintOffer
		{
			Kind = SketchConstraintKind.Distance,
			Label = "Length",
			NeedsValue = true,
			Value = 9f,
			Constraint = new SketchConstraint( SketchConstraintKind.Distance, line.Start, line.End ),
		};

		var refused = ConstraintTools.ApplyAndSolve( sketch, contradiction );

		Report.Check( "a contradictory dimension is refused", !refused.Applied );

		Report.Check( "and says why", refused.Message is not null, refused.Message ?? "no message" );

		Report.Check( "the rule is not left on the sketch", sketch.Constraints.Count == 1,
			$"{sketch.Constraints.Count} constraints" );

		Report.Check( "and the geometry is exactly where it was",
			sketch.Points.Zip( settled, ( a, b ) => (a - b).Length ).All( d => d < 1e-6f ),
			$"line is now {(sketch.Points[line.End] - sketch.Points[line.Start]).Length:0.####} long" );

		Report.Check( "so the first dimension still holds",
			MathF.Abs( (sketch.Points[line.End] - sketch.Points[line.Start]).Length - 6f ) < 1e-3f );

		// A duplicate is refused too, and differently — it is not a contradiction, it is already true.
		var duplicate = new ConstraintOffer
		{
			Kind = SketchConstraintKind.Distance,
			Label = "Length",
			Value = 6f,
			Constraint = new SketchConstraint( SketchConstraintKind.Distance, line.End, line.Start ),
		};

		var already = ConstraintTools.ApplyAndSolve( sketch, duplicate );

		Report.Check( "a duplicate is refused", !already.Applied );

		Report.Check( "and says the sketch already has it",
			already.Message is not null && already.Message.Contains( "already" ), already.Message ?? "" );

		Report.Check( "without adding a second copy", sketch.Constraints.Count == 1 );

		// A successful apply reports the freedom left, which is what a UI shows next.
		var fresh = new Sketch();
		var only = fresh.AddLine( new Vec2( 0, 0 ), new Vec2( 3, 1 ) );

		var horizontal = ConstraintTools.Offers( fresh, new SketchSelection( null, new[] { only.Id } ) )
			.Single( o => o.Label == "Horizontal" );

		var applied = ConstraintTools.ApplyAndSolve( fresh, horizontal );

		Report.Check( "a successful apply carries the solve with it",
			applied.Applied && applied.Solve is not null );

		Report.Check( "with the degrees of freedom left to report",
			applied.Solve.DegreesOfFreedom > 0, $"{applied.Solve.DegreesOfFreedom}" );
	}

	/// <summary>
	/// Where each rule's glyph goes.
	///
	/// This is geometry, so it is testable, and it is worth testing because getting it slightly
	/// wrong produces a sketch covered in marks that are all in nearly the right place — which is
	/// the kind of wrong nobody reports as a bug and everybody works around.
	/// </summary>
	static void TestMarkers()
	{
		var sketch = new Sketch();
		var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );

		Apply( sketch, new SketchSelection( null, new[] { line.Id } ), "Horizontal" );

		var marks = ConstraintTools.Markers( sketch );

		Report.Check( "one rule on one line makes one mark", marks.Count == 1 );

		Report.Check( "at the middle of the line it holds",
			(marks[0].Anchor - new Vec2( 2, 0 )).Length < 1e-4f,
			$"({marks[0].Anchor.x:0.##},{marks[0].Anchor.y:0.##})" );

		Report.Check( "labelled for what it is", marks[0].Label == "H", marks[0].Label );

		Report.Check( "and it points off the line rather than along it",
			MathF.Abs( Vec2.Dot( marks[0].Away, new Vec2( 1, 0 ) ) ) < 1e-4f
			&& MathF.Abs( marks[0].Away.Length - 1f ) < 1e-4f,
			$"({marks[0].Away.x:0.##},{marks[0].Away.y:0.##})" );

		Report.Check( "and carries the rule itself, so clicking it can delete it",
			ReferenceEquals( marks[0].Constraint, sketch.Constraints[0] ) );

		Report.Check( "a plain rule is not a dimension", !marks[0].IsDimension );

		// A RULE ABOUT TWO SEGMENTS MARKS BOTH. One glyph between them would leave you guessing
		// which pair it meant on a sketch with six lines in it.
		var second = sketch.AddLine( new Vec2( 0, 3 ), new Vec2( 4, 3 ) );

		Apply( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ), "Equal length" );

		var equals = ConstraintTools.Markers( sketch ).Where( m => m.Label == "=" ).ToList();

		Report.Check( "equal length marks both lines", equals.Count == 2 );

		Report.Check( "one on each, at their middles",
			equals.Any( m => (m.Anchor - new Vec2( 2, 0 )).Length < 1e-4f )
			&& equals.Any( m => (m.Anchor - new Vec2( 2, 3 )).Length < 1e-4f ) );

		// A DIMENSION READS ITS VALUE.
		var dim = new Sketch();
		var bar = dim.AddLine( new Vec2( 0, 0 ), new Vec2( 7.5f, 0 ) );

		Apply( dim, new SketchSelection( null, new[] { bar.Id } ), "Length" );

		var length = ConstraintTools.Markers( dim ).Single();

		Report.Check( "a dimension is labelled with its number",
			length.Label == "7.500", length.Label );

		Report.Check( "and says it is a dimension", length.IsDimension );

		// AN ANGLE IS MARKED WHERE ITS LINES CROSS, which is where a person looks for it.
		var corner = new Sketch();
		var across = corner.AddLine( new Vec2( -4, 0 ), new Vec2( 4, 0 ) );
		var up = corner.AddLine( new Vec2( 0, -3 ), new Vec2( 0, 5 ) );

		Apply( corner, new SketchSelection( null, new[] { across.Id, up.Id } ), "Angle" );

		var angle = ConstraintTools.Markers( corner ).Single();

		Report.Check( "an angle sits at the crossing, not between the midpoints",
			angle.Anchor.Length < 1e-4f, $"({angle.Anchor.x:0.###},{angle.Anchor.y:0.###})" );

		Report.Check( "labelled in degrees", angle.Label.EndsWith( "\u00b0" ), angle.Label );

		// Two lines that never touch still have an angle, out where the extended lines would meet —
		// which is what the angle between them means.
		var apart = new Sketch();
		var flat = apart.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
		var slanted = apart.AddLine( new Vec2( 6, 2 ), new Vec2( 10, 6 ) );

		Apply( apart, new SketchSelection( null, new[] { flat.Id, slanted.Id } ), "Angle" );

		var extended = ConstraintTools.Markers( apart ).Single();

		Report.Check( "two lines that do not touch are marked where they would meet",
			(extended.Anchor - new Vec2( 4, 0 )).Length < 1e-3f,
			$"({extended.Anchor.x:0.###},{extended.Anchor.y:0.###})" );

		// Parallel lines have no crossing at all, and must not produce a NaN.
		var parallel = new Sketch();
		var one = parallel.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
		var two = parallel.AddLine( new Vec2( 0, 2 ), new Vec2( 4, 2 ) );

		parallel.Constraints.Add( new SketchConstraint( SketchConstraintKind.Angle,
			one.Start, one.End, two.Start, two.End ) { Value = 0f } );

		var noCrossing = ConstraintTools.Markers( parallel ).Single();

		Report.Check( "parallel lines fall back to a real position rather than a NaN",
			float.IsFinite( noCrossing.Anchor.x ) && float.IsFinite( noCrossing.Anchor.y ),
			$"({noCrossing.Anchor.x},{noCrossing.Anchor.y})" );

		// A RULE LEFT BEHIND BY A DELETED CURVE MARKS NOTHING, and must not throw. This is ordinary
		// rather than exceptional — deleting a curve leaves its rules pointing at points that went
		// with it, which is why the solver drops them too.
		var stale = new Sketch();
		stale.AddPoint( new Vec2( 0, 0 ) );
		stale.Constraints.Add( new SketchConstraint( SketchConstraintKind.Distance, 0, 99, 5f ) );

		Report.Check( "a rule pointing at a point that is gone marks nothing",
			ConstraintTools.Markers( stale ).Count == 0 );

		Report.Check( "an empty sketch marks nothing",
			ConstraintTools.Markers( new Sketch() ).Count == 0 );

		Report.Check( "and a null one does not throw",
			ConstraintTools.Markers( null ).Count == 0 );
	}

	// --- helpers ------------------------------------------------------------------------------

	/// <summary>Perpendicular distance from a point to the infinite line through two others — the
	/// relation "on the line" actually asserts, wherever the line has ended up.</summary>
	static float DistanceToLine( Sketch sketch, int point, int a, int b ) =>
		DistanceToLine( sketch.Points[point], sketch.Points[a], sketch.Points[b] );

	static float DistanceToLine( Vec2 p, Vec2 a, Vec2 b )
	{
		var along = b - a;

		if ( along.Length < 1e-9f )
			return (p - a).Length;

		return MathF.Abs( Vec2.Cross( along, p - a ) ) / along.Length;
	}

	static List<string> Labels( Sketch sketch, SketchSelection selection ) =>
		ConstraintTools.Offers( sketch, selection ).Select( o => o.Label ).OrderBy( s => s ).ToList();

	static void Apply( Sketch sketch, SketchSelection selection, string label )
	{
		var offer = ConstraintTools.Offers( sketch, selection ).SingleOrDefault( o => o.Label == label );

		if ( offer is null )
			throw new InvalidOperationException( $"no offer labelled '{label}' for that selection" );

		ConstraintTools.Apply( sketch, offer );
	}
}