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
 -- &quot;\n&quot; (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(&quot;^%s*(.-)%s*$&quot;)
 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 = ''
 		align = 'center'
 		bgcolor = 'silver'
 		label = 'Cmnt'
 	elseif expected == '' then
 		stats.ignored = stats.ignored + 1
 		return '', actual
 	elseif normalize(expected) == normalize(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=&quot;text-align:' .. align .. ';color:white;background:' .. bgcolor .. ';&quot; | ' .. label
 	return sbox, actual, isfail
 end
 
 local function status_text(stats)
 	local bgcolor, ignored_text, msg, ttext
 	if stats.template then
 		ttext = &quot;'''Using [[Template:&quot; .. stats.template .. &quot;]]:''' &quot;
 	else
 		ttext = ''
 	end
 	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 ttext .. '&lt;span style=&quot;font-size:120%;color:white;background-color:' .. bgcolor .. ';&quot;&gt;' ..
 		msg .. ignored_text .. '.&lt;/span&gt;'
 end
 
 local function run_template(frame, template, args, collapse_multiline)
 	-- Template &quot;{{ example |  2  =  def  |  abc  |  name  =  ghi jkl  }}&quot;
 	-- gives xargs { &quot;  abc  &quot;, &quot;def&quot;, name = &quot;ghi jkl&quot; }.
 	if template:sub(1, 2) == '{{' and template:sub(-2, -1) == '}}' 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(&quot;^(.-)(=)(.*)$&quot;)
 			if eq then
 				k, v = strip(k), strip(v)  -- k and/or v can be empty
 				local i = tonumber(k)
 				if i and i &gt; 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 &quot;|5=five&quot;.
 					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 &lt; templen and templen &lt;= 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(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 &lt;pre&gt;...&lt;/pre&gt;.
 	return '&lt;pre&gt;\n' .. mw.text.nowiki(result:join('\n')) .. '\n&lt;/pre&gt;'
 end
 
 local function _run_tests(frame, all_tests, args)
 	local function safe_cell(text, multiline)
 		-- For testing {{convert}}, want wikitext like '[[kilogram|kg]]' to be unchanged
 		-- so the link works and so the displayed text is short (just &quot;kg&quot; in example).
 		text = text:gsub('(%[%[[^%[%]]-)|(.-%]%])', '%1\0%2')  -- replace pipe in piped link with a zero byte
 		text = text:gsub('{', '&amp;#123;'):gsub('|', '&amp;#124;')    -- escape '{' and '|'
 		text = text:gsub('%z', '|')                            -- restore pipe in piped link
 		if multiline then
 			text = text:gsub('\\n', '&lt;br /&gt;')
 		end
 		return text
 	end
 	local function nowiki_cell(text, multiline)
 		text = mw.text.nowiki(text)
 		if multiline then
 			text = text:gsub('\\n', '&lt;br /&gt;')
 		end
 		return text
 	end
 	local stats = { pass = 0, fail = 0, ignored = 0, template = args.template }
 	local result = Collection.new()
 	result:add('{| class=&quot;wikitable sortable&quot;')
 	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=&quot;center&quot;| (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('| colspan=&quot;3&quot; style=&quot;color:white;background:silver;&quot; | ' .. 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 &quot;[[' .. page_title .. ']]&quot;.', 0)
 	end
 end
 
 local function _compare(frame, page_pairs)
 	local prefix = frame.args.prefix or '*'
 	local function diff_link(title1, title2)
 		return '&lt;span class=&quot;plainlinks&quot;&gt;[' ..
 			tostring(mw.uri.fullUrl('Special:ComparePages',
 				{ page1 = title1, page2 = title2 })) ..
 			' diff]&lt;/span&gt;'
 	end
 	local function link(title)
 		return '[[' .. title .. ']]'
 	end
 	local function message(text, isgood)
 		local color = isgood and 'green' or 'darkred'
 		return '&lt;span style=&quot;color:' .. color .. ';&quot;&gt;' .. text .. '&lt;/span&gt;'
 	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 &lt;= #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 &quot;page=' .. page_title .. '&quot; 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 &quot;' .. section_title .. '&quot; 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(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 '&lt;strong class=&quot;error&quot;&gt;Error&lt;/strong&gt;\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 '&lt;strong class=&quot;error&quot;&gt;Error&lt;/strong&gt;\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