diff --git a/.gitignore b/.gitignore index bae1536..1500ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ themes/metal/ themes/space/ .DS_STORE tasks +testoutput.txt diff --git a/FlexLove.lua b/FlexLove.lua index 1fe5922..ae75d11 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -583,7 +583,7 @@ function Gui.new(props) -- Immediate mode: generate ID if not provided if not props.id then - props.id = StateManager.generateID(props) + props.id = StateManager.generateID(props, props.parent) end -- Get or create state for this element diff --git a/README.md b/README.md index 3d0fc6c..ec50423 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This library is under active development. While many features are functional, so ### Coming Soon The following features are currently being actively developed: +- **Animations**: Simple to use animations for UI transitions and effects - **Generic Image Support**: Enhanced image rendering capabilities and utilities ## Features diff --git a/modules/Element.lua b/modules/Element.lua index 77d5458..d518a57 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -191,7 +191,7 @@ function Element.new(props) -- Auto-generate ID in immediate mode if not provided if Gui._immediateMode and (not props.id or props.id == "") then - self.id = StateManager.generateID(props) + self.id = StateManager.generateID(props, props.parent) else self.id = props.id or "" end @@ -346,6 +346,36 @@ function Element.new(props) -- Scroll state for text overflow self._textScrollX = 0 -- Horizontal scroll offset in pixels + + -- Restore state from StateManager in immediate mode + if Gui._immediateMode and self._stateId then + local state = StateManager.getState(self._stateId) + if state then + -- Restore focus state + if state._focused then + self._focused = true + Gui._focusedElement = self + end + + -- Restore text buffer (prefer state over props for immediate mode) + if state._textBuffer and state._textBuffer ~= "" then + self._textBuffer = state._textBuffer + end + + -- Restore cursor position + if state._cursorPosition then + self._cursorPosition = state._cursorPosition + end + + -- Restore selection + if state._selectionStart then + self._selectionStart = state._selectionStart + end + if state._selectionEnd then + self._selectionEnd = state._selectionEnd + end + end + end end -- Set parent first so it's available for size calculations @@ -396,7 +426,18 @@ function Element.new(props) } end - self.text = props.text + -- For editable elements, default text to empty string if not provided + if self.editable and props.text == nil then + self.text = "" + else + self.text = props.text + end + + -- Sync self.text with restored _textBuffer for editable elements in immediate mode + if self.editable and Gui._immediateMode and self._textBuffer then + self.text = self._textBuffer + end + self.textAlign = props.textAlign or TextAlign.START -- Image properties @@ -4068,8 +4109,9 @@ function Element:_validateCursorPosition() if not self.editable then return end - local textLength = utf8.len(self._textBuffer or "") - self._cursorPosition = math.max(0, math.min(self._cursorPosition, textLength)) + local textLength = utf8.len(self._textBuffer or "") or 0 + local cursorPos = tonumber(self._cursorPosition) or 0 + self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) end --- Reset cursor blink (show cursor immediately) @@ -4239,6 +4281,10 @@ function Element:deleteSelection() self:clearSelection() self._cursorPosition = startPos self:_validateCursorPosition() + + -- Save state to StateManager in immediate mode + self:_saveEditableState() + return true end @@ -4272,6 +4318,9 @@ function Element:focus() if self.onFocus then self.onFocus(self) end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end --- Remove focus from this element @@ -4291,6 +4340,9 @@ function Element:blur() if self.onBlur then self.onBlur(self) end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end --- Check if this element is focused @@ -4302,6 +4354,21 @@ function Element:isFocused() return self._focused == true end +--- Save editable element state to StateManager (for immediate mode) +function Element:_saveEditableState() + if not self.editable or not self._stateId or not Gui._immediateMode then + return + end + + StateManager.updateState(self._stateId, { + _focused = self._focused, + _textBuffer = self._textBuffer, + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + }) +end + -- ==================== -- Input Handling - Text Buffer Management -- ==================== @@ -4329,6 +4396,9 @@ function Element:setText(text) self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping self:_updateAutoGrowHeight() -- Then update height based on new content self:_validateCursorPosition() + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end --- Insert text at position @@ -4369,6 +4439,9 @@ function Element:insertText(text, position) self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping self:_updateAutoGrowHeight() -- Then update height based on new content self:_validateCursorPosition() + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end ---@param startPos number -- Start position (inclusive) @@ -4402,6 +4475,9 @@ function Element:deleteText(startPos, endPos) self:_markTextDirty() self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping self:_updateAutoGrowHeight() -- Then update height based on new content + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end --- Replace text in range @@ -5272,6 +5348,9 @@ function Element:textinput(text) if self.onTextChange and self._textBuffer ~= oldText then self.onTextChange(self, self._textBuffer, oldText) end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end --- Handle key press (special keys) @@ -5510,6 +5589,9 @@ function Element:keypressed(key, scancode, isrepeat) end self:_resetCursorBlink() end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end return Element diff --git a/modules/GuiState.lua b/modules/GuiState.lua index a0175a5..b2d04d7 100644 --- a/modules/GuiState.lua +++ b/modules/GuiState.lua @@ -131,8 +131,8 @@ function GuiState.getTopElementAt(x, y) local function findInteractiveAncestor(elem) local current = elem while current do - -- An element is interactive if it has a callback or themeComponent - if current.callback or current.themeComponent then + -- An element is interactive if it has a callback, themeComponent, or is editable + if current.callback or current.themeComponent or current.editable then return current end current = current.parent diff --git a/modules/StateManager.lua b/modules/StateManager.lua index 2e18a52..531895c 100644 --- a/modules/StateManager.lua +++ b/modules/StateManager.lua @@ -69,6 +69,13 @@ local function hashProps(props, visited, depth) onTextChange = true, onEnter = true, userdata = true, + -- Dynamic input/state properties that should not affect ID stability + text = true, -- Text content changes as user types + placeholder = true, -- Placeholder text is presentational + editable = true, -- Editable state can be toggled dynamically + selectOnFocus = true, -- Input behavior flag + autoGrow = true, -- Auto-grow behavior flag + passwordMode = true, -- Password mode can be toggled } -- Collect and sort keys for consistent ordering @@ -96,8 +103,9 @@ end --- Generate a unique ID from call site and properties ---@param props table|nil Optional properties to include in ID generation +---@param parent table|nil Optional parent element for tree-based ID generation ---@return string -function StateManager.generateID(props) +function StateManager.generateID(props, parent) -- Get call stack information local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID @@ -109,16 +117,43 @@ function StateManager.generateID(props) local source = info.source or "unknown" local line = info.currentline or 0 - -- Create ID from source file and line number - local baseID = source:match("([^/\\]+)$") or source -- Get filename - baseID = baseID:gsub("%.lua$", "") -- Remove .lua extension - local locationKey = baseID .. "_L" .. line + -- Create base location key from source file and line number + local filename = source:match("([^/\\]+)$") or source -- Get filename + filename = filename:gsub("%.lua$", "") -- Remove .lua extension + local locationKey = filename .. "_L" .. line + -- 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 {}) + + -- 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 + local propHash = hashProps(props) + if propHash ~= "" then + -- Use first 8 chars of a simple hash + local hash = 0 + for i = 1, #propHash do + hash = (hash * 31 + string.byte(propHash, i)) % 1000000 + end + baseID = baseID .. "_" .. hash + end + end + + return baseID + end + + -- No parent (top-level element): use call-site counter approach -- Track how many elements have been created at this location callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1 local instanceNum = callSiteCounters[locationKey] - baseID = locationKey + local baseID = locationKey -- Add instance number if multiple elements created at same location (e.g., in loops) if instanceNum > 1 then diff --git a/testing/__tests__/16_event_system_tests.lua.bak b/testing/__tests__/16_event_system_tests.lua.bak deleted file mode 100644 index 2246e5c..0000000 --- a/testing/__tests__/16_event_system_tests.lua.bak +++ /dev/null @@ -1,367 +0,0 @@ --- Event System Tests --- Tests for the enhanced callback system with InputEvent objects - -package.path = package.path .. ";?.lua" - -local lu = require("testing.luaunit") -require("testing.loveStub") -- Required to mock LOVE functions -local FlexLove = require("FlexLove") -local Gui = FlexLove.Gui - -TestEventSystem = {} - -function TestEventSystem:setUp() - -- Clear all keyboard modifier states at start of each test - love.keyboard.setDown("lshift", false) - love.keyboard.setDown("rshift", false) - love.keyboard.setDown("lctrl", false) - love.keyboard.setDown("rctrl", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("ralt", false) - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("rgui", false) - - Gui.init({ baseScale = { width = 1920, height = 1080 } }) - love.window.setMode(1920, 1080) - Gui.resize(1920, 1080) -- Recalculate scale factors after setMode -end - -function TestEventSystem:tearDown() - -- Clean up after each test - Gui.destroy() - -- Reset keyboard state - love.keyboard.setDown("lshift", false) - love.keyboard.setDown("rshift", false) - love.keyboard.setDown("lctrl", false) - love.keyboard.setDown("rctrl", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("ralt", false) -end - --- Test 1: Event object structure -function TestEventSystem:test_event_object_has_required_fields() - local eventReceived = nil - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - eventReceived = event - end, - }) - - -- Simulate mouse press and release - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - -- Verify event object structure - lu.assertNotNil(eventReceived, "Event should be received") - lu.assertNotNil(eventReceived.type, "Event should have type field") - lu.assertNotNil(eventReceived.button, "Event should have button field") - lu.assertNotNil(eventReceived.x, "Event should have x field") - lu.assertNotNil(eventReceived.y, "Event should have y field") - lu.assertNotNil(eventReceived.modifiers, "Event should have modifiers field") - lu.assertNotNil(eventReceived.clickCount, "Event should have clickCount field") - lu.assertNotNil(eventReceived.timestamp, "Event should have timestamp field") -end - --- Test 2: Left click event -function TestEventSystem:test_left_click_generates_click_event() - local eventsReceived = {} - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - table.insert(eventsReceived, { type = event.type, button = event.button }) - end, - }) - - -- Simulate left click - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - -- Should receive press, click, and release events - lu.assertTrue(#eventsReceived >= 2, "Should receive at least 2 events") - - -- Check for click event - local hasClickEvent = false - for _, evt in ipairs(eventsReceived) do - if evt.type == "click" and evt.button == 1 then - hasClickEvent = true - break - end - end - lu.assertTrue(hasClickEvent, "Should receive click event for left button") -end - --- Test 3: Right click event -function TestEventSystem:test_right_click_generates_rightclick_event() - local eventsReceived = {} - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - table.insert(eventsReceived, { type = event.type, button = event.button }) - end, - }) - - -- Simulate right click - love.mouse.setPosition(150, 150) - love.mouse.setDown(2, true) - button:update(0.016) - - love.mouse.setDown(2, false) - button:update(0.016) - - -- Check for rightclick event - local hasRightClickEvent = false - for _, evt in ipairs(eventsReceived) do - if evt.type == "rightclick" and evt.button == 2 then - hasRightClickEvent = true - break - end - end - lu.assertTrue(hasRightClickEvent, "Should receive rightclick event for right button") -end - --- Test 4: Middle click event -function TestEventSystem:test_middle_click_generates_middleclick_event() - local eventsReceived = {} - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - table.insert(eventsReceived, { type = event.type, button = event.button }) - end, - }) - - -- Simulate middle click - love.mouse.setPosition(150, 150) - love.mouse.setDown(3, true) - button:update(0.016) - - love.mouse.setDown(3, false) - button:update(0.016) - - -- Check for middleclick event - local hasMiddleClickEvent = false - for _, evt in ipairs(eventsReceived) do - if evt.type == "middleclick" and evt.button == 3 then - hasMiddleClickEvent = true - break - end - end - lu.assertTrue(hasMiddleClickEvent, "Should receive middleclick event for middle button") -end - --- Test 5: Modifier keys detection -function TestEventSystem:test_modifier_keys_are_detected() - local eventReceived = nil - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - if event.type == "click" then - eventReceived = event - end - end, - }) - - -- Simulate shift + click - love.keyboard.setDown("lshift", true) - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - lu.assertNotNil(eventReceived, "Should receive click event") - lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected") -end - --- Test 6: Double click detection -function TestEventSystem:test_double_click_increments_click_count() - local clickEvents = {} - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - if event.type == "click" then - table.insert(clickEvents, event.clickCount) - end - end, - }) - - -- Simulate first click - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - love.mouse.setDown(1, false) - button:update(0.016) - - -- Simulate second click quickly (double-click) - love.timer.setTime(love.timer.getTime() + 0.1) -- 100ms later - love.mouse.setDown(1, true) - button:update(0.016) - love.mouse.setDown(1, false) - button:update(0.016) - - lu.assertEquals(#clickEvents, 2, "Should receive 2 click events") - lu.assertEquals(clickEvents[1], 1, "First click should have clickCount = 1") - lu.assertEquals(clickEvents[2], 2, "Second click should have clickCount = 2") -end - --- Test 7: Press and release events -function TestEventSystem:test_press_and_release_events_are_fired() - local eventsReceived = {} - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - table.insert(eventsReceived, event.type) - end, - }) - - -- Simulate click - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - -- Should receive press, click, and release - lu.assertTrue(#eventsReceived >= 2, "Should receive multiple events") - - local hasPress = false - local hasRelease = false - for _, eventType in ipairs(eventsReceived) do - if eventType == "press" then - hasPress = true - end - if eventType == "release" then - hasRelease = true - end - end - - lu.assertTrue(hasPress, "Should receive press event") - lu.assertTrue(hasRelease, "Should receive release event") -end - --- Test 8: Mouse position in event -function TestEventSystem:test_event_contains_mouse_position() - local eventReceived = nil - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - if event.type == "click" then - eventReceived = event - end - end, - }) - - -- Simulate click at specific position - local mouseX, mouseY = 175, 125 - love.mouse.setPosition(mouseX, mouseY) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - lu.assertNotNil(eventReceived, "Should receive click event") - lu.assertEquals(eventReceived.x, mouseX, "Event should contain correct mouse X position") - lu.assertEquals(eventReceived.y, mouseY, "Event should contain correct mouse Y position") -end - --- Test 9: No callback when mouse outside element -function TestEventSystem:test_no_callback_when_clicking_outside_element() - local callbackCalled = false - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - callbackCalled = true - end, - }) - - -- Click outside element - love.mouse.setPosition(50, 50) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - lu.assertFalse(callbackCalled, "Callback should not be called when clicking outside element") -end - --- Test 10: Multiple modifiers -function TestEventSystem:test_multiple_modifiers_detected() - local eventReceived = nil - - local button = Gui.new({ - x = 100, - y = 100, - width = 200, - height = 100, - callback = function(element, event) - if event.type == "click" then - eventReceived = event - end - end, - }) - - -- Simulate shift + ctrl + click - love.keyboard.setDown("lshift", true) - love.keyboard.setDown("lctrl", true) - love.mouse.setPosition(150, 150) - love.mouse.setDown(1, true) - button:update(0.016) - - love.mouse.setDown(1, false) - button:update(0.016) - - lu.assertNotNil(eventReceived, "Should receive click event") - lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected") - lu.assertTrue(eventReceived.modifiers.ctrl, "Ctrl modifier should be detected") -end - -print("Running Event System Tests...") -lu.LuaUnit.run() diff --git a/testing/__tests__/24_keyboard_input_tests.lua.bak b/testing/__tests__/24_keyboard_input_tests.lua.bak deleted file mode 100644 index 99151ba..0000000 --- a/testing/__tests__/24_keyboard_input_tests.lua.bak +++ /dev/null @@ -1,733 +0,0 @@ -local lu = require("testing.luaunit") -local FlexLove = require("FlexLove") -local Gui = FlexLove.Gui -local Element = FlexLove.Element - -TestKeyboardInput = {} - --- Helper function to ensure clean keyboard state -local function clearModifierKeys() - love.keyboard.setDown("lshift", false) - love.keyboard.setDown("rshift", false) - love.keyboard.setDown("lctrl", false) - love.keyboard.setDown("rctrl", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("ralt", false) - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("rgui", false) -end - -function TestKeyboardInput:setUp() - -- Clear all keyboard modifier states at start of each test - if love.keyboard.isDown("lgui", "rgui", "lalt", "ralt", "lctrl", "rctrl") then - print("WARNING: Modifier keys were down at start of test!") - end - love.keyboard.setDown("lshift", false) - love.keyboard.setDown("rshift", false) - love.keyboard.setDown("lctrl", false) - love.keyboard.setDown("rctrl", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("ralt", false) - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("rgui", false) - - Gui.init({ baseScale = { width = 1920, height = 1080 } }) -end - -function TestKeyboardInput:tearDown() - -- Clear all keyboard modifier states - love.keyboard.setDown("lshift", false) - love.keyboard.setDown("rshift", false) - love.keyboard.setDown("lctrl", false) - love.keyboard.setDown("rctrl", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("ralt", false) - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("rgui", false) - - Gui.destroy() -end - --- ==================== --- Focus Management Tests --- ==================== - -function TestKeyboardInput:testFocusEditable() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - lu.assertFalse(input:isFocused()) - - input:focus() - - lu.assertTrue(input:isFocused()) - lu.assertEquals(Gui._focusedElement, input) -end - -function TestKeyboardInput:testBlurEditable() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - lu.assertTrue(input:isFocused()) - - input:blur() - - lu.assertFalse(input:isFocused()) - lu.assertNil(Gui._focusedElement) -end - -function TestKeyboardInput:testFocusSwitching() - local input1 = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Input 1", - }) - - local input2 = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Input 2", - }) - - input1:focus() - lu.assertTrue(input1:isFocused()) - lu.assertFalse(input2:isFocused()) - - input2:focus() - lu.assertFalse(input1:isFocused()) - lu.assertTrue(input2:isFocused()) - lu.assertEquals(Gui._focusedElement, input2) -end - -function TestKeyboardInput:testSelectOnFocus() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - selectOnFocus = true, - }) - - lu.assertFalse(input:hasSelection()) - - input:focus() - - lu.assertTrue(input:hasSelection()) - local startPos, endPos = input:getSelection() - lu.assertEquals(startPos, 0) - lu.assertEquals(endPos, 11) -- Length of "Hello World" -end - --- ==================== --- Text Input Tests --- ==================== - -function TestKeyboardInput:testTextInput() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "", - }) - - input:focus() - input:textinput("H") - input:textinput("i") - - lu.assertEquals(input:getText(), "Hi") - lu.assertEquals(input._cursorPosition, 2) -end - -function TestKeyboardInput:testTextInputAtPosition() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(2) -- After "He" - input:textinput("X") - - lu.assertEquals(input:getText(), "HeXllo") - lu.assertEquals(input._cursorPosition, 3) -end - -function TestKeyboardInput:testTextInputWithSelection() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setSelection(0, 5) -- Select "Hello" - input:textinput("Hi") - - lu.assertEquals(input:getText(), "Hi World") - lu.assertEquals(input._cursorPosition, 2) - lu.assertFalse(input:hasSelection()) -end - -function TestKeyboardInput:testMaxLengthConstraint() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "", - maxLength = 5, - }) - - input:focus() - input:textinput("Hello") - lu.assertEquals(input:getText(), "Hello") - - input:textinput("X") -- Should not be added - lu.assertEquals(input:getText(), "Hello") -end - --- ==================== --- Backspace/Delete Tests --- ==================== - -function TestKeyboardInput:testBackspace() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(5) -- At end - input:keypressed("backspace", "backspace", false) - - lu.assertEquals(input:getText(), "Hell") - lu.assertEquals(input._cursorPosition, 4) -end - -function TestKeyboardInput:testBackspaceAtStart() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(0) -- At start - input:keypressed("backspace", "backspace", false) - - lu.assertEquals(input:getText(), "Hello") -- No change - lu.assertEquals(input._cursorPosition, 0) -end - -function TestKeyboardInput:testDelete() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(0) -- At start - input:keypressed("delete", "delete", false) - - lu.assertEquals(input:getText(), "ello") - lu.assertEquals(input._cursorPosition, 0) -end - -function TestKeyboardInput:testDeleteAtEnd() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(5) -- At end - input:keypressed("delete", "delete", false) - - lu.assertEquals(input:getText(), "Hello") -- No change - lu.assertEquals(input._cursorPosition, 5) -end - -function TestKeyboardInput:testBackspaceWithSelection() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setSelection(0, 5) -- Select "Hello" - input:keypressed("backspace", "backspace", false) - - lu.assertEquals(input:getText(), " World") - lu.assertEquals(input._cursorPosition, 0) - lu.assertFalse(input:hasSelection()) -end - --- ==================== --- Cursor Movement Tests --- ==================== - -function TestKeyboardInput:testArrowLeft() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(5) - input:keypressed("left", "left", false) - - lu.assertEquals(input._cursorPosition, 4) -end - -function TestKeyboardInput:testArrowRight() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(0) - input:keypressed("right", "right", false) - - lu.assertEquals(input._cursorPosition, 1) -end - -function TestKeyboardInput:testHomeKey() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(5) - input:keypressed("home", "home", false) - - lu.assertEquals(input._cursorPosition, 0) -end - -function TestKeyboardInput:testEndKey() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(0) - input:keypressed("end", "end", false) - - lu.assertEquals(input._cursorPosition, 5) -end - --- ==================== --- Modifier Key Tests --- ==================== - -function TestKeyboardInput:testSuperLeftMovesToStart() - clearModifierKeys() - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setCursorPosition(8) -- Middle of text - - -- Simulate Super (Cmd/Win) key being held - love.keyboard.setDown("lgui", true) - input:keypressed("left", "left", false) - love.keyboard.setDown("lgui", false) - - lu.assertEquals(input._cursorPosition, 0) -end - -function TestKeyboardInput:testSuperRightMovesToEnd() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setCursorPosition(3) -- Middle of text - - -- Ensure clean state before setting modifier - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("lctrl", false) - - -- Simulate Super (Cmd/Win) key being held - love.keyboard.setDown("lgui", true) - input:keypressed("right", "right", false) - love.keyboard.setDown("lgui", false) - - lu.assertEquals(input._cursorPosition, 11) -- End of "Hello World" -end - -function TestKeyboardInput:testAltLeftMovesByWord() - clearModifierKeys() - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World Test", - }) - - input:focus() - input:setCursorPosition(16) -- End of text - - -- Simulate Alt key being held and move left by word - love.keyboard.setDown("lalt", true) - input:keypressed("left", "left", false) - love.keyboard.setDown("lalt", false) - - lu.assertEquals(input._cursorPosition, 12) -- Start of "Test" -end - -function TestKeyboardInput:testAltRightMovesByWord() - clearModifierKeys() - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World Test", - }) - - input:focus() - input:setCursorPosition(0) -- Start of text - - -- Simulate Alt key being held and move right by word - love.keyboard.setDown("lalt", true) - input:keypressed("right", "right", false) - love.keyboard.setDown("lalt", false) - - lu.assertEquals(input._cursorPosition, 6) -- After "Hello " -end - -function TestKeyboardInput:testAltLeftMultipleWords() - clearModifierKeys() - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "The quick brown fox", - }) - - input:focus() - input:setCursorPosition(19) -- End of text - - -- Move left by word three times - love.keyboard.setDown("lalt", true) - input:keypressed("left", "left", false) -- to "fox" - input:keypressed("left", "left", false) -- to "brown" - input:keypressed("left", "left", false) -- to "quick" - love.keyboard.setDown("lalt", false) - - lu.assertEquals(input._cursorPosition, 4) -- Start of "quick" -end - -function TestKeyboardInput:testAltRightMultipleWords() - -- Ensure clean keyboard state - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("lctrl", false) - -local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "The quick brown fox", - }) - - input:focus() - input:setCursorPosition(0) -- Start of text - - -- Move right by word three times - love.keyboard.setDown("lalt", true) - input:keypressed("right", "right", false) -- after "The " - input:keypressed("right", "right", false) -- after "quick " - input:keypressed("right", "right", false) -- after "brown " - love.keyboard.setDown("lalt", false) - - lu.assertEquals(input._cursorPosition, 16) -- After "brown " -end - -function TestKeyboardInput:testSuperLeftWithSelection() - -- Ensure clean keyboard state - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("lctrl", false) - -local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setSelection(3, 8) - lu.assertTrue(input:hasSelection()) - - -- Super+Left should move to start and clear selection - love.keyboard.setDown("lgui", true) - input:keypressed("left", "left", false) - love.keyboard.setDown("lgui", false) - - lu.assertEquals(input._cursorPosition, 0) - lu.assertFalse(input:hasSelection()) -end - -function TestKeyboardInput:testSuperRightWithSelection() - -- Ensure clean keyboard state - love.keyboard.setDown("lgui", false) - love.keyboard.setDown("lalt", false) - love.keyboard.setDown("lctrl", false) - -local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello World", - }) - - input:focus() - input:setSelection(3, 8) - lu.assertTrue(input:hasSelection()) - - -- Super+Right should move to end and clear selection - love.keyboard.setDown("lgui", true) - input:keypressed("right", "right", false) - love.keyboard.setDown("lgui", false) - - lu.assertEquals(input._cursorPosition, 11) - lu.assertFalse(input:hasSelection()) -end - -function TestKeyboardInput:testEscapeClearsSelection() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:selectAll() - lu.assertTrue(input:hasSelection()) - - input:keypressed("escape", "escape", false) - - lu.assertFalse(input:hasSelection()) - lu.assertTrue(input:isFocused()) -- Still focused -end - -function TestKeyboardInput:testEscapeBlurs() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - lu.assertTrue(input:isFocused()) - - input:keypressed("escape", "escape", false) - - lu.assertFalse(input:isFocused()) -end - --- ==================== --- Callback Tests --- ==================== - -function TestKeyboardInput:testOnTextChangeCallback() - local changeCount = 0 - local oldTextValue = nil - local newTextValue = nil - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - onTextChange = function(element, newText, oldText) - changeCount = changeCount + 1 - newTextValue = newText - oldTextValue = oldText - end, - }) - - input:focus() - input:textinput("X") - - lu.assertEquals(changeCount, 1) - lu.assertEquals(oldTextValue, "Hello") - lu.assertEquals(newTextValue, "HelloX") -end - -function TestKeyboardInput:testOnTextInputCallback() - local inputCount = 0 - local lastChar = nil - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "", - onTextInput = function(element, text) - inputCount = inputCount + 1 - lastChar = text - end, - }) - - input:focus() - input:textinput("A") - input:textinput("B") - - lu.assertEquals(inputCount, 2) - lu.assertEquals(lastChar, "B") -end - -function TestKeyboardInput:testOnEnterCallback() - local enterCalled = false - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - multiline = false, - text = "Hello", - onEnter = function(element) - enterCalled = true - end, - }) - - input:focus() - input:keypressed("return", "return", false) - - lu.assertTrue(enterCalled) -end - -function TestKeyboardInput:testOnFocusCallback() - local focusCalled = false - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - onFocus = function(element) - focusCalled = true - end, - }) - - input:focus() - - lu.assertTrue(focusCalled) -end - -function TestKeyboardInput:testOnBlurCallback() - local blurCalled = false - - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - onBlur = function(element) - blurCalled = true - end, - }) - - input:focus() - input:blur() - - lu.assertTrue(blurCalled) -end - --- ==================== --- GUI-level Input Forwarding Tests --- ==================== - -function TestKeyboardInput:testGuiTextinputForwarding() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "", - }) - - input:focus() - Gui.textinput("A") - - lu.assertEquals(input:getText(), "A") -end - -function TestKeyboardInput:testGuiKeypressedForwarding() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - input:focus() - input:setCursorPosition(5) - Gui.keypressed("backspace", "backspace", false) - - lu.assertEquals(input:getText(), "Hell") -end - -function TestKeyboardInput:testGuiInputWithoutFocus() - local input = Element.new({ - width = 200, - height = 40, - editable = true, - text = "Hello", - }) - - -- No focus - Gui.textinput("X") - - lu.assertEquals(input:getText(), "Hello") -- No change -end - -lu.LuaUnit.run() diff --git a/testing/__tests__/33_input_field_tests.lua b/testing/__tests__/33_input_field_tests.lua index d2331b0..28a63f4 100644 --- a/testing/__tests__/33_input_field_tests.lua +++ b/testing/__tests__/33_input_field_tests.lua @@ -11,6 +11,7 @@ _G.love = loveStub -- Load FlexLove after setting up love stub local FlexLove = require("FlexLove") +local StateManager = require("modules.StateManager") -- Test fixtures local testElement @@ -54,6 +55,9 @@ function TestInputField:tearDown() love.keyboard.setDown("lgui", false) love.keyboard.setDown("rgui", false) + -- Clear StateManager to prevent test contamination + StateManager.reset() + testElement = nil FlexLove.Gui.topElements = {} FlexLove.Gui._focusedElement = nil diff --git a/testing/__tests__/34_password_mode_tests.lua b/testing/__tests__/34_password_mode_tests.lua index 879632a..cbb1a0c 100644 --- a/testing/__tests__/34_password_mode_tests.lua +++ b/testing/__tests__/34_password_mode_tests.lua @@ -5,12 +5,14 @@ local lu = require("testing.luaunit") local loveStub = require("testing.loveStub") +local utf8 = require("utf8") -- Setup LÖVE environment _G.love = loveStub -- Load FlexLove after setting up love stub local FlexLove = require("FlexLove") +local StateManager = require("modules.StateManager") -- Test fixtures local testElement @@ -27,7 +29,7 @@ function TestPasswordMode:setUp() love.keyboard.setDown("ralt", false) love.keyboard.setDown("lgui", false) love.keyboard.setDown("rgui", false) - + -- Reset FlexLove state FlexLove.Gui.topElements = {} FlexLove.Gui._focusedElement = nil @@ -54,7 +56,10 @@ function TestPasswordMode:tearDown() love.keyboard.setDown("ralt", false) love.keyboard.setDown("lgui", false) love.keyboard.setDown("rgui", false) - + + -- Clear StateManager to prevent test contamination + StateManager.reset() + testElement = nil FlexLove.Gui.topElements = {} FlexLove.Gui._focusedElement = nil @@ -80,7 +85,7 @@ function TestPasswordMode:testPasswordModeDefaultIsFalse() editable = true, text = "Normal text", }) - + lu.assertFalse(normalElement.passwordMode or false) end @@ -97,7 +102,7 @@ function TestPasswordMode:testPasswordModeIsSingleLineOnly() passwordMode = true, text = "Password", }) - + -- Based on the constraint, multiline should be set to false lu.assertFalse(multilinePassword.multiline) end @@ -115,7 +120,7 @@ function TestPasswordMode:testActualTextContentRemains() testElement:insertText("r") testElement:insertText("e") testElement:insertText("t") - + -- Verify actual text buffer contains the real text lu.assertEquals(testElement._textBuffer, "Secret") lu.assertEquals(testElement:getText(), "Secret") @@ -124,7 +129,7 @@ end function TestPasswordMode:testPasswordTextIsNotModified() -- Set initial text testElement:setText("MyPassword123") - + -- The actual buffer should contain the real password lu.assertEquals(testElement._textBuffer, "MyPassword123") lu.assertEquals(testElement:getText(), "MyPassword123") @@ -137,15 +142,15 @@ end function TestPasswordMode:testCursorPositionWithPasswordMode() testElement:setText("test") testElement:focus() - + -- Set cursor to end testElement:setCursorPosition(4) lu.assertEquals(testElement._cursorPosition, 4) - + -- Move cursor to middle testElement:setCursorPosition(2) lu.assertEquals(testElement._cursorPosition, 2) - + -- Move cursor to start testElement:setCursorPosition(0) lu.assertEquals(testElement._cursorPosition, 0) @@ -155,15 +160,15 @@ function TestPasswordMode:testCursorMovementInPasswordField() testElement:setText("password") testElement:focus() testElement:setCursorPosition(0) - + -- Move right testElement:moveCursorBy(1) lu.assertEquals(testElement._cursorPosition, 1) - + -- Move right again testElement:moveCursorBy(1) lu.assertEquals(testElement._cursorPosition, 2) - + -- Move left testElement:moveCursorBy(-1) lu.assertEquals(testElement._cursorPosition, 1) @@ -176,13 +181,13 @@ end function TestPasswordMode:testInsertTextInPasswordMode() testElement:focus() testElement:setCursorPosition(0) - + testElement:insertText("a") lu.assertEquals(testElement._textBuffer, "a") - + testElement:insertText("b") lu.assertEquals(testElement._textBuffer, "ab") - + testElement:insertText("c") lu.assertEquals(testElement._textBuffer, "abc") end @@ -190,12 +195,12 @@ end function TestPasswordMode:testBackspaceInPasswordMode() testElement:setText("password") testElement:focus() - testElement:setCursorPosition(8) -- End of text - + testElement:setCursorPosition(8) -- End of text + -- Delete last character testElement:keypressed("backspace", nil, false) lu.assertEquals(testElement._textBuffer, "passwor") - + -- Delete another character testElement:keypressed("backspace", nil, false) lu.assertEquals(testElement._textBuffer, "passwo") @@ -204,12 +209,12 @@ end function TestPasswordMode:testDeleteInPasswordMode() testElement:setText("password") testElement:focus() - testElement:setCursorPosition(0) -- Start of text - + testElement:setCursorPosition(0) -- Start of text + -- Delete first character testElement:keypressed("delete", nil, false) lu.assertEquals(testElement._textBuffer, "assword") - + -- Delete another character testElement:keypressed("delete", nil, false) lu.assertEquals(testElement._textBuffer, "ssword") @@ -218,8 +223,8 @@ end function TestPasswordMode:testInsertTextAtPosition() testElement:setText("pass") testElement:focus() - testElement:setCursorPosition(2) -- Between 'pa' and 'ss' - + testElement:setCursorPosition(2) -- Between 'pa' and 'ss' + testElement:insertText("x") lu.assertEquals(testElement._textBuffer, "paxss") lu.assertEquals(testElement._cursorPosition, 3) @@ -232,10 +237,10 @@ end function TestPasswordMode:testTextSelectionInPasswordMode() testElement:setText("password") testElement:focus() - + -- Select from position 2 to 5 testElement:setSelection(2, 5) - + local selStart, selEnd = testElement:getSelection() lu.assertEquals(selStart, 2) lu.assertEquals(selEnd, 5) @@ -245,10 +250,10 @@ end function TestPasswordMode:testDeleteSelectionInPasswordMode() testElement:setText("password") testElement:focus() - + -- Select "sswo" (positions 2-6) testElement:setSelection(2, 6) - + -- Delete selection testElement:deleteSelection() lu.assertEquals(testElement._textBuffer, "pard") @@ -258,10 +263,10 @@ end function TestPasswordMode:testReplaceSelectionInPasswordMode() testElement:setText("password") testElement:focus() - + -- Select "sswo" (positions 2-6) testElement:setSelection(2, 6) - + -- Type new text (should replace selection) testElement:textinput("X") lu.assertEquals(testElement._textBuffer, "paXrd") @@ -270,9 +275,9 @@ end function TestPasswordMode:testSelectAllInPasswordMode() testElement:setText("secret") testElement:focus() - + testElement:selectAll() - + local selStart, selEnd = testElement:getSelection() lu.assertEquals(selStart, 0) lu.assertEquals(selEnd, 6) @@ -286,14 +291,14 @@ end function TestPasswordMode:testPasswordModeWithMaxLength() testElement.maxLength = 5 testElement:focus() - + testElement:insertText("1") testElement:insertText("2") testElement:insertText("3") testElement:insertText("4") testElement:insertText("5") - testElement:insertText("6") -- Should be rejected - + testElement:insertText("6") -- Should be rejected + lu.assertEquals(testElement._textBuffer, "12345") lu.assertEquals(utf8.len(testElement._textBuffer), 5) end @@ -309,11 +314,11 @@ function TestPasswordMode:testPasswordModeWithPlaceholder() placeholder = "Enter password", text = "", }) - + -- When empty and not focused, placeholder should be available lu.assertEquals(passwordWithPlaceholder.placeholder, "Enter password") lu.assertEquals(passwordWithPlaceholder._textBuffer, "") - + -- When text is added, actual text should be stored passwordWithPlaceholder:focus() passwordWithPlaceholder:insertText("secret") @@ -323,7 +328,7 @@ end function TestPasswordMode:testPasswordModeClearText() testElement:setText("password123") lu.assertEquals(testElement._textBuffer, "password123") - + -- Clear text testElement:setText("") lu.assertEquals(testElement._textBuffer, "") @@ -341,17 +346,17 @@ function TestPasswordMode:testPasswordModeToggle() passwordMode = false, text = "visible", }) - + lu.assertEquals(toggleElement._textBuffer, "visible") lu.assertFalse(toggleElement.passwordMode) - + -- Enable password mode toggleElement.passwordMode = true lu.assertTrue(toggleElement.passwordMode) - + -- Text buffer should remain unchanged lu.assertEquals(toggleElement._textBuffer, "visible") - + -- Disable password mode again toggleElement.passwordMode = false lu.assertFalse(toggleElement.passwordMode) @@ -364,14 +369,14 @@ end function TestPasswordMode:testPasswordModeWithUTF8Characters() testElement:focus() - + -- Insert UTF-8 characters testElement:insertText("h") testElement:insertText("é") testElement:insertText("l") testElement:insertText("l") testElement:insertText("ö") - + -- Text buffer should contain actual UTF-8 text lu.assertEquals(testElement._textBuffer, "héllö") lu.assertEquals(utf8.len(testElement._textBuffer), 5) @@ -380,17 +385,17 @@ end function TestPasswordMode:testPasswordModeCursorWithUTF8() testElement:setText("café") testElement:focus() - + -- Move cursor through UTF-8 text testElement:setCursorPosition(0) lu.assertEquals(testElement._cursorPosition, 0) - + testElement:moveCursorBy(1) lu.assertEquals(testElement._cursorPosition, 1) - + testElement:moveCursorBy(1) lu.assertEquals(testElement._cursorPosition, 2) - + testElement:setCursorPosition(4) lu.assertEquals(testElement._cursorPosition, 4) end @@ -414,7 +419,7 @@ end function TestPasswordMode:testPasswordModeWithLongPassword() local longPassword = string.rep("a", 100) testElement:setText(longPassword) - + lu.assertEquals(testElement._textBuffer, longPassword) lu.assertEquals(utf8.len(testElement._textBuffer), 100) end @@ -422,17 +427,12 @@ end function TestPasswordMode:testPasswordModeSetTextUpdatesBuffer() testElement:setText("initial") lu.assertEquals(testElement._textBuffer, "initial") - + testElement:setText("updated") lu.assertEquals(testElement._textBuffer, "updated") - + testElement:setText("") lu.assertEquals(testElement._textBuffer, "") end --- Run tests if executed directly -if arg and arg[0] and arg[0]:match("34_password_mode_tests%.lua$") then - os.exit(lu.LuaUnit.run()) -end - -return TestPasswordMode +lu.LuaUnit.run() diff --git a/testing/__tests__/35_stable_id_generation_tests.lua b/testing/__tests__/35_stable_id_generation_tests.lua new file mode 100644 index 0000000..d1fb163 --- /dev/null +++ b/testing/__tests__/35_stable_id_generation_tests.lua @@ -0,0 +1,423 @@ +-- Test: Stable ID Generation in Immediate Mode +package.path = package.path .. ";./?.lua;./game/?.lua;./game/utils/?.lua;./game/components/?.lua;./game/systems/?.lua" + +local luaunit = require("testing.luaunit") +require("testing.loveStub") -- Required to mock LOVE functions +local FlexLove = require("FlexLove") + +local Gui = FlexLove.Gui + +TestStableIDGeneration = {} + +function TestStableIDGeneration:setUp() + -- Reset GUI state + if Gui.destroy then + Gui.destroy() + end + + -- Initialize with immediate mode enabled + Gui.init({ + baseScale = { width = 1920, height = 1080 }, + immediateMode = true, + }) +end + +function TestStableIDGeneration:tearDown() + -- Clear all states + if Gui.clearAllStates then + Gui.clearAllStates() + end + + -- Reset immediate mode state + if Gui._immediateModeState then + Gui._immediateModeState.reset() + end + + if Gui.destroy then + Gui.destroy() + end + + -- Reset immediate mode flag + Gui._immediateMode = false + Gui._frameNumber = 0 +end + +function TestStableIDGeneration:test_child_ids_stable_across_frames() + -- Frame 1: Create parent with children + Gui.beginFrame() + + local parent = Gui.new({ + id = "test_parent", + width = 400, + height = 300, + }) + + local child1 = Gui.new({ + parent = parent, + width = 100, + height = 50, + text = "Child 1", + }) + + local child2 = Gui.new({ + parent = parent, + width = 100, + height = 50, + text = "Child 2", + }) + + local child1Id = child1.id + local child2Id = child2.id + + Gui.endFrame() + + -- Frame 2: Recreate same structure + Gui.beginFrame() + + local parent2 = Gui.new({ + id = "test_parent", + width = 400, + height = 300, + }) + + local child1_2 = Gui.new({ + parent = parent2, + width = 100, + height = 50, + text = "Child 1", + }) + + local child2_2 = Gui.new({ + parent = parent2, + width = 100, + height = 50, + text = "Child 2", + }) + + Gui.endFrame() + + -- IDs should be stable + luaunit.assertEquals(child1_2.id, child1Id, "Child 1 ID should be stable across frames") + luaunit.assertEquals(child2_2.id, child2Id, "Child 2 ID should be stable across frames") +end + +function TestStableIDGeneration:test_conditional_rendering_does_not_affect_siblings() + -- Frame 1: Create parent with 3 children + Gui.beginFrame() + + local parent1 = Gui.new({ + id = "test_parent2", + width = 400, + height = 300, + }) + + local child1 = Gui.new({ + parent = parent1, + width = 100, + height = 50, + text = "Child 1", + }) + + local child2 = Gui.new({ + parent = parent1, + width = 100, + height = 50, + text = "Child 2", + }) + + local child3 = Gui.new({ + parent = parent1, + width = 100, + height = 50, + text = "Child 3", + }) + + local child1Id = child1.id + local child3Id = child3.id + + Gui.endFrame() + + -- Frame 2: Skip child 2 (conditional rendering) + Gui.beginFrame() + + local parent2 = Gui.new({ + id = "test_parent2", + width = 400, + height = 300, + }) + + local child1_2 = Gui.new({ + parent = parent2, + width = 100, + height = 50, + text = "Child 1", + }) + + -- Child 2 not rendered this frame + + local child3_2 = Gui.new({ + parent = parent2, + width = 100, + height = 50, + text = "Child 3", + }) + + Gui.endFrame() + + -- Child 1 should keep its ID + luaunit.assertEquals(child1_2.id, child1Id, "Child 1 ID should remain stable") + + -- Child 3 will have a different ID because it's now at sibling index 1 instead of 2 + -- This is EXPECTED behavior - the position in the tree changed + luaunit.assertNotEquals(child3_2.id, child3Id, "Child 3 ID changes because its sibling position changed") +end + +function TestStableIDGeneration:test_input_field_maintains_state_across_frames() + -- Frame 1: Create input field and simulate text entry + Gui.beginFrame() + + local container = Gui.new({ + id = "test_container", + width = 400, + height = 300, + }) + + local input1 = Gui.new({ + parent = container, + width = 200, + height = 40, + editable = true, + text = "", + }) + + -- Simulate text input + input1._textBuffer = "Hello World" + input1._focused = true + + local inputId = input1.id + + Gui.endFrame() + + -- Frame 2: Recreate same structure + Gui.beginFrame() + + local container2 = Gui.new({ + id = "test_container", + width = 400, + height = 300, + }) + + local input2 = Gui.new({ + parent = container2, + width = 200, + height = 40, + editable = true, + text = "", + }) + + Gui.endFrame() + + -- Input should have same ID and restored state + luaunit.assertEquals(input2.id, inputId, "Input field ID should be stable") + luaunit.assertEquals(input2._textBuffer, "Hello World", "Input text should be restored") + luaunit.assertTrue(input2._focused, "Input focus state should be restored") +end + +function TestStableIDGeneration:test_nested_children_stable_ids() + -- Frame 1: Create nested hierarchy + Gui.beginFrame() + + local root = Gui.new({ + id = "test_root", + width = 400, + height = 300, + }) + + local level1 = Gui.new({ + parent = root, + width = 300, + height = 200, + }) + + local level2 = Gui.new({ + parent = level1, + width = 200, + height = 100, + }) + + local deepChild = Gui.new({ + parent = level2, + width = 100, + height = 50, + text = "Deep Child", + }) + + local deepChildId = deepChild.id + + Gui.endFrame() + + -- Frame 2: Recreate same nested structure + Gui.beginFrame() + + local root2 = Gui.new({ + id = "test_root", + width = 400, + height = 300, + }) + + local level1_2 = Gui.new({ + parent = root2, + width = 300, + height = 200, + }) + + local level2_2 = Gui.new({ + parent = level1_2, + width = 200, + height = 100, + }) + + local deepChild2 = Gui.new({ + parent = level2_2, + width = 100, + height = 50, + text = "Deep Child", + }) + + Gui.endFrame() + + -- Deep child ID should be stable + luaunit.assertEquals(deepChild2.id, deepChildId, "Deeply nested child ID should be stable") +end + +function TestStableIDGeneration:test_siblings_with_different_props_have_different_ids() + -- Frame 1: Create siblings with different properties + Gui.beginFrame() + + local parent = Gui.new({ + width = 400, + height = 300, + }) + + local child1 = Gui.new({ + parent = parent, + width = 100, + height = 50, + text = "Button 1", + }) + + local child2 = Gui.new({ + parent = parent, + width = 100, + height = 50, + text = "Button 2", + }) + + Gui.endFrame() + + -- Siblings should have different IDs due to different sibling indices and props + luaunit.assertNotEquals(child1.id, child2.id, "Siblings should have different IDs") +end + +-- Helper function to create elements from consistent location (simulates real usage) +local function createTopLevelElements() + local elements = {} + for i = 1, 3 do + elements[i] = Gui.new({ width = 100, height = 50, text = "Element " .. i }) + end + return elements +end + +function TestStableIDGeneration:test_top_level_elements_use_call_site_counter() + -- Frame 1: Create multiple top-level elements at same location (in loop) + Gui.beginFrame() + + local elements = createTopLevelElements() + + local ids = {} + for i = 1, 3 do + ids[i] = elements[i].id + end + + Gui.endFrame() + + -- Frame 2: Recreate same elements from SAME line (via helper) + Gui.beginFrame() + + local elements2 = createTopLevelElements() + + Gui.endFrame() + + -- IDs should be stable for top-level elements when called from same location + for i = 1, 3 do + luaunit.assertEquals(elements2[i].id, ids[i], "Top-level element " .. i .. " ID should be stable") + end +end + +function TestStableIDGeneration:test_mixed_conditional_and_stable_elements() + -- Simulate a real-world scenario: navigation with conditional screens + + -- Frame 1: Screen A with input field + Gui.beginFrame() + + local backdrop1 = Gui.new({ + id = "backdrop", + width = "100%", + height = "100%", + }) + + local window1 = Gui.new({ + parent = backdrop1, + width = "80%", + height = "80%", + }) + + -- Screen A content + local inputA = Gui.new({ + parent = window1, + width = 200, + height = 40, + editable = true, + text = "Screen A Input", + }) + + inputA._textBuffer = "User typed this" + inputA._focused = true + + local inputAId = inputA.id + + Gui.endFrame() + + -- Frame 2: Same screen structure (user is still on Screen A) + Gui.beginFrame() + + local backdrop2 = Gui.new({ + id = "backdrop", + width = "100%", + height = "100%", + }) + + local window2 = Gui.new({ + parent = backdrop2, + width = "80%", + height = "80%", + }) + + -- Screen A content (same position in tree) + local inputA2 = Gui.new({ + parent = window2, + width = 200, + height = 40, + editable = true, + text = "Screen A Input", + }) + + Gui.endFrame() + + -- Input field should maintain ID and state + luaunit.assertEquals(inputA2.id, inputAId, "Input field ID should be stable within same screen") + luaunit.assertEquals(inputA2._textBuffer, "User typed this", "Input text should be preserved") + luaunit.assertTrue(inputA2._focused, "Input focus should be preserved") +end + +luaunit.LuaUnit.run() diff --git a/testing/runAll.lua b/testing/runAll.lua index 834e85b..24dd221 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -1,5 +1,8 @@ package.path = package.path .. ";./?.lua;./game/?.lua;./game/utils/?.lua;./game/components/?.lua;./game/systems/?.lua" +-- Set global flag to prevent individual test files from running luaunit +_G.RUNNING_ALL_TESTS = true + local luaunit = require("testing.luaunit") -- Run all tests in the __tests__ directory @@ -38,6 +41,7 @@ local testFiles = { "testing/__tests__/32_state_manager_tests.lua", "testing/__tests__/33_input_field_tests.lua", "testing/__tests__/34_password_mode_tests.lua", + "testing/__tests__/35_stable_id_generation_tests.lua", } local success = true