Module:Template test case: Difference between revisions

    From Nonbinary Wiki
    (Don't export the table and nowiki functions. At the moment the Lua interfaces don't make much sense, and rewriting the module to include nice Lua interfaces would probably be a lot of wasted work, as Lua modules will generally use Lua-based test cases.)
    m (87 revisions imported from wikipedia:Module:Template_test_case)
     
    (42 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.
     
      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
    -- Load required modules
    Line 33: Line 54:
    getFullPage = true,
    getFullPage = true,
    getName = true,
    getName = true,
    makeHeading = true,
    makeHeader = true,
    getOutput = true
    getOutput = true
    }
    }
    Line 73: Line 94:


    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
    hasColon = hasColon > 0
    Line 85: 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 112: Line 135:
    end
    end


    function Template:makeHeading()
    function Template:makeHeader()
    return self.heading or self:makeBraceLink()
    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{
    template = self:getName(),
    requireMagicWord = self.requireMagicWord,
    }
    if format == 'code' then
    if format == 'code' then
    invocation = '<code>' .. mw.text.nowiki(invocation) .. '</code>'
    invocation = '<code>' .. mw.text.nowiki(invocation) .. '</code>'
    elseif format == 'kbd' then
    invocation = '<kbd>' .. mw.text.nowiki(invocation) .. '</kbd>'
    elseif format == 'plain' then
    elseif format == 'plain' then
    invocation = mw.text.nowiki(invocation)
    invocation = mw.text.nowiki(invocation)
    Line 132: 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 148: Line 181:
    columns = 'renderColumns',
    columns = 'renderColumns',
    rows = 'renderRows',
    rows = 'renderRows',
    tablerows = 'renderRows',
    inline = 'renderInline',
    cells = 'renderCells',
    default = 'renderDefault'
    default = 'renderDefault'
    }
    }
    Line 158: Line 194:
    -- numbered, whereas general options are not.
    -- numbered, whereas general options are not.
    local generalOptions, templateOptions = {}, {}
    local generalOptions, templateOptions = {}, {}
    do
    for k, v in pairs(options) do
    local optionNum = {} -- a unique key for option numbers inside templateOptions
    local prefix, num
    local rawTemplateOptions = {}
    if type(k) == 'string' then
    for k, v in pairs(options) do
    prefix, num = k:match('^(.-)([1-9][0-9]*)$')
    local prefix, num
    end
    if type(k) == 'string' then
    if prefix then
    prefix, num = k:match('^(.-)([1-9][0-9]*)$')
    num = tonumber(num)
    end
    templateOptions[num] = templateOptions[num] or {}
    if prefix then
    templateOptions[num][prefix] = v
    num = tonumber(num)
    else
    rawTemplateOptions[num] = rawTemplateOptions[num] or {}
    generalOptions[k] = v
    rawTemplateOptions[num][prefix] = v
    rawTemplateOptions[num][optionNum] = num -- record for use in error messages
    else
    generalOptions[k] = v
    end
    end
    end
    end


    -- Add default template options
    -- Set general options
    rawTemplateOptions[1] = rawTemplateOptions[1] or {}
    generalOptions.showcode = yesno(generalOptions.showcode)
    rawTemplateOptions[2] = rawTemplateOptions[2] or {}
    generalOptions.showheader = yesno(generalOptions.showheader) ~= false
    if rawTemplateOptions[1].template and not rawTemplateOptions[2].template then
    generalOptions.showcaption = yesno(generalOptions.showcaption) ~= false
    rawTemplateOptions[2].template = rawTemplateOptions[1].template ..
    generalOptions.collapsible = yesno(generalOptions.collapsible)
    '/' .. obj.cfg.sandboxSubpage
    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
    if not rawTemplateOptions[1].template then
    end
    rawTemplateOptions[1].title = mw.title.getCurrentTitle().basePageTitle
     
    -- 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
    if not rawTemplateOptions[2].template then
    end
    rawTemplateOptions[2].title = rawTemplateOptions[1].title:subPageTitle(
     
    obj.cfg.sandboxSubpage
    -- 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
    end


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


    -- Check that there are no missing template options.
    -- Don't require the __TEMPLATENAME__ magic word for nowiki invocations if
    for i = 3, #templateOptions do -- Defaults have already been added for 1 and 2.
    -- there is only one template being output.
    local t = templateOptions[i]
    if #templateOptions <= 1 then
    if not t.template then
    templateOptions[1].requireMagicWord = false
    local num = t[optionNum]
    error(obj:message(
    'missing-template-option-error',
    num, num
    ), 2)
    end
    end
    end
    end


    -- Set general options
    mw.logObject(templateOptions)
    generalOptions.showcode = yesno(generalOptions.showcode)
    generalOptions.collapsible = yesno(generalOptions.collapsible)
    obj.options = generalOptions


    -- 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


    Line 246: Line 327:
    local out = obj:getOutput()
    local out = obj:getOutput()
    -- Remove the random parts from strip markers.
    -- Remove the random parts from strip markers.
    out = out:gsub('(%cUNIQ).-(QINU%c)', '%1%2')
    out = out:gsub('(\127\'"`UNIQ.-)%-%x+%-(QINU`"\'\127)', '%1%2')
    return out
    return out
    end
    end
    Line 260: Line 341:


    function TestCase:makeCollapsible(s)
    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 isEqual = self:templateOutputIsEqual()
    local root = mw.html.create('table')
    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
    root
    :addClass('collapsible')
    :css('background-color', 'transparent')
    :addClass(isEqual and 'collapsed' or nil)
    :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('background-color', 'transparent')
    :css('width', '100%')
    :css('width', '100%')
    Line 271: Line 387:
    :tag('th')
    :tag('th')
    :css('background-color', isEqual and 'lightgreen' or 'yellow')
    :css('background-color', isEqual and 'lightgreen' or 'yellow')
    :wikitext(self.options.title or self.templates[1]:makeHeading())
    :wikitext(title)
    :done()
    :done()
    :done()
    :done()
    :tag('tr')
    :tag('tr')
    :tag('td')
    :tag('td')
    :newline()
    :wikitext(s)
    :wikitext(s)
    :newline()
    end
    return tostring(root)
    return tostring(root)
    end
    end
    Line 289: Line 408:


    local tableroot = root:tag('table')
    local tableroot = root:tag('table')
    tableroot
    :addClass(self.options.class)
    :cssText(self.options.style)
    :tag('caption')
    :wikitext(self.options.caption or self:message('columns-header'))


    -- Headings
    if self.options.showheader then
    local headingRow = tableroot:tag('tr')
    -- Caption
    if self.options.rowheader then
    if self.options.showcaption then
    -- rowheader is correct here. We need to add another th cell if
    tableroot
    -- rowheader is set further down, even if heading0 is missing.
    :addClass(self.options.class)
    headingRow:tag('th'):wikitext(self.options.heading0)
    :cssText(self.options.style)
    end
    :tag('caption')
    local width
    :wikitext(self.options.caption or self:message('columns-header'))
    if #self.templates > 0 then
    end
    width = tostring(math.floor(100 / #self.templates)) .. '%'
     
    else
    -- Headers
    width = '100%'
    local headerRow = tableroot:tag('tr')
    end
    if self.options.rowheader then
    for i, obj in ipairs(self.templates) do
    -- rowheader is correct here. We need to add another th cell if
    headingRow
    -- rowheader is set further down, even if heading0 is missing.
    :tag('th')
    headerRow:tag('th'):wikitext(self.options.heading0)
    :css('width', width)
    end
    :wikitext(obj:makeHeading())
    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
    end


    Line 325: Line 450:
    -- Template output
    -- Template output
    for i, obj in ipairs(self.templates) do
    for i, obj in ipairs(self.templates) do
    dataRow:tag('td')
    if self.options.output == 'nowiki+' then
    :newline()
    dataRow:tag('td')
    :wikitext(self:getTemplateOutput(obj))
    :newline()
    :wikitext(self.options.after)
    :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
    end
    Line 354: Line 499:


    for _, obj in ipairs(self.templates) do
    for _, obj in ipairs(self.templates) do
    -- Build the row HTML
    local dataRow = tableroot:tag('tr')
    tableroot
    :tag('tr')
    -- Header
    :tag('td')
    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('text-align', 'center')
    :css('font-weight', 'bold')
    :css('font-weight', 'bold')
    :wikitext(obj:makeHeading())
    :wikitext(obj:makeHeader())
    :done()
    dataRow = tableroot:tag('tr')
    :done()
    end
    :tag('tr')
    end
    :tag('td')
    :newline()
    -- Template output
    :wikitext(self:getTemplateOutput(obj))
    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
    end


    return tostring(root)
    return tostring(root)
    Line 379: Line 646:
    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] = self:getTemplateOutput(obj)
    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 391: Line 666:
    if self.options.collapsible then
    if self.options.collapsible then
    ret = self:makeCollapsible(ret)
    ret = self:makeCollapsible(ret)
    end
    for cat in pairs(self.categories) do
    ret = ret .. string.format('[[Category:%s]]', cat)
    end
    end
    return ret
    return ret
    Line 418: Line 696:
    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(
    self.cfg.templateNameMagicWordPattern,
    self.cfg.templateNameMagicWordPattern,
    template
    template
    )
    )
    if count < 1 then
    if options.requireMagicWord ~= false and count < 1 then
    error(self:message(
    error(self:message(
    'nowiki-magic-word-error',
    'nowiki-magic-word-error',
    Line 433: 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 454: Line 732:
    end
    end


    function TableInvocation:getInvocation(template)
    function TableInvocation:getInvocation(options)
    if self.code then
    if self.code then
    local nowikiObj = NowikiInvocation(self.code, self.cfg)
    local nowikiObj = NowikiInvocation.new(self.code, self.cfg)
    return nowikiObj:getInvocation(template)
    return nowikiObj:getInvocation(options)
    else
    else
    return require('Module:Template invocation').invocation(
    return require('Module:Template invocation').invocation(
    template,
    options.template,
    self.invokeArgs
    self.invokeArgs
    )
    )
    Line 466: Line 744:
    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 514: Line 798:
    cfg = cfg or mw.loadData(DATA_MODULE)
    cfg = cfg or mw.loadData(DATA_MODULE)


    local invocationObj = NowikiInvocation.new(args.code, cfg)
    local code = args.code or args[1]
    local invocationObj = NowikiInvocation.new(code, cfg)
    args.code = nil
    args.code = nil
    args[1] = nil
    -- Assume we want to see the code as we already passed it in.
    -- Assume we want to see the code as we already passed it in.
    args.showcode = args.showcode or true
    args.showcode = args.showcode or true

    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