This module facilitates creating unit tests for Lua modules.

Put the following at Module:name/testcases:

local tests = require('Module:UnitTests')

function tests:test_example()
	--[[ here be the tests ]]
end

return tests

Then put the following on Module:name/testcases/documentation:

{{#invoke:name/testcases|run_tests}}

Tests should be written as Lua methods whose names start with test. The self object contains the following methods, which may be called from the method:

preprocess_equals(text, expected, options)
Will check whether expanding templates in text results in expected.
preprocess_equals_many(prefix, suffix, cases, options)
preprocess_equals_preprocess(text1, text2, options)
Will check whether expanding templates in text1 and text2 results in the same string.
preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
equals(name, actual, expected, options)
Will check whether two primitive values are equal. name will be used as the row header. When the value is a table, equals_deep should be used.
equals_deep(name, actual, expected, options)
Will check whether two values, which may be tables, are equal. name will be used as the row header.

options should be given in a table or omitted. Currently, these are the options supported:

  • nowiki: Causes both the expected and the actual values to be wrapped in <nowiki> tags when rendering the results table.
  • comment: A comment to be added to the table.
  • display: A function to yield the form actually displayed in the table. This is used in testcases for pronunciation modules to make the IPA transcriptions display with the correct fonts.
  • show_difference: If this is set to true (or any truthy value besides a function), failing tests will have the first offending character highlighted in red (that is, the first character in the "actual" string that is different from the corresponding character in the "expected string"). If this is a function, the character will be highlighted using the function. (Currently only available in the equals checking function. The highlighter will highlight combining characters together with the characters they are placed on.)

local UnitTester = {}

local ustring = mw.ustring
local is_combining = require("Module:Unicode data").is_combining

local frame
local tick, cross =
	'[[File:Yes check.svg|20px|alt=Sikeres|link=|Sikeres teszt]]',
	'[[File:X mark.svg|20px|alt=Sikertelen|link=|Sikertelen teszt]]'

local result_table_header = '{| class="unit-tests wikitable"\n! class="unit-tests-img-corner" style="cursor:pointer" title="Csak sikertelen tesztek"| !! Szöveg !! Várt !! Tényleges'
local result_table = {}
local num_failures = 0
local total_tests = 0

local function first_difference(s1, s2)
	if type(s1) ~= 'string' then return 'N/A' end
	if type(s2) ~= 'string' then return 'N/A' end
	if s1 == s2 then return '' end
	local iter1 = ustring.gmatch(s1, '.')
	local iter2 = ustring.gmatch(s2, '.')
	local max = math.min(ustring.len(s1) or #s1, ustring.len(s2) or #s2)
	for i = 1, max do
		local c1 = iter1()
		local c2 = iter2()
		if c1 ~= c2 then return i end
	end
	return max + 1
end

local function highlight(str)
	if ustring.find(str, "%s") then
		return '<span style="background-color: pink;">' ..
			str .. '</span>'
	else
		return '<span style="color: red;">' ..
			str .. '</span>'
	end
end

local function find_noncombining(str, i, incr)
	while is_combining(ustring.codepoint(ustring.sub(str, i, i))) do
		i = i + incr
	end
	return i
end

-- Highlight character where a difference was found. Start highlight at first
-- non-combining character before the position. End it after the first non-
-- combining characters after the position. Can specify a custom highlighing
-- function.
local function highlight_difference(actual, expected, differs_at, func)
	if type(differs_at) ~= "number" or not (actual and expected) then
		return actual
	end
	differs_at = find_noncombining(expected, differs_at, -1)
	local i = find_noncombining(actual, differs_at, -1)
	local j = find_noncombining(actual, differs_at + 1, 1)
	j = j - 1
	return ustring.sub(actual, 1, i - 1) ..
		(type(func) == "function" and func or highlight)(ustring.sub(actual, i, j)) ..
		ustring.sub(actual, j + 1, -1)
end

local function val_to_str(v)
	if type(v) == 'string' then
		v = ustring.gsub(v, '\n', '\\n')
		if ustring.match(ustring.gsub(v, '[^\'"]', ''), '^"+$') then
			return "'" .. v .. "'"
		end
		return '"' .. ustring.gsub(v, '"', '\\"' ) .. '"'
	elseif type(v) == 'table' then
		local result, done = {}, {}
		for k, val in ipairs(v) do
			table.insert(result, val_to_str(val))
			done[k] = true
		end
		for k, val in pairs(v) do
			if not done[k] then
				if (type(k) ~= "string") or not ustring.match(k, '^[_%a][_%a%d]*$') then
					k = '[' .. val_to_str(k) .. ']'
				end
				table.insert(result, k .. '=' .. val_to_str(val))
			end
		end
		return '{' .. table.concat(result, ', ') .. '}'
	else
		return tostring(v)
	end
end

local function deep_compare(t1, t2, ignore_mt)
	local ty1 = type(t1)
	local ty2 = type(t2)
	if ty1 ~= ty2 then return false end
	if ty1 ~= 'table' and ty2 ~= 'table' then return t1 == t2 end
	
	local mt = getmetatable(t1)
	if not ignore_mt and mt and mt.__eq then return t1 == t2 end
	
	for k1, v1 in pairs(t1) do
		local v2 = t2[k1]
		if v2 == nil or not deep_compare(v1, v2) then return false end
	end
	for k2, v2 in pairs(t2) do
		local v1 = t1[k2]
		if v1 == nil or not deep_compare(v1, v2) then return false end
	end

	return true
end

function UnitTester:preprocess_equals(text, expected, options)
	local actual = self.frame:preprocess(text)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		num_failures = num_failures + 1
	end
	local differs_at = self.differs_at and (' || ' .. first_difference(expected, actual)) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	actual   = tostring(actual)
	expected = tostring(expected)
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	table.insert(result_table, ' || ' .. mw.text.nowiki(text) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
end

function UnitTester:preprocess_equals_many(prefix, suffix, cases, options)
	for _, case in ipairs(cases) do
		self:preprocess_equals(prefix .. case[1] .. suffix, case[2], options)
	end
end

function UnitTester:preprocess_equals_preprocess(text1, text2, options)
	local actual = self.frame:preprocess(text1)
	local expected = self.frame:preprocess(text2)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		num_failures = num_failures + 1
	end
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	local differs_at = self.differs_at and (' || ' .. first_difference(expected, actual)) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	table.insert(result_table, ' || ' .. mw.text.nowiki(text1) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
end

function UnitTester:preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
	for _, case in ipairs(cases) do
		self:preprocess_equals_preprocess(prefix1 .. case[1] .. suffix1, prefix2 .. (case[2] and case[2] or case[1]) .. suffix2, options)
	end
end

function UnitTester:equals(name, actual, expected, options)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		num_failures = num_failures + 1
	end
	local difference = first_difference(expected, actual)
	if options and options.show_difference and type(difference) == "number" then
		actual = highlight_difference(actual, expected, difference,
			type(options.show_difference) == "function" and options.show_difference)
	end
	local differs_at = self.differs_at and (' || ' .. difference) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	if expected == nil then
		expected = '(nil)'
	else
		expected = tostring(expected)
	end
	if actual == nil then
		actual = '(nil)'
	else
		actual = tostring(actual)
	end
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	
	if options and type(options.display) == "function" then
		expected = options.display(expected)
		actual = options.display(actual)
	end
	
	table.insert(result_table, ' || ' .. name .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
end

function UnitTester:equals_deep(name, actual, expected, options)
	if deep_compare(actual, expected) then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		num_failures = num_failures + 1
	end
	local actual_str = val_to_str(actual)
	local expected_str = val_to_str(expected)
	if self.nowiki or options and options.nowiki then
		expected_str = mw.text.nowiki(expected_str)
		actual_str = mw.text.nowiki(actual_str)
	end
	local differs_at = self.differs_at and (' || ' .. first_difference(expected_str, actual_str)) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	table.insert(result_table, ' || ' .. name .. ' || ' .. expected_str .. ' || ' .. actual_str .. differs_at .. comment .. "\n")
end

function UnitTester:iterate(examples, func)
	if type(examples) ~= 'table' then
		error('First argument of iterate should be a table, not ' .. type(examples) .. '.')
	end
	if type(func) == 'string' then
		func = self[func]
		for i, example in pairs(examples) do
			if type(example) == 'table' then
				func(self, unpack(example))
			elseif type(example) == 'string' then
				self:heading(example)
			else
				error('iterate does not know what to do with example number ' .. i .. ', whose type is ' .. type(example) .. '.')
			end
		end
	elseif type(func) == 'function' then
		for i, example in pairs(examples) do
			if type(example) == 'table' then
				func(self, unpack(example))
			elseif type(example) == 'string' then
				self:heading(example)
			else
				error('iterate does not know what to do with example number ' .. i .. ', whose type is ' .. type(example) .. '.')
			end
		end
	else
		error('Second argument of iterate should be a function or a string, not ' .. type(func) .. '.')
	end
end

function UnitTester:heading(text)
	table.insert(result_table, (' |-\n ! colspan="%u" style="text-align: left" | %s\n'):format(self.columns, text))
end

function UnitTester:run(frame)
	num_failures = 0
	result_table = {}

	self.frame = frame
	self.nowiki = frame.args['nowiki']
	self.differs_at = frame.args['differs_at']
	self.comments = frame.args['comments']
	self.summarize = frame.args['summarize']

	self.columns = 4
	local table_header = result_table_header
	if self.differs_at then
		self.columns = self.columns + 1
		table_header = table_header .. ' !! Első eltérés'
	end
	if self.comments then
		self.columns = self.columns + 1
		table_header = table_header .. ' !! Megjegyzések'
	end

	-- Sort results into alphabetical order.
	local self_sorted = {}
	for key, value in pairs(self) do
		if key:find('^test') then
			total_tests = total_tests + 1
			table.insert(self_sorted, key)
		end
	end
	table.sort(self_sorted)
	-- Add results to the results table.
	for _, key in ipairs(self_sorted) do
		table.insert(result_table, table_header .. "\n")
		table.insert(result_table, '|+ style="text-align: left; font-weight: bold;" | ' .. key .. ':\n|-\n')
		local traceback = "(nincs nyomkövetés)"
		local success, mesg = xpcall(function ()
			return self[key](self)	
		end, function (mesg)
			traceback = debug.traceback("", 2)
			return mesg
		end)
		if not success then
			table.insert(result_table, (' || colspan="%u" style="text-align: left" | <strong class="error">Parancsfájlhiba tesztelés közben: %s</strong>%s\n'):format(
				self.columns, mw.text.nowiki(mesg), frame:extensionTag("pre", traceback)
			))
			num_failures = num_failures + 1
		end
		table.insert(result_table, "|}\n\n")
	end

	local refresh_link = tostring(mw.uri.fullUrl(mw.title.getCurrentTitle().fullText, 'action=purge&forcelinkupdate'))

	local failure_cat = '[[Kategória:Sikertelen egységtesztek]]'
	if mw.title.getCurrentTitle().text:find("/doc$") then
		failure_cat = ''
	end
	
	total_tests = (#result_table - 3) / 2
	local num_successes = total_tests - num_failures
	
	if (self.summarize) then
		if (num_failures == 0) then
			return '<strong class="success">' .. total_tests .. '/' .. total_tests .. ' teszt sikeres</strong>'
		else
			return '<strong class="error">' .. num_successes .. '/' .. total_tests .. ' teszt sikeres</strong>'
		end
	else
		return (num_failures == 0 and '<strong class="success">Minden teszt sikeres.</strong>' or 
				'<strong class="error">' .. num_failures .. ' teszt sikertelen.</strong>' .. failure_cat) ..
			" <span class='plainlinks unit-tests-refresh'>[" .. refresh_link .. " (frissítés)]</span>\n\n" ..
			table.concat(result_table)
	end
end

function UnitTester:new()
	local o = {}
	setmetatable(o, self)
	self.__index = self
	return o
end

local p = UnitTester:new()
function p.run_tests(frame) return p:run(frame) end
return p