Jump to content

Module:Convert/tester: Difference between revisions

From Wikipedia, the free encyclopedia
Content deleted Content added
another attempt to normalize any strip markers while not interfering with those needed for rendered text
show name of template if it was forced
Line 67: Line 67:


local function status_text(stats)
local function status_text(stats)
local bgcolor, ignored_text, msg
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.fail == 0 then
if stats.pass == 0 then
if stats.pass == 0 then
Line 86: Line 91:
ignored_text = string.format(', %d test%s ignored because expected text is blank', stats.ignored, stats.ignored == 1 and '' or 's')
ignored_text = string.format(', %d test%s ignored because expected text is blank', stats.ignored, stats.ignored == 1 and '' or 's')
end
end
return '<span style="font-size:120%;color:white;background-color:' .. bgcolor .. ';">' .. msg .. ignored_text .. '.</span>'
return ttext .. '<span style="font-size:120%;color:white;background-color:' .. bgcolor .. ';">' ..
msg .. ignored_text .. '.</span>'
end
end


Line 207: Line 213:
return text
return text
end
end
local stats = { pass = 0, fail = 0, ignored = 0 }
local stats = { pass = 0, fail = 0, ignored = 0, template = args.template }
local result = Collection.new()
local result = Collection.new()
result:add('{| class="wikitable sortable"')
result:add('{| class="wikitable sortable"')

Revision as of 09:19, 15 December 2020

-- 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 = ''
		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="text-align:' .. align .. ';color:white;background:' .. bgcolor .. ';" | ' .. 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
			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 .. '<span style="font-size:120%;color:white;background-color:' .. bgcolor .. ';">' ..
		msg .. ignored_text .. '.</span>'
end

local function run_template(frame, template, args, collapse_multiline)
	-- Template "{{ example |  2  =  def  |  abc  |  name  =  ghi jkl  }}"
	-- gives xargs { "  abc  ", "def", name = "ghi jkl" }.
	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("^(.-)(=)(.*)$")
			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.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 <pre>...</pre>.
	return '<pre>\n' .. mw.text.nowiki(result:join('\n')) .. '\n</pre>'
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 "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', '<br />')
		end
		return text
	end
	local function nowiki_cell(text, multiline)
		text = mw.text.nowiki(text)
		if multiline then
			text = text:gsub('\\n', '<br />')
		end
		return text
	end
	local stats = { pass = 0, fail = 0, ignored = 0, template = args.template }
	local result = Collection.new()
	result:add('{| class="wikitable sortable"')
	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(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="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 '<span class="plainlinks">[' ..
			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 style="color:' .. color .. ';">' .. 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('*' .. 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.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 '<strong class="error">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(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 '<strong class="error">Error</strong>\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