Module:Template test case

--[[ SPDX-FileCopyrightText: © 2014-2016 Mr. Stradivarius 

SPDX-License-Identifier: CC-BY-SA-3.0 AND GFDL-1.3-or-later

A module for generating test case templates.

This module incorporates code from the English Wikipedia's "Testcase table" module,[1] written by Frietjes [3] with contributions by Mr. Stradivarius and Jackmcbarn,[4] and the English Wikipedia's "Testcase rows" module,[2] written by Mr. Stradivarius.

The "Testcase table" and "Testcase rows" modules are released under the CC BY-SA 3.0 License and the GFDL.

[1] https://en.wikipedia.org/wiki/Module:Testcase_table [2] https://en.wikipedia.org/wiki/Module:Testcase_rows [3] https://en.wikipedia.org/wiki/User:Frietjes [4] https://en.wikipedia.org/wiki/User:Jackmcbarn ]]

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

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

--- -- Shared methods ---

local function message(self, key, ...) -- This method is added to classes that need to deal with messages from the -- config module. local msg = self.cfg.msg[key] if select(1, ...) then return mw.message.newRawMessage(msg, ...):plain else return msg end end

--- -- 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, makeHeader = true, getOutput = true }

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 not self.template then return self.title.prefixedText elseif self.template:sub(1, 7) == '#invoke' then return 'Module' .. self.template:sub(8):gsub('|.*', '') else 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 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', 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('') end

function Template:makeHeader return self.heading or self:makeBraceLink end

function Template:getInvocation(format) local invocation = self._invocation:getInvocation{ template = self:getName, requireMagicWord = self.requireMagicWord, }	if format == 'code' then invocation = ' ' elseif format == 'kbd' then invocation = ' ' .. mw.text.nowiki(invocation) .. ' '	elseif format == 'plain' then invocation = mw.text.nowiki(invocation) else -- Default is pre tags invocation = mw.text.encode(invocation, '&') invocation = '' .. invocation .. ' '		invocation = mw.getCurrentFrame:preprocess(invocation) end return invocation end

function Template:getOutput local protect = require('Module:Protect') -- calling self._invocation:getOutput{...} return protect(self._invocation.getOutput)(self._invocation, {		template = self:getName,		requireMagicWord = self.requireMagicWord,	}) end

--- -- TestCase class ---

local TestCase = {} TestCase.__index = TestCase TestCase.message = message -- add the message method

TestCase.renderMethods = { -- Keys in this table are values of the "format" option, values are the -- method for rendering that format. columns = 'renderColumns', rows = 'renderRows', tablerows = 'renderRows', inline = 'renderInline', cells = 'renderCells', default = 'renderDefault' }

function TestCase.new(invocationObj, options, cfg) local obj = setmetatable({}, TestCase) obj.cfg = cfg

-- Separate general options from template options. Template options are -- numbered, whereas general options are not. local generalOptions, templateOptions = {}, {} for k, v in pairs(options) do		local prefix, num if type(k) == 'string' then prefix, num = k:match('^(.-)([1-9][0-9]*)$') end if prefix then num = tonumber(num) templateOptions[num] = templateOptions[num] or {} templateOptions[num][prefix] = v		else generalOptions[k] = v		end end

-- Set general options generalOptions.showcode = yesno(generalOptions.showcode) generalOptions.showheader = yesno(generalOptions.showheader) ~= false generalOptions.showcaption = yesno(generalOptions.showcaption) ~= false generalOptions.collapsible = yesno(generalOptions.collapsible) generalOptions.notcollapsed = yesno(generalOptions.notcollapsed) generalOptions.wantdiff = yesno(generalOptions.wantdiff) obj.options = generalOptions

-- Preprocess template args for num, t in pairs(templateOptions) do		if t.showtemplate ~= nil then t.showtemplate = yesno(t.showtemplate) end end

-- Set up first two template options tables, so that if only the -- "template3" is specified it isn't made the first template when the -- the table options array is compressed. templateOptions[1] = templateOptions[1] or {} templateOptions[2] = templateOptions[2] or {}

-- Allow the "template" option to override the "template1" option for -- backwards compatibility with Module:Testcase table. if generalOptions.template then templateOptions[1].template = generalOptions.template end

-- Add default template options if templateOptions[1].template and not templateOptions[2].template then templateOptions[2].template = templateOptions[1].template .. '/' .. obj.cfg.sandboxSubpage 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(			obj.cfg.sandboxSubpage		) end

-- Remove template options for any templates where the showtemplate -- argument is false. This prevents any output for that template. for num, t in pairs(templateOptions) do		if t.showtemplate == false then templateOptions[num] = nil end end

-- Check for missing template names. for num, t in pairs(templateOptions) do		if not t.template and not t.title then error(obj:message( 'missing-template-option-error', num, num ), 2)		end end

-- Compress templateOptions table so we can iterate over it with ipairs. templateOptions = (function (t)		local nums = {}		for num in pairs(t) do			nums[#nums + 1] = num		end		table.sort(nums)		local ret = {}		for i, num in ipairs(nums) do			ret[i] = t[num]		end		return ret	end)(templateOptions)

-- Don't require the __TEMPLATENAME__ magic word for nowiki invocations if -- there is only one template being output. if #templateOptions <= 1 then templateOptions[1].requireMagicWord = false end

mw.logObject(templateOptions)

-- Make the template objects obj.templates = {} for i, options in ipairs(templateOptions) do		table.insert(obj.templates, Template.new(invocationObj, options)) end

-- Add tracking categories. At the moment we are only tracking templates -- that use any "heading" parameters or an "output" parameter. obj.categories = {} for k, v in pairs(options) do		if type(k) == 'string' and k:find('heading') then obj.categories['Test cases using heading parameters'] = true elseif k == 'output' then obj.categories['Test cases using output parameter'] = true end 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:templateOutputIsEqual -- Returns a boolean showing whether all of the template outputs are equal. -- The random parts of strip markers (see Help:Strip markers) are -- removed before comparison. This means a strip marker can contain anything -- and still be treated as equal, but it solves the problem of otherwise -- identical wikitext not returning as exactly equal. local function normaliseOutput(obj) local out = obj:getOutput -- Remove the random parts from strip markers. out = out:gsub('(\127\'"`UNIQ.-)%-%x+%-(QINU`"\'\127)', '%1%2') return out end local firstOutput = normaliseOutput(self.templates[1]) for i = 2, #self.templates do		local output = normaliseOutput(self.templates[i]) if output ~= firstOutput then return false end end return true end

function TestCase:makeCollapsible(s) local title = self.options.title or self.templates[1]:makeHeader if self.options.titlecode then title = self.templates[1]:getInvocation('kbd') end local isEqual = self:templateOutputIsEqual local root = mw.html.create('div') root :addClass('mw-collapsible') :css('width', '100%') :css('border', 'solid silver 1px') :css('padding', '0.2em') :addClass(self.options.notcollapsed == false and 'mw-collapsed' or nil) if self.options.wantdiff then root :tag('div') :css('background-color', isEqual and 'yellow' or '#90a8ee') :css('font-weight', 'bold') :css('padding', '0.2em') :wikitext(title) :done else if self.options.notcollapsed ~= true or false then root :addClass(isEqual and 'mw-collapsed' or nil) end root :tag('div') :css('background-color', isEqual and 'lightgreen' or 'yellow') :css('font-weight', 'bold') :css('padding', '0.2em') :wikitext(title) :done end root :tag('div') :addClass('mw-collapsible-content') :newline :wikitext(s) :newline 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')

if self.options.showheader then -- Caption if self.options.showcaption then tableroot :addClass(self.options.class) :cssText(self.options.style) :tag('caption') :wikitext(self.options.caption or self:message('columns-header')) end

-- Headers local headerRow = 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. headerRow: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			headerRow :tag('th') :css('width', width) :wikitext(obj:makeHeader) end 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		if self.options.output == 'nowiki+' then dataRow:tag('td') :newline :wikitext(self.options.before) :wikitext(self:getTemplateOutput(obj)) :wikitext(self.options.after) :wikitext('') :wikitext(mw.text.nowiki(self.options.before or "")) :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) :wikitext(mw.text.nowiki(self.options.after or "")) :wikitext(' ') elseif self.options.output == 'nowiki' then dataRow:tag('td') :newline :wikitext(mw.text.nowiki(self.options.before or "")) :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) :wikitext(mw.text.nowiki(self.options.after or "")) else dataRow:tag('td') :newline :wikitext(self.options.before) :wikitext(self:getTemplateOutput(obj)) :wikitext(self.options.after) end 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		local dataRow = tableroot:tag('tr') -- Header if self.options.showheader then if self.options.format == 'tablerows' then dataRow:tag('th') :attr('scope', 'row') :css('vertical-align', 'top') :css('text-align', 'left') :wikitext(obj:makeHeader) dataRow:tag('td') :css('vertical-align', 'top') :css('padding', '0 1em') :wikitext('→') else dataRow:tag('td') :css('text-align', 'center') :css('font-weight', 'bold') :wikitext(obj:makeHeader) dataRow = tableroot:tag('tr') end end -- Template output if self.options.output == 'nowiki+' then dataRow:tag('td') :newline :wikitext(self:getTemplateOutput(obj)) :wikitext('') :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) :wikitext(' ') elseif self.options.output == 'nowiki' then dataRow:tag('td') :newline :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) else dataRow:tag('td') :newline :wikitext(self:getTemplateOutput(obj)) end end

return tostring(root) end

function TestCase:renderInline local arrow = mw.language.getContentLanguage:getArrow('forwards') local ret = {} for i, obj in ipairs(self.templates) do		local line = {} line[#line + 1] = self.options.prefix or '* ' if self.options.showcode then line[#line + 1] = obj:getInvocation('code') line[#line + 1] = ' ' line[#line + 1] = arrow line[#line + 1] = ' ' end if self.options.output == 'nowiki+' then line[#line + 1] = self:getTemplateOutput(obj) line[#line + 1] = '' line[#line + 1] = mw.text.nowiki(self:getTemplateOutput(obj)) line[#line + 1] = ' ' elseif self.options.output == 'nowiki' then line[#line + 1] = mw.text.nowiki(self:getTemplateOutput(obj)) else line[#line + 1] = self:getTemplateOutput(obj) end ret[#ret + 1] = table.concat(line) end if self.options.addline then local line = {} line[#line + 1] = self.options.prefix or '* ' line[#line + 1] = self.options.addline ret[#ret + 1] = table.concat(line) end return table.concat(ret, '\n') end

function TestCase:renderCells local root = mw.html.create local dataRow = root:tag('tr') dataRow :css('vertical-align', 'top') :addClass(self.options.class) :cssText(self.options.style)

-- Row header if self.options.rowheader then dataRow:tag('th') :attr('scope', 'row') :newline :wikitext(self.options.rowheader or self:message('row-header')) end -- Caption if self.options.showcaption then dataRow:tag('th') :attr('scope', 'row') :newline :wikitext(self.options.caption or self:message('columns-header')) end

-- Show code if self.options.showcode then dataRow:tag('td') :newline :wikitext(self:getInvocation('code')) end

-- Template output for i, obj in ipairs(self.templates) do		if self.options.output == 'nowiki+' then dataRow:tag('td') :newline :wikitext(self.options.before) :wikitext(self:getTemplateOutput(obj)) :wikitext(self.options.after) :wikitext('') :wikitext(mw.text.nowiki(self.options.before or "")) :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) :wikitext(mw.text.nowiki(self.options.after or "")) :wikitext(' ') elseif self.options.output == 'nowiki' then dataRow:tag('td') :newline :wikitext(mw.text.nowiki(self.options.before or "")) :wikitext(mw.text.nowiki(self:getTemplateOutput(obj))) :wikitext(mw.text.nowiki(self.options.after or "")) else dataRow:tag('td') :newline :wikitext(self.options.before) :wikitext(self:getTemplateOutput(obj)) :wikitext(self.options.after) end 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] = ' ' if self.options.showheader then ret[#ret + 1] = obj:makeHeader end if self.options.output == 'nowiki+' then ret[#ret + 1] = self:getTemplateOutput(obj) .. '' .. mw.text.nowiki(self:getTemplateOutput(obj)) .. ' '		elseif self.options.output == 'nowiki' then ret[#ret + 1] = mw.text.nowiki(self:getTemplateOutput(obj)) else ret[#ret + 1] = self:getTemplateOutput(obj) end end return table.concat(ret, '\n\n') end

function TestCase:__tostring local format = self.options.format local method = format and TestCase.renderMethods[format] or 'renderDefault' local ret = self[method](self) if self.options.collapsible then ret = self:makeCollapsible(ret) end for cat in pairs(self.categories) do ret = ret .. string.format('', cat) end return ret end

--- -- Nowiki invocation class ---

local NowikiInvocation = {} NowikiInvocation.__index = NowikiInvocation NowikiInvocation.message = message -- Add the message method

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(options) local template = options.template:gsub('%%', '%%%%') -- Escape "%" with "%%" local invocation, count = self.invocation:gsub(		self.cfg.templateNameMagicWordPattern,		template	) if options.requireMagicWord ~= false and count < 1 then error(self:message( 'nowiki-magic-word-error', self.cfg.templateNameMagicWord ))	end return invocation end

function NowikiInvocation:getOutput(options) local invocation = self:getInvocation(options) return mw.getCurrentFrame:preprocess(invocation) end

--- -- Table invocation class ---

local TableInvocation = {} TableInvocation.__index = TableInvocation TableInvocation.message = message -- Add the message method

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(options) if self.code then local nowikiObj = NowikiInvocation.new(self.code, self.cfg) return nowikiObj:getInvocation(options) else return require('Module:Template invocation').invocation(			options.template,			self.invokeArgs		) end end

function TableInvocation:getOutput(options) if (options.template:sub(1, 7) == '#invoke') then local moduleCall = mw.text.split(options.template, '|', true) local args = mw.clone(self.invokeArgs) table.insert(args, 1, moduleCall[2]) return mw.getCurrentFrame:callParserFunction(moduleCall[1], args) end return mw.getCurrentFrame:expandTemplate{ title = options.template, args = self.invokeArgs } end

--- -- Bridge functions -- -- These functions translate template arguments into forms that can be accepted -- by the different classes, and return the results. ---

local bridge = {}

function bridge.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 bridge.nowiki(args, cfg) cfg = cfg or mw.loadData(DATA_MODULE)

local code = args.code or args[1] local invocationObj = NowikiInvocation.new(code, cfg) args.code = nil args[1] = 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

--- -- Exports ---

local p = {}

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 userArgs = require('Module:Arguments').getArgs(frame, {		parentOnly = wrapperConfig,		frameOnly = not wrapperConfig,		trim = func ~= 'table',		removeBlanks = func ~= 'table'	})

-- 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 bridge[func](args, cfg) end

function p._exportClasses -- For testing return { Template = Template, TestCase = TestCase, NowikiInvocation = NowikiInvocation, TableInvocation = TableInvocation } end

return p