Skip to content

Commit

Permalink
chore: unit testing improvements
Browse files Browse the repository at this point in the history
Refactored event system to better support any event
Refactored test harness to output test results on screen
  • Loading branch information
maxpowa committed Sep 3, 2024
1 parent cc19fb4 commit 08d5b17
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 105 deletions.
69 changes: 53 additions & 16 deletions lib/core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ if ... ~= "__react__.lib.core" then
return require("__react__.lib.core")
end

-- TODO: Support Fragments, to allow returning multiple elements from a function component

-- Explicit imports here prevent scope pollution
local createScopedHandler = require "__react__.lib.events".createScopedHandler
local addScopedHandler = require "__react__.lib.events".addScopedHandler
local enableHooks = require "__react__.lib.hooks".enableHooks
local disableHooks = require "__react__.lib.hooks".disableHooks

Expand Down Expand Up @@ -57,12 +55,50 @@ end

local function compareNodeProp(node, k, v)
-- Wube, WHY?!
if (node.type == "slider" and k == "value") then
return node.slider_value ~= v
if (node.type == "slider") then
if (k == "value") then
return node.slider_value ~= v
elseif (k == "minimum_value") then
return node.get_slider_minimum() ~= v
elseif (k == "maximum_value") then
return node.get_slider_maximum() ~= v
elseif (k == "value_step") then
return node.get_slider_value_step() ~= v
elseif (k == "discrete_slider") then
return node.get_slider_discrete_slider() ~= v
elseif (k == "discrete_values") then
return node.get_slider_discrete_values() ~= v
end
end
return node[k] ~= v
end

local function updateNodeProp(node, k, v)
-- Wube, this is infuriating... The game is practically unplayable now that I know about this.
if (node.type == "slider") then
if (k == "value") then
node.slider_value = v
return
elseif (k == "minimum_value") then
node.set_slider_minimum_maximum(v, node.get_slider_maximum())
return
elseif (k == "maximum_value") then
node.set_slider_minimum_maximum(node.get_slider_minimum(), v)
return
elseif (k == "value_step") then
node.set_slider_value_step(v)
return
elseif (k == "discrete_slider") then
node.set_slider_discrete_slider(v)
return
elseif (k == "discrete_values") then
node.set_slider_discrete_values(v)
return
end
end
node[k] = v
end

-- immediate mode rendering
local function renderImmediate(vnode, index, parent, storage, recurse)
local node = parent.children[index]
Expand All @@ -77,8 +113,8 @@ local function renderImmediate(vnode, index, parent, storage, recurse)

for k, v in pairs(vnode.props) do
if (k:find("on_gui_") == 1 and type(v) == "function") then
-- add event handler cleanup functions to the storage table
createScopedHandler(defines.events[k], node, v, node.index)
-- TODO: only add new handlers if they changed
addScopedHandler(defines.events[k], node, v, node.index)
elseif (k == "ref") then
v.current = node
elseif (k == "style") then
Expand All @@ -92,10 +128,11 @@ local function renderImmediate(vnode, index, parent, storage, recurse)
end
elseif ((not createdNewNode) and compareNodeProp(node, k, v)) then
-- If we created a new node, we don't need to update it
node[k] = v
updateNodeProp(node, k, v)
end
end

-- TODO: only rerender children if they changed (will need to store the previous children in storage I think...)
-- setup children storage and render (needed even if there are no children, since we can't put arbitrary data on the element itself)
storage.children = storage.children or {}
storage.children[node.index] = storage.children[node.index] or {}
Expand All @@ -116,7 +153,7 @@ local function render(vlist, parent, storage)

-- initialize storage if not present
if not storage then
storage = { hooks = {}, event_handlers = {} }
storage = { hooks = {} }
end
-- capture current hook storage
local hs = storage.hooks or {}
Expand All @@ -127,11 +164,12 @@ local function render(vlist, parent, storage)
local ids = {}
local extraNodeCount = 0
for i, vnode in ipairs(vlist) do
-- TODO: defer and batch forceUpdate requests
local forceUpdate = function() return render(vlist, parent, storage) end

-- special handling for string vnodes
if type(vnode) == "string" then
vnode = { type = "label", props = { caption = vnode }}
vnode = { type = "label", props = { caption = vnode } }
end

while (type(vnode.type) == "function") do
Expand All @@ -141,8 +179,7 @@ local function render(vlist, parent, storage)
k = '' .. ids[vnode.type]
end

local index = 1
enableHooks(hs[k] or {}, index, forceUpdate)
enableHooks(hs[k] or {}, forceUpdate)
vnode = vnode.type(vnode.props, vnode.children, forceUpdate)
storage.hooks[k] = disableHooks()
end
Expand All @@ -151,10 +188,10 @@ local function render(vlist, parent, storage)
for _, v in ipairs(vnode) do
-- we need to keep the index consistent, so we add extra nodes to the count
extraNodeCount = extraNodeCount + 1
renderImmediate(v, i+extraNodeCount, parent, storage, render)
renderImmediate(v, i + extraNodeCount, parent, storage, render)
end
elseif type(vnode) ~= nil then
renderImmediate(vnode, i+extraNodeCount, parent, storage, render)
renderImmediate(vnode, i + extraNodeCount, parent, storage, render)
end
end

Expand Down Expand Up @@ -186,7 +223,7 @@ local function render(vlist, parent, storage)
end

-- remove extra elements
while true do
while not parent.tags.__react_ignored do
-- only remove elements that are not part of the virtual element list (including extra nodes added for fragments)
child = parent.children[#vlist + extraNodeCount + 1]
if child then
Expand All @@ -212,7 +249,7 @@ local function createElement(type, props, ...)
end

--- A function that creates a "react_root" element. This is used as a container for the element tree in `render`.
---
---
--- @param parent LuaGuiElement the parent element to add the root to (e.g. `player.gui.screen`)
--- @param props? table a table of properties to set on the root element (you should not need to)
---
Expand Down
57 changes: 42 additions & 15 deletions lib/events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ if ... ~= "__react__.lib.events" then
end

-- TODO: This may change across versions... not very future-proof
local supported_events = {
local supported_scoped_events = {
[defines.events.on_gui_click] = {"button", "sprite-button"},
[defines.events.on_gui_checked_state_changed] = {"checkbox"},
[defines.events.on_gui_confirmed] = {"textfield"},
Expand All @@ -32,21 +32,20 @@ local function on_event_handler(event)
local eventId = event.name
handlers = eventHandlers[eventId]
for _, eventHandler in pairs(handlers or {}) do
if (type(eventHandler) == "function") then
eventHandler(event)
end
eventHandler(event)
end
end

-- Factorio requires this funky approach to event handling because the event handlers are global, instead of per-element
local function createScopedHandler(eventId, element, fn, index)
-- TODO: throw an error if the event does not make sense for the element
if (eventHandlers[eventId] == nil) then
error("Unsupported event: " .. eventId)
local function addScopedHandler(eventId, element, fn, index)
if not contains(supported_scoped_events[eventId], element.type) then
error("Unsupported event for element type: " .. eventId .. " " .. element.type)
elseif type(fn) ~= "function" then
error("Event handler must be a function")
end

if not contains(supported_events[eventId], element.type) then
error("Unsupported event for element type: " .. eventId .. " " .. element.type)
if eventHandlers[eventId][index] then
eventHandlers[eventId][index] = nil
end

eventHandlers[eventId][index] = function(event)
Expand All @@ -60,18 +59,46 @@ local function createScopedHandler(eventId, element, fn, index)

-- return a cleanup function we can use in core to remove the handler
return function()
game.print("Cleaning up event handler for " .. eventId .. " for element " .. element.index .. " of type " .. element.type)
eventHandlers[eventId][index] = nil
end
end

--- Create an event handler for the given event type
---
--- @param eventId integer the event id to handle
--- @param fn function the function to call when the event is triggered
--- @param options { index?: number, custom?: boolean } custom allows for custom events
---
--- @return function cleanup a cleanup function that will remove the handler
---
local function addGlobalHandler(eventId, fn, options)
options = options or {}
if type(fn) ~= "function" then
error("Event handler must be a function")
elseif not options.custom and type(eventId) ~= "number" then
error("Unsupported event: " .. eventId)
elseif options.custom and not eventHandlers[eventId] then
eventHandlers[eventId] = {}
script.on_event(eventId, on_event_handler)
end

local index = options.index or #eventHandlers[eventId] + 1
eventHandlers[eventId][index] = fn
return function()
eventHandlers[eventId][index] = nil
end
end

-- Register our supported events with the game's event emitter
for k, _ in pairs(supported_events) do
for k, _ in pairs(defines.events) do
-- initialize the event handler table with this event type
eventHandlers[k] = {}
script.on_event(k, on_event_handler)
eventHandlers[defines.events[k]] = {}
script.on_event(defines.events[k], on_event_handler)
end



return {
createScopedHandler = createScopedHandler
addScopedHandler = addScopedHandler,
addGlobalHandler = addGlobalHandler,
}
26 changes: 24 additions & 2 deletions lib/hooks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ if ... ~= "__react__.lib.hooks" then
return require("__react__.lib.hooks")
end

local addGlobalHandler = require("__react__.lib.events").addGlobalHandler
local addScopedHandler = require("__react__.lib.events").addScopedHandler

local function some(tbl, func)
for i, v in ipairs(tbl) do
if func(v, i) then
Expand All @@ -16,9 +19,9 @@ end
local hooks
local index = nil
local forceUpdate
local function enableHooks(h, i, fu)
local function enableHooks(h, fu)
hooks = h
index = i
index = 1
forceUpdate = fu
end
local function disableHooks()
Expand Down Expand Up @@ -128,6 +131,24 @@ local function useRef(initialValue)
return useMemo(function() return { current = initialValue } end, {})
end

--- A hook that lets you add local event handlers without overwriting other handlers in script.on_event
---
--- Note: This is not meant for GUI event handling, use `on_gui_*` props in your component instead. The intended use
--- is for global events like `on_tick`, `on_player_created`, etc.
---
--- This should be used sparingly, as it can lead to significant render performance issues.
---
--- @param eventId integer the event id to handle
--- @param cb function the function to call when the event is triggered
--- @param options { index?: number, custom?: boolean } custom allows for custom events
--- @param deps table list of dependencies
local function useEvent(eventId, cb, options, deps)
local callback = useCallback(cb, deps)
useEffect(function()
return addGlobalHandler(eventId, callback, options)
end, deps)
end

return {
-- Core util for hooks
enableHooks = enableHooks,
Expand All @@ -140,4 +161,5 @@ return {
useMemo = useMemo,
useCallback = useCallback,
useRef = useRef,
useEvent = useEvent,
}
1 change: 1 addition & 0 deletions react.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ return {
useMemo = hooks.useMemo,
useCallback = hooks.useCallback,
useRef = hooks.useRef,
useEvent = hooks.useEvent,

-- LSX
lsx = lsx.lsx
Expand Down
Loading

0 comments on commit 08d5b17

Please sign in to comment.