Module:Template test case: Difference between revisions

Nothing to hide, but nothing to show you either.
Jump to navigation Jump to search
Content added Content deleted
(simplify the wrapper template config by loading it in from a data module)
(don't export the Invocation class, as it has been removed)
Line 525: Line 525:
Template = Template,
Template = Template,
TestCase = TestCase,
TestCase = TestCase,
Invocation = Invocation,
NowikiInvocation = NowikiInvocation,
NowikiInvocation = NowikiInvocation,
TableInvocation = TableInvocation
TableInvocation = TableInvocation

Revision as of 15:10, 27 November 2014

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

-- This module provides several methods to generate test cases.

-- Load required modules
local yesno = require('Module:Yesno')
local mTableTools = require('Module:TableTools')

-- Set constants
local DATA_MODULE = 'Module:Template test case/data'

-------------------------------------------------------------------------------
-- 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, cfg)
	local obj = setmetatable({}, TestCase)
	obj.cfg = cfg

	-- 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 boolean options
	obj.options.showcode = yesno(obj.options.showcode)
	obj.options.collapsible = yesno(obj.options.collapsible)

	-- 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:makeCollapsible(s)
	local isEqual = Template.outputEquals(unpack(self.templates))
	local root = mw.html.create('table')
	root
		:addClass('collapsible')
		:addClass(isEqual and 'collapsed' or nil)
		:css('background-color', 'transparent')
		:css('width', '100%')
		:css('border', 'solid silver 1px')
		:tag('tr')
			:tag('th')
				:css('background-color', isEqual and 'lightgreen' or 'yellow')
				:wikitext(self.options.title or self.templates[1]:makeHeading())
				:done()
			:done()
		:tag('tr')
			:tag('td')
				:wikitext(s)
	return tostring(root)
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: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'
	local ret = self[method](self)
	if self.options.collapsible then
		ret = self:makeCollapsible(ret)
	end
	return ret
end

-------------------------------------------------------------------------------
-- Nowiki invocation class
-------------------------------------------------------------------------------

local NowikiInvocation = {}
NowikiInvocation.__index = NowikiInvocation

function NowikiInvocation.new(invocation, cfg)
	local obj = setmetatable({}, NowikiInvocation)
	obj.cfg = cfg
	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;', '"')
	obj.invocation = invocation
	return obj
end

function NowikiInvocation:getInvocation(template)
	template = template:gsub('%%', '%%%%') -- Escape "%" with "%%"
	local invocation, count = self.invocation:gsub(
		self.cfg.templateNameMagicWordPattern,
		template
	)
	if count < 1 then
		error(string.format(
			"the template invocation must include '%s' in place " ..
			"of the template name",
			self.cfg.templateNameMagicWord
		))
	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, nowikiCode, cfg)
	local obj = setmetatable({}, TableInvocation)
	obj.cfg = cfg
	obj.invokeArgs = invokeArgs
	obj.code = nowikiCode
	return obj
end

function TableInvocation:getInvocation(template)
	if self.code then
		local nowikiObj = NowikiInvocation(self.code)
		return nowikiObj:getInvocation(template)
	else
		return require('Module:Template invocation').invocation(
			template,
			self.invokeArgs
		)
	end
end

function TableInvocation:getOutput(template)
	return mw.getCurrentFrame():expandTemplate{
		title = template,
		args = self.invokeArgs
	}
end

-------------------------------------------------------------------------------
-- Exports
-------------------------------------------------------------------------------

local p = {}

function p.table(args, cfg)
	cfg = cfg or mw.loadData(DATA_MODULE)

	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

	-- Allow passing a nowiki invocation as an option. While this means users
	-- have to pass in the code twice, whitespace is preserved and &lt; etc.
	-- will work as intended.
	local nowikiCode = options.code
	options.code = nil

	local invocationObj = TableInvocation.new(invokeArgs, nowikiCode, cfg)
	local testCaseObj = TestCase.new(invocationObj, options, cfg)
	return tostring(testCaseObj)
end

function p.nowiki(args, cfg)
	cfg = cfg or mw.loadData(DATA_MODULE)

	local invocationObj = NowikiInvocation.new(args.code, cfg)
	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, cfg)
	return tostring(testCaseObj)
end

function p.main(frame, cfg)
	cfg = cfg or mw.loadData(DATA_MODULE)

	-- Load the wrapper config, if any.
	local wrapperConfig
	if frame.getParent then
		local title = frame:getParent():getTitle()
		local template = title:gsub(cfg.sandboxSubpagePattern, '')
		wrapperConfig = cfg.wrappers[template]
	end

	-- Work out the function we will call, use it to generate the config for
	-- Module:Arguments, and use Module:Arguments to find the arguments passed
	-- by the user.
	local func = wrapperConfig and wrapperConfig.func or 'table'
	local isTableFunc = func == 'table'
	local userArgs = require('Module:Arguments').getArgs(frame, {
		parentOnly = wrapperConfig,
		frameOnly = not wrapperConfig,
		trim = not isTableFunc,
		removeBlanks = not isTableFunc
	})

	-- Get default args and build the args table. User-specified args overwrite
	-- default args.
	local defaultArgs = wrapperConfig and wrapperConfig.args or {}
	local args = {}
	for k, v in pairs(defaultArgs) do
		args[k] = v
	end
	for k, v in pairs(userArgs) do
		args[k] = v
	end

	return p[func](args, cfg)
end

-- Exports for testing

function p._exportClasses()
	return {
		Template = Template,
		TestCase = TestCase,
		NowikiInvocation = NowikiInvocation,
		TableInvocation = TableInvocation
	}
end

return p