Module:Convert/tester
From CryptoWiki
Revision as of 06:25, 27 October 2021 by wiki_crypto>Johnuniq (add prefix parameter to compare so (I hope) |prefix=:* will indent)
Documentation for this module may be created at Module:Convert/tester/doc
-- 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 Collection = {} Collection.__index = Collection do function Collection:add(item) if item ~= nil then self.n = self.n + 1 self[self.n] = item end end function Collection:join(sep) return table.concat(self, sep) end function Collection.new() return setmetatable({n = 0}, Collection) 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 normalize(text) -- Return text with any strip markers normalized by replacing the -- unique number with a fixed value so comparisons work. return text:gsub('(\127[^\127]*UNIQ[^\127]*%-)(%x\+)(-QINU[^\127]*\127)', '%100000000%3') 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 normalize(expected) == normalize(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, ttext if stats.template then ttext = "'''Using [[Template:" .. stats.template .. "]]:''' " else ttext = '' end 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 ttext .. '<span style="font-size:120%;color:white;background-color:' .. bgcolor .. ';">' .. msg .. ignored_text .. '.</span>' end local function run_template(frame, template, args, collapse_multiline) -- Template "{{ example | 2 = def | abc | name = ghi jkl }}" -- gives xargs { " abc ", "def", name = "ghi jkl" }. if template:sub(1, 2) == '{{' and template:sub(-2, -1) == '}}' then template = template:sub(3, -3) .. '|' -- append sentinel to get last field else return '(invalid template)' end local xargs = {} 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 xargs[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 = args.template 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 xargs[index] ~= nil do -- Skip any explicit numbered parameters like "|5=five". index = index + 1 end put_arg(index, field) end end end if args.test and not xargs.test then -- For convert, allow test=preview or test=nopreview to be injected into -- the convert under test, if it does not already use that parameter. -- That allows, for example, a preview of make_tests to show nopreview results. xargs.test = args.test end local function expand(t) return frame:expandTemplate(t) end local ok, result = pcall(expand, { title = templatename, args = xargs }) 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, args) 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.new() for _, item in ipairs(all_tests) do local template = item[1] if template then local actual = run_template(frame, template, args, 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 <pre>...</pre>. return '<pre>\n' .. mw.text.nowiki(result:join('\n')) .. '\n</pre>' end local function _run_tests(frame, all_tests, args) local function safe_cell(text, multiline) -- For testing {{convert}}, want wikitext like '[[kilogram|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', '<br />') end return text end local function nowiki_cell(text, multiline) text = mw.text.nowiki(text) if multiline then text = text:gsub('\\n', '<br />') end return text end local stats = { pass = 0, fail = 0, ignored = 0, template = args.template } local result = Collection.new() result:add('{| class="wikitable sortable"') result:add('! Template !! Expected !! Actual, if different !! Status') for _, item in ipairs(all_tests) do local template, expected = item[1], item[2] or '' if template then local actual = run_template(frame, template, args, true) local sbox, actual, isfail = status_box(stats, expected, actual) result:add('|-') result:add('| ' .. safe_cell(template)) result:add('| ' .. safe_cell(expected, true)) result:add('| ' .. safe_cell(actual, true)) result:add('| ' .. sbox) if isfail then result:add('|-') result:add('| align="center"| (above, nowiki)') result:add('| ' .. nowiki_cell(normalize(expected), true)) result:add('| ' .. nowiki_cell(normalize(actual), true)) result:add('|') end else local text = item.text if text and text:sub(1, 3) == '---' then result:add('|-') result:add('| colspan="3" style="color:white;background:silver;" | ' .. safe_cell(strip(text:sub(4)), true)) result:add('| ' .. status_box(stats, '', '', true)) end end end result:add('|}') return status_text(stats) .. '\n\n' .. result:join('\n') end local function get_page_content(page_title, ignore_error) 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 if not ignore_error then error('Could not read wikitext from "[[' .. page_title .. ']]".', 0) end end local function _compare(frame, page_pairs) local prefix = frame.args.prefix or '*' local function diff_link(title1, title2) return '<span class="plainlinks">[' .. tostring(mw.uri.fullUrl('Special:ComparePages', { page1 = title1, page2 = title2 })) .. ' diff]</span>' end local function link(title) return '[[' .. title .. ']]' end local function message(text, isgood) local color = isgood and 'green' or 'darkred' return '<span style="color:' .. color .. ';">' .. text .. '</span>' end local result = Collection.new() 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, true) local content2 = get_page_content(title2, true) if not content1 or not content2 then label = message('does not exist', false) elseif content1 == content2 then label = message('same content', true) else label = message('different', false) .. ' (' .. diff_link(title1, title2) .. ')' end end result:add(prefix .. 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.new() 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 ok, result = pcall(get_tests, frame, p.tests) if ok then ok, result = pcall(worker, frame, result, frame.args) if ok then return result end end return '<strong class="error">Error</strong>\n\n' .. result end local modules = { -- For convenience, a key defined here can be used to refer to the -- corresponding list of modules. countries = { -- Commons 'Countries', 'Countries/Africa', 'Countries/Americas', 'Countries/Arab world', 'Countries/Asia', 'Countries/Caribbean', 'Countries/Central America', 'Countries/Europe', 'Countries/North America', 'Countries/North America (subcontinent)', 'Countries/Oceania', 'Countries/South America', 'Countries/United Kingdom', }, 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[i] = { title, title .. '/sandbox' } end end local ok, result = pcall(_compare, frame, page_pairs) if ok then return result end return '<strong class="error">Error</strong>\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