Module:Template test case: Difference between revisions

    From Nonbinary Wiki
    (if the first template is specified but the second is not, make the second the sandbox of the first rather than the sandbox of the base page; simplify the title-getting code in the process)
    m (87 revisions imported from wikipedia:Module:Template_test_case)
     
    (74 intermediate revisions by 11 users not shown)
    Line 1: Line 1:
    -- This module provides several methods to generate test cases.
    --[[
      A module for generating test case templates.


    local mTableTools = require('Module:TableTools')
      This module incorporates code from the English Wikipedia's "Testcase table"
    local libraryUtil = require('libraryUtil')
      module,[1] written by Frietjes [2] with contributions by Mr. Stradivarius [3]
    local checkType = libraryUtil.checkType
      and Jackmcbarn,[4] and the English Wikipedia's "Testcase rows" module,[5]
      written by Mr. Stradivarius.


    local TEMPLATE_NAME_MAGIC_WORD = '<TEMPLATE_NAME>'
      The "Testcase table" and "Testcase rows" modules are released under the
    local TEMPLATE_NAME_MAGIC_WORD_ESCAPED = TEMPLATE_NAME_MAGIC_WORD:gsub('%p', '%%%0')
      CC BY-SA 3.0 License [6] and the GFDL.[7]
     
      License: CC BY-SA 3.0 and the GFDL
      Author: Mr. Stradivarius
     
      [1] https://en.wikipedia.org/wiki/Module:Testcase_table
      [2] https://en.wikipedia.org/wiki/User:Frietjes
      [3] https://en.wikipedia.org/wiki/User:Mr._Stradivarius
      [4] https://en.wikipedia.org/wiki/User:Jackmcbarn
      [5] https://en.wikipedia.org/wiki/Module:Testcase_rows
      [6] https://en.wikipedia.org/wiki/Wikipedia:Text_of_Creative_Commons_Attribution-ShareAlike_3.0_Unported_License
      [7] https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_GNU_Free_Documentation_License
    ]]
     
    -- 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


    -------------------------------------------------------------------------------
    -------------------------------------------------------------------------------
    Line 13: Line 48:


    local Template = {}
    local Template = {}
    Template.__index = 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)
    function Template.new(invocationObj, options)
    local obj = setmetatable({}, Template)
    local obj = {}


    -- Set input
    -- Set input
    for k, v in pairs(options or {}) do
    for k, v in pairs(options or {}) do
    obj[k] = v
    if not Template[k] then
    obj[k] = v
    end
    end
    end
    obj.invocation = invocationObj
    obj._invocation = invocationObj


    -- Validate input
    -- Validate input
    Line 29: Line 74:
    end
    end


    return obj
    -- 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
    end


    function Template:getFullPage()
    function Template:getFullPage()
    if self.template then
    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)
    local strippedTemplate, hasColon = self.template:gsub('^:', '', 1)
    hasColon = hasColon > 0
    local ns = strippedTemplate:match('^(.-):')
    local ns = strippedTemplate:match('^(.-):')
    ns = ns and mw.site.namespaces[ns]
    ns = ns and mw.site.namespaces[ns]
    Line 44: Line 110:
    return mw.site.namespaces[10].name .. ':' .. strippedTemplate
    return mw.site.namespaces[10].name .. ':' .. strippedTemplate
    end
    end
    else
    return self.title.prefixedText
    end
    end
    end
    end
    Line 69: Line 133:
    local link = self:makeLink(display)
    local link = self:makeLink(display)
    return mw.text.nowiki('{{') .. link .. mw.text.nowiki('}}')
    return mw.text.nowiki('{{') .. link .. mw.text.nowiki('}}')
    end
    function Template:makeHeader()
    return self.heading or self:makeBraceLink()
    end
    end


    function Template:getInvocation(format)
    function Template:getInvocation(format)
    local invocation = self.invocation:getInvocation(self:getName())
    local invocation = self._invocation:getInvocation{
    invocation = mw.text.nowiki(invocation)
    template = self:getName(),
    requireMagicWord = self.requireMagicWord,
    }
    if format == 'code' then
    if format == 'code' then
    invocation = '<code>' .. invocation .. '</code>'
    invocation = '<code>' .. mw.text.nowiki(invocation) .. '</code>'
    elseif format == 'pre' then
    elseif format == 'kbd' then
    invocation = '<kbd>' .. mw.text.nowiki(invocation) .. '</kbd>'
    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 = '<pre style="white-space: pre-wrap;">' .. invocation .. '</pre>'
    invocation = mw.getCurrentFrame():preprocess(invocation)
    invocation = mw.getCurrentFrame():preprocess(invocation)
    Line 84: Line 160:


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


    Line 93: Line 174:
    local TestCase = {}
    local TestCase = {}
    TestCase.__index = TestCase
    TestCase.__index = TestCase
    TestCase.message = message -- add the message method


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


    -- Validate options
    -- Separate general options from template options. Template options are
    do
    -- numbered, whereas general options are not.
    local highestNum = 0
    local generalOptions, templateOptions = {}, {}
    for k in pairs(options) do
    for k, v in pairs(options) do
    if type(k) == 'string' then
    local prefix, num
    local num = k:match('([1-9][0-9]*)$')
    if type(k) == 'string' then
    num = tonumber(num)
    prefix, num = k:match('^(.-)([1-9][0-9]*)$')
    if num and num > highestNum then
    highestNum = num
    end
    end
    end
    end
    for i = 3, highestNum do
    if prefix then
    if not options['template' .. i] then
    num = tonumber(num)
    error(string.format(
    templateOptions[num] = templateOptions[num] or {}
    "one or more options ending in '%d' were " ..
    templateOptions[num][prefix] = v
    "detected, but no 'template%d' option was found",
    else
    i, i
    generalOptions[k] = v
    ), 2)
    end
    end
    end
    end
    end


    -- Separate general options from options for specific templates
    -- Set general options
    local templateOptions = mTableTools.numData(options, true)
    generalOptions.showcode = yesno(generalOptions.showcode)
    obj.options = templateOptions.other or {}
    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


    -- Add default template options
    -- 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[1] = templateOptions[1] or {}
    templateOptions[2] = templateOptions[2] 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
    if templateOptions[1].template and not templateOptions[2].template then
    templateOptions[2].template = templateOptions[1].template .. '/sandbox'
    templateOptions[2].template = templateOptions[1].template ..
    '/' .. obj.cfg.sandboxSubpage
    end
    end
    if not templateOptions[1].template then
    if not templateOptions[1].template then
    Line 134: Line 245:
    end
    end
    if not templateOptions[2].template then
    if not templateOptions[2].template then
    templateOptions[2].title = templateOptions[1].title:subPageTitle('sandbox')
    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
    end
    mw.logObject(templateOptions)


    -- Make the template objects
    -- Make the template objects
    obj.templates = {}
    obj.templates = {}
    for i, t in ipairs(templateOptions) do
    for i, options in ipairs(templateOptions) do
    table.insert(obj.templates, Template.new(invocationObj, t))
    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
    end


    return obj
    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('table')
    if self.options.wantdiff then
    root
    :addClass('mw-collapsible')
    if self.options.notcollapsed == false then
    root
    :addClass('mw-collapsed')
    end
    root
    :css('background-color', 'transparent')
    :css('width', '100%')
    :css('border', 'solid silver 1px')
    :tag('tr')
    :tag('th')
    :css('background-color', isEqual and 'yellow' or '#90a8ee')
    :wikitext(title)
    :done()
    :done()
    :tag('tr')
    :tag('td')
    :newline()
    :wikitext(s)
    :newline()
    else
    root
    :addClass('mw-collapsible')
    if self.options.notcollapsed == false then
    root
    :addClass('mw-collapsed')
    end
    if self.options.notcollapsed ~= true or false then
    root
    :addClass(isEqual and 'mw-collapsed' or nil)
    end
    root
    :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(title)
    :done()
    :done()
    :tag('tr')
    :tag('td')
    :newline()
    :wikitext(s)
    :newline()
    end
    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('<pre style="white-space: pre-wrap;">')
    :wikitext(mw.text.nowiki(self.options.before or ""))
    :wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    :wikitext(mw.text.nowiki(self.options.after or ""))
    :wikitext('</pre>')
    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('<pre style="white-space: pre-wrap;">')
    :wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    :wikitext('</pre>')
    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] = '<pre style="white-space: pre-wrap;">'
    line[#line + 1] = mw.text.nowiki(self:getTemplateOutput(obj))
    line[#line + 1] = '</pre>'
    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('<pre style="white-space: pre-wrap;">')
    :wikitext(mw.text.nowiki(self.options.before or ""))
    :wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    :wikitext(mw.text.nowiki(self.options.after or ""))
    :wikitext('</pre>')
    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
    end


    function TestCase:renderDefault()
    function TestCase:renderDefault()
    local ret = {}
    local ret = {}
    ret[#ret + 1] = self.templates[1]:getInvocation('code')
    if self.options.showcode then
    ret[#ret + 1] = self.templates[1]:getInvocation()
    end
    for i, obj in ipairs(self.templates) do
    for i, obj in ipairs(self.templates) do
    ret[#ret + 1] = '<div style="clear: both;"></div>'
    ret[#ret + 1] = '<div style="clear: both;"></div>'
    ret[#ret + 1] = obj:makeBraceLink()
    if self.options.showheader then
    ret[#ret + 1] = obj:getOutput()
    ret[#ret + 1] = obj:makeHeader()
    end
    if self.options.output == 'nowiki+' then
    ret[#ret + 1] = self:getTemplateOutput(obj) .. '<pre style="white-space: pre-wrap;">' .. mw.text.nowiki(self:getTemplateOutput(obj)) .. '</pre>'
    elseif self.options.output == 'nowiki' then
    ret[#ret + 1] = mw.text.nowiki(self:getTemplateOutput(obj))
    else
    ret[#ret + 1] = self:getTemplateOutput(obj)
    end
    end
    end
    return table.concat(ret, '\n\n')
    return table.concat(ret, '\n\n')
    Line 158: Line 661:


    function TestCase:__tostring()
    function TestCase:__tostring()
    local methods = {
    columns = 'renderColumns',
    rows = 'renderRows'
    }
    local format = self.options.format
    local format = self.options.format
    local method = format and methods[format] or 'renderDefault'
    local method = format and TestCase.renderMethods[format] or 'renderDefault'
    return self[method](self)
    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('[[Category:%s]]', cat)
    end
    return ret
    end
    end


    Line 173: Line 679:
    local NowikiInvocation = {}
    local NowikiInvocation = {}
    NowikiInvocation.__index = NowikiInvocation
    NowikiInvocation.__index = NowikiInvocation
    NowikiInvocation.message = message -- Add the message method


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


    function NowikiInvocation:getInvocation(template)
    function NowikiInvocation:getInvocation(options)
    template = template:gsub('%%', '%%%%') -- Escape "%" with "%%"
    local template = options.template:gsub('%%', '%%%%') -- Escape "%" with "%%"
    local invocation, count = self.invocation:gsub(
    local invocation, count = self.invocation:gsub(
    TEMPLATE_NAME_MAGIC_WORD_ESCAPED,
    self.cfg.templateNameMagicWordPattern,
    template
    template
    )
    )
    if count < 1 then
    if options.requireMagicWord ~= false and count < 1 then
    error(string.format(
    error(self:message(
    "the template invocation must include '%s' in place " ..
    'nowiki-magic-word-error',
    "of the template name",
    self.cfg.templateNameMagicWord
    TEMPLATE_NAME_MAGIC_WORD
    ))
    ))
    end
    end
    Line 196: Line 711:
    end
    end


    function NowikiInvocation:getOutput(template)
    function NowikiInvocation:getOutput(options)
    local invocation = self:getInvocation(template)
    local invocation = self:getInvocation(options)
    return mw.getCurrentFrame():preprocess(invocation)
    return mw.getCurrentFrame():preprocess(invocation)
    end
    end
    Line 207: Line 722:
    local TableInvocation = {}
    local TableInvocation = {}
    TableInvocation.__index = TableInvocation
    TableInvocation.__index = TableInvocation
    TableInvocation.message = message -- Add the message method


    function TableInvocation.new(invokeArgs)
    function TableInvocation.new(invokeArgs, nowikiCode, cfg)
    local obj = setmetatable({}, TableInvocation)
    local obj = setmetatable({}, TableInvocation)
    obj.cfg = cfg
    obj.invokeArgs = invokeArgs
    obj.invokeArgs = invokeArgs
    obj.code = nowikiCode
    return obj
    return obj
    end
    end


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


    function TableInvocation:getOutput(template)
    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{
    return mw.getCurrentFrame():expandTemplate{
    title = template,
    title = options.template,
    args = self.invokeArgs
    args = self.invokeArgs
    }
    }
    Line 229: Line 758:


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


    -- Table-based exports
    local bridge = {}


    local function getTableArgs(frame, wrappers)
    function bridge.table(args, cfg)
    return require('Module:Arguments').getArgs(frame, {
    cfg = cfg or mw.loadData(DATA_MODULE)
    wrappers = wrappers,
    trim = false,
    removeBlanks = false
    })
    end


    local p = {}
    function p._table(args)
    local options, invokeArgs = {}, {}
    local options, invokeArgs = {}, {}
    for k, v in pairs(args) do
    for k, v in pairs(args) do
    Line 259: Line 783:
    end
    end
    end
    end
    local invocationObj = TableInvocation.new(invokeArgs)
     
    local testCaseObj = TestCase.new(invocationObj, options)
    -- 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)
    return tostring(testCaseObj)
    end
    end


    function p.table(frame)
    function bridge.nowiki(args, cfg)
    return p._table(getTableArgs(frame, 'Template:Test case from arguments'))
    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
    end


    function p.columns(frame)
    -------------------------------------------------------------------------------
    local args = getTableArgs(frame, 'Template:Testcase table')
    -- Exports
    args._format = 'columns'
    -------------------------------------------------------------------------------
    return p._table(args)
    end


    function p.rows(frame)
    local p = {}
    local args = getTableArgs(frame, 'Template:Testcase rows')
    args._format = 'rows'
    return p._table(args)
    end


    -- Nowiki-based exports
    function p.main(frame, cfg)
    cfg = cfg or mw.loadData(DATA_MODULE)


    function p._nowiki(args)
    -- Load the wrapper config, if any.
    local invocationObj = NowikiInvocation.new(args.invocation)
    local wrapperConfig
    args.invocation = nil
    if frame.getParent then
    local options = args
    local title = frame:getParent():getTitle()
    local testCaseObj = TestCase.new(invocationObj, options)
    local template = title:gsub(cfg.sandboxSubpagePattern, '')
    return tostring(testCaseObj)
    wrapperConfig = cfg.wrappers[template]
    end
    end


    function p.nowiki(frame)
    -- Work out the function we will call, use it to generate the config for
    local args = require('Module:Arguments').getArgs(frame, {
    -- Module:Arguments, and use Module:Arguments to find the arguments passed
    wrappers = 'Template:Test case from invocation'
    -- 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'
    })
    })
    return p._nowiki(args)
     
    -- 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
    end


    -- Exports for testing
    function p._exportClasses() -- For testing
     
    function p._exportClasses()
    return {
    return {
    Template = Template,
    TestCase = TestCase,
    TestCase = TestCase,
    Invocation = Invocation,
    NowikiInvocation = NowikiInvocation,
    NowikiInvocation = NowikiInvocation,
    TableInvocation = TableInvocation
    TableInvocation = TableInvocation

    Latest revision as of 12:00, 21 May 2021

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

    --[[
       A module for generating test case templates.
    
       This module incorporates code from the English Wikipedia's "Testcase table"
       module,[1] written by Frietjes [2] with contributions by Mr. Stradivarius [3]
       and Jackmcbarn,[4] and the English Wikipedia's "Testcase rows" module,[5]
       written by Mr. Stradivarius.
    
       The "Testcase table" and "Testcase rows" modules are released under the
       CC BY-SA 3.0 License [6] and the GFDL.[7]
    
       License: CC BY-SA 3.0 and the GFDL
       Author: Mr. Stradivarius
    
       [1] https://en.wikipedia.org/wiki/Module:Testcase_table
       [2] https://en.wikipedia.org/wiki/User:Frietjes
       [3] https://en.wikipedia.org/wiki/User:Mr._Stradivarius
       [4] https://en.wikipedia.org/wiki/User:Jackmcbarn
       [5] https://en.wikipedia.org/wiki/Module:Testcase_rows
       [6] https://en.wikipedia.org/wiki/Wikipedia:Text_of_Creative_Commons_Attribution-ShareAlike_3.0_Unported_License
       [7] https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_GNU_Free_Documentation_License
    ]]
    
    -- 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|%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: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 = '<code>' .. mw.text.nowiki(invocation) .. '</code>'
    	elseif format == 'kbd' then
    		invocation = '<kbd>' .. mw.text.nowiki(invocation) .. '</kbd>'
    	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()
    	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('table')
    	if self.options.wantdiff then
    	root
    		:addClass('mw-collapsible')
    	if self.options.notcollapsed == false then
    		root
    			:addClass('mw-collapsed')
    	end
    	root
    		:css('background-color', 'transparent')
    		:css('width', '100%')
    		:css('border', 'solid silver 1px')
    		:tag('tr')
    			:tag('th')
    				:css('background-color', isEqual and 'yellow' or '#90a8ee')
    				:wikitext(title)
    				:done()
    			:done()
    		:tag('tr')
    			:tag('td')
    				:newline()
    				:wikitext(s)
    				:newline()
    	else
    		root
    		:addClass('mw-collapsible')
    		if self.options.notcollapsed == false then
    			root
    				:addClass('mw-collapsed')
    		end
    		if self.options.notcollapsed ~= true or false then
    			root
    				:addClass(isEqual and 'mw-collapsed' or nil)
    		end
    		root
    		: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(title)
    				:done()
    			:done()
    		:tag('tr')
    			:tag('td')
    				:newline()
    				:wikitext(s)
    				:newline()
    	 end
    	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('<pre style="white-space: pre-wrap;">')
    				:wikitext(mw.text.nowiki(self.options.before or ""))
    				:wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    				:wikitext(mw.text.nowiki(self.options.after or ""))
    				:wikitext('</pre>')
    		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('<pre style="white-space: pre-wrap;">')
    				:wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    				:wikitext('</pre>')
    		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] = '<pre style="white-space: pre-wrap;">'
    			line[#line + 1] = mw.text.nowiki(self:getTemplateOutput(obj))
    			line[#line + 1] = '</pre>'
    		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('<pre style="white-space: pre-wrap;">')
    				:wikitext(mw.text.nowiki(self.options.before or ""))
    				:wikitext(mw.text.nowiki(self:getTemplateOutput(obj)))
    				:wikitext(mw.text.nowiki(self.options.after or ""))
    				:wikitext('</pre>')
    		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] = '<div style="clear: both;"></div>'
    		if self.options.showheader then
    			ret[#ret + 1] = obj:makeHeader()
    		end
    		if self.options.output == 'nowiki+' then
    			ret[#ret + 1] = self:getTemplateOutput(obj) .. '<pre style="white-space: pre-wrap;">' .. mw.text.nowiki(self:getTemplateOutput(obj)) .. '</pre>'
    		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('[[Category:%s]]', 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