diff --git a/README.md b/README.md index 87c7ba1..0d98d84 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,7 @@ Running the tests are as simple as placing the file in a `tests` folder and then You can see more tests in this repo's [tests](https://github.com/FourierTransformer/tested/tree/main/tests) folder! ## AI Disclosure -- AI was not used to write any of this code. It's all been hand written over the course of 2-3 weeks. -- AI was used to help research Lua internals (mostly around file loading and the debug module) -- AI helped generate a prettier terminal output. - - I fed it my [original terminal output](./docs/original-output.txt), and it re-formatted it to something that looks a lot closer to the final terminal output. -- AI has been used to help debug issues +As of version 0.0.3, AI has been used to help implement features, research Lua/Teal internals, debug issues, and make more readable output. Before this version, the code was hand-written, but some research was done with the help of AI. The docs will remain hand-written for now. I am personally still a little skeptical of AI and its place in open source, but at the moment am willing to evaluate it. ## Licenses Parts of the following are included in the source code present in this repo: @@ -60,4 +56,4 @@ Parts of the following are included in the source code present in this repo: - Also bundles a slightly modified [ansicolors.lua](https://github.com/kikito/ansicolors.lua) - MIT - A function from [Luacov](https://github.com/lunarmodules/luacov) code to help merge stats files in process - MIT -Major thanks to hishamhm, kikito, and benoit-germain for their work in the Lua space. Without them, tested wouldn't be possible. +Major thanks to hishamhm, kikito, and benoit-germain for their work in the Lua space. Without them, `tested` wouldn't be possible. diff --git a/build/tested.lua b/build/tested.lua index 1642b6e..280f509 100644 --- a/build/tested.lua +++ b/build/tested.lua @@ -45,14 +45,16 @@ function tested.assert(assertion) actual_type .. "'). Expected: " .. tostring(assertion.expected) .. " (as '" .. expected_type .. "')" end - if assertion.actual == assertion.expected then - return true, "" - end + if actual_type == "table" and expected_type == "table" then return assert_table(assertion.expected, assertion.actual) end + if assertion.actual == assertion.expected then + return true, "" + end + return false, "Actual: " .. tostring(assertion.actual) .. "\nExpected: " .. tostring(assertion.expected) end diff --git a/build/tested/assert_table.lua b/build/tested/assert_table.lua index 1d845a9..33e03cd 100644 --- a/build/tested/assert_table.lua +++ b/build/tested/assert_table.lua @@ -60,7 +60,33 @@ local function add_key_error(prefix, key, error_type, expected, actual) tadd.add("\n") end -local function deep_compare(prefix, expected, actual) +local function add_index_eq_error(prefix, index) + tadd.add("~ ", prefix, "[", tostring(index), "]: Not equal according to __eq\n") +end + +local function add_key_eq_error(prefix, key) + tadd.add("~ ", prefix, ".", tostring(key), ": Not equal according to __eq\n") +end + + +local function get_shared_eq_function(a, b) + local mt = getmetatable(a) + if mt and mt == getmetatable(b) then + return mt.__eq + end +end + +local function deep_compare(prefix, expected, actual, ancestors_expected, ancestors_actual) + local expected_is_ancestor = ancestors_expected[expected] + local actual_is_ancestor = ancestors_actual[actual] + if expected_is_ancestor or actual_is_ancestor then + if not (expected_is_ancestor and actual_is_ancestor) then + tadd.add("~ ", prefix, ": Cycle structure mismatch\n") + end + return + end + ancestors_expected[expected] = true + ancestors_actual[actual] = true local keys, _key_length, sequence = inspect.getKeys(expected) for i = 1, sequence do @@ -68,8 +94,14 @@ local function deep_compare(prefix, expected, actual) add_index_error(prefix, i, "missing_key") elseif type(expected[i]) == "table" and type(actual[i]) == "table" then - - deep_compare(prefix .. "[" .. tostring(i) .. "]", expected[i], actual[i]) + local eq_fn = get_shared_eq_function(expected[i], actual[i]) + if eq_fn then + if not eq_fn(expected[i], actual[i]) then + add_index_eq_error(prefix, i) + end + else + deep_compare(prefix .. "[" .. tostring(i) .. "]", expected[i], actual[i], ancestors_expected, ancestors_actual) + end elseif actual[i] ~= expected[i] then add_index_error(prefix, i, "different_value", expected[i], actual[i]) @@ -80,8 +112,14 @@ local function deep_compare(prefix, expected, actual) add_key_error(prefix, k, "missing_key") elseif type(expected[k]) == "table" and type(actual[k]) == "table" then - - deep_compare(prefix .. "." .. tostring(k), expected[k], actual[k]) + local eq_fn = get_shared_eq_function(expected[k], actual[k]) + if eq_fn then + if not eq_fn(expected[k], actual[k]) then + add_key_eq_error(prefix, k) + end + else + deep_compare(prefix .. "." .. tostring(k), expected[k], actual[k], ancestors_expected, ancestors_actual) + end elseif actual[k] ~= expected[k] then add_key_error(prefix, k, "different_value", expected[k], actual[k]) @@ -96,10 +134,24 @@ local function deep_compare(prefix, expected, actual) for _i, k in ipairs(keys) do if expected[k] == nil then add_key_error(prefix, k, "additional_key") end end + + ancestors_expected[expected] = nil + ancestors_actual[actual] = nil end local function assert_tables(expected, actual) - deep_compare("", expected, actual) + local eq_fn = get_shared_eq_function(expected, actual) + if eq_fn then + if eq_fn(expected, actual) then + return true, "" + else + tadd.add("Not equal according to __eq at root\n") + end + + else + deep_compare("", expected, actual, {}, {}) + + end diff --git a/docs/teal-support.md b/docs/teal-support.md index 63570d5..5ff8970 100644 --- a/docs/teal-support.md +++ b/docs/teal-support.md @@ -3,7 +3,7 @@ `tested` is built from the ground up with with Teal and makes it a first class citizen. Unit tests can be written in Teal and all the functionality (including code coverage!) works across both languages wonderfully. However, there are a couple of things to keep in mind when using `tested` with Teal projects. ## Build, then test if perf matters -If you have a Teal project and are writing your unit tests in Teal, _every_ test file will compile the Teal as it gets loaded. +If you have a Teal project and are writing your unit tests in Teal, _every_ test file will compile the Teal as it gets loaded. However, it is worth knowing that `tested` will attmept to run the Teal files even if they are not typed correctly. Example Teal unit test: ```lua diff --git a/docs/unit-testing.md b/docs/unit-testing.md index 48146bf..e41871f 100644 --- a/docs/unit-testing.md +++ b/docs/unit-testing.md @@ -78,7 +78,7 @@ To see the entire list of CLI options, check out the [CLI Reference](./cli.md) ## Testing tables -`tested.assert` can also deep compare tables, and will generate a little summary of the differences as well as print out the expected and actual table. +`tested.assert` will also deep compare tables, and will generate a little summary of the differences as well as print out the expected and actual table. === "Test" @@ -143,6 +143,28 @@ To see the entire list of CLI options, check out the [CLI Reference](./cli.md) scores = { 10, 20, 30 } } ``` + +### Table cycle compare +`tested` can also check for cycles within a table. It performs a basic structural check to ensure the _structure_ of the cycles are the same. So, if you're writing an assertion that compares tables, you should mirror the cycle in the `expected` table. If you instead reference the `actual` table's cycle it will be considerd a failure. + +Example of a working cycle test: +```lua +tested.test("tables with self-cycles, but the same structure should be equal", function() + local cycle_a: {any:any} = {} + cycle_a["self"] = cycle_a + + local cycle_b: {any:any} = {} + cycle_b["self"] = cycle_b + + tested.assert({ + given = "two tables that each contain a reference to themselves", + should = "be considered structurally equal", + expected = cycle_a, + actual = cycle_b + }) +end) +``` + ## Truthy/Falsy tests Sometimes in Lua you want to check if _anything_ returned (like a `string.match` or that a value exists in a table), we've added in an `assert_truthy` and `assert_falsy` to help out in those cases. @@ -255,4 +277,4 @@ If a test file has a test that throws an unhandled exception or `tested` finds a ? should return unknown since no tested.assert called (0.00ms) No assertions run during test - \ No newline at end of file + diff --git a/src/tested.tl b/src/tested.tl index a703175..5610128 100644 --- a/src/tested.tl +++ b/src/tested.tl @@ -45,14 +45,16 @@ function tested.assert(assertion: types.Assertion): boolean, string actual_type .."'). Expected: " .. tostring(assertion.expected) .. " (as '" .. expected_type .. "')" end - if assertion.actual == assertion.expected then - return true, "" - end - + -- perform table comparison first, so we can explicitly handle __eq + -- as opposed to it being handles in the assertion compare below if actual_type == "table" and expected_type == "table" then return assert_table(assertion.expected as table, assertion.actual as table) end + if assertion.actual == assertion.expected then + return true, "" + end + return false, "Actual: " .. tostring(assertion.actual) .. "\nExpected: " .. tostring(assertion.expected) end diff --git a/src/tested/assert_table.tl b/src/tested/assert_table.tl index 4058441..5531558 100644 --- a/src/tested/assert_table.tl +++ b/src/tested/assert_table.tl @@ -60,16 +60,48 @@ local function add_key_error(prefix: string, key: any, error_type: TableDiff, ex tadd.add("\n") end -local function deep_compare(prefix: string, expected: table, actual: table) - -- not cycle safe, but might be good enough to get us started? +local function add_index_eq_error(prefix: string, index: integer) + tadd.add("~ ", prefix, "[", tostring(index), "]: Not equal according to __eq\n") +end + +local function add_key_eq_error(prefix: string, key: any) + tadd.add("~ ", prefix, ".", tostring(key), ": Not equal according to __eq\n") +end + +-- returns the shared __eq function if both tables use the same metatable that defines it +local function get_shared_eq_function(a: table, b: table): function(any, any): boolean + local mt = getmetatable(a) + if mt and mt == getmetatable(b) then + return mt.__eq + end +end + +local function deep_compare(prefix: string, expected: table, actual: table, ancestors_expected: {table: boolean}, ancestors_actual: {table: boolean}) + local expected_is_ancestor = ancestors_expected[expected] + local actual_is_ancestor = ancestors_actual[actual] + if expected_is_ancestor or actual_is_ancestor then + if not (expected_is_ancestor and actual_is_ancestor) then + tadd.add("~ ", prefix, ": Cycle structure mismatch\n") + end + return + end + ancestors_expected[expected] = true + ancestors_actual[actual] = true + local keys, _key_length, sequence = inspect.getKeys(expected) for i = 1, sequence do if actual[i] == nil then add_index_error(prefix, i, "missing_key") elseif type(expected[i]) == "table" and type(actual[i]) == "table" then - -- DESCEND! - deep_compare(prefix .. "[" .. tostring(i) .."]", expected[i] as {any:any}, actual[i] as {any:any}) + local eq_fn = get_shared_eq_function(expected[i] as table, actual[i] as table) + if eq_fn then + if not eq_fn(expected[i], actual[i]) then + add_index_eq_error(prefix, i) + end + else + deep_compare(prefix .. "[" .. tostring(i) .."]", expected[i] as {any:any}, actual[i] as {any:any}, ancestors_expected, ancestors_actual) + end elseif actual[i] ~= expected[i] then add_index_error(prefix, i, "different_value", expected[i], actual[i]) @@ -80,8 +112,14 @@ local function deep_compare(prefix: string, expected: table, actual: table) add_key_error(prefix, k, "missing_key") elseif type(expected[k]) == "table" and type(actual[k]) == "table" then - -- DESCEND! - deep_compare(prefix .. "." .. tostring(k), expected[k] as {any:any}, actual[k] as {any:any}) + local eq_fn = get_shared_eq_function(expected[k] as table, actual[k] as table) + if eq_fn then + if not eq_fn(expected[k], actual[k]) then + add_key_eq_error(prefix, k) + end + else + deep_compare(prefix .. "." .. tostring(k), expected[k] as {any:any}, actual[k] as {any:any}, ancestors_expected, ancestors_actual) + end elseif actual[k] ~= expected[k] then add_key_error(prefix, k, "different_value", expected[k], actual[k]) @@ -90,16 +128,30 @@ local function deep_compare(prefix: string, expected: table, actual: table) -- check if there are any additional keys floating around keys, _key_length, sequence = inspect.getKeys(actual) - for i = 1, sequence do + for i = 1, sequence do if expected[i] == nil then add_index_error(prefix, i, "additional_key") end end for _i, k in ipairs(keys) do if expected[k] == nil then add_key_error(prefix, k, "additional_key") end end + + ancestors_expected[expected] = nil + ancestors_actual[actual] = nil end local function assert_tables(expected: table, actual: table): (boolean, string) - deep_compare("", expected, actual) + local eq_fn = get_shared_eq_function(expected, actual) + if eq_fn then + if eq_fn(expected, actual) then + return true, "" + else + tadd.add("Not equal according to __eq at root\n") + end + + else + deep_compare("", expected, actual, {}, {}) + + end -- since change table starts off in there. -- using tadd here feels super risky to me... might switch to a bool or something? @@ -114,4 +166,4 @@ local function assert_tables(expected: table, actual: table): (boolean, string) end -return assert_tables \ No newline at end of file +return assert_tables diff --git a/tests/tables_test.tl b/tests/tables_test.tl index cbc0dc7..05e6d5c 100644 --- a/tests/tables_test.tl +++ b/tests/tables_test.tl @@ -42,4 +42,165 @@ tested.test("table compare should work", function() }) end) -return tested \ No newline at end of file +-- Cycle tests +tested.test("tables with self-cycles, but the same structure should be equal", function() + local cycle_a: {any:any} = {} + cycle_a["self"] = cycle_a + + local cycle_b: {any:any} = {} + cycle_b["self"] = cycle_b + + tested.assert({ + given = "two tables that each contain a reference to themselves", + should = "be considered structurally equal", + expected = cycle_a, + actual = cycle_b + }) +end) + +tested.test("slightly complicated tables with self-cycles, but with different structures should not be equal", function() + + local cycle_a: {any:any} = {} + cycle_a[1] = "first" + + local weird_loop = {cycle_a} + cycle_a[2] = weird_loop + + local cycle_b: {any:any} = {} + cycle_b[1] = "first" + cycle_b[2] = weird_loop + + tested.assert({ + given = "two tables where one contains a reference to itself and the other does not", + should = "not be considered structurally equal", + expected = cycle_a, + actual = cycle_b + }) +end) + +tested.test("slightly complicated tables with self-cycles, but the same structure should be equal", function() + + local cycle_a: {any:any} = {} + cycle_a[1] = "first" + + local weird_loop_a = {cycle_a} + cycle_a[2] = weird_loop_a + + local cycle_b: {any:any} = {} + cycle_b[1] = "first" + local weird_loop_b = {cycle_b} + cycle_b[2] = weird_loop_b + + tested.assert({ + given = "two tables where one contains a reference to itself and the other does not", + should = "be considered structurally equal", + expected = cycle_a, + actual = cycle_b + }) +end) + +tested.test("mutually-referential tables with equivalent structure should be equal", function() + local mutual_a: {any:any} = {} + local mutual_b: {any:any} = {} + mutual_a["other"] = mutual_b + mutual_b["other"] = mutual_a + + local mutual_c: {any:any} = {} + local mutual_d: {any:any} = {} + mutual_c["other"] = mutual_d + mutual_d["other"] = mutual_c + + tested.assert({ + given = "two pairs of tables that reference each other", + should = "be considered structurally equal", + expected = mutual_a, + actual = mutual_c + }) +end) + +tested.test("back-reference cycle two levels deep should be considered equal", function() + -- Cycle deeper in the tree: child holds a back-reference to its parent + local node_a: {any:any} = { name = "root", child = {} } + (node_a.child as {any:any})["parent"] = node_a + + local node_b: {any:any} = { name = "root", child = {} } + (node_b.child as {any:any})["parent"] = node_b + + tested.assert({ + given = "two trees where each child holds a reference back to its parent", + should = "be considered structurally equal", + expected = node_a, + actual = node_b + }) +end) + +-- __eq tests +local record Point + x: number + y: number + _internal: string + metamethod __eq: function(Point, Point): boolean +end + +local point_mt: metatable = { + __eq = function(a: Point, b: Point): boolean + return a.x == b.x and a.y == b.y + end +} + +tested.test("tables with matching __eq should be considered equal", function() + -- same logical value, but _internal differs — __eq should make them equal + local p1 = setmetatable({x=3, y=4, _internal="abc"} as Point, point_mt) + local p2 = setmetatable({x=3, y=4, _internal="xyz"} as Point, point_mt) + + tested.assert({ + given = "two Point tables at the same coordinates with differing internal fields", + should = "be equal according to their __eq metamethod", + expected = p1, + actual = p2 + }) +end) + +tested.test("tables with non-matching __eq should be considered not equal", function() + -- __eq returning false should still produce a failure, not a deep-field diff + local p3 = setmetatable({x=1, y=2} as Point, point_mt) + local p4 = setmetatable({x=9, y=9} as Point, point_mt) + + tested.assert({ + given = "two Point tables at different coordinates", + should = "not be equal according to their __eq metamethod", + expected = p3, + actual = p4 + }) +end) + +-- deeply nested __eq tests +-- Shape has no __eq of its own; comparison falls through to deep_compare, which +-- then encounters Point fields that do have __eq +local p5 = setmetatable({x=3, y=4, _internal="abc"} as Point, point_mt) +local p6 = setmetatable({x=3, y=4, _internal="xyz"} as Point, point_mt) -- same coords, differs only in _internal +local p7 = setmetatable({x=7, y=8} as Point, point_mt) -- different coords + +local shape_a = { name = "circle", center = p5 } +local shape_b = { name = "circle", center = p6 } +local shape_c = { name = "circle", center = p7 } + +tested.test("container table whose nested field has matching __eq should be equal", function() + tested.assert({ + given = "two shapes whose center Points share coordinates but differ in _internal", + should = "be equal because the nested Point's __eq only checks x and y", + expected = shape_a, + actual = shape_b + }) +end) + +tested.test("container table whose nested field has non-matching __eq should not be equal", function() + tested.assert({ + given = "two shapes whose center Points have different coordinates", + should = "not be equal because the nested Point's __eq returns false", + expected = shape_a, + actual = shape_c + }) +end) + +return tested