package world import ( "github.com/stretchr/testify/require" "image/color" "testing" ) // TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits. func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) { t.Parallel() w := NewWorld(10, 10) // Build index once renderer state is initialized. params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) // Add overlapping objects near center. idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) require.NoError(t, err) idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) require.NoError(t, err) idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200)) require.NoError(t, err) // Force index rebuild from last state (Add already does it, but keep explicit). w.Reindex() buf := make([]Hit, 0, 8) hits, err := w.HitTest(buf, ¶ms, 50, 50) // center of viewport require.NoError(t, err) // Should find all three, best first (priority desc). require.Len(t, hits, 3) require.Equal(t, idCircle, hits[0].ID) require.Equal(t, idPoint, hits[1].ID) require.Equal(t, idLine, hits[2].ID) } // TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits. func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) _, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) _, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200)) w.Reindex() // Only room for 1 hit => must keep the best (highest priority). buf := make([]Hit, 0, 1) hits, err := w.HitTest(buf, ¶ms, 50, 50) require.NoError(t, err) require.Len(t, hits, 1) require.Equal(t, idCircle, hits[0].ID) } // TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits. func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 25, MarginYPx: 25, CameraXWorldFp: -100000, // invalid camera, should be clamped CameraYWorldFp: -100000, CameraZoom: 1.0, Options: &RenderOptions{DisableWrapScroll: true}, } w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) _, err := w.AddPoint(0.0, 0.0, PointWithPriority(100)) require.NoError(t, err) w.Reindex() // Tap near top-left of viewport should still map to world and find the point. buf := make([]Hit, 0, 8) hits, err := w.HitTest(buf, ¶ms, 0, 0) require.NoError(t, err) require.NotEmpty(t, hits) } // TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center. func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) // Stroke-only circle: FillColor alpha=0 => ring mode. ov := StyleOverride{ FillColor: color.RGBA{A: 0}, StrokeColor: color.RGBA{A: 255}, } strokeStyle := w.AddStyleCircle(ov) _, err := w.AddCircle(5.0, 5.0, 2.0, CircleWithStyleID(strokeStyle), CircleWithPriority(100), ) require.NoError(t, err) w.Reindex() buf := make([]Hit, 0, 8) // Center must NOT hit. hits, err := w.HitTest(buf, ¶ms, 50, 50) require.NoError(t, err) require.Empty(t, hits) // Near ring should hit. For small circles we use a minimum visible ring radius (3px). // So tapping at +3px from center should be within ring+slop. hits, err = w.HitTest(buf, ¶ms, 50+3, 50) require.NoError(t, err) require.NotEmpty(t, hits) require.Equal(t, KindCircle, hits[0].Kind) } // TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area. func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults w.IndexOnViewportChange(100, 100, 1.0) // raw radius=2 units, centered at (5,5) _, err := w.AddCircle(5, 5, 2) require.NoError(t, err) // scale=2 => eff radius=4 require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE)) w.Reindex() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } // Tap at +4 px from center should hit (eff radius 4). buf := make([]Hit, 0, 8) hits, err := w.HitTest(buf, ¶ms, 50+4, 50) require.NoError(t, err) require.NotEmpty(t, hits) require.Equal(t, KindCircle, hits[0].Kind) // Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options. // We'll add a small-slope circle and test deterministically. } // TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table. func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) { t.Parallel() type tc struct { name string fillVisible bool rawRadius int // world units (not fixed); zoom=1 => 1px per unit scaleFp int hitSlopPx int cursorDxPx int // offset from center in pixels along X axis wantHit bool wantKind PrimitiveKind } // Common settings: world 20x20, viewport 200x200, camera at center (10,10). params := RenderParams{ ViewportWidthPx: 200, ViewportHeightPx: 200, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 10 * SCALE, CameraYWorldFp: 10 * SCALE, CameraZoom: 1.0, } tests := []tc{ { name: "filled: on boundary hits (R=4, S=1, dx=4)", fillVisible: true, rawRadius: 2, scaleFp: 2 * SCALE, // eff radius = 4 hitSlopPx: 1, cursorDxPx: 4, wantHit: true, wantKind: KindCircle, }, { name: "filled: outside beyond slop misses (R=4, S=1, dx=6)", fillVisible: true, rawRadius: 2, scaleFp: 2 * SCALE, hitSlopPx: 1, cursorDxPx: 6, // 6 > R+S = 5 wantHit: false, }, { name: "filled: just inside slop hits (R=4, S=1, dx=5)", fillVisible: true, rawRadius: 2, scaleFp: 2 * SCALE, hitSlopPx: 1, cursorDxPx: 5, // == R+S wantHit: true, wantKind: KindCircle, }, { name: "stroke-only: center must miss even if slop would cover", fillVisible: false, rawRadius: 2, scaleFp: 2 * SCALE, // eff radius = 4 hitSlopPx: 10, // huge, would normally include center without our rule cursorDxPx: 0, wantHit: false, }, { name: "stroke-only: on ring hits (R=4, S=1, dx=4)", fillVisible: false, rawRadius: 2, scaleFp: 2 * SCALE, hitSlopPx: 1, cursorDxPx: 4, wantHit: true, wantKind: KindCircle, }, { name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)", fillVisible: false, rawRadius: 2, scaleFp: 2 * SCALE, hitSlopPx: 1, cursorDxPx: 2, // 2 < R-S = 3 wantHit: false, }, { name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)", fillVisible: false, rawRadius: 2, scaleFp: 2 * SCALE, hitSlopPx: 1, cursorDxPx: 6, // 6 > R+S = 5 wantHit: false, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() w := NewWorld(20, 20) w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp)) // Build a stroke-only circle style if needed. var opts []CircleOpt opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx)) if !tt.fillVisible { // Force fill alpha=0 => stroke-only for hit-test and rendering. sw := 1.0 styleID := w.AddStyleCircle(StyleOverride{ FillColor: color.RGBA{A: 0}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: &sw, }) opts = append(opts, CircleWithStyleID(styleID)) } _, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...) require.NoError(t, err) w.Reindex() // Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit. cx := params.ViewportWidthPx/2 + tt.cursorDxPx cy := params.ViewportHeightPx / 2 buf := make([]Hit, 0, 8) hits, err := w.HitTest(buf, ¶ms, cx, cy) require.NoError(t, err) if !tt.wantHit { require.Empty(t, hits) return } require.NotEmpty(t, hits) require.Equal(t, tt.wantKind, hits[0].Kind) }) } }