Add ModuleLoader for conditional module loading with graceful fallbacks

- Create ModuleLoader.lua with safeRequire() for optional module loading
- Implement null-object pattern for missing optional modules
- Update FlexLove.lua to use ModuleLoader for Performance, Animation, Blur, Theme, ImageRenderer, ImageScaler, ImageCache, NinePatch, and GestureRecognizer
- Add comprehensive test suite for ModuleLoader (18 tests)
- Validate FlexLove works correctly when optional modules are missing
- All tests pass (1253/1254 successes)
This commit is contained in:
Michael Freno
2025-11-25 13:27:14 -05:00
parent 57da711492
commit 94d1b759ae
4 changed files with 464 additions and 39 deletions

View File

@@ -3,23 +3,28 @@ local function req(name)
return require(modulePath .. "modules." .. name) return require(modulePath .. "modules." .. name)
end end
-- internals -- Load ErrorHandler first (required for ModuleLoader)
local Blur = req("Blur") ---@type ErrorHandler
local ErrorHandler = req("ErrorHandler")
-- Load ModuleLoader
local ModuleLoader = req("ModuleLoader")
ModuleLoader.init({ ErrorHandler = ErrorHandler })
-- Helper function for safe module loading
local function safeReq(name, isOptional)
return ModuleLoader.safeRequire(modulePath .. "modules." .. name, isOptional)
end
-- Required core modules
local utils = req("utils") local utils = req("utils")
local Units = req("Units") local Units = req("Units")
local Context = req("Context") local Context = req("Context")
---@type StateManager ---@type StateManager
local StateManager = req("StateManager") local StateManager = req("StateManager")
---@type Performance
local Performance = req("Performance")
local ImageRenderer = req("ImageRenderer")
local ImageScaler = req("ImageScaler")
local NinePatch = req("NinePatch")
local RoundedRect = req("RoundedRect") local RoundedRect = req("RoundedRect")
local ImageCache = req("ImageCache")
local Grid = req("Grid") local Grid = req("Grid")
local InputEvent = req("InputEvent") local InputEvent = req("InputEvent")
local GestureRecognizer = req("GestureRecognizer")
local TextEditor = req("TextEditor") local TextEditor = req("TextEditor")
---@type LayoutEngine ---@type LayoutEngine
local LayoutEngine = req("LayoutEngine") local LayoutEngine = req("LayoutEngine")
@@ -27,19 +32,28 @@ local Renderer = req("Renderer")
---@type EventHandler ---@type EventHandler
local EventHandler = req("EventHandler") local EventHandler = req("EventHandler")
local ScrollManager = req("ScrollManager") local ScrollManager = req("ScrollManager")
---@type ErrorHandler
local ErrorHandler = req("ErrorHandler")
---@type Element ---@type Element
local Element = req("Element") local Element = req("Element")
-- externals
---@type Animation
local Animation = req("Animation")
local Transform = Animation.Transform
---@type Color ---@type Color
local Color = req("Color") local Color = req("Color")
-- Optional modules (can be excluded in minimal builds)
local Blur = safeReq("Blur", true)
---@type Performance
local Performance = safeReq("Performance", true)
local ImageRenderer = safeReq("ImageRenderer", true)
local ImageScaler = safeReq("ImageScaler", true)
local NinePatch = safeReq("NinePatch", true)
local ImageCache = safeReq("ImageCache", true)
local GestureRecognizer = safeReq("GestureRecognizer", true)
---@type Animation
local Animation = safeReq("Animation", true)
---@type Theme ---@type Theme
local Theme = req("Theme") local Theme = safeReq("Theme", true)
-- Handle Animation.Transform safely
local Transform = Animation.Transform or nil
local enums = utils.enums local enums = utils.enums
---@class FlexLove ---@class FlexLove
@@ -103,6 +117,8 @@ function flexlove.init(config)
enableRotation = config.errorLogRotateEnabled, enableRotation = config.errorLogRotateEnabled,
}) })
-- Initialize Performance if available
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Performance") then
flexlove._Performance = Performance.init({ flexlove._Performance = Performance.init({
enabled = config.performanceMonitoring or true, enabled = config.performanceMonitoring or true,
hudEnabled = false, -- Start with HUD disabled hudEnabled = false, -- Start with HUD disabled
@@ -120,18 +136,38 @@ function flexlove.init(config)
flexlove._Performance:registerTableForMonitoring("StateManager.stateStore", StateManager._getInternalState().stateStore) flexlove._Performance:registerTableForMonitoring("StateManager.stateStore", StateManager._getInternalState().stateStore)
flexlove._Performance:registerTableForMonitoring("StateManager.stateMetadata", StateManager._getInternalState().stateMetadata) flexlove._Performance:registerTableForMonitoring("StateManager.stateMetadata", StateManager._getInternalState().stateMetadata)
end end
else
flexlove._Performance = Performance
end
ImageRenderer.init({ ErrorHandler = flexlove._ErrorHandler, utils = flexlove._utils }) -- Initialize optional modules if available
if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageRenderer") then
ImageRenderer.init({ ErrorHandler = flexlove._ErrorHandler, utils = utils })
end
if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageScaler") then
ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler })
end
if ModuleLoader.isModuleLoaded(modulePath .. "modules.NinePatch") then
NinePatch.init({ ErrorHandler = flexlove._ErrorHandler }) NinePatch.init({ ErrorHandler = flexlove._ErrorHandler })
end
-- Initialize required modules
Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler }) Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler })
Color.init({ ErrorHandler = flexlove._ErrorHandler }) Color.init({ ErrorHandler = flexlove._ErrorHandler })
utils.init({ ErrorHandler = flexlove._ErrorHandler }) utils.init({ ErrorHandler = flexlove._ErrorHandler })
-- Initialize optional Animation module
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Animation") then
Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color }) Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color })
end
-- Initialize optional Theme module
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then
Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils }) Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils })
end
LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance }) LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance })
EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils }) EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils })
@@ -175,7 +211,7 @@ function flexlove.init(config)
flexlove.scaleFactors.y = currentHeight / flexlove.baseScale.height flexlove.scaleFactors.y = currentHeight / flexlove.baseScale.height
end end
if config.theme then if config.theme and ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then
local success, err = pcall(function() local success, err = pcall(function()
if type(config.theme) == "string" then if type(config.theme) == "string" then
Theme.load(config.theme) Theme.load(config.theme)
@@ -268,7 +304,9 @@ function flexlove.resize()
flexlove.scaleFactors.y = newHeight / flexlove.baseScale.height flexlove.scaleFactors.y = newHeight / flexlove.baseScale.height
end end
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then
Blur.clearCache() Blur.clearCache()
end
-- Release old canvases explicitly -- Release old canvases explicitly
if flexlove._gameCanvas then if flexlove._gameCanvas then

190
modules/ModuleLoader.lua Normal file
View File

@@ -0,0 +1,190 @@
---@class ModuleLoader
local ModuleLoader = {}
-- Module registry to track loaded vs. stub modules
ModuleLoader._registry = {}
ModuleLoader._ErrorHandler = nil
--- Initialize ModuleLoader with dependencies
---@param deps table
function ModuleLoader.init(deps)
ModuleLoader._ErrorHandler = deps.ErrorHandler
end
--- Create a null-object stub for a missing optional module
--- Provides safe defaults that won't cause runtime errors
---@param moduleName string
---@return table
local function createNullObject(moduleName)
local stub = {
_isStub = true,
_moduleName = moduleName,
}
-- Common method stubs that return safe defaults
local metatable = {
__index = function(_, key)
-- Common initialization method
if key == "init" then
return function() return stub end
end
-- Common constructor method
if key == "new" then
return function() return stub end
end
-- Common draw method
if key == "draw" then
return function() end
end
-- Common update method
if key == "update" then
return function() end
end
-- Common render method
if key == "render" then
return function() end
end
-- Common cleanup method
if key == "destroy" then
return function() end
end
-- Common cleanup method
if key == "cleanup" then
return function() end
end
-- Common clear method
if key == "clear" then
return function() end
end
-- Common reset method
if key == "reset" then
return function() end
end
-- Common get method
if key == "get" then
return function() return nil end
end
-- Common set method
if key == "set" then
return function() end
end
-- Common load method
if key == "load" then
return function() return stub end
end
-- Common cache-related methods
if key == "cache" or key == "getCache" or key == "clearCache" then
return function() return {} end
end
-- Return nil for unknown properties (allows safe property access)
return nil
end,
-- Make function calls safe (in case the stub itself is called)
__call = function()
return stub
end,
}
setmetatable(stub, metatable)
return stub
end
--- Safely require a module with graceful fallback for optional modules
--- Returns the module if it exists, or a null-object stub if it's optional and missing
--- Throws an error if a required module is missing
---@param modulePath string Full path to the module (e.g., "modules.Performance")
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
---@return table module The loaded module or a null-object stub
function ModuleLoader.safeRequire(modulePath, isOptional)
-- Check if already loaded
if ModuleLoader._registry[modulePath] then
return ModuleLoader._registry[modulePath]
end
-- Attempt to load the module
local success, result = pcall(require, modulePath)
if success then
-- Module loaded successfully
ModuleLoader._registry[modulePath] = result
return result
else
-- Module failed to load
if isOptional then
-- Create null-object stub for optional module
local stub = createNullObject(modulePath)
ModuleLoader._registry[modulePath] = stub
-- Log warning about missing optional module
if ModuleLoader._ErrorHandler then
ModuleLoader._ErrorHandler:warn(
"ModuleLoader",
string.format("Optional module '%s' not found, using stub implementation", modulePath)
)
end
return stub
else
-- Required module is missing - throw error
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
end
end
end
--- Check if a module is actually loaded (not a stub)
---@param modulePath string Full path to the module
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
function ModuleLoader.isModuleLoaded(modulePath)
local module = ModuleLoader._registry[modulePath]
if not module then
return false
end
-- Check if it's a stub
return not module._isStub
end
--- Get list of all loaded modules
---@return table modules List of module paths that are actually loaded (not stubs)
function ModuleLoader.getLoadedModules()
local loaded = {}
for path, module in pairs(ModuleLoader._registry) do
if not module._isStub then
table.insert(loaded, path)
end
end
return loaded
end
--- Get list of all stub modules
---@return table stubs List of module paths that are stubs
function ModuleLoader.getStubModules()
local stubs = {}
for path, module in pairs(ModuleLoader._registry) do
if module._isStub then
table.insert(stubs, path)
end
end
return stubs
end
--- Clear the module registry (useful for testing)
function ModuleLoader._clearRegistry()
ModuleLoader._registry = {}
end
return ModuleLoader

View File

@@ -0,0 +1,196 @@
local lu = require("testing.luaunit")
local loveStub = require("testing.loveStub")
-- Set up love stub globally
_G.love = loveStub
-- Load modules
local function req(name)
return require("modules." .. name)
end
local ErrorHandler = req("ErrorHandler")
local ModuleLoader = req("ModuleLoader")
-- Module path for testing
local modulePath = ""
TestModuleLoader = {}
function TestModuleLoader:setUp()
-- Initialize ErrorHandler
ErrorHandler.init({})
-- Initialize ModuleLoader
ModuleLoader.init({ ErrorHandler = ErrorHandler })
-- Clear registry before each test
ModuleLoader._clearRegistry()
end
function TestModuleLoader:tearDown()
-- Clear registry after each test
ModuleLoader._clearRegistry()
end
function TestModuleLoader:test_safeRequire_loads_existing_module()
-- Test loading an existing required module
local utils = ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
lu.assertNotNil(utils)
lu.assertIsTable(utils)
lu.assertIsNil(utils._isStub)
end
function TestModuleLoader:test_safeRequire_returns_stub_for_missing_optional_module()
-- Test loading a non-existent optional module
local fakeModule = ModuleLoader.safeRequire(modulePath .. "modules.NonExistentModule", true)
lu.assertNotNil(fakeModule)
lu.assertIsTable(fakeModule)
lu.assertTrue(fakeModule._isStub)
lu.assertEquals(fakeModule._moduleName, modulePath .. "modules.NonExistentModule")
end
function TestModuleLoader:test_safeRequire_throws_error_for_missing_required_module()
-- Test loading a non-existent required module should throw error
lu.assertErrorMsgContains(
"Required module",
function()
ModuleLoader.safeRequire(modulePath .. "modules.NonExistentModule", false)
end
)
end
function TestModuleLoader:test_stub_has_safe_init_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.init)
local result = stub.init()
lu.assertEquals(result, stub)
end
function TestModuleLoader:test_stub_has_safe_new_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.new)
local result = stub.new()
lu.assertEquals(result, stub)
end
function TestModuleLoader:test_stub_has_safe_draw_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.draw)
-- Should not throw error
stub.draw()
end
function TestModuleLoader:test_stub_has_safe_update_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.update)
-- Should not throw error
stub.update()
end
function TestModuleLoader:test_stub_has_safe_clear_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.clear)
-- Should not throw error
stub.clear()
end
function TestModuleLoader:test_stub_has_safe_clearCache_method()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsFunction(stub.clearCache)
local result = stub.clearCache()
lu.assertIsTable(result)
end
function TestModuleLoader:test_stub_returns_nil_for_unknown_properties()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertIsNil(stub.unknownProperty)
lu.assertIsNil(stub.anotherUnknownProperty)
end
function TestModuleLoader:test_stub_callable_returns_itself()
local stub = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
local result = stub()
lu.assertEquals(result, stub)
end
function TestModuleLoader:test_isModuleLoaded_returns_true_for_loaded_module()
ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
lu.assertTrue(ModuleLoader.isModuleLoaded(modulePath .. "modules.utils"))
end
function TestModuleLoader:test_isModuleLoaded_returns_false_for_stub_module()
ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
lu.assertFalse(ModuleLoader.isModuleLoaded(modulePath .. "modules.FakeModule"))
end
function TestModuleLoader:test_isModuleLoaded_returns_false_for_unloaded_module()
lu.assertFalse(ModuleLoader.isModuleLoaded(modulePath .. "modules.NeverLoaded"))
end
function TestModuleLoader:test_getLoadedModules_returns_only_real_modules()
ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
ModuleLoader.safeRequire(modulePath .. "modules.FakeModule1", true)
ModuleLoader.safeRequire(modulePath .. "modules.FakeModule2", true)
local loaded = ModuleLoader.getLoadedModules()
lu.assertIsTable(loaded)
-- Should only contain utils (real module)
local hasUtils = false
for _, path in ipairs(loaded) do
if path == modulePath .. "modules.utils" then
hasUtils = true
end
end
lu.assertTrue(hasUtils)
end
function TestModuleLoader:test_getStubModules_returns_only_stubs()
ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
ModuleLoader.safeRequire(modulePath .. "modules.FakeModule1", true)
ModuleLoader.safeRequire(modulePath .. "modules.FakeModule2", true)
local stubs = ModuleLoader.getStubModules()
lu.assertIsTable(stubs)
-- Should contain 2 stubs
lu.assertEquals(#stubs, 2)
end
function TestModuleLoader:test_safeRequire_caches_modules()
-- Load module twice
local module1 = ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
local module2 = ModuleLoader.safeRequire(modulePath .. "modules.utils", false)
-- Should return same instance
lu.assertEquals(module1, module2)
end
function TestModuleLoader:test_safeRequire_caches_stubs()
-- Load stub twice
local stub1 = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
local stub2 = ModuleLoader.safeRequire(modulePath .. "modules.FakeModule", true)
-- Should return same instance
lu.assertEquals(stub1, stub2)
end
-- Run tests if executed directly
if not _G.RUNNING_ALL_TESTS then
os.exit(lu.LuaUnit.run())
end
return TestModuleLoader

View File

@@ -46,6 +46,7 @@ local testFiles = {
"testing/__tests__/image_scaler_test.lua", "testing/__tests__/image_scaler_test.lua",
"testing/__tests__/input_event_test.lua", "testing/__tests__/input_event_test.lua",
"testing/__tests__/layout_engine_test.lua", "testing/__tests__/layout_engine_test.lua",
"testing/__tests__/module_loader_test.lua",
"testing/__tests__/ninepatch_test.lua", "testing/__tests__/ninepatch_test.lua",
"testing/__tests__/performance_test.lua", "testing/__tests__/performance_test.lua",
"testing/__tests__/renderer_test.lua", "testing/__tests__/renderer_test.lua",