From 1dab1a197e6f8d6469211fe08b8a115f4999a3c7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 14 Nov 2025 20:59:40 -0500 Subject: [PATCH] start testing --- .gitignore | 1 + .luacov | 26 + examples/performance_example.lua | 124 ++++ modules/Color.lua | 309 +++++++++ modules/ErrorCodes.lua | 445 +++++++++++++ modules/Performance.lua | 435 +++++++++++++ modules/TextEditor.lua | 100 ++- modules/Units.lua | 24 +- modules/utils.lua | 542 ++++++++++++++++ testing/__tests__/color_validation_test.lua | 514 +++++++++++++++ testing/__tests__/path_validation_test.lua | 281 ++++++++ testing/__tests__/sanitization_test.lua | 236 +++++++ .../texteditor_sanitization_test.lua | 306 +++++++++ testing/__tests__/theme_validation_test.lua | 598 ++++++++++++++++++ testing/__tests__/units_test.lua | 399 ++++++++++++ testing/__tests__/utils_test.lua | 487 ++++++++++++++ testing/runAll.lua | 31 +- testing/run_with_coverage.sh | 39 ++ 18 files changed, 4886 insertions(+), 11 deletions(-) create mode 100644 .luacov create mode 100644 examples/performance_example.lua create mode 100644 modules/ErrorCodes.lua create mode 100644 modules/Performance.lua create mode 100644 testing/__tests__/color_validation_test.lua create mode 100644 testing/__tests__/path_validation_test.lua create mode 100644 testing/__tests__/sanitization_test.lua create mode 100644 testing/__tests__/texteditor_sanitization_test.lua create mode 100644 testing/__tests__/theme_validation_test.lua create mode 100644 testing/__tests__/units_test.lua create mode 100644 testing/__tests__/utils_test.lua create mode 100755 testing/run_with_coverage.sh diff --git a/.gitignore b/.gitignore index 64d28fa..9baa3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ themes/space/ .DS_STORE tasks testoutput +luacov.* diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..5b640c1 --- /dev/null +++ b/.luacov @@ -0,0 +1,26 @@ +-- LuaCov configuration for FlexLove +return { + -- Stats file location + statsfile = "luacov.stats.out", + + -- Report file location + reportfile = "luacov.report.out", + + -- Exclude patterns (files to ignore) - use exclude instead of include for better results + exclude = { + "testing", + "examples", + "tasks", + "themes", + "luarocks", + }, + + -- Run reporter by default + runreport = false, + + -- Delete stats file after reporting + deletestats = false, + + -- Tick options + tick = true +} diff --git a/examples/performance_example.lua b/examples/performance_example.lua new file mode 100644 index 0000000..5c08a5d --- /dev/null +++ b/examples/performance_example.lua @@ -0,0 +1,124 @@ +--- Performance Monitoring Example +--- Demonstrates how to use the Performance module + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub and Performance module +require("testing.loveStub") +local Performance = require("modules.Performance") + +print("=== Performance Module Example ===\n") + +-- 1. Initialize and enable performance monitoring +print("1. Initializing Performance monitoring...") +Performance.init({ + enabled = true, + logToConsole = true, + logWarnings = true, +}) +print(" Enabled: " .. tostring(Performance.isEnabled())) +print() + +-- 2. Test basic timer functionality +print("2. Testing timers...") +Performance.startTimer("test_operation") +-- Simulate some work +local sum = 0 +for i = 1, 1000000 do + sum = sum + i +end +local elapsed = Performance.stopTimer("test_operation") +print(string.format(" Test operation completed in %.3fms", elapsed)) +print() + +-- 3. Test measure wrapper +print("3. Testing measure wrapper...") +local expensiveFunction = function(n) + local result = 0 + for i = 1, n do + result = result + math.sqrt(i) + end + return result +end + +local measuredFunction = Performance.measure("expensive_calculation", expensiveFunction) +local result = measuredFunction(100000) +print(string.format(" Expensive calculation result: %.2f", result)) +print() + +-- 4. Simulate frame timing +print("4. Simulating frame timing...") +for _ = 1, 10 do + Performance.startFrame() + + -- Simulate frame work + Performance.startTimer("frame_layout") + local layoutSum = 0 + for i = 1, 50000 do + layoutSum = layoutSum + i + end + Performance.stopTimer("frame_layout") + + Performance.startTimer("frame_render") + local renderSum = 0 + for i = 1, 30000 do + renderSum = renderSum + i + end + Performance.stopTimer("frame_render") + + Performance.endFrame() +end +print(string.format(" Simulated %d frames", 10)) +print() + +-- 5. Get and display metrics +print("5. Performance Metrics:") +local metrics = Performance.getMetrics() +print(string.format(" FPS: %d", metrics.frame.fps)) +print(string.format(" Average Frame Time: %.3fms", metrics.frame.averageFrameTime)) +print(string.format(" Min/Max Frame Time: %.3f/%.3fms", metrics.frame.minFrameTime, metrics.frame.maxFrameTime)) +print(string.format(" Memory: %.2f MB (peak: %.2f MB)", metrics.memory.currentMb, metrics.memory.peakMb)) +print() + +print("6. Top Timings:") +for name, data in pairs(metrics.timings) do + print(string.format(" %s:", name)) + print(string.format(" Average: %.3fms", data.average)) + print(string.format(" Min/Max: %.3f/%.3fms", data.min, data.max)) + print(string.format(" Count: %d", data.count)) +end +print() + +-- 7. Export metrics +print("7. Exporting metrics...") +local json = Performance.exportJSON() +print(" JSON Export:") +print(json) +print() + +local csv = Performance.exportCSV() +print(" CSV Export:") +print(csv) +print() + +-- 8. Test warnings +print("8. Recent Warnings:") +local warnings = Performance.getWarnings(5) +if #warnings > 0 then + for _, warning in ipairs(warnings) do + print(string.format(" [%s] %s: %.3fms", warning.level, warning.name, warning.value)) + end +else + print(" No warnings") +end +print() + +-- 9. Reset and verify +print("9. Testing reset...") +Performance.reset() +local newMetrics = Performance.getMetrics() +print(string.format(" Frame count after reset: %d", newMetrics.frame.frameCount)) +print(string.format(" Timings count after reset: %d", #newMetrics.timings)) +print() + +print("=== Performance Module Example Complete ===") diff --git a/modules/Color.lua b/modules/Color.lua index 44f2cf9..f30b904 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -6,6 +6,36 @@ local function formatError(module, message) return string.format("[FlexLove.%s] %s", module, message) end +-- Named colors (CSS3 color names) +local NAMED_COLORS = { + -- Basic colors + black = {0, 0, 0, 1}, + white = {1, 1, 1, 1}, + red = {1, 0, 0, 1}, + green = {0, 0.502, 0, 1}, + blue = {0, 0, 1, 1}, + yellow = {1, 1, 0, 1}, + cyan = {0, 1, 1, 1}, + magenta = {1, 0, 1, 1}, + + -- Extended colors + gray = {0.502, 0.502, 0.502, 1}, + grey = {0.502, 0.502, 0.502, 1}, + silver = {0.753, 0.753, 0.753, 1}, + maroon = {0.502, 0, 0, 1}, + olive = {0.502, 0.502, 0, 1}, + lime = {0, 1, 0, 1}, + aqua = {0, 1, 1, 1}, + teal = {0, 0.502, 0.502, 1}, + navy = {0, 0, 0.502, 1}, + fuchsia = {1, 0, 1, 1}, + purple = {0.502, 0, 0.502, 1}, + orange = {1, 0.647, 0, 1}, + pink = {1, 0.753, 0.796, 1}, + brown = {0.647, 0.165, 0.165, 1}, + transparent = {0, 0, 0, 0}, +} + --- Utility class for color handling ---@class Color ---@field r number -- Red component (0-1) @@ -64,4 +94,283 @@ function Color.fromHex(hexWithTag) end end +--- Validate a single color channel value +---@param value any -- Value to validate +---@param max number? -- Maximum value (255 for 0-255 range, 1 for 0-1 range) +---@return boolean valid -- True if valid +---@return number? clamped -- Clamped value in 0-1 range +function Color.validateColorChannel(value, max) + max = max or 1 + + if type(value) ~= "number" then + return false, nil + end + + -- Check for NaN + if value ~= value then + return false, nil + end + + -- Check for Infinity + if value == math.huge or value == -math.huge then + return false, nil + end + + -- Normalize to 0-1 range + local normalized = value + if max == 255 then + normalized = value / 255 + end + + -- Clamp to valid range + normalized = math.max(0, math.min(1, normalized)) + + return true, normalized +end + +--- Validate hex color format +---@param hex string -- Hex color string (with or without #) +---@return boolean valid -- True if valid format +---@return string? error -- Error message if invalid +function Color.validateHexColor(hex) + if type(hex) ~= "string" then + return false, "Hex color must be a string" + end + + -- Remove # prefix + local cleanHex = hex:gsub("^#", "") + + -- Check length (3, 6, or 8 characters) + if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then + return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex) + end + + -- Check for valid hex characters + if not cleanHex:match("^[0-9A-Fa-f]+$") then + return false, "Invalid hex characters. Use only 0-9, A-F" + end + + return true, nil +end + +--- Validate RGB/RGBA color values +---@param r number -- Red component +---@param g number -- Green component +---@param b number -- Blue component +---@param a number? -- Alpha component (optional) +---@param max number? -- Maximum value (255 or 1) +---@return boolean valid -- True if valid +---@return string? error -- Error message if invalid +function Color.validateRGBColor(r, g, b, a, max) + max = max or 1 + a = a or max + + local rValid = Color.validateColorChannel(r, max) + local gValid = Color.validateColorChannel(g, max) + local bValid = Color.validateColorChannel(b, max) + local aValid = Color.validateColorChannel(a, max) + + if not rValid then + return false, string.format("Invalid red channel: %s", tostring(r)) + end + if not gValid then + return false, string.format("Invalid green channel: %s", tostring(g)) + end + if not bValid then + return false, string.format("Invalid blue channel: %s", tostring(b)) + end + if not aValid then + return false, string.format("Invalid alpha channel: %s", tostring(a)) + end + + return true, nil +end + +--- Validate named color +---@param name string -- Color name +---@return boolean valid -- True if valid +---@return string? error -- Error message if invalid +function Color.validateNamedColor(name) + if type(name) ~= "string" then + return false, "Color name must be a string" + end + + local lowerName = name:lower() + if not NAMED_COLORS[lowerName] then + return false, string.format("Unknown color name: '%s'", name) + end + + return true, nil +end + +--- Check if a value is a valid color format +---@param value any -- Value to check +---@return string? format -- Format type (hex, rgb, rgba, named, table, nil if invalid) +function Color.isValidColorFormat(value) + local valueType = type(value) + + -- Check for hex string + if valueType == "string" then + if value:match("^#?[0-9A-Fa-f]+$") then + local valid = Color.validateHexColor(value) + if valid then + return "hex" + end + end + + -- Check for named color + if NAMED_COLORS[value:lower()] then + return "named" + end + + return nil + end + + -- Check for table format + if valueType == "table" then + -- Check for Color instance + if getmetatable(value) == Color then + return "table" + end + + -- Check for array format {r, g, b, a} + if value[1] and value[2] and value[3] then + local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4]) + if valid then + return "table" + end + end + + -- Check for named format {r=, g=, b=, a=} + if value.r and value.g and value.b then + local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a) + if valid then + return "table" + end + end + + return nil + end + + return nil +end + +--- Validate a color value +---@param value any -- Color value to validate +---@param options table? -- Validation options +---@return boolean valid -- True if valid +---@return string? error -- Error message if invalid +function Color.validateColor(value, options) + options = options or {} + local allowNamed = options.allowNamed ~= false + local requireAlpha = options.requireAlpha or false + + if value == nil then + return false, "Color value is nil" + end + + local format = Color.isValidColorFormat(value) + + if not format then + return false, string.format("Invalid color format: %s", tostring(value)) + end + + if format == "named" and not allowNamed then + return false, "Named colors not allowed" + end + + -- Additional validation for alpha requirement + if requireAlpha and format == "hex" then + local cleanHex = value:gsub("^#", "") + if #cleanHex ~= 8 then + return false, "Alpha channel required (use 8-digit hex)" + end + end + + return true, nil +end + +--- Sanitize a color value +---@param value any -- Color value to sanitize +---@param default Color? -- Default color if invalid +---@return Color -- Sanitized color +function Color.sanitizeColor(value, default) + default = default or Color.new(0, 0, 0, 1) + + local format = Color.isValidColorFormat(value) + + if not format then + return default + end + + -- Handle hex format + if format == "hex" then + local cleanHex = value:gsub("^#", "") + + -- Expand 3-digit hex to 6-digit + if #cleanHex == 3 then + cleanHex = cleanHex:gsub("(.)", "%1%1") + end + + -- Try to parse + local success, result = pcall(Color.fromHex, "#" .. cleanHex) + if success then + return result + else + return default + end + end + + -- Handle named format + if format == "named" then + local lowerName = value:lower() + local rgba = NAMED_COLORS[lowerName] + if rgba then + return Color.new(rgba[1], rgba[2], rgba[3], rgba[4]) + end + return default + end + + -- Handle table format + if format == "table" then + -- Color instance + if getmetatable(value) == Color then + return value + end + + -- Array format + if value[1] then + local _, r = Color.validateColorChannel(value[1], 1) + local _, g = Color.validateColorChannel(value[2], 1) + local _, b = Color.validateColorChannel(value[3], 1) + local _, a = Color.validateColorChannel(value[4] or 1, 1) + + if r and g and b and a then + return Color.new(r, g, b, a) + end + end + + -- Named format + if value.r then + local _, r = Color.validateColorChannel(value.r, 1) + local _, g = Color.validateColorChannel(value.g, 1) + local _, b = Color.validateColorChannel(value.b, 1) + local _, a = Color.validateColorChannel(value.a or 1, 1) + + if r and g and b and a then + return Color.new(r, g, b, a) + end + end + end + + return default +end + +--- Parse a color from various formats +---@param value any -- Color value (hex, named, table) +---@return Color -- Parsed color +function Color.parse(value) + return Color.sanitizeColor(value, Color.new(0, 0, 0, 1)) +end + return Color diff --git a/modules/ErrorCodes.lua b/modules/ErrorCodes.lua new file mode 100644 index 0000000..7527553 --- /dev/null +++ b/modules/ErrorCodes.lua @@ -0,0 +1,445 @@ +--- Error code definitions for FlexLove +--- Provides centralized error codes, descriptions, and suggested fixes +---@class ErrorCodes +local ErrorCodes = {} + +-- Error code categories +ErrorCodes.categories = { + VAL = "Validation", + LAY = "Layout", + REN = "Render", + THM = "Theme", + EVT = "Event", + RES = "Resource", + SYS = "System", +} + +-- Error code definitions +ErrorCodes.codes = { + -- Validation Errors (VAL_001 - VAL_099) + VAL_001 = { + code = "FLEXLOVE_VAL_001", + category = "VAL", + description = "Invalid property type", + suggestion = "Check the property type matches the expected type (e.g., number, string, table)", + }, + VAL_002 = { + code = "FLEXLOVE_VAL_002", + category = "VAL", + description = "Property value out of range", + suggestion = "Ensure the value is within the allowed min/max range", + }, + VAL_003 = { + code = "FLEXLOVE_VAL_003", + category = "VAL", + description = "Required property missing", + suggestion = "Provide the required property in your element definition", + }, + VAL_004 = { + code = "FLEXLOVE_VAL_004", + category = "VAL", + description = "Invalid color format", + suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object", + }, + VAL_005 = { + code = "FLEXLOVE_VAL_005", + category = "VAL", + description = "Invalid unit format", + suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.", + }, + VAL_006 = { + code = "FLEXLOVE_VAL_006", + category = "VAL", + description = "Invalid file path", + suggestion = "Check that the file path is correct and the file exists", + }, + VAL_007 = { + code = "FLEXLOVE_VAL_007", + category = "VAL", + description = "Invalid enum value", + suggestion = "Use one of the allowed enum values for this property", + }, + VAL_008 = { + code = "FLEXLOVE_VAL_008", + category = "VAL", + description = "Invalid text input", + suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)", + }, + + -- Layout Errors (LAY_001 - LAY_099) + LAY_001 = { + code = "FLEXLOVE_LAY_001", + category = "LAY", + description = "Invalid flex direction", + suggestion = "Use 'horizontal' or 'vertical' for flexDirection", + }, + LAY_002 = { + code = "FLEXLOVE_LAY_002", + category = "LAY", + description = "Circular dependency detected", + suggestion = "Remove circular references in element hierarchy or layout constraints", + }, + LAY_003 = { + code = "FLEXLOVE_LAY_003", + category = "LAY", + description = "Invalid dimensions (negative or NaN)", + suggestion = "Ensure width and height are positive numbers", + }, + LAY_004 = { + code = "FLEXLOVE_LAY_004", + category = "LAY", + description = "Layout calculation overflow", + suggestion = "Reduce complexity of layout or increase recursion limit", + }, + LAY_005 = { + code = "FLEXLOVE_LAY_005", + category = "LAY", + description = "Invalid alignment value", + suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)", + }, + LAY_006 = { + code = "FLEXLOVE_LAY_006", + category = "LAY", + description = "Invalid positioning mode", + suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning", + }, + LAY_007 = { + code = "FLEXLOVE_LAY_007", + category = "LAY", + description = "Grid layout error", + suggestion = "Check grid template columns/rows and item placement", + }, + + -- Rendering Errors (REN_001 - REN_099) + REN_001 = { + code = "FLEXLOVE_REN_001", + category = "REN", + description = "Invalid render state", + suggestion = "Ensure element is properly initialized before rendering", + }, + REN_002 = { + code = "FLEXLOVE_REN_002", + category = "REN", + description = "Texture loading failed", + suggestion = "Check image path and format, ensure file exists", + }, + REN_003 = { + code = "FLEXLOVE_REN_003", + category = "REN", + description = "Font loading failed", + suggestion = "Check font path and format, ensure file exists", + }, + REN_004 = { + code = "FLEXLOVE_REN_004", + category = "REN", + description = "Invalid color value", + suggestion = "Color components must be numbers between 0 and 1", + }, + REN_005 = { + code = "FLEXLOVE_REN_005", + category = "REN", + description = "Clipping stack overflow", + suggestion = "Reduce nesting depth or check for missing scissor pops", + }, + REN_006 = { + code = "FLEXLOVE_REN_006", + category = "REN", + description = "Shader compilation failed", + suggestion = "Check shader code for syntax errors", + }, + REN_007 = { + code = "FLEXLOVE_REN_007", + category = "REN", + description = "Invalid nine-patch configuration", + suggestion = "Check nine-patch slice values and image dimensions", + }, + + -- Theme Errors (THM_001 - THM_099) + THM_001 = { + code = "FLEXLOVE_THM_001", + category = "THM", + description = "Theme file not found", + suggestion = "Check theme file path and ensure file exists", + }, + THM_002 = { + code = "FLEXLOVE_THM_002", + category = "THM", + description = "Invalid theme structure", + suggestion = "Theme must return a table with 'name' and component styles", + }, + THM_003 = { + code = "FLEXLOVE_THM_003", + category = "THM", + description = "Required theme property missing", + suggestion = "Ensure theme has required properties (name, base styles, etc.)", + }, + THM_004 = { + code = "FLEXLOVE_THM_004", + category = "THM", + description = "Invalid component style", + suggestion = "Component styles must be tables with valid properties", + }, + THM_005 = { + code = "FLEXLOVE_THM_005", + category = "THM", + description = "Theme loading failed", + suggestion = "Check theme file for Lua syntax errors", + }, + THM_006 = { + code = "FLEXLOVE_THM_006", + category = "THM", + description = "Invalid theme color", + suggestion = "Theme colors must be valid color values (hex, rgba, Color object)", + }, + + -- Event Errors (EVT_001 - EVT_099) + EVT_001 = { + code = "FLEXLOVE_EVT_001", + category = "EVT", + description = "Invalid event type", + suggestion = "Use valid event types (mousepressed, textinput, etc.)", + }, + EVT_002 = { + code = "FLEXLOVE_EVT_002", + category = "EVT", + description = "Event handler error", + suggestion = "Check event handler function for errors", + }, + EVT_003 = { + code = "FLEXLOVE_EVT_003", + category = "EVT", + description = "Event propagation error", + suggestion = "Check event bubbling/capturing logic", + }, + EVT_004 = { + code = "FLEXLOVE_EVT_004", + category = "EVT", + description = "Invalid event target", + suggestion = "Ensure event target element exists and is valid", + }, + EVT_005 = { + code = "FLEXLOVE_EVT_005", + category = "EVT", + description = "Event handler not a function", + suggestion = "Event handlers must be functions", + }, + + -- Resource Errors (RES_001 - RES_099) + RES_001 = { + code = "FLEXLOVE_RES_001", + category = "RES", + description = "File not found", + suggestion = "Check file path and ensure file exists in the filesystem", + }, + RES_002 = { + code = "FLEXLOVE_RES_002", + category = "RES", + description = "Permission denied", + suggestion = "Check file permissions and access rights", + }, + RES_003 = { + code = "FLEXLOVE_RES_003", + category = "RES", + description = "Invalid file format", + suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)", + }, + RES_004 = { + code = "FLEXLOVE_RES_004", + category = "RES", + description = "Resource loading failed", + suggestion = "Check file integrity and format compatibility", + }, + RES_005 = { + code = "FLEXLOVE_RES_005", + category = "RES", + description = "Image cache error", + suggestion = "Clear image cache or check memory availability", + }, + + -- System Errors (SYS_001 - SYS_099) + SYS_001 = { + code = "FLEXLOVE_SYS_001", + category = "SYS", + description = "Memory allocation failed", + suggestion = "Reduce memory usage or check available memory", + }, + SYS_002 = { + code = "FLEXLOVE_SYS_002", + category = "SYS", + description = "Stack overflow", + suggestion = "Reduce recursion depth or check for infinite loops", + }, + SYS_003 = { + code = "FLEXLOVE_SYS_003", + category = "SYS", + description = "Invalid state", + suggestion = "Check initialization order and state management", + }, + SYS_004 = { + code = "FLEXLOVE_SYS_004", + category = "SYS", + description = "Module initialization failed", + suggestion = "Check module dependencies and initialization order", + }, +} + +--- Get error information by code +--- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001") +--- @return table? errorInfo Error information or nil if not found +function ErrorCodes.get(code) + -- Handle both short and full format + local shortCode = code:gsub("^FLEXLOVE_", "") + return ErrorCodes.codes[shortCode] +end + +--- Get human-readable description for error code +--- @param code string Error code +--- @return string description Error description +function ErrorCodes.describe(code) + local info = ErrorCodes.get(code) + if info then + return info.description + end + return "Unknown error code: " .. code +end + +--- Get suggested fix for error code +--- @param code string Error code +--- @return string suggestion Suggested fix +function ErrorCodes.getSuggestion(code) + local info = ErrorCodes.get(code) + if info then + return info.suggestion + end + return "No suggestion available" +end + +--- Get category for error code +--- @param code string Error code +--- @return string category Error category name +function ErrorCodes.getCategory(code) + local info = ErrorCodes.get(code) + if info then + return ErrorCodes.categories[info.category] or info.category + end + return "Unknown" +end + +--- List all error codes in a category +--- @param category string Category code (e.g., "VAL", "LAY") +--- @return table codes List of error codes in category +function ErrorCodes.listByCategory(category) + local result = {} + for code, info in pairs(ErrorCodes.codes) do + if info.category == category then + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + }) + end + end + table.sort(result, function(a, b) + return a.code < b.code + end) + return result +end + +--- Search error codes by keyword +--- @param keyword string Keyword to search for +--- @return table codes Matching error codes +function ErrorCodes.search(keyword) + keyword = keyword:lower() + local result = {} + for code, info in pairs(ErrorCodes.codes) do + local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower() + if searchText:find(keyword, 1, true) then + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + category = ErrorCodes.categories[info.category], + }) + end + end + return result +end + +--- Get all error codes +--- @return table codes All error codes +function ErrorCodes.listAll() + local result = {} + for code, info in pairs(ErrorCodes.codes) do + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + category = ErrorCodes.categories[info.category], + }) + end + table.sort(result, function(a, b) + return a.code < b.code + end) + return result +end + +--- Format error message with code +--- @param code string Error code +--- @param message string Error message +--- @return string formattedMessage Formatted error message with code +function ErrorCodes.formatMessage(code, message) + local info = ErrorCodes.get(code) + if info then + return string.format("[%s] %s", info.code, message) + end + return message +end + +--- Validate that all error codes are unique and properly formatted +--- @return boolean, string? Returns true if valid, or false with error message +function ErrorCodes.validate() + local seen = {} + local fullCodes = {} + + for code, info in pairs(ErrorCodes.codes) do + -- Check for duplicates + if seen[code] then + return false, "Duplicate error code: " .. code + end + seen[code] = true + + if fullCodes[info.code] then + return false, "Duplicate full error code: " .. info.code + end + fullCodes[info.code] = true + + -- Check format + if not code:match("^[A-Z]+_[0-9]+$") then + return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)" + end + + -- Check full code format + local expectedFullCode = "FLEXLOVE_" .. code + if info.code ~= expectedFullCode then + return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code + end + + -- Check required fields + if not info.description or info.description == "" then + return false, "Missing description for " .. code + end + if not info.suggestion or info.suggestion == "" then + return false, "Missing suggestion for " .. code + end + if not info.category or info.category == "" then + return false, "Missing category for " .. code + end + end + + return true, nil +end + +return ErrorCodes diff --git a/modules/Performance.lua b/modules/Performance.lua new file mode 100644 index 0000000..a7bccaa --- /dev/null +++ b/modules/Performance.lua @@ -0,0 +1,435 @@ +--- Performance monitoring module for FlexLove +--- Provides timing, profiling, and performance metrics +---@class Performance +local Performance = {} + +-- Configuration +local config = { + enabled = false, + hudEnabled = false, + hudToggleKey = "f3", + warningThresholdMs = 13.0, -- Yellow warning + criticalThresholdMs = 16.67, -- Red warning (60 FPS) + logToConsole = false, + logWarnings = true, +} + +-- State +local timers = {} -- Active timers {name -> startTime} +local metrics = {} -- Accumulated metrics {name -> {total, count, min, max}} +local frameMetrics = { + frameCount = 0, + totalTime = 0, + lastFrameTime = 0, + minFrameTime = math.huge, + maxFrameTime = 0, + fps = 0, + lastFpsUpdate = 0, + fpsUpdateInterval = 0.5, -- Update FPS every 0.5s +} +local memoryMetrics = { + current = 0, + peak = 0, + gcCount = 0, + lastGcCheck = 0, +} +local warnings = {} +local lastFrameStart = nil + +--- Initialize performance monitoring +--- @param options table? Optional configuration overrides +function Performance.init(options) + if options then + for k, v in pairs(options) do + config[k] = v + end + end + Performance.reset() +end + +--- Enable performance monitoring +function Performance.enable() + config.enabled = true +end + +--- Disable performance monitoring +function Performance.disable() + config.enabled = false +end + +--- Check if performance monitoring is enabled +--- @return boolean +function Performance.isEnabled() + return config.enabled +end + +--- Toggle performance HUD +function Performance.toggleHUD() + config.hudEnabled = not config.hudEnabled +end + +--- Reset all metrics +function Performance.reset() + timers = {} + metrics = {} + warnings = {} + frameMetrics.frameCount = 0 + frameMetrics.totalTime = 0 + frameMetrics.lastFrameTime = 0 + frameMetrics.minFrameTime = math.huge + frameMetrics.maxFrameTime = 0 + memoryMetrics.current = 0 + memoryMetrics.peak = 0 + memoryMetrics.gcCount = 0 +end + +--- Start a named timer +--- @param name string Timer name +function Performance.startTimer(name) + if not config.enabled then + return + end + timers[name] = love.timer.getTime() +end + +--- Stop a named timer and record the elapsed time +--- @param name string Timer name +--- @return number? elapsedMs Elapsed time in milliseconds, or nil if timer not found +function Performance.stopTimer(name) + if not config.enabled then + return nil + end + + local startTime = timers[name] + if not startTime then + if config.logWarnings then + print(string.format("[Performance] Warning: Timer '%s' was not started", name)) + end + return nil + end + + local elapsed = (love.timer.getTime() - startTime) * 1000 -- Convert to ms + timers[name] = nil + + -- Update metrics + if not metrics[name] then + metrics[name] = { + total = 0, + count = 0, + min = math.huge, + max = 0, + average = 0, + } + end + + local m = metrics[name] + m.total = m.total + elapsed + m.count = m.count + 1 + m.min = math.min(m.min, elapsed) + m.max = math.max(m.max, elapsed) + m.average = m.total / m.count + + -- Check for warnings + if elapsed > config.criticalThresholdMs then + Performance.addWarning(name, elapsed, "critical") + elseif elapsed > config.warningThresholdMs then + Performance.addWarning(name, elapsed, "warning") + end + + if config.logToConsole then + print(string.format("[Performance] %s: %.3fms", name, elapsed)) + end + + return elapsed +end + +--- Wrap a function with performance timing +--- @param name string Metric name +--- @param fn function Function to measure +--- @return function Wrapped function +function Performance.measure(name, fn) + if not config.enabled then + return fn + end + + return function(...) + Performance.startTimer(name) + local results = table.pack(fn(...)) + Performance.stopTimer(name) + return table.unpack(results, 1, results.n) + end +end + +--- Start frame timing (call at beginning of frame) +function Performance.startFrame() + if not config.enabled then + return + end + lastFrameStart = love.timer.getTime() + Performance.updateMemory() +end + +--- End frame timing (call at end of frame) +function Performance.endFrame() + if not config.enabled or not lastFrameStart then + return + end + + local now = love.timer.getTime() + local frameTime = (now - lastFrameStart) * 1000 -- ms + + frameMetrics.lastFrameTime = frameTime + frameMetrics.totalTime = frameMetrics.totalTime + frameTime + frameMetrics.frameCount = frameMetrics.frameCount + 1 + frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime) + frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime) + + -- Update FPS + if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then + frameMetrics.fps = math.floor(1000 / frameTime + 0.5) + frameMetrics.lastFpsUpdate = now + end + + -- Check for frame drops + if frameTime > config.criticalThresholdMs then + Performance.addWarning("frame", frameTime, "critical") + end +end + +--- Update memory metrics +function Performance.updateMemory() + if not config.enabled then + return + end + + local memKb = collectgarbage("count") + memoryMetrics.current = memKb + memoryMetrics.peak = math.max(memoryMetrics.peak, memKb) + + -- Track GC cycles + local now = love.timer.getTime() + if now - memoryMetrics.lastGcCheck >= 1.0 then + memoryMetrics.gcCount = memoryMetrics.gcCount + 1 + memoryMetrics.lastGcCheck = now + end +end + +--- Add a performance warning +--- @param name string Metric name +--- @param value number Metric value +--- @param level "warning"|"critical" Warning level +function Performance.addWarning(name, value, level) + if not config.logWarnings then + return + end + + table.insert(warnings, { + name = name, + value = value, + level = level, + time = love.timer.getTime(), + }) + + -- Keep only last 100 warnings + if #warnings > 100 then + table.remove(warnings, 1) + end +end + +--- Get current FPS +--- @return number fps Frames per second +function Performance.getFPS() + return frameMetrics.fps +end + +--- Get frame metrics +--- @return table frameMetrics Frame timing data +function Performance.getFrameMetrics() + return { + fps = frameMetrics.fps, + lastFrameTime = frameMetrics.lastFrameTime, + minFrameTime = frameMetrics.minFrameTime, + maxFrameTime = frameMetrics.maxFrameTime, + averageFrameTime = frameMetrics.frameCount > 0 and frameMetrics.totalTime / frameMetrics.frameCount or 0, + frameCount = frameMetrics.frameCount, + } +end + +--- Get memory metrics +--- @return table memoryMetrics Memory usage data +function Performance.getMemoryMetrics() + Performance.updateMemory() + return { + currentKb = memoryMetrics.current, + currentMb = memoryMetrics.current / 1024, + peakKb = memoryMetrics.peak, + peakMb = memoryMetrics.peak / 1024, + gcCount = memoryMetrics.gcCount, + } +end + +--- Get all performance metrics +--- @return table metrics All collected metrics +function Performance.getMetrics() + local result = { + frame = Performance.getFrameMetrics(), + memory = Performance.getMemoryMetrics(), + timings = {}, + } + + for name, data in pairs(metrics) do + result.timings[name] = { + average = data.average, + min = data.min, + max = data.max, + total = data.total, + count = data.count, + } + end + + return result +end + +--- Get recent warnings +--- @param count number? Number of warnings to return (default: 10) +--- @return table warnings Recent warnings +function Performance.getWarnings(count) + count = count or 10 + local result = {} + local start = math.max(1, #warnings - count + 1) + for i = start, #warnings do + table.insert(result, warnings[i]) + end + return result +end + +--- Export metrics to JSON format +--- @return string json JSON string of metrics +function Performance.exportJSON() + local allMetrics = Performance.getMetrics() + -- Simple JSON encoding (for more complex needs, use a JSON library) + local json = "{\n" + json = json .. string.format(' "fps": %d,\n', allMetrics.frame.fps) + json = json .. string.format(' "averageFrameTime": %.3f,\n', allMetrics.frame.averageFrameTime) + json = json .. string.format(' "memoryMb": %.2f,\n', allMetrics.memory.currentMb) + json = json .. ' "timings": {\n' + + local timingPairs = {} + for name, data in pairs(allMetrics.timings) do + table.insert( + timingPairs, + string.format(' "%s": {"average": %.3f, "min": %.3f, "max": %.3f, "count": %d}', name, data.average, data.min, data.max, data.count) + ) + end + json = json .. table.concat(timingPairs, ",\n") .. "\n" + + json = json .. " }\n" + json = json .. "}" + return json +end + +--- Export metrics to CSV format +--- @return string csv CSV string of metrics +function Performance.exportCSV() + local csv = "Name,Average (ms),Min (ms),Max (ms),Count\n" + for name, data in pairs(metrics) do + csv = csv .. string.format("%s,%.3f,%.3f,%.3f,%d\n", name, data.average, data.min, data.max, data.count) + end + return csv +end + +--- Render performance HUD +--- @param x number? X position (default: 10) +--- @param y number? Y position (default: 10) +function Performance.renderHUD(x, y) + if not config.hudEnabled then + return + end + + x = x or 10 + y = y or 10 + + local fm = Performance.getFrameMetrics() + local mm = Performance.getMemoryMetrics() + + -- Background + love.graphics.setColor(0, 0, 0, 0.8) + love.graphics.rectangle("fill", x, y, 300, 200) + + -- Text + love.graphics.setColor(1, 1, 1, 1) + local lineHeight = 18 + local currentY = y + 10 + + -- FPS + local fpsColor = { 1, 1, 1 } + if fm.lastFrameTime > config.criticalThresholdMs then + fpsColor = { 1, 0, 0 } -- Red + elseif fm.lastFrameTime > config.warningThresholdMs then + fpsColor = { 1, 1, 0 } -- Yellow + end + love.graphics.setColor(fpsColor) + love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY) + currentY = currentY + lineHeight + + -- Frame times + love.graphics.setColor(1, 1, 1, 1) + love.graphics.print(string.format("Avg Frame: %.2fms", fm.averageFrameTime), x + 10, currentY) + currentY = currentY + lineHeight + love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY) + currentY = currentY + lineHeight + + -- Memory + love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY) + currentY = currentY + lineHeight + + -- Separator + currentY = currentY + 5 + + -- Top timings + local sortedMetrics = {} + for name, data in pairs(metrics) do + table.insert(sortedMetrics, { name = name, average = data.average }) + end + table.sort(sortedMetrics, function(a, b) + return a.average > b.average + end) + + love.graphics.print("Top Timings:", x + 10, currentY) + currentY = currentY + lineHeight + + for i = 1, math.min(5, #sortedMetrics) do + local m = sortedMetrics[i] + love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY) + currentY = currentY + lineHeight + end + + -- Warnings count + if #warnings > 0 then + love.graphics.setColor(1, 0.5, 0, 1) + love.graphics.print(string.format("Warnings: %d", #warnings), x + 10, currentY) + end +end + +--- Handle keyboard input for HUD toggle +--- @param key string Key pressed +function Performance.keypressed(key) + if key == config.hudToggleKey then + Performance.toggleHUD() + end +end + +--- Get configuration +--- @return table config Current configuration +function Performance.getConfig() + return config +end + +--- Set configuration option +--- @param key string Configuration key +--- @param value any Configuration value +function Performance.setConfig(key, value) + config[key] = value +end + +return Performance diff --git a/modules/TextEditor.lua b/modules/TextEditor.lua index 50543c7..5efe64c 100644 --- a/modules/TextEditor.lua +++ b/modules/TextEditor.lua @@ -13,6 +13,10 @@ local utf8 = utf8 or require("utf8") ---@field scrollable boolean ---@field autoGrow boolean ---@field selectOnFocus boolean +---@field sanitize boolean +---@field allowNewlines boolean +---@field allowTabs boolean +---@field customSanitizer function? ---@field cursorColor Color? ---@field selectionColor Color? ---@field cursorBlinkRate number @@ -37,12 +41,14 @@ local utf8 = utf8 or require("utf8") ---@field onTextInput fun(element:Element, text:string)? ---@field onTextChange fun(element:Element, text:string)? ---@field onEnter fun(element:Element)? +---@field onSanitize fun(element:Element, original:string, sanitized:string)? ---@field _element Element? ---@field _Context table ---@field _StateManager table ---@field _Color table ---@field _FONT_CACHE table ---@field _getModifiers function +---@field _utils table ---@field _textDragOccurred boolean? local TextEditor = {} TextEditor.__index = TextEditor @@ -60,6 +66,10 @@ TextEditor.__index = TextEditor ---@field scrollable boolean -- Whether text is scrollable ---@field autoGrow boolean -- Whether element auto-grows with text ---@field selectOnFocus boolean -- Whether to select all text on focus +---@field sanitize boolean? -- Whether to sanitize text input (default: true) +---@field allowNewlines boolean? -- Whether to allow newline characters (default: true in multiline) +---@field allowTabs boolean? -- Whether to allow tab characters (default: true) +---@field customSanitizer function? -- Custom sanitization function ---@field cursorColor Color? -- Cursor color ---@field selectionColor Color? -- Selection background color ---@field cursorBlinkRate number -- Cursor blink rate in seconds @@ -77,6 +87,7 @@ function TextEditor.new(config, deps) self._Color = deps.Color self._FONT_CACHE = deps.utils.FONT_CACHE self._getModifiers = deps.utils.getModifiers + self._utils = deps.utils -- Store configuration self.editable = config.editable or false @@ -94,9 +105,21 @@ function TextEditor.new(config, deps) self.cursorColor = config.cursorColor self.selectionColor = config.selectionColor self.cursorBlinkRate = config.cursorBlinkRate or 0.5 + + -- Sanitization configuration + self.sanitize = config.sanitize ~= false -- Default to true + -- If allowNewlines is explicitly set, use that value; otherwise follow multiline setting + if config.allowNewlines ~= nil then + self.allowNewlines = config.allowNewlines + else + self.allowNewlines = self.multiline + end + self.allowTabs = config.allowTabs ~= false -- Default to true + self.customSanitizer = config.customSanitizer - -- Initialize text buffer state - self._textBuffer = config.text or "" + -- Initialize text buffer state (with sanitization) + local initialText = config.text or "" + self._textBuffer = self:_sanitizeText(initialText) self._lines = nil self._wrappedLines = nil self._textDirty = true @@ -127,6 +150,7 @@ function TextEditor.new(config, deps) self.onTextInput = config.onTextInput self.onTextChange = config.onTextChange self.onEnter = config.onEnter + self.onSanitize = config.onSanitize -- Element reference (set via initialize) self._element = nil @@ -134,6 +158,36 @@ function TextEditor.new(config, deps) return self end +---Internal: Sanitize text input +---@param text string -- Text to sanitize +---@return string -- Sanitized text +function TextEditor:_sanitizeText(text) + if not self.sanitize then + return text + end + + -- Use custom sanitizer if provided + if self.customSanitizer then + return self.customSanitizer(text) or text + end + + local options = { + maxLength = self.maxLength, + allowNewlines = self.allowNewlines, + allowTabs = self.allowTabs, + trimWhitespace = false -- Preserve whitespace in text editors + } + + local sanitized = self._utils.sanitizeText(text, options) + + -- Trigger callback if text was sanitized + if sanitized ~= text and self.onSanitize and self._element then + self.onSanitize(self._element, text, sanitized) + end + + return sanitized +end + ---Initialize TextEditor with parent element reference ---@param element table The parent Element instance function TextEditor:initialize(element) @@ -187,8 +241,16 @@ end ---Set text buffer and mark dirty ---@param text string -function TextEditor:setText(text) - self._textBuffer = text or "" +---@param skipSanitization boolean? -- Skip sanitization (for trusted input) +function TextEditor:setText(text, skipSanitization) + text = text or "" + + -- Sanitize text unless explicitly skipped + if not skipSanitization then + text = self:_sanitizeText(text) + end + + self._textBuffer = text self:_markTextDirty() self:_updateTextIfDirty() self:_validateCursorPosition() @@ -198,9 +260,20 @@ end ---Insert text at position ---@param text string -- Text to insert ---@param position number? -- Position to insert at (default: cursor position) -function TextEditor:insertText(text, position) +---@param skipSanitization boolean? -- Skip sanitization (for internal use) +function TextEditor:insertText(text, position, skipSanitization) position = position or self._cursorPosition local buffer = self._textBuffer or "" + + -- Sanitize text unless explicitly skipped + if not skipSanitization then + text = self:_sanitizeText(text) + end + + -- Check if text is empty after sanitization + if not text or text == "" then + return + end -- Check maxLength constraint before inserting if self.maxLength then @@ -209,7 +282,22 @@ function TextEditor:insertText(text, position) local newLength = currentLength + textLength if newLength > self.maxLength then - return + -- Truncate text to fit + local remaining = self.maxLength - currentLength + if remaining <= 0 then + return + end + -- Truncate to remaining characters + local truncated = "" + local count = 0 + for _, code in utf8.codes(text) do + if count >= remaining then + break + end + truncated = truncated .. utf8.char(code) + count = count + 1 + end + text = truncated end end diff --git a/modules/Units.lua b/modules/Units.lua index faa4bc0..f778b94 100644 --- a/modules/Units.lua +++ b/modules/Units.lua @@ -188,7 +188,29 @@ function Units.isValid(unitStr) return false end - local _, unit = Units.parse(unitStr) + -- Check for invalid format (space between number and unit) + if unitStr:match("%d%s+%a") then + return false + end + + -- Match number followed by optional unit + local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$") + if not numStr then + return false + end + + -- Check if numeric part is valid + local num = tonumber(numStr) + if not num then + return false + end + + -- Default to pixels if no unit specified + if unit == "" then + unit = "px" + end + + -- Check if unit is valid local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } return validUnits[unit] == true end diff --git a/modules/utils.lua b/modules/utils.lua index 3570e97..58c23f6 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -429,6 +429,525 @@ local function normalizeBooleanTable(value, defaultValue) return { vertical = defaultValue, horizontal = defaultValue } end +-- Text sanitization utilities + +--- Sanitize text to prevent security vulnerabilities +--- @param text string? Text to sanitize +--- @param options table? Sanitization options +--- @return string Sanitized text +local function sanitizeText(text, options) + -- Handle nil or non-string inputs + if text == nil then + return "" + end + if type(text) ~= "string" then + text = tostring(text) + end + + -- Default options + options = options or {} + local maxLength = options.maxLength or 10000 + local allowNewlines = options.allowNewlines ~= false -- default true + local allowTabs = options.allowTabs ~= false -- default true + local stripControls = options.stripControls ~= false -- default true + local trimWhitespace = options.trimWhitespace ~= false -- default true + + -- Remove null bytes (critical security risk) + text = text:gsub("%z", "") + + -- Strip control characters except allowed ones + if stripControls then + local pattern = "[\1-\31\127]" -- All control characters + if allowNewlines and allowTabs then + pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13) + elseif allowNewlines then + pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13) + elseif allowTabs then + pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9) + end + text = text:gsub(pattern, "") + end + + -- Trim leading/trailing whitespace + if trimWhitespace then + text = text:match("^%s*(.-)%s*$") or "" + end + + -- Limit string length + if #text > maxLength then + text = text:sub(1, maxLength) + if ErrorHandler then + ErrorHandler.warn("utils", string.format("Text truncated from %d to %d characters", #text, maxLength)) + end + end + + return text +end + +--- Validate text input against rules +--- @param text string Text to validate +--- @param rules table Validation rules +--- @return boolean, string? Returns true if valid, or false with error message +local function validateTextInput(text, rules) + rules = rules or {} + + -- Check minimum length + if rules.minLength and #text < rules.minLength then + return false, string.format("Text must be at least %d characters", rules.minLength) + end + + -- Check maximum length + if rules.maxLength and #text > rules.maxLength then + return false, string.format("Text must be at most %d characters", rules.maxLength) + end + + -- Check pattern match + if rules.pattern and not text:match(rules.pattern) then + return false, rules.patternError or "Text does not match required pattern" + end + + -- Check character whitelist + if rules.allowedChars then + local pattern = "[^" .. rules.allowedChars .. "]" + if text:match(pattern) then + return false, "Text contains invalid characters" + end + end + + -- Check character blacklist + if rules.forbiddenChars then + local pattern = "[" .. rules.forbiddenChars .. "]" + if text:match(pattern) then + return false, "Text contains forbidden characters" + end + end + + return true, nil +end + +--- Escape HTML special characters +--- @param text string Text to escape +--- @return string Escaped text +local function escapeHtml(text) + if text == nil then + return "" + end + text = tostring(text) + text = text:gsub("&", "&") + text = text:gsub("<", "<") + text = text:gsub(">", ">") + text = text:gsub('"', """) + text = text:gsub("'", "'") + return text +end + +--- Escape Lua pattern special characters +--- @param text string Text to escape +--- @return string Escaped text +local function escapeLuaPattern(text) + if text == nil then + return "" + end + text = tostring(text) + -- Escape all Lua pattern special characters + text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + return text +end + +--- Strip all non-printable characters from text +--- @param text string Text to clean +--- @return string Cleaned text +local function stripNonPrintable(text) + if text == nil then + return "" + end + text = tostring(text) + -- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13) + text = text:gsub("[^\9\10\13\32-\126]", "") + return text +end + +-- Path validation utilities + +--- Sanitize a file path +--- @param path string Path to sanitize +--- @return string Sanitized path +local function sanitizePath(path) + if path == nil then + return "" + end + path = tostring(path) + + -- Trim whitespace + path = path:match("^%s*(.-)%s*$") or "" + + -- Normalize separators to forward slash + path = path:gsub("\\", "/") + + -- Remove duplicate slashes + path = path:gsub("/+", "/") + + -- Remove trailing slash (except for root) + if #path > 1 and path:sub(-1) == "/" then + path = path:sub(1, -2) + end + + return path +end + +--- Check if a path is safe (no traversal attacks) +--- @param path string Path to check +--- @param baseDir string? Base directory to check against (optional) +--- @return boolean, string? Returns true if safe, or false with reason +local function isPathSafe(path, baseDir) + if path == nil or path == "" then + return false, "Path is empty" + end + + -- Sanitize the path + path = sanitizePath(path) + + -- Check for suspicious patterns + if path:match("%.%.") then + return false, "Path contains '..' (parent directory reference)" + end + + -- Check for null bytes + if path:match("%z") then + return false, "Path contains null bytes" + end + + -- Check for encoded traversal attempts (including double-encoding) + local lowerPath = path:lower() + if lowerPath:match("%%2e") or lowerPath:match("%%2f") or lowerPath:match("%%5c") or + lowerPath:match("%%252e") or lowerPath:match("%%252f") or lowerPath:match("%%255c") then + return false, "Path contains URL-encoded directory separators" + end + + -- If baseDir is provided, ensure path is within it + if baseDir then + baseDir = sanitizePath(baseDir) + + -- For relative paths, prepend baseDir + local fullPath = path + if not path:match("^/") and not path:match("^%a:") then + fullPath = baseDir .. "/" .. path + end + fullPath = sanitizePath(fullPath) + + -- Check if fullPath starts with baseDir + if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then + return false, "Path is outside allowed directory" + end + end + + return true, nil +end + +--- Validate a file path with comprehensive checks +--- @param path string Path to validate +--- @param options table? Validation options +--- @return boolean, string? Returns true if valid, or false with error message +local function validatePath(path, options) + options = options or {} + + -- Check path is not nil/empty + if path == nil or path == "" then + return false, "Path is empty" + end + + path = tostring(path) + + -- Check maximum length + local maxLength = options.maxLength or 4096 + if #path > maxLength then + return false, string.format("Path exceeds maximum length of %d characters", maxLength) + end + + -- Sanitize path + path = sanitizePath(path) + + -- Check for safety (traversal attacks) + local safe, reason = isPathSafe(path, options.baseDir) + if not safe then + return false, reason + end + + -- Check allowed extensions + if options.allowedExtensions then + local ext = path:match("%.([^%.]+)$") + if not ext then + return false, "Path has no file extension" + end + + ext = ext:lower() + local allowed = false + for _, allowedExt in ipairs(options.allowedExtensions) do + if ext == allowedExt:lower() then + allowed = true + break + end + end + + if not allowed then + return false, string.format("File extension '%s' is not allowed", ext) + end + end + + -- Check if file must exist + if options.mustExist and love and love.filesystem then + local info = love.filesystem.getInfo(path) + if not info then + return false, "File does not exist" + end + end + + return true, nil +end + +--- Get file extension from path +--- @param path string File path +--- @return string? extension File extension (lowercase) or nil +local function getFileExtension(path) + if not path then + return nil + end + local ext = path:match("%.([^%.]+)$") + return ext and ext:lower() or nil +end + +--- Check if path has allowed extension +--- @param path string File path +--- @param allowedExtensions table Array of allowed extensions +--- @return boolean +local function hasAllowedExtension(path, allowedExtensions) + local ext = getFileExtension(path) + if not ext then + return false + end + + for _, allowedExt in ipairs(allowedExtensions) do + if ext == allowedExt:lower() then + return true + end + end + + return false +end + +-- Numeric validation utilities + +--- Check if a value is NaN (not-a-number) +--- @param value any Value to check +--- @return boolean +local function isNaN(value) + return type(value) == "number" and value ~= value +end + +--- Check if a value is Infinity +--- @param value any Value to check +--- @return boolean +local function isInfinity(value) + return type(value) == "number" and (value == math.huge or value == -math.huge) +end + +--- Validate a numeric value with comprehensive checks +--- @param value any Value to validate +--- @param options table? Validation options +--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue +local function validateNumber(value, options) + options = options or {} + + -- Check if value is a number type + if type(value) ~= "number" then + if options.default ~= nil then + return true, nil, options.default + end + return false, string.format("Value must be a number, got %s", type(value)), nil + end + + -- Check for NaN + if isNaN(value) then + if not options.allowNaN then + if options.default ~= nil then + return true, nil, options.default + end + return false, "Value is NaN (not-a-number)", nil + end + end + + -- Check for Infinity + if isInfinity(value) then + if not options.allowInfinity then + if options.default ~= nil then + return true, nil, options.default + end + return false, "Value is Infinity", nil + end + end + + -- Check for integer requirement + if options.integer and math.floor(value) ~= value then + return false, string.format("Value must be an integer, got %s", value), nil + end + + -- Check for positive requirement + if options.positive and value <= 0 then + return false, string.format("Value must be positive, got %s", value), nil + end + + -- Check bounds + if options.min and value < options.min then + return false, string.format("Value %s is below minimum %s", value, options.min), nil + end + + if options.max and value > options.max then + return false, string.format("Value %s is above maximum %s", value, options.max), nil + end + + return true, nil, value +end + +--- Sanitize a numeric value (never errors, always returns valid number) +--- @param value any Value to sanitize +--- @param min number? Minimum value +--- @param max number? Maximum value +--- @param default number? Default value for invalid inputs +--- @return number Sanitized value +local function sanitizeNumber(value, min, max, default) + default = default or 0 + min = min or -math.huge + max = max or math.huge + + -- Convert to number if possible + if type(value) == "string" then + value = tonumber(value) + end + + -- Handle non-numeric + if type(value) ~= "number" then + return default + end + + -- Handle NaN + if isNaN(value) then + return default + end + + -- Handle Infinity + if value == math.huge then + return max + end + if value == -math.huge then + return min + end + + -- Clamp to range + return clamp(value, min, max) +end + +--- Validate and convert to integer +--- @param value any Value to validate +--- @param min number? Minimum value +--- @param max number? Maximum value +--- @return boolean, string?, number? Returns valid, errorMessage, integerValue +local function validateInteger(value, min, max) + local valid, err, sanitized = validateNumber(value, { + min = min, + max = max, + integer = true, + }) + + if not valid then + return false, err, nil + end + + return true, nil, math.floor(sanitized or value) +end + +--- Validate and normalize percentage value +--- @param value any Value to validate (can be "50%", 0.5, or 50) +--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1) +local function validatePercentage(value) + -- Handle string percentage + if type(value) == "string" then + local num = value:match("^(%d+%.?%d*)%%$") + if num then + value = tonumber(num) + if value then + value = value / 100 + end + else + value = tonumber(value) + end + end + + if type(value) ~= "number" then + return false, "Percentage must be a number", nil + end + + if isNaN(value) or isInfinity(value) then + return false, "Percentage cannot be NaN or Infinity", nil + end + + -- If value is > 1, assume it's 0-100 range + if value > 1 then + value = value / 100 + end + + -- Clamp to 0-1 + value = clamp(value, 0, 1) + + return true, nil, value +end + +--- Validate opacity value (0-1) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue +local function validateOpacity(value) + return validateNumber(value, { min = 0, max = 1, default = 1 }) +end + +--- Validate degree value (0-360) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue +local function validateDegrees(value) + local valid, err, sanitized = validateNumber(value) + if not valid then + return false, err, nil + end + + -- Normalize to 0-360 range + local degrees = sanitized or value + degrees = degrees % 360 + if degrees < 0 then + degrees = degrees + 360 + end + + return true, nil, degrees +end + +--- Validate coordinate value (pixel position) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue +local function validateCoordinate(value) + return validateNumber(value, { + allowNaN = false, + allowInfinity = false, + }) +end + +--- Validate dimension value (width/height, must be non-negative) +--- @param value any Value to validate +--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue +local function validateDimension(value) + return validateNumber(value, { + min = 0, + allowNaN = false, + allowInfinity = false, + }) +end + return { enums = enums, FONT_CACHE = FONT_CACHE, @@ -450,4 +969,27 @@ return { resolveFontPath = resolveFontPath, getFont = getFont, applyContentMultiplier = applyContentMultiplier, + -- Text sanitization + sanitizeText = sanitizeText, + validateTextInput = validateTextInput, + escapeHtml = escapeHtml, + escapeLuaPattern = escapeLuaPattern, + stripNonPrintable = stripNonPrintable, + -- Path validation + sanitizePath = sanitizePath, + isPathSafe = isPathSafe, + validatePath = validatePath, + getFileExtension = getFileExtension, + hasAllowedExtension = hasAllowedExtension, + -- Numeric validation + isNaN = isNaN, + isInfinity = isInfinity, + validateNumber = validateNumber, + sanitizeNumber = sanitizeNumber, + validateInteger = validateInteger, + validatePercentage = validatePercentage, + validateOpacity = validateOpacity, + validateDegrees = validateDegrees, + validateCoordinate = validateCoordinate, + validateDimension = validateDimension, } diff --git a/testing/__tests__/color_validation_test.lua b/testing/__tests__/color_validation_test.lua new file mode 100644 index 0000000..e4089ed --- /dev/null +++ b/testing/__tests__/color_validation_test.lua @@ -0,0 +1,514 @@ +-- Import test framework +package.path = package.path .. ";../../?.lua" +local luaunit = require("testing.luaunit") + +-- Set up LΓ–VE stub environment +require("testing.loveStub") + +-- Import the Color module +local Color = require("modules.Color") + +-- Test Suite for Color Validation +TestColorValidation = {} + +-- === validateColorChannel Tests === + +function TestColorValidation:test_validateColorChannel_valid_0to1() + local valid, clamped = Color.validateColorChannel(0.5, 1) + luaunit.assertTrue(valid) + luaunit.assertEquals(clamped, 0.5) +end + +function TestColorValidation:test_validateColorChannel_valid_0to255() + local valid, clamped = Color.validateColorChannel(128, 255) + luaunit.assertTrue(valid) + luaunit.assertAlmostEquals(clamped, 128/255, 0.001) +end + +function TestColorValidation:test_validateColorChannel_clamp_below_min() + local valid, clamped = Color.validateColorChannel(-0.5, 1) + luaunit.assertTrue(valid) + luaunit.assertEquals(clamped, 0) +end + +function TestColorValidation:test_validateColorChannel_clamp_above_max() + local valid, clamped = Color.validateColorChannel(1.5, 1) + luaunit.assertTrue(valid) + luaunit.assertEquals(clamped, 1) +end + +function TestColorValidation:test_validateColorChannel_clamp_above_255() + local valid, clamped = Color.validateColorChannel(300, 255) + luaunit.assertTrue(valid) + luaunit.assertEquals(clamped, 1) +end + +function TestColorValidation:test_validateColorChannel_nan() + local valid, clamped = Color.validateColorChannel(0/0, 1) + luaunit.assertFalse(valid) + luaunit.assertNil(clamped) +end + +function TestColorValidation:test_validateColorChannel_infinity() + local valid, clamped = Color.validateColorChannel(math.huge, 1) + luaunit.assertFalse(valid) + luaunit.assertNil(clamped) +end + +function TestColorValidation:test_validateColorChannel_negative_infinity() + local valid, clamped = Color.validateColorChannel(-math.huge, 1) + luaunit.assertFalse(valid) + luaunit.assertNil(clamped) +end + +function TestColorValidation:test_validateColorChannel_non_number() + local valid, clamped = Color.validateColorChannel("0.5", 1) + luaunit.assertFalse(valid) + luaunit.assertNil(clamped) +end + +-- === validateHexColor Tests === + +function TestColorValidation:test_validateHexColor_valid_6digit() + local valid, err = Color.validateHexColor("#FF0000") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_valid_6digit_no_hash() + local valid, err = Color.validateHexColor("FF0000") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_valid_8digit() + local valid, err = Color.validateHexColor("#FF0000AA") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_valid_3digit() + local valid, err = Color.validateHexColor("#F00") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_valid_lowercase() + local valid, err = Color.validateHexColor("#ff0000") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_valid_mixed_case() + local valid, err = Color.validateHexColor("#Ff00Aa") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateHexColor_invalid_length() + local valid, err = Color.validateHexColor("#FF00") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid hex length") +end + +function TestColorValidation:test_validateHexColor_invalid_characters() + local valid, err = Color.validateHexColor("#GG0000") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid hex characters") +end + +function TestColorValidation:test_validateHexColor_not_string() + local valid, err = Color.validateHexColor(123) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "must be a string") +end + +-- === validateRGBColor Tests === + +function TestColorValidation:test_validateRGBColor_valid_0to1() + local valid, err = Color.validateRGBColor(0.5, 0.5, 0.5, 1.0, 1) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateRGBColor_valid_0to255() + local valid, err = Color.validateRGBColor(128, 128, 128, 255, 255) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateRGBColor_valid_no_alpha() + local valid, err = Color.validateRGBColor(0.5, 0.5, 0.5, nil, 1) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateRGBColor_invalid_red() + local valid, err = Color.validateRGBColor("red", 0.5, 0.5, 1.0, 1) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid red channel") +end + +function TestColorValidation:test_validateRGBColor_invalid_green() + local valid, err = Color.validateRGBColor(0.5, nil, 0.5, 1.0, 1) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid green channel") +end + +function TestColorValidation:test_validateRGBColor_invalid_blue() + local valid, err = Color.validateRGBColor(0.5, 0.5, {}, 1.0, 1) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid blue channel") +end + +function TestColorValidation:test_validateRGBColor_invalid_alpha() + local valid, err = Color.validateRGBColor(0.5, 0.5, 0.5, 0/0, 1) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid alpha channel") +end + +-- === validateNamedColor Tests === + +function TestColorValidation:test_validateNamedColor_valid_lowercase() + local valid, err = Color.validateNamedColor("red") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateNamedColor_valid_uppercase() + local valid, err = Color.validateNamedColor("RED") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateNamedColor_valid_mixed_case() + local valid, err = Color.validateNamedColor("BlUe") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateNamedColor_invalid_name() + local valid, err = Color.validateNamedColor("notacolor") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Unknown color name") +end + +function TestColorValidation:test_validateNamedColor_not_string() + local valid, err = Color.validateNamedColor(123) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "must be a string") +end + +-- === isValidColorFormat Tests === + +function TestColorValidation:test_isValidColorFormat_hex_6digit() + local format = Color.isValidColorFormat("#FF0000") + luaunit.assertEquals(format, "hex") +end + +function TestColorValidation:test_isValidColorFormat_hex_8digit() + local format = Color.isValidColorFormat("#FF0000AA") + luaunit.assertEquals(format, "hex") +end + +function TestColorValidation:test_isValidColorFormat_hex_3digit() + local format = Color.isValidColorFormat("#F00") + luaunit.assertEquals(format, "hex") +end + +function TestColorValidation:test_isValidColorFormat_named() + local format = Color.isValidColorFormat("red") + luaunit.assertEquals(format, "named") +end + +function TestColorValidation:test_isValidColorFormat_table_array() + local format = Color.isValidColorFormat({0.5, 0.5, 0.5, 1.0}) + luaunit.assertEquals(format, "table") +end + +function TestColorValidation:test_isValidColorFormat_table_named() + local format = Color.isValidColorFormat({r=0.5, g=0.5, b=0.5, a=1.0}) + luaunit.assertEquals(format, "table") +end + +function TestColorValidation:test_isValidColorFormat_table_color_instance() + local color = Color.new(0.5, 0.5, 0.5, 1.0) + local format = Color.isValidColorFormat(color) + luaunit.assertEquals(format, "table") +end + +function TestColorValidation:test_isValidColorFormat_invalid_string() + local format = Color.isValidColorFormat("not-a-color") + luaunit.assertNil(format) +end + +function TestColorValidation:test_isValidColorFormat_invalid_table() + local format = Color.isValidColorFormat({invalid=true}) + luaunit.assertNil(format) +end + +function TestColorValidation:test_isValidColorFormat_nil() + local format = Color.isValidColorFormat(nil) + luaunit.assertNil(format) +end + +function TestColorValidation:test_isValidColorFormat_number() + local format = Color.isValidColorFormat(12345) + luaunit.assertNil(format) +end + +-- === validateColor Tests === + +function TestColorValidation:test_validateColor_hex() + local valid, err = Color.validateColor("#FF0000") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateColor_named() + local valid, err = Color.validateColor("blue") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateColor_table_array() + local valid, err = Color.validateColor({0.5, 0.5, 0.5, 1.0}) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateColor_table_named() + local valid, err = Color.validateColor({r=0.5, g=0.5, b=0.5, a=1.0}) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateColor_named_disallowed() + local valid, err = Color.validateColor("red", {allowNamed=false}) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Named colors not allowed") +end + +function TestColorValidation:test_validateColor_require_alpha_8digit() + local valid, err = Color.validateColor("#FF0000AA", {requireAlpha=true}) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestColorValidation:test_validateColor_require_alpha_6digit() + local valid, err = Color.validateColor("#FF0000", {requireAlpha=true}) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Alpha channel required") +end + +function TestColorValidation:test_validateColor_nil() + local valid, err = Color.validateColor(nil) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "nil") +end + +function TestColorValidation:test_validateColor_invalid() + local valid, err = Color.validateColor("not-a-color") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) + luaunit.assertStrContains(err, "Invalid color format") +end + +-- === sanitizeColor Tests === + +function TestColorValidation:test_sanitizeColor_hex_6digit() + local color = Color.sanitizeColor("#FF0000") + luaunit.assertAlmostEquals(color.r, 1.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_hex_8digit() + local color = Color.sanitizeColor("#FF000080") + luaunit.assertAlmostEquals(color.r, 1.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 0.5, 0.01) +end + +function TestColorValidation:test_sanitizeColor_hex_3digit() + local color = Color.sanitizeColor("#F00") + luaunit.assertAlmostEquals(color.r, 1.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_named_red() + local color = Color.sanitizeColor("red") + luaunit.assertAlmostEquals(color.r, 1.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_named_blue_uppercase() + local color = Color.sanitizeColor("BLUE") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 1.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_named_transparent() + local color = Color.sanitizeColor("transparent") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 0.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_table_array() + local color = Color.sanitizeColor({0.5, 0.6, 0.7, 0.8}) + luaunit.assertAlmostEquals(color.r, 0.5, 0.01) + luaunit.assertAlmostEquals(color.g, 0.6, 0.01) + luaunit.assertAlmostEquals(color.b, 0.7, 0.01) + luaunit.assertAlmostEquals(color.a, 0.8, 0.01) +end + +function TestColorValidation:test_sanitizeColor_table_named() + local color = Color.sanitizeColor({r=0.5, g=0.6, b=0.7, a=0.8}) + luaunit.assertAlmostEquals(color.r, 0.5, 0.01) + luaunit.assertAlmostEquals(color.g, 0.6, 0.01) + luaunit.assertAlmostEquals(color.b, 0.7, 0.01) + luaunit.assertAlmostEquals(color.a, 0.8, 0.01) +end + +function TestColorValidation:test_sanitizeColor_table_array_clamp_high() + local color = Color.sanitizeColor({1.5, 1.5, 1.5, 1.5}) + luaunit.assertAlmostEquals(color.r, 1.0, 0.01) + luaunit.assertAlmostEquals(color.g, 1.0, 0.01) + luaunit.assertAlmostEquals(color.b, 1.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_table_array_clamp_low() + local color = Color.sanitizeColor({-0.5, -0.5, -0.5, -0.5}) + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 0.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_table_no_alpha() + local color = Color.sanitizeColor({0.5, 0.6, 0.7}) + luaunit.assertAlmostEquals(color.r, 0.5, 0.01) + luaunit.assertAlmostEquals(color.g, 0.6, 0.01) + luaunit.assertAlmostEquals(color.b, 0.7, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_color_instance() + local original = Color.new(0.5, 0.6, 0.7, 0.8) + local color = Color.sanitizeColor(original) + luaunit.assertEquals(color, original) +end + +function TestColorValidation:test_sanitizeColor_invalid_returns_default() + local color = Color.sanitizeColor("invalid-color") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +function TestColorValidation:test_sanitizeColor_invalid_custom_default() + local defaultColor = Color.new(1.0, 1.0, 1.0, 1.0) + local color = Color.sanitizeColor("invalid-color", defaultColor) + luaunit.assertEquals(color, defaultColor) +end + +function TestColorValidation:test_sanitizeColor_nil_returns_default() + local color = Color.sanitizeColor(nil) + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 1.0, 0.01) +end + +-- === Color.parse Tests === + +function TestColorValidation:test_parse_hex() + local color = Color.parse("#00FF00") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 1.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) +end + +function TestColorValidation:test_parse_named() + local color = Color.parse("green") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.502, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) +end + +function TestColorValidation:test_parse_table() + local color = Color.parse({0.25, 0.50, 0.75, 1.0}) + luaunit.assertAlmostEquals(color.r, 0.25, 0.01) + luaunit.assertAlmostEquals(color.g, 0.50, 0.01) + luaunit.assertAlmostEquals(color.b, 0.75, 0.01) +end + +function TestColorValidation:test_parse_invalid() + local color = Color.parse("not-a-color") + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) +end + +-- === Edge Case Tests === + +function TestColorValidation:test_edge_empty_string() + local valid, err = Color.validateColor("") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestColorValidation:test_edge_whitespace() + local valid, err = Color.validateColor(" ") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestColorValidation:test_edge_empty_table() + local valid, err = Color.validateColor({}) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestColorValidation:test_edge_hex_with_spaces() + local valid, err = Color.validateColor(" #FF0000 ") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestColorValidation:test_edge_negative_values_clamped() + local color = Color.sanitizeColor({-1, -2, -3, -4}) + luaunit.assertAlmostEquals(color.r, 0.0, 0.01) + luaunit.assertAlmostEquals(color.g, 0.0, 0.01) + luaunit.assertAlmostEquals(color.b, 0.0, 0.01) + luaunit.assertAlmostEquals(color.a, 0.0, 0.01) +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/path_validation_test.lua b/testing/__tests__/path_validation_test.lua new file mode 100644 index 0000000..aefa139 --- /dev/null +++ b/testing/__tests__/path_validation_test.lua @@ -0,0 +1,281 @@ +-- Test suite for path validation functions +-- Tests sanitizePath, isPathSafe, validatePath, getFileExtension, hasAllowedExtension + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local utils = require("modules.utils") + +-- Test suite for sanitizePath +TestSanitizePath = {} + +function TestSanitizePath:testSanitizePath_NilInput() + local result = utils.sanitizePath(nil) + luaunit.assertEquals(result, "") +end + +function TestSanitizePath:testSanitizePath_EmptyString() + local result = utils.sanitizePath("") + luaunit.assertEquals(result, "") +end + +function TestSanitizePath:testSanitizePath_Whitespace() + local result = utils.sanitizePath(" /path/to/file ") + luaunit.assertEquals(result, "/path/to/file") +end + +function TestSanitizePath:testSanitizePath_Backslashes() + local result = utils.sanitizePath("C:\\path\\to\\file") + luaunit.assertEquals(result, "C:/path/to/file") +end + +function TestSanitizePath:testSanitizePath_DuplicateSlashes() + local result = utils.sanitizePath("/path//to///file") + luaunit.assertEquals(result, "/path/to/file") +end + +function TestSanitizePath:testSanitizePath_TrailingSlash() + local result = utils.sanitizePath("/path/to/dir/") + luaunit.assertEquals(result, "/path/to/dir") + + -- Root should keep trailing slash + result = utils.sanitizePath("/") + luaunit.assertEquals(result, "/") +end + +function TestSanitizePath:testSanitizePath_MixedIssues() + local result = utils.sanitizePath(" C:\\path\\\\to///file.txt ") + luaunit.assertEquals(result, "C:/path/to/file.txt") +end + +-- Test suite for isPathSafe +TestIsPathSafe = {} + +function TestIsPathSafe:testIsPathSafe_EmptyPath() + local safe, reason = utils.isPathSafe("") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "empty") +end + +function TestIsPathSafe:testIsPathSafe_NilPath() + local safe, reason = utils.isPathSafe(nil) + luaunit.assertFalse(safe) + luaunit.assertNotNil(reason) +end + +function TestIsPathSafe:testIsPathSafe_ParentDirectory() + local safe, reason = utils.isPathSafe("../etc/passwd") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "..") +end + +function TestIsPathSafe:testIsPathSafe_MultipleParentDirectories() + local safe, reason = utils.isPathSafe("../../../../../../etc/passwd") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "..") +end + +function TestIsPathSafe:testIsPathSafe_HiddenParentDirectory() + local safe, reason = utils.isPathSafe("/path/to/../../../etc/passwd") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "..") +end + +function TestIsPathSafe:testIsPathSafe_NullBytes() + local safe, reason = utils.isPathSafe("/path/to/file\0.txt") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "null") +end + +function TestIsPathSafe:testIsPathSafe_EncodedTraversal() + local safe, reason = utils.isPathSafe("/path/%2e%2e/file") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "encoded") +end + +function TestIsPathSafe:testIsPathSafe_LegitimatePathNoBaseDir() + local safe, reason = utils.isPathSafe("/themes/default.lua") + luaunit.assertTrue(safe) + luaunit.assertNil(reason) +end + +function TestIsPathSafe:testIsPathSafe_LegitimatePathWithBaseDir() + local safe, reason = utils.isPathSafe("/allowed/themes/default.lua", "/allowed") + luaunit.assertTrue(safe) + luaunit.assertNil(reason) +end + +function TestIsPathSafe:testIsPathSafe_RelativePathWithBaseDir() + local safe, reason = utils.isPathSafe("themes/default.lua", "/allowed") + luaunit.assertTrue(safe) + luaunit.assertNil(reason) +end + +function TestIsPathSafe:testIsPathSafe_OutsideBaseDir() + local safe, reason = utils.isPathSafe("/other/themes/default.lua", "/allowed") + luaunit.assertFalse(safe) + luaunit.assertStrContains(reason, "outside") +end + +-- Test suite for validatePath +TestValidatePath = {} + +function TestValidatePath:testValidatePath_EmptyPath() + local valid, err = utils.validatePath("") + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "empty") +end + +function TestValidatePath:testValidatePath_NilPath() + local valid, err = utils.validatePath(nil) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "empty") +end + +function TestValidatePath:testValidatePath_TooLong() + local longPath = string.rep("a", 5000) + local valid, err = utils.validatePath(longPath, { maxLength = 100 }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "maximum length") +end + +function TestValidatePath:testValidatePath_TraversalAttack() + local valid, err = utils.validatePath("../../../etc/passwd") + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestValidatePath:testValidatePath_AllowedExtension() + local valid, err = utils.validatePath("theme.lua", { allowedExtensions = { "lua", "txt" } }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestValidatePath:testValidatePath_DisallowedExtension() + local valid, err = utils.validatePath("script.exe", { allowedExtensions = { "lua", "txt" } }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "not allowed") +end + +function TestValidatePath:testValidatePath_NoExtension() + local valid, err = utils.validatePath("README", { allowedExtensions = { "lua", "txt" } }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "no file extension") +end + +function TestValidatePath:testValidatePath_CaseInsensitiveExtension() + local valid, err = utils.validatePath("Theme.LUA", { allowedExtensions = { "lua" } }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestValidatePath:testValidatePath_WithBaseDir() + local valid, err = utils.validatePath("themes/default.lua", { baseDir = "/allowed" }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +function TestValidatePath:testValidatePath_OutsideBaseDir() + local valid, err = utils.validatePath("/other/theme.lua", { baseDir = "/allowed" }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "outside") +end + +-- Test suite for getFileExtension +TestGetFileExtension = {} + +function TestGetFileExtension:testGetFileExtension_SimpleExtension() + local ext = utils.getFileExtension("file.txt") + luaunit.assertEquals(ext, "txt") +end + +function TestGetFileExtension:testGetFileExtension_MultipleDotsInPath() + local ext = utils.getFileExtension("/path/to/file.name.txt") + luaunit.assertEquals(ext, "txt") +end + +function TestGetFileExtension:testGetFileExtension_NoExtension() + local ext = utils.getFileExtension("README") + luaunit.assertNil(ext) +end + +function TestGetFileExtension:testGetFileExtension_NilPath() + local ext = utils.getFileExtension(nil) + luaunit.assertNil(ext) +end + +function TestGetFileExtension:testGetFileExtension_CaseSensitive() + local ext = utils.getFileExtension("File.TXT") + luaunit.assertEquals(ext, "txt") -- Should be lowercase +end + +function TestGetFileExtension:testGetFileExtension_LongExtension() + local ext = utils.getFileExtension("archive.tar.gz") + luaunit.assertEquals(ext, "gz") +end + +-- Test suite for hasAllowedExtension +TestHasAllowedExtension = {} + +function TestHasAllowedExtension:testHasAllowedExtension_Allowed() + local allowed = utils.hasAllowedExtension("file.txt", { "txt", "lua" }) + luaunit.assertTrue(allowed) +end + +function TestHasAllowedExtension:testHasAllowedExtension_NotAllowed() + local allowed = utils.hasAllowedExtension("file.exe", { "txt", "lua" }) + luaunit.assertFalse(allowed) +end + +function TestHasAllowedExtension:testHasAllowedExtension_CaseInsensitive() + local allowed = utils.hasAllowedExtension("File.TXT", { "txt", "lua" }) + luaunit.assertTrue(allowed) +end + +function TestHasAllowedExtension:testHasAllowedExtension_NoExtension() + local allowed = utils.hasAllowedExtension("README", { "txt", "lua" }) + luaunit.assertFalse(allowed) +end + +function TestHasAllowedExtension:testHasAllowedExtension_EmptyArray() + local allowed = utils.hasAllowedExtension("file.txt", {}) + luaunit.assertFalse(allowed) +end + +-- Test suite for security scenarios +TestPathSecurity = {} + +function TestPathSecurity:testPathSecurity_WindowsTraversal() + local safe = utils.isPathSafe("..\\..\\..\\windows\\system32") + luaunit.assertFalse(safe) +end + +function TestPathSecurity:testPathSecurity_MixedSeparators() + local safe = utils.isPathSafe("../path\\to/../file") + luaunit.assertFalse(safe) +end + +function TestPathSecurity:testPathSecurity_DoubleEncodedTraversal() + local safe = utils.isPathSafe("%252e%252e%252f") + luaunit.assertFalse(safe) +end + +function TestPathSecurity:testPathSecurity_LegitimateFileWithDots() + -- Files with dots in name should be OK (not ..) + local safe = utils.isPathSafe("/path/to/file.backup.txt") + luaunit.assertTrue(safe) +end + +function TestPathSecurity:testPathSecurity_HiddenFiles() + -- Hidden files (starting with .) should be OK + local safe = utils.isPathSafe("/path/to/.hidden") + luaunit.assertTrue(safe) +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/sanitization_test.lua b/testing/__tests__/sanitization_test.lua new file mode 100644 index 0000000..b72b037 --- /dev/null +++ b/testing/__tests__/sanitization_test.lua @@ -0,0 +1,236 @@ +-- Test suite for text sanitization functions +-- Tests sanitizeText, validateTextInput, escapeHtml, escapeLuaPattern, stripNonPrintable + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local utils = require("modules.utils") + +-- Test suite for sanitizeText +TestSanitizeText = {} + +function TestSanitizeText:testSanitizeText_NilInput() + local result = utils.sanitizeText(nil) + luaunit.assertEquals(result, "") +end + +function TestSanitizeText:testSanitizeText_NonStringInput() + local result = utils.sanitizeText(123) + luaunit.assertEquals(result, "123") + + result = utils.sanitizeText(true) + luaunit.assertEquals(result, "true") +end + +function TestSanitizeText:testSanitizeText_NullBytes() + local result = utils.sanitizeText("Hello\0World") + luaunit.assertEquals(result, "HelloWorld") +end + +function TestSanitizeText:testSanitizeText_ControlCharacters() + -- Test removal of various control characters + local result = utils.sanitizeText("Hello\1\2\3World") + luaunit.assertEquals(result, "HelloWorld") +end + +function TestSanitizeText:testSanitizeText_AllowNewlines() + local result = utils.sanitizeText("Hello\nWorld", { allowNewlines = true }) + luaunit.assertEquals(result, "Hello\nWorld") + + result = utils.sanitizeText("Hello\nWorld", { allowNewlines = false }) + luaunit.assertEquals(result, "HelloWorld") +end + +function TestSanitizeText:testSanitizeText_AllowTabs() + local result = utils.sanitizeText("Hello\tWorld", { allowTabs = true }) + luaunit.assertEquals(result, "Hello\tWorld") + + result = utils.sanitizeText("Hello\tWorld", { allowTabs = false }) + luaunit.assertEquals(result, "HelloWorld") +end + +function TestSanitizeText:testSanitizeText_TrimWhitespace() + local result = utils.sanitizeText(" Hello World ", { trimWhitespace = true }) + luaunit.assertEquals(result, "Hello World") + + result = utils.sanitizeText(" Hello World ", { trimWhitespace = false }) + luaunit.assertEquals(result, " Hello World ") +end + +function TestSanitizeText:testSanitizeText_MaxLength() + local longText = string.rep("a", 100) + local result = utils.sanitizeText(longText, { maxLength = 50 }) + luaunit.assertEquals(#result, 50) + luaunit.assertEquals(result, string.rep("a", 50)) +end + +function TestSanitizeText:testSanitizeText_DefaultOptions() + -- Test with default options + local result = utils.sanitizeText(" Hello\nWorld\t ") + luaunit.assertEquals(result, "Hello\nWorld") +end + +function TestSanitizeText:testSanitizeText_EmptyString() + local result = utils.sanitizeText("") + luaunit.assertEquals(result, "") +end + +function TestSanitizeText:testSanitizeText_OnlyWhitespace() + local result = utils.sanitizeText(" \n \t ", { trimWhitespace = true }) + luaunit.assertEquals(result, "") +end + +-- Test suite for validateTextInput +TestValidateTextInput = {} + +function TestValidateTextInput:testValidateTextInput_MinLength() + local valid, err = utils.validateTextInput("abc", { minLength = 3 }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) + + valid, err = utils.validateTextInput("ab", { minLength = 3 }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "at least") +end + +function TestValidateTextInput:testValidateTextInput_MaxLength() + local valid, err = utils.validateTextInput("abc", { maxLength = 5 }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) + + valid, err = utils.validateTextInput("abcdef", { maxLength = 5 }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "at most") +end + +function TestValidateTextInput:testValidateTextInput_Pattern() + local valid, err = utils.validateTextInput("123", { pattern = "^%d+$" }) + luaunit.assertTrue(valid) + luaunit.assertNil(err) + + valid, err = utils.validateTextInput("abc", { pattern = "^%d+$" }) + luaunit.assertFalse(valid) + luaunit.assertNotNil(err) +end + +function TestValidateTextInput:testValidateTextInput_AllowedChars() + local valid, err = utils.validateTextInput("abc123", { allowedChars = "a-z0-9" }) + luaunit.assertTrue(valid) + + valid, err = utils.validateTextInput("abc123!", { allowedChars = "a-z0-9" }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "invalid characters") +end + +function TestValidateTextInput:testValidateTextInput_ForbiddenChars() + local valid, err = utils.validateTextInput("hello world", { forbiddenChars = "@#$%%" }) + luaunit.assertTrue(valid) + + valid, err = utils.validateTextInput("hello@world", { forbiddenChars = "@#$%%" }) + luaunit.assertFalse(valid) + luaunit.assertStrContains(err, "forbidden characters") +end + +function TestValidateTextInput:testValidateTextInput_NoRules() + local valid, err = utils.validateTextInput("anything goes") + luaunit.assertTrue(valid) + luaunit.assertNil(err) +end + +-- Test suite for escapeHtml +TestEscapeHtml = {} + +function TestEscapeHtml:testEscapeHtml_BasicChars() + local result = utils.escapeHtml("") + luaunit.assertEquals(result, "<script>alert('xss')</script>") +end + +function TestEscapeHtml:testEscapeHtml_Ampersand() + local result = utils.escapeHtml("Tom & Jerry") + luaunit.assertEquals(result, "Tom & Jerry") +end + +function TestEscapeHtml:testEscapeHtml_Quotes() + local result = utils.escapeHtml('Hello "World"') + luaunit.assertEquals(result, "Hello "World"") + + result = utils.escapeHtml("It's fine") + luaunit.assertEquals(result, "It's fine") +end + +function TestEscapeHtml:testEscapeHtml_NilInput() + local result = utils.escapeHtml(nil) + luaunit.assertEquals(result, "") +end + +function TestEscapeHtml:testEscapeHtml_EmptyString() + local result = utils.escapeHtml("") + luaunit.assertEquals(result, "") +end + +-- Test suite for escapeLuaPattern +TestEscapeLuaPattern = {} + +function TestEscapeLuaPattern:testEscapeLuaPattern_SpecialChars() + local result = utils.escapeLuaPattern("^$()%.[]*+-?") + luaunit.assertEquals(result, "%^%$%(%)%%%.%[%]%*%+%-%?") +end + +function TestEscapeLuaPattern:testEscapeLuaPattern_NormalText() + local result = utils.escapeLuaPattern("Hello World") + luaunit.assertEquals(result, "Hello World") +end + +function TestEscapeLuaPattern:testEscapeLuaPattern_NilInput() + local result = utils.escapeLuaPattern(nil) + luaunit.assertEquals(result, "") +end + +function TestEscapeLuaPattern:testEscapeLuaPattern_UsageInMatch() + -- Test that escaped pattern can be used safely + local text = "The price is $10.50" + local escaped = utils.escapeLuaPattern("$10.50") + local found = text:match(escaped) + luaunit.assertEquals(found, "$10.50") +end + +-- Test suite for stripNonPrintable +TestStripNonPrintable = {} + +function TestStripNonPrintable:testStripNonPrintable_BasicText() + local result = utils.stripNonPrintable("Hello World") + luaunit.assertEquals(result, "Hello World") +end + +function TestStripNonPrintable:testStripNonPrintable_KeepNewlines() + local result = utils.stripNonPrintable("Hello\nWorld") + luaunit.assertEquals(result, "Hello\nWorld") +end + +function TestStripNonPrintable:testStripNonPrintable_KeepTabs() + local result = utils.stripNonPrintable("Hello\tWorld") + luaunit.assertEquals(result, "Hello\tWorld") +end + +function TestStripNonPrintable:testStripNonPrintable_RemoveControlChars() + local result = utils.stripNonPrintable("Hello\1\2\3World") + luaunit.assertEquals(result, "HelloWorld") +end + +function TestStripNonPrintable:testStripNonPrintable_NilInput() + local result = utils.stripNonPrintable(nil) + luaunit.assertEquals(result, "") +end + +function TestStripNonPrintable:testStripNonPrintable_EmptyString() + local result = utils.stripNonPrintable("") + luaunit.assertEquals(result, "") +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/texteditor_sanitization_test.lua b/testing/__tests__/texteditor_sanitization_test.lua new file mode 100644 index 0000000..870fbdc --- /dev/null +++ b/testing/__tests__/texteditor_sanitization_test.lua @@ -0,0 +1,306 @@ +-- Import test framework +package.path = package.path .. ";./?.lua;./game/?.lua" +local luaunit = require("testing.luaunit") + +-- Set up LΓ–VE stub environment +require("testing.loveStub") + +-- Import the TextEditor module and dependencies +local utils = require("modules.utils") +local Color = require("modules.Color") + +-- Mock dependencies +local mockContext = { + _immediateMode = false, + _focusedElement = nil +} + +local mockStateManager = { + getState = function() return nil end, + setState = function() end +} + +-- Test Suite for TextEditor Sanitization +TestTextEditorSanitization = {} + +---Helper to create a TextEditor instance +function TestTextEditorSanitization:_createEditor(config) + local TextEditor = require("modules.TextEditor") + config = config or {} + local deps = { + Context = mockContext, + StateManager = mockStateManager, + Color = Color, + utils = utils + } + return TextEditor.new(config, deps) +end + +-- === Sanitization Enabled Tests === + +function TestTextEditorSanitization:test_sanitization_enabled_by_default() + local editor = self:_createEditor({editable = true}) + luaunit.assertTrue(editor.sanitize) +end + +function TestTextEditorSanitization:test_sanitization_can_be_disabled() + local editor = self:_createEditor({editable = true, sanitize = false}) + luaunit.assertFalse(editor.sanitize) +end + +function TestTextEditorSanitization:test_setText_removes_control_characters() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello\x00World\x01Test") + luaunit.assertEquals(editor:getText(), "HelloWorldTest") +end + +function TestTextEditorSanitization:test_setText_preserves_valid_text() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello World! 123") + luaunit.assertEquals(editor:getText(), "Hello World! 123") +end + +function TestTextEditorSanitization:test_setText_removes_multiple_control_chars() + local editor = self:_createEditor({editable = true}) + editor:setText("Test\x00\x01\x02\x03\x04Data") + luaunit.assertEquals(editor:getText(), "TestData") +end + +function TestTextEditorSanitization:test_setText_with_sanitization_disabled() + local editor = self:_createEditor({editable = true, sanitize = false}) + editor:setText("Hello\x00World") + luaunit.assertEquals(editor:getText(), "Hello\x00World") +end + +function TestTextEditorSanitization:test_setText_skip_sanitization_parameter() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello\x00World", true) -- skipSanitization = true + luaunit.assertEquals(editor:getText(), "Hello\x00World") +end + +-- === Initial Text Sanitization === + +function TestTextEditorSanitization:test_initial_text_is_sanitized() + local editor = self:_createEditor({ + editable = true, + text = "Initial\x00Text\x01" + }) + luaunit.assertEquals(editor:getText(), "InitialText") +end + +function TestTextEditorSanitization:test_initial_text_preserved_when_disabled() + local editor = self:_createEditor({ + editable = true, + sanitize = false, + text = "Initial\x00Text" + }) + luaunit.assertEquals(editor:getText(), "Initial\x00Text") +end + +-- === insertText Sanitization === + +function TestTextEditorSanitization:test_insertText_sanitizes_input() + local editor = self:_createEditor({editable = true, text = "Hello"}) + editor:insertText("\x00World", 5) + luaunit.assertEquals(editor:getText(), "HelloWorld") +end + +function TestTextEditorSanitization:test_insertText_with_valid_text() + local editor = self:_createEditor({editable = true, text = "Hello"}) + editor:insertText(" World", 5) + luaunit.assertEquals(editor:getText(), "Hello World") +end + +function TestTextEditorSanitization:test_insertText_empty_after_sanitization() + local editor = self:_createEditor({editable = true, text = "Hello"}) + editor:insertText("\x00\x01\x02", 5) -- Only control chars + luaunit.assertEquals(editor:getText(), "Hello") -- Should remain unchanged +end + +-- === Length Limiting === + +function TestTextEditorSanitization:test_maxLength_enforced_on_setText() + local editor = self:_createEditor({editable = true, maxLength = 10}) + editor:setText("This is a very long text") + luaunit.assertEquals(#editor:getText(), 10) +end + +function TestTextEditorSanitization:test_maxLength_enforced_on_insertText() + local editor = self:_createEditor({editable = true, text = "12345", maxLength = 10}) + editor:insertText("67890", 5) -- This would make it exactly 10 + luaunit.assertEquals(editor:getText(), "1234567890") +end + +function TestTextEditorSanitization:test_maxLength_truncates_excess() + local editor = self:_createEditor({editable = true, text = "12345", maxLength = 10}) + editor:insertText("67890EXTRA", 5) -- Would exceed limit + luaunit.assertEquals(editor:getText(), "1234567890") +end + +function TestTextEditorSanitization:test_maxLength_prevents_insert_when_full() + local editor = self:_createEditor({editable = true, text = "1234567890", maxLength = 10}) + editor:insertText("X", 10) + luaunit.assertEquals(editor:getText(), "1234567890") -- Should not change +end + +-- === Newline Handling === + +function TestTextEditorSanitization:test_newlines_allowed_in_multiline() + local editor = self:_createEditor({editable = true, multiline = true}) + editor:setText("Line1\nLine2") + luaunit.assertEquals(editor:getText(), "Line1\nLine2") +end + +function TestTextEditorSanitization:test_newlines_removed_in_singleline() + local editor = self:_createEditor({editable = true, multiline = false}) + editor:setText("Line1\nLine2") + luaunit.assertEquals(editor:getText(), "Line1Line2") +end + +function TestTextEditorSanitization:test_allowNewlines_explicit_false() + local editor = self:_createEditor({ + editable = true, + multiline = true, + allowNewlines = false + }) + editor:setText("Line1\nLine2") + luaunit.assertEquals(editor:getText(), "Line1Line2") +end + +-- === Tab Handling === + +function TestTextEditorSanitization:test_tabs_allowed_by_default() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello\tWorld") + luaunit.assertEquals(editor:getText(), "Hello\tWorld") +end + +function TestTextEditorSanitization:test_tabs_removed_when_disabled() + local editor = self:_createEditor({ + editable = true, + allowTabs = false + }) + editor:setText("Hello\tWorld") + luaunit.assertEquals(editor:getText(), "HelloWorld") +end + +-- === Custom Sanitizer === + +function TestTextEditorSanitization:test_custom_sanitizer_used() + local customSanitizer = function(text) + return text:upper() + end + + local editor = self:_createEditor({ + editable = true, + customSanitizer = customSanitizer + }) + editor:setText("hello world") + luaunit.assertEquals(editor:getText(), "HELLO WORLD") +end + +function TestTextEditorSanitization:test_custom_sanitizer_with_control_chars() + local customSanitizer = function(text) + -- Custom sanitizer that replaces control chars with * + return text:gsub("[\x00-\x1F]", "*") + end + + local editor = self:_createEditor({ + editable = true, + customSanitizer = customSanitizer + }) + editor:setText("Hello\x00World\x01") + luaunit.assertEquals(editor:getText(), "Hello*World*") +end + +-- === Unicode and Special Characters === + +function TestTextEditorSanitization:test_unicode_preserved() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello δΈ–η•Œ 🌍") + luaunit.assertEquals(editor:getText(), "Hello δΈ–η•Œ 🌍") +end + +function TestTextEditorSanitization:test_emoji_preserved() + local editor = self:_createEditor({editable = true}) + editor:setText("πŸ˜€πŸ˜ƒπŸ˜„πŸ˜") + luaunit.assertEquals(editor:getText(), "πŸ˜€πŸ˜ƒπŸ˜„πŸ˜") +end + +function TestTextEditorSanitization:test_special_chars_preserved() + local editor = self:_createEditor({editable = true}) + editor:setText("!@#$%^&*()_+-=[]{}|;':\",./<>?") + luaunit.assertEquals(editor:getText(), "!@#$%^&*()_+-=[]{}|;':\",./<>?") +end + +-- === Edge Cases === + +function TestTextEditorSanitization:test_empty_string() + local editor = self:_createEditor({editable = true}) + editor:setText("") + luaunit.assertEquals(editor:getText(), "") +end + +function TestTextEditorSanitization:test_only_control_characters() + local editor = self:_createEditor({editable = true}) + editor:setText("\x00\x01\x02\x03") + luaunit.assertEquals(editor:getText(), "") +end + +function TestTextEditorSanitization:test_nil_text() + local editor = self:_createEditor({editable = true}) + editor:setText(nil) + luaunit.assertEquals(editor:getText(), "") +end + +function TestTextEditorSanitization:test_very_long_text_with_control_chars() + local editor = self:_createEditor({editable = true}) + local longText = string.rep("Hello\x00World", 100) + editor:setText(longText) + luaunit.assertStrContains(editor:getText(), "Hello") + luaunit.assertStrContains(editor:getText(), "World") + luaunit.assertNotStrContains(editor:getText(), "\x00") +end + +function TestTextEditorSanitization:test_mixed_valid_and_invalid() + local editor = self:_createEditor({editable = true}) + editor:setText("Valid\x00Text\x01With\x02Control\x03Chars") + luaunit.assertEquals(editor:getText(), "ValidTextWithControlChars") +end + +-- === Whitespace Handling === + +function TestTextEditorSanitization:test_spaces_preserved() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello World") + luaunit.assertEquals(editor:getText(), "Hello World") +end + +function TestTextEditorSanitization:test_leading_trailing_spaces_preserved() + local editor = self:_createEditor({editable = true}) + editor:setText(" Hello World ") + luaunit.assertEquals(editor:getText(), " Hello World ") +end + +-- === Integration Tests === + +function TestTextEditorSanitization:test_cursor_position_after_sanitization() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello") + editor:insertText("\x00World", 5) + -- Cursor should be at end of "HelloWorld" = position 10 + luaunit.assertEquals(editor._cursorPosition, 10) +end + +function TestTextEditorSanitization:test_multiple_operations() + local editor = self:_createEditor({editable = true}) + editor:setText("Hello") + editor:insertText(" ", 5) + editor:insertText("World\x00", 6) + luaunit.assertEquals(editor:getText(), "Hello World") +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/theme_validation_test.lua b/testing/__tests__/theme_validation_test.lua new file mode 100644 index 0000000..5ceabd7 --- /dev/null +++ b/testing/__tests__/theme_validation_test.lua @@ -0,0 +1,598 @@ +-- Import test framework +package.path = package.path .. ";./?.lua;./game/?.lua" +local luaunit = require("testing.luaunit") + +-- Set up LΓ–VE stub environment +require("testing.loveStub") + +-- Import the Theme module +local Theme = require("modules.Theme") +local Color = require("modules.Color") + +-- Test Suite for Theme Validation +TestThemeValidation = {} + +-- === Basic Theme Validation === + +function TestThemeValidation:test_validate_nil_theme() + local valid, errors = Theme.validateTheme(nil) + luaunit.assertFalse(valid) + luaunit.assertEquals(#errors, 1) + luaunit.assertStrContains(errors[1], "nil") +end + +function TestThemeValidation:test_validate_non_table_theme() + local valid, errors = Theme.validateTheme("not a table") + luaunit.assertFalse(valid) + luaunit.assertEquals(#errors, 1) + luaunit.assertStrContains(errors[1], "must be a table") +end + +function TestThemeValidation:test_validate_empty_theme() + local valid, errors = Theme.validateTheme({}) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + -- Should have error about missing name +end + +function TestThemeValidation:test_validate_minimal_valid_theme() + local theme = { + name = "Test Theme" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_theme_with_empty_name() + local theme = { + name = "" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_theme_with_non_string_name() + local theme = { + name = 123 + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +-- === Color Validation === + +function TestThemeValidation:test_validate_valid_colors() + local theme = { + name = "Test Theme", + colors = { + primary = Color.new(1, 0, 0, 1), + secondary = Color.new(0, 1, 0, 1) + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_colors_with_hex() + local theme = { + name = "Test Theme", + colors = { + primary = "#FF0000" + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_colors_with_named() + local theme = { + name = "Test Theme", + colors = { + primary = "red", + secondary = "blue" + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_invalid_color() + local theme = { + name = "Test Theme", + colors = { + primary = "not-a-color" + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "primary") +end + +function TestThemeValidation:test_validate_colors_non_table() + local theme = { + name = "Test Theme", + colors = "should be a table" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_color_with_non_string_name() + local theme = { + name = "Test Theme", + colors = { + [123] = Color.new(1, 0, 0, 1) + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +-- === Font Validation === + +function TestThemeValidation:test_validate_valid_fonts() + local theme = { + name = "Test Theme", + fonts = { + default = "path/to/font.ttf", + heading = "path/to/heading.ttf" + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_fonts_non_table() + local theme = { + name = "Test Theme", + fonts = "should be a table" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_font_with_non_string_path() + local theme = { + name = "Test Theme", + fonts = { + default = 123 + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_font_with_non_string_name() + local theme = { + name = "Test Theme", + fonts = { + [123] = "path/to/font.ttf" + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +-- === Component Validation === + +function TestThemeValidation:test_validate_valid_component() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png" + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_component_with_insets() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + insets = {left = 5, top = 5, right = 5, bottom = 5} + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_component_with_missing_inset() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + insets = {left = 5, top = 5, right = 5} -- missing bottom + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "bottom") +end + +function TestThemeValidation:test_validate_component_with_negative_inset() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + insets = {left = -5, top = 5, right = 5, bottom = 5} + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "non-negative") +end + +function TestThemeValidation:test_validate_component_with_states() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + states = { + hover = { + atlas = "path/to/button_hover.png" + }, + pressed = { + atlas = "path/to/button_pressed.png" + } + } + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_component_with_invalid_state() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + states = { + hover = "should be a table" + } + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_component_with_scaleCorners() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + scaleCorners = 2.0 + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_component_with_invalid_scaleCorners() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + scaleCorners = -1 + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "positive") +end + +function TestThemeValidation:test_validate_component_with_valid_scalingAlgorithm() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + scalingAlgorithm = "nearest" + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_component_with_invalid_scalingAlgorithm() + local theme = { + name = "Test Theme", + components = { + button = { + atlas = "path/to/button.png", + scalingAlgorithm = "invalid" + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_components_non_table() + local theme = { + name = "Test Theme", + components = "should be a table" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +-- === ContentAutoSizingMultiplier Validation === + +function TestThemeValidation:test_validate_valid_multiplier() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = {width = 1.1, height = 1.2} + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_multiplier_with_only_width() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = {width = 1.1} + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_multiplier_non_table() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = 1.5 + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_multiplier_with_non_number() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = {width = "not a number"} + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +function TestThemeValidation:test_validate_multiplier_with_negative() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = {width = -1} + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "positive") +end + +function TestThemeValidation:test_validate_multiplier_with_zero() + local theme = { + name = "Test Theme", + contentAutoSizingMultiplier = {width = 0} + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) +end + +-- === Global Atlas Validation === + +function TestThemeValidation:test_validate_valid_global_atlas() + local theme = { + name = "Test Theme", + atlas = "path/to/atlas.png" + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +-- === Strict Mode Validation === + +function TestThemeValidation:test_validate_unknown_field_strict() + local theme = { + name = "Test Theme", + unknownField = "should trigger warning" + } + local valid, errors = Theme.validateTheme(theme, {strict = true}) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors > 0) + luaunit.assertStrContains(errors[1], "Unknown field") +end + +function TestThemeValidation:test_validate_unknown_field_non_strict() + local theme = { + name = "Test Theme", + unknownField = "should be ignored" + } + local valid, errors = Theme.validateTheme(theme, {strict = false}) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +-- === Theme Sanitization === + +function TestThemeValidation:test_sanitize_nil_theme() + local sanitized = Theme.sanitizeTheme(nil) + luaunit.assertNotNil(sanitized) + luaunit.assertEquals(sanitized.name, "Invalid Theme") +end + +function TestThemeValidation:test_sanitize_theme_without_name() + local theme = { + colors = {primary = "red"} + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertEquals(sanitized.name, "Unnamed Theme") +end + +function TestThemeValidation:test_sanitize_theme_with_non_string_name() + local theme = { + name = 123 + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertEquals(type(sanitized.name), "string") +end + +function TestThemeValidation:test_sanitize_colors() + local theme = { + name = "Test", + colors = { + valid = "red", + invalid = "not-a-color" + } + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertNotNil(sanitized.colors.valid) + luaunit.assertNotNil(sanitized.colors.invalid) -- Should have fallback +end + +function TestThemeValidation:test_sanitize_removes_non_string_color_names() + local theme = { + name = "Test", + colors = { + [123] = "red" + } + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertNil(sanitized.colors[123]) +end + +function TestThemeValidation:test_sanitize_fonts() + local theme = { + name = "Test", + fonts = { + default = "path/to/font.ttf", + invalid = 123 + } + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertNotNil(sanitized.fonts.default) + luaunit.assertNil(sanitized.fonts.invalid) +end + +function TestThemeValidation:test_sanitize_preserves_components() + local theme = { + name = "Test", + components = { + button = {atlas = "path/to/button.png"} + } + } + local sanitized = Theme.sanitizeTheme(theme) + luaunit.assertNotNil(sanitized.components.button) + luaunit.assertEquals(sanitized.components.button.atlas, "path/to/button.png") +end + +-- === Complex Theme Validation === + +function TestThemeValidation:test_validate_complete_theme() + local theme = { + name = "Complete Theme", + atlas = "path/to/atlas.png", + contentAutoSizingMultiplier = {width = 1.05, height = 1.1}, + colors = { + primary = Color.new(1, 0, 0, 1), + secondary = "#00FF00", + tertiary = "blue" + }, + fonts = { + default = "path/to/font.ttf", + heading = "path/to/heading.ttf" + }, + components = { + button = { + atlas = "path/to/button.png", + insets = {left = 5, top = 5, right = 5, bottom = 5}, + scaleCorners = 2, + scalingAlgorithm = "nearest", + states = { + hover = { + atlas = "path/to/button_hover.png" + }, + pressed = { + atlas = "path/to/button_pressed.png" + } + } + }, + panel = { + atlas = "path/to/panel.png" + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertTrue(valid) + luaunit.assertEquals(#errors, 0) +end + +function TestThemeValidation:test_validate_theme_with_multiple_errors() + local theme = { + name = "", + colors = { + invalid1 = "not-a-color", + invalid2 = 123 + }, + fonts = { + bad = 456 + }, + components = { + button = { + insets = {left = -5} -- missing fields and negative + } + } + } + local valid, errors = Theme.validateTheme(theme) + luaunit.assertFalse(valid) + luaunit.assertTrue(#errors >= 5) -- Should have multiple errors +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/units_test.lua b/testing/__tests__/units_test.lua new file mode 100644 index 0000000..cb67355 --- /dev/null +++ b/testing/__tests__/units_test.lua @@ -0,0 +1,399 @@ +-- Test suite for Units.lua module +-- Tests unit parsing, resolution, and conversion functions + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local Units = require("modules.Units") + +-- Mock viewport dimensions for consistent tests +local MOCK_VIEWPORT_WIDTH = 1920 +local MOCK_VIEWPORT_HEIGHT = 1080 + +-- Test suite for Units.parse() +TestUnitsParse = {} + +function TestUnitsParse:testParseNumber() + local value, unit = Units.parse(100) + luaunit.assertEquals(value, 100) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParsePixels() + local value, unit = Units.parse("100px") + luaunit.assertEquals(value, 100) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParsePixelsNoUnit() + local value, unit = Units.parse("100") + luaunit.assertEquals(value, 100) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParsePercentage() + local value, unit = Units.parse("50%") + luaunit.assertEquals(value, 50) + luaunit.assertEquals(unit, "%") +end + +function TestUnitsParse:testParseViewportWidth() + local value, unit = Units.parse("10vw") + luaunit.assertEquals(value, 10) + luaunit.assertEquals(unit, "vw") +end + +function TestUnitsParse:testParseViewportHeight() + local value, unit = Units.parse("20vh") + luaunit.assertEquals(value, 20) + luaunit.assertEquals(unit, "vh") +end + +function TestUnitsParse:testParseElementWidth() + local value, unit = Units.parse("15ew") + luaunit.assertEquals(value, 15) + luaunit.assertEquals(unit, "ew") +end + +function TestUnitsParse:testParseElementHeight() + local value, unit = Units.parse("25eh") + luaunit.assertEquals(value, 25) + luaunit.assertEquals(unit, "eh") +end + +function TestUnitsParse:testParseDecimal() + local value, unit = Units.parse("10.5px") + luaunit.assertEquals(value, 10.5) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParseNegative() + local value, unit = Units.parse("-50px") + luaunit.assertEquals(value, -50) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParseNegativeDecimal() + local value, unit = Units.parse("-10.5%") + luaunit.assertEquals(value, -10.5) + luaunit.assertEquals(unit, "%") +end + +function TestUnitsParse:testParseZero() + local value, unit = Units.parse("0") + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParseInvalidType() + local value, unit = Units.parse(nil) + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParseInvalidString() + local value, unit = Units.parse("abc") + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsParse:testParseInvalidUnit() + local value, unit = Units.parse("100xyz") + luaunit.assertEquals(value, 100) + luaunit.assertEquals(unit, "px") -- Falls back to px +end + +function TestUnitsParse:testParseWithSpace() + -- Spaces between number and unit should be invalid + local value, unit = Units.parse("100 px") + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +-- Test suite for Units.resolve() +TestUnitsResolve = {} + +function TestUnitsResolve:testResolvePixels() + local result = Units.resolve(100, "px", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 100) +end + +function TestUnitsResolve:testResolvePercentage() + local result = Units.resolve(50, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 200) + luaunit.assertEquals(result, 100) -- 50% of 200 +end + +function TestUnitsResolve:testResolveViewportWidth() + local result = Units.resolve(10, "vw", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 192) -- 10% of 1920 +end + +function TestUnitsResolve:testResolveViewportHeight() + local result = Units.resolve(20, "vh", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 216) -- 20% of 1080 +end + +function TestUnitsResolve:testResolvePercentageZero() + local result = Units.resolve(0, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 200) + luaunit.assertEquals(result, 0) +end + +function TestUnitsResolve:testResolvePercentage100() + local result = Units.resolve(100, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 200) + luaunit.assertEquals(result, 200) +end + +function TestUnitsResolve:testResolveNegativePixels() + local result = Units.resolve(-50, "px", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, -50) +end + +function TestUnitsResolve:testResolveDecimalPercentage() + local result = Units.resolve(33.33, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 300) + luaunit.assertAlmostEquals(result, 99.99, 0.01) +end + +-- Test suite for Units.parseAndResolve() +TestUnitsParseAndResolve = {} + +function TestUnitsParseAndResolve:testParseAndResolvePixels() + local result = Units.parseAndResolve("100px", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 100) +end + +function TestUnitsParseAndResolve:testParseAndResolveNumber() + local result = Units.parseAndResolve(100, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 100) +end + +function TestUnitsParseAndResolve:testParseAndResolvePercentage() + local result = Units.parseAndResolve("50%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 400) + luaunit.assertEquals(result, 200) +end + +function TestUnitsParseAndResolve:testParseAndResolveViewportWidth() + local result = Units.parseAndResolve("10vw", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 192) +end + +function TestUnitsParseAndResolve:testParseAndResolveViewportHeight() + local result = Units.parseAndResolve("50vh", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT) + luaunit.assertEquals(result, 540) +end + +-- Test suite for Units.isValid() +TestUnitsIsValid = {} + +function TestUnitsIsValid:testIsValidPixels() + luaunit.assertTrue(Units.isValid("100px")) +end + +function TestUnitsIsValid:testIsValidPercentage() + luaunit.assertTrue(Units.isValid("50%")) +end + +function TestUnitsIsValid:testIsValidViewportWidth() + luaunit.assertTrue(Units.isValid("10vw")) +end + +function TestUnitsIsValid:testIsValidViewportHeight() + luaunit.assertTrue(Units.isValid("20vh")) +end + +function TestUnitsIsValid:testIsValidElementWidth() + luaunit.assertTrue(Units.isValid("15ew")) +end + +function TestUnitsIsValid:testIsValidElementHeight() + luaunit.assertTrue(Units.isValid("25eh")) +end + +function TestUnitsIsValid:testIsValidNumber() + luaunit.assertTrue(Units.isValid("100")) +end + +function TestUnitsIsValid:testIsValidNegative() + luaunit.assertTrue(Units.isValid("-50px")) +end + +function TestUnitsIsValid:testIsValidDecimal() + luaunit.assertTrue(Units.isValid("10.5px")) +end + +function TestUnitsIsValid:testIsInvalidString() + luaunit.assertFalse(Units.isValid("abc")) +end + +function TestUnitsIsValid:testIsInvalidNil() + luaunit.assertFalse(Units.isValid(nil)) +end + +function TestUnitsIsValid:testIsInvalidNumber() + luaunit.assertFalse(Units.isValid(100)) +end + +-- Test suite for Units.resolveSpacing() +TestUnitsResolveSpacing = {} + +function TestUnitsResolveSpacing:testResolveSpacingNil() + local result = Units.resolveSpacing(nil, 800, 600) + luaunit.assertEquals(result.top, 0) + luaunit.assertEquals(result.right, 0) + luaunit.assertEquals(result.bottom, 0) + luaunit.assertEquals(result.left, 0) +end + +function TestUnitsResolveSpacing:testResolveSpacingAllSides() + local spacing = { + top = "10px", + right = "20px", + bottom = "30px", + left = "40px" + } + local result = Units.resolveSpacing(spacing, 800, 600) + luaunit.assertEquals(result.top, 10) + luaunit.assertEquals(result.right, 20) + luaunit.assertEquals(result.bottom, 30) + luaunit.assertEquals(result.left, 40) +end + +function TestUnitsResolveSpacing:testResolveSpacingVerticalHorizontal() + local spacing = { + vertical = "10px", + horizontal = "20px" + } + local result = Units.resolveSpacing(spacing, 800, 600) + luaunit.assertEquals(result.top, 10) + luaunit.assertEquals(result.right, 20) + luaunit.assertEquals(result.bottom, 10) + luaunit.assertEquals(result.left, 20) +end + +function TestUnitsResolveSpacing:testResolveSpacingVerticalHorizontalNumbers() + local spacing = { + vertical = 10, + horizontal = 20 + } + local result = Units.resolveSpacing(spacing, 800, 600) + luaunit.assertEquals(result.top, 10) + luaunit.assertEquals(result.right, 20) + luaunit.assertEquals(result.bottom, 10) + luaunit.assertEquals(result.left, 20) +end + +function TestUnitsResolveSpacing:testResolveSpacingMixedPercentage() + local spacing = { + top = "10%", + right = "5%", + bottom = "10%", + left = "5%" + } + local result = Units.resolveSpacing(spacing, 800, 600) + luaunit.assertEquals(result.top, 60) -- 10% of 600 (height) + luaunit.assertEquals(result.right, 40) -- 5% of 800 (width) + luaunit.assertEquals(result.bottom, 60) -- 10% of 600 (height) + luaunit.assertEquals(result.left, 40) -- 5% of 800 (width) +end + +function TestUnitsResolveSpacing:testResolveSpacingOverride() + -- Individual sides should override vertical/horizontal + local spacing = { + vertical = "10px", + horizontal = "20px", + top = "50px" + } + local result = Units.resolveSpacing(spacing, 800, 600) + luaunit.assertEquals(result.top, 50) -- Overridden + luaunit.assertEquals(result.right, 20) + luaunit.assertEquals(result.bottom, 10) + luaunit.assertEquals(result.left, 20) +end + +-- Test suite for Units.applyBaseScale() +TestUnitsApplyBaseScale = {} + +function TestUnitsApplyBaseScale:testApplyBaseScaleX() + local scaleFactors = { x = 2, y = 3 } + local result = Units.applyBaseScale(100, "x", scaleFactors) + luaunit.assertEquals(result, 200) +end + +function TestUnitsApplyBaseScale:testApplyBaseScaleY() + local scaleFactors = { x = 2, y = 3 } + local result = Units.applyBaseScale(100, "y", scaleFactors) + luaunit.assertEquals(result, 300) +end + +function TestUnitsApplyBaseScale:testApplyBaseScaleIdentity() + local scaleFactors = { x = 1, y = 1 } + local result = Units.applyBaseScale(100, "x", scaleFactors) + luaunit.assertEquals(result, 100) +end + +function TestUnitsApplyBaseScale:testApplyBaseScaleZero() + local scaleFactors = { x = 0, y = 0 } + local result = Units.applyBaseScale(100, "x", scaleFactors) + luaunit.assertEquals(result, 0) +end + +function TestUnitsApplyBaseScale:testApplyBaseScaleDecimal() + local scaleFactors = { x = 0.5, y = 1.5 } + local result = Units.applyBaseScale(100, "x", scaleFactors) + luaunit.assertEquals(result, 50) +end + +-- Test suite for Units.getViewport() +TestUnitsGetViewport = {} + +function TestUnitsGetViewport:testGetViewportReturnsValues() + local width, height = Units.getViewport() + luaunit.assertIsNumber(width) + luaunit.assertIsNumber(height) + luaunit.assertTrue(width > 0) + luaunit.assertTrue(height > 0) +end + +-- Test edge cases +TestUnitsEdgeCases = {} + +function TestUnitsEdgeCases:testParseVeryLargeNumber() + local value, unit = Units.parse("999999px") + luaunit.assertEquals(value, 999999) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsEdgeCases:testParseVerySmallDecimal() + local value, unit = Units.parse("0.001px") + luaunit.assertEquals(value, 0.001) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsEdgeCases:testResolveZeroParentSize() + local result = Units.resolve(50, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 0) + luaunit.assertEquals(result, 0) +end + +function TestUnitsEdgeCases:testParseEmptyString() + local value, unit = Units.parse("") + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsEdgeCases:testParseOnlyUnit() + local value, unit = Units.parse("px") + luaunit.assertEquals(value, 0) + luaunit.assertEquals(unit, "px") +end + +function TestUnitsEdgeCases:testResolveNegativePercentage() + local result = Units.resolve(-50, "%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 200) + luaunit.assertEquals(result, -100) +end + +-- Run tests if not running as part of a suite +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/utils_test.lua b/testing/__tests__/utils_test.lua new file mode 100644 index 0000000..31d0565 --- /dev/null +++ b/testing/__tests__/utils_test.lua @@ -0,0 +1,487 @@ +-- Test suite for utils.lua module +-- Tests all 16+ utility functions with comprehensive edge cases + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local utils = require("modules.utils") + +-- Test suite for validation utilities +TestValidationUtils = {} + +function TestValidationUtils:testValidateEnum_ValidValue() + local testEnum = { VALUE1 = "value1", VALUE2 = "value2", VALUE3 = "value3" } + luaunit.assertTrue(utils.validateEnum("value1", testEnum, "testProp")) + luaunit.assertTrue(utils.validateEnum("value2", testEnum, "testProp")) + luaunit.assertTrue(utils.validateEnum("value3", testEnum, "testProp")) +end + +function TestValidationUtils:testValidateEnum_NilValue() + local testEnum = { VALUE1 = "value1", VALUE2 = "value2" } + luaunit.assertTrue(utils.validateEnum(nil, testEnum, "testProp")) +end + +function TestValidationUtils:testValidateEnum_InvalidValue() + local testEnum = { VALUE1 = "value1", VALUE2 = "value2" } + luaunit.assertErrorMsgContains("must be one of", function() + utils.validateEnum("invalid", testEnum, "testProp") + end) +end + +function TestValidationUtils:testValidateRange_InRange() + luaunit.assertTrue(utils.validateRange(5, 0, 10, "testProp")) + luaunit.assertTrue(utils.validateRange(0, 0, 10, "testProp")) + luaunit.assertTrue(utils.validateRange(10, 0, 10, "testProp")) +end + +function TestValidationUtils:testValidateRange_OutOfRange() + luaunit.assertErrorMsgContains("must be between", function() + utils.validateRange(-1, 0, 10, "testProp") + end) + luaunit.assertErrorMsgContains("must be between", function() + utils.validateRange(11, 0, 10, "testProp") + end) +end + +function TestValidationUtils:testValidateRange_NilValue() + luaunit.assertTrue(utils.validateRange(nil, 0, 10, "testProp")) +end + +function TestValidationUtils:testValidateRange_WrongType() + luaunit.assertErrorMsgContains("must be a number", function() + utils.validateRange("not a number", 0, 10, "testProp") + end) +end + +function TestValidationUtils:testValidateType_CorrectType() + luaunit.assertTrue(utils.validateType("hello", "string", "testProp")) + luaunit.assertTrue(utils.validateType(123, "number", "testProp")) + luaunit.assertTrue(utils.validateType(true, "boolean", "testProp")) + luaunit.assertTrue(utils.validateType({}, "table", "testProp")) + luaunit.assertTrue(utils.validateType(function() end, "function", "testProp")) +end + +function TestValidationUtils:testValidateType_WrongType() + luaunit.assertErrorMsgContains("must be string", function() + utils.validateType(123, "string", "testProp") + end) + luaunit.assertErrorMsgContains("must be number", function() + utils.validateType("hello", "number", "testProp") + end) +end + +function TestValidationUtils:testValidateType_NilValue() + luaunit.assertTrue(utils.validateType(nil, "string", "testProp")) +end + +-- Test suite for math utilities +TestMathUtils = {} + +function TestMathUtils:testClamp_WithinRange() + luaunit.assertEquals(utils.clamp(5, 0, 10), 5) + luaunit.assertEquals(utils.clamp(0, 0, 10), 0) + luaunit.assertEquals(utils.clamp(10, 0, 10), 10) +end + +function TestMathUtils:testClamp_BelowMin() + luaunit.assertEquals(utils.clamp(-5, 0, 10), 0) + luaunit.assertEquals(utils.clamp(-100, 0, 10), 0) +end + +function TestMathUtils:testClamp_AboveMax() + luaunit.assertEquals(utils.clamp(15, 0, 10), 10) + luaunit.assertEquals(utils.clamp(100, 0, 10), 10) +end + +function TestMathUtils:testClamp_NegativeRange() + luaunit.assertEquals(utils.clamp(-5, -10, -1), -5) + luaunit.assertEquals(utils.clamp(-15, -10, -1), -10) + luaunit.assertEquals(utils.clamp(0, -10, -1), -1) +end + +function TestMathUtils:testLerp_Boundaries() + luaunit.assertEquals(utils.lerp(0, 10, 0), 0) + luaunit.assertEquals(utils.lerp(0, 10, 1), 10) +end + +function TestMathUtils:testLerp_Midpoint() + luaunit.assertEquals(utils.lerp(0, 10, 0.5), 5) + luaunit.assertEquals(utils.lerp(10, 20, 0.5), 15) +end + +function TestMathUtils:testLerp_NegativeValues() + luaunit.assertEquals(utils.lerp(-10, 10, 0.5), 0) + luaunit.assertEquals(utils.lerp(-20, -10, 0.5), -15) +end + +function TestMathUtils:testLerp_BeyondRange() + luaunit.assertEquals(utils.lerp(0, 10, 1.5), 15) + luaunit.assertEquals(utils.lerp(0, 10, -0.5), -5) +end + +function TestMathUtils:testRound_UpAndDown() + luaunit.assertEquals(utils.round(0.4), 0) + luaunit.assertEquals(utils.round(0.5), 1) + luaunit.assertEquals(utils.round(0.6), 1) +end + +function TestMathUtils:testRound_NegativeNumbers() + luaunit.assertEquals(utils.round(-0.4), 0) + luaunit.assertEquals(utils.round(-0.5), 0) + luaunit.assertEquals(utils.round(-0.6), -1) +end + +function TestMathUtils:testRound_WholeNumbers() + luaunit.assertEquals(utils.round(5), 5) + luaunit.assertEquals(utils.round(-5), -5) + luaunit.assertEquals(utils.round(0), 0) +end + +-- Test suite for path utilities +TestPathUtils = {} + +function TestPathUtils:testNormalizePath_Whitespace() + luaunit.assertEquals(utils.normalizePath(" /path/to/file "), "/path/to/file") + luaunit.assertEquals(utils.normalizePath("\t/path/to/file\t"), "/path/to/file") + luaunit.assertEquals(utils.normalizePath(" /path/to/file "), "/path/to/file") +end + +function TestPathUtils:testNormalizePath_Backslashes() + luaunit.assertEquals(utils.normalizePath("C:\\path\\to\\file"), "C:/path/to/file") + luaunit.assertEquals(utils.normalizePath("path\\to\\file"), "path/to/file") +end + +function TestPathUtils:testNormalizePath_DuplicateSlashes() + luaunit.assertEquals(utils.normalizePath("/path//to///file"), "/path/to/file") + luaunit.assertEquals(utils.normalizePath("path//to//file"), "path/to/file") +end + +function TestPathUtils:testNormalizePath_Combined() + luaunit.assertEquals(utils.normalizePath(" C:\\path\\\\to///file "), "C:/path/to/file") +end + +function TestPathUtils:testResolveImagePath_AbsolutePath() + local result = utils.resolveImagePath("/absolute/path/to/image.png") + luaunit.assertEquals(result, "/absolute/path/to/image.png") +end + +function TestPathUtils:testResolveImagePath_WindowsAbsolutePath() + local result = utils.resolveImagePath("C:/path/to/image.png") + luaunit.assertEquals(result, "C:/path/to/image.png") +end + +function TestPathUtils:testResolveImagePath_RelativePath() + local result = utils.resolveImagePath("themes/images/icon.png") + -- Should prepend the FlexLove base path + luaunit.assertStrContains(result, "themes/images/icon.png") +end + +function TestPathUtils:testSafeLoadImage_InvalidPath() + -- Test with an invalid path - should return nil and error message + local image, imageData, errorMsg = utils.safeLoadImage("nonexistent/path/to/image.png") + luaunit.assertNil(image) + luaunit.assertNil(imageData) + luaunit.assertNotNil(errorMsg) + luaunit.assertStrContains(errorMsg, "Failed to load") +end + +-- Test suite for color utilities +TestColorUtils = {} + +function TestColorUtils:testBrightenColor_NormalFactor() + local r, g, b, a = utils.brightenColor(0.5, 0.5, 0.5, 1.0, 1.5) + luaunit.assertAlmostEquals(r, 0.75, 0.001) + luaunit.assertAlmostEquals(g, 0.75, 0.001) + luaunit.assertAlmostEquals(b, 0.75, 0.001) + luaunit.assertAlmostEquals(a, 1.0, 0.001) +end + +function TestColorUtils:testBrightenColor_ClampingAt1() + local r, g, b, a = utils.brightenColor(0.8, 0.8, 0.8, 1.0, 2.0) + luaunit.assertAlmostEquals(r, 1.0, 0.001) + luaunit.assertAlmostEquals(g, 1.0, 0.001) + luaunit.assertAlmostEquals(b, 1.0, 0.001) + luaunit.assertAlmostEquals(a, 1.0, 0.001) +end + +function TestColorUtils:testBrightenColor_FactorOne() + local r, g, b, a = utils.brightenColor(0.5, 0.6, 0.7, 0.8, 1.0) + luaunit.assertAlmostEquals(r, 0.5, 0.001) + luaunit.assertAlmostEquals(g, 0.6, 0.001) + luaunit.assertAlmostEquals(b, 0.7, 0.001) + luaunit.assertAlmostEquals(a, 0.8, 0.001) +end + +function TestColorUtils:testBrightenColor_AlphaUnchanged() + local _, _, _, a = utils.brightenColor(0.5, 0.5, 0.5, 0.5, 2.0) + luaunit.assertAlmostEquals(a, 0.5, 0.001) -- Alpha should remain unchanged +end + +-- Test suite for property utilities +TestPropertyUtils = {} + +function TestPropertyUtils:testNormalizeBooleanTable_Boolean() + local result = utils.normalizeBooleanTable(true) + luaunit.assertEquals(result.vertical, true) + luaunit.assertEquals(result.horizontal, true) + + result = utils.normalizeBooleanTable(false) + luaunit.assertEquals(result.vertical, false) + luaunit.assertEquals(result.horizontal, false) +end + +function TestPropertyUtils:testNormalizeBooleanTable_Nil() + local result = utils.normalizeBooleanTable(nil) + luaunit.assertEquals(result.vertical, false) + luaunit.assertEquals(result.horizontal, false) +end + +function TestPropertyUtils:testNormalizeBooleanTable_NilWithDefault() + local result = utils.normalizeBooleanTable(nil, true) + luaunit.assertEquals(result.vertical, true) + luaunit.assertEquals(result.horizontal, true) +end + +function TestPropertyUtils:testNormalizeBooleanTable_Table() + local result = utils.normalizeBooleanTable({ vertical = true, horizontal = false }) + luaunit.assertEquals(result.vertical, true) + luaunit.assertEquals(result.horizontal, false) +end + +function TestPropertyUtils:testNormalizeBooleanTable_PartialTable() + local result = utils.normalizeBooleanTable({ vertical = true }) + luaunit.assertEquals(result.vertical, true) + luaunit.assertEquals(result.horizontal, false) + + result = utils.normalizeBooleanTable({ horizontal = true }) + luaunit.assertEquals(result.vertical, false) + luaunit.assertEquals(result.horizontal, true) +end + +function TestPropertyUtils:testNormalizeBooleanTable_EmptyTable() + local result = utils.normalizeBooleanTable({}) + luaunit.assertEquals(result.vertical, false) + luaunit.assertEquals(result.horizontal, false) +end + +-- Test suite for font utilities +TestFontUtils = {} + +function TestFontUtils:testResolveFontPath_DirectPath() + local result = utils.resolveFontPath("path/to/font.ttf", nil, nil) + luaunit.assertEquals(result, "path/to/font.ttf") +end + +function TestFontUtils:testResolveFontPath_NilFontFamily() + local result = utils.resolveFontPath(nil, nil, nil) + luaunit.assertNil(result) +end + +function TestFontUtils:testResolveFontPath_ThemeFont() + -- Mock theme manager with font + local mockThemeManager = { + getTheme = function() + return { + fonts = { + mainFont = "themes/fonts/main.ttf" + } + } + end + } + + local result = utils.resolveFontPath("mainFont", "button", mockThemeManager) + luaunit.assertEquals(result, "themes/fonts/main.ttf") +end + +function TestFontUtils:testResolveFontPath_ThemeFontNotFound() + -- Mock theme manager without the requested font + local mockThemeManager = { + getTheme = function() + return { + fonts = {} + } + end + } + + -- Should fall back to treating it as a direct path + local result = utils.resolveFontPath("unknownFont", "button", mockThemeManager) + luaunit.assertEquals(result, "unknownFont") +end + +function TestFontUtils:testGetFont_WithTextSize() + local font = utils.getFont(16, nil, nil, nil) + luaunit.assertNotNil(font) + -- Font height should match the requested size + if font.getHeight then + luaunit.assertEquals(font.getHeight(), 16) + end +end + +function TestFontUtils:testGetFont_WithoutTextSize() + local font = utils.getFont(nil, nil, nil, nil) + luaunit.assertNotNil(font) + -- Should return the default font +end + +function TestFontUtils:testApplyContentMultiplier_WithWidth() + local result = utils.applyContentMultiplier(100, { width = 2.0, height = 1.5 }, "width") + luaunit.assertEquals(result, 200) +end + +function TestFontUtils:testApplyContentMultiplier_WithHeight() + local result = utils.applyContentMultiplier(100, { width = 2.0, height = 1.5 }, "height") + luaunit.assertEquals(result, 150) +end + +function TestFontUtils:testApplyContentMultiplier_NilMultiplier() + local result = utils.applyContentMultiplier(100, nil, "width") + luaunit.assertEquals(result, 100) +end + +function TestFontUtils:testApplyContentMultiplier_MissingAxis() + local result = utils.applyContentMultiplier(100, { width = 2.0 }, "height") + luaunit.assertEquals(result, 100) +end + +-- Test suite for input utilities +TestInputUtils = {} + +function TestInputUtils:testGetModifiers_NoModifiers() + -- Reset all modifier keys + 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) + + local mods = utils.getModifiers() + luaunit.assertFalse(mods.shift) + luaunit.assertFalse(mods.ctrl) + luaunit.assertFalse(mods.alt) + luaunit.assertFalse(mods.super) +end + +function TestInputUtils:testGetModifiers_ShiftKey() + love.keyboard.setDown("lshift", true) + local mods = utils.getModifiers() + luaunit.assertTrue(mods.shift) + love.keyboard.setDown("lshift", false) + + love.keyboard.setDown("rshift", true) + mods = utils.getModifiers() + luaunit.assertTrue(mods.shift) + love.keyboard.setDown("rshift", false) +end + +function TestInputUtils:testGetModifiers_CtrlKey() + love.keyboard.setDown("lctrl", true) + local mods = utils.getModifiers() + luaunit.assertTrue(mods.ctrl) + love.keyboard.setDown("lctrl", false) +end + +function TestInputUtils:testGetModifiers_AltKey() + love.keyboard.setDown("lalt", true) + local mods = utils.getModifiers() + luaunit.assertTrue(mods.alt) + love.keyboard.setDown("lalt", false) +end + +function TestInputUtils:testGetModifiers_SuperKey() + love.keyboard.setDown("lgui", true) + local mods = utils.getModifiers() + luaunit.assertTrue(mods.super) + love.keyboard.setDown("lgui", false) +end + +function TestInputUtils:testGetModifiers_MultipleModifiers() + love.keyboard.setDown("lshift", true) + love.keyboard.setDown("lctrl", true) + local mods = utils.getModifiers() + luaunit.assertTrue(mods.shift) + luaunit.assertTrue(mods.ctrl) + luaunit.assertFalse(mods.alt) + luaunit.assertFalse(mods.super) + + -- Clean up + love.keyboard.setDown("lshift", false) + love.keyboard.setDown("lctrl", false) +end + +-- Test suite for text size presets +TestTextSizePresets = {} + +function TestTextSizePresets:testResolveTextSizePreset_ValidPresets() + local value, unit = utils.resolveTextSizePreset("xs") + luaunit.assertEquals(value, 1.25) + luaunit.assertEquals(unit, "vh") + + value, unit = utils.resolveTextSizePreset("md") + luaunit.assertEquals(value, 2.25) + luaunit.assertEquals(unit, "vh") + + value, unit = utils.resolveTextSizePreset("xl") + luaunit.assertEquals(value, 3.5) + luaunit.assertEquals(unit, "vh") +end + +function TestTextSizePresets:testResolveTextSizePreset_NumericValue() + local value, unit = utils.resolveTextSizePreset(20) + luaunit.assertNil(value) + luaunit.assertNil(unit) +end + +function TestTextSizePresets:testResolveTextSizePreset_InvalidPreset() + local value, unit = utils.resolveTextSizePreset("invalid") + luaunit.assertNil(value) + luaunit.assertNil(unit) +end + +function TestTextSizePresets:testResolveTextSizePreset_AllPresets() + -- Test all available presets + local presets = { "xxs", "2xs", "xs", "sm", "md", "lg", "xl", "xxl", "2xl", "3xl", "4xl" } + for _, preset in ipairs(presets) do + local value, unit = utils.resolveTextSizePreset(preset) + luaunit.assertNotNil(value, "Preset " .. preset .. " should return a value") + luaunit.assertEquals(unit, "vh", "Preset " .. preset .. " should return 'vh' unit") + end +end + +-- Test suite for enums +TestEnums = {} + +function TestEnums:testEnums_Exist() + luaunit.assertNotNil(utils.enums) + luaunit.assertNotNil(utils.enums.TextAlign) + luaunit.assertNotNil(utils.enums.Positioning) + luaunit.assertNotNil(utils.enums.FlexDirection) + luaunit.assertNotNil(utils.enums.JustifyContent) + luaunit.assertNotNil(utils.enums.AlignItems) + luaunit.assertNotNil(utils.enums.FlexWrap) + luaunit.assertNotNil(utils.enums.TextSize) +end + +function TestEnums:testEnums_TextAlign() + luaunit.assertEquals(utils.enums.TextAlign.START, "start") + luaunit.assertEquals(utils.enums.TextAlign.CENTER, "center") + luaunit.assertEquals(utils.enums.TextAlign.END, "end") + luaunit.assertEquals(utils.enums.TextAlign.JUSTIFY, "justify") +end + +function TestEnums:testEnums_Positioning() + luaunit.assertEquals(utils.enums.Positioning.ABSOLUTE, "absolute") + luaunit.assertEquals(utils.enums.Positioning.RELATIVE, "relative") + luaunit.assertEquals(utils.enums.Positioning.FLEX, "flex") + luaunit.assertEquals(utils.enums.Positioning.GRID, "grid") +end + +-- Run tests if this file is executed directly +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/runAll.lua b/testing/runAll.lua index 247022f..fba8c3a 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -1,25 +1,48 @@ package.path = package.path .. ";./?.lua;./game/?.lua;./game/utils/?.lua;./game/components/?.lua;./game/systems/?.lua" +-- Enable code coverage tracking BEFORE loading any modules +local coverage_enabled = os.getenv("COVERAGE") == "1" +if coverage_enabled then + local status, luacov = pcall(require, "luacov") + if status then + print("========================================") + print("Code coverage tracking enabled") + print("========================================") + else + print("Warning: luacov not found, coverage tracking disabled") + end +end + -- 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 -local testFiles = {} +local testFiles = { + "testing/__tests__/utils_test.lua", + "testing/__tests__/sanitization_test.lua", + "testing/__tests__/path_validation_test.lua", + "testing/__tests__/color_validation_test.lua", + "testing/__tests__/texteditor_sanitization_test.lua", + "testing/__tests__/theme_validation_test.lua", + "testing/__tests__/units_test.lua", +} local success = true print("========================================") print("Running ALL tests") print("========================================") -for _, testFile in ipairs(testFiles) do +for i, testFile in ipairs(testFiles) do print("========================================") - print("Running test file: " .. testFile) + print("Running test file " .. i .. "/" .. #testFiles .. ": " .. testFile) print("========================================") local status, err = pcall(dofile, testFile) if not status then - print("Error running test " .. testFile .. ": " .. tostring(err)) + print("ERROR running test " .. testFile .. ": " .. tostring(err)) success = false + else + print("Successfully loaded " .. testFile) end end diff --git a/testing/run_with_coverage.sh b/testing/run_with_coverage.sh new file mode 100755 index 0000000..09120e7 --- /dev/null +++ b/testing/run_with_coverage.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Run tests with code coverage + +# Set up LuaRocks path +eval $(luarocks path) + +# Clean up old coverage files +rm -f luacov.stats.out luacov.report.out + +# Run tests with coverage enabled +COVERAGE=1 lua testing/runAll.lua + +# Check if tests passed +if [ $? -eq 0 ]; then + echo "" + echo "========================================" + echo "Generating coverage report..." + echo "========================================" + + # Generate detailed report + luacov + + # Show summary + echo "" + echo "========================================" + echo "Coverage Summary" + echo "========================================" + + # Extract and display summary information + if [ -f luacov.report.out ]; then + echo "" + grep -A 100 "^Summary" luacov.report.out | head -30 + echo "" + echo "Full report available in: luacov.report.out" + fi +else + echo "Tests failed. Coverage report not generated." + exit 1 +fi