Module:Convert/tester

-- Test the output from a template by comparing it with fixed text. -- The expected text must be in a single line, but can include -- "\n" (two characters) to indicate that a newline is expected. -- Tests are run (or created) by setting p.tests (string or table), or -- by setting page=PAGE_TITLE (and optionally section=SECTION_TITLE), -- then executing run_tests (or make_tests).

local function collection -- Return a table to hold lines of text. return { n = 0, add = function (self, s)			self.n = self.n + 1 self[self.n] = s		end, join = function (self, sep) return table.concat(self, sep) end, } end

local function empty(text) -- Return true if text is nil or empty (assuming a string). return text == nil or text == '' end

local function strip(text) -- Return text with no leading/trailing whitespace. return text:match("^%s*(.-)%s*$") end

local function status_box(stats, expected, actual, iscomment) local label, bgcolor, align, isfail if iscomment then actual = '' align = 'center' bgcolor = 'silver' label = 'Cmnt' elseif expected == '' then stats.ignored = stats.ignored + 1 return '', actual elseif expected == actual then stats.pass = stats.pass + 1 actual = '' align = 'center' bgcolor = 'green' label = 'Pass' else stats.fail = stats.fail + 1 align = 'center' bgcolor = 'red' label = 'Fail' isfail = true end local sbox = 'style="text-align:' .. align .. ';color:white;background:' .. bgcolor .. ';" | ' .. label return sbox, actual, isfail end

local function status_text(stats) local bgcolor, ignored_text, msg if stats.fail == 0 then if stats.pass == 0 then bgcolor = 'salmon' msg = 'No tests performed' else bgcolor = 'green' msg = string.format('All %d tests passed', stats.pass) end else bgcolor = 'darkred' msg = string.format('%d test%s failed', stats.fail, stats.fail == 1 and '' or 's') end if stats.ignored == 0 then ignored_text = '' else bgcolor = 'salmon' ignored_text = string.format(', %d test%s ignored because expected text is blank', stats.ignored, stats.ignored == 1 and '' or 's') end return ' ' .. msg .. ignored_text .. '. ' end

local function run_template(frame, template, args, collapse_multiline) -- Template "" -- gives xargs { " abc  ", "def", name = "ghi jkl" }. if template:sub(1, 2) == '' then template = template:sub(3, -3) .. '|' -- append sentinel to get last field else return '(invalid template)' end local xargs = {} local index = 1 local templatename local function put_arg(k, v)		-- Kludge: Module:Val uses Module:Arguments which trims arguments and -- omits blank arguments. Simulate that here. -- LATER Need a parameter to control this. if templatename:sub(1, 3) == 'val' then v = strip(v) if v == '' then return end end xargs[k] = v	end template = template:gsub('(%[%^%[%-)|(.-%]%])', '%1\0%2') -- replace pipe in piped link with a zero byte for field in template:gmatch('(.-)|') do		field = field:gsub('%z', '|') -- restore pipe in piped link if templatename == nil then templatename = args.template or strip(field) if templatename == '' then return '(invalid template)' end else local k, eq, v = field:match("^(.-)(=)(.*)$") if eq then k, v = strip(k), strip(v) -- k and/or v can be empty local i = tonumber(k) if i and i > 0 and string.match(k, '^%d+$') then put_arg(i, v)				else put_arg(k, v)				end else while xargs[index] ~= nil do -- Skip any explicit numbered parameters like "|5=five". index = index + 1 end put_arg(index, field) end end end if args.test and not xargs.test then -- For convert, allow test=preview or test=nopreview to be injected into -- the convert under test, if it does not already use that parameter. -- That allows, for example, a preview of make_tests to show nopreview results. xargs.test = args.test end local function expand(t) return frame:expandTemplate(t) end local ok, result = pcall(expand, { title = templatename, args = xargs }) if not ok then result = 'Error: ' .. result end if collapse_multiline then result = result:gsub('\n', '\\n') end return result end

local function _make_tests(frame, all_tests, args) local maxlen = 38 for _, item in ipairs(all_tests) do		local template = item[1] if template then local templen = mw.ustring.len(template) item.templen = templen if maxlen < templen and templen <= 70 then maxlen = templen end end end local result = collection for _, item in ipairs(all_tests) do		local template = item[1] if template then local actual = run_template(frame, template, args, true) local pad = string.rep(' ', maxlen - item.templen) .. ' '			result:add(template .. pad .. actual) else local text = item.text if text then result:add(text) end end end -- Pre tags returned by a module are html tags, not like wikitext ... .	return ' \n' .. mw.text.nowiki(result:join('\n')) .. '\n ' end

local function _run_tests(frame, all_tests, args) local function safe_cell(text, multiline) -- For testing undefined undefined, want wikitext like 'kg' to be unchanged -- so the link works and so the displayed text is short (just "kg" in example). text = text:gsub('(%[%^%[%-)|(.-%]%])', '%1\0%2') -- replace pipe in piped link with a zero byte text = text:gsub('{', '&#123;'):gsub('|', '&#124;')   -- escape '{' and '|' text = text:gsub('%z', '|')                           -- restore pipe in piped link if multiline then text = text:gsub('\\n', ' ') end return text end local function nowiki_cell(text, multiline) text = mw.text.nowiki(text) if multiline then text = text:gsub('\\n', ' ') end return text end local stats = { pass = 0, fail = 0, ignored = 0 } local result = collection result:add('{| class="wikitable"') result:add('! Template !! Expected !! Actual, if different !! Status') for _, item in ipairs(all_tests) do		local template, expected = item[1], item[2] or '' if template then local actual = run_template(frame, template, args, true) local sbox, actual, isfail = status_box(stats, expected, actual) result:add('|-') result:add('| ' .. safe_cell(template)) result:add('| ' .. safe_cell(expected, true)) result:add('| ' .. safe_cell(actual, true)) result:add('| ' .. sbox) if isfail then result:add('|-') result:add('| align="center"| (above, nowiki)') result:add('| ' .. nowiki_cell(expected, true)) result:add('| ' .. nowiki_cell(actual, true)) result:add('|') end else local text = item.text if text and text:sub(1, 3) == '---' then result:add('|-') result:add('| colspan="3" style="color:white;background:silver;" | ' .. safe_cell(strip(text:sub(4)), true)) result:add('| ' .. status_box(stats, , , true)) end end end result:add('|}') return status_text(stats) .. '\n\n' .. result:join('\n') end

local function get_page_content(page_title, ignore_error) local t = mw.title.new(page_title) if t then local content = t:getContent if content then if content:sub(-1) ~= '\n' then content = content .. '\n' end return content end end if not ignore_error then error('Could not read wikitext from "' .. page_title .. '".', 0) end end

local function _compare(frame, page_pairs) local function diff_link(title1, title2) return ' [' .. tostring(mw.uri.fullUrl('Special:ComparePages', { page1 = title1, page2 = title2 })) .. ' diff] ' end local function link(title) return  .. title ..  end local function message(text, isgood) local color = isgood and 'green' or 'darkred' return ' ' .. text .. ' '	end local result = collection for _, item in ipairs(page_pairs) do		local label local title1 = item[1] local title2 = item[2] if title1 == title2 then label = message('same title', false) else local content1 = get_page_content(title1, true) local content2 = get_page_content(title2, true) if not content1 or not content2 then label = message('does not exist', false) elseif content1 == content2 then label = message('same content', true) else label = message('different', false) .. ' (' .. diff_link(title1, title2) .. ')' end end result:add('*' .. link(title1) .. ' • ' .. link(title2) .. ' • ' .. label) end return result:join('\n') end

local function sections(text) return { first = 1, -- just after the newline at the end of the last heading this_section = 1, next_heading = function(self) local first = self.first while first <= #text do				local last, heading first, last, heading = text:find('==+[\t ]*([^\n]-)[\t ]*==+[\t\r ]*\n', first) if first then if first == 1 or text:sub(first - 1, first - 1) == '\n' then self.this_section = first self.first = last + 1 return heading end first = last + 1 else break end end self.first = #text + 1 return nil end, current_section = function(self) local first = self.this_section local last = text:find('\n==[^\n]-==[\t\r ]*\n', first) if not last then last = -1 end return text:sub(first, last) end, } end

local function get_tests(frame, tests) local args = frame.args local page_title, section_title = args.page, args.section local show_all = (args.show == 'all') if not empty(page_title) then if not empty(tests) then error('Invoke must not set "page=' .. page_title .. '" if also setting p.tests.', 0) end if page_title:sub(1, 2) ==  and page_title:sub(-2) ==  then page_title = strip(page_title:sub(3, -3)) end tests = get_page_content(page_title) if not empty(section_title) then local s = sections(tests) while true do				local heading = s:next_heading if heading then if heading == section_title then tests = s:current_section break end else error('Section "' .. section_title .. '" not found in page ' .. page_title .. '.', 0) end end end end if type(tests) ~= 'string' then if type(tests) == 'table' then return tests end error('No tests were specified; see Module:Convert/tester/doc.', 0) end if tests:sub(-1) ~= '\n' then tests = tests .. '\n' end local template_count = 0 local all_tests = collection for line in (tests):gmatch('([^\n]-)[\t\r ]*\n') do		local template, expected = line:match('^%s*(.-)%s*$') if template then template_count = template_count + 1 all_tests:add({ template, expected }) elseif show_all then all_tests:add({ text = line }) end end if template_count == 0 then error('No templates found; see Module:Convert/tester/doc.', 0) end return all_tests end

local function main(frame, p, worker) local ok, result = pcall(get_tests, frame, p.tests) if ok then ok, result = pcall(worker, frame, result, frame.args) if ok then return result end end return ' Error \n\n' .. result end

local modules = { -- For convenience, a key defined here can be used to refer to the -- corresponding list of modules. countries = { -- Commons 'Countries', 'Countries/Africa', 'Countries/Americas', 'Countries/Arab world', 'Countries/Asia', 'Countries/Caribbean', 'Countries/Central America', 'Countries/Europe', 'Countries/North America', 'Countries/North America (subcontinent)', 'Countries/Oceania', 'Countries/South America', 'Countries/United Kingdom', },	convert = { 'Convert', 'Convert/data', 'Convert/text', 'Convert/extra', 'Convert/wikidata', 'Convert/wikidata/data', },	cs1 = { 'Citation/CS1', 'Citation/CS1/Configuration', },	cs1all = { 'Citation/CS1', 'Citation/CS1/Configuration', 'Citation/CS1/Whitelist', 'Citation/CS1/Date validation', },	team = { 'Team appearances list', 'Team appearances list/data', 'Team appearances list/show', },	val = { 'Val', 'Val/units', }, }

local p = {}

function p.compare(frame) local page_pairs = p.pairs if not page_pairs then local args = frame.args if not args[2] then local builtins = modules[args[1] or 'convert'] if builtins then args = builtins end end page_pairs = {} for i, title in ipairs(args) do			if not title:find(':', 1, true) then title = 'Module:' .. title end page_pairs[i] = { title, title .. '/sandbox' } end end local ok, result = pcall(_compare, frame, page_pairs) if ok then return result end return ' Error \n\n' .. result end

p.check_sandbox = p.compare

function p.make_tests(frame) return main(frame, p, _make_tests) end

function p.run_tests(frame) return main(frame, p, _run_tests) end

return p