Jump to content

Module:Convert/tester: Difference between revisions

From Wikipedia, the free encyclopedia
Content deleted Content added
remove trailing newline from pre outputs as it makes an extra gap before headings
define team keyword for Module:Team appearances list modules
Line 394: Line 394:
'Citation/CS1/Whitelist',
'Citation/CS1/Whitelist',
'Citation/CS1/Date validation',
'Citation/CS1/Date validation',
},
team = {
'Team appearances list',
'Team appearances list/data',
'Team appearances list/show',
},
},
val = {
val = {

Revision as of 06:02, 26 November 2016

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

local function run_template(frame, template, forcename, collapse_multiline)
	-- Template "{{ example |  2  =  def  |  abc  |  name  =  ghi jkl  }}"
	-- gives args { "  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 args = {}
	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
		args[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 = forcename 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 args[index] ~= nil do
					-- Skip any explicit numbered parameters like "|5=five".
					index = index + 1
				end
				put_arg(index, field)
			end
		end
	end
	local function expand(t)
		return frame:expandTemplate(t)
	end
	local ok, result = pcall(expand, { title = templatename, args = args })
	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, forcename)
	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, forcename, 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, forcename)
	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 }
	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, forcename, 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)
	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
	error('Could not read wikitext from "[[' .. page_title .. ']]".', 0)
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()
	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)
			local content2 = get_page_content(title2)
			if 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 args = frame.args
	local ok, result = pcall(get_tests, frame, p.tests)
	if ok then
		ok, result = pcall(worker, frame, result, args.template)
		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.
	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