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