Module:Template test case

    From Nonbinary Wiki
    Revision as of 03:11, 27 November 2014 by wikipedia>Mr. Stradivarius (allow Template.outputEquals to compare more than two template objects)

    Documentation for this module may be created at Module:Template test case/doc

    -- This module provides several methods to generate test cases.
    
    local yesno = require('Module:Yesno')
    local mTableTools = require('Module:TableTools')
    local libraryUtil = require('libraryUtil')
    local checkType = libraryUtil.checkType
    
    local TEMPLATE_NAME_MAGIC_WORD = '__TEMPLATENAME__'
    local TEMPLATE_NAME_MAGIC_WORD_ESCAPED = TEMPLATE_NAME_MAGIC_WORD:gsub('%p', '%%%0')
    
    -------------------------------------------------------------------------------
    -- Template class
    -------------------------------------------------------------------------------
    
    local Template = {}
    
    Template.memoizedMethods = {
    	-- Names of methods to be memoized in each object. This table should only
    	-- hold methods with no parameters.
    	getFullPage = true,
    	getName = true,
    	makeHeading = true,
    	getOutput = true
    }
    
    function Template.outputEquals(...)
    	-- This function accepts 2 or more template objects and compares their
    	-- output. If all the outputs are equal, it returns true, and if any of them
    	-- are different, it returns false.
    	local n = select('#', ...)
    	if n < 2 then
    		error('Template.outputEquals requires at least two arguments', 2)
    	end
    	local function normaliseOutput(obj)
    		local out = obj:getOutput()
    		-- Remove the random parts from strip markers (see [[Help:Strip markers]])
    		out = out:gsub('(%cUNIQ).-(QINU%c)', '%1%2')
    		return out
    	end
    	local prevOutput = normaliseOutput(select(1, ...))
    	for i = 2, n do
    		local output = normaliseOutput(select(i, ...))
    		if output ~= prevOutput then
    			return false
    		end
    		prevOutput = output
    	end
    	return true
    end
    
    function Template.new(invocationObj, options)
    	local obj = {}
    
    	-- Set input
    	for k, v in pairs(options or {}) do
    		if not Template[k] then
    			obj[k] = v
    		end
    	end
    	obj._invocation = invocationObj
    
    	-- Validate input
    	if not obj.template and not obj.title then
    		error('no template or title specified', 2)
    	end
    
    	-- Memoize expensive method calls
    	local memoFuncs = {}
    	return setmetatable(obj, {
    		__index = function (t, key)
    			if Template.memoizedMethods[key] then
    				local func = memoFuncs[key]
    				if not func then
    					local val = Template[key](t)
    					func = function () return val end
    					memoFuncs[key] = func
    				end
    				return func
    			else
    				return Template[key]
    			end
    		end
    	})
    end
    
    function Template:getFullPage()
    	if self.template then
    		local strippedTemplate, hasColon = self.template:gsub('^:', '', 1)
    		hasColon = hasColon > 0
    		local ns = strippedTemplate:match('^(.-):')
    		ns = ns and mw.site.namespaces[ns]
    		if ns then
    			return strippedTemplate
    		elseif hasColon then
    			return strippedTemplate -- Main namespace
    		else
    			return mw.site.namespaces[10].name .. ':' .. strippedTemplate
    		end
    	else
    		return self.title.prefixedText
    	end
    end
    
    function Template:getName()
    	if self.template then
    		return self.template
    	else
    		return require('Module:Template invocation').name(self.title)
    	end
    end
    
    function Template:makeLink(display)
    	if display then
    		return string.format('[[:%s|%s]]', self:getFullPage(), display)
    	else
    		return string.format('[[:%s]]', self:getFullPage())
    	end
    end
    
    function Template:makeBraceLink(display)
    	display = display or self:getName()
    	local link = self:makeLink(display)
    	return mw.text.nowiki('{{') .. link .. mw.text.nowiki('}}')
    end
    
    function Template:makeHeading()
    	return self.heading or self:makeBraceLink()
    end
    
    function Template:getInvocation(format)
    	local invocation = self._invocation:getInvocation(self:getName())
    	if format == 'code' then
    		invocation = '<code>' .. mw.text.nowiki(invocation) .. '</code>'
    	elseif format == 'plain' then
    		invocation = mw.text.nowiki(invocation)
    	else
    		-- Default is pre tags
    		invocation = mw.text.encode(invocation, '&')
    		invocation = '<pre style="white-space: pre-wrap;">' .. invocation .. '</pre>'
    		invocation = mw.getCurrentFrame():preprocess(invocation)
    	end
    	return invocation
    end
    
    function Template:getOutput()
    	return self._invocation:getOutput(self:getName())
    end
    
    -------------------------------------------------------------------------------
    -- TestCase class
    -------------------------------------------------------------------------------
    
    local TestCase = {}
    TestCase.__index = TestCase
    
    function TestCase.new(invocationObj, options)
    	local obj = setmetatable({}, TestCase)
    
    	-- Validate options
    	do
    		local highestNum = 0
    		for k in pairs(options) do
    			if type(k) == 'string' then
    				local num = k:match('([1-9][0-9]*)$')
    				num = tonumber(num)
    				if num and num > highestNum then
    					highestNum = num
    				end
    			end
    		end
    		for i = 3, highestNum do
    			if not options['template' .. i] then
    				error(string.format(
    					"one or more options ending in '%d' were " ..
    					"detected, but no 'template%d' option was found",
    					i, i
    				), 2)
    			end
    		end
    	end
    
    	-- Separate general options from options for specific templates
    	local templateOptions = mTableTools.numData(options, true)
    	obj.options = templateOptions.other or {}
    
    	-- Normalize showcode option
    	obj.options.showcode = yesno(obj.options.showcode)
    
    	-- Add default template options
    	templateOptions[1] = templateOptions[1] or {}
    	templateOptions[2] = templateOptions[2] or {}
    	if templateOptions[1].template and not templateOptions[2].template then
    		templateOptions[2].template = templateOptions[1].template .. '/sandbox'
    	end
    	if not templateOptions[1].template then
    		templateOptions[1].title = mw.title.getCurrentTitle().basePageTitle
    	end
    	if not templateOptions[2].template then
    		templateOptions[2].title = templateOptions[1].title:subPageTitle('sandbox')
    	end
    
    	-- Make the template objects
    	obj.templates = {}
    	for i, t in ipairs(templateOptions) do
    		table.insert(obj.templates, Template.new(invocationObj, t))
    	end
    
    	return obj
    end
    
    function TestCase:getTemplateOutput(templateObj)
    	local output = templateObj:getOutput()
    	if self.options.resetRefs then
    		mw.getCurrentFrame():extensionTag('references')
    	end
    	return output
    end
    
    function TestCase:renderColumns()
    	local root = mw.html.create()
    	if self.options.showcode then
    		root
    			:wikitext(self.templates[1]:getInvocation())
    			:newline()
    	end
    
    	local tableroot = root:tag('table')
    	tableroot
    		:addClass(self.options.class)
    		:cssText(self.options.style)
    		:tag('caption')
    			:wikitext(self.options.caption or 'Side by side comparison')
    
    	-- Headings
    	local headingRow = tableroot:tag('tr')
    	if self.options.rowheader then
    		-- rowheader is correct here. We need to add another th cell if
    		-- rowheader is set further down, even if heading0 is missing.
    		headingRow:tag('th'):wikitext(self.options.heading0)
    	end
    	local width
    	if #self.templates > 0 then
    		width = tostring(math.floor(100 / #self.templates)) .. '%'
    	else
    		width = '100%'
    	end
    	for i, obj in ipairs(self.templates) do
    		headingRow
    			:tag('th')
    				:css('width', width)
    				:wikitext(obj:makeHeading())
    	end
    
    	-- Row header
    	local dataRow = tableroot:tag('tr'):css('vertical-align', 'top')
    	if self.options.rowheader then
    		dataRow:tag('th')
    			:attr('scope', 'row')
    			:wikitext(self.options.rowheader)
    	end
    	
    	-- Template output
    	for i, obj in ipairs(self.templates) do
    		dataRow:tag('td')
    			:newline()
    			:wikitext(self:getTemplateOutput(obj))
    			:wikitext(self.options.after)
    	end
    	
    	return tostring(root)
    end
    
    function TestCase:renderRows()
    	local root = mw.html.create()
    	if self.options.showcode then
    		root
    			:wikitext(self.templates[1]:getInvocation())
    			:newline()
    	end
    
    	local tableroot = root:tag('table')
    	tableroot
    		:addClass(self.options.class)
    		:cssText(self.options.style)
    
    	if self.options.caption then
    		tableroot
    			:tag('caption')
    				:wikitext(self.options.caption)
    	end
    
    	for _, obj in ipairs(self.templates) do
    		-- Build the row HTML
    		tableroot
    			:tag('tr')
    				:tag('td')
    					:css('text-align', 'center')
    					:css('font-weight', 'bold')
    					:wikitext(obj:makeHeading())
    					:done()
    				:done()
    			:tag('tr')
    				:tag('td')
    					:newline()
    					:wikitext(self:getTemplateOutput(obj))
    	end
    
    	return tostring(root)
    end
    
    function TestCase:renderCollapsed()
    	
    end
    
    function TestCase:renderDefault()
    	local ret = {}
    	if self.options.showcode then
    		ret[#ret + 1] = self.templates[1]:getInvocation()
    	end
    	for i, obj in ipairs(self.templates) do
    		ret[#ret + 1] = '<div style="clear: both;"></div>'
    		ret[#ret + 1] = obj:makeBraceLink()
    		ret[#ret + 1] = self:getTemplateOutput(obj)
    	end
    	return table.concat(ret, '\n\n')
    end
    
    function TestCase:__tostring()
    	local methods = {
    		collapsed = 'renderCollapsed',
    		columns = 'renderColumns',
    		rows = 'renderRows'
    	}
    	local format = self.options.format
    	local method = format and methods[format] or 'renderDefault'
    	return self[method](self)
    end
    
    -------------------------------------------------------------------------------
    -- Nowiki invocation class
    -------------------------------------------------------------------------------
    
    local NowikiInvocation = {}
    NowikiInvocation.__index = NowikiInvocation
    
    function NowikiInvocation.new(invocation)
    	local obj = setmetatable({}, NowikiInvocation)
    	invocation = mw.text.unstrip(invocation)
    	-- Decode HTML entities for <, >, and ". This means that HTML entities in
    	-- the original code must be escaped as e.g. &amp;lt;, which is unfortunate,
    	-- but it is the best we can do as the distinction between <, >, " and &lt;,
    	-- &gt;, &quot; is lost during the original nowiki operation.
    	invocation = invocation:gsub('&lt;', '<')
    	invocation = invocation:gsub('&gt;', '>')
    	invocation = invocation:gsub('&quot;', '"')
    	-- Decode &amp; only when it is used to escape &lt;, &gt; and &quot;
    	invocation = invocation:gsub('&amp;lt;', '&lt;')
    	invocation = invocation:gsub('&amp;gt;', '&gt;')
    	invocation = invocation:gsub('&amp;quot;', '&quot;')
    	obj.invocation = invocation
    	return obj
    end
    
    function NowikiInvocation:getInvocation(template)
    	template = template:gsub('%%', '%%%%') -- Escape "%" with "%%"
    	local invocation, count = self.invocation:gsub(
    		TEMPLATE_NAME_MAGIC_WORD_ESCAPED,
    		template
    	)
    	if count < 1 then
    		error(string.format(
    			"the template invocation must include '%s' in place " ..
    			"of the template name",
    			TEMPLATE_NAME_MAGIC_WORD
    		))
    	end
    	return invocation
    end
    
    function NowikiInvocation:getOutput(template)
    	local invocation = self:getInvocation(template)
    	return mw.getCurrentFrame():preprocess(invocation)
    end
    
    -------------------------------------------------------------------------------
    -- Table invocation class
    -------------------------------------------------------------------------------
    
    local TableInvocation = {}
    TableInvocation.__index = TableInvocation
    
    function TableInvocation.new(invokeArgs)
    	local obj = setmetatable({}, TableInvocation)
    	obj.invokeArgs = invokeArgs
    	return obj
    end
    
    function TableInvocation:getInvocation(template)
    	return require('Module:Template invocation').invocation(
    		template,
    		self.invokeArgs
    	)
    end
    
    function TableInvocation:getOutput(template)
    	return mw.getCurrentFrame():expandTemplate{
    		title = template,
    		args = self.invokeArgs
    	}
    end
    
    -------------------------------------------------------------------------------
    -- Exports
    -------------------------------------------------------------------------------
    
    -- Table-based exports
    
    local function getTableArgs(frame, wrappers)
    	return require('Module:Arguments').getArgs(frame, {
    		wrappers = wrappers,
    		trim = false,
    		removeBlanks = false
    	})
    end
    
    local p = {}
    
    function p._table(args)
    	local options, invokeArgs = {}, {}
    	for k, v in pairs(args) do
    		local optionKey = type(k) == 'string' and k:match('^_(.*)$')
    		if optionKey then
    			if type(v) == 'string' then
    				v = v:match('^%s*(.-)%s*$') -- trim whitespace
    			end
    			if v ~= '' then
    				options[optionKey] = v
    			end
    		else
    			invokeArgs[k] = v
    		end
    	end
    	local invocationObj = TableInvocation.new(invokeArgs)
    	local testCaseObj = TestCase.new(invocationObj, options)
    	return tostring(testCaseObj)
    end
    
    function p.table(frame)
    	return p._table(getTableArgs(frame, 'Template:Test case from arguments'))
    end
    
    function p.columns(frame)
    	local args = getTableArgs(frame, 'Template:Testcase table')
    	args._format = 'columns'
    	return p._table(args)
    end
    
    function p.rows(frame)
    	local args = getTableArgs(frame, 'Template:Testcase rows')
    	args._format = 'rows'
    	return p._table(args)
    end
    
    -- Nowiki-based exports
    
    function p._nowiki(args)
    	local invocationObj = NowikiInvocation.new(args.code)
    	args.code = nil
    	-- Assume we want to see the code as we already passed it in.
    	args.showcode = args.showcode or true
    	local testCaseObj = TestCase.new(invocationObj, args)
    	return tostring(testCaseObj)
    end
    
    function p.nowiki(frame)
    	local args = require('Module:Arguments').getArgs(frame, {
    		wrappers = 'Template:Test case from invocation'
    	})
    	return p._nowiki(args)
    end
    
    -- Exports for testing
    
    function p._exportClasses()
    	return {
    		Template = Template,
    		TestCase = TestCase,
    		Invocation = Invocation,
    		NowikiInvocation = NowikiInvocation,
    		TableInvocation = TableInvocation
    	}
    end
    
    return p