start testing
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ themes/space/
|
||||
.DS_STORE
|
||||
tasks
|
||||
testoutput
|
||||
luacov.*
|
||||
|
||||
26
.luacov
Normal file
26
.luacov
Normal file
@@ -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
|
||||
}
|
||||
124
examples/performance_example.lua
Normal file
124
examples/performance_example.lua
Normal file
@@ -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 ===")
|
||||
@@ -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
|
||||
|
||||
445
modules/ErrorCodes.lua
Normal file
445
modules/ErrorCodes.lua
Normal file
@@ -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
|
||||
435
modules/Performance.lua
Normal file
435
modules/Performance.lua
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -95,8 +106,20 @@ function TextEditor.new(config, deps)
|
||||
self.selectionColor = config.selectionColor
|
||||
self.cursorBlinkRate = config.cursorBlinkRate or 0.5
|
||||
|
||||
-- Initialize text buffer state
|
||||
self._textBuffer = config.text or ""
|
||||
-- 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 (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,10 +260,21 @@ 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
|
||||
local currentLength = utf8.len(buffer) or 0
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
514
testing/__tests__/color_validation_test.lua
Normal file
514
testing/__tests__/color_validation_test.lua
Normal file
@@ -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
|
||||
281
testing/__tests__/path_validation_test.lua
Normal file
281
testing/__tests__/path_validation_test.lua
Normal file
@@ -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
|
||||
236
testing/__tests__/sanitization_test.lua
Normal file
236
testing/__tests__/sanitization_test.lua
Normal file
@@ -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("<script>alert('xss')</script>")
|
||||
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
|
||||
306
testing/__tests__/texteditor_sanitization_test.lua
Normal file
306
testing/__tests__/texteditor_sanitization_test.lua
Normal file
@@ -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
|
||||
598
testing/__tests__/theme_validation_test.lua
Normal file
598
testing/__tests__/theme_validation_test.lua
Normal file
@@ -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
|
||||
399
testing/__tests__/units_test.lua
Normal file
399
testing/__tests__/units_test.lua
Normal file
@@ -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
|
||||
487
testing/__tests__/utils_test.lua
Normal file
487
testing/__tests__/utils_test.lua
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
39
testing/run_with_coverage.sh
Executable file
39
testing/run_with_coverage.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user