|
1 | 1 | import { describe, it, expect } from "vitest"; |
2 | 2 | import { |
3 | 3 | computeSnap, |
| 4 | + computePointSnap, |
4 | 5 | computeResizeSnap, |
5 | 6 | deriveActiveEdges, |
6 | 7 | SNAP_THRESHOLD_PX, |
@@ -166,3 +167,64 @@ describe("computeResizeSnap", () => { |
166 | 167 | }); |
167 | 168 | }); |
168 | 169 | }); |
| 170 | + |
| 171 | +describe("computePointSnap", () => { |
| 172 | + it("snaps to an other's nearest edge in x and y independently", () => { |
| 173 | + // other rect at (100..200, 50..150). point near its left edge x=100 |
| 174 | + // and bottom edge y=150. |
| 175 | + const other = r("o", 100, 50, 100, 100); |
| 176 | + const result = computePointSnap({ x: 102, y: 148 }, [other], 6); |
| 177 | + expect(result.x).toBe(100); |
| 178 | + expect(result.y).toBe(150); |
| 179 | + expect(result.guides).toHaveLength(2); |
| 180 | + }); |
| 181 | + |
| 182 | + it("does NOT consider the other's centre as a snap target (regression: 50%-snap bug)", () => { |
| 183 | + // other rect at (100..200, 50..150). centre y = 100. point at y=98 |
| 184 | + // is 2 px from the centre but >= 6 px from any edge — must not snap. |
| 185 | + const other = r("o", 100, 50, 100, 100); |
| 186 | + const result = computePointSnap({ x: 500, y: 98 }, [other], 6); |
| 187 | + expect(result.y).toBe(98); |
| 188 | + // x is far from the other and the labelRect is omitted, so no snap. |
| 189 | + expect(result.x).toBe(500); |
| 190 | + expect(result.guides).toHaveLength(0); |
| 191 | + }); |
| 192 | + |
| 193 | + it("uses the label rect for edge alignment too", () => { |
| 194 | + const lbl = r("_lbl", 0, 0, 1000, 600); |
| 195 | + // y=300 sits exactly on the label's vertical centre — a valid |
| 196 | + // label-centre snap, so two guides fire (right edge + centre). |
| 197 | + const result = computePointSnap({ x: 998, y: 300 }, [], 6, lbl); |
| 198 | + expect(result.x).toBe(1000); // right edge of label |
| 199 | + expect(result.y).toBe(300); // label vertical centre |
| 200 | + expect(result.guides).toHaveLength(2); |
| 201 | + }); |
| 202 | + |
| 203 | + it("snaps to label centre (allowed for label only, not for neighbour objects)", () => { |
| 204 | + const lbl = r("_lbl", 0, 0, 1000, 600); |
| 205 | + // Point near horizontal centre of label (500, 300). |
| 206 | + const result = computePointSnap({ x: 502, y: 302 }, [], 6, lbl); |
| 207 | + expect(result.x).toBe(500); |
| 208 | + expect(result.y).toBe(300); |
| 209 | + expect(result.guides).toHaveLength(2); |
| 210 | + }); |
| 211 | + |
| 212 | + it("respects the threshold — far targets are ignored", () => { |
| 213 | + const other = r("o", 100, 50, 50, 50); |
| 214 | + const result = computePointSnap({ x: 300, y: 300 }, [other], 6); |
| 215 | + expect(result.x).toBe(300); |
| 216 | + expect(result.y).toBe(300); |
| 217 | + expect(result.guides).toHaveLength(0); |
| 218 | + }); |
| 219 | + |
| 220 | + it("picks the closest of two competing edges", () => { |
| 221 | + // Two other rects with nearby edges at y=98 and y=102. |
| 222 | + // point at y=100 → both at distance 2. The implementation prefers |
| 223 | + // the first-encountered tied target; pin this to the closer one |
| 224 | + // when it's strictly closer. |
| 225 | + const a = r("a", 0, 0, 50, 96); // y_end = 96 |
| 226 | + const b = r("b", 0, 102, 50, 50); // y_start = 102 |
| 227 | + const result = computePointSnap({ x: 500, y: 100.5 }, [a, b], 6); |
| 228 | + expect(result.y).toBe(102); // 102 - 100.5 = 1.5, closer than 100.5 - 96 = 4.5 |
| 229 | + }); |
| 230 | +}); |
0 commit comments