UnitTests/ShapeMathTests.cs
#nullable enable
using System;
using HitShapes;
using Sandbox;
using Xunit;
namespace HitShapes.Tests;
public class ShapeMathTests
{
[Fact]
public void Radial_top_position_resolves_to_slot_0()
{
var shape = HitShape.Radial(slots: 8);
// y=5 keeps the point inside the inscribed circle (radius=160).
var slot = shape.Resolve(new Vector2(160, 5), new Vector2(320, 320));
Assert.Equal(0, slot);
}
[Theory]
[InlineData(160, 5, 0)] // 12 o'clock (top)
[InlineData(315, 160, 2)] // 3 o'clock (right, inside inscribed circle)
[InlineData(160, 315, 4)] // 6 o'clock (bottom)
[InlineData(5, 160, 6)] // 9 o'clock (left)
public void Radial_8_slots_resolves_cardinals(float x, float y, int expectedSlot)
{
var shape = HitShape.Radial(slots: 8);
var slot = shape.Resolve(new Vector2(x, y), new Vector2(320, 320));
Assert.Equal(expectedSlot, slot);
}
[Fact]
public void Radial_with_inner_ratio_rejects_center()
{
var shape = HitShape.Radial(slots: 8, innerRatio: 0.4f);
var slot = shape.Resolve(new Vector2(160, 160), new Vector2(320, 320));
Assert.Null(slot);
}
[Fact]
public void Radial_rejects_corners_outside_inscribed_circle()
{
var shape = HitShape.Radial(slots: 8);
Assert.Null(shape.Resolve(new Vector2(0, 0), new Vector2(320, 320)));
Assert.Null(shape.Resolve(new Vector2(320, 0), new Vector2(320, 320)));
Assert.Null(shape.Resolve(new Vector2(0, 320), new Vector2(320, 320)));
Assert.Null(shape.Resolve(new Vector2(320, 320), new Vector2(320, 320)));
}
[Theory]
[InlineData(50, 50, 0)] // first cell (col=0, row=0)
[InlineData(150, 50, 1)] // col=1, row=0
[InlineData(50, 150, 4)] // col=0, row=1 -> 1*4+0 = 4
[InlineData(350, 250, 11)] // last cell (col=3, row=2) -> 2*4+3 = 11
public void RectGrid_4x3_resolves_cells(float x, float y, int expectedSlot)
{
var shape = HitShape.RectGrid(cols: 4, rows: 3);
var slot = shape.Resolve(new Vector2(x, y), new Vector2(400, 300));
Assert.Equal(expectedSlot, slot);
}
[Fact]
public void RectGrid_out_of_bounds_returns_null()
{
var shape = HitShape.RectGrid(cols: 4, rows: 3);
Assert.Null(shape.Resolve(new Vector2(-1, 50), new Vector2(400, 300)));
Assert.Null(shape.Resolve(new Vector2(401, 50), new Vector2(400, 300)));
Assert.Null(shape.Resolve(new Vector2(50, -1), new Vector2(400, 300)));
Assert.Null(shape.Resolve(new Vector2(50, 301), new Vector2(400, 300)));
}
[Fact]
public void RectGrid_boundary_clamps_to_last_cell()
{
var shape = HitShape.RectGrid(cols: 4, rows: 3);
var slot = shape.Resolve(new Vector2(399.99f, 50), new Vector2(400, 300));
Assert.Equal(3, slot);
}
[Fact]
public void RectGrid_slot_count_is_cols_times_rows()
{
var shape = HitShape.RectGrid(cols: 4, rows: 3);
Assert.Equal(12, shape.SlotCount);
}
[Fact]
public void CustomRaw_resolver_is_invoked_with_position_and_size()
{
Vector2 capturedPos = default;
Vector2 capturedSize = default;
var shape = HitShape.CustomRaw(slotCount: 3, (pos, size) =>
{
capturedPos = pos;
capturedSize = size;
return 1;
});
var slot = shape.Resolve(new Vector2(42, 17), new Vector2(200, 100));
Assert.Equal(1, slot);
Assert.Equal(new Vector2(42, 17), capturedPos);
Assert.Equal(new Vector2(200, 100), capturedSize);
}
[Fact]
public void CustomRaw_slot_count_is_the_provided_value()
{
var shape = HitShape.CustomRaw(slotCount: 7, (_, _) => null);
Assert.Equal(7, shape.SlotCount);
}
[Fact]
public void CustomRaw_null_resolver_throws()
{
Assert.Throws<System.ArgumentNullException>(
() => HitShape.CustomRaw(slotCount: 1, resolver: null!));
}
[Fact]
public void Custom_native_frame_passes_position_unchanged_at_matching_size()
{
Vector2 captured = default;
var shape = HitShape.Custom(slotCount: 1, nativeSize: new Vector2(400, 200),
pos => { captured = pos; return 0; });
shape.Resolve(new Vector2(100, 50), new Vector2(400, 200));
Assert.Equal(new Vector2(100, 50), captured);
}
[Fact]
public void Custom_native_frame_descales_when_panel_is_larger_than_native()
{
Vector2 captured = default;
var shape = HitShape.Custom(slotCount: 1, nativeSize: new Vector2(100, 50),
pos => { captured = pos; return 0; });
shape.Resolve(new Vector2(200, 150), new Vector2(200, 150));
Assert.Equal(new Vector2(100, 50), captured);
}
[Fact]
public void Custom_native_frame_descales_independently_on_each_axis()
{
Vector2 captured = default;
var shape = HitShape.Custom(slotCount: 1, nativeSize: new Vector2(100, 100),
pos => { captured = pos; return 0; });
shape.Resolve(new Vector2(150, 50), new Vector2(300, 200));
Assert.Equal(new Vector2(50, 25), captured);
}
[Fact]
public void Custom_native_frame_returns_null_for_degenerate_size()
{
var shape = HitShape.Custom(slotCount: 1, nativeSize: new Vector2(100, 100), _ => 0);
Assert.Null(shape.Resolve(new Vector2(50, 50), new Vector2(0, 100)));
Assert.Null(shape.Resolve(new Vector2(50, 50), new Vector2(100, 0)));
}
[Fact]
public void Custom_native_frame_slot_count_is_the_provided_value()
{
var shape = HitShape.Custom(slotCount: 9, nativeSize: new Vector2(100, 100), _ => null);
Assert.Equal(9, shape.SlotCount);
}
[Fact]
public void Custom_native_frame_null_resolver_throws()
{
Assert.Throws<System.ArgumentNullException>(
() => HitShape.Custom(slotCount: 1, nativeSize: new Vector2(100, 100),
resolver: (Func<Vector2, int?>)null!));
}
[Fact]
public void Custom_native_frame_throws_when_resolver_returns_out_of_range_slot()
{
var shape = HitShape.Custom(slotCount: 4, nativeSize: new Vector2(100, 100), _ => 9);
Assert.Throws<InvalidOperationException>(
() => shape.Resolve(new Vector2(50, 50), new Vector2(100, 100)));
}
[Fact]
public void Custom_native_frame_throws_when_resolver_returns_negative_slot()
{
var shape = HitShape.Custom(slotCount: 4, nativeSize: new Vector2(100, 100), _ => -1);
Assert.Throws<InvalidOperationException>(
() => shape.Resolve(new Vector2(50, 50), new Vector2(100, 100)));
}
[Fact]
public void CustomRaw_throws_when_resolver_returns_out_of_range_slot()
{
var shape = HitShape.CustomRaw(slotCount: 4, (_, _) => 9);
Assert.Throws<InvalidOperationException>(
() => shape.Resolve(new Vector2(50, 50), new Vector2(100, 100)));
}
[Fact]
public void CustomRaw_throws_when_resolver_returns_negative_slot()
{
var shape = HitShape.CustomRaw(slotCount: 4, (_, _) => -1);
Assert.Throws<InvalidOperationException>(
() => shape.Resolve(new Vector2(50, 50), new Vector2(100, 100)));
}
[Fact]
public void Polygon_triangle_interior_resolves_to_slot_0()
{
var shape = HitShape.Polygon(
new Vector2(0.5f, 0f),
new Vector2(1f, 1f),
new Vector2(0f, 1f));
var slot = shape.Resolve(new Vector2(200, 200), new Vector2(400, 400));
Assert.Equal(0, slot);
}
[Fact]
public void Polygon_above_triangle_returns_null()
{
var shape = HitShape.Polygon(
new Vector2(0.5f, 0f),
new Vector2(1f, 1f),
new Vector2(0f, 1f));
Assert.Null(shape.Resolve(new Vector2(20, 20), new Vector2(400, 400)));
Assert.Null(shape.Resolve(new Vector2(380, 20), new Vector2(400, 400)));
}
[Fact]
public void Polygon_concave_L_rejects_notch_accepts_arm()
{
// L-shape vertices in unit coords (clockwise from top-left):
// (0,0) -> (0.5,0) -> (0.5,0.5) -> (1,0.5) -> (1,1) -> (0,1)
var shape = HitShape.Polygon(
new Vector2(0f, 0f),
new Vector2(0.5f, 0f),
new Vector2(0.5f, 0.5f),
new Vector2(1f, 0.5f),
new Vector2(1f, 1f),
new Vector2(0f, 1f));
Assert.Equal(0, shape.Resolve(new Vector2(100, 200), new Vector2(400, 400)));
Assert.Equal(0, shape.Resolve(new Vector2(300, 300), new Vector2(400, 400)));
Assert.Null(shape.Resolve(new Vector2(300, 100), new Vector2(400, 400)));
}
[Fact]
public void Polygon_slot_count_is_one()
{
var shape = HitShape.Polygon(
new Vector2(0f, 0f),
new Vector2(1f, 0f),
new Vector2(0.5f, 1f));
Assert.Equal(1, shape.SlotCount);
}
[Fact]
public void Polygon_null_verts_throws()
{
Assert.Throws<System.ArgumentNullException>(
() => HitShape.Polygon(verts: null!));
}
[Fact]
public void Polygon_with_fewer_than_3_verts_throws()
{
Assert.Throws<System.ArgumentException>(
() => HitShape.Polygon(new Vector2(0f, 0f), new Vector2(1f, 1f)));
}
[Fact]
public void Polygons_non_overlapping_routes_to_correct_slot()
{
// Two triangles. Left triangle occupies x in [0, 0.4]. Right occupies x in [0.6, 1].
var left = new[]
{
new Vector2(0f, 0f), new Vector2(0.4f, 0f), new Vector2(0.4f, 1f), new Vector2(0f, 1f),
};
var right = new[]
{
new Vector2(0.6f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0.6f, 1f),
};
var shape = HitShape.Polygons(left, right);
Assert.Equal(0, shape.Resolve(new Vector2(80, 200), new Vector2(400, 400))); // x=0.2 -> left
Assert.Equal(1, shape.Resolve(new Vector2(320, 200), new Vector2(400, 400))); // x=0.8 -> right
Assert.Null(shape.Resolve(new Vector2(200, 200), new Vector2(400, 400))); // x=0.5 -> neither
}
[Fact]
public void Polygons_overlap_returns_first_match()
{
// Two squares overlapping in the middle.
var a = new[]
{
new Vector2(0f, 0f), new Vector2(0.6f, 0f), new Vector2(0.6f, 0.6f), new Vector2(0f, 0.6f),
};
var b = new[]
{
new Vector2(0.4f, 0.4f), new Vector2(1f, 0.4f), new Vector2(1f, 1f), new Vector2(0.4f, 1f),
};
var shape = HitShape.Polygons(a, b);
// Point in overlap region (0.5, 0.5):
Assert.Equal(0, shape.Resolve(new Vector2(200, 200), new Vector2(400, 400)));
}
[Fact]
public void Polygons_slot_count_matches_polygon_count()
{
var p1 = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 1f) };
var p2 = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 1f) };
var p3 = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 1f) };
Assert.Equal(3, HitShape.Polygons(p1, p2, p3).SlotCount);
}
[Fact]
public void Polygons_null_array_throws()
{
Assert.Throws<System.ArgumentNullException>(
() => HitShape.Polygons(polys: null!));
}
[Fact]
public void Polygons_null_sub_array_throws()
{
Vector2[] valid = { new(0f, 0f), new(1f, 0f), new(0.5f, 1f) };
Assert.Throws<System.ArgumentNullException>(
() => HitShape.Polygons(valid, null!));
}
[Fact]
public void Polygons_sub_array_with_fewer_than_3_verts_throws()
{
Vector2[] tooFew = { new(0f, 0f), new(1f, 0f) };
Assert.Throws<System.ArgumentException>(
() => HitShape.Polygons(tooFew));
}
[Fact]
public void Union_routes_to_A_when_only_A_resolves()
{
var grid = HitShape.RectGrid(2, 2); // SlotCount 4
var circle = HitShape.Radial(slots: 1, outerRatio: 0.2f); // SlotCount 1
var combo = HitShape.Union(grid, circle);
// Point near top-left corner: inside grid (slot 0), outside small circle.
var slot = combo.Resolve(new Vector2(20, 20), new Vector2(400, 400));
Assert.Equal(0, slot);
}
[Fact]
public void Union_offsets_B_slots_by_A_slot_count()
{
var aOnly = HitShape.Radial(slots: 1, outerRatio: 0.0f); // never resolves
var b = HitShape.RectGrid(2, 2); // SlotCount 4, slot 0..3
var combo = HitShape.Union(aOnly, b); // SlotCount 5
// aOnly never resolves; b at (20, 20) on 400x400 = top-left grid cell -> slot 0 in B.
// After offset by A.SlotCount (1), expect slot 1.
var slot = combo.Resolve(new Vector2(20, 20), new Vector2(400, 400));
Assert.Equal(1, slot);
}
[Fact]
public void Union_A_wins_on_overlap()
{
var a = HitShape.RectGrid(1, 1); // covers whole panel, slot 0
var b = HitShape.RectGrid(1, 1); // also covers whole panel
var combo = HitShape.Union(a, b); // SlotCount 2
var slot = combo.Resolve(new Vector2(50, 50), new Vector2(100, 100));
Assert.Equal(0, slot); // A wins
}
[Fact]
public void Union_slot_count_is_sum_of_inputs()
{
var combo = HitShape.Union(HitShape.Radial(8), HitShape.RectGrid(2, 2));
Assert.Equal(12, combo.SlotCount);
}
[Fact]
public void Union_null_args_throw()
{
var ok = HitShape.Radial(4);
Assert.Throws<System.ArgumentNullException>(() => HitShape.Union(null!, ok));
Assert.Throws<System.ArgumentNullException>(() => HitShape.Union(ok, null!));
}
[Fact]
public void Intersect_returns_null_when_A_alone_resolves()
{
var bigBox = HitShape.RectGrid(1, 1); // whole panel = slot 0
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f); // tiny center disc
var combo = HitShape.Intersect(bigBox, smallCircle);
// Corner: A resolves (0), B does not.
Assert.Null(combo.Resolve(new Vector2(10, 10), new Vector2(400, 400)));
}
[Fact]
public void Intersect_returns_A_slot_when_both_resolve()
{
var bigBox = HitShape.RectGrid(1, 1);
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f);
var combo = HitShape.Intersect(bigBox, smallCircle);
// Center: both A and B resolve. Expect A's slot (0).
Assert.Equal(0, combo.Resolve(new Vector2(200, 200), new Vector2(400, 400)));
}
[Fact]
public void Intersect_returns_null_when_B_alone_resolves()
{
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f);
var bigBox = HitShape.RectGrid(1, 1);
var combo = HitShape.Intersect(smallCircle, bigBox);
// Corner: A (small circle) does not resolve. Result should be null even though B does.
Assert.Null(combo.Resolve(new Vector2(10, 10), new Vector2(400, 400)));
}
[Fact]
public void Intersect_slot_count_is_A_slot_count()
{
var combo = HitShape.Intersect(HitShape.Radial(8), HitShape.RectGrid(2, 2));
Assert.Equal(8, combo.SlotCount);
}
[Fact]
public void Intersect_null_args_throw()
{
var ok = HitShape.Radial(4);
Assert.Throws<System.ArgumentNullException>(() => HitShape.Intersect(null!, ok));
Assert.Throws<System.ArgumentNullException>(() => HitShape.Intersect(ok, null!));
}
[Fact]
public void Difference_returns_A_slot_when_only_A_resolves()
{
var bigBox = HitShape.RectGrid(1, 1); // whole panel = slot 0
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f); // tiny center disc
var combo = HitShape.Difference(bigBox, smallCircle);
// Corner: A resolves (0), B does not -> A's slot.
Assert.Equal(0, combo.Resolve(new Vector2(10, 10), new Vector2(400, 400)));
}
[Fact]
public void Difference_returns_null_when_both_resolve()
{
var bigBox = HitShape.RectGrid(1, 1);
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f);
var combo = HitShape.Difference(bigBox, smallCircle);
// Center: both resolve. Difference punches the hole -> null.
Assert.Null(combo.Resolve(new Vector2(200, 200), new Vector2(400, 400)));
}
[Fact]
public void Difference_returns_null_when_only_B_resolves()
{
var smallCircle = HitShape.Radial(slots: 1, outerRatio: 0.2f);
var bigBox = HitShape.RectGrid(1, 1);
var combo = HitShape.Difference(smallCircle, bigBox);
// Corner: A (small circle) does not resolve.
Assert.Null(combo.Resolve(new Vector2(10, 10), new Vector2(400, 400)));
}
[Fact]
public void Difference_slot_count_is_A_slot_count()
{
var combo = HitShape.Difference(HitShape.Radial(8), HitShape.Radial(1, outerRatio: 0.3f));
Assert.Equal(8, combo.SlotCount);
}
[Fact]
public void Difference_null_args_throw()
{
var ok = HitShape.Radial(4);
Assert.Throws<System.ArgumentNullException>(() => HitShape.Difference(null!, ok));
Assert.Throws<System.ArgumentNullException>(() => HitShape.Difference(ok, null!));
}
}