-- Test the output from a template by comparing it with fixed text. -- The expected text must be in a single line, but can include -- "\n" (two characters) to indicate that a newline is expected. -- Tests are run (or created) by setting p.tests (string or table), or -- by setting page=PAGE_TITLE (and optionally section=SECTION_TITLE), -- then executing run_tests (or make_tests). local function collection -- Return a table to hold lines of text. return { n = 0, add = function (self, s) self.n = self.n + 1 self[self.n] = s end, join = function (self, sep) return table.concat(self, sep) end, } end local function empty(text) -- Return true if text is nil or empty (assuming a string). return text nil or text end local function strip(text) -- Return text with no leading/trailing whitespace. return text:match("^%s*(.-)%s*$") end local function status_box(stats, expected, actual, iscomment) local label, bgcolor, align, isfail if iscomment then actual = align = 'center' bgcolor = 'silver' label = 'Cmnt' elseif expected then stats.ignored stats.ignored + 1 return , actual elseif expected actual then stats.pass = stats.pass + 1 actual = align = 'center' bgcolor = 'green' label = 'Pass' else stats.fail = stats.fail + 1 align = 'center' bgcolor = 'red' label = 'Fail' isfail = true end local sbox = 'style="text-align:' .. align .. ';color:white;background:' .. bgcolor .. ';" | ' .. label return sbox, actual, isfail end local function status_text(stats) local bgcolor, ignored_text, msg if stats.fail 0 then if stats.pass 0 then bgcolor = 'salmon' msg = 'No tests performed' else bgcolor = 'green' msg = string.format('All %d tests passed', stats.pass) end else bgcolor = 'darkred' msg = string.format('%d test%s failed', stats.fail, stats.fail 1 and or 's') end if stats.ignored 0 then ignored_text = else bgcolor = 'salmon' ignored_text = string.format(', %d test%s ignored because expected text is blank', stats.ignored, stats.ignored 1 and or 's') end return end local function run_template(frame, template, forcename, collapse_multiline) -- Template "" -- gives args { " abc ", "def", name "ghi jkl" }. if template:sub(1, 2) then template = template:sub(3, -3) .. '|' -- append sentinel to get last field else return '(invalid template)' end local args = {} local index = 1 local templatename local function put_arg(k, v) -- Kludge: Module:Val uses Module:Arguments which trims arguments and -- omits blank arguments. Simulate that here. -- LATER Need a parameter to control this. if templatename:sub(1, 3) 'val' then v strip(v) if v then return end end args[k] = v end template = template:gsub('(%[%[[^%[%]]-)|(.-%]%])', '%1\0%2') -- replace pipe in piped link with a zero byte for field in template:gmatch('(.-)|') do field = field:gsub('%z', '|') -- restore pipe in piped link if templatename nil then templatename forcename or strip(field) if templatename then return '(invalid template)' end else local k, eq, v = field:match("^(.-)(=)(.*)$") if eq then k, v = strip(k), strip(v) -- k and/or v can be empty local i = tonumber(k) if i and i > 0 and string.match(k, '^%d+$') then put_arg(i, v) else put_arg(k, v) end else while args[index] ~= nil do -- Skip any explicit numbered parameters like "|5=five". index = index + 1 end put_arg(index, field) end end end local function expand(t) return frame:expandTemplate(t) end local ok, result = pcall(expand, { title = templatename, args = args }) if not ok then result = 'Error: ' .. result end if collapse_multiline then result = result:gsub('\n', '\\n') end return result end local function _make_tests(frame, all_tests, forcename) local maxlen = 38 for _, item in ipairs(all_tests) do local template = item[1] if template then local templen = mw.ustring.len(template) item.templen = templen if maxlen < templen and templen <= 70 then maxlen = templen end end end local result = collection for _, item in ipairs(all_tests) do local template = item[1] if template then local actual = run_template(frame, template, forcename, true) local pad = string.rep(' ', maxlen - item.templen) .. ' ' result:add(template .. pad .. actual) else local text = item.text if text then result:add(text) end end end -- Pre tags returned by a module are html tags, not like wikitext . return end local function _run_tests(frame, all_tests, forcename) local function safe_cell(text, multiline) -- For testing , want wikitext like 'kg' to be unchanged -- so the link works and so the displayed text is short (just "kg" in example). text = text:gsub('(%[%[[^%[%]]-)|(.-%]%])', '%1\0%2') -- replace pipe in piped link with a zero byte text = text:gsub('{', '{'):gsub('|', '|') -- escape '{' and '|' text = text:gsub('%z', '|') -- restore pipe in piped link if multiline then text = text:gsub('\\n', ) end return text end local function nowiki_cell(text, multiline) text = mw.text.nowiki(text) if multiline then text = text:gsub('\\n', ) end return text end local stats = { pass = 0, fail = 0, ignored = 0 } local result = collection result:add return status_text(stats) .. '\n\n' .. result:join('\n') end local function get_page_content(page_title) local t = mw.title.new(page_title) if t then local content = t:getContent if content then if content:sub(-1) ~= '\n' then content = content .. '\n' end return content end end error('Could not read wikitext from "' .. page_title .. '".', 0) end local function _compare(frame, page_pairs) local function diff_link(title1, title2) return end local function link(title) return .. title .. end local function message(text, isgood) local color = isgood and 'green' or 'darkred' return end local result = collection for _, item in ipairs(page_pairs) do local label local title1 = item[1] local title2 = item[2] if title1 title2 then label message('same title', false) else local content1 get_page_content(title1) local content2 get_page_content(title2) if content1 content2 then label = message('same content', true) else label = message('different', false) .. ' (' .. diff_link(title1, title2) .. ')' end end result:add('*' .. link(title1) .. ' • ' .. link(title2) .. ' • ' .. label) end return result:join('\n') end local function sections(text) return { first = 1, -- just after the newline at the end of the last heading this_section = 1, next_heading = function(self) local first = self.first while first <= #text do local last, heading first, last, heading = text:find(' +[\t ]*([^\n]-)[\t ]* +[\t\r ]*\n', first) if first then if first 1 or text:sub(first - 1, first - 1) '\n' then self.this_section = first self.first = last + 1 return heading end first = last + 1 else break end end self.first = #text + 1 return nil end, current_section = function(self) local first = self.this_section local last = text:find('\n [^\n]- [\t\r ]*\n', first) if not last then last = -1 end return text:sub(first, last) end, } end local function get_tests(frame, tests) local args = frame.args local page_title, section_title = args.page, args.section local show_all = (args.show 'all') if not empty(page_title) then if not empty(tests) then error('Invoke must not set "page ' .. page_title .. '" if also setting p.tests.', 0) end if page_title:sub(1, 2) and page_title:sub(-2) then page_title strip(page_title:sub(3, -3)) end tests get_page_content(page_title) if not empty(section_title) then local s sections(tests) while true do local heading s:next_heading if heading then if heading section_title then tests = s:current_section break end else error('Section "' .. section_title .. '" not found in page ' .. page_title .. '.', 0) end end end end if type(tests) ~= 'string' then if type(tests) 'table' then return tests end error('No tests were specified; see Module:Convert/tester/doc.', 0) end if tests:sub(-1) ~ '\n' then tests tests .. '\n' end local template_count 0 local all_tests collection for line in (tests):gmatch('([^\n]-)[\t\r ]*\n') do local template, expected line:match('^%s*(.-)%s*$') if template then template_count template_count + 1 all_tests:add({ template, expected }) elseif show_all then all_tests:add({ text line }) end end if template_count 0 then error('No templates found; see Module:Convert/tester/doc.', 0) end return all_tests end local function main(frame, p, worker) local args = frame.args local ok, result = pcall(get_tests, frame, p.tests) if ok then ok, result = pcall(worker, frame, result, args.template) if ok then return result end end return '\n\n' .. result end local modules = { -- For convenience, a key defined here can be used to refer to the -- corresponding list of modules. convert = { 'Convert', 'Convert/data', 'Convert/text', 'Convert/extra', 'Convert/wikidata', 'Convert/wikidata/data', }, cs1 = { 'Citation/CS1', 'Citation/CS1/Configuration', }, cs1all = { 'Citation/CS1', 'Citation/CS1/Configuration', 'Citation/CS1/Whitelist', 'Citation/CS1/Date validation', }, team = { 'Team appearances list', 'Team appearances list/data', 'Team appearances list/show', }, val = { 'Val', 'Val/units', }, } local p = {} function p.compare(frame) local page_pairs = p.pairs if not page_pairs then local args = frame.args if not args[2] then local builtins = modules[args[1] or 'convert'] if builtins then args = builtins end end page_pairs = {} for i, title in ipairs(args) do if not title:find(':', 1, true) then title = 'Module:' .. title end page_pairs = { title, title .. '/sandbox' } end end local ok, result = pcall(_compare, frame, page_pairs) if ok then return result end return '\n\n' .. result end p.check_sandbox = p.compare function p.make_tests(frame) return main(frame, p, _make_tests) end function p.run_tests(frame) return main(frame, p, _run_tests) end return p