From 94d1b759ae27176444eb30603f21f6cb3c5bc3fc Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 25 Nov 2025 13:27:14 -0500 Subject: [PATCH] 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) --- FlexLove.lua | 116 +++++++++----- modules/ModuleLoader.lua | 190 ++++++++++++++++++++++ testing/__tests__/module_loader_test.lua | 196 +++++++++++++++++++++++ testing/runAll.lua | 1 + 4 files changed, 464 insertions(+), 39 deletions(-) create mode 100644 modules/ModuleLoader.lua create mode 100644 testing/__tests__/module_loader_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index 1a1b5b8..375ecc0 100644 --- a/FlexLove.lua +++ b/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,35 +117,57 @@ function flexlove.init(config) enableRotation = config.errorLogRotateEnabled, }) - flexlove._Performance = Performance.init({ - enabled = config.performanceMonitoring or true, - hudEnabled = false, -- Start with HUD disabled - hudToggleKey = config.performanceHudKey or "f3", - hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, - warningThresholdMs = config.performanceWarningThreshold or 13.0, - criticalThresholdMs = config.performanceCriticalThreshold or 16.67, - logToConsole = config.performanceLogToConsole or false, - logWarnings = config.performanceWarnings or false, - warningsEnabled = config.performanceWarnings or false, - memoryProfiling = config.memoryProfiling or config.immediateMode and true or false, - }, { ErrorHandler = flexlove._ErrorHandler }) + -- 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 + hudToggleKey = config.performanceHudKey or "f3", + hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, + warningThresholdMs = config.performanceWarningThreshold or 13.0, + criticalThresholdMs = config.performanceCriticalThreshold or 16.67, + logToConsole = config.performanceLogToConsole or false, + logWarnings = config.performanceWarnings or false, + warningsEnabled = config.performanceWarnings or false, + memoryProfiling = config.memoryProfiling or config.immediateMode and true or false, + }, { ErrorHandler = flexlove._ErrorHandler }) - if config.immediateMode then - flexlove._Performance:registerTableForMonitoring("StateManager.stateStore", StateManager._getInternalState().stateStore) - flexlove._Performance:registerTableForMonitoring("StateManager.stateMetadata", StateManager._getInternalState().stateMetadata) + if config.immediateMode then + 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 - ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) + if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageScaler") then + ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) + end - NinePatch.init({ ErrorHandler = flexlove._ErrorHandler }) + 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 }) - Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color }) - Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils }) + + -- 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 - Blur.clearCache() + if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then + Blur.clearCache() + end -- Release old canvases explicitly if flexlove._gameCanvas then diff --git a/modules/ModuleLoader.lua b/modules/ModuleLoader.lua new file mode 100644 index 0000000..91a7ebb --- /dev/null +++ b/modules/ModuleLoader.lua @@ -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 diff --git a/testing/__tests__/module_loader_test.lua b/testing/__tests__/module_loader_test.lua new file mode 100644 index 0000000..a0d53db --- /dev/null +++ b/testing/__tests__/module_loader_test.lua @@ -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 diff --git a/testing/runAll.lua b/testing/runAll.lua index 32cd7a7..b4fe94d 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -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",