Modul:ScribuntoUnit

-------------------------------------------------------------------------------
-- Unit tests for Scribunto.
-------------------------------------------------------------------------------
local DebugHelper = {}
local ScribuntoUnit = {}

-------------------------------------------------------------------------------
-- Concatenates keys and values, ideal for displaying a template argument table.
-- @param keySeparator glue between key and value (defaults to " = ")
-- @param separator glue between different key-value pairs (defaults to ", ")
-- @example concatWithKeys({a = 1, b = 2, c = 3}, ' => ', ', ') => "a => 1, b => 2, c => 3"
--
function DebugHelper.concatWithKeys(table, keySeparator, separator)
	keySeparator = keySeparator or ' = '
	separator = separator or ', '
	local concatted = ''
	local i = 1
	local first = true
	local unnamedArguments = true
	for k, v in pairs(table) do
		if first then
			first = false
		else
			concatted = concatted .. separator
		end
		if k == i and unnamedArguments then
			i = i + 1
			concatted = concatted .. tostring(v)
		else
			unnamedArguments = false
			concatted = concatted .. tostring(k) .. keySeparator .. tostring(v)
		end
	end
	return concatted
end

-------------------------------------------------------------------------------
-- Compares two tables recursively (non-table values are handled correctly as well).
-- @param ignoreMetatable if false, t1.__eq is used for the comparison
--
function DebugHelper.deepCompare(t1, t2, ignoreMetatable)
	local type1 = type(t1)
	local type2 = type(t2)

	if type1 ~= type2 then
		return false
	end
	if type1 ~= 'table' then
		return t1 == t2
	end

	local metatable = getmetatable(t1)
	if not ignoreMetatable and metatable and metatable.__eq then
		return t1 == t2
	end

	for k1, v1 in pairs(t1) do
		local v2 = t2[k1]
		if v2 == nil or not DebugHelper.deepCompare(v1, v2) then
			return false
		end
	end
	for k2, v2 in pairs(t2) do
		if t1[k2] == nil then
			return false
		end
	end

	return true
end

-------------------------------------------------------------------------------
-- Raises an error with stack information
-- @param details a table with error details
--        - should have a 'text' key which is the error message to display
--        - a 'trace' key will be added with the stack data
--        - and a 'source' key with file/line number
--        - a metatable will be added for error handling
--
function DebugHelper.raise(details, level)
	level = (level or 1) + 1
	details.trace = debug.traceback('', level)
	details.source = mw.text.split(details.trace, '%s')[5]
	-- this would be more robust but does not work
	-- local match = string.match(details.trace, '^%s*stack traceback:%s*(%S*): ')
	-- details.source = match and match[1] or ''

--    setmetatable(details, {
--        __tostring: function() return details.text end
--    })
	error(details, level)
end

-------------------------------------------------------------------------------
-- when used in a test, that test gets ignored, and the skipped count increases by one.
--
function ScribuntoUnit:markTestSkipped()
	DebugHelper.raise({ScribuntoUnit = true, skipped = true}, 3)
end

-------------------------------------------------------------------------------
-- Checks that the input is true
-- @param message optional description of the test
--
function ScribuntoUnit:assertTrue(actual, message)
	if not actual then
		DebugHelper.raise({ScribuntoUnit = true, text = string.format("Potvrzení, že „%s“ je pravdivé, selhalo", tostring(actual)), message = message}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that the input is false
-- @param message optional description of the test
--
function ScribuntoUnit:assertFalse(actual, message)
	if actual then
		DebugHelper.raise({ScribuntoUnit = true, text = string.format("Potvrzení, že „%s“ je nepravdivé, selhalo", tostring(actual)), message = message}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks an input string contains the expected string
-- @param message optional description of the test
-- @param plain search is made with a plain string instead of a ustring pattern
--
function ScribuntoUnit:assertStringContains(pattern, s, plain, message)
	if type(pattern) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Chyba typu vzoru (očekáván řetězec, předán %s)", type(pattern)),
			message = message
		}, 2)
	end
	if type(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Chyba typu řetězce (očekáván řetězec, předán %s)", type(s)),
			message = message
		}, 2)
	end
	if not mw.ustring.find(s, pattern, nil, plain) then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format('Nalezení %s „%s“ v „%s“ se nezdařilo', plain and "prostého řetězce" or "vzoru", pattern, s),
			message = message
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks an input string doesn't contain the expected string
-- @param message optional description of the test
-- @param plain search is made with a plain string instead of a ustring pattern
--
function ScribuntoUnit:assertNotStringContains(pattern, s, plain, message)
	if type(pattern) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Chyba typu vzoru (očekáván řetězec, předán %s)", type(pattern)),
			message = message
		}, 2)
	end
	if type(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Chyba typu řetězce (očekáván řetězec, předán %s)", type(s)),
			message = message
		}, 2)
	end
	local i, j = mw.ustring.find(s, pattern, nil, plain)
	if i then
		local match = mw.ustring.sub(s, i, j)
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format('Nalezení „%s“ bylo %s „%s“ potvrzeno', match, plain and "v prostém řetězci" or "ve vzoru", pattern),
			message = message
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that an input has the expected value.
-- @param message optional description of the test
-- @example assertEquals(4, add(2,2), "2+2 should be 4")
--
function ScribuntoUnit:assertEquals(expected, actual, message)

	if type(expected) == 'number' and type(actual) == 'number' then
		self:assertWithinDelta(expected, actual, 1e-8, message)

	elseif expected ~= actual then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Potvrzení, že „%s“ se rovná očekávanému „%s“, selhalo", tostring(actual), tostring(expected)),
			actual = actual,
			expected = expected,
			message = message,
		}, 2)
	end

end

-------------------------------------------------------------------------------
-- Checks that 'actual' is within 'delta' of 'expected'.
-- @param message optional description of the test
-- @example assertEquals(1/3, 9/3, "9/3 should be 1/3", 0.000001)
function ScribuntoUnit:assertWithinDelta(expected, actual, delta, message)
	if type(expected) ~= "number" then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Očekávaná hodnota „%s“ není číslo", tostring(expected)),
			actual = actual,
			expected = expected,
			message = message,
		}, 2)
	end
	if type(actual) ~= "number" then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Skutečná hodnota „%s“ není číslo", tostring(actual)),
			actual = actual,
			expected = expected,
			message = message,
		}, 2)
	end
	local diff = expected - actual
	if diff < 0 then diff = - diff end  -- instead of importing math.abs
	if diff > delta then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Potvrzení, že %f je v rozsahu %f od očekávaných %f, selhalo", actual, delta, expected),
			actual = actual,
			expected = expected,
			message = message,
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that a table has the expected value (including sub-tables).
-- @param message optional description of the test
-- @example assertDeepEquals({{1,3}, {2,4}}, partition(odd, {1,2,3,4}))
function ScribuntoUnit:assertDeepEquals(expected, actual, message)
	if not DebugHelper.deepCompare(expected, actual) then
		if type(expected) == 'table' then
			expected = mw.dumpObject(expected)
		end
		if type(actual) == 'table' then
			actual = mw.dumpObject(actual)
		end
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Potvrzení, že %s se rovná očekávaným %s, selhalo", tostring(actual), tostring(expected)),
			actual = actual,
			expected = expected,
			message = message,
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that a wikitext gives the expected result after processing.
-- @param message optional description of the test
-- @example assertResultEquals("Hello world", "{{concat|Hello|world}}")
function ScribuntoUnit:assertResultEquals(expected, text, message)
	local frame = self.frame
	local actual = frame:preprocess(text)
	if expected ~= actual then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Failed to assert that %s equals expected %s after preprocessing", text, tostring(expected)),
			actual = actual,
			actualRaw = text,
			expected = expected,
			message = message,
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that two wikitexts give the same result after processing.
-- @param message optional description of the test
-- @example assertSameResult("{{concat|Hello|world}}", "{{deleteLastChar|Hello world!}}")
function ScribuntoUnit:assertSameResult(text1, text2, message)
	local frame = self.frame
	local processed1 = frame:preprocess(text1)
	local processed2 = frame:preprocess(text2)
	if processed1 ~= processed2 then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Failed to assert that %s equals expected %s after preprocessing", processed1, processed2),
			actual = processed1,
			actualRaw = text1,
			expected = processed2,
			expectedRaw = text2,
			message = message,
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks that a template gives the expected output.
-- @param message optional description of the test
-- @example assertTemplateEquals("Hello world", "concat", {"Hello", "world"})
function ScribuntoUnit:assertTemplateEquals(expected, template, args, message)
	local frame = self.frame
	local actual = frame:expandTemplate(template, args)
	if expected ~= actual then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = string.format("Failed to assert that %s with args %s equals expected %s after preprocessing",
								 DebugHelper.concatWithKeys(args), template, expected),
			actual = actual,
			actualRaw = template,
			expected = expected,
			message = message,
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Creates a new test suite.
-- @param o a table with test functions (alternatively, the functions can be added later to the returned suite)
--
function ScribuntoUnit:new(o)
	o = o or {}
	setmetatable(o, {__index = self})
	o.run = function(frame) return self:run(o, frame) end
	return o
end

-------------------------------------------------------------------------------
-- Resets global counters
--
function ScribuntoUnit:init(frame)
	self.frame = frame
	self.successCount = 0
	self.failureCount = 0
	self.skipCount = 0
	self.results = {}
end

-------------------------------------------------------------------------------
-- Runs a single testcase
-- @param name test nume
-- @param test function containing assertions
--
function ScribuntoUnit:runTest(suite, name, test)
	local success, details = pcall(test, suite)

	if success then
		self.successCount = self.successCount + 1
		table.insert(self.results, {name = name, success = true})
	elseif type(details) ~= 'table' or not details.ScribuntoUnit then -- a real error, not a failed assertion
		self.failureCount = self.failureCount + 1
		table.insert(self.results, {name = name, error = true, message = 'Chyba Lua -- ' .. tostring(details)})
	elseif details.skipped then
		self.skipCount = self.skipCount + 1
		table.insert(self.results, {name = name, skipped = true})
	else
		self.failureCount = self.failureCount + 1
		local message = details.source
		if details.message then
			message = message .. details.message .. "\n"
		end
		message = message .. details.text
		table.insert(self.results, {name = name, error = true, message = message, expected = details.expected, actual = details.actual})
	end
end

-------------------------------------------------------------------------------
-- Runs all tests and displays the results.
--
function ScribuntoUnit:runSuite(suite, frame)
	self:init(frame)
	local names = {}
	for name in pairs(suite) do
		if name:find('^test') then
			table.insert(names, name)
		end
	end
	table.sort(names) -- Put tests in alphabetical order.
	for i, name in ipairs(names) do
		local func = suite[name]
		self:runTest(suite, name, func)
	end
	return {
		successCount = self.successCount,
		failureCount = self.failureCount,
		skipCount = self.skipCount,
		results = self.results,
	}
end

-------------------------------------------------------------------------------
-- #invoke entry point for running the tests.
-- Can be called without a frame, in which case it will use mw.log for output
-- @param displayMode see displayResults()
--
function ScribuntoUnit:run(suite, frame)
	local testData = self:runSuite(suite, frame)
	if frame then
		return self:displayResults(testData, frame.args.displayMode or 'table')
	else
		return self:displayResults(testData, 'log')
	end
end

-------------------------------------------------------------------------------
-- Displays test results
-- @param displayMode: 'table', 'log' or 'short'
--
function ScribuntoUnit:displayResults(testData, displayMode)
	if displayMode == 'table' then
		return self:displayResultsAsTable(testData)
	elseif displayMode == 'log' then
		return self:displayResultsAsLog(testData)
	elseif displayMode == 'short' then
		return self:displayResultsAsShort(testData)
	else
		error('unknown display mode')
	end
end

function ScribuntoUnit:displayResultsAsLog(testData)
	if testData.failureCount > 0 then
		mw.log('SELHÁNÍ!!!')
	elseif testData.skipCount > 0 then
		mw.log('Některé testy nemohly být provedeny bez přístupu k frame a byly vynechány. Pro provedení všech testů volejte tento testovací balík jako šablonu.')
	end
	mw.log(string.format('Potvrzení: úspěšných: %d, chybných: %d, vynechaných: %d', testData.successCount, testData.failureCount, testData.skipCount))
	mw.log('-------------------------------------------------------------------------------')
	for _, result in ipairs(testData.results) do
		if result.error then
			mw.log(string.format('%s: %s', result.name, result.message))
		end
	end
end

-- TODO l10n

function ScribuntoUnit:displayResultsAsShort(testData)
	local text = string.format('úspěšných: %d, chybných: %d, vynechaných: %d', testData.successCount, testData.failureCount, testData.skipCount)
	if testData.failureCount > 0 then
		text = '<span class="error">' .. text .. '</span>'
	end
	return text
end

function ScribuntoUnit:displayResultsAsTable(testData)
	local successIcon, failIcon = self.frame:preprocess('{{ano}}'), self.frame:preprocess('{{ne}}')
	local text = ''
	if testData.failureCount > 0 then
		local msg = "'''{{PLURAL:$1|Jeden test selhal|$1 testy selhaly|$1 testů selhalo}}'''."
		msg = mw.message.newRawMessage(msg, testData.failureCount):plain()
		msg = self.frame:preprocess(msg)
		text = text .. failIcon .. ' ' .. msg .. '\n'
	else
		local msg = "'''Všechny testy prošly'''."
		text = text .. successIcon .. ' ' .. msg .. '\n'
	end
	text = text .. '{| class="wikitable scribunto-test-table"\n'
	text = text .. '!\n! Název\n! Očekáváno\n! Ve skutečnosti\n'
	for _, result in ipairs(testData.results) do
		text = text .. '|-\n'
		if result.error then
			text = text .. '| ' .. failIcon .. '\n| ' .. result.name .. '\n| '
			if (result.expected and result.actual) then
				text = text .. mw.text.nowiki(tostring(result.expected)) .. '\n| ' .. mw.text.nowiki(tostring(result.actual)) .. '\n'
			else
				text = text .. ' colspan="2" | ' .. mw.text.nowiki(result.message) .. '\n'
			end
		else
			text = text .. '| ' .. successIcon .. '\n| ' .. result.name .. '\n|\n|\n'
		end
	end
	text = text .. '|}\n'
	return text
end

return ScribuntoUnit