Lua

CodeDiscussionEditHistoryLinksLink count Subpages:DocumentationTestsResultsSandboxLive code All modules

Documentation is at en:Module:Convert/tester/doc.

Code

-- 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 bgcolor, label, isfail
	if iscomment then
		actual = ''
		bgcolor = 'silver'
		label = 'Cmnt'
	elseif expected == '' then -- FIXME: should it be ignored if actual is not the expected '' ?
		stats.ignored = stats.ignored + 1
		return '', actual
	elseif expected == actual then
		actual = ''
		stats.pass = stats.pass + 1
		bgcolor = 'green'
		label = 'Pass'
	else
		isfail = true
		stats.fail = stats.fail + 1
		bgcolor = 'red'
		label = 'Fail'
	end
	local sbox = 'style="background:' .. bgcolor .. ';color:#FFF;text-align:center"| ' .. label
	return sbox, actual, isfail
end

local function status_text(stats)
	local bgcolor, msg, ignored_text
	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 '<big style="font-size:120%;background-color:' .. bgcolor .. ';color:#FFF">' .. msg .. ignored_text .. '.</big>'
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()
	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 }
	local result = collection()
	result:add('{|class="wikitable"')
	result:add('{|-')
	result:add('!scope="col"| Template')
	result:add('!scope="col"| Expected')
	result:add('!scope="col"| Actual, if different')
	result:add('!scope="col"| 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="background:#777;color:#FFF"| ' .. 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 _getPageContent(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 _createLink(pageTitle, text)
	local t = mw.title.new(pageTitle)
	if text then
		return '[[:' .. t.fullText .. '|' .. text .. ']]'
	elseif t.nsText == 'Template' then
		return '[[:' .. t.fullText .. '|' .. mw.text.nowiki('{{') .. t.text .. mw.text.nowiki('}}') .. ']]'
	else
		return '[[:' .. t.fullText .. ']]'
	end
end

local function _createTalkLink(pageTitle)
	local t = mw.title.new(pageTitle)
	return '[[' .. tostring(t.talkPageTitle) .. '|talk]]'
end

local function _createDiffLink(title1, title2)
	return '<span class="plainlinks">[' ..
		tostring(mw.uri.fullUrl('Special:ComparePages', { page1 = title1, page2 = title2 })) ..
		' different content]</span>'
end

local function _message(text, isgood)
	local bgcolor = isgood and '#FFF' or '#FDD'
	return ' <small style="background:' .. bgcolor .. ';color:#AAA">(' .. text .. ')</small>'
end

--[[
text1 and text2 are static texts to display instead of actual page titles stored in each pair in page_pairs;
These texts to display are first taken from each pair (in [text1] and [text2] properties) if they are set;
otherwise static (in text1 and/or text2) are displayed if they are set;
otherwise page titles (in [1] and/or [2]) of each pair will be displayed as is.
note: if any item in page_pairs is a string, instead of a table containing the pair
then this is a text to display as "is in" the results (no pair of pages will be compared).

Example of value for page_pairs:
  {
    '== Header line ==',
    'Sample line of text',
    { 'pagename1', 'pagename2' }, -- will use show text1, text2 generic labels
    { 'pagename1', 'pagename2', text1='label for page 1', text2='label for page 2' },
    'Other line of text',
    { 'Template:Sample', 'Template:Sample/sandbox' }, -- will use show text1, text2 generic labels
    { 'Template:Sample', 'Template:Sample/sandbox', text1='Normal', text2='Sandboxed' },
    '*: comment below the previous compare result',
    -- and so on...
  }
--]]
local function _compare(frame, page_pairs, text1, text2)
	local result, inlist = collection(), false
	for _, pages in ipairs(page_pairs) do
		if type(pages) == 'string' then
			-- it's not a table (can't compare pages) but some wikitext to insert as a separate line in the result
			if inlist then
				result:add('</ul>')
				inlist = false
			end
			result:add(pages)
		else -- assume this is a table, indicating the pair of pages to compare
			local page1, page2 = pages[1], pages[2]
			local diff = ''
			if page1 == page2 then
				diff = _message('same title', true)
			else
				local content1 = _getPageContent(page1, true)
				local content2 = _getPageContent(page2, true)
				if not content1 or not content2 then
					-- diff = _message(' cannot compare', true)
				elseif content1 == content2 then
					-- diff = _message(' same content', true)
				else
					diff = _message(_createDiffLink(page1, page2), false)
				end
			end
			if not inlist then
				result:add('<ul style="margin:0;margin-left:1.6em">')
				inlist = true
			end
			result:add('<li>' ..
				_createLink(page1, pages.text1 or text1) .. '&nbsp;<b>·</b> ' ..
				_createLink(page2, pages.text2 or text2) .. diff .. '&nbsp;<b>·</b> ' ..
				_createLink(page1 .. '/testcases', 'testcases') .. '&nbsp;<b>·</b> ' ..
				_createTalkLink(page1) .. '</li>'
			)
		end
	end
	if inlist then
		result:add('</ul>')
		inlist = false
	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 = _getPageContent(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 '<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.
	-- As these lists are used to generate automatically the pairs of pages to compare
	-- (the normal version and the "/sandbox" version), if a string given below
	-- starts with a '#', it's not a valid page title, and any wikitext given after '#'
	-- will be used to generate a static text within the list, which will be inserted
	-- as is in the result, on a separate line. Other results for comparing pages will
	-- be in bulleted list items. So you can use it to add comments to display after
	-- any actual page name, or to add headings before actual page titles to break
	-- the list into separate sections.
	countries = {
		'#The sandbox versions of modules should be identical, except temporarily for testing changes (rendered with the sandbox template, in their comparative test cases)',
		'#<table class="wikitable" width="100%"><tr style="vertical-align:top"><td>',
		'#==== [[Module:Convert/tester|Modules in Commons]] ====',
		'Countries',
		'Countries/Africa',
		'Countries/Americas',
		'Countries/Arab world',
		'Countries/Asia',
		'Countries/Caribbean',
		'Countries/Central America',
		'Countries/Europe',
		'Countries/European Union',
		'Countries/North America',
		'Countries/North America (subcontinent)',
		'Countries/Oceania',
		'Countries/South America',
		'Countries/CRT other',
		'Countries/Olympic teams',
		'Countries/United Kingdom',
		'Regions of France',
		'Departments of France',
		'States of the United States',
		'Most populous cities of the world',
		'#</td><td>',
		'#==== [[Module:Convert/tester|Templates in Commons using these modules]] ====',
		'#<div>The sandbox versions of these templates should be different as they use the sandbox version of the modules.</div>',
		'Template:Countries of Africa',
		'Template:Countries of the Americas',
		'Template:Countries of the Arab world',
		'Template:Countries of Asia',
		'Template:Countries of the Caribbean',
		'Template:Countries of Central America',
		'Template:Countries of Europe',
		'Template:Countries of the European Union',
		'Template:Countries of North America',
		'Template:Countries of North America (subcontinent)',
		'Template:Countries of Oceania',
		'Template:Countries of South America',
		'Template:Copyright rules by territory',
		'Template:Olympic teams',
		'Template:Countries of the United Kingdom',
		'Template:Regions of France',
		'Template:Departments of France',
		'Template:States of the United States',
		'Template:Most populous cities of the world',
		'#</td></tr></table>',
	},
	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, text1, text2 = p.pairs, p.text1, p.text2
	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 = {}
		text1, text2 = args.text1, args.text2
		if text1 == '' then text1 = nil end
		if text2 == nil or text2 == '' then text2 = 'sandbox' end
		for i, title in ipairs(args) do
			-- If any "title" in args starts with '#', then it's not a valid page title; in that case any wikitext that follows
			-- this '#' will be returned as is in the result (including wiki markup such as section headings), followed by a
			-- newline, without comparing any pages.
			-- This allows splitting long lists of pages into multiple sections, or allows inserting comments about the previous
			-- item in the list (the result for every compared pages will render as a separate list item starting by '*' and
			-- ending by a newline, so you can comment the results of an item item in the result by adding an additional item
			-- like "#*: comment" to not break that list).
			if title:sub(1,1) == '#' then
				page_pairs[i] = title:sub(2)
			else
				if not title:find(':', 1, true) then
					title = 'Module:' .. title
				end
				page_pairs[i] = { title, title .. '/sandbox', text1 = text1, text2 = text2 }
			end
		end
	end
	local ok, result = pcall(_compare, frame, page_pairs, text1, text2)
	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