# 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 Collection = {}  
Collection.__index = Collection  
do  
function Collection:add(item)  
if item ~= nil then  
self.n = self.n + 1  
self[self.n] = item  
end  
end  
function Collection:join(sep)  
return table.concat(self, sep)  
end  
function Collection.new  
return setmetatable({n = 0}, Collection)  
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 normalize(text)  
-- Return text with any strip markers normalized by replacing the  
-- unique number with a fixed value so comparisons work.  
return text:gsub('(\127[^\127]*UNIQ[^\127]*%-)(%x+)(-QINU[^\127]*\127)', '%100000000%3')  
end  

local function status_box(stats, expected, actual, iscomment)  
local label, bgcolor, align, isfail  
if iscomment then  
actual = ''  
label = 'Cmnt'  
elseif expected == '' then  
stats.ignored = stats.ignored + 1  
return '', actual  
elseif normalize(expected) == normalize(actual) then  
stats.pass = stats.pass + 1  
actual = ''  
label = 'Pass'  
else  
stats.fail = stats.fail + 1  
label = 'Fail'  
isfail = true  
end  
local sbox = ' | ' .. label  
return sbox, actual, isfail  
end  

local function status_text(stats)  
local bgcolor, ignored_text, msg, ttext  
if stats.template then  
ttext = "**Using Template:" .. stats.template .. ":** "  
else  
ttext = ''  
end  
if stats.fail == 0 then  
if stats.pass == 0 then  
msg = 'No tests performed'  
else  
msg = string.format('All %d tests passed', stats.pass)  
end  
else  
msg = string.format('%d test%s failed', stats.fail, stats.fail == 1 and '' or 's')  
end  
if stats.ignored == 0 then  
ignored_text = ''  
else  
ignored_text = string.format(', %d test%s ignored because expected text is blank', stats.ignored, stats.ignored == 1 and '' or 's')  
end  
return ttext .. '<span>' ..  
msg .. ignored_text .. '.</span>'  
end  

local function run_template(, 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 :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(, 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.new  
for _, item in ipairs(all_tests) do  
local template = item[1]  
if template then  
local actual = run_template(, 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 <pre>...</pre>.  
return '<pre>\n' .. mw.text.nowiki(result:join('\n')) .. '\n</pre>'  
end  

local function _run_tests(, all_tests, args)  
local function safe_cell(text, multiline)  
-- For testing , 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, template = args.template }  
local result = Collection.new  
result:add('{|')  
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(, 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('|| (above, nowiki)')  
result:add('| ' .. nowiki_cell(normalize(expected), true))  
result:add('| ' .. nowiki_cell(normalize(actual), true))  
result:add('|')  
end  
else  
local text = item.text  
if text and text:sub(1, 3) == '---' then  
result:add('|-')  
result:add('| | ' .. 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(, page_pairs)  
local prefix = .args.prefix or '*'  
local function diff_link(title1, title2)  
return '<span>[' ..  
tostring(mw.uri.fullUrl('Special:ComparePages',  
{ page1 = title1, page2 = title2 })) ..  
' diff]</span>'  
end  
local function link(title)  
return * .. title .. *  
end  
local function message(text, isgood)  
local color = isgood and 'green' or 'darkred'  
return '<span>' .. text .. '</span>'  
end  
local result = Collection.new  
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(prefix .. 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(, tests)  
local args = .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.new  
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(, p, worker)  
local ok, result = pcall(get_tests, , p.tests)  
if ok then  
ok, result = pcall(worker, , result, .args)  
if ok then  
return result  
end  
end  
return '<strong>Error</strong>\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  
local page_pairs = p.pairs  
if not page_pairs then  
local args = .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, , page_pairs)  
if ok then  
return result  
end  
return '<strong>Error</strong>\n\n' .. result  
end  

p.check_sandbox = p.compare  

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

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

return p