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:
76
FlexLove.lua
76
FlexLove.lua
@@ -3,23 +3,28 @@ local function req(name)
|
||||
return require(modulePath .. "modules." .. name)
|
||||
end
|
||||
|
||||
-- internals
|
||||
local Blur = req("Blur")
|
||||
-- Load ErrorHandler first (required for ModuleLoader)
|
||||
---@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 Units = req("Units")
|
||||
local Context = req("Context")
|
||||
---@type 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 ImageCache = req("ImageCache")
|
||||
local Grid = req("Grid")
|
||||
local InputEvent = req("InputEvent")
|
||||
local GestureRecognizer = req("GestureRecognizer")
|
||||
local TextEditor = req("TextEditor")
|
||||
---@type LayoutEngine
|
||||
local LayoutEngine = req("LayoutEngine")
|
||||
@@ -27,19 +32,28 @@ local Renderer = req("Renderer")
|
||||
---@type EventHandler
|
||||
local EventHandler = req("EventHandler")
|
||||
local ScrollManager = req("ScrollManager")
|
||||
---@type ErrorHandler
|
||||
local ErrorHandler = req("ErrorHandler")
|
||||
---@type Element
|
||||
local Element = req("Element")
|
||||
|
||||
-- externals
|
||||
---@type Animation
|
||||
local Animation = req("Animation")
|
||||
local Transform = Animation.Transform
|
||||
---@type 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
|
||||
local Theme = req("Theme")
|
||||
local Theme = safeReq("Theme", true)
|
||||
|
||||
-- Handle Animation.Transform safely
|
||||
local Transform = Animation.Transform or nil
|
||||
|
||||
local enums = utils.enums
|
||||
|
||||
---@class FlexLove
|
||||
@@ -103,6 +117,8 @@ function flexlove.init(config)
|
||||
enableRotation = config.errorLogRotateEnabled,
|
||||
})
|
||||
|
||||
-- Initialize Performance if available
|
||||
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Performance") then
|
||||
flexlove._Performance = Performance.init({
|
||||
enabled = config.performanceMonitoring or true,
|
||||
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.stateMetadata", StateManager._getInternalState().stateMetadata)
|
||||
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 })
|
||||
end
|
||||
|
||||
if ModuleLoader.isModuleLoaded(modulePath .. "modules.NinePatch") then
|
||||
NinePatch.init({ ErrorHandler = flexlove._ErrorHandler })
|
||||
end
|
||||
|
||||
-- Initialize required modules
|
||||
Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler })
|
||||
Color.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 })
|
||||
end
|
||||
|
||||
-- Initialize optional Theme module
|
||||
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then
|
||||
Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils })
|
||||
end
|
||||
|
||||
LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance })
|
||||
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
|
||||
end
|
||||
|
||||
if config.theme then
|
||||
if config.theme and ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then
|
||||
local success, err = pcall(function()
|
||||
if type(config.theme) == "string" then
|
||||
Theme.load(config.theme)
|
||||
@@ -268,7 +304,9 @@ function flexlove.resize()
|
||||
flexlove.scaleFactors.y = newHeight / flexlove.baseScale.height
|
||||
end
|
||||
|
||||
if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then
|
||||
Blur.clearCache()
|
||||
end
|
||||
|
||||
-- Release old canvases explicitly
|
||||
if flexlove._gameCanvas then
|
||||
|
||||
190
modules/ModuleLoader.lua
Normal file
190
modules/ModuleLoader.lua
Normal 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
|
||||
196
testing/__tests__/module_loader_test.lua
Normal file
196
testing/__tests__/module_loader_test.lua
Normal 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
|
||||
@@ -46,6 +46,7 @@ local testFiles = {
|
||||
"testing/__tests__/image_scaler_test.lua",
|
||||
"testing/__tests__/input_event_test.lua",
|
||||
"testing/__tests__/layout_engine_test.lua",
|
||||
"testing/__tests__/module_loader_test.lua",
|
||||
"testing/__tests__/ninepatch_test.lua",
|
||||
"testing/__tests__/performance_test.lua",
|
||||
"testing/__tests__/renderer_test.lua",
|
||||
|
||||
Reference in New Issue
Block a user