From 309ebde9859a45aa82c9210eab88b636af06f342 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 25 Feb 2026 10:17:16 -0500 Subject: [PATCH] feat: gesture/multi-touch progress --- FlexLove.lua | 252 +++++++++ modules/Element.lua | 59 ++ modules/EventHandler.lua | 82 ++- modules/GestureRecognizer.lua | 26 +- modules/types.lua | 6 + testing/__tests__/element_touch_test.lua | 328 +++++++++++ testing/__tests__/gesture_recognizer_test.lua | 533 ++++++++++++++++++ testing/__tests__/touch_events_test.lua | 1 + testing/__tests__/touch_routing_test.lua | 528 +++++++++++++++++ testing/__tests__/touch_scroll_test.lua | 475 ++++++++++++++++ testing/runAll.lua | 4 + 11 files changed, 2283 insertions(+), 11 deletions(-) create mode 100644 testing/__tests__/element_touch_test.lua create mode 100644 testing/__tests__/gesture_recognizer_test.lua create mode 100644 testing/__tests__/touch_routing_test.lua create mode 100644 testing/__tests__/touch_scroll_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index 0be64b1..d634fce 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -112,6 +112,14 @@ flexlove._deferredCallbacks = {} -- Track accumulated delta time for immediate mode updates flexlove._accumulatedDt = 0 +-- Touch ownership tracking: maps touch ID (string) to the element that owns it +---@type table +flexlove._touchOwners = {} + +-- Shared GestureRecognizer instance for touch routing (initialized in init()) +---@type GestureRecognizer|nil +flexlove._gestureRecognizer = nil + --- Check if FlexLove initialization is complete and ready to create elements --- Use this before creating elements to avoid automatic queueing ---@return boolean ready True if FlexLove is initialized and ready to use @@ -207,6 +215,11 @@ function flexlove.init(config) LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, FFI = flexlove._FFI }) EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils }) + -- Initialize shared GestureRecognizer for touch routing + if GestureRecognizer then + flexlove._gestureRecognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils }) + end + flexlove._defaultDependencies = { Context = Context, Theme = Theme, @@ -1107,6 +1120,239 @@ function flexlove.wheelmoved(dx, dy) end end +--- Find the touch-interactive element at a given position using z-index ordering +--- Similar to getElementAtPosition but checks for touch-enabled elements +---@param x number Touch X position +---@param y number Touch Y position +---@return Element|nil element The topmost touch-enabled element at position +function flexlove._getTouchElementAtPosition(x, y) + local candidates = {} + + local function collectTouchHits(element, scrollOffsetX, scrollOffsetY) + scrollOffsetX = scrollOffsetX or 0 + scrollOffsetY = scrollOffsetY or 0 + + local bx = element.x + local by = element.y + local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) + local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + + -- Adjust touch position by accumulated scroll offset for hit testing + local adjustedX = x + scrollOffsetX + local adjustedY = y + scrollOffsetY + + if adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh then + -- Check if element is touch-enabled and interactive + if element.touchEnabled and not element.disabled and (element.onEvent or element.onTouchEvent or element.onGesture) then + table.insert(candidates, element) + end + + -- Check if this element has scrollable overflow (for touch scrolling) + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" + ) + + -- Accumulate scroll offset for children + local childScrollOffsetX = scrollOffsetX + local childScrollOffsetY = scrollOffsetY + if hasScrollableOverflow then + childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) + childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) + end + + for _, child in ipairs(element.children) do + collectTouchHits(child, childScrollOffsetX, childScrollOffsetY) + end + end + end + + for _, element in ipairs(flexlove.topElements) do + collectTouchHits(element) + end + + -- Sort by z-index (highest first) — topmost element wins + table.sort(candidates, function(a, b) + return a.z > b.z + end) + + return candidates[1] +end + +--- Handle touch press events from LÖVE's touch input system +--- Routes touch to the topmost element at the touch position and assigns touch ownership +--- Hook this to love.touchpressed() to enable touch interaction +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved (usually 0 on press) +---@param dy number Y distance moved (usually 0 on press) +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchpressed(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Find the topmost touch-enabled element at this position + local element = flexlove._getTouchElementAtPosition(touchX, touchY) + + if element then + -- Assign touch ownership: this element receives all subsequent events for this touch + flexlove._touchOwners[touchId] = element + + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "began", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchPress(touchX, touchY) + end + end + end +end + +--- Handle touch move events from LÖVE's touch input system +--- Routes touch to the element that owns this touch ID (from the original press), regardless of current position +--- Hook this to love.touchmoved() to enable touch drag and gesture tracking +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved since last event +---@param dy number Y distance moved since last event +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchmoved(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Route to owning element (touch ownership persists from press to release) + local element = flexlove._touchOwners[touchId] + if element then + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "moved", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchMove(touchX, touchY) + end + end + end +end + +--- Handle touch release events from LÖVE's touch input system +--- Routes touch to the owning element and cleans up touch ownership tracking +--- Hook this to love.touchreleased() to properly end touch interactions +---@param id lightuserdata Touch identifier from LÖVE +---@param x number Touch X position in screen coordinates +---@param y number Touch Y position in screen coordinates +---@param dx number X distance moved since last event +---@param dy number Y distance moved since last event +---@param pressure number Touch pressure (0-1, if supported by device) +function flexlove.touchreleased(id, x, y, dx, dy, pressure) + local touchId = tostring(id) + pressure = pressure or 1.0 + + -- Apply base scaling if configured + local touchX, touchY = x, y + if flexlove.baseScale then + touchX = x / flexlove.scaleFactors.x + touchY = y / flexlove.scaleFactors.y + end + + -- Route to owning element + local element = flexlove._touchOwners[touchId] + if element then + -- Create and route touch event + local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "ended", pressure) + element:handleTouchEvent(touchEvent) + + -- Feed to shared gesture recognizer + if flexlove._gestureRecognizer then + local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) + if gestures then + for _, gesture in ipairs(gestures) do + element:handleGesture(gesture) + end + end + end + + -- Route to scroll manager for scrollable elements + if element._scrollManager then + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + element._scrollManager:handleTouchRelease() + end + end + end + + -- Clean up touch ownership (touch is complete) + flexlove._touchOwners[touchId] = nil +end + +--- Get the number of currently active touches being tracked +---@return number count Number of active touch points +function flexlove.getActiveTouchCount() + local count = 0 + for _ in pairs(flexlove._touchOwners) do + count = count + 1 + end + return count +end + +--- Get the element that currently owns a specific touch +---@param touchId string|lightuserdata Touch identifier +---@return Element|nil element The element owning this touch, or nil +function flexlove.getTouchOwner(touchId) + return flexlove._touchOwners[tostring(touchId)] +end + --- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down --- Use this to prevent memory leaks when transitioning between game states or menus function flexlove.destroy() @@ -1131,6 +1377,12 @@ function flexlove.destroy() flexlove._canvasDimensions = { width = 0, height = 0 } Context.clearFocus() StateManager:reset() + + -- Clean up touch state + flexlove._touchOwners = {} + if flexlove._gestureRecognizer then + flexlove._gestureRecognizer:reset() + end end --- Create a new UI element with flexbox layout, styling, and interaction capabilities diff --git a/modules/Element.lua b/modules/Element.lua index cc14047..629f005 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -160,6 +160,12 @@ ---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking ---@field _textDragOccurred boolean? -- Internal: whether text drag occurred ---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil) +---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events +---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false) +---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures +---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false) +---@field touchEnabled boolean -- Whether the element responds to touch events (default: true) +---@field multiTouchEnabled boolean -- Whether the element supports multiple simultaneous touches (default: false) ---@field animation table? -- Animation instance for this element local Element = {} Element.__index = Element @@ -366,6 +372,14 @@ function Element.new(props) self.customDraw = props.customDraw -- Custom rendering callback + -- Touch event properties + self.onTouchEvent = props.onTouchEvent + self.onTouchEventDeferred = props.onTouchEventDeferred or false + self.onGesture = props.onGesture + self.onGestureDeferred = props.onGestureDeferred or false + self.touchEnabled = props.touchEnabled ~= false -- Default true + self.multiTouchEnabled = props.multiTouchEnabled or false -- Default false + -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) self._stateId = self.id @@ -373,6 +387,12 @@ function Element.new(props) local eventHandlerConfig = { onEvent = self.onEvent, onEventDeferred = props.onEventDeferred, + onTouchEvent = self.onTouchEvent, + onTouchEventDeferred = self.onTouchEventDeferred, + onGesture = self.onGesture, + onGestureDeferred = self.onGestureDeferred, + touchEnabled = self.touchEnabled, + multiTouchEnabled = self.multiTouchEnabled, } if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then local state = Element._StateManager.getState(self._stateId) @@ -2603,6 +2623,10 @@ function Element:destroy() -- Clear onEvent to prevent closure leaks self.onEvent = nil + + -- Clear touch callbacks to prevent closure leaks + self.onTouchEvent = nil + self.onGesture = nil end --- Draw element and its children @@ -3082,6 +3106,39 @@ function Element:update(dt) end end +--- Handle a touch event directly (for external touch routing) +--- Invokes both onEvent and onTouchEvent callbacks if set +---@param touchEvent InputEvent The touch event to handle +function Element:handleTouchEvent(touchEvent) + if not self.touchEnabled or self.disabled then + return + end + if self._eventHandler then + self._eventHandler:_invokeCallback(self, touchEvent) + self._eventHandler:_invokeTouchCallback(self, touchEvent) + end +end + +--- Handle a gesture event (from GestureRecognizer or external routing) +---@param gesture table The gesture data (type, position, velocity, etc.) +function Element:handleGesture(gesture) + if not self.touchEnabled or self.disabled then + return + end + if self._eventHandler then + self._eventHandler:_invokeGestureCallback(self, gesture) + end +end + +--- Get active touches currently tracked on this element +---@return table Active touches keyed by touch ID +function Element:getTouches() + if self._eventHandler then + return self._eventHandler:getActiveTouches() + end + return {} +end + ---@param newViewportWidth number ---@param newViewportHeight number function Element:recalculateUnits(newViewportWidth, newViewportHeight) @@ -4066,6 +4123,8 @@ function Element:_cleanup() self.onEnter = nil self.onImageLoad = nil self.onImageError = nil + self.onTouchEvent = nil + self.onGesture = nil end return Element diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua index 242b3cb..5126e3d 100644 --- a/modules/EventHandler.lua +++ b/modules/EventHandler.lua @@ -1,6 +1,12 @@ ---@class EventHandler ---@field onEvent fun(element:Element, event:InputEvent)? ---@field onEventDeferred boolean? +---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback +---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred +---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback +---@field onGestureDeferred boolean? -- Whether onGesture is deferred +---@field touchEnabled boolean -- Whether touch events are processed (default: true) +---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false) ---@field _pressed table ---@field _lastClickTime number? ---@field _lastClickButton number? @@ -39,6 +45,12 @@ function EventHandler.new(config) self.onEvent = config.onEvent self.onEventDeferred = config.onEventDeferred + self.onTouchEvent = config.onTouchEvent + self.onTouchEventDeferred = config.onTouchEventDeferred or false + self.onGesture = config.onGesture + self.onGestureDeferred = config.onGestureDeferred or false + self.touchEnabled = config.touchEnabled ~= false -- Default true + self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false self._pressed = config._pressed or {} @@ -462,7 +474,7 @@ function EventHandler:processTouchEvents(element) local activeTouchIds = {} -- Check if element can process events - local canProcessEvents = (self.onEvent or element.editable) and not element.disabled + local canProcessEvents = (self.onEvent or self.onTouchEvent or element.editable) and not element.disabled and self.touchEnabled if not canProcessEvents then if EventHandler._Performance and EventHandler._Performance.enabled then @@ -483,6 +495,12 @@ function EventHandler:processTouchEvents(element) activeTouches[tostring(id)] = true end + -- Count active tracked touches for multi-touch filtering + local trackedTouchCount = 0 + for _ in pairs(self._touches) do + trackedTouchCount = trackedTouchCount + 1 + end + -- Process active touches for _, id in ipairs(touches) do local touchId = tostring(id) @@ -494,8 +512,15 @@ function EventHandler:processTouchEvents(element) if isInside then if not self._touches[touchId] then - -- New touch began - self:_handleTouchBegan(element, touchId, tx, ty, pressure) + -- Multi-touch filtering: reject new touches when multiTouchEnabled=false + -- and we already have an active touch + if not self.multiTouchEnabled and trackedTouchCount > 0 then + -- Skip this new touch (single-touch mode, already tracking one) + else + -- New touch began + self:_handleTouchBegan(element, touchId, tx, ty, pressure) + trackedTouchCount = trackedTouchCount + 1 + end else -- Touch moved self:_handleTouchMoved(element, touchId, tx, ty, pressure) @@ -561,6 +586,7 @@ function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure) touchEvent.dx = 0 touchEvent.dy = 0 self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) end --- Handle touch moved event @@ -607,6 +633,7 @@ function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure) touchEvent.dx = dx touchEvent.dy = dy self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) end end @@ -634,6 +661,7 @@ function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure) touchEvent.dx = dx touchEvent.dy = dy self:_invokeCallback(element, touchEvent) + self:_invokeTouchCallback(element, touchEvent) -- Cleanup touch state self:_cleanupTouch(touchId) @@ -709,4 +737,52 @@ function EventHandler:_invokeCallback(element, event) end end +--- Invoke the onTouchEvent callback, optionally deferring it +---@param element Element The element that triggered the event +---@param event InputEvent The touch event data +function EventHandler:_invokeTouchCallback(element, event) + if not self.onTouchEvent then + return + end + + if self.onTouchEventDeferred then + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + self.onTouchEvent(element, event) + end) + else + EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { + eventType = event.type, + }) + end + else + self.onTouchEvent(element, event) + end +end + +--- Invoke the onGesture callback, optionally deferring it +---@param element Element The element that triggered the event +---@param gesture table The gesture data from GestureRecognizer +function EventHandler:_invokeGestureCallback(element, gesture) + if not self.onGesture then + return + end + + if self.onGestureDeferred then + local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] + if FlexLove and FlexLove.deferCallback then + FlexLove.deferCallback(function() + self.onGesture(element, gesture) + end) + else + EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { + gestureType = gesture.type, + }) + end + else + self.onGesture(element, gesture) + end +end + return EventHandler diff --git a/modules/GestureRecognizer.lua b/modules/GestureRecognizer.lua index 9e378f9..8e50627 100644 --- a/modules/GestureRecognizer.lua +++ b/modules/GestureRecognizer.lua @@ -92,10 +92,11 @@ end ---@param event InputEvent Touch event function GestureRecognizer:processTouchEvent(event) if not event.touchId then - return + return nil end local touchId = event.touchId + local gestures = {} -- Update touch state if event.type == "touchpress" then @@ -122,13 +123,17 @@ function GestureRecognizer:processTouchEvent(event) touch.phase = "moved" -- Update gesture detection - self:_detectPan(touchId, event) - self:_detectSwipe(touchId, event) + local panGesture = self:_detectPan(touchId, event) + if panGesture then table.insert(gestures, panGesture) end + local swipeGesture = self:_detectSwipe(touchId, event) + if swipeGesture then table.insert(gestures, swipeGesture) end -- Multi-touch gestures if self:_getTouchCount() >= 2 then - self:_detectPinch(event) - self:_detectRotate(event) + local pinchGesture = self:_detectPinch(event) + if pinchGesture then table.insert(gestures, pinchGesture) end + local rotateGesture = self:_detectRotate(event) + if rotateGesture then table.insert(gestures, rotateGesture) end end end @@ -138,9 +143,12 @@ function GestureRecognizer:processTouchEvent(event) touch.phase = "ended" -- Finalize gesture detection - self:_detectTapEnded(touchId, event) - self:_detectSwipeEnded(touchId, event) - self:_detectPanEnded(touchId, event) + local tapGesture = self:_detectTapEnded(touchId, event) + if tapGesture then table.insert(gestures, tapGesture) end + local swipeGesture = self:_detectSwipeEnded(touchId, event) + if swipeGesture then table.insert(gestures, swipeGesture) end + local panGesture = self:_detectPanEnded(touchId, event) + if panGesture then table.insert(gestures, panGesture) end -- Cleanup touch self._touches[touchId] = nil @@ -151,6 +159,8 @@ function GestureRecognizer:processTouchEvent(event) self._touches[touchId] = nil self:_cancelAllGestures() end + + return #gestures > 0 and gestures or nil end --- Get number of active touches diff --git a/modules/types.lua b/modules/types.lua index a4afb77..9c244ac 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -85,6 +85,12 @@ local AnimationProps = {} ---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false) ---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed ---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false) +---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease) +---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false) +---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.) +---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false) +---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true) +---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false) ---@field transform TransformProps? -- Transform properties for animations and styling ---@field transition TransitionProps? -- Transition settings for animations ---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil) diff --git a/testing/__tests__/element_touch_test.lua b/testing/__tests__/element_touch_test.lua new file mode 100644 index 0000000..1ee896b --- /dev/null +++ b/testing/__tests__/element_touch_test.lua @@ -0,0 +1,328 @@ +package.path = package.path .. ";./?.lua;./modules/?.lua" +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() + return require("modules." .. moduleName) + end + end +end) +require("testing.loveStub") +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") + +FlexLove.init() + +TestElementTouch = {} + +function TestElementTouch:setUp() + FlexLove.setMode("immediate") + love.window.setMode(800, 600) +end + +function TestElementTouch:tearDown() + FlexLove.destroy() +end + +-- ============================================ +-- Touch Property Tests +-- ============================================ + +function TestElementTouch:test_touchEnabled_defaults_true() + FlexLove.beginFrame() + local element = FlexLove.new({ width = 100, height = 100 }) + FlexLove.endFrame() + + luaunit.assertTrue(element.touchEnabled) +end + +function TestElementTouch:test_touchEnabled_can_be_set_false() + FlexLove.beginFrame() + local element = FlexLove.new({ width = 100, height = 100, touchEnabled = false }) + FlexLove.endFrame() + + luaunit.assertFalse(element.touchEnabled) +end + +function TestElementTouch:test_multiTouchEnabled_defaults_false() + FlexLove.beginFrame() + local element = FlexLove.new({ width = 100, height = 100 }) + FlexLove.endFrame() + + luaunit.assertFalse(element.multiTouchEnabled) +end + +function TestElementTouch:test_multiTouchEnabled_can_be_set_true() + FlexLove.beginFrame() + local element = FlexLove.new({ width = 100, height = 100, multiTouchEnabled = true }) + FlexLove.endFrame() + + luaunit.assertTrue(element.multiTouchEnabled) +end + +-- ============================================ +-- Touch Callback Tests +-- ============================================ + +function TestElementTouch:test_onTouchEvent_callback() + FlexLove.beginFrame() + local receivedEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(receivedEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("t1", 100, 100, 0, 0, 1.0) + + luaunit.assertTrue(#receivedEvents >= 1) + luaunit.assertEquals(receivedEvents[1].type, "touchpress") +end + +function TestElementTouch:test_onGesture_callback() + FlexLove.beginFrame() + local receivedGestures = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function() end, + onGesture = function(el, gesture) + table.insert(receivedGestures, gesture) + end, + }) + FlexLove.endFrame() + + -- Quick tap + FlexLove.touchpressed("t1", 100, 100, 0, 0, 1.0) + love.timer.step(0.05) + FlexLove.touchreleased("t1", 100, 100, 0, 0, 1.0) + + local tapGestures = {} + for _, g in ipairs(receivedGestures) do + if g.type == "tap" then + table.insert(tapGestures, g) + end + end + luaunit.assertTrue(#tapGestures >= 1, "Should receive tap gesture callback") +end + +function TestElementTouch:test_onEvent_also_receives_touch() + FlexLove.beginFrame() + local receivedEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onEvent = function(el, event) + table.insert(receivedEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("t1", 100, 100, 0, 0, 1.0) + + local touchEvents = {} + for _, e in ipairs(receivedEvents) do + if e.type == "touchpress" then + table.insert(touchEvents, e) + end + end + luaunit.assertTrue(#touchEvents >= 1, "onEvent should receive touch events") +end + +-- ============================================ +-- handleTouchEvent direct tests +-- ============================================ + +function TestElementTouch:test_handleTouchEvent_disabled_element() + FlexLove.beginFrame() + local receivedEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + disabled = true, + onTouchEvent = function(el, event) + table.insert(receivedEvents, event) + end, + }) + FlexLove.endFrame() + + local InputEvent = package.loaded["modules.InputEvent"] + local touchEvent = InputEvent.fromTouch("t1", 100, 100, "began", 1.0) + element:handleTouchEvent(touchEvent) + + luaunit.assertEquals(#receivedEvents, 0, "Disabled element should not receive touch events") +end + +function TestElementTouch:test_handleTouchEvent_touchEnabled_false() + FlexLove.beginFrame() + local receivedEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + touchEnabled = false, + onTouchEvent = function(el, event) + table.insert(receivedEvents, event) + end, + }) + FlexLove.endFrame() + + local InputEvent = package.loaded["modules.InputEvent"] + local touchEvent = InputEvent.fromTouch("t1", 100, 100, "began", 1.0) + element:handleTouchEvent(touchEvent) + + luaunit.assertEquals(#receivedEvents, 0, "touchEnabled=false should prevent events") +end + +-- ============================================ +-- handleGesture direct tests +-- ============================================ + +function TestElementTouch:test_handleGesture_fires_callback() + FlexLove.beginFrame() + local receivedGestures = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onGesture = function(el, gesture) + table.insert(receivedGestures, gesture) + end, + }) + FlexLove.endFrame() + + element:handleGesture({ type = "tap", state = "ended", x = 100, y = 100 }) + + luaunit.assertEquals(#receivedGestures, 1) + luaunit.assertEquals(receivedGestures[1].type, "tap") +end + +function TestElementTouch:test_handleGesture_disabled_element() + FlexLove.beginFrame() + local receivedGestures = {} + local element = FlexLove.new({ + width = 200, + height = 200, + disabled = true, + onGesture = function(el, gesture) + table.insert(receivedGestures, gesture) + end, + }) + FlexLove.endFrame() + + element:handleGesture({ type = "tap", state = "ended", x = 100, y = 100 }) + + luaunit.assertEquals(#receivedGestures, 0, "Disabled element should not receive gestures") +end + +function TestElementTouch:test_handleGesture_touchEnabled_false() + FlexLove.beginFrame() + local receivedGestures = {} + local element = FlexLove.new({ + width = 200, + height = 200, + touchEnabled = false, + onGesture = function(el, gesture) + table.insert(receivedGestures, gesture) + end, + }) + FlexLove.endFrame() + + element:handleGesture({ type = "tap", state = "ended", x = 100, y = 100 }) + + luaunit.assertEquals(#receivedGestures, 0, "touchEnabled=false should prevent gestures") +end + +-- ============================================ +-- getTouches tests +-- ============================================ + +function TestElementTouch:test_getTouches_returns_table() + FlexLove.beginFrame() + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function() end, + }) + FlexLove.endFrame() + + local touches = element:getTouches() + luaunit.assertEquals(type(touches), "table") +end + +-- ============================================ +-- Touch + Gesture combined lifecycle +-- ============================================ + +function TestElementTouch:test_touch_pan_lifecycle() + FlexLove.beginFrame() + local touchEvents = {} + local gestureEvents = {} + local element = FlexLove.new({ + width = 400, + height = 400, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + onGesture = function(el, gesture) + table.insert(gestureEvents, gesture) + end, + }) + FlexLove.endFrame() + + -- Simulate a pan gesture: press, move significantly, release + FlexLove.touchpressed("t1", 100, 100, 0, 0, 1.0) + love.timer.step(0.05) + FlexLove.touchmoved("t1", 150, 150, 50, 50, 1.0) + love.timer.step(0.05) + FlexLove.touchmoved("t1", 200, 200, 50, 50, 1.0) + love.timer.step(0.05) + FlexLove.touchreleased("t1", 200, 200, 0, 0, 1.0) + + -- Should have received touch events + luaunit.assertTrue(#touchEvents >= 3, "Should receive press + move + release touch events") + + -- Should have received pan gestures from GestureRecognizer + local panGestures = {} + for _, g in ipairs(gestureEvents) do + if g.type == "pan" then + table.insert(panGestures, g) + end + end + luaunit.assertTrue(#panGestures >= 1, "Should receive pan gesture events") +end + +-- ============================================ +-- Deferred callbacks +-- ============================================ + +function TestElementTouch:test_onTouchEventDeferred_prop_accepted() + FlexLove.beginFrame() + -- Just test that the prop is accepted without error + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEventDeferred = function() end, + }) + FlexLove.endFrame() + + luaunit.assertNotNil(element, "Element with onTouchEventDeferred should be created") +end + +function TestElementTouch:test_onGestureDeferred_prop_accepted() + FlexLove.beginFrame() + local element = FlexLove.new({ + width = 200, + height = 200, + onGestureDeferred = function() end, + }) + FlexLove.endFrame() + + luaunit.assertNotNil(element, "Element with onGestureDeferred should be created") +end + +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/gesture_recognizer_test.lua b/testing/__tests__/gesture_recognizer_test.lua new file mode 100644 index 0000000..dd1fbe2 --- /dev/null +++ b/testing/__tests__/gesture_recognizer_test.lua @@ -0,0 +1,533 @@ +package.path = package.path .. ";./?.lua;./modules/?.lua" +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() + return require("modules." .. moduleName) + end + end +end) +require("testing.loveStub") +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") + +FlexLove.init() + +local InputEvent = package.loaded["modules.InputEvent"] +local GestureRecognizer = package.loaded["modules.GestureRecognizer"] + +TestGestureRecognizer = {} + +function TestGestureRecognizer:setUp() + self.recognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = {} }) + love.timer.setTime(0) +end + +function TestGestureRecognizer:tearDown() + self.recognizer:reset() +end + +-- Helper: create touch event +local function touchEvent(id, x, y, phase, time) + if time then love.timer.setTime(time) end + local event = InputEvent.fromTouch(id, x, y, phase, 1.0) + return event +end + +-- ============================================ +-- Tap Gesture Tests +-- ============================================ + +function TestGestureRecognizer:test_tap_detected() + local event1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(event1) + + local event2 = touchEvent("t1", 100, 100, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(event2) + + luaunit.assertNotNil(gestures, "Tap gesture should be detected") + luaunit.assertEquals(gestures[1].type, "tap") + luaunit.assertEquals(gestures[1].state, "ended") + luaunit.assertEquals(gestures[1].x, 100) + luaunit.assertEquals(gestures[1].y, 100) +end + +function TestGestureRecognizer:test_tap_not_detected_when_too_slow() + local event1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(event1) + + -- Release after tapMaxDuration (0.3s) + local event2 = touchEvent("t1", 100, 100, "ended", 0.5) + local gestures = self.recognizer:processTouchEvent(event2) + + -- Should not be a tap (too slow) + if gestures then + for _, g in ipairs(gestures) do + luaunit.assertNotEquals(g.type, "tap", "Slow touch should not be tap") + end + end +end + +function TestGestureRecognizer:test_tap_not_detected_when_moved_too_far() + local event1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(event1) + + -- Move more than tapMaxMovement (10px) + local event2 = touchEvent("t1", 120, 120, "moved", 0.05) + self.recognizer:processTouchEvent(event2) + + local event3 = touchEvent("t1", 120, 120, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(event3) + + -- Should not be a tap (moved too far) + if gestures then + for _, g in ipairs(gestures) do + if g.type == "tap" then + -- Tap detection checks distance from START to END position + local dx = 120 - 100 + local dy = 120 - 100 + local dist = math.sqrt(dx*dx + dy*dy) + luaunit.assertTrue(dist >= 10, "Movement should exceed tap threshold") + end + end + end +end + +function TestGestureRecognizer:test_double_tap_detected() + -- First tap + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + local e2 = touchEvent("t1", 100, 100, "ended", 0.05) + self.recognizer:processTouchEvent(e2) + + -- Second tap quickly + local e3 = touchEvent("t2", 100, 100, "began", 0.15) + self.recognizer:processTouchEvent(e3) + local e4 = touchEvent("t2", 100, 100, "ended", 0.2) + local gestures = self.recognizer:processTouchEvent(e4) + + luaunit.assertNotNil(gestures, "Should detect gesture on second tap") + -- Should have double_tap + local foundDoubleTap = false + for _, g in ipairs(gestures) do + if g.type == "double_tap" then + foundDoubleTap = true + end + end + luaunit.assertTrue(foundDoubleTap, "Should detect double-tap gesture") +end + +function TestGestureRecognizer:test_double_tap_not_detected_when_too_slow() + -- First tap + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + local e2 = touchEvent("t1", 100, 100, "ended", 0.05) + self.recognizer:processTouchEvent(e2) + + -- Second tap too late (>0.3s interval) + local e3 = touchEvent("t2", 100, 100, "began", 0.5) + self.recognizer:processTouchEvent(e3) + local e4 = touchEvent("t2", 100, 100, "ended", 0.55) + local gestures = self.recognizer:processTouchEvent(e4) + + -- Should detect tap but NOT double_tap + if gestures then + for _, g in ipairs(gestures) do + luaunit.assertNotEquals(g.type, "double_tap", "Too-slow second tap should not be double-tap") + end + end +end + +-- ============================================ +-- Pan Gesture Tests +-- ============================================ + +function TestGestureRecognizer:test_pan_began() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- Move beyond panMinMovement (5px) + local e2 = touchEvent("t1", 110, 110, "moved", 0.05) + local gestures = self.recognizer:processTouchEvent(e2) + + luaunit.assertNotNil(gestures, "Pan should be detected") + luaunit.assertEquals(gestures[1].type, "pan") + luaunit.assertEquals(gestures[1].state, "began") +end + +function TestGestureRecognizer:test_pan_changed() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- First move to start pan + local e2 = touchEvent("t1", 110, 110, "moved", 0.05) + self.recognizer:processTouchEvent(e2) + + -- Continue moving + local e3 = touchEvent("t1", 120, 120, "moved", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local panChanged = nil + for _, g in ipairs(gestures) do + if g.type == "pan" and g.state == "changed" then + panChanged = g + end + end + luaunit.assertNotNil(panChanged, "Should detect pan changed") + luaunit.assertEquals(panChanged.dx, 20) -- delta from startX=100 (lastX set to startX on began) + luaunit.assertEquals(panChanged.dy, 20) +end + +function TestGestureRecognizer:test_pan_ended() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 110, 110, "moved", 0.05) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 120, 120, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local panEnded = nil + for _, g in ipairs(gestures) do + if g.type == "pan" and g.state == "ended" then + panEnded = g + end + end + luaunit.assertNotNil(panEnded, "Should detect pan ended") +end + +function TestGestureRecognizer:test_pan_not_detected_with_small_movement() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- Move less than panMinMovement (5px) + local e2 = touchEvent("t1", 102, 102, "moved", 0.05) + local gestures = self.recognizer:processTouchEvent(e2) + + luaunit.assertNil(gestures, "Small movement should not trigger pan") +end + +-- ============================================ +-- Swipe Gesture Tests +-- ============================================ + +function TestGestureRecognizer:test_swipe_right() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- Fast swipe right (>50px in <0.2s with >200px/s velocity) + local e2 = touchEvent("t1", 200, 100, "moved", 0.1) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 200, 100, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local swipe = nil + for _, g in ipairs(gestures) do + if g.type == "swipe" then + swipe = g + end + end + luaunit.assertNotNil(swipe, "Should detect swipe") + luaunit.assertEquals(swipe.direction, "right") +end + +function TestGestureRecognizer:test_swipe_left() + local e1 = touchEvent("t1", 200, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 100, 100, "moved", 0.1) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 100, 100, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local swipe = nil + for _, g in ipairs(gestures) do + if g.type == "swipe" then + swipe = g + end + end + luaunit.assertNotNil(swipe, "Should detect left swipe") + luaunit.assertEquals(swipe.direction, "left") +end + +function TestGestureRecognizer:test_swipe_down() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 100, 200, "moved", 0.1) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 100, 200, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local swipe = nil + for _, g in ipairs(gestures) do + if g.type == "swipe" then + swipe = g + end + end + luaunit.assertNotNil(swipe, "Should detect down swipe") + luaunit.assertEquals(swipe.direction, "down") +end + +function TestGestureRecognizer:test_swipe_up() + local e1 = touchEvent("t1", 100, 200, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 100, 100, "moved", 0.1) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 100, 100, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + luaunit.assertNotNil(gestures) + local swipe = nil + for _, g in ipairs(gestures) do + if g.type == "swipe" then + swipe = g + end + end + luaunit.assertNotNil(swipe, "Should detect up swipe") + luaunit.assertEquals(swipe.direction, "up") +end + +function TestGestureRecognizer:test_swipe_not_detected_when_too_slow() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- Too slow (>0.2s) + local e2 = touchEvent("t1", 200, 100, "moved", 0.3) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 200, 100, "ended", 0.3) + local gestures = self.recognizer:processTouchEvent(e3) + + if gestures then + for _, g in ipairs(gestures) do + luaunit.assertNotEquals(g.type, "swipe", "Slow movement should not be a swipe") + end + end +end + +function TestGestureRecognizer:test_swipe_not_detected_when_too_short() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + -- Too short distance (<50px) + local e2 = touchEvent("t1", 130, 100, "moved", 0.05) + self.recognizer:processTouchEvent(e2) + + local e3 = touchEvent("t1", 130, 100, "ended", 0.05) + local gestures = self.recognizer:processTouchEvent(e3) + + if gestures then + for _, g in ipairs(gestures) do + luaunit.assertNotEquals(g.type, "swipe", "Short movement should not be a swipe") + end + end +end + +-- ============================================ +-- Pinch Gesture Tests +-- ============================================ + +function TestGestureRecognizer:test_pinch_detected() + -- Two fingers start 100px apart + local e1 = touchEvent("t1", 100, 200, "began", 0) + self.recognizer:processTouchEvent(e1) + local e2 = touchEvent("t2", 200, 200, "began", 0) + self.recognizer:processTouchEvent(e2) + + -- Move fingers apart to 200px (scale = 2.0) + local e3 = touchEvent("t1", 50, 200, "moved", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + local e4 = touchEvent("t2", 250, 200, "moved", 0.1) + gestures = self.recognizer:processTouchEvent(e4) + + luaunit.assertNotNil(gestures, "Pinch should be detected") + local pinch = nil + for _, g in ipairs(gestures) do + if g.type == "pinch" then + pinch = g + end + end + luaunit.assertNotNil(pinch, "Should detect pinch gesture") + luaunit.assertTrue(pinch.scale > 1.0, "Scale should be greater than 1.0 for spread") +end + +function TestGestureRecognizer:test_pinch_scale_decreases() + -- Two fingers start 200px apart + local e1 = touchEvent("t1", 50, 200, "began", 0) + self.recognizer:processTouchEvent(e1) + local e2 = touchEvent("t2", 250, 200, "began", 0) + self.recognizer:processTouchEvent(e2) + + -- Move fingers closer to 100px (scale = 0.5) + local e3 = touchEvent("t1", 100, 200, "moved", 0.1) + self.recognizer:processTouchEvent(e3) + local e4 = touchEvent("t2", 200, 200, "moved", 0.1) + local gestures = self.recognizer:processTouchEvent(e4) + + if gestures then + local pinch = nil + for _, g in ipairs(gestures) do + if g.type == "pinch" then + pinch = g + end + end + if pinch then + luaunit.assertTrue(pinch.scale < 1.0, "Scale should be less than 1.0 for pinch") + end + end +end + +function TestGestureRecognizer:test_pinch_not_detected_with_one_touch() + local e1 = touchEvent("t1", 100, 200, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 150, 200, "moved", 0.1) + local gestures = self.recognizer:processTouchEvent(e2) + + if gestures then + for _, g in ipairs(gestures) do + luaunit.assertNotEquals(g.type, "pinch", "Single touch should not trigger pinch") + end + end +end + +-- ============================================ +-- Rotate Gesture Tests +-- ============================================ + +function TestGestureRecognizer:test_rotate_detected() + -- Two fingers horizontally + local e1 = touchEvent("t1", 100, 200, "began", 0) + self.recognizer:processTouchEvent(e1) + local e2 = touchEvent("t2", 200, 200, "began", 0) + self.recognizer:processTouchEvent(e2) + + -- Rotate: move t2 above t1 (significant angle change > 5 degrees) + local e3 = touchEvent("t2", 200, 150, "moved", 0.1) + local gestures = self.recognizer:processTouchEvent(e3) + + if gestures then + local rotate = nil + for _, g in ipairs(gestures) do + if g.type == "rotate" then + rotate = g + end + end + if rotate then + luaunit.assertNotNil(rotate.rotation, "Rotate gesture should have rotation angle") + end + end +end + +-- ============================================ +-- processTouchEvent return value tests +-- ============================================ + +function TestGestureRecognizer:test_processTouchEvent_returns_nil_for_no_gesture() + local e1 = touchEvent("t1", 100, 100, "began", 0) + local gestures = self.recognizer:processTouchEvent(e1) + + -- Press alone should not produce gesture + luaunit.assertNil(gestures, "Press alone should not produce gesture") +end + +function TestGestureRecognizer:test_processTouchEvent_returns_gesture_array() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 100, 100, "ended", 0.1) + local gestures = self.recognizer:processTouchEvent(e2) + + luaunit.assertNotNil(gestures) + luaunit.assertTrue(#gestures >= 1, "Should return array with at least 1 gesture") + luaunit.assertEquals(type(gestures[1]), "table") + luaunit.assertNotNil(gestures[1].type) +end + +function TestGestureRecognizer:test_processTouchEvent_ignores_no_touchId() + local event = { type = "touchpress", x = 100, y = 100 } -- No touchId + local gestures = self.recognizer:processTouchEvent(event) + luaunit.assertNil(gestures) +end + +function TestGestureRecognizer:test_touchcancel_cleans_up() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + local e2 = touchEvent("t1", 100, 100, "cancelled", 0.1) + local gestures = self.recognizer:processTouchEvent(e2) + + -- After cancel, no touches should remain + luaunit.assertEquals(self.recognizer:_getTouchCount(), 0) +end + +-- ============================================ +-- Reset Tests +-- ============================================ + +function TestGestureRecognizer:test_reset_clears_state() + local e1 = touchEvent("t1", 100, 100, "began", 0) + self.recognizer:processTouchEvent(e1) + + luaunit.assertTrue(self.recognizer:_getTouchCount() > 0) + + self.recognizer:reset() + + luaunit.assertEquals(self.recognizer:_getTouchCount(), 0) +end + +-- ============================================ +-- Custom Configuration Tests +-- ============================================ + +function TestGestureRecognizer:test_custom_config_overrides_defaults() + local custom = GestureRecognizer.new({ + tapMaxDuration = 1.0, + panMinMovement = 20, + }, { InputEvent = InputEvent, utils = {} }) + + luaunit.assertEquals(custom._config.tapMaxDuration, 1.0) + luaunit.assertEquals(custom._config.panMinMovement, 20) + -- Defaults for non-overridden values + luaunit.assertEquals(custom._config.swipeMinDistance, 50) +end + +-- ============================================ +-- GestureType and GestureState exports +-- ============================================ + +function TestGestureRecognizer:test_gesture_types_exported() + luaunit.assertEquals(GestureRecognizer.GestureType.TAP, "tap") + luaunit.assertEquals(GestureRecognizer.GestureType.DOUBLE_TAP, "double_tap") + luaunit.assertEquals(GestureRecognizer.GestureType.LONG_PRESS, "long_press") + luaunit.assertEquals(GestureRecognizer.GestureType.SWIPE, "swipe") + luaunit.assertEquals(GestureRecognizer.GestureType.PAN, "pan") + luaunit.assertEquals(GestureRecognizer.GestureType.PINCH, "pinch") + luaunit.assertEquals(GestureRecognizer.GestureType.ROTATE, "rotate") +end + +function TestGestureRecognizer:test_gesture_states_exported() + luaunit.assertEquals(GestureRecognizer.GestureState.POSSIBLE, "possible") + luaunit.assertEquals(GestureRecognizer.GestureState.BEGAN, "began") + luaunit.assertEquals(GestureRecognizer.GestureState.CHANGED, "changed") + luaunit.assertEquals(GestureRecognizer.GestureState.ENDED, "ended") + luaunit.assertEquals(GestureRecognizer.GestureState.CANCELLED, "cancelled") + luaunit.assertEquals(GestureRecognizer.GestureState.FAILED, "failed") +end + +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/touch_events_test.lua b/testing/__tests__/touch_events_test.lua index cf04d63..6786f7d 100644 --- a/testing/__tests__/touch_events_test.lua +++ b/testing/__tests__/touch_events_test.lua @@ -230,6 +230,7 @@ function TestTouchEvents:testEventHandler_MultiTouch() local element = FlexLove.new({ width = 200, height = 200, + multiTouchEnabled = true, onEvent = function(el, event) table.insert(touchEvents, event) end, diff --git a/testing/__tests__/touch_routing_test.lua b/testing/__tests__/touch_routing_test.lua new file mode 100644 index 0000000..9e337c7 --- /dev/null +++ b/testing/__tests__/touch_routing_test.lua @@ -0,0 +1,528 @@ +package.path = package.path .. ";./?.lua;./modules/?.lua" +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() + return require("modules." .. moduleName) + end + end +end) +require("testing.loveStub") +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") + +FlexLove.init() + +TestTouchRouting = {} + +function TestTouchRouting:setUp() + FlexLove.setMode("immediate") + love.window.setMode(800, 600) +end + +function TestTouchRouting:tearDown() + FlexLove.destroy() + love.touch.getTouches = function() return {} end + love.touch.getPosition = function() return 0, 0 end +end + +-- Test: touchpressed routes to element at position +function TestTouchRouting:test_touchpressed_routes_to_element() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + luaunit.assertTrue(#touchEvents >= 1, "Should receive touchpress event") + luaunit.assertEquals(touchEvents[1].type, "touchpress") + luaunit.assertEquals(touchEvents[1].touchId, "touch1") + luaunit.assertEquals(touchEvents[1].x, 100) + luaunit.assertEquals(touchEvents[1].y, 100) +end + +-- Test: touchmoved routes to owning element +function TestTouchRouting:test_touchmoved_routes_to_owner() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + FlexLove.touchmoved("touch1", 150, 150, 50, 50, 1.0) + + -- Filter for move events + local moveEvents = {} + for _, e in ipairs(touchEvents) do + if e.type == "touchmove" then + table.insert(moveEvents, e) + end + end + + luaunit.assertTrue(#moveEvents >= 1, "Should receive touchmove event") + luaunit.assertEquals(moveEvents[1].x, 150) + luaunit.assertEquals(moveEvents[1].y, 150) +end + +-- Test: touchreleased routes to owning element and cleans up +function TestTouchRouting:test_touchreleased_routes_and_cleans_up() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertNotNil(FlexLove.getTouchOwner("touch1"), "Touch should be owned") + + FlexLove.touchreleased("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertNil(FlexLove.getTouchOwner("touch1"), "Touch ownership should be cleaned up") + + -- Filter for release events + local releaseEvents = {} + for _, e in ipairs(touchEvents) do + if e.type == "touchrelease" then + table.insert(releaseEvents, e) + end + end + + luaunit.assertTrue(#releaseEvents >= 1, "Should receive touchrelease event") +end + +-- Test: Touch ownership persists — move events route even outside element bounds +function TestTouchRouting:test_touch_ownership_persists_outside_bounds() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + -- Press inside element + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + -- Move far outside element bounds + FlexLove.touchmoved("touch1", 500, 500, 400, 400, 1.0) + + -- Should still receive the move event due to ownership + local moveEvents = {} + for _, e in ipairs(touchEvents) do + if e.type == "touchmove" then + table.insert(moveEvents, e) + end + end + + luaunit.assertTrue(#moveEvents >= 1, "Move event should route to owner even outside bounds") + luaunit.assertEquals(moveEvents[1].x, 500) + luaunit.assertEquals(moveEvents[1].y, 500) +end + +-- Test: Touch outside all elements creates no ownership +function TestTouchRouting:test_touch_outside_elements_no_ownership() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 100, + height = 100, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + -- Press outside element bounds + FlexLove.touchpressed("touch1", 500, 500, 0, 0, 1.0) + + luaunit.assertNil(FlexLove.getTouchOwner("touch1"), "No element should own touch outside bounds") + luaunit.assertEquals(#touchEvents, 0, "No events should fire for touch outside bounds") +end + +-- Test: Multiple touches route to different elements +function TestTouchRouting:test_multi_touch_different_elements() + FlexLove.beginFrame() + local events1 = {} + local events2 = {} + -- Two elements side by side (default row layout) + local container = FlexLove.new({ + width = 400, + height = 200, + }) + local element1 = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(events1, event) + end, + parent = container, + }) + local element2 = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(events2, event) + end, + parent = container, + }) + FlexLove.endFrame() + + -- Touch element1 (at x=0..200, y=0..200) + FlexLove.touchpressed("touch1", 50, 100, 0, 0, 1.0) + -- Touch element2 (at x=200..400, y=0..200) + FlexLove.touchpressed("touch2", 300, 100, 0, 0, 1.0) + + luaunit.assertTrue(#events1 >= 1, "Element1 should receive touch event") + luaunit.assertTrue(#events2 >= 1, "Element2 should receive touch event") + luaunit.assertEquals(events1[1].touchId, "touch1") + luaunit.assertEquals(events2[1].touchId, "touch2") +end + +-- Test: Z-index ordering — higher z element receives touch +function TestTouchRouting:test_z_index_ordering() + FlexLove.beginFrame() + local eventsLow = {} + local eventsHigh = {} + -- Lower z element + local low = FlexLove.new({ + width = 200, + height = 200, + z = 1, + onTouchEvent = function(el, event) + table.insert(eventsLow, event) + end, + }) + -- Higher z element overlapping + local high = FlexLove.new({ + width = 200, + height = 200, + z = 10, + onTouchEvent = function(el, event) + table.insert(eventsHigh, event) + end, + }) + FlexLove.endFrame() + + -- Touch overlapping area + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + luaunit.assertTrue(#eventsHigh >= 1, "Higher z element should receive touch") + luaunit.assertEquals(#eventsLow, 0, "Lower z element should NOT receive touch") +end + +-- Test: Disabled element does not receive touch +function TestTouchRouting:test_disabled_element_no_touch() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + disabled = true, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + luaunit.assertEquals(#touchEvents, 0, "Disabled element should not receive touch events") + luaunit.assertNil(FlexLove.getTouchOwner("touch1")) +end + +-- Test: getActiveTouchCount tracks active touches +function TestTouchRouting:test_getActiveTouchCount() + FlexLove.beginFrame() + local element = FlexLove.new({ + width = 800, + height = 600, + onTouchEvent = function() end, + }) + FlexLove.endFrame() + + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 0) + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 1) + + FlexLove.touchpressed("touch2", 200, 200, 0, 0, 1.0) + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 2) + + FlexLove.touchreleased("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 1) + + FlexLove.touchreleased("touch2", 200, 200, 0, 0, 1.0) + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 0) +end + +-- Test: getTouchOwner returns correct element +function TestTouchRouting:test_getTouchOwner() + FlexLove.beginFrame() + local element = FlexLove.new({ + id = "owner-test", + width = 200, + height = 200, + onTouchEvent = function() end, + }) + FlexLove.endFrame() + + luaunit.assertNil(FlexLove.getTouchOwner("touch1")) + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + local owner = FlexLove.getTouchOwner("touch1") + luaunit.assertNotNil(owner) + luaunit.assertEquals(owner.id, "owner-test") +end + +-- Test: destroy() cleans up touch state +function TestTouchRouting:test_destroy_cleans_touch_state() + FlexLove.beginFrame() + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function() end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 1) + + FlexLove.destroy() + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 0) +end + +-- Test: Touch routing with onEvent (not just onTouchEvent) +function TestTouchRouting:test_onEvent_receives_touch_events() + FlexLove.beginFrame() + local allEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onEvent = function(el, event) + table.insert(allEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + -- onEvent should receive touch events via handleTouchEvent -> _invokeCallback + local touchPressEvents = {} + for _, e in ipairs(allEvents) do + if e.type == "touchpress" then + table.insert(touchPressEvents, e) + end + end + + luaunit.assertTrue(#touchPressEvents >= 1, "onEvent should receive touchpress events") +end + +-- Test: Touch routing with onGesture callback +function TestTouchRouting:test_gesture_routing() + FlexLove.beginFrame() + local gestureEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function() end, + onGesture = function(el, gesture) + table.insert(gestureEvents, gesture) + end, + }) + FlexLove.endFrame() + + -- Simulate a quick tap (press and release at same position within threshold) + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + -- Small time step to avoid zero dt + love.timer.step(0.05) + FlexLove.touchreleased("touch1", 100, 100, 0, 0, 1.0) + + -- GestureRecognizer should detect a tap gesture + local tapGestures = {} + for _, g in ipairs(gestureEvents) do + if g.type == "tap" then + table.insert(tapGestures, g) + end + end + + luaunit.assertTrue(#tapGestures >= 1, "Should detect tap gesture from press+release") +end + +-- Test: touchpressed with no onTouchEvent but onGesture — should still find element +function TestTouchRouting:test_element_with_only_onGesture() + FlexLove.beginFrame() + local gestureEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onGesture = function(el, gesture) + table.insert(gestureEvents, gesture) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertNotNil(FlexLove.getTouchOwner("touch1"), "Element with onGesture should be found") +end + +-- Test: touchEnabled=false prevents touch routing +function TestTouchRouting:test_touchEnabled_false_prevents_routing() + FlexLove.beginFrame() + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + touchEnabled = false, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + luaunit.assertNil(FlexLove.getTouchOwner("touch1"), "touchEnabled=false should prevent ownership") + luaunit.assertEquals(#touchEvents, 0, "touchEnabled=false should prevent events") +end + +-- Test: Complete touch lifecycle (press, move, release) +function TestTouchRouting:test_full_lifecycle() + FlexLove.beginFrame() + local phases = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(phases, event.type) + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + FlexLove.touchmoved("touch1", 110, 110, 10, 10, 1.0) + FlexLove.touchmoved("touch1", 120, 120, 10, 10, 1.0) + FlexLove.touchreleased("touch1", 120, 120, 0, 0, 1.0) + + luaunit.assertEquals(phases[1], "touchpress") + luaunit.assertEquals(phases[2], "touchmove") + luaunit.assertEquals(phases[3], "touchmove") + luaunit.assertEquals(phases[4], "touchrelease") + luaunit.assertEquals(#phases, 4) +end + +-- Test: Orphaned move/release with no owner (no crash) +function TestTouchRouting:test_orphaned_move_release_no_crash() + -- Move and release events with no prior press should not crash + FlexLove.touchmoved("ghost_touch", 100, 100, 0, 0, 1.0) + FlexLove.touchreleased("ghost_touch", 100, 100, 0, 0, 1.0) + + luaunit.assertEquals(FlexLove.getActiveTouchCount(), 0) +end + +-- Test: Pressure value is passed through +function TestTouchRouting:test_pressure_passthrough() + FlexLove.beginFrame() + local receivedPressure = nil + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + if event.type == "touchpress" then + receivedPressure = event.pressure + end + end, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 0.75) + luaunit.assertAlmostEquals(receivedPressure, 0.75, 0.01) +end + +-- Test: Retained mode touch routing +function TestTouchRouting:test_retained_mode_routing() + FlexLove.setMode("retained") + + local touchEvents = {} + local element = FlexLove.new({ + width = 200, + height = 200, + onTouchEvent = function(el, event) + table.insert(touchEvents, event) + end, + }) + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + luaunit.assertTrue(#touchEvents >= 1, "Touch routing should work in retained mode") + luaunit.assertEquals(touchEvents[1].type, "touchpress") +end + +-- Test: Child element receives touch over parent +function TestTouchRouting:test_child_receives_touch_over_parent() + FlexLove.beginFrame() + local parentEvents = {} + local childEvents = {} + local parent = FlexLove.new({ + width = 400, + height = 400, + onTouchEvent = function(el, event) + table.insert(parentEvents, event) + end, + }) + local child = FlexLove.new({ + width = 200, + height = 200, + z = 1, -- Ensure child has higher z than parent + onTouchEvent = function(el, event) + table.insert(childEvents, event) + end, + parent = parent, + }) + FlexLove.endFrame() + + -- Touch within child area (which is also within parent) + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + + -- Child has explicit higher z, should receive touch + luaunit.assertTrue(#childEvents >= 1, + string.format("Child should receive touch (child=%d, parent=%d, topElements=%d)", + #childEvents, #parentEvents, #FlexLove.topElements)) +end + +-- Test: Element with no callbacks not found by touch routing +function TestTouchRouting:test_non_interactive_element_ignored() + FlexLove.beginFrame() + -- Element with no onEvent, onTouchEvent, or onGesture + local element = FlexLove.new({ + width = 200, + height = 200, + }) + FlexLove.endFrame() + + FlexLove.touchpressed("touch1", 100, 100, 0, 0, 1.0) + luaunit.assertNil(FlexLove.getTouchOwner("touch1"), "Non-interactive element should not capture touch") +end + +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/touch_scroll_test.lua b/testing/__tests__/touch_scroll_test.lua new file mode 100644 index 0000000..10635f1 --- /dev/null +++ b/testing/__tests__/touch_scroll_test.lua @@ -0,0 +1,475 @@ +package.path = package.path .. ";./?.lua;./modules/?.lua" + +require("testing.loveStub") +local luaunit = require("testing.luaunit") +local ErrorHandler = require("modules.ErrorHandler") + +-- Initialize ErrorHandler +ErrorHandler.init({}) +local ScrollManager = require("modules.ScrollManager") +local Color = require("modules.Color") +local utils = require("modules.utils") + +-- Initialize ScrollManager with ErrorHandler +ScrollManager.init({ ErrorHandler = ErrorHandler }) + +-- Helper to create ScrollManager with touch config +local function createTouchScrollManager(config) + config = config or {} + config.overflow = config.overflow or "scroll" + return ScrollManager.new(config, { + Color = Color, + utils = utils, + }) +end + +-- Helper to create mock element with content taller than container +local function createMockElement(width, height, contentWidth, contentHeight) + local children = {} + -- Create a single child that represents all content + table.insert(children, { + x = 0, + y = 0, + width = contentWidth or 200, + height = contentHeight or 600, + margin = { top = 0, right = 0, bottom = 0, left = 0 }, + _explicitlyAbsolute = false, + getBorderBoxWidth = function(self) return self.width end, + getBorderBoxHeight = function(self) return self.height end, + }) + + return { + x = 0, + y = 0, + width = width or 200, + height = height or 300, + padding = { top = 0, right = 0, bottom = 0, left = 0 }, + children = children, + getBorderBoxWidth = function(self) return self.width end, + getBorderBoxHeight = function(self) return self.height end, + } +end + +-- ============================================================================ +-- Test Suite: Touch Press +-- ============================================================================ + +TestTouchScrollPress = {} + +function TestTouchScrollPress:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollPress:test_handleTouchPress_starts_scrolling() + local sm = createTouchScrollManager() + local el = createMockElement() + sm:detectOverflow(el) + + local started = sm:handleTouchPress(100, 150) + + luaunit.assertTrue(started) + luaunit.assertTrue(sm:isTouchScrolling()) +end + +function TestTouchScrollPress:test_handleTouchPress_disabled_returns_false() + local sm = createTouchScrollManager({ touchScrollEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + local started = sm:handleTouchPress(100, 150) + + luaunit.assertFalse(started) + luaunit.assertFalse(sm:isTouchScrolling()) +end + +function TestTouchScrollPress:test_handleTouchPress_no_overflow_returns_false() + local sm = createTouchScrollManager({ overflow = "hidden" }) + + local started = sm:handleTouchPress(100, 150) + + luaunit.assertFalse(started) +end + +function TestTouchScrollPress:test_handleTouchPress_stops_momentum_scrolling() + local sm = createTouchScrollManager() + local el = createMockElement() + sm:detectOverflow(el) + + -- Simulate momentum by starting touch, moving fast, releasing + sm:handleTouchPress(100, 200) + -- Manually set momentum state + sm._momentumScrolling = true + sm._scrollVelocityY = 500 + + -- New press should stop momentum + sm:handleTouchPress(100, 200) + + luaunit.assertFalse(sm:isMomentumScrolling()) + luaunit.assertEquals(sm._scrollVelocityX, 0) + luaunit.assertEquals(sm._scrollVelocityY, 0) +end + +-- ============================================================================ +-- Test Suite: Touch Move +-- ============================================================================ + +TestTouchScrollMove = {} + +function TestTouchScrollMove:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollMove:test_handleTouchMove_scrolls_content() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + + -- Advance time so dt > 0 in handleTouchMove + love.timer.step(1 / 60) + local handled = sm:handleTouchMove(100, 150) + + luaunit.assertTrue(handled) + -- Touch moved UP by 50px, so scroll should increase (content moves down relative to finger) + luaunit.assertTrue(sm._scrollY > 0) +end + +function TestTouchScrollMove:test_handleTouchMove_without_press_returns_false() + local sm = createTouchScrollManager() + + local handled = sm:handleTouchMove(100, 150) + + luaunit.assertFalse(handled) +end + +function TestTouchScrollMove:test_handleTouchMove_calculates_velocity() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + love.timer.step(1 / 60) + sm:handleTouchMove(100, 100) -- Move 100px up + + -- Velocity should be set (non-zero since time elapsed) + -- Note: velocity direction is inverted (touch up = scroll down = positive velocity) + luaunit.assertTrue(sm._scrollVelocityY > 0) +end + +function TestTouchScrollMove:test_handleTouchMove_horizontal() + local sm = createTouchScrollManager({ + bounceEnabled = false, + overflowX = "scroll", + overflowY = "hidden", + }) + local el = createMockElement(200, 300, 600, 300) -- Wide content + sm:detectOverflow(el) + + sm:handleTouchPress(200, 150) + love.timer.step(1 / 60) + sm:handleTouchMove(100, 150) -- Move 100px left + + luaunit.assertTrue(sm._scrollX > 0) +end + +function TestTouchScrollMove:test_handleTouchMove_with_bounce_allows_overscroll() + local sm = createTouchScrollManager({ bounceEnabled = true, maxOverscroll = 100 }) + local el = createMockElement() + sm:detectOverflow(el) + + -- Scroll is at 0 (top), try to scroll further up (negative) + sm:handleTouchPress(100, 100) + love.timer.step(1 / 60) + sm:handleTouchMove(100, 200) -- Move down = scroll up (negative) + + -- With bounce, overscroll should be allowed (scroll < 0) + luaunit.assertTrue(sm._scrollY < 0) +end + +-- ============================================================================ +-- Test Suite: Touch Release and Momentum +-- ============================================================================ + +TestTouchScrollRelease = {} + +function TestTouchScrollRelease:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollRelease:test_handleTouchRelease_ends_touch_scrolling() + local sm = createTouchScrollManager() + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + luaunit.assertTrue(sm:isTouchScrolling()) + + sm:handleTouchRelease() + luaunit.assertFalse(sm:isTouchScrolling()) +end + +function TestTouchScrollRelease:test_handleTouchRelease_without_press_returns_false() + local sm = createTouchScrollManager() + + local released = sm:handleTouchRelease() + + luaunit.assertFalse(released) +end + +function TestTouchScrollRelease:test_handleTouchRelease_starts_momentum_with_velocity() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + love.timer.step(1 / 60) + sm:handleTouchMove(100, 50) -- Fast swipe up + + sm:handleTouchRelease() + + -- Should start momentum scrolling due to high velocity + luaunit.assertTrue(sm:isMomentumScrolling()) +end + +function TestTouchScrollRelease:test_handleTouchRelease_no_momentum_with_low_velocity() + local sm = createTouchScrollManager() + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + -- Simulate a very slow move by setting low velocity manually + sm._scrollVelocityX = 0 + sm._scrollVelocityY = 10 -- Below threshold of 50 + + sm:handleTouchRelease() + + luaunit.assertFalse(sm:isMomentumScrolling()) +end + +function TestTouchScrollRelease:test_handleTouchRelease_no_momentum_when_disabled() + local sm = createTouchScrollManager({ momentumScrollEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + sm._scrollVelocityY = 500 + + sm:handleTouchRelease() + + luaunit.assertFalse(sm:isMomentumScrolling()) + luaunit.assertEquals(sm._scrollVelocityX, 0) + luaunit.assertEquals(sm._scrollVelocityY, 0) +end + +-- ============================================================================ +-- Test Suite: Momentum Scrolling +-- ============================================================================ + +TestMomentumScrolling = {} + +function TestMomentumScrolling:setUp() + love.timer.setTime(0) +end + +function TestMomentumScrolling:test_momentum_decelerates_over_time() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + -- Set up momentum manually + sm._momentumScrolling = true + sm._scrollVelocityY = 200 + + local initialVelocity = sm._scrollVelocityY + + sm:update(1 / 60) + + luaunit.assertTrue(sm._scrollVelocityY < initialVelocity) + luaunit.assertTrue(sm._scrollVelocityY > 0) +end + +function TestMomentumScrolling:test_momentum_stops_at_low_velocity() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm._momentumScrolling = true + sm._scrollVelocityY = 200 + + -- Run many frames until momentum stops + for i = 1, 500 do + sm:update(1 / 60) + if not sm:isMomentumScrolling() then + break + end + end + + luaunit.assertFalse(sm:isMomentumScrolling()) + luaunit.assertEquals(sm._scrollVelocityY, 0) +end + +function TestMomentumScrolling:test_momentum_moves_scroll_position() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm._momentumScrolling = true + sm._scrollVelocityY = 500 + + local initialScrollY = sm._scrollY + sm:update(1 / 60) + + luaunit.assertTrue(sm._scrollY > initialScrollY) +end + +function TestMomentumScrolling:test_friction_coefficient_affects_deceleration() + local smFast = createTouchScrollManager({ scrollFriction = 0.99, bounceEnabled = false }) + local smSlow = createTouchScrollManager({ scrollFriction = 0.90, bounceEnabled = false }) + local el = createMockElement() + smFast:detectOverflow(el) + smSlow:detectOverflow(el) + + smFast._momentumScrolling = true + smFast._scrollVelocityY = 200 + smSlow._momentumScrolling = true + smSlow._scrollVelocityY = 200 + + smFast:update(1 / 60) + smSlow:update(1 / 60) + + -- Higher friction (0.99) preserves more velocity than lower friction (0.90) + luaunit.assertTrue(smFast._scrollVelocityY > smSlow._scrollVelocityY) +end + +-- ============================================================================ +-- Test Suite: Bounce Effects +-- ============================================================================ + +TestTouchScrollBounce = {} + +function TestTouchScrollBounce:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollBounce:test_bounce_returns_to_boundary() + local sm = createTouchScrollManager({ bounceEnabled = true }) + local el = createMockElement() + sm:detectOverflow(el) + + -- Force overscroll position + sm._scrollY = -50 + + -- Run bounce updates + for i = 1, 100 do + sm:update(1 / 60) + end + + -- Should have bounced back to 0 + luaunit.assertAlmostEquals(sm._scrollY, 0, 1) +end + +function TestTouchScrollBounce:test_bounce_at_bottom_boundary() + local sm = createTouchScrollManager({ bounceEnabled = true }) + local el = createMockElement() + sm:detectOverflow(el) + + -- Force overscroll past max + sm._scrollY = sm._maxScrollY + 50 + + for i = 1, 100 do + sm:update(1 / 60) + end + + luaunit.assertAlmostEquals(sm._scrollY, sm._maxScrollY, 1) +end + +function TestTouchScrollBounce:test_no_bounce_when_disabled() + local sm = createTouchScrollManager({ bounceEnabled = false }) + local el = createMockElement() + sm:detectOverflow(el) + + sm._scrollY = -50 + + sm:update(1 / 60) + + -- Without bounce, scroll should stay where it is (clamped by scrollBy) + -- But here we set it directly, so it stays + luaunit.assertEquals(sm._scrollY, -50) +end + +-- ============================================================================ +-- Test Suite: State Query Methods +-- ============================================================================ + +TestTouchScrollState = {} + +function TestTouchScrollState:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollState:test_isTouchScrolling_initially_false() + local sm = createTouchScrollManager() + luaunit.assertFalse(sm:isTouchScrolling()) +end + +function TestTouchScrollState:test_isMomentumScrolling_initially_false() + local sm = createTouchScrollManager() + luaunit.assertFalse(sm:isMomentumScrolling()) +end + +function TestTouchScrollState:test_isTouchScrolling_true_during_touch() + local sm = createTouchScrollManager() + local el = createMockElement() + sm:detectOverflow(el) + + sm:handleTouchPress(100, 200) + luaunit.assertTrue(sm:isTouchScrolling()) + + sm:handleTouchRelease() + luaunit.assertFalse(sm:isTouchScrolling()) +end + +-- ============================================================================ +-- Test Suite: Configuration +-- ============================================================================ + +TestTouchScrollConfig = {} + +function TestTouchScrollConfig:setUp() + love.timer.setTime(0) +end + +function TestTouchScrollConfig:test_default_config_values() + local sm = createTouchScrollManager() + + luaunit.assertTrue(sm.touchScrollEnabled) + luaunit.assertTrue(sm.momentumScrollEnabled) + luaunit.assertTrue(sm.bounceEnabled) + luaunit.assertEquals(sm.scrollFriction, 0.95) + luaunit.assertEquals(sm.bounceStiffness, 0.2) + luaunit.assertEquals(sm.maxOverscroll, 100) +end + +function TestTouchScrollConfig:test_custom_config_values() + local sm = createTouchScrollManager({ + touchScrollEnabled = false, + momentumScrollEnabled = false, + bounceEnabled = false, + scrollFriction = 0.98, + bounceStiffness = 0.1, + maxOverscroll = 50, + }) + + luaunit.assertFalse(sm.touchScrollEnabled) + luaunit.assertFalse(sm.momentumScrollEnabled) + luaunit.assertFalse(sm.bounceEnabled) + luaunit.assertEquals(sm.scrollFriction, 0.98) + luaunit.assertEquals(sm.bounceStiffness, 0.1) + luaunit.assertEquals(sm.maxOverscroll, 50) +end + +-- Run all tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/runAll.lua b/testing/runAll.lua index d71a346..930930f 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -44,10 +44,12 @@ local testFiles = { "testing/__tests__/calc_test.lua", "testing/__tests__/critical_failures_test.lua", "testing/__tests__/element_test.lua", + "testing/__tests__/element_touch_test.lua", "testing/__tests__/element_mode_override_test.lua", "testing/__tests__/event_handler_test.lua", "testing/__tests__/flex_grow_shrink_test.lua", "testing/__tests__/flexlove_test.lua", + "testing/__tests__/gesture_recognizer_test.lua", "testing/__tests__/grid_test.lua", "testing/__tests__/image_cache_test.lua", "testing/__tests__/image_renderer_test.lua", @@ -69,6 +71,8 @@ local testFiles = { "testing/__tests__/text_editor_test.lua", "testing/__tests__/theme_test.lua", "testing/__tests__/touch_events_test.lua", + "testing/__tests__/touch_routing_test.lua", + "testing/__tests__/touch_scroll_test.lua", "testing/__tests__/transition_test.lua", "testing/__tests__/units_test.lua", "testing/__tests__/utils_test.lua",