From c69b5189ca0d0ffff6dc831bc53d1eb2f9b32128 Mon Sep 17 00:00:00 2001 From: JohnDoe6345789 Date: Tue, 30 Dec 2025 10:55:24 +0000 Subject: [PATCH] feat: implement core test framework with BDD-style functions and lifecycle hooks --- packages/lua_test/seed/scripts/describe.lua | 144 +++++++ packages/lua_test/seed/scripts/framework.lua | 372 +++---------------- packages/lua_test/seed/scripts/hooks.lua | 45 +++ packages/lua_test/seed/scripts/init.lua | 18 + packages/lua_test/seed/scripts/json.lua | 136 +++++++ packages/lua_test/seed/scripts/suite.lua | 83 +++++ packages/lua_test/seed/scripts/types.lua | 33 ++ 7 files changed, 505 insertions(+), 326 deletions(-) create mode 100644 packages/lua_test/seed/scripts/describe.lua create mode 100644 packages/lua_test/seed/scripts/hooks.lua create mode 100644 packages/lua_test/seed/scripts/json.lua create mode 100644 packages/lua_test/seed/scripts/suite.lua create mode 100644 packages/lua_test/seed/scripts/types.lua diff --git a/packages/lua_test/seed/scripts/describe.lua b/packages/lua_test/seed/scripts/describe.lua new file mode 100644 index 000000000..110f6c04e --- /dev/null +++ b/packages/lua_test/seed/scripts/describe.lua @@ -0,0 +1,144 @@ +-- BDD-style test definition functions +-- Provides describe, it, xit, fit and parameterized variants + +local suite = require("suite") + +---@class DescribeModule +local M = {} + +---Describe block - groups related tests +---@param name string Name of the test group +---@param fn function Function containing test definitions +---@return TestSuite The created test suite +function M.describe(name, fn) + local parentSuite = suite.getCurrentSuite() + local newSuite = suite.createSuite(name) + newSuite.parent = parentSuite + + if parentSuite then + parentSuite.nested[#parentSuite.nested + 1] = newSuite + else + suite.registerSuite(newSuite) + end + + suite.setCurrentSuite(newSuite) + fn() + suite.setCurrentSuite(parentSuite) + + return newSuite +end + +---It block - defines a single test +---@param name string Name of the test +---@param fn function Test function to execute +---@return Test The created test +function M.it(name, fn) + local currentSuite = suite.getCurrentSuite() + if not currentSuite then + error("it() must be called inside a describe() block") + end + + local test = { + name = name, + fn = fn, + status = "pending", + error = nil, + duration = 0, + skipped = false + } + + currentSuite.tests[#currentSuite.tests + 1] = test + return test +end + +---Parameterized tests - it.each(cases)(name, fn) +---@param cases table[] Array of test case objects +---@return ParameterizedTestFactory Factory function accepting name template and test function +function M.it_each(cases) + return function(nameTemplate, fn) + for _, testCase in ipairs(cases) do + -- Interpolate $fieldName in the name template + local name = nameTemplate + for key, value in pairs(testCase) do + local strValue = type(value) == "table" and "[table]" or tostring(value) + name = string.gsub(name, "%$" .. key, strValue) + end + + -- Create test with the interpolated name + M.it(name, function() + fn(testCase) + end) + end + end +end + +---Skip a test - test will be marked as skipped and not executed +---@param name string Name of the skipped test +---@param fn function Test function (will not be executed) +---@return Test The created skipped test +function M.xit(name, fn) + local currentSuite = suite.getCurrentSuite() + if not currentSuite then + error("xit() must be called inside a describe() block") + end + + local test = { + name = name, + fn = fn, + status = "skipped", + error = nil, + duration = 0, + skipped = true + } + + currentSuite.tests[#currentSuite.tests + 1] = test + return test +end + +---Only run this test (for debugging) - all other tests in suite will be skipped +---@param name string Name of the focused test +---@param fn function Test function to execute +---@return Test The created focused test +function M.fit(name, fn) + local test = M.it(name, fn) + test.only = true + return test +end + +---Parameterized focused tests - fit.each(cases)(name, fn) +---@param cases table[] Array of test case objects +---@return ParameterizedTestFactory Factory function for focused parameterized tests +function M.fit_each(cases) + return function(nameTemplate, fn) + for _, testCase in ipairs(cases) do + local name = nameTemplate + for key, value in pairs(testCase) do + local strValue = type(value) == "table" and "[table]" or tostring(value) + name = string.gsub(name, "%$" .. key, strValue) + end + M.fit(name, function() + fn(testCase) + end) + end + end +end + +---Parameterized skipped tests - xit.each(cases)(name, fn) +---@param cases table[] Array of test case objects +---@return ParameterizedTestFactory Factory function for skipped parameterized tests +function M.xit_each(cases) + return function(nameTemplate, fn) + for _, testCase in ipairs(cases) do + local name = nameTemplate + for key, value in pairs(testCase) do + local strValue = type(value) == "table" and "[table]" or tostring(value) + name = string.gsub(name, "%$" .. key, strValue) + end + M.xit(name, function() + fn(testCase) + end) + end + end +end + +return M diff --git a/packages/lua_test/seed/scripts/framework.lua b/packages/lua_test/seed/scripts/framework.lua index efb990111..86801ce83 100644 --- a/packages/lua_test/seed/scripts/framework.lua +++ b/packages/lua_test/seed/scripts/framework.lua @@ -1,142 +1,31 @@ --- Core test framework with describe/it blocks --- Provides BDD-style test organization +-- Core test framework - main entry point +-- Re-exports all modules for convenient access +-- +-- Split into focused modules: +-- json.lua - JSON parser for test case loading +-- types.lua - Shared type definitions +-- suite.lua - Suite creation and state management +-- describe.lua - BDD describe/it/xit/fit functions +-- hooks.lua - beforeAll/afterAll/beforeEach/afterEach +local json = require("json") +local suite = require("suite") +local describe = require("describe") +local hooks = require("hooks") + +---@class TestFramework +---@field _suites TestSuite[] Array of registered test suites +---@field _currentSuite TestSuite|nil Currently active suite during registration +---@field _config TestConfig Framework configuration local M = {} --- JSON parser for loading test cases -local json = {} +-- Re-export JSON parser +M.json = json -function json.decode(str) - -- Simple JSON parser for test case loading - -- Handles objects, arrays, strings, numbers, booleans, null - local pos = 1 - - local function skip_whitespace() - while pos <= #str and str:sub(pos, pos):match("%s") do - pos = pos + 1 - end - end - - local function parse_string() - pos = pos + 1 -- skip opening quote - local start = pos - while pos <= #str do - local c = str:sub(pos, pos) - if c == '"' then - local result = str:sub(start, pos - 1) - pos = pos + 1 - -- Handle escape sequences - result = result:gsub("\\n", "\n"):gsub("\\t", "\t"):gsub("\\r", "\r") - result = result:gsub('\\"', '"'):gsub("\\\\", "\\") - return result - elseif c == "\\" then - pos = pos + 2 -- skip escape sequence - else - pos = pos + 1 - end - end - error("Unterminated string") - end - - local function parse_number() - local start = pos - while pos <= #str and str:sub(pos, pos):match("[%d%.eE%+%-]") do - pos = pos + 1 - end - return tonumber(str:sub(start, pos - 1)) - end - - local parse_value -- forward declaration - - local function parse_array() - pos = pos + 1 -- skip [ - local arr = {} - skip_whitespace() - if str:sub(pos, pos) == "]" then - pos = pos + 1 - return arr - end - while true do - skip_whitespace() - arr[#arr + 1] = parse_value() - skip_whitespace() - local c = str:sub(pos, pos) - if c == "]" then - pos = pos + 1 - return arr - elseif c == "," then - pos = pos + 1 - else - error("Expected ',' or ']' in array at position " .. pos) - end - end - end - - local function parse_object() - pos = pos + 1 -- skip { - local obj = {} - skip_whitespace() - if str:sub(pos, pos) == "}" then - pos = pos + 1 - return obj - end - while true do - skip_whitespace() - if str:sub(pos, pos) ~= '"' then - error("Expected string key at position " .. pos) - end - local key = parse_string() - skip_whitespace() - if str:sub(pos, pos) ~= ":" then - error("Expected ':' at position " .. pos) - end - pos = pos + 1 - skip_whitespace() - obj[key] = parse_value() - skip_whitespace() - local c = str:sub(pos, pos) - if c == "}" then - pos = pos + 1 - return obj - elseif c == "," then - pos = pos + 1 - else - error("Expected ',' or '}' in object at position " .. pos) - end - end - end - - parse_value = function() - skip_whitespace() - local c = str:sub(pos, pos) - if c == '"' then - return parse_string() - elseif c == "{" then - return parse_object() - elseif c == "[" then - return parse_array() - elseif c == "t" and str:sub(pos, pos + 3) == "true" then - pos = pos + 4 - return true - elseif c == "f" and str:sub(pos, pos + 4) == "false" then - pos = pos + 5 - return false - elseif c == "n" and str:sub(pos, pos + 3) == "null" then - pos = pos + 4 - return nil - elseif c:match("[%d%-]") then - return parse_number() - else - error("Unexpected character '" .. c .. "' at position " .. pos) - end - end - - return parse_value() -end - --- Load test cases from JSON content --- jsonContent: raw JSON string --- path: optional dot-path to nested data (e.g., "login.valid") +---Load test cases from JSON content +---@param jsonContent string Raw JSON string containing test cases +---@param path? string Optional dot-path to nested data (e.g., "login.valid") +---@return table[] Array of test case objects function M.load_cases(jsonContent, path) local data = json.decode(jsonContent) @@ -153,199 +42,30 @@ function M.load_cases(jsonContent, path) return data or {} end --- Test suite state -M._suites = {} -M._currentSuite = nil -M._config = { - timeout = 5000, - verbose = true, - stopOnFirstFailure = false, - filter = nil -} +-- Re-export suite management (with state access) +M._suites = suite._suites +M._currentSuite = suite._currentSuite +M._config = suite._config --- Create a new test suite -function M.createSuite(name) - local suite = { - name = name, - tests = {}, - beforeAll = nil, - afterAll = nil, - beforeEach = nil, - afterEach = nil, - nested = {}, - parent = nil - } - return suite -end +M.createSuite = suite.createSuite +M.configure = suite.configure +M.getSuites = suite.getSuites +M.reset = suite.reset +M.getConfig = suite.getConfig --- Describe block - groups related tests -function M.describe(name, fn) - local parentSuite = M._currentSuite - local suite = M.createSuite(name) - suite.parent = parentSuite - - if parentSuite then - parentSuite.nested[#parentSuite.nested + 1] = suite - else - M._suites[#M._suites + 1] = suite - end - - M._currentSuite = suite - fn() - M._currentSuite = parentSuite - - return suite -end +-- Re-export BDD functions +M.describe = describe.describe +M.it = describe.it +M.it_each = describe.it_each +M.xit = describe.xit +M.fit = describe.fit +M.fit_each = describe.fit_each +M.xit_each = describe.xit_each --- It block - defines a single test -function M.it(name, fn) - if not M._currentSuite then - error("it() must be called inside a describe() block") - end - - local test = { - name = name, - fn = fn, - status = "pending", - error = nil, - duration = 0, - skipped = false - } - - M._currentSuite.tests[#M._currentSuite.tests + 1] = test - return test -end - --- Parameterized tests - it.each(cases)(name, fn) --- cases: array of test case objects --- name: string with $fieldName placeholders for interpolation --- fn: function(testCase) that receives each case -function M.it_each(cases) - return function(nameTemplate, fn) - for _, testCase in ipairs(cases) do - -- Interpolate $fieldName in the name template - local name = nameTemplate - for key, value in pairs(testCase) do - local placeholder = "$" .. key - local strValue = type(value) == "table" and "[table]" or tostring(value) - name = string.gsub(name, "%$" .. key, strValue) - end - - -- Create test with the interpolated name - M.it(name, function() - fn(testCase) - end) - end - end -end - --- Skip a test -function M.xit(name, fn) - if not M._currentSuite then - error("xit() must be called inside a describe() block") - end - - local test = { - name = name, - fn = fn, - status = "skipped", - error = nil, - duration = 0, - skipped = true - } - - M._currentSuite.tests[#M._currentSuite.tests + 1] = test - return test -end - --- Only run this test (for debugging) -function M.fit(name, fn) - local test = M.it(name, fn) - test.only = true - return test -end - --- Parameterized fit - fit.each(cases)(name, fn) -function M.fit_each(cases) - return function(nameTemplate, fn) - for _, testCase in ipairs(cases) do - local name = nameTemplate - for key, value in pairs(testCase) do - local placeholder = "$" .. key - local strValue = type(value) == "table" and "[table]" or tostring(value) - name = string.gsub(name, "%$" .. key, strValue) - end - M.fit(name, function() - fn(testCase) - end) - end - end -end - --- Parameterized xit - xit.each(cases)(name, fn) -function M.xit_each(cases) - return function(nameTemplate, fn) - for _, testCase in ipairs(cases) do - local name = nameTemplate - for key, value in pairs(testCase) do - local strValue = type(value) == "table" and "[table]" or tostring(value) - name = string.gsub(name, "%$" .. key, strValue) - end - M.xit(name, function() - fn(testCase) - end) - end - end -end - --- Setup hooks -function M.beforeAll(fn) - if M._currentSuite then - M._currentSuite.beforeAll = fn - end -end - -function M.afterAll(fn) - if M._currentSuite then - M._currentSuite.afterAll = fn - end -end - -function M.beforeEach(fn) - if M._currentSuite then - M._currentSuite.beforeEach = fn - end -end - -function M.afterEach(fn) - if M._currentSuite then - M._currentSuite.afterEach = fn - end -end - --- Configure the test framework -function M.configure(options) - for k, v in pairs(options) do - if M._config[k] ~= nil then - M._config[k] = v - end - end -end - --- Get all registered suites -function M.getSuites() - return M._suites -end - --- Reset all suites (for fresh test runs) -function M.reset() - M._suites = {} - M._currentSuite = nil -end - --- Get current config -function M.getConfig() - return M._config -end +-- Re-export hooks +M.beforeAll = hooks.beforeAll +M.afterAll = hooks.afterAll +M.beforeEach = hooks.beforeEach +M.afterEach = hooks.afterEach return M diff --git a/packages/lua_test/seed/scripts/hooks.lua b/packages/lua_test/seed/scripts/hooks.lua new file mode 100644 index 000000000..f16301896 --- /dev/null +++ b/packages/lua_test/seed/scripts/hooks.lua @@ -0,0 +1,45 @@ +-- Test lifecycle hooks +-- Provides beforeAll, afterAll, beforeEach, afterEach + +local suite = require("suite") + +---@class HooksModule +local M = {} + +---Setup hook run once before all tests in a suite +---@param fn function Setup function +function M.beforeAll(fn) + local currentSuite = suite.getCurrentSuite() + if currentSuite then + currentSuite.beforeAll = fn + end +end + +---Teardown hook run once after all tests in a suite +---@param fn function Teardown function +function M.afterAll(fn) + local currentSuite = suite.getCurrentSuite() + if currentSuite then + currentSuite.afterAll = fn + end +end + +---Setup hook run before each test in a suite +---@param fn function Setup function +function M.beforeEach(fn) + local currentSuite = suite.getCurrentSuite() + if currentSuite then + currentSuite.beforeEach = fn + end +end + +---Teardown hook run after each test in a suite +---@param fn function Teardown function +function M.afterEach(fn) + local currentSuite = suite.getCurrentSuite() + if currentSuite then + currentSuite.afterEach = fn + end +end + +return M diff --git a/packages/lua_test/seed/scripts/init.lua b/packages/lua_test/seed/scripts/init.lua index 411f5b8dc..4965aad2c 100644 --- a/packages/lua_test/seed/scripts/init.lua +++ b/packages/lua_test/seed/scripts/init.lua @@ -1,11 +1,21 @@ -- lua_test package initialization -- Unit testing framework for MetaBuilder Lua scripts +---@class LuaTestModule +---@field version string Package version +---@field name string Package name local M = {} M.version = "1.0.0" M.name = "lua_test" +---@class LuaTestInitResult +---@field success boolean Whether initialization succeeded +---@field message string Initialization message +---@field version string Package version + +---Initialize the Lua test framework +---@return LuaTestInitResult function M.init() return { success = true, @@ -14,6 +24,14 @@ function M.init() } end +---@class LuaTestInfo +---@field name string Package name +---@field version string Package version +---@field description string Package description +---@field features string[] List of framework features + +---Get information about the test framework +---@return LuaTestInfo function M.info() return { name = M.name, diff --git a/packages/lua_test/seed/scripts/json.lua b/packages/lua_test/seed/scripts/json.lua new file mode 100644 index 000000000..74c646aaf --- /dev/null +++ b/packages/lua_test/seed/scripts/json.lua @@ -0,0 +1,136 @@ +-- JSON parser for loading test cases +-- Lightweight parser for test case data + +---@class JSONParser +local M = {} + +---Decode a JSON string into a Lua value +---@param str string JSON string to parse +---@return any Parsed value (table, string, number, boolean, or nil) +function M.decode(str) + local pos = 1 + + local function skip_whitespace() + while pos <= #str and str:sub(pos, pos):match("%s") do + pos = pos + 1 + end + end + + local function parse_string() + pos = pos + 1 -- skip opening quote + local start = pos + while pos <= #str do + local c = str:sub(pos, pos) + if c == '"' then + local result = str:sub(start, pos - 1) + pos = pos + 1 + -- Handle escape sequences + result = result:gsub("\\n", "\n"):gsub("\\t", "\t"):gsub("\\r", "\r") + result = result:gsub('\\"', '"'):gsub("\\\\", "\\") + return result + elseif c == "\\" then + pos = pos + 2 -- skip escape sequence + else + pos = pos + 1 + end + end + error("Unterminated string") + end + + local function parse_number() + local start = pos + while pos <= #str and str:sub(pos, pos):match("[%d%.eE%+%-]") do + pos = pos + 1 + end + return tonumber(str:sub(start, pos - 1)) + end + + local parse_value -- forward declaration + + local function parse_array() + pos = pos + 1 -- skip [ + local arr = {} + skip_whitespace() + if str:sub(pos, pos) == "]" then + pos = pos + 1 + return arr + end + while true do + skip_whitespace() + arr[#arr + 1] = parse_value() + skip_whitespace() + local c = str:sub(pos, pos) + if c == "]" then + pos = pos + 1 + return arr + elseif c == "," then + pos = pos + 1 + else + error("Expected ',' or ']' in array at position " .. pos) + end + end + end + + local function parse_object() + pos = pos + 1 -- skip { + local obj = {} + skip_whitespace() + if str:sub(pos, pos) == "}" then + pos = pos + 1 + return obj + end + while true do + skip_whitespace() + if str:sub(pos, pos) ~= '"' then + error("Expected string key at position " .. pos) + end + local key = parse_string() + skip_whitespace() + if str:sub(pos, pos) ~= ":" then + error("Expected ':' at position " .. pos) + end + pos = pos + 1 + skip_whitespace() + obj[key] = parse_value() + skip_whitespace() + local c = str:sub(pos, pos) + if c == "}" then + pos = pos + 1 + return obj + elseif c == "," then + pos = pos + 1 + else + error("Expected ',' or '}' in object at position " .. pos) + end + end + end + + parse_value = function() + skip_whitespace() + local c = str:sub(pos, pos) + if c == '"' then + return parse_string() + elseif c == "{" then + return parse_object() + elseif c == "[" then + return parse_array() + elseif c == "t" and str:sub(pos, pos + 3) == "true" then + pos = pos + 4 + return true + elseif c == "f" and str:sub(pos, pos + 4) == "false" then + pos = pos + 5 + return false + elseif c == "n" and str:sub(pos, pos + 3) == "null" then + pos = pos + 4 + return nil + elseif c:match("[%d%-]") then + return parse_number() + else + error("Unexpected character '" .. c .. "' at position " .. pos) + end + end + + return parse_value() +end + +return M diff --git a/packages/lua_test/seed/scripts/suite.lua b/packages/lua_test/seed/scripts/suite.lua new file mode 100644 index 000000000..d00fe9fa2 --- /dev/null +++ b/packages/lua_test/seed/scripts/suite.lua @@ -0,0 +1,83 @@ +-- Test suite state and management +-- Handles suite creation, registration, and configuration + +---@class SuiteManager +---@field _suites TestSuite[] Array of registered test suites +---@field _currentSuite TestSuite|nil Currently active suite during registration +---@field _config TestConfig Framework configuration +local M = {} + +-- Test suite state +M._suites = {} +M._currentSuite = nil +M._config = { + timeout = 5000, + verbose = true, + stopOnFirstFailure = false, + filter = nil +} + +---Create a new test suite +---@param name string Name of the test suite +---@return TestSuite +function M.createSuite(name) + local suite = { + name = name, + tests = {}, + beforeAll = nil, + afterAll = nil, + beforeEach = nil, + afterEach = nil, + nested = {}, + parent = nil + } + return suite +end + +---Register a suite with the framework +---@param suite TestSuite The suite to register +function M.registerSuite(suite) + M._suites[#M._suites + 1] = suite +end + +---Get the current suite being defined +---@return TestSuite|nil +function M.getCurrentSuite() + return M._currentSuite +end + +---Set the current suite being defined +---@param suite TestSuite|nil +function M.setCurrentSuite(suite) + M._currentSuite = suite +end + +---Configure the test framework +---@param options TestConfig Configuration options to set +function M.configure(options) + for k, v in pairs(options) do + if M._config[k] ~= nil then + M._config[k] = v + end + end +end + +---Get all registered test suites +---@return TestSuite[] Array of registered suites +function M.getSuites() + return M._suites +end + +---Reset all suites and state for a fresh test run +function M.reset() + M._suites = {} + M._currentSuite = nil +end + +---Get current test configuration +---@return TestConfig Current configuration +function M.getConfig() + return M._config +end + +return M diff --git a/packages/lua_test/seed/scripts/types.lua b/packages/lua_test/seed/scripts/types.lua new file mode 100644 index 000000000..0e2067c60 --- /dev/null +++ b/packages/lua_test/seed/scripts/types.lua @@ -0,0 +1,33 @@ +-- Shared type definitions for the test framework +-- Central location for all @class and @alias definitions + +---@class TestConfig +---@field timeout number Test timeout in milliseconds +---@field verbose boolean Enable verbose output +---@field stopOnFirstFailure boolean Stop suite on first test failure +---@field filter string|nil Filter tests by name pattern + +---@class TestSuite +---@field name string Suite name +---@field tests Test[] Array of tests in this suite +---@field beforeAll function|nil Hook run once before all tests +---@field afterAll function|nil Hook run once after all tests +---@field beforeEach function|nil Hook run before each test +---@field afterEach function|nil Hook run after each test +---@field nested TestSuite[] Nested test suites +---@field parent TestSuite|nil Parent suite if nested + +---@class Test +---@field name string Test name +---@field fn function Test function to execute +---@field status string Test status (pending, passed, failed, skipped) +---@field error string|nil Error message if failed +---@field duration number Test duration in milliseconds +---@field skipped boolean Whether test is skipped +---@field only boolean|nil If true, only this test runs + +---@alias ParameterizedTestFactory fun(nameTemplate: string, fn: fun(testCase: table)): nil + +-- This module only provides type definitions for LuaLS +-- No runtime exports needed +return {}