Module:UnitTests: Difference between revisions

From Linguifex
Jump to navigation Jump to search
No edit summary
No edit summary
Line 1: Line 1:
local m_table = require("Module:table")
local UnitTester = {}
local UnitTester = {}


local concat = table.concat
local deep_equals = m_table.deepEquals
local explode_utf8 = require("Module:string utilities").explode_utf8
local html = mw.html
local insert = table.insert
local is_combining = require("Module:Unicode data").is_combining
local nowiki = require("Module:string/nowiki")
local shallow_copy = m_table.shallowCopy
local sort = table.sort
local sorted_pairs = m_table.sortedPairs
local ustring = mw.ustring
local ustring = mw.ustring
local is_combining = require "Module:Unicode data".is_combining
local UTF8_char = '[\1-\127\194-\244][\128-\191]*'
local sorted_pairs = require('Module:table').sortedPairs
local Array = require("Module:array")


local tick, cross =
local tick, cross =
'[[File:Yes check.svg|20px|alt=Passed|link=|Test passed]]',
'[[File:Yes check.svg|20px|alt=Passed|link=|Test passed]]',
'[[File:X mark.svg|20px|alt=Failed|link=|Test failed]]'
'[[File:X mark.svg|20px|alt=Failed|link=|Test failed]]'
local result_table_header = '{| class="unit-tests wikitable"\n! class="unit-tests-img-corner" style="cursor:pointer" title="Only failed tests"| !! Text !! Expected !! Actual'
local function iter_UTF8(str)
return string.gmatch(str, UTF8_char)
end
-- Skips over bytes that are not used by UTF-8, and will count overlong encodings.
local function len(str)
local _, length = string.gsub(str, UTF8_char, '')
return length
end


local function first_difference(s1, s2)
local function first_difference(s1, s2)
if type(s1) ~= 'string' or type(s2) ~= 'string' then return 'N/A' end
if not (type(s1) == "string" and type(s2) == "string") then
if s1 == s2 then return '' end
return "N/A"
local next_char1, next_char2 = iter_UTF8(s1), iter_UTF8(s2)
elseif s1 == s2 then
local max = math.min(len(s1), len(s2))
return ""
for i = 1, max do
local c1, c2 = next_char1(), next_char2()
if c1 ~= c2 then return i end
end
end
return max + 1
s1 = explode_utf8(s1)
s2 = explode_utf8(s2)
local i = 0
repeat
i = i + 1
until s1[i] ~= s2[i]
return i
end
end


Line 48: Line 49:
local function find_noncombining(str, i, incr)
local function find_noncombining(str, i, incr)
local char = ustring.sub(str, i, i)
local char = ustring.sub(str, i, i)
while char ~= '' and is_combining(ustring.codepoint(char)) do
while char ~= '' and is_combining(char) do
i = i + incr
i = i + incr
char = ustring.sub(str, i, i)
char = ustring.sub(str, i, i)
Line 80: Line 81:
return '"' .. string.gsub(v, '"', '\\"' ) .. '"'
return '"' .. string.gsub(v, '"', '\\"' ) .. '"'
elseif type(v) == 'table' then
elseif type(v) == 'table' then
local result, done = Array(), {}
local result, done = {}, {}
for k, val in ipairs(v) do
for k, val in ipairs(v) do
result:insert(val_to_str(val))
insert(result, val_to_str(val))
done[k] = true
done[k] = true
end
end
Line 90: Line 91:
k = '[' .. val_to_str(k) .. ']'
k = '[' .. val_to_str(k) .. ']'
end
end
result:insert(k .. '=' .. val_to_str(val))
insert(result, k .. '=' .. val_to_str(val))
end
end
end
end
return '{' .. result:concat(', ') .. '}'
return "{" .. concat(result, ", ") .. "}"
else
else
return tostring(v)
return tostring(v)
Line 99: Line 100:
end
end


local function deep_compare(t1, t2, ignore_mt)
local function insert_differences(keys, t1, t2)
local ty1, ty2 = type(t1), type(t2)
for k, v1 in pairs(t1) do
if ty1 ~= ty2 then return false
local v2 = t2[k]
elseif ty1 ~= 'table' then return t1 == t2 end
if v2 == nil or not deep_equals(v1, v2, true) then
insert(keys, k)
end
end
end
 
local function get_differing_keys(t1, t2)
local ty1 = type(t1)
if not (ty1 == type(t2) and ty1 == "table") then
return nil
end
local mt = getmetatable(t1)
local mt = getmetatable(t1)
if not ignore_mt and mt and mt.__eq then return t1 == t2 end
if mt and type(mt) == "table" and rawget(mt, "__eq") then
return nil
end
local keys = {}
insert_differences(keys, t1, t2)
insert_differences(keys, t2, t1)
 
return keys
end
 
local function extract_keys(table, keys)
if not keys then return table end
local new_table = {}
for _, key in ipairs(keys) do
new_table[key] = table[key]
end
return new_table
end
 
-- Return the header for the result table along with the number of columns in the table.
function UnitTester:new_result_table()
local header_row = html.create("tr")
:tag("th")
:attr("class", "unit-tests-img-corner")
:css("cursor", "pointer")
:attr("title", "Only failed tests")
:done()
local columns = shallow_copy(self.name_columns)
insert(columns, "Expected")
insert(columns, "Actual")
insert(columns, differs_at)
for k1, v1 in pairs(t1) do
if self.differs_at then
local v2 = t2[k1]
insert(columns, "Differs at")
if v2 == nil or not deep_compare(v1, v2) then return false end
end
end
for k2, v2 in pairs(t2) do
local v1 = t1[k2]
if self.comments then
if v1 == nil or not deep_compare(v1, v2) then return false end
insert(columns, "Comments")
end
end
 
return true
for _, cell in ipairs(columns) do
header_row = header_row:tag("th")
:wikitext(cell)
:done()
end
self.columns = #columns + 1
return html.create("table")
:attr("class", "unit-tests wikitable")
:node(header_row)
end
end


function UnitTester:preprocess_equals(text, expected, options)
function UnitTester:display_difference(success, name, actual, expected, options)
local actual = self.frame:preprocess(text)
local differs_at = self.differs_at and first_difference(expected, actual)
if actual == expected then
local comment = self.comments and (options and options.comment or "")
self.result_table:insert('|- class="unit-test-pass"\n | ' .. tick)
expected = expected == nil and "(nil)" or tostring(expected)
actual  = actual == nil and "(nil)" or tostring(actual)
if self.nowiki or options and options.nowiki then
expected = nowiki(expected)
actual = nowiki(actual)
end
if options and type(options.display) == "function" then
expected = options.display(expected)
actual = options.display(actual)
end
local cells
if type(name) == "table" then
cells = shallow_copy(name)
insert(cells, expected)
insert(cells, actual)
insert(cells, differs_at)
else
cells = {
name,
expected,
actual,
differs_at
}
end
insert(cells, comment) -- In case differs_at is nil.
local row = html.create("tr")
if success then
row = row:attr("class", "unit-test-pass")
insert(cells, 1, tick)
else
else
self.result_table:insert('|- class="unit-test-fail"\n | ' .. cross)
row = row:attr("class", "unit-test-fail")
insert(cells, 1, cross)
self.num_failures = self.num_failures + 1
self.num_failures = self.num_failures + 1
end
end
local differs_at = self.differs_at and (' || ' .. first_difference(expected, actual)) or ''
local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
for _, cell in ipairs(cells) do
actual  = tostring(actual)
row = row:tag("td")
expected = tostring(expected)
:wikitext(cell)
if self.nowiki or options and options.nowiki then
:done()
expected = mw.text.nowiki(expected)
actual = mw.text.nowiki(actual)
end
end
self.result_table:insert(' || ' .. mw.text.nowiki(text) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
self.result_table = self.result_table:node(row)
self.total_tests = self.total_tests + 1
self.total_tests = self.total_tests + 1
end
function UnitTester:equals(name, actual, expected, options)
success = actual == expected
if options and options.show_difference then
local difference = first_difference(expected, actual)
if type(difference) == "number" then
actual = highlight_difference(actual, expected, difference,
type(options.show_difference) == "function" and options.show_difference)
end
end
self:display_difference(success, name, actual, expected, options)
end
function UnitTester:preprocess_equals(text, expected, options)
local actual = self.frame:preprocess(text)
self:equals(nowiki(text), actual, expected, options)
end
end


Line 146: Line 250:


function UnitTester:preprocess_equals_preprocess(text1, text2, options)
function UnitTester:preprocess_equals_preprocess(text1, text2, options)
local actual = self.frame:preprocess(text1)
local expected = self.frame:preprocess(text2)
local expected = self.frame:preprocess(text2)
if actual == expected then
self:preprocess_equals(text1, expected, options)
self.result_table:insert('|- class="unit-test-pass"\n | ' .. tick)
else
self.result_table:insert('|- class="unit-test-fail"\n | ' .. cross)
self.num_failures = self.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 ''
self.result_table:insert(' || ' .. mw.text.nowiki(text1) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
self.total_tests = self.total_tests + 1
end
end


Line 170: Line 260:
end
end


function UnitTester:equals(name, actual, expected, options)
function UnitTester:equals_deep(name, actual, expected, options)
if actual == expected then
local actual_str, expected_str
self.result_table:insert('|- class="unit-test-pass"\n | ' .. tick)
local success = deep_equals(actual, expected, true)
if success then
if options and options.show_table_difference then
actual_str = ''
expected_str = ''
end
else
else
self.result_table:insert('|- class="unit-test-fail"\n | ' .. cross)
if options and options.show_table_difference then
self.num_failures = self.num_failures + 1
local keys = get_differing_keys(actual, expected)
end
actual_str = val_to_str(extract_keys(actual, keys))
local difference = first_difference(expected, actual)
expected_str = val_to_str(extract_keys(expected, keys))
if options and options.show_difference and type(difference) == "number" then
end
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
end
if self.nowiki or options and options.nowiki then
if (not options) or not options.show_table_difference then
expected = mw.text.nowiki(expected)
actual_str = val_to_str(actual)
actual = mw.text.nowiki(actual)
expected_str = val_to_str(expected)
end
end
if options and type(options.display) == "function" then
expected = options.display(expected)
actual = options.display(actual)
end
self.result_table:insert(' || ' .. name .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. comment .. "\n")
self.total_tests = self.total_tests + 1
end


function UnitTester:equals_deep(name, actual, expected, options)
self:display_difference(success, name, actual_str, expected_str, options)
if deep_compare(actual, expected) then
self.result_table:insert('|- class="unit-test-pass"\n | ' .. tick)
else
self.result_table:insert('|- class="unit-test-fail"\n | ' .. cross)
self.num_failures = self.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
if options and type(options.display) == "function" then
expected_str = options.display(expected_str)
actual_str = options.display(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 ''
self.result_table:insert(' || ' .. name .. ' || ' .. expected_str .. ' || ' .. actual_str .. differs_at .. comment .. "\n")
self.total_tests = self.total_tests + 1
end
end


Line 247: Line 296:
func(self, unpack(example))
func(self, unpack(example))
elseif type(example) == 'string' then
elseif type(example) == 'string' then
self:heading(example)
self:header(example)
else
else
error(('bad example #%d (expected table or string, got %s)')
error(('bad example #%d (expected table or string, got %s)')
Line 255: Line 304:
end
end


function UnitTester:heading(text)
function UnitTester:header(text)
self.result_table:insert((' |-\n ! colspan="%u" style="text-align: left" | %s\n'):format(self.columns, text))
local prefix, maintext = text:match('^#(h[0-9]+):(.*)$')
if not prefix then
maintext = text
end
local header = html.create("th")
:attr("colspan", self.columns)
if prefix == "h1" then
header = header:css("text-align", "center")
:css("font-size", "150%")
else
header = header:css("text-align", "left")
end
header = header:wikitext(maintext)
self.result_table = self.result_table:tag("tr")
:node(header)
:done()
end
end


Line 262: Line 330:
self.num_failures = 0
self.num_failures = 0
local output = Array()
local output = {}
 
local iparams = {
["nowiki"] = {type = "boolean"},
["differs_at"] = {type = "boolean"},
["comments"] = {type = "boolean"},
["summarize"] = {type = "boolean"},
["name_column"] = {list = true, default = "Text"},
}
 
local iargs = require("Module:parameters").process(frame.args, iparams)


self.frame = frame
self.frame = frame
self.nowiki = frame.args['nowiki']
self.nowiki = iargs.nowiki
self.differs_at = frame.args['differs_at']
self.differs_at = iargs.differs_at
self.comments = frame.args['comments']
self.comments = iargs.comments
self.summarize = frame.args['summarize']
self.summarize = iargs.summarize
self.name_columns = iargs.name_column
self.total_tests = 0
self.total_tests = 0
self.result_table = Array()
self.columns = 4
local table_header = result_table_header
if self.differs_at then
self.columns = self.columns + 1
table_header = table_header .. ' !! Differs at'
end
if self.comments then
self.columns = self.columns + 1
table_header = table_header .. ' !! Comments'
end


-- Sort results into alphabetical order.
-- Sort results into alphabetical order.
local self_sorted = Array()
local self_sorted = {}
for key, value in pairs(self) do
for key in pairs(self) do
if key:find('^test') then
if key:find('^test') then
self_sorted:insert(key)
insert(self_sorted, key)
end
end
end
end
self_sorted:sort()
sort(self_sorted)
-- Add results to the results table.
-- Add results to the results table.
for _, key in ipairs(self_sorted) do
for _, key in ipairs(self_sorted) do
self.result_table:insert(table_header .. "\n")
self.result_table = self:new_result_table()
self.result_table:insert('|+ style="text-align: left; font-weight: bold;" | ' .. key .. ':\n|-\n')
:tag("caption")
:css("text-align", "left")
:css("font-weight", "bold")
:wikitext(key .. ":")
:done()
local traceback = "(no traceback)"
local traceback = "(no traceback)"
local success, mesg = xpcall(function ()
local success, mesg = xpcall(function()
return self[key](self)
return self[key](self)
end, function (mesg)
end, function(mesg)
traceback = debug.traceback("", 2)
traceback = debug.traceback("", 2)
return mesg
return mesg
end)
end)
if not success then
if not success then
self.result_table:insert((' |-\n | colspan="%u" style="text-align: left" | <strong class="error">Script error during testing: %s</strong>%s\n'):format(
self.result_table = self.result_table:tag("tr")
self.columns, mw.text.nowiki(mesg), frame:extensionTag("pre", traceback)
:tag("td")
))
:attr("colspan", self.columns)
:css("text-align", "left")
:tag("strong")
:attr("class", "error")
:wikitext("Script error during testing: " .. nowiki(mesg))
:done()
:wikitext(frame:extensionTag("pre", traceback))
:allDone()
self.num_failures = self.num_failures + 1
self.num_failures = self.num_failures + 1
end
end
self.result_table:insert("|}")
insert(output, tostring(self.result_table))
output:insert(self.result_table:concat())
self.result_table = Array()
end
end


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


local failure_cat = '[[Category:Failing module unit tests]]'
local failure_cat = '[[Category:Failing testcase modules]]'
if mw.title.getCurrentTitle().text:find("/documentation$") then
if mw.title.getCurrentTitle().text:find("/documentation$") then
failure_cat = ''
failure_cat = ''
Line 323: Line 399:
local num_successes = self.total_tests - self.num_failures
local num_successes = self.total_tests - self.num_failures
if (self.summarize) then
if self.summarize then
if (self.num_failures == 0) then
if self.num_failures == 0 then
return '<strong class="success">' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed</strong>'
return '<strong class="success">' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed</strong>'
else
else
Line 331: Line 407:
else
else
return (self.num_failures == 0 and '<strong class="success">All tests passed.</strong>' or  
return (self.num_failures == 0 and '<strong class="success">All tests passed.</strong>' or  
'<strong class="error">' .. self.num_failures .. ' test' .. (self.num_failures == 1 and '' or 's' ) .. ' failed.</strong>' .. failure_cat) ..
'<strong class="error">' .. self.num_failures .. ' of ' .. self.total_tests .. ' test' .. (self.total_tests == 1 and '' or 's' ) .. ' failed.</strong>' .. failure_cat) ..
" <span class='plainlinks unit-tests-refresh'>[" .. refresh_link .. " (refresh)]</span>\n\n" ..
" <span class='plainlinks unit-tests-refresh'>[" .. refresh_link .. " (refresh)]</span>\n\n" ..
output:concat("\n\n")
concat(output, "\n\n")
end
end
end
end

Revision as of 16:13, 22 January 2025



local m_table = require("Module:table")

local UnitTester = {}

local concat = table.concat
local deep_equals = m_table.deepEquals
local explode_utf8 = require("Module:string utilities").explode_utf8
local html = mw.html
local insert = table.insert
local is_combining = require("Module:Unicode data").is_combining
local nowiki = require("Module:string/nowiki")
local shallow_copy = m_table.shallowCopy
local sort = table.sort
local sorted_pairs = m_table.sortedPairs
local ustring = mw.ustring

local tick, cross =
	'[[File:Yes check.svg|20px|alt=Passed|link=|Test passed]]',
	'[[File:X mark.svg|20px|alt=Failed|link=|Test failed]]'

local function first_difference(s1, s2)
	if not (type(s1) == "string" and type(s2) == "string") then
		return "N/A"
	elseif s1 == s2 then
		return ""
	end
	
	s1 = explode_utf8(s1)
	s2 = explode_utf8(s2)
	
	local i = 0
	repeat
		i = i + 1
	until s1[i] ~= s2[i]
	
	return i
end

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

local function find_noncombining(str, i, incr)
	local char = ustring.sub(str, i, i)
	while char ~= '' and is_combining(char) do
		i = i + incr
		char = ustring.sub(str, i, i)
	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 = string.gsub(v, '\n', '\\n')
		if string.find(string.gsub(v, '[^\'"]', ''), '^"+$') then
			return "'" .. v .. "'"
		end
		return '"' .. string.gsub(v, '"', '\\"' ) .. '"'
	elseif type(v) == 'table' then
		local result, done = {}, {}
		for k, val in ipairs(v) do
			insert(result, val_to_str(val))
			done[k] = true
		end
		for k, val in sorted_pairs(v) do
			if not done[k] then
				if (type(k) ~= "string") or not string.find(k, '^[_%a][_%a%d]*$') then
					k = '[' .. val_to_str(k) .. ']'
				end
				insert(result, k .. '=' .. val_to_str(val))
			end
		end
		return "{" .. concat(result, ", ") .. "}"
	else
		return tostring(v)
	end
end

local function insert_differences(keys, t1, t2)
	for k, v1 in pairs(t1) do
		local v2 = t2[k]
		if v2 == nil or not deep_equals(v1, v2, true) then
			insert(keys, k)
		end
	end
end

local function get_differing_keys(t1, t2)
	local ty1 = type(t1)
	if not (ty1 == type(t2) and ty1 == "table") then
		return nil
	end
	
	local mt = getmetatable(t1)
	if mt and type(mt) == "table" and rawget(mt, "__eq") then
		return nil
	end
	
	local keys = {}
	insert_differences(keys, t1, t2)
	insert_differences(keys, t2, t1)

	return keys
end

local function extract_keys(table, keys)
	if not keys then return table end
	local new_table = {}
	for _, key in ipairs(keys) do
		new_table[key] = table[key]
	end
	return new_table
end

-- Return the header for the result table along with the number of columns in the table.
function UnitTester:new_result_table()
	local header_row = html.create("tr")
		:tag("th")
			:attr("class", "unit-tests-img-corner")
			:css("cursor", "pointer")
			:attr("title", "Only failed tests")
			:done()
	
	local columns = shallow_copy(self.name_columns)
	insert(columns, "Expected")
	insert(columns, "Actual")
	insert(columns, differs_at)
	
	if self.differs_at then
		insert(columns, "Differs at")
	end
	
	if self.comments then
		insert(columns, "Comments")
	end
	
	for _, cell in ipairs(columns) do
		header_row = header_row:tag("th")
			:wikitext(cell)
			:done()
	end
	
	self.columns = #columns + 1
	
	return html.create("table")
		:attr("class", "unit-tests wikitable")
		:node(header_row)
end

function UnitTester:display_difference(success, name, actual, expected, options)
	local differs_at = self.differs_at and first_difference(expected, actual)
	local comment = self.comments and (options and options.comment or "")
	
	expected = expected == nil and "(nil)" or tostring(expected)
	actual   = actual == nil and "(nil)" or tostring(actual)
	
	if self.nowiki or options and options.nowiki then
		expected = nowiki(expected)
		actual = nowiki(actual)
	end
	
	if options and type(options.display) == "function" then
		expected = options.display(expected)
		actual = options.display(actual)
	end
	
	local cells
	if type(name) == "table" then
		cells = shallow_copy(name)
		insert(cells, expected)
		insert(cells, actual)
		insert(cells, differs_at)
	else
		cells = {
			name,
			expected,
			actual,
			differs_at
		}
	end
	insert(cells, comment) -- In case differs_at is nil.
	
	local row = html.create("tr")
	
	if success then
		row = row:attr("class", "unit-test-pass")
		insert(cells, 1, tick)
	else
		row = row:attr("class", "unit-test-fail")
		insert(cells, 1, cross)
		self.num_failures = self.num_failures + 1
	end
	
	for _, cell in ipairs(cells) do
		row = row:tag("td")
			:wikitext(cell)
			:done()
	end
	
	self.result_table = self.result_table:node(row)
	
	self.total_tests = self.total_tests + 1
end

function UnitTester:equals(name, actual, expected, options)
	success = actual == expected
	if options and options.show_difference then
		local difference = first_difference(expected, actual)
		if type(difference) == "number" then
			actual = highlight_difference(actual, expected, difference,
				type(options.show_difference) == "function" and options.show_difference)
		end
	end
	self:display_difference(success, name, actual, expected, options)
end

function UnitTester:preprocess_equals(text, expected, options)
	local actual = self.frame:preprocess(text)
	self:equals(nowiki(text), actual, expected, options)
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 expected = self.frame:preprocess(text2)
	self:preprocess_equals(text1, expected, options)
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_deep(name, actual, expected, options)
	local actual_str, expected_str
	local success = deep_equals(actual, expected, true)
	if success then
		if options and options.show_table_difference then
			actual_str = ''
			expected_str = ''
		end
	else
		if options and options.show_table_difference then
			local keys = get_differing_keys(actual, expected)
			actual_str = val_to_str(extract_keys(actual, keys))
			expected_str = val_to_str(extract_keys(expected, keys))
		end
	end
	if (not options) or not options.show_table_difference then
		actual_str = val_to_str(actual)
		expected_str = val_to_str(expected)
	end

	self:display_difference(success, name, actual_str, expected_str, options)
end

function UnitTester:iterate(examples, func)
	require 'libraryUtil'.checkType('iterate', 1, examples, 'table')
	if type(func) == 'string' then
		func = self[func]
	elseif type(func) ~= 'function' then
		error(("bad argument #2 to 'iterate' (expected function or string, got %s)")
			:format(type(func)), 2)
	end
	
	for i, example in ipairs(examples) do
		if type(example) == 'table' then
			func(self, unpack(example))
		elseif type(example) == 'string' then
			self:header(example)
		else
			error(('bad example #%d (expected table or string, got %s)')
				:format(i, type(example)), 2)
		end
	end
end

function UnitTester:header(text)
	local prefix, maintext = text:match('^#(h[0-9]+):(.*)$')
	if not prefix then
		maintext = text
	end
	
	local header = html.create("th")
		:attr("colspan", self.columns)
	
	if prefix == "h1" then
		header = header:css("text-align", "center")
			:css("font-size", "150%")
	else
		header = header:css("text-align", "left")
	end
	
	header = header:wikitext(maintext)
	
	self.result_table = self.result_table:tag("tr")
		:node(header)
		:done()
end

function UnitTester:run(frame)
	self.num_failures = 0
	
	local output = {}

	local iparams = {
		["nowiki"] = {type = "boolean"},
		["differs_at"] = {type = "boolean"},
		["comments"] = {type = "boolean"},
		["summarize"] = {type = "boolean"},
		["name_column"] = {list = true, default = "Text"},
	}

	local iargs = require("Module:parameters").process(frame.args, iparams)

	self.frame = frame
	self.nowiki = iargs.nowiki
	self.differs_at = iargs.differs_at
	self.comments = iargs.comments
	self.summarize = iargs.summarize
	self.name_columns = iargs.name_column
	self.total_tests = 0

	-- Sort results into alphabetical order.
	local self_sorted = {}
	for key in pairs(self) do
		if key:find('^test') then
			insert(self_sorted, key)
		end
	end
	sort(self_sorted)
	
	-- Add results to the results table.
	for _, key in ipairs(self_sorted) do
		self.result_table = self:new_result_table()
			:tag("caption")
				:css("text-align", "left")
				:css("font-weight", "bold")
				:wikitext(key .. ":")
				:done()
		local traceback = "(no traceback)"
		local success, mesg = xpcall(function()
			return self[key](self)	
		end, function(mesg)
			traceback = debug.traceback("", 2)
			return mesg
		end)
		if not success then
			self.result_table = self.result_table:tag("tr")
				:tag("td")
					:attr("colspan", self.columns)
					:css("text-align", "left")
					:tag("strong")
						:attr("class", "error")
						:wikitext("Script error during testing: " .. nowiki(mesg))
						:done()
					:wikitext(frame:extensionTag("pre", traceback))
					:allDone()
			self.num_failures = self.num_failures + 1
		end
		insert(output, tostring(self.result_table))
	end

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

	local failure_cat = '[[Category:Failing testcase modules]]'
	if mw.title.getCurrentTitle().text:find("/documentation$") then
		failure_cat = ''
	end
	
	local num_successes = self.total_tests - self.num_failures
	
	if self.summarize then
		if self.num_failures == 0 then
			return '<strong class="success">' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed</strong>'
		else
			return '<strong class="error">' .. num_successes .. '/' .. self.total_tests .. ' tests passed</strong>'
		end
	else
		return (self.num_failures == 0 and '<strong class="success">All tests passed.</strong>' or 
				'<strong class="error">' .. self.num_failures .. ' of ' .. self.total_tests .. ' test' .. (self.total_tests == 1 and '' or 's' ) .. ' failed.</strong>' .. failure_cat) ..
			" <span class='plainlinks unit-tests-refresh'>[" .. refresh_link .. " (refresh)]</span>\n\n" ..
			concat(output, "\n\n")
	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