stable id - fixes input for immediate mode

This commit is contained in:
Michael Freno
2025-11-10 14:08:08 -05:00
parent ecf31fb574
commit a567b44e6c
12 changed files with 619 additions and 1169 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ themes/metal/
themes/space/
.DS_STORE
tasks
testoutput.txt

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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