Модуль:UnitTests

Материал из Викисловаря
Документация

Этот модуль упрощает создание юнит-тестов для модулей Lua.

Добавьте следующий код на страницу модуля Модуль:name/testcases:

local tests = require('Module:UnitTests')

function tests:test_example()
	--[[ here be the tests ]]
end

return tests

Затем добавьте следующий код на страницу документации Модуль:name/testcases/Документация:

{{#invoke:name/testcases|run_tests}}

Описание функций и параметров

Tests should be written as Lua methods whose names start with lua. The lua object contains the following methods, which may be called from the method:

  • preprocess_equals(text, expected, options)
    Сравнивает значение и ожидаемый результат
    • text — значение
    • expected — ожидаемый результат.
  • preprocess_equals_many(prefix, suffix, cases, options)
    Сравнивает несколько результатов вызова теста, добавляя префикс и суффкс
    • prefix — префикс
    • suffix — суффикс
    • cases — массив из двух элементов
    • options — опции
      1. значение
      2. ожидаемый результат.
  • preprocess_equals_preprocess(text1, text2, options)
    Сравнивает два значения
    • text1 — первое значение
    • text2 — второе значение
    • options — опция nowiki для подавления препроцессора для результата
  • preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
    Сравнивает несколько результатов вызова теста, добавляя префикс и суффкс
    • prefix1 — префикс для первого значения
    • prefix2 — префикс для второгозначения
    • suffix1 — суффикс первого значения
    • suffix2 — суффикс второго значения
    • cases — массив из двух значений
    • options — опции
  • equals(name, actual, expected, options)
    Формирует строку таблицы сравнения два значения
    • name — название теста (1-я ячейка строки таблицы). Если вместо строки нужно передать таблицу, то следует использовать функцию equals_deep.
  • equals_deep(name, actual, expected, options)
    Сравнивает два значения, каждое из которых таблица
    • name — название теста
    • actual — таблица значений
    • expected — таблица ожидаемых результатов
  • heading(string)
    Формирует подвал таблицы, добавляется последней строкой.
  • iterate
    Принимает таблицу в первом параметре. Итарационно обходит таблицу. Если элемент таблицы тоже таблица, то вызывает функцию в контексте этого массива. Если элемент строка, то для неё вызывается self:heading().
    Существует в двух вариантах:
    • iterate(array, function_name)
      Где, function_name — строка с именем тестовой функции в self. Например, self:iterate({ { "a", "b" }, { "c", "d" } }, "check") вызовет self:check("a", "b") и self:check("c", "d"). Метод self:check() должен быть определён в коде теста отдельно.
    • iterate(array, func)
      Также, но принимает вторым аргументом функцию. Например, self:iterate( { { "a", "b" }, { "c", "d" } }, check) вызовет check(self, "a", "b") and check(self, "c", "d").

Параметр options должен быть передан как таблица или пропущен. На данный момент сущестуют следующие опции

  • nowiki — подавляет вызов препроцессора значений, как будто тег <nowiki> обернут вокруг значений.
  • comment — комментарий добавляемый в последнюю справа ячейку строки таблицы.
  • display — функция формирующая текст фактически отображаемый в таблице. Используется в тестах модулей транскрипции для правильного отображения шрифта МФА.
  • show_difference — если задано true (или любое другое истинное значение, кроме функции), в случае неудачных тестов первый неверный символ будет выделен красным (то есть первый символ в «фактической» строке, который отличается от соответствующего символа в «ожидаемой» строке); если это функция, то символ будет выделен с помощью функции. (В настоящее время доступна только в функции equals. Подсветка выделит диакритические знаки вместе с символами, за которыми они помещены.)
local UnitTester = {}

local is_combining = require "Module:Unicode data".is_combining
local tick, cross =
	'[[File:Yes check.svg|20px|alt=Пройдено|link=|Тест успешно пройден]]',
	'[[File:X mark.svg|20px|alt=Провалено|link=|Тест провален]]'

local result_table_header = '{| class="unit-tests wikitable"\n! class="unit-tests-img-corner" | !! Текст !! Правильно !! Выдаётся'
local result_table = {}

local function first_difference(s1, s2)
	if type(s1) ~= 'string' or type(s2) ~= 'string' then return 'N/A' end
	local s1c = mw.ustring.gcodepoint(s1);
    local s2c = mw.ustring.gcodepoint(s2);
    local i =  1
    while true do
        local c1 = s1c();
        local c2 = s2c();
        if c1 == nil then
            if c2 == nil then
                return ''
            else
                return i
            end
        else
            if c2 ~= nil then
                if c1 ~= c2 then
                  return i
              end
            else
                return i
            end
        end
        i = i+1
    end
end

local function highlight(str)
	if mw.ustring.find(str, "%s") then
		return '<span style="background-color: pink;">' ..
			string.gsub(str, " ", "&nbsp;") .. '</span>'
	else
		return '<span style="color: red;">' ..
			str .. '</span>'
	end
end

local function find_noncombining(str, i, incr)
	local char = mw.ustring.sub(str, i, i)
	while char ~= '' and is_combining(mw.ustring.codepoint(char)) do
		i = i + incr
		char = mw.ustring.sub(str, i, i)
	end
	return i
end

-- Highlight character where a difference was found. Start highlight at first
-- non-combining character before the position. End it after the first non-
-- combining characters after the position. Can specify a custom highlighing
-- function.
local function highlight_difference(actual, expected, differs_at, func)
	if type(differs_at) ~= "number" or not (actual and expected) then
		return actual
	end
	differs_at = find_noncombining(expected, differs_at, -1)
	local i = find_noncombining(actual, differs_at, -1)
	local j = find_noncombining(actual, differs_at + 1, 1)
	j = j - 1
	return mw.ustring.sub(actual, 1, i - 1) ..
		(type(func) == "function" and func or highlight)(mw.ustring.sub(actual, i, j)) ..
		mw.ustring.sub(actual, j + 1, -1)
end

local function val_to_str(v)
	if type(v) == 'string' then
		v = mw.ustring.gsub(v, '\n', '\\n')
		if mw.ustring.match(mw.ustring.gsub(v, '[^\'"]', ''), '^"+$') then
			return "'" .. v .. "'"
		end
		return '"' .. mw.ustring.gsub(v, '"', '\\"' ) .. '"'
	elseif type(v) == 'table' then
		local result, done = {}, {}
		for k, val in ipairs(v) do
			table.insert(result, val_to_str(val))
			done[k] = true
		end
		for k, val in pairs(v) do
			if not done[k] then
				if (type(k) ~= "string") or not mw.ustring.match(k, '^[_%a][_%a%d]*$') then
					k = '[' .. val_to_str(k) .. ']'
				end
				table.insert(result, k .. '=' .. val_to_str(val))
			end
		end
		return '{' .. table.concat(result, ', ') .. '}'
	else
		return tostring(v)
	end
end

local function deep_compare(t1, t2, ignore_mt)
	local ty1, ty2 = type(t1), type(t2)
	if ty1 ~= ty2 then return false
	elseif ty1 ~= 'table' then return t1 == t2 end
	
	local mt = getmetatable(t1)
	if not ignore_mt and mt and mt.__eq then return t1 == t2 end
	
	for k1, v1 in pairs(t1) do
		local v2 = t2[k1]
		if v2 == nil or not deep_compare(v1, v2) then return false end
	end
	for k2, v2 in pairs(t2) do
		local v1 = t1[k2]
		if v1 == nil or not deep_compare(v1, v2) then return false end
	end

	return true
end

function UnitTester:preprocess_equals(text, expected, options)
	local actual = self.frame:preprocess(text)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		self.num_failures = self.num_failures + 1
	end
	local differs_at = self.differs_at and (' || ' .. first_difference(expected, actual)) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	actual   = tostring(actual)
	expected = tostring(expected)
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	table.insert(result_table, ' || ' .. mw.text.nowiki(text) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. "\n")
	self.total_tests = self.total_tests + 1
end

function UnitTester:preprocess_equals_many(prefix, suffix, cases, options)
	for _, case in ipairs(cases) do
		self:preprocess_equals(prefix .. case[1] .. suffix, case[2], options)
	end
end

function UnitTester:preprocess_equals_preprocess(text1, text2, options)
	local actual = self.frame:preprocess(text1)
	local expected = self.frame:preprocess(text2)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		self.num_failures = self.num_failures + 1
	end
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	local differs_at = self.differs_at and (' || ' .. first_difference(expected, actual)) or ''
	table.insert(result_table, ' || ' .. mw.text.nowiki(text1) .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. "\n")
end

function UnitTester:preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
	for _, case in ipairs(cases) do
		self:preprocess_equals_preprocess(prefix1 .. case[1] .. suffix1, prefix2 .. (case[2] and case[2] or case[1]) .. suffix2, options)
	end
end

function UnitTester:equals(name, actual, expected, options)
	if actual == expected then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		self.num_failures = self.num_failures + 1
	end
	local difference = first_difference(expected, actual)
	if options and options.show_difference and type(difference) == "number" then
		actual = highlight_difference(actual, expected, difference,
			type(options.show_difference) == "function" and options.show_difference)
	end
	local differs_at = self.differs_at and (' || ' .. difference) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	if expected == nil then
		expected = '(nil)'
	else
		expected = tostring(expected)
	end
	if actual == nil then
		actual = '(nil)'
	else
		actual = tostring(actual)
	end
	if self.nowiki or options and options.nowiki then
		expected = mw.text.nowiki(expected)
		actual = mw.text.nowiki(actual)
	end
	
	if options and type(options.display) == "function" then
		expected = options.display(expected)
		actual = options.display(actual)
	end
	
	table.insert(result_table, ' || ' .. name .. ' || ' .. expected .. ' || ' .. actual .. differs_at .. "\n")
	self.total_tests = self.total_tests + 1
end

function UnitTester:equals_deep(name, actual, expected, options)
	if deep_compare(actual, expected) then
		table.insert(result_table, '|- class="unit-test-pass"\n | ' .. tick)
	else
		table.insert(result_table, '|- class="unit-test-fail"\n | ' .. cross)
		self.num_failures = self.num_failures + 1
	end
	local actual_str = val_to_str(actual)
	local expected_str = val_to_str(expected)
	if self.nowiki or options and options.nowiki then
		expected_str = mw.text.nowiki(expected_str)
		actual_str = mw.text.nowiki(actual_str)
	end
	
	if options and type(options.display) == "function" then
		expected_str = options.display(expected_str)
		actual_str = options.display(actual_str)
	end
	
	local differs_at = self.differs_at and (' || ' .. first_difference(expected_str, actual_str)) or ''
	local comment = self.comments and (' || ' .. (options and options.comment or '')) or ''
	table.insert(result_table, ' || ' .. name .. ' || ' .. expected_str .. ' || ' .. actual_str .. differs_at .. "\n")
	self.total_tests = self.total_tests + 1
end

function UnitTester:iterate(examples, func)
	require 'libraryUtil'.checkType('iterate', 1, examples, 'table')
	if type(func) == 'string' then
		func = self[func]
	elseif type(func) ~= 'function' then
		error(("bad argument #2 to 'iterate' (expected function or string, got %s)")
			:format(type(func)), 2)
	end
	
	for i, example in ipairs(examples) do
		if type(example) == 'table' then
			func(self, unpack(example))
		elseif type(example) == 'string' then
			self:heading(example)
		else
			error(('bad example #%d (expected table or string, got %s)')
				:format(i, type(example)), 2)
		end
	end
end

function UnitTester:heading(text)
	self.result_table:insert((' |-\n ! colspan="%u" style="text-align: left" | %s\n'):format(self.columns, text))
end

function UnitTester:run(frame)
	self.num_failures = 0
	self.frame = frame
	self.nowiki = frame.args['nowiki']
	self.differs_at = frame.args['differs_at']
	self.comments = frame.args['comments']
	self.summarize = frame.args['summarize']
	self.total_tests = 0

	self.columns = 4
	local table_header = result_table_header
	if self.differs_at then
		self.columns = self.columns + 1
		table_header = table_header .. ' !! Расходится в '
	end
	if self.comments then
		self.columns = self.columns + 1
		table_header = table_header .. ' !! Комментарий'
	end

	-- Sort results into alphabetical order.
	local self_sorted = {}
	for key, value in pairs(self) do
		if key:find('^test') then
			table.insert(self_sorted, key)
		end
	end
	table.sort(self_sorted)
	-- Add results to the results table.
	for _, key in ipairs(self_sorted) do
		table.insert(result_table, table_header .. "\n")
		table.insert(result_table, '|+ style="text-align: left; font-weight: bold;" | ' .. key .. ':\n|-\n')
		local traceback = "(трейсбеков нет)"
		local success, mesg = xpcall(function ()
			return self[key](self)	
		end, function (mesg)
			traceback = debug.traceback("", 2)
			return mesg
		end)
		if not success then
			table.insert(result_table, (' |-\n | colspan="%u" style="text-align: left" | <strong class="error">Ошибка скрипта в процессе тестирования: %s</strong>%s\n'):format(
				self.columns, mw.text.nowiki(mesg), frame:extensionTag("pre", traceback)
			))
			self.num_failures = self.num_failures + 1
		end
		table.insert(result_table, "|}\n\n")
	end

	local refresh_link = tostring(mw.uri.fullUrl(mw.title.getCurrentTitle().fullText, 'action=purge&forcelinkupdate'))

	local failure_cat = '[[Категория:Тесты для модулей]]'
	if mw.title.getCurrentTitle().text:find("/documentation$") then
		failure_cat = ''
	end
	
	local lang = mw.getLanguage( 'ru' )
	local num_successes = self.total_tests - self.num_failures
	
	if (self.summarize) then
		if (self.num_failures == 0) then
			return '<strong class="success">' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed</strong>'
		else
			return '<strong class="error">' .. self.num_successes .. '/' .. self.total_tests .. ' tests passed</strong>'
		end
	else
		return (self.num_failures == 0 and '<strong class="success">Все тесты успешно пройдены.</strong>' or 
				'<strong class="error">' .. self.num_failures .. lang:plural(self.num_failures, ' тест провален', ' теста провалено', ' тестов провалено') .. '.</strong>' .. failure_cat) .. 
				" <span class='plainlinks unit-tests-refresh'>[" .. refresh_link .. " (обновить)]</span>\n\n" .. 
				table.concat(result_table)
	end
end

function UnitTester:new()
	local o = {}
	setmetatable(o, self)
	self.__index = self
	return o
end

local p = UnitTester:new()
function p.run_tests(frame) return p:run(frame) end
return p