From e57cc8619305b98fd0f234e016ff8a25a44d6b95 Mon Sep 17 00:00:00 2001 From: Maksim Tiushev Date: Thu, 18 Dec 2025 15:50:30 +0000 Subject: [PATCH] Add unified diff support This patch adds unified diff output for `t.assert_equals()` and `t.assert_covers()` failures. Closes #412 --- CHANGELOG.md | 2 + luatest/assertions.lua | 21 ++- luatest/diff.lua | 218 +++++++++++++++++++++++ test/luaunit/error_msg_test.lua | 306 ++++++++++++++++++++++++++++++-- 4 files changed, 535 insertions(+), 12 deletions(-) create mode 100644 luatest/diff.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d008d25..101576e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added support for unified diff output in `t.assert_equals()` and + `t.assert_covers()` failure messages (gh-412). - Fixed a bug when the JUnit reporter generated invalid XML for parameterized tests with string arguments (gh-407). - Group and suite hooks must now be registered using the call-style diff --git a/luatest/assertions.lua b/luatest/assertions.lua index ffb79dbb..11fde98c 100644 --- a/luatest/assertions.lua +++ b/luatest/assertions.lua @@ -6,6 +6,7 @@ local math = require('math') local comparator = require('luatest.comparator') +local diff = require('luatest.diff') local mismatch_formatter = require('luatest.mismatch_formatter') local pp = require('luatest.pp') local log = require('luatest.log') @@ -83,6 +84,12 @@ local function error_msg_equality(actual, expected, deep_analysis) if success then result = table.concat({result, mismatchResult}, '\n') end + + local diff_result = diff.build_unified_diff(expected, actual) + if diff_result then + result = table.concat({result, 'diff:', diff_result}, '\n') + end + return result end return string.format("expected: %s, actual: %s", @@ -470,7 +477,19 @@ end function M.assert_covers(actual, expected, message) if not table_covers(actual, expected) then local str_actual, str_expected = prettystr_pairs(actual, expected) - failure(string.format('expected %s to cover %s', str_actual, str_expected), message, 2) + local sliced_actual = table_slice(actual, expected) + + local parts = { + string.format('expected %s to cover %s', str_actual, str_expected), + } + + local diff_result = diff.build_unified_diff(expected, sliced_actual) + if diff_result then + table.insert(parts, 'diff:') + table.insert(parts, diff_result) + end + + failure(table.concat(parts, '\n'), message, 2) end end diff --git a/luatest/diff.lua b/luatest/diff.lua new file mode 100644 index 00000000..af9ff2ad --- /dev/null +++ b/luatest/diff.lua @@ -0,0 +1,218 @@ +local M = {} + +-- Recursively normalize a value into something that: +-- * is safe and stable for textual encoding; +-- * produces meaningful diffs for values that provide informative tostring(); +-- * does NOT produce noisy diffs for opaque userdata/cdata (newproxy, ffi types, etc). +local function normalize_for_text(value) + local t = type(value) + + if t == 'table' then + local entries = {} + for k, v in pairs(value) do + local nk = normalize_for_text(k) + if nk == nil then + -- Keys must be representable; fallback to tostring. + nk = tostring(k) + end + + entries[#entries + 1] = { nk, normalize_for_text(v) } + end + + return { __luatest_entries = entries } + end + + + if t == 'cdata' or t == 'userdata' then + local ok, s = pcall(tostring, value) + if ok and type(s) == 'string' then + return { __luatest_scalar = s } + end + + return '' + end + + if t == 'function' or t == 'thread' then + return '<' .. t .. '>' + end + + -- other primitive types. + return value +end + +-- Encode a Lua value as plain text after normalizing it to a diff-friendly form. +local function encode_scalar(v) + local t = type(v) + + if t == 'string' then + return string.format('%q', v) + end + + if t == 'table' and v.__luatest_scalar ~= nil then + -- Scalar wrapper produced by normalize_for_text() for userdata/cdata. + local s = v.__luatest_scalar + if v.__luatest_quote and type(s) == 'string' then + return string.format('%q', s) + end + return tostring(s) + end + + return tostring(v) +end + +local function encode_value(v, indent) + indent = indent or '' + + -- Non-normalized values (or scalars) are emitted on a single line. + if type(v) ~= 'table' or v.__luatest_entries == nil then + return { indent .. encode_scalar(v) } + end + + local lines = { indent .. '{' } + + for _, entry in ipairs(v.__luatest_entries) do + local key_text = entry[1] + local child = encode_value(entry[2], indent .. ' ') + + if #child == 1 then + -- Inline one-liners: key: value + lines[#lines + 1] = string.format( + '%s %s: %s', indent, key_text, child[1]:gsub('^%s*', '') + ) + else + -- Multi-line blocks: + -- key: + -- ... + lines[#lines + 1] = string.format('%s %s:', indent, key_text) + for i = 1, #child do + lines[#lines + 1] = child[i] + end + end + end + + lines[#lines + 1] = indent .. '}' + return lines +end + +--- Convert a supported Lua value into a textual form suitable for diffing. +-- +-- * Tables are serialized with recursive normalization and a simple textual encoder. +-- * Strings are used as-is. +-- * Numbers / booleans are converted via tostring(). +-- * Top-level opaque userdata/cdata disable diffing when tostring() fails (return nil). +local function as_text(value) + local t = type(value) + + if t == 'cdata' or t == 'userdata' then + -- Top-level: if we can't get a meaningful tostring(), disable diffing. + local ok, s = pcall(tostring, value) + if ok and type(s) == 'string' then + return s + end + return nil + end + + if t == 'string' then + return value + end + + local normalized = normalize_for_text(value) + return table.concat(encode_value(normalized), '\n') +end + +local function diff_by_lines(text1, text2) + local lines1 = string.split(text1, '\n') + local lines2 = string.split(text2, '\n') + + local m = #lines1 + local n = #lines2 + local lcs = {} + + for i = 0, m do + lcs[i] = {} + lcs[i][0] = 0 + end + + for j = 0, n do + lcs[0][j] = 0 + end + + for i = 1, m do + for j = 1, n do + if lines1[i] == lines2[j] then + lcs[i][j] = lcs[i - 1][j - 1] + 1 + else + local left = lcs[i - 1][j] + local top = lcs[i][j - 1] + lcs[i][j] = left >= top and left or top + end + end + end + + local diffs = {} + local i = m + local j = n + + while i > 0 or j > 0 do + if i > 0 and j > 0 and lines1[i] == lines2[j] then + table.insert(diffs, 1, {'equal', lines1[i]}) + i = i - 1 + j = j - 1 + elseif j > 0 and (i == 0 or lcs[i][j - 1] >= lcs[i - 1][j]) then + table.insert(diffs, 1, {'insert', lines2[j]}) + j = j - 1 + else + table.insert(diffs, 1, {'delete', lines1[i]}) + i = i - 1 + end + end + + return diffs +end + +local function prettify_patch(diffs) + local out = {} + + for _, diff in ipairs(diffs) do + local tag, line = diff[1], diff[2] + if tag == 'equal' then + table.insert(out, line) + elseif tag == 'delete' then + table.insert(out, '-' .. line) + elseif tag == 'insert' then + table.insert(out, '+' .. line) + end + end + + if #out == 0 then + return nil + end + + return table.concat(out, '\n') +end + +--- Build unified diff for expected and actual values serialized to text. +-- Returns nil when values can't be serialized or there is no diff. +function M.build_unified_diff(expected, actual) + local expected_text = as_text(expected) + local actual_text = as_text(actual) + + if expected_text == nil or actual_text == nil then + return nil + end + + if expected_text == actual_text then + return nil + end + + local diffs = diff_by_lines(expected_text, actual_text) + local patch_text = prettify_patch(diffs) + + if patch_text == '' or patch_text == nil then + return nil + end + + return patch_text +end + +return M diff --git a/test/luaunit/error_msg_test.lua b/test/luaunit/error_msg_test.lua index 09c26044..8399611e 100644 --- a/test/luaunit/error_msg_test.lua +++ b/test/luaunit/error_msg_test.lua @@ -1,4 +1,5 @@ local t = require('luatest') +local utils = require('luatest.utils') local g = t.group() local helper = require('test.helpers.general') @@ -7,15 +8,15 @@ local assert_failure_contains = helper.assert_failure_contains local assert_failure_equals = helper.assert_failure_equals function g.test_assert_equalsMsg() - assert_failure_equals('expected: 2, actual: 1', t.assert_equals, 1, 2 ) - assert_failure_equals('expected: "exp"\nactual: "act"', t.assert_equals, 'act', 'exp') - assert_failure_equals('expected: \n"exp\\\npxe"\nactual: \n"act\\\ntca"', t.assert_equals, 'act\ntca', 'exp\npxe') - assert_failure_equals('expected: true, actual: false', t.assert_equals, false, true) - assert_failure_equals('expected: 1.2, actual: 1', t.assert_equals, 1.0, 1.2) - assert_failure_matches('expected: {1, 2}\nactual: {2, 1}', t.assert_equals, {2,1}, {1,2}) - assert_failure_matches('expected: {one = 1, two = 2}\nactual: {3, 2, 1}', t.assert_equals, {3,2,1}, {one=1,two=2}) - assert_failure_equals('expected: 2, actual: nil', t.assert_equals, nil, 2) - assert_failure_equals('toto\nexpected: 2, actual: nil', t.assert_equals, nil, 2, 'toto') + assert_failure_contains('expected: 2, actual: 1', t.assert_equals, 1, 2 ) + assert_failure_contains('expected: "exp"\nactual: "act"', t.assert_equals, 'act', 'exp') + assert_failure_contains('expected: \n"exp\\\npxe"\nactual: \n"act\\\ntca"', t.assert_equals, 'act\ntca', 'exp\npxe') + assert_failure_contains('expected: true, actual: false', t.assert_equals, false, true) + assert_failure_contains('expected: 1.2, actual: 1', t.assert_equals, 1.0, 1.2) + assert_failure_contains('expected: {1, 2}\nactual: {2, 1}', t.assert_equals, {2,1}, {1,2}) + assert_failure_contains('expected: {one = 1, two = 2}\nactual: {3, 2, 1}', t.assert_equals, {3,2,1}, {one=1,two=2}) + assert_failure_contains('expected: 2, actual: nil', t.assert_equals, nil, 2) + assert_failure_contains('toto\nexpected: 2, actual: nil', t.assert_equals, nil, 2, 'toto') end function g.test_assert_almost_equalsMsg() @@ -340,12 +341,295 @@ function g.test_printTableWithRef() Expected table: {one = 2, two = 3} Actual table: {1, 2}]], t.assert_items_equals, {1,2}, {one=2, two=3}) assert_failure_matches([[expected: {1, 2} -actual: {2, 1}]], t.assert_equals, {2,1}, {1,2}) +actual: {2, 1}[%s%S]*]], + t.assert_equals, + {2,1}, {1,2} + ) -- trigger multiline prettystr assert_failure_matches([[expected: {one = 1, two = 2} -actual: {3, 2, 1}]], t.assert_equals, {3,2,1}, {one=1,two=2}) +actual: {3, 2, 1}[%s%S]*]], + t.assert_equals, + {3,2,1}, {one=1,two=2} + ) -- trigger mismatch formatting assert_failure_contains([[lists