using SheetMe.Core.Layout; namespace SheetMe.Core.Tests; /// /// 스냅 계산 고정 — 손에 닿는 감각을 숫자로 못 박는다. /// /// 스냅은 "어쩐지 잘 붙는다"로는 지킬 수 없다. 후보가 하나 늘거나 열거 순서가 바뀌는 것만으로 /// 붙는 자리가 조용히 달라지고, 그건 드래그해 보기 전엔 아무도 모른다. /// 그래서 승자 규칙·가이드 개수·선분 범위·간격 균등을 전부 여기서 고정한다. /// [TestClass] public sealed class SnapSolverTests { #region Member Fields private static readonly SnapRect Page = new(0, 0, 720, 856); private static readonly SnapOptions Options = new(Tolerance: 6, GridSize: 4, EnableSpacing: true); private static readonly SnapOptions NoSpacing = Options with { EnableSpacing = false }; #endregion #region Methods private static SnapSolver Solver(params SnapRect[] siblings) { var solver = new SnapSolver(); solver.Prepare(new SnapContext { Pages = new[] { Page }, Siblings = siblings, SpacingPage = Page, }); return solver; } // ── 정렬 ─────────────────────────────────────────────────────────── [TestMethod] public void 형제_좌변에_붙는다() { // 형제 폭을 81 로 잡아 중심(140.5)이 이동체 우변(137)과 '같은 거리'가 되지 않게 한다 — // 80 이면 중심이 140 이라 우변도 정확히 +3 이 되어 가이드가 둘 나온다(그건 그것대로 옳다) var solver = Solver(new SnapRect(100, 50, 81, 20)); var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(100, 0 + result.Dx, 1e-9, "좌변이 형제 좌변에 맞아야 한다"); Assert.AreEqual(1, result.Guides.Count); var guide = result.Guides[0]; Assert.IsTrue(guide.IsVertical); Assert.AreEqual(100, guide.Position, 1e-9); Assert.AreEqual(SnapGuideKind.SiblingEdge, guide.Kind); // 선분은 형제와 이동체를 모두 덮는 만큼만 — 화면을 가로지르지 않는다 Assert.AreEqual(50, guide.Start, 1e-9); Assert.AreEqual(210, guide.End, 1e-9); } [TestMethod] public void 허용오차_경계는_포함이고_그_바깥은_격자로_떨어진다() { var solver = Solver(new SnapRect(100, 50, 80, 20)); var onEdge = solver.SolveMove(new SnapRect(0, 200, 40, 10), 94, 0, NoSpacing, free: false); Assert.IsTrue(onEdge.SnappedX, "거리 6은 붙는다"); var justOutside = solver.SolveMove(new SnapRect(0, 200, 40, 10), 93.999, 0, NoSpacing, free: false); Assert.IsFalse(justOutside.SnappedX, "거리 6.001 은 안 붙는다"); Assert.AreEqual(0, result4(justOutside.Dx) % 4, "정합 실패 축은 4px 격자"); Assert.AreEqual(0, justOutside.Guides.Count); static double result4(double dx) => Math.Round(dx, 6); } [TestMethod] public void 같은_좌표의_형제_둘은_가이드_하나로_합쳐지고_범위가_넓어진다() { var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(100, 300, 50, 20)); var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false); Assert.AreEqual(1, result.Guides.Count, "같은 자리에 선을 겹쳐 긋지 않는다"); Assert.AreEqual(0, result.Guides[0].Start, 1e-9); Assert.AreEqual(320, result.Guides[0].End, 1e-9); } [TestMethod] public void 서로_다른_자리에_동시에_맞으면_가이드가_둘이다() { // 이동체 폭 40. Left=100 에 형제 A 의 좌변, Right=140 에 형제 B 의 좌변 var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20)); var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(2, result.Guides.Count); CollectionAssert.AreEquivalent( new[] { 100.0, 140.0 }, result.Guides.Select(g => g.Position).ToArray()); } [TestMethod] public void 거리가_같아도_부호가_반대면_한쪽만_그린다() { // 이동 후 Left=100 기준: 형제 A 좌변 103(+3), 형제 B 좌변 97(−3). // 형제를 넓게 잡아 중심·우변이 사정권에 안 들어오게 한다(들어오면 더 가까운 매치가 승자가 된다) var solver = Solver(new SnapRect(103, 0, 200, 20), new SnapRect(97, 400, 200, 20)); var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 100, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(1, result.Guides.Count, "보정 후 실제로 맞는 것만 그린다 — 반대편에 선을 그으면 '붙었다고 그려 놓고 안 붙은' 선이 된다"); Assert.AreEqual(97, result.Guides[0].Position, 1e-9, "동률이면 좌표가 작은 쪽이 이긴다"); } [TestMethod] public void 중심_정합은_반정수를_보존한다() { var solver = Solver(new SnapRect(100, 0, 81, 20)); // CenterX = 140.5 var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 120, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(120.5, 0 + result.Dx, 1e-9, "반올림하면 중심이 0.5px 어긋난다"); Assert.AreEqual(SnapGuideKind.SiblingCenter, result.Guides[0].Kind); Assert.AreEqual(140.5, result.Guides[0].Position, 1e-9); } [TestMethod] public void 용지_가이드는_용지_전체로_긋는다() { var solver = Solver(); var result = solver.SolveMove(new SnapRect(3, 200, 40, 10), 0, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(SnapGuideKind.PageEdge, result.Guides[0].Kind); Assert.AreEqual(0, result.Guides[0].Start, 1e-9); Assert.AreEqual(856, result.Guides[0].End, 1e-9); } [TestMethod] public void 용지_중앙에_붙는다() { var solver = Solver(); // 폭 40 이 용지 중앙(360)에 오려면 Left=340 var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(340, 337 + result.Dx, 1e-9); Assert.AreEqual(SnapGuideKind.PageCenter, result.Guides[0].Kind); Assert.AreEqual(360, result.Guides[0].Position, 1e-9); } [TestMethod] public void 후보가_없으면_격자로_떨어지고_가이드는_없다() { var solver = Solver(new SnapRect(500, 500, 20, 20)); var result = solver.SolveMove(new SnapRect(101, 200, 40, 10), 0, 0, NoSpacing, free: false); Assert.IsFalse(result.SnappedX); Assert.AreEqual(100, 101 + result.Dx, 1e-9); Assert.AreEqual(0, result.Guides.Count); } [TestMethod] public void 형제_모서리가_용지_중앙보다_이긴다() { // 이동체 Left=337 → 형제 좌변 340 이 +3, 중심 357 → 용지 중앙 360 도 +3. // 둘 다 실제로 맞으므로 선은 둘 다 그린다 — 갈리는 것은 '어느 쪽으로 보정하는가'다. var solver = Solver(new SnapRect(340, 0, 200, 20)); var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false); Assert.AreEqual(340, 337 + result.Dx, 1e-9, "거리가 같으면 형제 모서리가 용지 중앙보다 이긴다"); Assert.AreEqual(2, result.Guides.Count); Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.SiblingEdge)); Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.PageCenter)); } [TestMethod] public void 한_축만_붙어도_다른_축은_격자로_간다() { var solver = Solver(new SnapRect(100, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(97, 201, 40, 10), 0, 0, NoSpacing, free: false); Assert.IsTrue(result.SnappedX); Assert.IsFalse(result.SnappedY); Assert.AreEqual(200, 201 + result.Dy, 1e-9); } [TestMethod] public void Alt_는_격자까지_전부_끈다() { var solver = Solver(new SnapRect(100, 50, 80, 20)); var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97.3, 11.7, Options, free: true); Assert.AreEqual(97.3, result.Dx, 1e-9); Assert.AreEqual(11.7, result.Dy, 1e-9); Assert.IsFalse(result.SnappedX); Assert.AreEqual(0, result.Guides.Count); Assert.AreEqual(0, result.Spacings.Count); } [TestMethod] public void 같은_입력을_두_번_풀어도_결과가_같다() { var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20)); var first = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false); var second = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false); Assert.AreEqual(first.Dx, second.Dx, 1e-9); Assert.AreEqual(first.Guides.Count, second.Guides.Count, "스크래치 버퍼가 새면 두 번째가 불어난다"); Assert.AreEqual(first.Spacings.Count, second.Spacings.Count); } [TestMethod] public void 페이지가_멀리_떨어져_있어도_정밀도가_유지된다() { var solver = new SnapSolver(); solver.Prepare(new SnapContext { Pages = new[] { new SnapRect(0, 10000, 720, 856) }, Siblings = new[] { new SnapRect(100, 10050, 80, 20) }, }); var result = solver.SolveMove(new SnapRect(0, 10200, 40, 10), 97, 0, NoSpacing, free: false); Assert.AreEqual(100, 0 + result.Dx, 1e-9); } // ── 간격 균등 ────────────────────────────────────────────────────── [TestMethod] public void 두_형제_사이에_끼면_남는_틈을_반씩_나눈다() { // 틈 [50,200] = 150, 이동 폭 40 → 양쪽 55 씩 → Left = 105 var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(200, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(103, 0, 40, 20), 0, 0, Options, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(105, 103 + result.Dx, 1e-9); Assert.AreEqual(2, result.Spacings.Count); Assert.AreEqual(55, result.Spacings[0].Gap, 1e-9); Assert.AreEqual(55, result.Spacings[1].Gap, 1e-9); } [TestMethod] public void 줄_뒤에_같은_간격으로_이어_붙는다() { // 간격 30 (50→80). 뒤에 붙으면 Left = 130 + 30 = 160 var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(160, 157 + result.Dx, 1e-9); Assert.AreEqual(30, result.Spacings[^1].Gap, 1e-9); } [TestMethod] public void 줄_앞에_같은_간격으로_이어_붙는다() { // 간격 30 (150→180). 앞에 붙으면 Left = 100 − 30 − 40 = 30 var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(180, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(33, 0, 40, 20), 0, 0, Options, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(30, 33 + result.Dx, 1e-9); } [TestMethod] public void 교차축이_안_겹치면_같은_줄이_아니다() { var solver = Solver(new SnapRect(0, 500, 50, 20), new SnapRect(80, 500, 50, 20)); var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false); Assert.AreEqual(0, result.Spacings.Count); } [TestMethod] public void 형제가_하나면_간격_후보가_없다() { var solver = Solver(new SnapRect(0, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(300, 0, 40, 20), 0, 0, Options, free: false); Assert.AreEqual(0, result.Spacings.Count); } [TestMethod] public void 정렬이_되면_간격은_보지_않는다() { // 뒤에 붙는 자리(160)와 형제 좌변 정합(158)이 둘 다 사정권 var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20), new SnapRect(158, 300, 20, 20)); var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false); Assert.IsTrue(result.SnappedX); Assert.AreEqual(158, 157 + result.Dx, 1e-9, "정렬이 이긴다"); Assert.AreEqual(0, result.Spacings.Count); } [TestMethod] public void 용지_밖으로_나가는_간격_자리는_버린다() { // 앞에 붙는 자리가 음수가 되는 배치 var solver = Solver(new SnapRect(10, 0, 50, 20), new SnapRect(90, 0, 50, 20)); var result = solver.SolveMove(new SnapRect(-58, 0, 40, 20), 0, 0, Options, free: false); Assert.AreEqual(0, result.Spacings.Count); } [TestMethod] public void 겹치는_형제가_너무_많으면_간격을_포기하고_정렬만_한다() { var many = new SnapRect[70]; for (var i = 0; i < many.Length; i++) { many[i] = new SnapRect(i * 60, 0, 50, 20); } var solver = Solver(many); var result = solver.SolveMove(new SnapRect(58, 0, 40, 20), 0, 0, Options, free: false); Assert.AreEqual(0, result.Spacings.Count, "성능 가드"); Assert.IsTrue(result.SnappedX, "정렬 스냅은 그대로 돈다"); } // ── 리사이즈 · 줌 ────────────────────────────────────────────────── [TestMethod] public void 모서리_스냅은_첫_매치가_아니라_최근접이다() { var solver = Solver(new SnapRect(100, 0, 0, 20), new SnapRect(104, 0, 0, 20)); var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: false); Assert.IsTrue(result.Snapped); Assert.AreEqual(104, result.Value, 1e-9, "이동 스냅과 같은 규칙이어야 한다"); } [TestMethod] public void 모서리_후보가_없으면_격자로_떨어진다() { var solver = Solver(new SnapRect(500, 0, 10, 20)); var result = solver.SolveEdge(102, isXAxis: true, Options, free: false); Assert.IsFalse(result.Snapped); Assert.AreEqual(104, result.Value, 1e-9); } [TestMethod] public void 모서리_스냅도_Alt_로_꺼진다() { var solver = Solver(new SnapRect(100, 0, 0, 20)); var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: true); Assert.IsFalse(result.Snapped); Assert.AreEqual(102.5, result.Value, 1e-9); } [TestMethod] public void 허용오차는_화면_기준이라_줌에_반비례한다() { Assert.AreEqual(3, SnapOptions.ForZoom(6, 4, 2.0, true).Tolerance, 1e-9); Assert.AreEqual(12, SnapOptions.ForZoom(6, 4, 0.5, true).Tolerance, 1e-9); Assert.AreEqual(6, SnapOptions.ForZoom(6, 4, 1.0, true).Tolerance, 1e-9); // 범위 밖 줌이 들어와도 오차가 폭주하지 않는다 Assert.AreEqual(24, SnapOptions.ForZoom(6, 4, 0.01, true).Tolerance, 1e-9); Assert.AreEqual(2, SnapOptions.ForZoom(6, 4, 99, true).Tolerance, 1e-9); } [TestMethod] public void 같은_거리라도_줌에_따라_붙고_안_붙는다() { var solver = Solver(new SnapRect(104, 0, 50, 20)); var bbox = new SnapRect(100, 200, 40, 10); var zoomedIn = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 2.0, false), free: false); Assert.IsFalse(zoomedIn.SnappedX, "줌 200% 에서 허용오차는 3 — 거리 4는 안 붙는다"); var zoomedOut = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 0.5, false), free: false); Assert.IsTrue(zoomedOut.SnappedX, "줌 50% 에서 허용오차는 12 — 붙는다"); } #endregion }