Module:UnitTests

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 shallowcopy = m_table.shallowcopy local sort = table.sort local sorted_pairs = m_table.sortedPairs local ustring = mw.ustring

local tick, cross = ,	

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 ' ' .. string.gsub(str, " ", " ") .. ' '	else return ' ' .. str .. ' '	end end

local function find_noncombining(str, i, incr) local char = ustring.sub(str, i, i)	while char ~= '' and is_combining(ustring.codepoint(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 deep_compare(t1, t2, ignore_mt) return deep_equals(t1, t2, not ignore_mt) end

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

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 = shallowcopy(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 = shallowcopy(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_compare(actual, expected) 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 = '' 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 ' ' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed ' else return ' ' .. num_successes .. '/' .. self.total_tests .. ' tests passed ' end else return (self.num_failures == 0 and ' All tests passed. ' or 				' ' .. self.num_failures .. ' of ' .. self.total_tests .. ' test' .. (self.total_tests == 1 and '' or 's' ) .. ' failed. ' .. failure_cat) .. " [" .. refresh_link .. " (refresh)] \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