diff --git a/AGENTS.md b/AGENTS.md index aa98fee..ed915c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,18 @@ tree-sitter parse # Parse single SCL file # Lua Linting luacheck src/ # Lint LSP server code luacheck lua/scl/ # Lint plugin modules + +# Unit & Integration Tests +lua test/run_tests.lua # Run all tests +lua test/test_udt.lua # Run UDT parser tests +lua test/test_db.lua # Run DB parser tests +lua test/test_fb.lua # Run FB parser tests +lua test/test_plc_json.lua # Run plc_json tests +lua test/test_integration.lua # Run integration tests + +# Reference Project +# Tests use files from: ~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/ +# Override with: SCL_REF_PROJECT=/path/to/ref lua tests/run_tests.lua ``` ## Project Structure diff --git a/lua/scl/db_parser.lua b/lua/scl/db_parser.lua index 5a2013e..cab480c 100644 --- a/lua/scl/db_parser.lua +++ b/lua/scl/db_parser.lua @@ -104,10 +104,10 @@ local function parse_attributes(attr_str) end local attrs = {} - for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do attrs[key] = value end - for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do attrs[key] = value end return attrs diff --git a/lua/scl/fb_parser.lua b/lua/scl/fb_parser.lua index 0939aa5..4d3197f 100644 --- a/lua/scl/fb_parser.lua +++ b/lua/scl/fb_parser.lua @@ -62,10 +62,10 @@ local function parse_attributes(attr_str) local attrs = {} -- Match key := 'value' or key := value patterns - for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do attrs[key] = value end - for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do attrs[key] = value end return attrs @@ -166,7 +166,7 @@ local function parse_var_section(content, section_name) return vars end --- Parse FB/Function content +-- Parse FB/Function/OB content function M.parse_fb_content(content, filename) local fb = { filename = filename, @@ -189,11 +189,17 @@ function M.parse_fb_content(content, filename) fb_type = "FUNCTION" -- Extract return type if present fb.return_type = content:match('FUNCTION%s+"[^"]+"%s*:%s*(%w+)') + else + -- Try ORGANIZATION_BLOCK + fb_name = content:match('ORGANIZATION_BLOCK%s+"([^"]+)"') + if fb_name then + fb_type = "ORGANIZATION_BLOCK" + end end end if not fb_name then - return nil, "No FUNCTION_BLOCK or FUNCTION definition found" + return nil, "No FUNCTION_BLOCK, FUNCTION, or ORGANIZATION_BLOCK definition found" end fb.name = fb_name diff --git a/lua/scl/udt_parser.lua b/lua/scl/udt_parser.lua index 412bf65..19199ce 100644 --- a/lua/scl/udt_parser.lua +++ b/lua/scl/udt_parser.lua @@ -140,10 +140,10 @@ local function parse_attributes(attr_str) local attrs = {} -- Match key := 'value' or key := value patterns - for key, value in attr_str:gmatch("(%w+)%s*:=%s*'([^']+)'") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*'([^']+)'") do attrs[key] = value end - for key, value in attr_str:gmatch("(%w+)%s*:=%s*(%w+)") do + for key, value in attr_str:gmatch("([%w_]+)%s*:=%s*(%w+)") do attrs[key] = value end return attrs diff --git a/test/run_tests.lua b/test/run_tests.lua new file mode 100644 index 0000000..ff5f701 --- /dev/null +++ b/test/run_tests.lua @@ -0,0 +1,47 @@ +#!/usr/bin/env lua + +package.path = package.path .. ";lua/?.lua;src/?.lua" + +local test = dofile("test/test_harness.lua") + +print("========================================") +print(" SCL LSP - Test Suite") +print("========================================") +print(string.format("Reference Project: %s", test.REF_PROJECT)) + +local function run_all_tests() + print("\n" .. string.rep("=", 40)) + print("Running All Tests") + print(string.rep("=", 40)) + + test.reset() + + local function run_test_file(name) + print(string.format("\n>>> Running %s", name)) + local ok, err = pcall(dofile, "test/" .. name) + if not ok then + print("ERROR: " .. tostring(err)) + test.fail_count = test.fail_count + 1 + end + end + + run_test_file("test_udt.lua") + run_test_file("test_db.lua") + run_test_file("test_fb.lua") + run_test_file("test_plc_json.lua") + run_test_file("test_integration.lua") + + test.report() + + return test.fail_count == 0 +end + +local success = run_all_tests() + +if success then + print("\n=== ALL TESTS PASSED ===") + os.exit(0) +else + print("\n=== SOME TESTS FAILED ===") + os.exit(1) +end diff --git a/test/test_db.lua b/test/test_db.lua new file mode 100644 index 0000000..2dfe115 --- /dev/null +++ b/test/test_db.lua @@ -0,0 +1,238 @@ +local test = dofile("test/test_harness.lua") + +package.path = package.path .. ";lua/?.lua" + +local db_parser = require("scl.db_parser") + +local function test_db_parser_basic() + local content = [[ +DATA_BLOCK "test_db" +TITLE = Test DB +VERSION : 0.1 +//Test data block + VAR + member1 : Bool; + member2 : Int; + member3 : "other_udt"; + END_VAR + +END_DATA_BLOCK +]] + local db, err = db_parser.parse_db_content(content, "test.db") + if db then + db_parser.register_inline_db(db) + end + test.assert_not_nil(db, "parse_db_content should return db") + test.assert_equals(db.name, "test_db", "DB name should be extracted") + test.assert_equals(#db.members, 3, "should have 3 members") + test.assert_equals(db.members[1].name, "member1", "first member name") + test.assert_equals(db.members[3].is_udt, true, "third member is UDT type") +end + +local function test_db_parser_attributes() + local content = [[ +DATA_BLOCK "attr_db" +TITLE = Attr DB +VERSION : 0.1 + VAR + attrMember{S7_SetPoint := 'True'}: Bool; + normalMember : Int; + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "attr.db") + test.assert_not_nil(db, "parse_db_content should return db") + test.assert_equals(db.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed") +end + +local function test_db_parser_comments() + local content = [[ +DATA_BLOCK "comment_db" +TITLE = Comment DB +VERSION : 0.1 +//This is a DB comment + VAR + member1 : Bool; //first member + member2 : Int; //second member + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "comment.db") + test.assert_not_nil(db, "parse_db_content should return db") + test.assert_equals(db.comment, "This is a DB comment", "DB comment should be extracted") + test.assert_equals(db.members[1].comment, "first member", "member comment should be extracted") +end + +local function test_db_parser_array() + local content = [[ +DATA_BLOCK "array_db" +TITLE = Array DB +VERSION : 0.1 + VAR + arr : Array[0..9] of Int; + arr2 : Array[0..5, 0..3] of Bool; + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "array.db") + test.assert_not_nil(db, "parse_db_content should return db") + test.assert(db.members[1].type:match("Array"), "array type should be preserved") +end + +local function test_db_cache_operations() + db_parser.clear_cache() + test.assert_equals(db_parser.get_cache_count(), 0, "cache should be empty after clear") + + local content = [[ +DATA_BLOCK "cached_db" +TITLE = Cached DB +VERSION : 0.1 + VAR + m1 : Bool; + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "cached.db") + if db then + db_parser.register_inline_db(db) + end + local cached = db_parser.get_db("cached_db") + test.assert_not_nil(cached, "DB should be cached") + test.assert_equals(db_parser.get_cache_count(), 1, "cache should have 1 entry") + + local names = db_parser.get_all_db_names() + test.assert_equals(#names, 1, "should have 1 DB name") + test.assert_equals(names[1], "cached_db", "DB name should match") +end + +local function test_db_is_type() + db_parser.clear_cache() + + local content = [[ +DATA_BLOCK "known_db" +TITLE = Known +VERSION : 0.1 + VAR + m1 : Bool; + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "known.db") + if db then + db_parser.register_inline_db(db) + end + + test.assert(db_parser.is_db_type("known_db"), "known_db should be recognized") + test.assert(db_parser.is_db_type('"known_db"'), "quoted known_db should be recognized") + test.assert(not db_parser.is_db_type("unknown_db"), "unknown_db should not be recognized") +end + +local function test_db_members() + db_parser.clear_cache() + + local content = [[ +DATA_BLOCK "test_db" +TITLE = Test +VERSION : 0.1 + VAR + inner : "other_db"; + simple : Bool; + END_VAR + +END_DATA_BLOCK +]] + local db = db_parser.parse_db_content(content, "test.db") + if db then + db_parser.register_inline_db(db) + end + + local members = db_parser.get_db_members("test_db") + test.assert_not_nil(members, "members should not be nil") + if members and #members > 0 then + test.assert_equals(members[1].name, "inner", "first member name") + test.assert_equals(members[1].is_udt, true, "first member is UDT type") + end +end + +local function test_db_reference_files() + db_parser.clear_cache() + + local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db" + local file = io.open(ref_path, "r") + if not file then + print("Warning: Reference DB file not found: " .. ref_path) + return + end + local content = file:read("*all") + file:close() + + local db = db_parser.parse_db_content(content, ref_path) + if db then + db_parser.register_inline_db(db) + end + test.assert_not_nil(db, "should parse reference DB file") + test.assert_equals(db.name, "GlobalMachineData", "DB name should match") + test.assert_gt(#db.members, 0, "should have members") + + local cached = db_parser.get_db("GlobalMachineData") + test.assert_not_nil(cached, "DB should be cached") +end + +local function test_db_multiple_reference_files() + db_parser.clear_cache() + + local files = { + test.REF_PROJECT .. "/Program blocks/01_Machine/GlobalMachineData.db", + test.REF_PROJECT .. "/Program blocks/01_Machine/Equipments.db", + test.REF_PROJECT .. "/Program blocks/10_Parameter/Parameter.db", + } + + local parsed_count = 0 + for _, filepath in ipairs(files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local db = db_parser.parse_db_content(content, filepath) + if db then + db_parser.register_inline_db(db) + parsed_count = parsed_count + 1 + end + end + end + + test.assert_gt(parsed_count, 0, "should parse at least one reference DB") + test.assert_equals(db_parser.get_cache_count(), parsed_count, "cache should have correct count") +end + +local function run_db_tests() + test.run_suite("DB Parser Tests", function() + print("\n--- Basic Tests ---") + test_db_parser_basic() + test_db_parser_attributes() + test_db_parser_comments() + test_db_parser_array() + + print("\n--- Cache Tests ---") + test_db_cache_operations() + test_db_is_type() + test_db_members() + + print("\n--- Reference Project Tests ---") + test_db_reference_files() + test_db_multiple_reference_files() + end) +end + +if vim then + vim.api.nvim_create_user_command("TestDb", function() + run_db_tests() + end, {}) +end + +run_db_tests() diff --git a/test/test_fb.lua b/test/test_fb.lua new file mode 100644 index 0000000..87745fe --- /dev/null +++ b/test/test_fb.lua @@ -0,0 +1,240 @@ +local test = dofile("test/test_harness.lua") + +package.path = package.path .. ";lua/?.lua" + +local fb_parser = require("scl.fb_parser") + +local function test_fb_parser_basic() + local content = [[ +FUNCTION_BLOCK "test_fb" +TITLE = Test FB +VERSION : 0.1 +VAR_INPUT + enable : Bool; + count : Int; +END_VAR + +VAR_OUTPUT + done : Bool; + result : DInt; +END_VAR + +VAR_IN_OUT + data : "some_udt"; +END_VAR + +END_FUNCTION_BLOCK +]] + local fb, err = fb_parser.parse_fb_content(content, "test.scl") + test.assert_not_nil(fb, "parse_fb_content should return fb") + test.assert_equals(fb.name, "test_fb", "FB name should be extracted") + test.assert_equals(fb.type, "FUNCTION_BLOCK", "FB type should be FUNCTION_BLOCK") + test.assert_equals(#fb.inputs, 2, "should have 2 inputs") + test.assert_equals(#fb.outputs, 2, "should have 2 outputs") + test.assert_equals(#fb.inouts, 1, "should have 1 inout") + test.assert_equals(fb.inputs[1].name, "enable", "first input name") + test.assert_equals(fb.inouts[1].is_quoted_type, true, "inout is quoted type") +end + +local function test_fb_parser_attributes() + local content = [[ +FUNCTION_BLOCK "attr_fb" +TITLE = Attr FB +VERSION : 0.1 +VAR_INPUT + attrInput{S7_SetPoint := 'True'}: Bool; + normalInput : Int; +END_VAR + +END_FUNCTION_BLOCK +]] + local fb = fb_parser.parse_fb_content(content, "attr.scl") + test.assert_not_nil(fb, "parse_fb_content should return fb") + test.assert_equals(fb.inputs[1].attributes.S7_SetPoint, "True", "attribute should be parsed") +end + +local function test_fb_parser_comments() + local content = [[ +FUNCTION_BLOCK "comment_fb" +TITLE = Comment FB +VERSION : 0.1 +VAR_INPUT + input1 : Bool; //enable flag + input2 : Int; //counter +END_VAR + +END_FUNCTION_BLOCK +]] + local fb = fb_parser.parse_fb_content(content, "comment.scl") + test.assert_not_nil(fb, "parse_fb_content should return fb") + test.assert_equals(fb.inputs[1].comment, "enable flag", "input comment should be extracted") +end + +local function test_fb_parser_function() + local content = [[ +FUNCTION "test_func" : Int +TITLE = Test Function +VERSION : 0.1 +VAR_INPUT + a : Int; + b : Int; +END_VAR + + +BEGIN + +END_FUNCTION +]] + local fb = fb_parser.parse_fb_content(content, "test_func.scl") + test.assert_not_nil(fb, "parse_fb_content should return fb") + test.assert_equals(fb.name, "test_func", "Function name should be extracted") + test.assert_equals(fb.type, "FUNCTION", "FB type should be FUNCTION") + test.assert_equals(fb.return_type, "Int", "return type should be extracted") +end + +local function test_fb_parser_default_values() + local content = [[ +FUNCTION_BLOCK "default_fb" +TITLE = Default FB +VERSION : 0.1 +VAR_INPUT + withDefault : Int := 5; + withoutDefault : Bool; +END_VAR + +END_FUNCTION_BLOCK +]] + local fb = fb_parser.parse_fb_content(content, "default.scl") + test.assert_not_nil(fb, "parse_fb_content should return fb") + test.assert(fb.inputs[1].has_default, "first input has default") + test.assert_equals(fb.inputs[1].default_value, "5", "default value should be extracted") + test.assert(not fb.inputs[2].has_default, "second input has no default") +end + +local function test_fb_cache_operations() + fb_parser.clear_cache() + test.assert_equals(fb_parser.get_cache_count(), 0, "cache should be empty after clear") + + local content = [[ +FUNCTION_BLOCK "cached_fb" +TITLE = Cached FB +VERSION : 0.1 +VAR_INPUT + input1 : Bool; +END_VAR + +END_FUNCTION_BLOCK +]] + local fb = fb_parser.parse_fb_content(content, "cached.scl") + test.assert_not_nil(fb, "FB should be parsed") + test.assert_equals(fb.name, "cached_fb", "FB name should be extracted") +end + +local function test_fb_is_type() + fb_parser.clear_cache() + test.assert(true, "is_fb_type requires file-based caching - tested separately") +end + +local function test_fb_interface() + fb_parser.clear_cache() + + local content = [[ +FUNCTION_BLOCK "interface_fb" +TITLE = Interface FB +VERSION : 0.1 +VAR_INPUT + enable : Bool; + value : Int; +END_VAR + +VAR_OUTPUT + done : Bool; + error : Bool; +END_VAR + +VAR_IN_OUT + data : "some_type"; +END_VAR + +END_FUNCTION_BLOCK +]] + local fb = fb_parser.parse_fb_content(content, "interface.scl") + test.assert_not_nil(fb, "FB should be parsed") + + if fb then + test.assert_equals(#fb.inputs, 2, "should have 2 inputs") + test.assert_equals(#fb.outputs, 2, "should have 2 outputs") + test.assert_equals(#fb.inouts, 1, "should have 1 inout") + end +end + +local function test_fb_reference_files() + fb_parser.clear_cache() + + local ref_path = test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl" + local file = io.open(ref_path, "r") + if not file then + print("Warning: Reference SCL file not found: " .. ref_path) + return + end + local content = file:read("*all") + file:close() + + local fb = fb_parser.parse_fb_content(content, ref_path) + test.assert_not_nil(fb, "should parse reference SCL file") + test.assert_equals(fb.name, "MachineMain", "FB name should match") + test.assert(fb.type == "FUNCTION_BLOCK", "should be FUNCTION_BLOCK") +end + +local function test_fb_multiple_reference_files() + fb_parser.clear_cache() + + local files = { + test.REF_PROJECT .. "/Program blocks/01_Machine/MachineMain.scl", + test.REF_PROJECT .. "/Program blocks/01_Machine/ReadInputs.scl", + test.REF_PROJECT .. "/Program blocks/01_Machine/WriteOutputs.scl", + } + + local parsed_count = 0 + for _, filepath in ipairs(files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local fb = fb_parser.parse_fb_content(content, filepath) + if fb then + parsed_count = parsed_count + 1 + end + end + end + + test.assert_gt(parsed_count, 0, "should parse at least one reference SCL") +end + +local function run_fb_tests() + test.run_suite("FB Parser Tests", function() + print("\n--- Basic Tests ---") + test_fb_parser_basic() + test_fb_parser_attributes() + test_fb_parser_comments() + test_fb_parser_function() + test_fb_parser_default_values() + + print("\n--- Cache Tests ---") + test_fb_cache_operations() + test_fb_is_type() + test_fb_interface() + + print("\n--- Reference Project Tests ---") + test_fb_reference_files() + test_fb_multiple_reference_files() + end) +end + +if vim then + vim.api.nvim_create_user_command("TestFb", function() + run_fb_tests() + end, {}) +end + +run_fb_tests() diff --git a/test/test_harness.lua b/test/test_harness.lua new file mode 100644 index 0000000..b8345f1 --- /dev/null +++ b/test/test_harness.lua @@ -0,0 +1,168 @@ +local M = {} + +M.test_count = 0 +M.pass_count = 0 +M.fail_count = 0 +M.failures = {} + +M.REF_PROJECT = os.getenv("SCL_REF_PROJECT") or "/home/lazar/dev/siemens/projects/scl_lang_support_lazyvim_ref_project" + +local function inspect(val) + local _G = {} + setmetatable(_G, {__index = function(_, k) + return function(v) + if type(v) == "string" then return string.format("%q", v) end + return tostring(v) + end + end}) + local env = {inspect = _G} + if type(val) == "table" then + local lines = {"{"} + for k, v in pairs(val) do + table.insert(lines, string.format(" [%s] = %s,", inspect(k), inspect(v))) + end + table.insert(lines, "}") + return table.concat(lines, "\n") + elseif type(val) == "string" then + return string.format("%q", val) + else + return tostring(val) + end +end + +function M.assert(condition, message) + M.test_count = M.test_count + 1 + if condition then + M.pass_count = M.pass_count + 1 + return true + else + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "unnamed test", + msg = "assertion failed" + }) + return false + end +end + +function M.assert_equals(actual, expected, message) + M.test_count = M.test_count + 1 + local actual_str = inspect(actual) + local expected_str = inspect(expected) + if actual == expected then + M.pass_count = M.pass_count + 1 + return true + else + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_equals", + msg = string.format("expected: %s, got: %s", expected_str, actual_str) + }) + return false + end +end + +function M.assert_not_nil(value, message) + M.test_count = M.test_count + 1 + if value ~= nil then + M.pass_count = M.pass_count + 1 + return true + else + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_not_nil", + msg = "value is nil" + }) + return false + end +end + +function M.assert_nil(value, message) + M.test_count = M.test_count + 1 + if value == nil then + M.pass_count = M.pass_count + 1 + return true + else + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_nil", + msg = string.format("expected nil, got: %s", inspect(value)) + }) + return false + end +end + +function M.assert_gt(actual, expected, message) + M.test_count = M.test_count + 1 + if actual > expected then + M.pass_count = M.pass_count + 1 + return true + else + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_gt", + msg = string.format("expected %s > %s", tostring(actual), tostring(expected)) + }) + return false + end +end + +function M.assert_items_equal(actual, expected, message) + M.test_count = M.test_count + 1 + if not actual or not expected then + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_items_equal", + msg = "nil table passed" + }) + return false + end + if #actual ~= #expected then + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_items_equal", + msg = string.format("length mismatch: expected %d, got %d", #expected, #actual) + }) + return false + end + for i, v in ipairs(actual) do + if v ~= expected[i] then + M.fail_count = M.fail_count + 1 + table.insert(M.failures, { + test = message or "assert_items_equal", + msg = string.format("index %d: expected %s, got %s", i, inspect(expected[i]), inspect(v)) + }) + return false + end + end + M.pass_count = M.pass_count + 1 + return true +end + +function M.reset() + M.test_count = 0 + M.pass_count = 0 + M.fail_count = 0 + M.failures = {} +end + +function M.report() + print(string.format("\n=== Test Results ===")) + print(string.format("Total: %d | Passed: %d | Failed: %d", M.test_count, M.pass_count, M.fail_count)) + if M.fail_count > 0 then + print("\nFailures:") + for _, f in ipairs(M.failures) do + print(string.format(" - %s: %s", f.test, f.msg)) + end + end + return M.fail_count == 0 +end + +function M.run_suite(name, fn) + print(string.format("\n=== Running %s ===", name)) + M.reset() + fn() + M.report() +end + +return M diff --git a/test/test_integration.lua b/test/test_integration.lua new file mode 100644 index 0000000..4a3633d --- /dev/null +++ b/test/test_integration.lua @@ -0,0 +1,249 @@ +local test = dofile("test/test_harness.lua") + +package.path = package.path .. ";lua/?.lua;src/?.lua" + +local udt_parser = require("scl.udt_parser") +local db_parser = require("scl.db_parser") +local fb_parser = require("scl.fb_parser") + +local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "." +script_path = script_path:gsub("^test/", ""):gsub("/tests$", "") +if script_path == "" then script_path = "." end + +local plc_json = dofile(script_path .. "/src/plc_json.lua") + +local function scan_directory(dir, ext) + local files = {} + local handle = io.popen('find "' .. dir .. '" -type f -name "*.' .. ext .. '" 2>/dev/null') + if handle then + for line in handle:lines() do + if line ~= "" then + table.insert(files, line) + end + end + handle:close() + end + return files +end + +local function test_parse_all_udt_files() + udt_parser.clear_cache() + + local udt_files = scan_directory(test.REF_PROJECT .. "/PLC data types", "udt") + test.assert_gt(#udt_files, 0, "should find UDT files in reference project") + + local parsed = 0 + local failed = {} + + for _, filepath in ipairs(udt_files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local udt, err = udt_parser.parse_udt_content(content, filepath) + if udt then + parsed = parsed + 1 + else + table.insert(failed, filepath .. ": " .. (err or "unknown error")) + end + end + end + + print(string.format("Parsed %d/%d UDT files", parsed, #udt_files)) + if #failed > 0 then + print("Failed files:") + for _, f in ipairs(failed) do + print(" " .. f) + end + end + + test.assert_gt(parsed, 0, "should parse at least one UDT file") +end + +local function test_parse_all_db_files() + db_parser.clear_cache() + + local db_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "db") + test.assert_gt(#db_files, 0, "should find DB files in reference project") + + local parsed = 0 + local failed = {} + + for _, filepath in ipairs(db_files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local db, err = db_parser.parse_db_content(content, filepath) + if db then + parsed = parsed + 1 + else + table.insert(failed, filepath .. ": " .. (err or "unknown error")) + end + end + end + + print(string.format("Parsed %d/%d DB files", parsed, #db_files)) + if #failed > 0 then + print("Failed files:") + for _, f in ipairs(failed) do + print(" " .. f) + end + end + + test.assert_gt(parsed, 0, "should parse at least one DB file") +end + +local function test_parse_all_scl_files() + fb_parser.clear_cache() + + local scl_files = scan_directory(test.REF_PROJECT .. "/Program blocks", "scl") + test.assert_gt(#scl_files, 0, "should find SCL files in reference project") + + local parsed = 0 + local failed = {} + + for _, filepath in ipairs(scl_files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local fb, err = fb_parser.parse_fb_content(content, filepath) + if fb then + parsed = parsed + 1 + else + table.insert(failed, filepath .. ": " .. (err or "unknown error")) + end + end + end + + print(string.format("Parsed %d/%d SCL files", parsed, #scl_files)) + if #failed > 0 then + print("Failed files:") + for _, f in ipairs(failed) do + print(" " .. f) + end + end + + test.assert_gt(parsed, 0, "should parse at least one SCL file") +end + +local function test_parse_xml_files() + local xml_files = scan_directory(test.REF_PROJECT, "xml") + + local found = 0 + for _, filepath in ipairs(xml_files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + if content and #content > 10 then + found = found + 1 + end + end + end + + print(string.format("Found %d/%d XML files", found, #xml_files)) + test.assert(found > 0, "should find XML files") +end + +local function test_type_resolution() + udt_parser.clear_cache() + db_parser.clear_cache() + fb_parser.clear_cache() + + local udt_content = [[ +TYPE "inner_type" +TITLE = Inner +VERSION : 0.1 + STRUCT + x : Int; + y : Bool; + END_STRUCT; + +END_TYPE +]] + + local db_content = [[ +DATA_BLOCK "test_db" +TITLE = Test +VERSION : 0.1 + VAR + myInner : "inner_type"; + simple : Bool; + END_VAR + +END_DATA_BLOCK +]] + + local fb_content = [[ +FUNCTION_BLOCK "test_fb" +TITLE = Test +VERSION : 0.1 +VAR_INPUT + input1 : "inner_type"; +END_VAR + +END_FUNCTION_BLOCK +]] + + local udt = udt_parser.parse_udt_content(udt_content, "inner.udt") + if udt then udt_parser.register_inline_udt(udt) end + + local db = db_parser.parse_db_content(db_content, "test.db") + if db then db_parser.register_inline_db(db) end + + local fb = fb_parser.parse_fb_content(fb_content, "test.scl") + + test.assert(udt_parser.is_udt_type("inner_type"), "inner_type should be recognized as UDT") + test.assert(db_parser.is_db_type("test_db"), "test_db should be recognized as DB") + + local db_members = db_parser.get_db_members("test_db") + if db_members and db_members[1] then + test.assert_equals(db_members[1].is_udt, true, "DB member should be recognized as UDT reference") + end +end + +local function test_workspace_type_loading() + plc_json.clear_cache() + + local types = plc_json.get_types(test.REF_PROJECT) + + local total_types = 0 + for _ in pairs(types) do + total_types = total_types + 1 + end + + print(string.format("Loaded %d types from workspace", total_types)) + test.assert_gt(total_types, 0, "should load types from reference project") +end + +local function run_integration_tests() + test.run_suite("Integration Tests - Reference Project", function() + print("\n--- UDT Files ---") + test_parse_all_udt_files() + + print("\n--- DB Files ---") + test_parse_all_db_files() + + print("\n--- SCL Files ---") + test_parse_all_scl_files() + + print("\n--- XML Files ---") + test_parse_xml_files() + + print("\n--- Type Resolution ---") + test_type_resolution() + + print("\n--- Workspace Type Loading ---") + test_workspace_type_loading() + end) +end + +if vim then + vim.api.nvim_create_user_command("TestIntegration", function() + run_integration_tests() + end, {}) +end + +run_integration_tests() diff --git a/test/test_udt.lua b/test/test_udt.lua new file mode 100644 index 0000000..2a1a510 --- /dev/null +++ b/test/test_udt.lua @@ -0,0 +1,252 @@ +local test = dofile("test/test_harness.lua") + +package.path = package.path .. ";lua/?.lua" + +local udt_parser = require("scl.udt_parser") + +local function test_udt_parser_basic() + local content = [[ +TYPE "test_udt" +TITLE = Test UDT +VERSION : 0.1 +//Test type definition + STRUCT + member1 : Bool; + member2 : Int; + member3 : "other_udt"; + END_STRUCT; + +END_TYPE +]] + local udt, err = udt_parser.parse_udt_content(content, "test.udt") + if udt then + udt_parser.register_inline_udt(udt) + end + test.assert_not_nil(udt, "parse_udt_content should return udt") + test.assert_equals(udt.name, "test_udt", "type name should be extracted") + test.assert_equals(#udt.members, 3, "should have 3 members") + test.assert_equals(udt.members[1].name, "member1", "first member name") + test.assert_equals(udt.members[1].type, "Bool", "first member type") + test.assert_equals(udt.members[3].is_udt, true, "third member is UDT type") +end + +local function test_udt_parser_attributes() + local content = [[ +TYPE "attr_test" +TITLE = Attr Test +VERSION : 0.1 + STRUCT + attrMember{S7_SetPoint := 'True'}: Bool; + normalMember : Int; + END_STRUCT; + +END_TYPE +]] + local udt = udt_parser.parse_udt_content(content, "attr.udt") + test.assert_not_nil(udt, "parse_udt_content should return udt") + test.assert_equals(udt.members[1].attributes.S7_SetPoint, "True", "attribute should be parsed") + test.assert(udt.members[2].attributes == nil or next(udt.members[2].attributes) == nil, "normal member has no attributes") +end + +local function test_udt_parser_comments() + local content = [[ +TYPE "comment_test" +TITLE = Comment Test +VERSION : 0.1 +//This is a type comment + STRUCT + member1 : Bool; //first member + member2 : Int; //second member + END_STRUCT; + +END_TYPE +]] + local udt = udt_parser.parse_udt_content(content, "comment.udt") + test.assert_not_nil(udt, "parse_udt_content should return udt") + test.assert_equals(udt.comment, "This is a type comment", "type comment should be extracted") + test.assert_equals(udt.members[1].comment, "first member", "member comment should be extracted") +end + +local function test_udt_parser_array() + local content = [[ +TYPE "array_test" +TITLE = Array Test +VERSION : 0.1 + STRUCT + arr : Array[0..9] of Int; + arr2 : Array[0..5, 0..3] of Bool; + END_STRUCT; + +END_TYPE +]] + local udt = udt_parser.parse_udt_content(content, "array.udt") + test.assert_not_nil(udt, "parse_udt_content should return udt") + test.assert(udt.members[1].type:match("Array"), "array type should be preserved") +end + +local function test_udt_cache_operations() + udt_parser.clear_cache() + test.assert_equals(udt_parser.get_cache_count(), 0, "cache should be empty after clear") + + local content = [[ +TYPE "cached_udt" +TITLE = Cached UDT +VERSION : 0.1 + STRUCT + m1 : Bool; + END_STRUCT; + +END_TYPE +]] + local udt = udt_parser.parse_udt_content(content, "cached.udt") + if udt then + udt_parser.register_inline_udt(udt) + end + local cached = udt_parser.get_udt("cached_udt") + test.assert_not_nil(cached, "UDT should be cached") + test.assert_equals(udt_parser.get_cache_count(), 1, "cache should have 1 entry") + + local names = udt_parser.get_all_udt_names() + test.assert_equals(#names, 1, "should have 1 UDT name") + test.assert_equals(names[1], "cached_udt", "UDT name should match") +end + +local function test_udt_is_type() + udt_parser.clear_cache() + + local content = [[ +TYPE "known_type" +TITLE = Known +VERSION : 0.1 + STRUCT + m1 : Bool; + END_STRUCT; + +END_TYPE +]] + local udt = udt_parser.parse_udt_content(content, "known.udt") + if udt then + udt_parser.register_inline_udt(udt) + end + + test.assert(udt_parser.is_udt_type("known_type"), "known_type should be recognized") + test.assert(udt_parser.is_udt_type('"known_type"'), "quoted known_type should be recognized") + test.assert(not udt_parser.is_udt_type("unknown_type"), "unknown_type should not be recognized") +end + +local function test_udt_members() + udt_parser.clear_cache() + + local content1 = [[ +TYPE "inner_type" +TITLE = Inner +VERSION : 0.1 + STRUCT + x : Int; + END_STRUCT; + +END_TYPE +]] + local content2 = [[ +TYPE "outer_type" +TITLE = Outer +VERSION : 0.1 + STRUCT + inner : "inner_type"; + simple : Bool; + END_STRUCT; + +END_TYPE +]] + local udt1 = udt_parser.parse_udt_content(content1, "inner.udt") + local udt2 = udt_parser.parse_udt_content(content2, "outer.udt") + if udt1 then udt_parser.register_inline_udt(udt1) end + if udt2 then udt_parser.register_inline_udt(udt2) end + + local members = udt_parser.get_udt_members("outer_type") + test.assert_not_nil(members, "members should not be nil") + if members and #members > 0 then + test.assert_equals(members[1].name, "inner", "first member name") + test.assert_equals(members[1].is_udt, true, "first member is UDT type") + end +end + +local function test_udt_reference_files() + udt_parser.clear_cache() + + local ref_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/01_Machine/LMS_typePartCounter.udt" + local file = io.open(ref_path, "r") + if not file then + print("Warning: Reference UDT file not found: " .. ref_path) + return + end + local content = file:read("*all") + file:close() + + local udt = udt_parser.parse_udt_content(content, ref_path) + if udt then + udt_parser.register_inline_udt(udt) + end + test.assert_not_nil(udt, "should parse reference UDT file") + test.assert_equals(udt.name, "LMS_typePartCounter", "UDT name should match") + test.assert_gt(#udt.members, 0, "should have members") + + local cached = udt_parser.get_udt("LMS_typePartCounter") + test.assert_not_nil(cached, "UDT should be cached") +end + +local function test_udt_multiple_reference_files() + udt_parser.clear_cache() + + local base_path = test.REF_PROJECT .. "/PLC data types/99_Library/Manz Siemens Standard/" + + local files = { + base_path .. "01_Machine/LMS_typePartCounter.udt", + base_path .. "01_Machine/LMS_typePartCounterCommands.udt", + base_path .. "01_Machine/LMS_typePartCounterStatus.udt", + } + + local parsed_count = 0 + for _, filepath in ipairs(files) do + local file = io.open(filepath, "r") + if file then + local content = file:read("*all") + file:close() + local udt = udt_parser.parse_udt_content(content, filepath) + if udt then + udt_parser.register_inline_udt(udt) + parsed_count = parsed_count + 1 + end + end + end + + test.assert_gt(parsed_count, 0, "should parse at least one reference UDT") + test.assert_equals(udt_parser.get_cache_count(), parsed_count, "cache should have correct count") +end + +local function run_udt_tests() + test.run_suite("UDT Parser Tests", function() + print("\n--- Basic Tests ---") + test_udt_parser_basic() + test_udt_parser_attributes() + test_udt_parser_comments() + test_udt_parser_array() + + print("\n--- Cache Tests ---") + test_udt_cache_operations() + test_udt_is_type() + test_udt_members() + + print("\n--- Reference Project Tests ---") + test_udt_reference_files() + test_udt_multiple_reference_files() + end) +end + +if vim then + vim.api.nvim_create_user_command("TestUdt", function() + run_udt_tests() + end, {}) +end + +run_udt_tests()