Bruker:Jeblad/Module:BDD
- Note that the module is not ready for production, it is still under active development!
The purpose of this module is to support behavior-driven development (BDD), a software development process that emerged from test-driven development (TDD), in on-going development of Lua-based modules. It makes an assumption of a test module on a separate page, possibly a subpage, and presentation on another page like the talk page or generated through an API call.
The module mimics some ideas from other BDD libs, like RSpec [1], Jasmine [2], and Busted [3]. There are no clear standard on how to do this, so there are some variation and adaption.
The module is not built for great speed, it uses closures to build a number of small objects, that is methods returns a value that holds a closure. This creates a fairly efficient implementation for simple access, but it is not very efficient for caching larger structures. Some larger structures are put in tables and built once. This is done for Expect
where each new instance would otherwise create a lot of methods.
The module avoids as much caching as possible, as this can poison the tests.
Usage
If you have a module like «Module:HelloWorld», the ubiquitous and quite pesky example, coded as something like
local p = {}
function p.helloWorld()
return "Hi there!"
end
return p
Then on a test page you would test this like like the following
require 'Module:BDD'()
local p = require 'Module:HelloWorld'()
describe('Hello world', function()
context('On all pages', function()
it('says hello', function()
expect('p.helloWorld()', p.helloWorld()):toBe("Hi there!");
end);
end);
end);
return result('Module:HelloWorld')
In the first line the test framework is not only loaded, but it is also installed in the global name space. The trailing parenthesis is a call on the loaded table, and it will install additional global functions. It is not necessary to keep a pointer to the test framework, the functions keep their own pointers.
In the second line the module under test is loaded. Usually it is necessary to keep a pointer to the returned value.
The describe
(alt Given
) function adds a title and evaluates the function from a xpcall
(not done yet). The same happens with context
(alt When
) and it
(alt Then
). These three are really the same, it is only our interpretation that changes.
The expect
function adds a comment and the statement that creates the actual value. It then builds a closure and returns a structure in a flow style. This makes it possible to manipulate the instance, before we finally evaluates it and stores the result for later visualization. In this case we simply tests it to see if it is equal.
The final results are returned by a call to result
. Like before this can add a title.
The tests can be prepared for for localization into several languages, for example English and Norwegian Bokmål
require 'Module:BDD'()
local p = require 'Module:HelloWorld'()
describe({en='Hello world', nb='Hallo verden'}, function()
context({en='on all pages', nb='på alle sider'}, function()
it({en='says hello', nb='sier hallo'}, function()
expect('p.helloWorld()', p.helloWorld()):toBe("Hi there!");
end);
end);
end);
return result({ en='Module:HelloWorld', nb='Module:HelloWorld'})
In this case the languages will be chosen according to the site content language, and if that isn't defined it will use the languages from the fallback chain. If the first argument is a string, then that string will always be used. The language can be changed if the tests are run through the api, which makes it easy to check tests if they are prepared for the requested language.
Usually it is good practice to both use a common language to make the tests readable and reusable, and use a local language to make them readable for the local community at each project.
Further work
At present formatting does not work properly. It is expected to be fixed. ;)
Setup/teardown
There will be a solution whereby setup and teardown is chained. That means that the tests are somewhat isolated from each other at each test level, that is each time we define a describe, context, or it, the environment will be recreated. State can still leak between expectations though.
It is assumed that there will be no global setup and teardown as there is no resource allocation available from pages in Scribunto.
Xpcall
All calls to provided functions should be protected in xpcalls so a single exception does not make the whole test set to fail. A failed function should be marked as such in the report.
Actual function is xpcall, and it will always add a stack trace on our own stack. The entry might be without a message.
This is partially implemented, proper formatting remains unsolved.
Spy on public calls
Note that spying on public calls made before the module is available to the testing regime will not be possible.
- Carp
- Adds a message to stack without exiting the test, printing the callers name and its arguments.
- Cluck
- Like Carp, but also prints a stack trace starting one level up.
- Croak/Confess
- Like Carp, but also stops the running test (the user provided anonymous function). Because it throws an exception it will always trigger a stack trace.
Call would be like [carp|cluck|croak|confess](message, table, method)
.
Set up of spies are implemented, but they should be removed during teardown. Such cleanup must check if the struct is still available. This may happen if structs are created inside the test environment.
Coverage for public call
It should be possible to register spies for profiling coverage on members of a module. A function should loop over all members and register spies for all of them. Coverage can then be calculated for the public members. This will only give coverage of the entry points, but it seems sufficient to get a coarse number for coverage.
Stub public methods
It should be possible to stub public methods from the test page. This will not include private methods. (Is this something that should be part of a BDD module?)
Module return
At present an explicit return must be done. It seems to be possible to do an implicit return. This makes a slightly less error prone setup procedure.
Several options exist in literature, but it might be some limitations to what is actually allowed in Scribunto. It seems like all options are blocked.
Test Anything Protocol
At some point the Test Anything Protocol (TAP) should be supported. The simplest solution is to use yet another page for that response, but it is also possible to test on the page name. This can be different if we transclude the talk page into some other page, or even constructs the page on the fly through the api. If the transcluded page name is "API" then we switch to a TAP response. This makes it possible to support several formats from a single page.
Support libs
- Module:BDD/utils – Utility functions for testing tables and table contents. The functions are roughly equal to the same methods in Yonabas Moses library. [4]
- Module:BDD/view – View to show the result of the tests.
- Module:BDD/localize – Access methods to the localization libraries and the localized strings.
- Special:Prefixindex/Module:BDD/localize/ – The localized strings for each language.
See also
-- module for Behavior Driven Development testing framework
-- © John Erling Blad, Creative Commons by Attribution 3.0
local util = require('Modul:BDD/utils')
-- @var The table holding the modules exported members
local bdd = {}
local function View( tbl, stats )
local self = {}
-- @var configuration of truncated code chunks
local truncateLength = 30
-- statefull function for constructing unique number sequences
-- @return function that returns a number from an unique sequence
local idrunner = (function()
local counter = 0
return function()
counter = 1+counter
return tostring( counter )
end
end)()
-- escape a string to make it a valid name for classes and add a prefix
-- @param str string used as the source for the class name
-- @param pref string used as the prefix
-- @return string escaped and prefixed
local function escClass( str, prefix )
assert(str, "invalid call: String not defined")
function helper( char )
return string.format ( "$%02X", string.byte( char ) )
end
str = string.gsub( str, "[%s]", "_" )
str = string.gsub( str, "([^%w%-%_])", helper )
if prefix then
prefix = string.gsub( str, "[%s]", "_" )
prefix = string.gsub( str, "([^%w%-%_])", helper )
end
return (prefix or 'bdd') .. '-' .. str
end
-- @var _params to be used unless there are local overrides
local messages = {}
messages['result'] = 'Result $1'
messages['pending'] = 'Pending $1'
messages['describe'] = 'Describe $1'
messages['context'] = 'Context $1'
messages['it'] = 'It $1'
messages['xdescribe'] = 'Mute describe $1'
messages['xcontext'] = 'Mute context $1'
messages['xit'] = 'Mute it $1'
messages['exception'] = 'Catch $1'
messages['stack'] = 'Stack'
messages['carp'] = 'Carp'
messages['cluck'] = 'Cluck'
messages['croak'] = 'Croak'
messages['spy'] = 'Spy $1'
messages['spy-args'] = 'Spy $1'
messages['spy-no-formatter'] = 'Spy $1'
messages['actual'] = 'actual ($1)'
--messages['actual-noargs'] = 'actual is empty'
messages['actual-noargs'] = ''
messages['anticipated'] = 'and anticipate ($1),'
--messages['anticipated-noargs'] = 'and anticipate is empty'
messages['anticipated-noargs'] = ''
messages['expect'] = 'Expect $1'
messages['expect-args'] = 'Expect $1'
messages['expect-no-formatter'] = 'Expect $1'
messages['as-upper'] = 'as uppercase'
messages['as-lower'] = 'as lowercase'
messages['as-upper-first'] = 'as uppercase first'
messages['as-lower-first'] = 'as lowercase first'
messages['as-identity'] = 'as identity ($1)'
messages['when-invert'] = 'and then invert ($1)'
messages['when-identity'] = 'and then as identity ($1)'
messages['if-boolean'] = ', it is boolean type'
messages['if-string'] = ', it is string type'
messages['if-number'] = ', it is number type'
messages['if-table'] = ', it is table type'
messages['if-nil'] = 'it is nil'
messages['if-fail'] = ', force fail' --change
messages['if-equal'] = 'compare with equal ($1)'
messages['if-deep-equal'] = 'compare with deep equal ($1)'
messages['if-lesser-than'] = 'if lesser than, then'
messages['if-greater-than'] = 'if greather than, then'
messages['if-lesser-or-equal'] = 'if lesser or equal, then'
messages['if-greater-or-equal'] = 'if greater or equal, then'
messages['if-false'] = 'compare with false'
messages['if-true'] = 'compare with true'
messages['if-falsy'] = 'compare with falsy'
messages['if-truthy'] = 'compare with truthy'
messages['if-first-identity'] = 'first identity to be "$1"'
messages['if-second-identity'] = 'second identity to be "$1"'
messages['if-match'] = 'if it match ($1)'
messages['if-match-noargs'] = 'if it match nothing'
messages['if-ustring-match'] = 'if it match ($1)'
messages['if-ustring-match-noargs'] = 'if it match nothing'
messages['if-contains'] = 'if it contains value ($1)'
messages['if-close-to'] = 'if value is close ($1)'
messages['to-be-boolean'] = 'to be a boolean'
messages['to-be-string'] = 'to be a string'
messages['to-be-number'] = 'to be a number'
messages['to-be-table'] = 'to be a table'
messages['to-be-nil'] = 'to be a nil'
messages['to-be'] = 'to become "$1"'
messages['to-be-failing'] = 'to be failing'
--messages['to-be-equal'] = 'to be equal ($1)'
messages['to-be-deep-equal'] = 'to be deep equal ($1)'
messages['to-be-lesser-than'] = 'to be lesser than "$1"'
messages['to-be-greater-than'] = 'to be greater than'
messages['to-be-lesser-or-equal'] = 'to be lesser or equal'
messages['to-be-greater-or-equal'] = 'to be greater or equal'
messages['to-be-falsy'] = 'to be falsy'
messages['to-be-truthy'] = 'to be truthy'
messages['to-be-false'] = 'to be false'
messages['to-be-true'] = 'to be true'
messages['to-be-first-identity'] = 'first identity to be "$1"'
messages['to-be-second-identity'] = 'second identity to be "$1"'
messages['to-be-match'] = 'to match ($1)'
messages['to-be-match-noargs'] = 'to match nothing'
messages['to-be-ustring-match'] = 'to match ($1)'
messages['to-be-ustring-match-noargs'] = 'to match nothing'
messages['to-be-contains'] = 'to contain value ($1)'
messages['to-be-close-to'] = 'to be close to value ($1)'
messages['joiner'] = ', '
messages['no-formatter'] = "[no-formatter:$1]"
messages['skipped'] = "[skipped]"
messages['category-all-tests'] = "All tests"
messages['category-failed-tests'] = "Failed tests"
messages['category-good-tests'] = "Good tests"
local function g( key, ...)
local msg = mw.message.new( 'bdd-' .. key )
if msg:isBlank() then
assert(messages[key],
"invalid message: " .. key .. " is not a defined string" )
msg = mw.message.newRawMessage( messages[key] )
end
return msg
end
local formatters = {}
local function _xdefinition( ... )
local t = {...}
local dt = mw.html.create( 'dt' )
local dl = mw.html.create( 'dl' )
:node( dt )
for i,v in ipairs({...}) do
if i == 1 then
dt:addClass( escClass( v ) )
elseif i == 2 then
dt:wikitext(
g( t[1] )
:params( v )
:plain()
)
end
end
return dl
end
local function _definition( ... )
local t = {...}
local id = idrunner()
local dl = mw.html.create( 'dl' )
local dt = mw.html.create( 'dt' )
dt:addClass( 'mw-customtoggle-' .. id )
local dd = mw.html.create( 'dd' )
dd:addClass( 'mw-collapsible' )
dd:attr( 'id', 'mw-customcollapsible-' .. id )
for i,v in ipairs(t) do
if i == 1 then
dt:addClass( escClass( v ) )
elseif i == 2 then
dt:wikitext(
g( t[1] )
:params( v )
:plain()
)
--elseif i == 3 then
--local msg = g( t[1] )
--dt:wikitext(msg:params( v ):plain() )
--dt:wikitext(v[1])
elseif v and v[1] then
if type(formatters[v[1]]) == 'function' then
local html = formatters[v[1]]( unpack( v ) )
dd:node(
html
)
else
dd:wikitext(
g( 'no-formatter' )
:params(v[1] or 'none')
:plain()
)
end
else
dd:wikitext(
g( 'skipped' )
:plain()
)
end
end
dl:node( dt )
dl:node( dd )
return dl
end
local function _expect( ... )
local t = {...}
local ul = mw.html.create( 'ul' )
for i,v in ipairs(t) do
if i == 1 then
ul:addClass( escClass( v ) )
local last = t[#t]
local final = last[#last]
ul:addClass(
final and 'bdd-expect-good' or 'bdd-expect-fail'
)
elseif i == 2 then
-- delay this
elseif i == 3 then
local li = mw.html.create( 'li' )
if v then
if type(formatters[v[1]]) == 'function' then
local html = formatters[v[1]]( unpack( v ) )
if html then
li:wikitext(
g( 'expect-args' )
:rawParams( mw.text.nowiki( t[2] ) )
:rawParams( html:done() )
:plain()
)
else
li:wikitext(
g( 'expect' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
else
li:wikitext(
g( 'expect-no-formatter' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
else
li:wikitext(
g( 'expect' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
ul:node(li)
elseif i == 4 then
-- skip this for now, it is the statistics
elseif v and v[1] then
local li = mw.html.create( 'li' )
if type(formatters[v[1]]) == 'function' then
local html = formatters[v[1]]( unpack( v ) )
li:node( html )
else
li:wikitext(
g( 'no-formatter' )
:params(v[1] or 'none')
:plain()
)
end
ul:node(li)
else
local li = mw.html.create( 'li' )
dd:wikitext(
g( 'skipped' )
:plain()
)
ul:node(li)
end
end
return ul
end
local function dumpBox( obj )
local dump = mw.dumpObject( obj )
local short = mw.html.create( 'div' )
short
:addClass( 'bdd-popup-handle' )
:css( 'display', 'inline-block' )
:wikitext(
mw.text.nowiki(
mw.text.trim(
mw.text.truncate( dump:gsub('\n', ' '), truncateLength, nil, true )
)
)
)
local box = mw.html.create( 'div' )
box
:addClass( 'bdd-popup-box' )
:css('display', 'none')
local full = mw.html.create( 'pre' )
full
:wikitext(
mw.text.nowiki(
mw.text.trim(
dump
)
)
)
box:node(full)
short:node(box)
return short
end
local function _partial( ... )
local t = {...}
local li = mw.html.create( 'li' )
local html
if #t == 1 then
-- empty params
elseif #t == 2 then
-- single params
html = dumpBox( t[2] )
else
-- several params
end
if html then
li:wikitext(
g( t[1] )
:rawParams( html )
:plain()
)
else
li:wikitext(
g( t[1]..'-noargs' )
:plain()
)
end
return li
end
local function _params( ... )
local t = {...}
local html
if #t == 1 then
-- empty params
elseif #t == 2 then
-- single params
html = dumpBox( t[2] )
else
-- several params
end
return html
end
local function _spy( ... )
local t = {...}
local ul = mw.html.create( 'ul' )
for i,v in ipairs(t) do
if i == 1 then
ul:addClass( escClass( v ) )
local last = t[#t]
local final = last[#last]
ul:addClass(
final and 'bdd-spy-good' or 'bdd-spy-fail'
)
elseif i == 2 then
-- delay this
elseif i == 3 then
local li = mw.html.create( 'li' )
if v then
if type(formatters[v[1]]) == 'function' then
local html = formatters[v[1]]( unpack( v ) )
if html then
li:wikitext(
g( 'spy-args' )
:rawParams( mw.text.nowiki( t[2] ) )
:rawParams( html:done() )
:plain()
)
else
li:wikitext(
g( 'spy' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
else
li:wikitext(
g( 'spy-no-formatter' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
else
li:wikitext(
g( 'spy' )
:rawParams( mw.text.nowiki( t[2] ) )
:plain()
)
end
ul:node(li)
elseif i == 4 then
-- skip this for now, it is the statistics
elseif v and v[1] then
local li = mw.html.create( 'li' )
if type(formatters[v[1]]) == 'function' then
local html = formatters[v[1]]( unpack( v ) )
li:node( html )
else
li:wikitext(
g( 'no-formatter' )
:params(v[1] or 'none')
:plain()
)
end
ul:node(li)
else
local li = mw.html.create( 'li' )
dd:wikitext(
g( 'skipped' )
:plain()
)
ul:node(li)
end
end
return ul
end
local function _nop( ... )
local placeholder = mw.html.create( 'div' )
:css( 'display', 'none' )
return placeholder
end
formatters['result'] = _definition
formatters['describe'] = _definition
formatters['context'] = _definition
formatters['it'] = _definition
formatters['xdescribe'] = _xdefinition
formatters['xcontext'] = _xdefinition
formatters['xit'] = _xdefinition
formatters['expect'] = _expect
formatters['results'] = _results
formatters['params'] = _params
formatters['carp'] = _spy
formatters['cluck'] = _spy
formatters['croak'] = _spy
formatters['actual'] = _partial
formatters['anticipated'] = _partial
formatters['as-identity'] = _partial
formatters['as-upper'] = _partial
formatters['as-lower'] = _partial
formatters['as-upper-first'] = _partial
formatters['as-lower-first'] = _partial
formatters['when-identity'] = _partial
formatters['when-invert'] = _partial
formatters['if-boolean'] = _partial
formatters['if-string'] = _partial
formatters['if-number'] = _partial
formatters['if-table'] = _partial
formatters['if-nil'] = _partial
formatters['if-fail'] = _partial
formatters['if-equal'] = _partial
formatters['if-deep-equal'] = _partial
formatters['if-lesser-than'] = _partial
formatters['if-greater-than'] = _partial
formatters['if-lesser-or-equal'] = _partial
formatters['if-greater-or-equal'] = _partial
formatters['if-false'] = _partial
formatters['if-true'] = _partial
formatters['if-falsy'] = _partial
formatters['if-truthy'] = _partial
formatters['if-match'] = _partial
formatters['if-ustring-match'] = _partial
formatters['if-contains'] = _partial
formatters['if-close-to'] = _partial
formatters['to-be'] = _partial
formatters['to-be-failing'] = _partial
formatters['to-be-deep-equal'] = _partial
formatters['to-be-lesser-than'] = _partial
formatters['to-be-greater-than'] = _partial
formatters['to-be-lesser-or-equal'] = _partial
formatters['to-be-greater-or-equal'] = _partial
formatters['to-be-falsy'] = _partial
formatters['to-be-truthy'] = _partial
formatters['to-be-nil'] = _partial
formatters['to-be-boolean'] = _partial
formatters['to-be-string'] = _partial
formatters['to-be-number'] = _partial
formatters['to-be-table'] = _partial
formatters['to-be-false'] = _partial
formatters['to-be-true'] = _partial
formatters['to-be-match'] = _partial
formatters['to-be-ustring-match'] = _partial
formatters['to-be-contains'] = _partial
formatters['to-be-close-to'] = _partial
function self.format()
local div = mw.html.create( 'div' )
if tbl[1] then
div:node( formatters[tbl[1]]( unpack( tbl ) ) )
end
local rep = (stats and stats.numfailed > 0) and '[[Category:Failed bdd test sets]]' or ''
return div:allDone() -- .. ' ' .. rep
end
return self
end
local function buildName( str )
return str:gsub("([A-Z])", function(str) return '-'..string.lower(str) end )
end
-- constructor for a closure to make the functions use the same stack
local function BDD()
-- @var This is the stack where we store our temporary formatters
local stack = { "result", '' }
-- @var This is the accumulators where we store our statistics
-- local statistics = { "statistics", { "accumulator" , 0, 0 } }
-- @var accumulator for failed tests
local numFailed = 0
-- @var base for expectations
local base = {}
-- @var list of preprocess functions
local preprocess = {}
-- function to make the chainable preprocess method
-- @param name identifier for the method
-- @param func the code to be run
-- @return self to make the method chainable
local function makePreProcess( name, func )
base[name] = function( self )
self.preprocess[1+#self.preprocess] = { buildName( name ), func }
return self
end
end
-- preprocess a string to make it all upper case
-- @param str string that will be upper cased
-- @return string that is upper cased
makePreProcess( 'asUpper',
function ( str )
return str:upper()
end
)
-- @alias upper
base.upper = base.asUpper
-- @alias asUC
base.asUC = base.asUpper
-- @alias uc
base.uc = base.asUpper
-- preprocess a string to make it all lowerr case
-- @param str string that will be lower cased
-- @return string that is lower cased
makePreProcess( 'asLower',
function ( str )
return str:lower()
end
)
-- @alias lower
base.lower = base.asLower
-- @alias asLC
base.asLC = base.asLower
-- @alias lc
base.lc = base.asLower
-- preprocess a string to make the first letter upper case
-- @param str string that will have the first letter upper cased
-- @return string with the first letter upper cased
makePreProcess( 'asUpperFirst',
function ( str )
return str:sub(1,1):upper()..str:sub(2)
end
)
-- @alias upperfirst
base.upperfirst = base.asUpperFirst
-- @alias asUCFirst
base.asUCFirst = base.asUpperFirst
-- @alias asUcFirst
base.asUcFirst = base.asUpperFirst
-- @alias asUCfirst
base.asUCfirst = base.asUpperFirst
-- @alias ucfirst
base.ucfirst = base.asUpperFirst
-- preprocess a string to make the first letter lower case
-- @param str string that will have the first letter lower cased
-- @return string with the first letter lower cased
makePreProcess( 'asLowerFirst',
function ( str )
return str:sub(1,1):lower()..str:sub(2)
end
)
-- @alias lowerfirst
base.lowerfirst = base.asLowerFirst
-- @alias asLCFirst
base.asLCFirst = base.asLowerFirst
-- @alias asLcFirst
base.asLcFirst = base.asLowerFirst
-- @alias asLCfirst
base.asLCfirst = base.asLowerFirst
-- @alias lcfirst
base.lcfirst = base.asLowerFirst
-- preprocess a string with an identity function
-- @param str string that will be the source for the identity
-- @return string that is the identity
makePreProcess( 'asIdentity',
function ( str )
return str
end
)
-- @var list of postprocess functions
local postprocess = {}
-- function to make the chainable postprocess method
-- @param name identifier for the method
-- @param func the code to be run
-- @return self to make the method chainable
local function makePostProcess( name, func )
base[name] = function( self )
self.postprocess[1+#self.postprocess] = { buildName( name ), func }
return self
end
end
-- postprocess a value with an identity function
-- @param bool boolean that will be the source for the identity
-- @return boolean that is the identity
makePostProcess( 'whenIdentity',
function ( bool )
return bool
end
)
-- postprocess a value with an invert function
-- @param bool boolean that will be the source for the inversion
-- @return boolean that is the inverted
makePostProcess( 'whenInvert',
function ( bool )
return not bool
end
)
-- @alias invert
base.invert = base.whenInvert
-- @alias inverted
base.inverted = base.whenInvert
-- @alias whenInv
base.whenInv = base.whenInvert
-- @alias inv
base.inv = base.whenInvert
local similarity = {}
similarity['ifBoolean'] = function ( a ) return type( a ) == 'boolean' end
similarity['ifString'] = function ( a ) return type( a ) == 'string' end
similarity['ifNumber'] = function ( a ) return type( a ) == 'number' end
similarity['ifTable'] = function ( a ) return type( a ) == 'table' end
similarity['ifNil'] = function ( a ) return type( a ) == 'nil' end
similarity['ifFail'] = function ( a, b ) return false end
similarity['ifEqual'] = function ( a, b ) return a == b end
similarity['ifDeepEqual'] = function ( a, b ) return util.deepEqual( a, b) end
similarity['ifLesserThan'] = function ( a, b ) return a < b end
similarity['ifGreaterThan'] = function ( a, b ) return a > b end
similarity['ifLesserOrEqual'] = function ( a, b ) return a <= b end
similarity['ifGreaterOrEqual'] = function ( a, b ) return a >= b end
similarity['ifFalse'] = function ( a ) return type(a) == 'boolean' and a == false end
similarity['ifTrue'] = function ( a ) return type(a) == 'boolean' and a == true end
similarity['ifFalsy'] = function ( a ) return not a end
similarity['ifTruthy'] = function ( a ) return not not a end
similarity['ifFirstIdentity'] = function ( a, b ) return a end
similarity['ifSecondIdentity'] = function ( a, b ) return b end
similarity['ifMatch'] = function ( a, b ) local res = string.match(a, b); return res or false end
similarity['ifUstringMatch'] = function ( a, b ) local res = mw.ustring.match(a, b); return res or false end
similarity['ifContains'] = function ( a, b ) return not not util.contains( a, b ) end
similarity['ifCloseTo'] = function ( a, b, c ) return b-c <= a and a <= b+c end
for k,v in pairs( similarity ) do
base[k] = function( self )
self.similarity = { buildName(k), v }
return self
end
end
base.equal = base.ifEqual
base.deepequal = base.ifDeepEqual
base.ifLesser = base.ifLesserThan
base.ifLT = base.ifLesserThan
base.lesser = base.ifLesserThan
base.ifGreater = base.ifGreaterThan
base.ifGT = base.ifGreaterThan
base.greater = base.ifGreaterThan
base.ifLesserEqual = base.ifLesserOrEqual
base.ifLE = base.ifLesserOrEqual
base.lesserequal = base.ifLesserOrEqual
base.ifGreaterEqual = base.ifGreaterOrEqual
base.ifGE = base.ifGreaterOrEqual
base.greaterequal = base.ifGreaterOrEqual
base.False = base.ifFalse
base.True = base.ifTrue
base.Nil = base.ifNil
base.falsy = base.ifFalsy
base.truthy = base.ifTruthy
base.ifContain = base.ifContains
base.contains = base.ifContains
base.contain = base.ifContains
base.closeTo = base.ifCloseTo
local matchers = {}
matchers['toBe'] = false
matchers['toBeBoolean'] = similarity['ifBoolean']
matchers['toBeString'] = similarity['ifString']
matchers['toBeNumber'] = similarity['ifNumber']
matchers['toBeTable'] = similarity['ifTable']
matchers['toBeNil'] = similarity['ifNil']
matchers['toBeDeepEqual'] = similarity['ifDeepEqual']
matchers['toBeFailing'] = similarity['ifFail']
matchers['toBeLesserThan'] = similarity['ifLesserThan']
matchers['toBeGreaterThan'] = similarity['ifGreaterThan']
matchers['toBeLesserOrEqual'] = similarity['ifLesserOrEqual']
matchers['toBeGreaterOrEqual'] = similarity['ifGreaterOrEqual']
matchers['toBeFalsy'] = similarity['ifFalsy']
matchers['toBeTruthy'] = similarity['ifTruthy']
matchers['toBeFalse'] = similarity['ifFalse']
matchers['toBeTrue'] = similarity['ifTrue']
matchers['toBeFirstIdentity'] = similarity['ifFirstIdentity']
matchers['toBeSecondIdentity'] = similarity['ifSecondIdentity']
matchers['toBeMatch'] = similarity['ifMatch']
matchers['toBeUstringMatch'] = similarity['ifUstringMatch']
matchers['toBeContains'] = similarity['ifContains']
matchers['toBeCloseTo'] = similarity['ifCloseTo']
for k,v in pairs( matchers ) do
base[k] = function( self, ... )
if v then
self.similarity = { buildName(k), v }
end
local res = self.exec( ... )
-- @todo must be wrapped up and made html safe
-- str comes from the constructor
local rep = { "expect", self.str, { "params", ... }, {}, unpack( res ) }
if not res then
numFailed = 1+numFailed
--rep[1+#rep] = debug.traceback()
end
stack[1+#stack] = rep
return self
end
end
base.be = base.toBe
base.beFalsy = base.toBeFalsy
base.beTruthy = base.toBeTruthy
base.beNil = base.toBeNil
base.beMatch = base.toBeMatch
base.toMatch = base.toBeMatch
base.beEqual = base.toBe
base.toEqual = base.toBe
base.beDeepEqual = base.toBeDeepEqual
base.toDeepEqual = base.toBeDeepEqual
base.toContains = base.toBeContains
base.toContain = base.toBeContains
-- constructor for expectations
local function Expect( str, actual )
local self = {}
setmetatable(self, {__index = base})
-- @var list of processes to run before matching
self.preprocess = {}
self.str = str
self.actual = actual
-- @var similarity process
self.similarity = {
buildName('ifEqual'),
-- following is a default
function ( a, b ) return a == b end
}
-- @var list of processes to run after matching
self.postprocess = {}
function self.exec( ... )
local values = {}
-- this is the actual argument
values[1+#values] = { 'actual', self.actual }
--this is a chain of preprocesses
for _,v in ipairs( self.preprocess ) do
values[1+#values] = { v[1], v[2]( values[#values][2] ) }
end
local keep = #values
-- this is the anticipated result
values[1+#values] = { 'anticipated', ... }
-- this is the actual similarity test
values[1+#values] = { self.similarity[1], self.similarity[2]( values[keep][2], ... ) }
-- this is a chain of postprocesses
for _,v in ipairs( self.postprocess ) do
values[1+#values] = { v[1], v[2]( values[#values][2] ) }
end
return values
end
-- match for "throw"
-- @return table
function self.toThrow()
return self
end
-- match for "have been called"
-- @return table
function self.toHaveBeenCalled()
return self
end
-- match for "have been called with"
-- @return table
function self.toHaveBeenCalledWith()
return self
end
-- this is our instance
return self
end
-- @var This is the module structure returned from the test set
local wrap = {
-- the test runner
tests = function( frame )
--return mw.dumpObject( stack )
return View( stack, {numfailed=numFailed} ).format()
end,
categories = function(frame)
local cats = {
'[[Category:category-all-tests]]',
--'[[Category:'..g('category-all-tests')..']]',
}
return table.concat(cats, '\n')
end
}
local function stackTrace( msg )
local tbl = {}
tbl[1+#tbl] = "stack"
tbl[1+#tbl] = msg
-- @todo must be made html safe
tbl[1+#tbl] = mw.text.split( debug.traceback(), "\n\t", true )
stack[1+#stack] = tbl
end
local function exceptionTrace( msg )
local tbl = {}
tbl[1+#tbl] = "exception"
tbl[1+#tbl] = msg
-- @todo must be made html safe
tbl[1+#tbl] = mw.text.split( debug.traceback(), "\n\t", true )
stack[1+#stack] = tbl
end
-- this builds a frame and later wraps it up
local function frame( name )
-- build and return an appropriate function
return function( str, func )
-- statistics[1+#statistics] = { "accumulator", 0, 0 }
local depth = 1+#stack
stack[1+#stack] = name
stack[1+#stack] = str
xpcall( func, exceptionTrace )
local tbl = {}
for i=depth,#stack,1 do
tbl[1+#tbl] = stack[i]
stack[i] = nil
end
-- local last = statistics[#statistics]
--table.insert(tbl, 3, last)
--local prev = statistics[#statistics-1]
--prev[2] = prev[2]+last[2]
--prev[3] = prev[3]+last[3]
-- table.remove(statistics)
stack[1+#stack] = tbl
end
end
-- this builds a xframe and later wraps it up
local function xframe( name )
-- build and return an appropriate function
return function( str, func )
stack[1+#stack] = { name, str }
end
end
-- this builds spies by monkey patching
-- note that it seems impossible to store at a funcs location
-- @todo still needs to clean it up in a teardown, now it sticks
local function spy( name, trace )
-- build and return an appropriate function
return function( str, struct, meth )
local oldFunc = struct[meth]
struct[meth] = function(...)
-- @todo must be wrapped up and made html safe
stack[1+#stack] = { name, str, { "params", ... } }
if name == 'cluck' then
stackTrace( name )
elseif name == 'croak' then
error('croak')
end
return oldFunc(...)
end
end
end
-- @var This is the functions exposed for the tests
local members = {
describe = frame('describe'),
context = frame('context'),
it = frame('it'),
test = it,
pending = xframe('pending'),
xdescribe = xframe('xdescribe'),
xcontext = xframe('xcontext'),
xit = xframe('xit'),
xtest = xit,
-- after,
-- before,
expect = Expect,
carp = spy("carp"),
cluck = spy("cluck", true),
croak = spy("croak"),
confess = croak,
view = View,
result = function(head)
stack[2] = head
return wrap
end,
stack = function()
return stack
end,
similarity = function()
return similarity
end
}
return members
end
-- metatable for the export
local mt = {
-- adjust the installation of the module
__call = function ()
_G['_BDD_TEST'] = true
local t = BDD()
for k,v in pairs(t) do
_G[k] = v
end
return bdd
end
}
-- install the metatable
setmetatable(bdd, mt)
return bdd