--- Module for creating properties. Provides a small domain specific language -- for ease of use. -- @module lqc.property -- @alias property local lqc = require 'lqc.quickcheck' local report = require 'lqc.report' local results = require 'lqc.property_result' local unpack = unpack or table.unpack -- for compatibility reasons --- Helper function, checks if x is an integer. -- @param x a value to be checked -- @return true if x is an integer; false otherwise. local function is_integer(x) return type(x) == 'number' and x % 1 == 0 end --- Adds a small wrapper around the check function indicating success or failure -- @param prop_table Property to be wrapped. local function add_check_wrapper(prop_table) local check_func = prop_table.check prop_table.check = function(...) if check_func(...) then return results.SUCCESS else return results.FAILURE end end end --- Adds an 'implies' wrapper to the check function -- @param prop_table Property to be wrapped. local function add_implies(prop_table) local check_func = prop_table.check prop_table.check = function(...) if prop_table.implies(...) == false then return results.SKIPPED end return check_func(...) end end --- Adds a 'when_fail' wrapper to the check function -- @param prop_table Property to be wrapped. local function add_when_fail(prop_table) local check_func = prop_table.check prop_table.check = function(...) local result = check_func(...) if result == results.FAILURE then prop_table.when_fail(...) end return result end end --- Shrinks a property that failed with a certain set of inputs. -- This function is called recursively if a shrink fails. -- This function returns a simplified list or inputs. -- @param property the property that failed -- @param generated_values array of values to be shrunk down -- @param tries Amount of shrinking tries already done -- @return array of shrunk down values local function do_shrink(property, generated_values, tries) if not tries then tries = 1 end local shrunk_values = property.shrink(unpack(generated_values)) local result = property(unpack(shrunk_values)) if tries == property.numshrinks then -- Maximum amount of shrink attempts exceeded. return generated_values end if result == results.FAILURE then -- further try to shrink down return do_shrink(property, shrunk_values, tries + 1) elseif result == results.SKIPPED then -- shrunk to invalid situation, retry return do_shrink(property, generated_values, tries + 1) end -- return generated values since they were last values for which property failed! return generated_values end --- Function that checks if the property is valid for a set amount of inputs. -- 1. check result of property X amount of times: -- - SUCCESS = OK, print '.' -- - SKIPPED = OK, print 'x' -- - FAILURE = NOT OK, see 2. -- 2. if FAILURE: -- 2.1 print property info, values for which it fails -- 2.2 do shrink to find minimal error case -- 2.3 when shrink stays the same or max amount exceeded -> print minimal example -- @param property Property to be checked X amount of times. -- @return nil on success; otherwise returns a table containing error info. local function do_check(property) for _ = 1, property.numtests do local generated_values = property.pick() local result = property(unpack(generated_values)) if result == results.SUCCESS then report.report_success() elseif result == results.SKIPPED then report.report_skipped() else report.report_failed() if #generated_values == 0 then -- Empty list of generators -> no further shrinking possible! return { property = property, generated_values = generated_values, shrunk_values = generated_values, } end local shrunk_values = do_shrink(property, generated_values) return { property = property, generated_values = generated_values, shrunk_values = shrunk_values, } end end return nil end --- Creates a new property. -- NOTE: property is limited to 1 implies, for_all, when_fail -- more complex scenarios should be handled with state machine. -- @param descr String containing description of the property -- @param property_func Function to be checked X amount of times -- @param generators List of generators used to generate values supplied to 'property_func' -- @param numtests Number of times the property should be checked -- @param numshrinks Number of times a failing property should be shrunk down -- @return property object local function new(descr, property_func, generators, numtests, numshrinks) local prop = { description = descr, numtests = numtests, numshrinks = numshrinks, } -- Generates a new set of inputs for this property. -- Returns the newly generated set of inputs as a table. function prop.pick() local generated_values = {} for i = 1, #generators do generated_values[i] = generators[i]:pick(numtests) end return generated_values end -- Shrink 1 value randomly out of the given list of values. function prop.shrink(...) local values = {...} local which = math.random(#values) local shrunk_value = generators[which]:shrink(values[which]) values[which] = shrunk_value return values end -- Function that checks if the property is valid for a set amount of inputs. function prop:check() return do_check(self) end return setmetatable(prop, { __call = function(_, ...) return property_func(...) end, }) end --- Inserts the property into the list of existing properties. -- @param descr String containing text description of the property -- @param prop_info_table Table containing information of the property -- @return nil; raises an error if prop_info_table is not in a valid format local function property(descr, prop_info_table) local function prop_func(prop_table) local generators = prop_table.generators if not generators or type(generators) ~= 'table' then error('Need to supply generators in property!') end local check_type = type(prop_table.check) if check_type ~= 'function' and check_type ~= 'table' then error('Need to provide a check function to property!') end add_check_wrapper(prop_table) local implies_type = type(prop_table.implies) if implies_type == 'function' or implies_type == 'table' then add_implies(prop_table) end local when_fail_type = type(prop_table.when_fail) if when_fail_type == 'function' or when_fail_type == 'table' then add_when_fail(prop_table) end local it_amount = prop_table.numtests local shrink_amount = prop_table.numshrinks local numtests = is_integer(it_amount) and it_amount or lqc.numtests local numshrinks = is_integer(shrink_amount) and shrink_amount or lqc.numshrinks local new_prop = new(descr, prop_table.check, prop_table.generators, numtests, numshrinks) table.insert(lqc.properties, new_prop) end -- property called without DSL-like syntax if prop_info_table then prop_func(prop_info_table) return function() end end -- property called with DSL syntax! return prop_func end return property