From ec73d8c7c85dcc72a89d376a6029027ab179a290 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 12 Dec 2025 00:08:25 -0500 Subject: [PATCH] cleaned up rendering mode swapping --- FlexLove.lua | 19 +- README.md | 45 +- examples/mixed_mode_demo.lua | 219 ++++++++++ modules/Context.lua | 13 +- modules/Element.lua | 80 +++- modules/StateManager.lua | 101 ++++- .../__tests__/mixed_mode_children_test.lua | 400 ++++++++++++++++++ testing/__tests__/mixed_mode_events_test.lua | 57 +-- .../__tests__/retained_in_immediate_test.lua | 193 +++++++++ .../retained_prop_stability_test.lua | 184 ++++++++ testing/runAll.lua | 5 +- 11 files changed, 1270 insertions(+), 46 deletions(-) create mode 100644 examples/mixed_mode_demo.lua create mode 100644 testing/__tests__/mixed_mode_children_test.lua create mode 100644 testing/__tests__/retained_in_immediate_test.lua create mode 100644 testing/__tests__/retained_prop_stability_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index 01f573d..76b18f5 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -399,6 +399,7 @@ function flexlove.beginFrame() -- Cleanup elements from PREVIOUS frame (after they've been drawn) -- This breaks circular references and allows GC to collect memory -- Note: Cleanup is minimal to preserve functionality + -- IMPORTANT: Only cleanup immediate-mode elements, preserve retained-mode elements if flexlove._currentFrameElements then local function cleanupChildren(elem) for _, child in ipairs(elem.children) do @@ -408,17 +409,31 @@ function flexlove.beginFrame() end for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then + -- Only cleanup immediate-mode top-level elements + -- Retained-mode elements persist across frames + if not element.parent and element._elementMode == "immediate" then cleanupChildren(element) end end end + -- Preserve top-level retained elements before resetting + local retainedTopElements = {} + if flexlove.topElements then + for _, element in ipairs(flexlove.topElements) do + if element._elementMode == "retained" then + table.insert(retainedTopElements, element) + end + end + end + flexlove._frameNumber = flexlove._frameNumber + 1 StateManager.incrementFrame() flexlove._currentFrameElements = {} flexlove._frameStarted = true - flexlove.topElements = {} + + -- Restore retained top-level elements + flexlove.topElements = retainedTopElements Context.clearFrameElements() end diff --git a/README.md b/README.md index 62f9eaf..a138806 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ function love.draw() width = "30vw", height = "40vh", backgroundColor = Color.new(0.2, 0.2, 0.2, 1), - -- No ID auto-generation since it's in retained mode + -- ID is auto-generated if not provided (works in both modes) }) FlexLove.endFrame() @@ -229,15 +229,51 @@ end ``` **Key behaviors:** -- `mode = "immediate"` - Element uses immediate-mode lifecycle (recreated each frame, auto-generates ID, uses StateManager) -- `mode = "retained"` - Element uses retained-mode lifecycle (persists across frames, no auto-ID, no StateManager) +- `mode = "immediate"` - Element uses immediate-mode lifecycle (recreated each frame, uses StateManager) +- `mode = "retained"` - Element uses retained-mode lifecycle (persists across frames, uses StateManager for mixed-mode children) - `mode = nil` - Element inherits from global mode setting (default behavior) +- **ID auto-generation** - All elements receive an auto-generated ID if not explicitly provided, enabling state management features - Mode does NOT inherit from parent to child - each element independently controls its own lifecycle - Common use cases: - Performance-critical static UI in retained mode while using immediate mode globally - Reactive debug overlays in immediate mode within a retained-mode application - Mixed UI where some components are static (menus) and others are dynamic (HUD) +#### Automatic ID Generation + +All elements automatically receive a unique ID if not explicitly provided. This enables powerful state management features: + +```lua +-- ID is automatically generated based on call site +local button = FlexLove.new({ + text = "Click Me", + -- id is auto-generated (e.g., "main_L42_child0_123456") +}) + +-- Or provide a custom ID for explicit control +local namedButton = FlexLove.new({ + id = "submit_button", + text = "Submit", +}) +``` + +**ID Generation Strategy:** +- **Top-level elements**: ID based on source file and line number (e.g., `main_L42`) +- **Child elements**: ID based on parent ID + sibling index (e.g., `parent_child0`, `parent_child1`) +- **Multiple elements at same location**: Automatically numbered (e.g., `main_L42_1`, `main_L42_2`) +- **Property differentiation**: Similar elements get unique hash suffixes based on their properties + +**Benefits of Auto-Generated IDs:** +- Enables state persistence in immediate mode (scroll position, input text, animations) +- Allows retained children in immediate parents (mixed-mode trees) +- Supports state management features without manual ID tracking +- Stable across frames when element structure is consistent + +**When to Use Custom IDs:** +- When you need to reference elements programmatically +- For debugging and logging (readable names) +- When element creation order might change but identity should remain stable + ### Element Properties Common properties for all elements: @@ -303,6 +339,9 @@ Common properties for all elements: -- Rendering Mode mode = nil, -- "immediate", "retained", or nil (uses global setting) + -- Element Identity + id = nil, -- Element ID (auto-generated if not provided) + -- Hierarchy parent = nil, -- Parent element } diff --git a/examples/mixed_mode_demo.lua b/examples/mixed_mode_demo.lua new file mode 100644 index 0000000..e21b1b0 --- /dev/null +++ b/examples/mixed_mode_demo.lua @@ -0,0 +1,219 @@ +-- Demo: Retained children with immediate parents +-- Shows how retained-mode children persist when immediate-mode parents recreate each frame + +local FlexLove = require("FlexLove") + +-- Track frame count for demo +local frameCount = 0 + +-- Retained button state (will persist across frames) +local buttonClicks = 0 +local topLevelButtonClicks = 0 + +function love.load() + love.window.setTitle("Mixed-Mode Demo: Retained Children + Immediate Parents") + love.window.setMode(800, 600) + + FlexLove.init({ + immediateMode = true, + performanceMonitoring = true, + }) +end + +function love.update(dt) + FlexLove.update(dt) +end + +function love.draw() + FlexLove.beginFrame() + + -- Frame counter (immediate mode - recreates each frame) + local header = FlexLove.new({ + width = 800, + height = 60, + backgroundColor = { 0.1, 0.1, 0.15, 1 }, + padding = 20, + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + }) + + FlexLove.new({ + parent = header, + text = "Frame: " .. frameCount, + textColor = { 1, 1, 1, 1 }, + textSize = 20, + }) + + FlexLove.new({ + parent = header, + text = "Mixed-Mode Element Tree Demo", + textColor = { 0.8, 0.9, 1, 1 }, + textSize = 24, + }) + + -- Main content area (immediate parent) + local container = FlexLove.new({ + id = "main_container", + y = 60, + width = 800, + height = 540, + padding = 30, + gap = 20, + backgroundColor = { 0.05, 0.05, 0.08, 1 }, + flexDirection = "vertical", + }) + + -- Section 1: Retained button in immediate parent + FlexLove.new({ + parent = container, + text = "1. Retained Button (persists across frames)", + textColor = { 0.9, 0.9, 0.9, 1 }, + textSize = 18, + }) + + local retainedButton = FlexLove.new({ + id = "retained_button", + mode = "retained", -- This button will persist! + parent = container, + width = 300, + height = 50, + backgroundColor = { 0.2, 0.6, 0.9, 1 }, + cornerRadius = 8, + text = "Clicks: " .. buttonClicks, + textColor = { 1, 1, 1, 1 }, + textSize = 16, + textAlign = "center", + onEvent = function(element, event) + if event.type == "click" then + buttonClicks = buttonClicks + 1 + -- Update button text + element.text = "Clicks: " .. buttonClicks + end + end, + }) + + FlexLove.new({ + parent = container, + text = "Note: Button state persists even though parent recreates every frame", + textColor = { 0.6, 0.6, 0.6, 1 }, + textSize = 12, + }) + + -- Section 2: Top-level retained element + FlexLove.new({ + parent = container, + text = "2. Top-Level Retained Element (also persists)", + textColor = { 0.9, 0.9, 0.9, 1 }, + textSize = 18, + margin = { top = 20 }, + }) + + FlexLove.new({ + parent = container, + text = "Look at the bottom-left corner for a persistent panel", + textColor = { 0.6, 0.6, 0.6, 1 }, + textSize = 12, + }) + + -- Section 3: Comparison with immediate button + FlexLove.new({ + parent = container, + text = "3. Immediate Button (recreates every frame)", + textColor = { 0.9, 0.9, 0.9, 1 }, + textSize = 18, + margin = { top = 20 }, + }) + + FlexLove.new({ + parent = container, + width = 300, + height = 50, + backgroundColor = { 0.9, 0.3, 0.3, 1 }, + cornerRadius = 8, + text = "Can't track clicks (recreated)", + textColor = { 1, 1, 1, 1 }, + textSize = 16, + textAlign = "center", + onEvent = function(element, event) + if event.type == "click" then + print("Immediate button clicked (but counter can't persist)") + end + end, + }) + + FlexLove.new({ + parent = container, + text = "Note: This button is recreated every frame, so it can't maintain state", + textColor = { 0.6, 0.6, 0.6, 1 }, + textSize = 12, + }) + + -- Top-level retained element (persists in immediate mode!) + local topLevelPanel = FlexLove.new({ + id = "top_level_panel", + mode = "retained", + x = 10, + y = 500, + width = 250, + height = 90, + backgroundColor = { 0.15, 0.5, 0.3, 1 }, + cornerRadius = 10, + padding = 15, + gap = 10, + flexDirection = "vertical", + }) + + FlexLove.new({ + id = "panel_title", + mode = "retained", + parent = topLevelPanel, + text = "Persistent Panel", + textColor = { 1, 1, 1, 1 }, + textSize = 16, + }) + + FlexLove.new({ + id = "panel_button", + mode = "retained", + parent = topLevelPanel, + width = 220, + height = 35, + backgroundColor = { 1, 1, 1, 0.9 }, + cornerRadius = 5, + text = "Panel Clicks: " .. topLevelButtonClicks, + textColor = { 0.15, 0.5, 0.3, 1 }, + textSize = 14, + textAlign = "center", + onEvent = function(element, event) + if event.type == "click" then + topLevelButtonClicks = topLevelButtonClicks + 1 + element.text = "Panel Clicks: " .. topLevelButtonClicks + end + end, + }) + + FlexLove.endFrame() + + -- Increment frame counter AFTER drawing + frameCount = frameCount + 1 + + -- Draw all UI elements + FlexLove.draw() +end + +function love.mousepressed(x, y, button) + FlexLove.mousepressed(x, y, button) +end + +function love.mousereleased(x, y, button) + FlexLove.mousereleased(x, y, button) +end + +function love.mousemoved(x, y, dx, dy) + FlexLove.mousemoved(x, y, dx, dy) +end + +function love.wheelmoved(x, y) + FlexLove.wheelmoved(x, y) +end diff --git a/modules/Context.lua b/modules/Context.lua index be3f037..2c00b9b 100644 --- a/modules/Context.lua +++ b/modules/Context.lua @@ -38,7 +38,18 @@ function Context.registerElement(element) end function Context.clearFrameElements() - Context._zIndexOrderedElements = {} + -- Preserve retained-mode elements + if Context._immediateMode then + local retainedElements = {} + for _, element in ipairs(Context._zIndexOrderedElements) do + if element._elementMode == "retained" then + table.insert(retainedElements, element) + end + end + Context._zIndexOrderedElements = retainedElements + else + Context._zIndexOrderedElements = {} + end end --- Sort elements by z-index (called after all elements are registered) diff --git a/modules/Element.lua b/modules/Element.lua index 475ad80..5350e04 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -188,6 +188,24 @@ end ---@param props ElementProps ---@return Element function Element.new(props) + -- Early check: If this is a retained-mode element in an immediate-mode context, + -- check if it already exists (was restored to parent) to prevent duplicates + local elementMode = props.mode + if elementMode == nil then + elementMode = Element._Context._immediateMode and "immediate" or "retained" + end + + -- If retained mode and has an ID, check if element already exists in parent's children + if elementMode == "retained" and props.id and props.id ~= "" and props.parent then + -- Check if this element already exists in parent's restored children + for _, child in ipairs(props.parent.children) do + if child.id == props.id and child._elementMode == "retained" then + -- Element already exists (was restored), return existing instance + return child + end + end + end + local self = setmetatable({}, Element) -- Create dependency subsets for sub-modules (defined once, used throughout) @@ -272,11 +290,56 @@ function Element.new(props) self.children = {} self.onEvent = props.onEvent - -- Auto-generate ID in immediate mode if not provided - if self._elementMode == "immediate" and (not props.id or props.id == "") then + -- Track whether ID was auto-generated (before ID assignment) + local idWasAutoGenerated = not props.id or props.id == "" + + -- Auto-generate ID if not provided (for all elements) + if idWasAutoGenerated then self.id = Element._StateManager.generateID(props, props.parent) else - self.id = props.id or "" + self.id = props.id + end + + -- AFTER ID is determined, check for duplicate top-level OR child retained elements + -- ONLY for auto-generated IDs (same call site recreating same element) + -- If user provides explicit ID, they control uniqueness + if self._elementMode == "retained" and idWasAutoGenerated and self.id and self.id ~= "" then + if not props.parent then + -- Top-level element: check in topElements + for _, existingElement in ipairs(Element._Context.topElements) do + if existingElement.id == self.id and existingElement._elementMode == "retained" then + -- Element already exists (was preserved from previous frame), return existing instance + -- CRITICAL: Clear children array to prevent accumulation + -- Children will be re-declared this frame (retained children will be found via duplicate check) + existingElement.children = {} + return existingElement + end + end + else + -- Child element: check in parent's children + for _, existingChild in ipairs(props.parent.children) do + if existingChild.id == self.id and existingChild._elementMode == "retained" then + -- Element already exists (was restored to parent), return existing instance + -- CRITICAL: Clear children array to prevent accumulation + -- Children will be re-declared this frame (retained children will be found via duplicate check) + existingChild.children = {} + return existingChild + end + end + end + end + + -- In immediate mode, restore retained children from StateManager + -- This allows retained-mode children to persist when immediate-mode parents recreate + if self._elementMode == "immediate" and self.id and self.id ~= "" then + local retainedChildren = Element._StateManager.getRetainedChildren(self.id) + if retainedChildren and #retainedChildren > 0 then + -- Restore retained children and update their parent references + for _, child in ipairs(retainedChildren) do + child.parent = self + table.insert(self.children, child) + end + end end self.userdata = props.userdata @@ -2237,6 +2300,11 @@ function Element:destroy() -- Clear children table self.children = {} + -- Clear retained children from StateManager (if this is an immediate-mode element) + if self._elementMode == "immediate" and self.id and self.id ~= "" then + Element._StateManager.clearRetainedChildren(self.id) + end + -- Clear parent reference if self.parent then self.parent = nil @@ -3503,6 +3571,12 @@ function Element:saveState() state._textDragOccurred = self._textDragOccurred end + -- Save retained children references (for mixed-mode trees) + -- Only save if this is an immediate-mode element with retained children + if self._elementMode == "immediate" and #self.children > 0 then + Element._StateManager.saveRetainedChildren(self.id, self.children) + end + return state end diff --git a/modules/StateManager.lua b/modules/StateManager.lua index a0dee6a..73d7661 100644 --- a/modules/StateManager.lua +++ b/modules/StateManager.lua @@ -64,6 +64,9 @@ local stateDefaults = { _cursorVisible = true, _cursorBlinkPaused = false, _cursorBlinkPauseTimer = 0, + + -- Retained children references (for mixed-mode trees) + retainedChildren = nil, } --- Check if a value equals the default for a key @@ -208,16 +211,24 @@ function StateManager.generateID(props, parent) -- If we have a parent, use tree-based ID generation for stability if parent and parent.id and parent.id ~= "" then - -- Count how many children the parent currently has - -- This gives us a stable sibling index - local siblingIndex = #(parent.children or {}) + -- For child elements, use call-site (file + line) like top-level elements + -- This ensures the same call site always generates the same ID, even when + -- retained children persist in parent.children array + local baseID = parent.id .. "_" .. locationKey + + -- Count how many children have been created at THIS call site + local callSiteKey = parent.id .. "_" .. locationKey + callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1 + local instanceNum = callSiteCounters[callSiteKey] + + if instanceNum > 1 then + baseID = baseID .. "_" .. instanceNum + end - -- Generate ID based on parent ID + sibling position (NO line number for stability) - -- This ensures the same position in the tree always gets the same ID - local baseID = parent.id .. "_child" .. siblingIndex - - -- Add property hash if provided (for additional differentiation at same position) - if props then + -- Add property hash if provided (for additional differentiation) + -- IMPORTANT: Skip property hash for retained-mode elements to ensure ID stability + -- Retained elements should persist across frames even if props change slightly + if props and props.mode ~= "retained" then local propHash = hashProps(props) if propHash ~= "" then -- Use first 8 chars of a simple hash @@ -245,7 +256,9 @@ function StateManager.generateID(props, parent) end -- Add property hash if provided (for additional differentiation) - if props then + -- IMPORTANT: Skip property hash for retained-mode elements to ensure ID stability + -- Retained elements should persist across frames even if props change slightly + if props and props.mode ~= "retained" then local propHash = hashProps(props) if propHash ~= "" then -- Use first 8 chars of a simple hash @@ -655,4 +668,72 @@ function StateManager.isActive(id) return state.active or false end +-- ==================== +-- Retained Children Management (for mixed-mode trees) +-- ==================== + +--- Save retained children for an element +--- Only stores children that are in retained mode +---@param id string Parent element ID +---@param children table Array of child elements +function StateManager.saveRetainedChildren(id, children) + if not id or not children then + return + end + + -- Filter to only retained-mode children + local retainedChildren = {} + for _, child in ipairs(children) do + if child._elementMode == "retained" then + table.insert(retainedChildren, child) + end + end + + -- Only save if we have retained children + if #retainedChildren > 0 then + local state = StateManager.getState(id) + state.retainedChildren = retainedChildren + end +end + +--- Get retained children for an element +--- Returns an array of retained-mode child elements +---@param id string Parent element ID +---@return table children Array of retained child elements (empty if none) +function StateManager.getRetainedChildren(id) + if not id then + return {} + end + + local state = StateManager.getCurrentState(id) + if state.retainedChildren then + -- Verify children still exist (weren't destroyed) + local validChildren = {} + for _, child in ipairs(state.retainedChildren) do + -- Children are element objects, check if they're still valid + -- A destroyed element would have nil references or be garbage collected + if child and type(child) == "table" and child.id then + table.insert(validChildren, child) + end + end + return validChildren + end + + return {} +end + +--- Clear retained children for an element +--- Used when parent is destroyed or children are manually removed +---@param id string Parent element ID +function StateManager.clearRetainedChildren(id) + if not id then + return + end + + local state = StateManager.getCurrentState(id) + if state.retainedChildren then + state.retainedChildren = nil + end +end + return StateManager diff --git a/testing/__tests__/mixed_mode_children_test.lua b/testing/__tests__/mixed_mode_children_test.lua new file mode 100644 index 0000000..ea5d536 --- /dev/null +++ b/testing/__tests__/mixed_mode_children_test.lua @@ -0,0 +1,400 @@ +-- Test retained children persisting when immediate parents recreate +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") + +TestMixedModeChildren = {} + +function TestMixedModeChildren:setUp() + FlexLove.init({ immediateMode = true }) + FlexLove.setMode("immediate") +end + +function TestMixedModeChildren:tearDown() + FlexLove._defaultDependencies.StateManager.reset() + FlexLove.topElements = {} + FlexLove._currentFrameElements = {} + FlexLove._frameStarted = false +end + +-- Test 1: Retained child persists when immediate parent recreates +function TestMixedModeChildren:testRetainedChildPersistsWithImmediateParent() + FlexLove.beginFrame() + + -- Frame 1: Create immediate parent with retained child + local parent1 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + local retainedChild = FlexLove.new({ + id = "retained_child", + mode = "retained", + width = 50, + height = 50, + }) + + parent1:addChild(retainedChild) + + luaunit.assertEquals(#parent1.children, 1, "Parent should have 1 child") + luaunit.assertEquals(parent1.children[1], retainedChild, "Child should be the retained element") + luaunit.assertEquals(retainedChild.parent, parent1, "Child's parent should be set") + + FlexLove.endFrame() + + -- Frame 2: Recreate immediate parent, retained child should persist + FlexLove.beginFrame() + + local parent2 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + -- The retained child should be automatically restored + luaunit.assertEquals(#parent2.children, 1, "Parent should still have 1 child after recreation") + luaunit.assertEquals(parent2.children[1], retainedChild, "Child should be the same retained element") + luaunit.assertEquals(retainedChild.parent, parent2, "Child's parent reference should be updated") + + FlexLove.endFrame() +end + +-- Test 2: Multiple retained children persist +function TestMixedModeChildren:testMultipleRetainedChildrenPersist() + FlexLove.beginFrame() + + local parent1 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + local child1 = FlexLove.new({ + id = "child1", + mode = "retained", + width = 50, + height = 50, + }) + + local child2 = FlexLove.new({ + id = "child2", + mode = "retained", + width = 50, + height = 50, + }) + + local child3 = FlexLove.new({ + id = "child3", + mode = "retained", + width = 50, + height = 50, + }) + + parent1:addChild(child1) + parent1:addChild(child2) + parent1:addChild(child3) + + luaunit.assertEquals(#parent1.children, 3, "Parent should have 3 children") + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local parent2 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + luaunit.assertEquals(#parent2.children, 3, "Parent should still have 3 children") + luaunit.assertEquals(parent2.children[1], child1, "First child should persist") + luaunit.assertEquals(parent2.children[2], child2, "Second child should persist") + luaunit.assertEquals(parent2.children[3], child3, "Third child should persist") + + FlexLove.endFrame() +end + +-- Test 3: Immediate children do NOT persist (only retained children) +function TestMixedModeChildren:testImmediateChildrenDoNotPersist() + FlexLove.beginFrame() + + local parent1 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + local immediateChild = FlexLove.new({ + id = "immediate_child", + mode = "immediate", + width = 50, + height = 50, + }) + + local retainedChild = FlexLove.new({ + id = "retained_child", + mode = "retained", + width = 50, + height = 50, + }) + + parent1:addChild(immediateChild) + parent1:addChild(retainedChild) + + luaunit.assertEquals(#parent1.children, 2, "Parent should have 2 children") + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local parent2 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + -- Only retained child should persist + luaunit.assertEquals(#parent2.children, 1, "Parent should only have 1 child (retained)") + luaunit.assertEquals(parent2.children[1], retainedChild, "Only retained child should persist") + + FlexLove.endFrame() +end + +-- Test 4: Top-level retained element persists in immediate mode +function TestMixedModeChildren:testTopLevelRetainedElementPersists() + FlexLove.beginFrame() + + local retainedElement = FlexLove.new({ + id = "top_retained", + mode = "retained", + width = 100, + height = 100, + }) + + luaunit.assertEquals(#FlexLove.topElements, 1, "Should have 1 top-level element") + luaunit.assertEquals(FlexLove.topElements[1], retainedElement, "Top element should be retained element") + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + -- Retained element should still be in topElements + luaunit.assertEquals(#FlexLove.topElements, 1, "Retained element should persist in topElements") + luaunit.assertEquals(FlexLove.topElements[1], retainedElement, "Should be same retained element") + + FlexLove.endFrame() +end + +-- Test 5: Mixed top-level elements (immediate and retained) +function TestMixedModeChildren:testMixedTopLevelElements() + FlexLove.beginFrame() + + local immediateElement1 = FlexLove.new({ + id = "immediate1", + mode = "immediate", + width = 100, + height = 100, + }) + + local retainedElement = FlexLove.new({ + id = "retained", + mode = "retained", + width = 100, + height = 100, + }) + + luaunit.assertEquals(#FlexLove.topElements, 2, "Should have 2 top-level elements") + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local immediateElement2 = FlexLove.new({ + id = "immediate2", + mode = "immediate", + width = 100, + height = 100, + }) + + -- Should have retained element + new immediate element + luaunit.assertEquals(#FlexLove.topElements, 2, "Should have 2 top-level elements") + + -- Find retained element in topElements + local foundRetained = false + for _, elem in ipairs(FlexLove.topElements) do + if elem == retainedElement then + foundRetained = true + break + end + end + + luaunit.assertTrue(foundRetained, "Retained element should still be in topElements") + + FlexLove.endFrame() +end + +-- Test 6: Retained child cleanup on parent destroy +function TestMixedModeChildren:testRetainedChildCleanupOnDestroy() + FlexLove.beginFrame() + + local parent = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + local retainedChild = FlexLove.new({ + id = "retained_child", + mode = "retained", + width = 50, + height = 50, + }) + + parent:addChild(retainedChild) + + FlexLove.endFrame() + + -- Destroy parent (simulating explicit cleanup) + parent:destroy() + + -- Frame 2: Create new parent with same ID + FlexLove.beginFrame() + + local parent2 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + -- Since parent was destroyed, retained children should be cleared + luaunit.assertEquals(#parent2.children, 0, "Parent should have no children after destroy") + + FlexLove.endFrame() +end + +-- Test 7: Nested mixed-mode tree (immediate -> retained -> immediate) +function TestMixedModeChildren:testNestedMixedModeTree() + FlexLove.beginFrame() + + local immediateParent = FlexLove.new({ + id = "immediate_parent", + mode = "immediate", + width = 300, + height = 300, + }) + + local retainedMiddle = FlexLove.new({ + id = "retained_middle", + mode = "retained", + width = 200, + height = 200, + }) + + local immediateGrandchild = FlexLove.new({ + id = "immediate_grandchild", + mode = "immediate", + width = 100, + height = 100, + }) + + immediateParent:addChild(retainedMiddle) + retainedMiddle:addChild(immediateGrandchild) + + luaunit.assertEquals(#immediateParent.children, 1, "Immediate parent should have 1 child") + luaunit.assertEquals(#retainedMiddle.children, 1, "Retained middle should have 1 child") + + FlexLove.endFrame() + + -- Frame 2: Recreate immediate parent + FlexLove.beginFrame() + + local immediateParent2 = FlexLove.new({ + id = "immediate_parent", + mode = "immediate", + width = 300, + height = 300, + }) + + -- Retained middle should persist + luaunit.assertEquals(#immediateParent2.children, 1, "Immediate parent should still have retained child") + luaunit.assertEquals(immediateParent2.children[1], retainedMiddle, "Retained middle should persist") + + -- Immediate grandchild should also persist (as child of retained middle) + luaunit.assertEquals(#retainedMiddle.children, 1, "Retained middle should still have its child") + + FlexLove.endFrame() +end + +-- Test 8: Prevent duplicate creation of retained children +function TestMixedModeChildren:testPreventDuplicateRetainedChildren() + FlexLove.beginFrame() + + -- Frame 1: Create immediate parent with retained child + local parent1 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + local retainedChild = FlexLove.new({ + id = "unique_child", + mode = "retained", + parent = parent1, + width = 50, + height = 50, + }) + + luaunit.assertEquals(#parent1.children, 1, "Parent should have 1 child") + local originalChild = parent1.children[1] + + FlexLove.endFrame() + + -- Frame 2: Recreate parent and try to create child again + FlexLove.beginFrame() + + local parent2 = FlexLove.new({ + id = "parent", + mode = "immediate", + width = 200, + height = 200, + }) + + -- Try to create the same retained child again + local duplicateAttempt = FlexLove.new({ + id = "unique_child", + mode = "retained", + parent = parent2, + width = 50, + height = 50, + }) + + -- Should return the existing child, not create a new one + luaunit.assertEquals(duplicateAttempt, originalChild, "Should return existing child instead of creating duplicate") + luaunit.assertEquals(duplicateAttempt, retainedChild, "Should be the same retained child instance") + luaunit.assertEquals(#parent2.children, 1, "Parent should still have only 1 child") + + FlexLove.endFrame() +end + +-- Run tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/mixed_mode_events_test.lua b/testing/__tests__/mixed_mode_events_test.lua index 4488c3b..739b473 100644 --- a/testing/__tests__/mixed_mode_events_test.lua +++ b/testing/__tests__/mixed_mode_events_test.lua @@ -16,7 +16,9 @@ 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 + return function() + return require("modules." .. moduleName) + end end end) @@ -41,17 +43,17 @@ end function TestMixedModeEvents:test_immediateChildOfRetainedParentHandlesEvents() local eventFired = false local eventType = nil - + -- Create retained parent local parent = FlexLove.new({ mode = "retained", width = 800, height = 600, }) - + FlexLove.setMode("immediate") FlexLove.beginFrame() - + -- Create immediate child with event handler local child = FlexLove.new({ mode = "immediate", @@ -66,19 +68,19 @@ function TestMixedModeEvents:test_immediateChildOfRetainedParentHandlesEvents() eventType = event.type end, }) - + FlexLove.endFrame() - + -- Verify child is positioned correctly luaunit.assertEquals(child.x, 0) luaunit.assertEquals(child.y, 0) - + -- Manually call the event handler (simulating event processing) -- In the real app, this would be triggered by mousepressed/released if child.onEvent then child.onEvent(child, { type = "release", x = 50, y = 25, button = 1 }) end - + -- Verify event was handled luaunit.assertTrue(eventFired) luaunit.assertEquals(eventType, "release") @@ -87,17 +89,17 @@ end -- Test that hover state is tracked for immediate children function TestMixedModeEvents:test_immediateChildOfRetainedParentTracksHover() FlexLove.setMode("retained") - + -- Create retained parent local parent = FlexLove.new({ mode = "retained", width = 800, height = 600, }) - + FlexLove.setMode("immediate") FlexLove.beginFrame() - + -- Create immediate child local child = FlexLove.new({ mode = "immediate", @@ -108,12 +110,12 @@ function TestMixedModeEvents:test_immediateChildOfRetainedParentTracksHover() width = 100, height = 50, }) - + FlexLove.endFrame() - + -- Child should have event handler module luaunit.assertNotNil(child._eventHandler) - + -- Verify child can track hover state (stored in StateManager for immediate mode) -- The actual hover detection happens in Element's event processing luaunit.assertEquals(child._elementMode, "immediate") @@ -123,9 +125,9 @@ end function TestMixedModeEvents:test_multipleImmediateChildrenHandleEventsIndependently() local button1Clicked = false local button2Clicked = false - + FlexLove.setMode("retained") - + -- Create retained parent local parent = FlexLove.new({ mode = "retained", @@ -135,10 +137,10 @@ function TestMixedModeEvents:test_multipleImmediateChildrenHandleEventsIndepende flexDirection = "horizontal", gap = 10, }) - + FlexLove.setMode("immediate") FlexLove.beginFrame() - + -- Create two immediate button children local button1 = FlexLove.new({ mode = "immediate", @@ -152,7 +154,7 @@ function TestMixedModeEvents:test_multipleImmediateChildrenHandleEventsIndepende end end, }) - + local button2 = FlexLove.new({ mode = "immediate", id = "button2", @@ -165,28 +167,31 @@ function TestMixedModeEvents:test_multipleImmediateChildrenHandleEventsIndepende end end, }) - + FlexLove.endFrame() - + -- Verify buttons are positioned correctly luaunit.assertEquals(button1.x, 0) luaunit.assertEquals(button2.x, 110) -- 100 + 10 gap - + -- Simulate clicking button1 if button1.onEvent then button1.onEvent(button1, { type = "release", x = 50, y = 25, button = 1 }) end - + luaunit.assertTrue(button1Clicked) luaunit.assertFalse(button2Clicked) - + -- Simulate clicking button2 if button2.onEvent then button2.onEvent(button2, { type = "release", x = 150, y = 25, button = 1 }) end - + luaunit.assertTrue(button1Clicked) luaunit.assertTrue(button2Clicked) end -os.exit(luaunit.LuaUnit.run()) +-- Run tests +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/retained_in_immediate_test.lua b/testing/__tests__/retained_in_immediate_test.lua new file mode 100644 index 0000000..b53d35b --- /dev/null +++ b/testing/__tests__/retained_in_immediate_test.lua @@ -0,0 +1,193 @@ +--[[ + Test: Retained Elements in Immediate Mode (No Duplication) + + This test verifies that retained-mode elements don't get recreated + when the overall application is in immediate mode. +]] + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") +local Color = require("modules.Color") + +TestRetainedInImmediateMode = {} + +function TestRetainedInImmediateMode:setUp() + -- Initialize in IMMEDIATE mode + FlexLove.init({ immediateMode = true }) +end + +function TestRetainedInImmediateMode:tearDown() + if FlexLove.getMode() == "immediate" then + FlexLove.endFrame() + end + FlexLove.init({ immediateMode = false }) +end + +-- Test that top-level retained elements persist across frames +function TestRetainedInImmediateMode:test_topLevelRetainedElementPersists() + -- Helper function that creates elements (simulates user code called each frame) + local function createUI() + local backdrop = FlexLove.new({ + mode = "retained", + width = "100%", + height = "100%", + backgroundColor = Color.new(0.1, 0.2, 0.3, 0.5), + }) + return backdrop + end + + FlexLove.beginFrame() + + -- Frame 1: Create a retained element (no explicit ID) + local backdrop = createUI() + + local backdropId = backdrop.id + luaunit.assertNotNil(backdropId, "Backdrop should have auto-generated ID") + luaunit.assertEquals(backdrop._elementMode, "retained") + + FlexLove.endFrame() + + -- Frame 2: Call createUI() again (same function, same line numbers) + FlexLove.beginFrame() + + local backdrop2 = createUI() + + -- Should return the SAME element, not create a new one + luaunit.assertEquals(backdrop2.id, backdropId, "Should return existing element with same ID") + luaunit.assertEquals(backdrop2, backdrop, "Should return exact same element instance") + + FlexLove.endFrame() +end + +-- Test that retained elements with explicit IDs can be recreated +function TestRetainedInImmediateMode:test_explicitIdAllowsNewElements() + FlexLove.beginFrame() + + -- Create element with explicit ID + local element1 = FlexLove.new({ + id = "my_custom_id", + mode = "retained", + width = 100, + height = 100, + backgroundColor = Color.new(1, 0, 0, 1), + }) + + FlexLove.endFrame() + + FlexLove.beginFrame() + + -- Create another element with SAME explicit ID but different properties + -- This should create a NEW element (user controls uniqueness) + local element2 = FlexLove.new({ + id = "my_custom_id", + mode = "retained", + width = 200, -- Different properties + height = 200, + backgroundColor = Color.new(0, 1, 0, 1), + }) + + -- With explicit IDs, we allow duplicates (user responsibility) + luaunit.assertEquals(element2.id, "my_custom_id") + -- Properties should match NEW element, not old + luaunit.assertEquals(element2.width, 200) + + FlexLove.endFrame() +end + +-- Test that multiple retained elements persist independently +function TestRetainedInImmediateMode:test_multipleRetainedElementsPersist() + local function createUI() + local backdrop = FlexLove.new({ + mode = "retained", + width = "100%", + height = "100%", + }) + + local window = FlexLove.new({ + mode = "retained", + width = "90%", + height = "90%", + }) + + return backdrop, window + end + + FlexLove.beginFrame() + + local backdrop, window = createUI() + + local backdropId = backdrop.id + local windowId = window.id + + luaunit.assertNotEquals(backdropId, windowId, "Different elements should have different IDs") + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local backdrop2, window2 = createUI() + + -- Both should return existing elements + luaunit.assertEquals(backdrop2.id, backdropId) + luaunit.assertEquals(window2.id, windowId) + luaunit.assertEquals(backdrop2, backdrop) + luaunit.assertEquals(window2, window) + + FlexLove.endFrame() +end + +-- Test that retained children of retained parents persist +function TestRetainedInImmediateMode:test_retainedChildOfRetainedParentPersists() + local function createUI() + local parent = FlexLove.new({ + mode = "retained", + width = 400, + height = 400, + }) + + local child = FlexLove.new({ + mode = "retained", + parent = parent, + width = 100, + height = 100, + }) + + return parent, child + end + + FlexLove.beginFrame() + + local parent, child = createUI() + + local parentId = parent.id + local childId = child.id + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local parent2, child2 = createUI() + + -- Parent should be the same + luaunit.assertEquals(parent2.id, parentId) + luaunit.assertEquals(parent2, parent) + + -- Child should also be the same instance + luaunit.assertEquals(child2.id, childId, "Child ID should match") + luaunit.assertEquals(child2, child, "Child should be same instance") + + -- Child should still exist in parent's children + luaunit.assertEquals(#parent2.children, 1, "Parent should have exactly 1 child") + luaunit.assertEquals(parent2.children[1].id, childId) + + FlexLove.endFrame() +end + +-- Run tests +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/__tests__/retained_prop_stability_test.lua b/testing/__tests__/retained_prop_stability_test.lua new file mode 100644 index 0000000..2d7365f --- /dev/null +++ b/testing/__tests__/retained_prop_stability_test.lua @@ -0,0 +1,184 @@ +--[[ + Test: Retained Elements with Varying Props (ID Stability) + + This test verifies that retained-mode elements return the same instance + across frames even when props vary slightly (e.g., different Color instances). +]] + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local FlexLove = require("FlexLove") +local Color = require("modules.Color") + +TestRetainedPropStability = {} + +function TestRetainedPropStability:setUp() + -- Initialize in IMMEDIATE mode + FlexLove.init({ immediateMode = true }) +end + +function TestRetainedPropStability:tearDown() + if FlexLove.getMode() == "immediate" then + FlexLove.endFrame() + end + FlexLove.init({ immediateMode = false }) +end + +-- Test that retained elements persist despite creating new Color instances +function TestRetainedPropStability:test_retainedElementIgnoresColorInstanceChanges() + FlexLove.beginFrame() + + -- Frame 1: Create retained element with Color instance + local backdrop1 = FlexLove.new({ + mode = "retained", + width = "100%", + height = "100%", + backgroundColor = Color.new(1, 1, 1, 0.1), -- NEW Color instance + }) + + local id1 = backdrop1.id + + FlexLove.endFrame() + + -- Frame 2: Same props but NEW Color instance (common pattern in user code) + FlexLove.beginFrame() + + local backdrop2 = FlexLove.new({ + mode = "retained", + width = "100%", + height = "100%", + backgroundColor = Color.new(1, 1, 1, 0.1), -- NEW Color instance (different table) + }) + + -- Should return SAME element despite different Color instance + luaunit.assertEquals(backdrop2.id, id1, "ID should be stable across frames") + luaunit.assertEquals(backdrop2, backdrop1, "Should return same element instance") + + FlexLove.endFrame() +end + +-- Test that retained elements with complex props persist +function TestRetainedPropStability:test_retainedElementWithComplexProps() + local function createWindow() + return FlexLove.new({ + mode = "retained", + z = 100, + x = "5%", + y = "5%", + width = "90%", + height = "90%", + themeComponent = "framev3", + positioning = "flex", + flexDirection = "vertical", + justifySelf = "center", + justifyContent = "flex-start", + alignItems = "center", + scaleCorners = 3, + padding = { horizontal = "5%", vertical = "3%" }, + gap = 10, + }) + end + + FlexLove.beginFrame() + + local window1 = createWindow() + local id1 = window1.id + + FlexLove.endFrame() + + -- Frame 2: Same function, same props + FlexLove.beginFrame() + + local window2 = createWindow() + + -- Should return same element + luaunit.assertEquals(window2.id, id1) + luaunit.assertEquals(window2, window1) + + FlexLove.endFrame() +end + +-- Test that retained elements with backdrop blur persist +function TestRetainedPropStability:test_retainedElementWithBackdropBlur() + local function createBackdrop() + return FlexLove.new({ + mode = "retained", + width = "100%", + height = "100%", + backdropBlur = { radius = 10 }, -- Table prop + backgroundColor = Color.new(1, 1, 1, 0.1), + }) + end + + FlexLove.beginFrame() + + local backdrop1 = createBackdrop() + local id1 = backdrop1.id + + FlexLove.endFrame() + + -- Frame 2 + FlexLove.beginFrame() + + local backdrop2 = createBackdrop() + + -- Should return same element + luaunit.assertEquals(backdrop2.id, id1) + luaunit.assertEquals(backdrop2, backdrop1) + + FlexLove.endFrame() +end + +-- Test that multiple retained elements persist independently +function TestRetainedPropStability:test_multipleRetainedElementsWithVaryingProps() + local function createUI() + local backdrop = FlexLove.new({ + mode = "retained", + z = 50, + width = "100%", + height = "100%", + backdropBlur = { radius = 10 }, + backgroundColor = Color.new(1, 1, 1, 0.1), + }) + + local window = FlexLove.new({ + mode = "retained", + z = 100, + x = "5%", + y = "5%", + width = "90%", + height = "90%", + themeComponent = "framev3", + padding = { horizontal = "5%", vertical = "3%" }, + }) + + return backdrop, window + end + + FlexLove.beginFrame() + + local backdrop1, window1 = createUI() + local backdropId = backdrop1.id + local windowId = window1.id + + FlexLove.endFrame() + + -- Frame 2: New Color instances, new table instances for props + FlexLove.beginFrame() + + local backdrop2, window2 = createUI() + + -- Both should return existing elements + luaunit.assertEquals(backdrop2.id, backdropId) + luaunit.assertEquals(window2.id, windowId) + luaunit.assertEquals(backdrop2, backdrop1) + luaunit.assertEquals(window2, window1) + + FlexLove.endFrame() +end + +-- Run tests +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/runAll.lua b/testing/runAll.lua index 26700a7..4e70350 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -46,6 +46,9 @@ local testFiles = { "testing/__tests__/image_scaler_test.lua", "testing/__tests__/input_event_test.lua", "testing/__tests__/layout_engine_test.lua", + "testing/__tests__/mixed_mode_events_test.lua", + "testing/__tests__/mixed_mode_children_test.lua", + "testing/__tests__/retained_in_immediate_test.lua", "testing/__tests__/module_loader_test.lua", "testing/__tests__/ninepatch_test.lua", "testing/__tests__/performance_test.lua", @@ -58,7 +61,7 @@ local testFiles = { "testing/__tests__/utils_test.lua", "testing/__tests__/calc_test.lua", -- Feature/Integration tests - --"testing/__tests__/critical_failures_test.lua", + "testing/__tests__/critical_failures_test.lua", "testing/__tests__/flexlove_test.lua", "testing/__tests__/touch_events_test.lua", }