-- Unit tests for Scribunto.
require('strict')

local DebugHelper = {}
local ScribuntoUnit = {}

-- The cfg table contains all localisable strings and configuration, to make it
-- easier to port this module to another wiki.
local cfg = mw.loadData('Module:ScribuntoUnit/config')

-- Concatenates keys and values, ideal for displaying a template or parser function argument table.
-- @param keySeparator glue between key and value (defaults to " = ")
-- @param separator glue between different key-value pairs 
-- @example concatWithKeys => "a => 1, b => 2, c => 3"
-- 
function DebugHelper.concatWithKeys
 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
 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 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
 level = (level or 1) + 1
 details.trace = debug.traceback
 details.source = string.match(details.trace, '^%s*stack traceback:%s*(%S*: )')

-- setmetatable

 error
end

-- when used in a test, that test gets ignored, and the skipped count increases by one.
-- 
function ScribuntoUnit:markTestSkipped
 DebugHelper.raise
end

-- Checks that the input is true
-- @param message optional description of the test
-- 
function ScribuntoUnit:assertTrue
 if not actual then
 DebugHelper.raise({ScribuntoUnit = true, text = string.format("Failed to assert that %s is true", tostring(actual)), message = message}, 2)
 end
end

-- Checks that the input is false
-- @param message optional description of the test
-- 
function ScribuntoUnit:assertFalse
 if actual then
 DebugHelper.raise({ScribuntoUnit = true, text = string.format("Failed to assert that %s is false", 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
	if type(pattern) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Pattern type error ", type(pattern)),
			message = message
	end
	if type(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("String type error ", type(s)),
			message = message
	end
	if not mw.ustring.find then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format,
			message = message
	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
	if type(pattern) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("Pattern type error ", type(pattern)),
			message = message
	end
	if type(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("String type error ", type(s)),
			message = message
	end
	local i, j = mw.ustring.find
	if i then
		local match = mw.ustring.sub
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format,
			message = message
	end
end

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

	if type(expected) == 'number' and type(actual) == 'number' then
 self:assertWithinDelta

	elseif expected ~= actual then
 DebugHelper.raise({
 ScribuntoUnit = true, 
 text = string.format("Failed to assert that %s equals expected %s", tostring(actual), tostring(expected)), 
 actual = actual,
 expected = expected,
 message = message,
 end

end

-- Checks that 'actual' is within 'delta' of 'expected'.
-- @param message optional description of the test
-- @example assertEquals
function ScribuntoUnit:assertWithinDelta
 if type(expected) ~= "number" then
 DebugHelper.raise({
 ScribuntoUnit = true,
 text = string.format("Expected value %s is not a number", tostring(expected)),
 actual = actual,
 expected = expected,
 message = message,
 end
 if type(actual) ~= "number" then
 DebugHelper.raise({
 ScribuntoUnit = true,
 text = string.format("Actual value %s is not a number", tostring(actual)),
 actual = actual,
 expected = expected,
 message = message,
 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, 
 actual = actual,
 expected = expected,
 message = message,
 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)
function ScribuntoUnit:assertDeepEquals
 if not DebugHelper.deepCompare 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("Failed to assert that %s equals expected %s", tostring(actual), tostring(expected)), 
 actual = actual,
 expected = expected,
 message = message,
 end
end

-- Checks that a wikitext gives the expected result after processing.
-- @param message optional description of the test
-- @example assertResultEquals
function ScribuntoUnit:assertResultEquals
 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,
 end
end

-- Checks that two wikitexts give the same result after processing.
-- @param message optional description of the test
-- @example assertSameResult
function ScribuntoUnit:assertSameResult
 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, 
 actual = processed1,
 actualRaw = text1,
 expected = processed2,
 expectedRaw = text2,
 message = message,
 end
end

-- Checks that a parser function gives the expected output.
-- @param message optional description of the test
-- @example assertParserFunctionEquals
function ScribuntoUnit:assertParserFunctionEquals
 local frame = self.frame
 local actual = frame:callParserFunction{ name = pfname, args = 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), pfname, expected),
 actual = actual,
 actualRaw = pfname,
 expected = expected,
 message = message,
 end
end

-- Checks that a template gives the expected output.
-- @param message optional description of the test
-- @example assertTemplateEquals
function ScribuntoUnit:assertTemplateEquals
 local frame = self.frame
 local actual = frame:expandTemplate{ title = template, args = 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,
 end
end

-- Checks whether a function throws an error
-- @param fn the function to test
-- @param expectedMessage optional the expected error message
-- @param message optional description of the test
function ScribuntoUnit:assertThrows
 local succeeded, actualMessage = pcall(fn)
 if succeeded then
 DebugHelper.raise({
 ScribuntoUnit = true,
 text = 'Expected exception but none was thrown',
 message = message,
 end
	-- For strings, strip the line number added to the error message
 actualMessage = type(actualMessage) == 'string' 
 	and string.match(actualMessage, 'Module:[^:]*:[0-9]*: (.*)')
 	or actualMessage
 local messagesMatch = DebugHelper.deepCompare
 if expectedMessage and not messagesMatch then
 DebugHelper.raise({
 ScribuntoUnit = true,
 expected = expectedMessage,
 actual = actualMessage,
 text = string.format('Expected exception with message %s, but got message %s', 
 tostring(expectedMessage), tostring(actualMessage)
 ),
 message = message
 end
end

-- Checks whether a function doesn't throw an error
-- @param fn the function to test
-- @param message optional description of the test
function ScribuntoUnit:assertDoesNotThrow
	local succeeded, actualMessage = pcall(fn)
	if succeeded then
	 return
	end
	-- For strings, strip the line number added to the error message
	actualMessage = type(actualMessage) == 'string' 
		and string.match(actualMessage, 'Module:[^:]*:[0-9]*: (.*)')
		or actualMessage
	DebugHelper.raise({
		ScribuntoUnit = true,
		actual = actualMessage,
		text = string.format('Expected no exception, but got exception with message %s',
			tostring(actualMessage)
		),
		message = message
end

-- Creates a new test suite.
-- @param o a table with test functions 
-- 
function ScribuntoUnit:new(o)
 o = o or {}
 setmetatable
 o.run = function(frame) return self:run end
 return o
end

-- Resets global counters
-- 
function ScribuntoUnit:init(frame)
 self.frame = frame or mw.getCurrentFrame
 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
 local success, details = pcall
 
 if success then
 self.successCount = self.successCount + 1
 table.insert
 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 = 'Lua error -- ' .. tostring(details)})
 elseif details.skipped then
 self.skipCount = self.skipCount + 1
 table.insert
 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
 end
end

-- Runs all tests and displays the results.
-- 
function ScribuntoUnit:runSuite
 self:init(frame)
	local names = {}
 for name in pairs(suite) do
 if name:find('^test') then
			table.insert
 end
 end
	table.sort(names) -- Put tests in alphabetical order.
	for i, name in ipairs(names) do
		local func = suite[name]
		self:runTest
	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
 local testData = self:runSuite
 if frame and frame.args then
 return self:displayResults
 else
 return self:displayResults
 end
end

-- Displays test results 
-- @param displayMode: 'table', 'log' or 'short'
-- 
function ScribuntoUnit:displayResults
 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('FAILURES!!!')
 elseif testData.skipCount > 0 then
 mw.log('Some tests could not be executed without a frame and have been skipped. Invoke this test suite as a template to run all tests.')
 end
 mw.log(string.format)
 mw.log('-------------------------------------------------------------------------------')
 for _, result in ipairs(testData.results) do
 if result.error then
 mw.log(string.format)
 end
 end
end

function ScribuntoUnit:displayResultsAsShort(testData)
 local text = string.format
 if testData.failureCount > 0 then
 text = ''
 end
 return text
end

function ScribuntoUnit:displayResultsAsTable(testData)
 local successIcon, failIcon = self.frame:preprocess(cfg.successIndicator), self.frame:preprocess(cfg.failureIndicator)
 local text = ''
	if testData.failureCount > 0 then
		local msg = mw.message.newRawMessage:plain
		msg = self.frame:preprocess(msg)
		if cfg.failureCategory then
			msg = cfg.failureCategory .. msg
		end
		text = text .. failIcon .. ' ' .. msg .. '\n'
	else
		text = text .. successIcon .. ' ' .. cfg.successSummary .. '\n'
	end
 text = text .. '{| class="wikitable scribunto-test-table"\n'
 text = text .. '!\n! ' .. cfg.nameString .. '\n! ' .. cfg.expectedString .. '\n! ' .. cfg.actualString .. '\n'
 for _, result in ipairs(testData.results) do
 text = text .. '|-\n'
 if result.error then
 text = text .. '| ' .. failIcon .. '\n| '
 if (result.expected and result.actual) then
 	local name = result.name
 	if result.testname then
 		name = name .. ' / ' .. result.testname
 	end
 text = text .. name .. '\n| ' .. mw.text.nowiki(tostring(result.expected)) .. '\n| ' .. mw.text.nowiki(tostring(result.actual)) .. '\n'
 else
 text = text .. result.name .. '\n| ' .. ' 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