diff --git a/README.md b/README.md index 2145091..9468b16 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ a simple specification framework for [Loom][loom-sdk] Download the library into its matching sdk folder: $ curl -L -o ~/.loom/sdks/sprint34/libs/Spec.loomlib \ - https://github.com/pixeldroid/spec-ls/releases/download/v1.3.1/Spec-sprint34.loomlib + https://github.com/pixeldroid/spec-ls/releases/download/v2.0.0/Spec-sprint34.loomlib To uninstall, simply delete the file: @@ -25,9 +25,17 @@ To uninstall, simply delete the file: ## usage -0. import Spec, a Reporter, and your specifications -0. have the specifications describe themselves to Spec -0. add your reporter(s) to Spec and execute +### in a nutshell + +0. import `Spec`, one or more `Reporter`s, and one or more specifications +0. add the reporter(s) to an instance of `Spec` +0. in the specifications, describe the desired behavior of the thing they validate + * `Spec.describe()` instantiates a `Thing` + * `Thing.should()` declares a requirement function + - in the function, `expects()` and `asserts()` validate the requirement +0. execute the spec to see results from the reporter + +### simple example ```ls package @@ -37,31 +45,49 @@ package import pixeldroid.bdd.Spec; import pixeldroid.bdd.reporters.ConsoleReporter; + import WidgetSpec; + public class SpecTest extends Application { override public function run():void { - MySpec.describe(); + var spec:Spec = new Spec(); + spec.addReporter(new ConsoleReporter()); - Spec.addReporter(new ConsoleReporter()); - Spec.execute(); + WidgetSpec.specify(spec); + + spec.execute(); } } + import pixeldroid.bdd.Spec; import pixeldroid.bdd.Thing; - public static class MySpec + public static class WidgetSpec { - public static function describe():void + private static var it:Thing; + + public static function specify(specifier:Spec):void + { + it = specifier.describe('Widget'); + + it.should('be versioned', be_versioned); + it.should('contain three thingamajigs when initialized', have_three_thingamajigs); + } + + private static function be_versioned():void { - var it:Thing = Spec.describe('a Thing'); + it.expects(Widget.version).toPatternMatch('(%d+).(%d+).(%d+)', 3); + } - it.should('exist', function() { - it.expects(MySpec).not.toBeNull(); - }); + private static function have_three_thingamajigs():void + { + // assert before array access to avoid out-of-bounds error + it.asserts(Widget.thingamajigs.length).isEqualTo(3).or('Widget initialized without three thingamajigs'); + it.expects(Widget.thingamajigs[2]).isTypeOf(Sprocket).or('Third thingamajig not a Sprocket'); } } @@ -70,25 +96,44 @@ package > **TIP**: use [SpecExecutor][SpecExecutor.ls]; it has convenience methods to set reporter formats and seed values. See [SpecTest][SpecTest.ls] for an example. -### matchers +### expectations + +spec-ls provides a set of expectation tests for specifying behavior: -spec-ls has a basic set of expectation phrases for specifying behavior: +`it.expects(value:Object)` -* `toBeA(type:Type)` -* `toBeEmpty()` -* `toBeFalsey()` / `toBeTruthy()` -* `toBeGreaterThan(value2:Number)` / `toBeLessThan(value2:Number)` -* `toBeNaN()` -* `toBeNull()` -* `toBePlusOrMinus(absoluteDelta:Number).from(value2:Number)` -* `toContain(value2:Object)` -* `toEndWith(value2:String)` / `toStartWith(value2:String)` -* `toEqual(value2:Object)` -* `toPatternMatch(value2:String, matches:Number=1)` +* `.toBeA(type:Type)` +* `.toBeEmpty()` +* `.toBeFalsey()` / `toBeTruthy()` +* `.toBeGreaterThan(value2:Number)` / `toBeLessThan(value2:Number)` +* `.toBeNaN()` +* `.toBeNull()` +* `.toBePlusOrMinus(absoluteDelta:Number).from(value2:Number)` +* `.toContain(value2:Object)` +* `.toEndWith(value2:String)` / `toStartWith(value2:String)` +* `.toEqual(value2:Object)` +* `.toPatternMatch(value2:String, matches:Number=1)` they are defined in [Matcher.ls][Matcher.ls]; you can see them used in the specifications for spec-ls itself: [ExpectationSpec][ExpectationSpec.ls] +### assertions + +spec-ls provides a set of assertion tests for mandating test pre-conditions and aborting on violation: + +`it.asserts(value:Object)` + +* `.isNotNaN().or('value was NaN')` +* `.isNull().or('value was not null')` / `.isNotNull().or('value was null')` +* `.isEmpty().or('value was not empty')` / `.isNotEmpty().or('value was empty')` +* `.isEqualTo(value2).or('value was not equal to value2')` / `.isNotEqualTo(value2).or('value was equal to value2')` +* `.isGreaterThan(value2).or('value was not greater than value2')` +* `.isLessThan(value2).or('value was not less than value2')` +* `.isTypeOf(type).or('value was not a kind of type')` + +they are defined in [Assertion.ls][Assertion.ls]; +you can see them used in the specifications for spec-ls itself: [AssertionSpec][AssertionSpec.ls] + ### reporters spec-ls ships with three reporters: @@ -101,7 +146,7 @@ spec-ls ships with three reporters: ### random seed -by default, Spec will execute tests in a different random order every time, to guard against accidental order dependencies. +by default, Spec will execute tests in a different random order every time, to guard against hidden dependencies. to reproduce the order of a specific run, pass in the same seed value to `Spec.execute()`: @@ -140,6 +185,8 @@ this will build the Spec library, install it in the currently configured sdk, bu Pull requests are welcome! +[Assertion.ls]: lib/src/pixeldroid/bdd/Assertion.ls "Assertion.ls" +[AssertionSpec.ls]: test/src/spec/AssertionSpec.ls "AssertionSpec.ls" [ExpectationSpec.ls]: test/src/spec/ExpectationSpec.ls "ExpectationSpec.ls" [loom-sdk]: https://github.com/LoomSDK/LoomSDK "a native mobile app and game framework" [loomtasks]: https://github.com/pixeldroid/loomtasks "Rake tasks for working with loomlibs" diff --git a/lib/src/Spec.build b/lib/src/Spec.build index 6669495..074e330 100644 --- a/lib/src/Spec.build +++ b/lib/src/Spec.build @@ -1,6 +1,6 @@ { "name": "Spec", - "version": "1.3.1", + "version": "2.0.0", "outputDir": "./build", "references": [ "System" @@ -8,7 +8,7 @@ "modules": [ { "name": "Spec", - "version": "1.3.1", + "version": "2.0.0", "sourcePath": [ "." ] diff --git a/lib/src/pixeldroid/ansi/ANSI.ls b/lib/src/pixeldroid/ansi/ANSI.ls index 0649aa4..67e140f 100644 --- a/lib/src/pixeldroid/ansi/ANSI.ls +++ b/lib/src/pixeldroid/ansi/ANSI.ls @@ -74,7 +74,7 @@ package pixeldroid.ansi private function addSGR(n:Number, args:String=''):ANSI { // SGR codes follow the CSIx[;y][;z]m format - _string = _string.concat([code_begin, n, args, sgr_end]); + _string += (code_begin +n +args +sgr_end); return this; } @@ -82,7 +82,7 @@ package pixeldroid.ansi private function addCode(code:String):ANSI { // regular codes are just CSIx format - _string = _string.concat([code_begin, code]); + _string += (code_begin +code); return this; } diff --git a/lib/src/pixeldroid/bdd/Assertion.ls b/lib/src/pixeldroid/bdd/Assertion.ls new file mode 100644 index 0000000..8fdb78c --- /dev/null +++ b/lib/src/pixeldroid/bdd/Assertion.ls @@ -0,0 +1,173 @@ + +package pixeldroid.bdd +{ + import system.Debug; + import system.Process; + import system.reflection.Type; + + import pixeldroid.bdd.Does; + import pixeldroid.bdd.models.Requirement; + import pixeldroid.platform.CallUtils; + + + public class Assertion + { + private var value1:Object; + private var source:String; + private var line:Number; + private var method:String; + + public static const defaultAsserter:Asserter = new ExitAsserter(); + public static var asserter:Asserter = defaultAsserter; + + + public function Assertion(context:Requirement, value:Object) + { + value1 = value; + source = context.currentCallInfo.source; + line = context.currentCallInfo.line; + method = context.currentCallInfo.method.getName(); + } + + protected function getAsserter(condition:Boolean):Asserter + { + var a:Asserter = asserter.getType().getConstructor().invoke() as Asserter; + a.init(condition, source, line, method); + + return a; + } + + + public function isNotNaN():Asserter + { + var condition:Boolean = (!isNaN(value1 as Number)); + return getAsserter(condition); + } + + public function isNull():Asserter + { + var condition:Boolean = (value1 == null); + return getAsserter(condition); + } + + public function isNotNull():Asserter + { + var condition:Boolean = (value1 != null); + return getAsserter(condition); + } + + public function isEmpty():Asserter + { + var condition:Boolean; + + if (Does.typeMatch(value1, String)) + { + var s:String = value1 as String; + condition = (s.length == 0); + } + else if (Does.typeMatch(value1, Vector)) + { + var vector:Vector = value1 as Vector; + condition = (vector.length == 0); + } + else + { + (getAsserter(false)).or('value provided is not a container'); + } + + return getAsserter(condition); + } + + public function isNotEmpty():Asserter + { + var condition:Boolean; + + if (Does.typeMatch(value1, String)) + { + var s:String = value1 as String; + condition = (s.length > 0); + } + else if (Does.typeMatch(value1, Vector)) + { + var vector:Vector = value1 as Vector; + condition = (vector.length > 0); + } + else + { + (getAsserter(false)).or('value provided is not a container'); + } + + return getAsserter(condition); + } + + public function isEqualTo(value2:Object):Asserter + { + var condition:Boolean = (value1 == value2); + return getAsserter(condition); + } + + public function isNotEqualTo(value2:Object):Asserter + { + var condition:Boolean = (value1 != value2); + return getAsserter(condition); + } + + public function isGreaterThan(value2:Number):Asserter + { + var condition:Boolean = (value1 > value2); + return getAsserter(condition); + } + + public function isLessThan(value2:Number):Asserter + { + var condition:Boolean = (value1 < value2); + return getAsserter(condition); + } + + public function isTypeOf(type:Type):Asserter + { + var condition:Boolean = (Does.typeMatch(value1, type) || Does.subtypeMatch(value1, type)); + return getAsserter(condition); + } + + + } + + + public interface Asserter + { + function init(condition:Boolean, source:String, line:Number, method:String):void; + function or(message:String):Boolean; + } + + public class ExitAsserter implements Asserter + { + private static const FAILURE:Number = 1; + + private var condition:Boolean; + private var source:String; + private var line:Number; + private var method:String; + + public function init(condition:Boolean, source:String, line:Number, method:String):void + { + this.condition = condition; + this.source = source; + this.line = line; + this.method = method; + } + + public function or(message:String):Boolean + { + if (!condition) + { + trace('runtime assertion failed:', message); + trace(source +'(' +method +'):' +line); + + Process.exit(FAILURE); + } + + return true; + } + } +} diff --git a/lib/src/pixeldroid/bdd/Does.ls b/lib/src/pixeldroid/bdd/Does.ls new file mode 100644 index 0000000..dae2e1b --- /dev/null +++ b/lib/src/pixeldroid/bdd/Does.ls @@ -0,0 +1,38 @@ + +package pixeldroid.bdd +{ + static final class Does + { + + public static function typeMatch(value:Object, type:Type):Boolean + { + return (value.getFullTypeName() == type.getFullName()); + } + + public static function subtypeMatch(value:Object, type:Type):Boolean + { + return ((value instanceof type) || (value is type) || ((value as type) != null)); + } + + public static function vectorEndWith(vector:Vector., item:Object):Boolean + { + return (vector[vector.length - 1] == item); + } + + public static function vectorStartWith(vector:Vector., item:Object):Boolean + { + return (vector[0] == item); + } + + public static function stringEndWith(string1:String, string2:String):Boolean + { + return (string1.indexOf(string2) == (string1.length - string2.length)); + } + + public static function stringStartWith(string1:String, string2:String):Boolean + { + return (string1.indexOf(string2) == 0); + } + + } +} diff --git a/lib/src/pixeldroid/bdd/Matcher.ls b/lib/src/pixeldroid/bdd/Expectation.ls similarity index 89% rename from lib/src/pixeldroid/bdd/Matcher.ls rename to lib/src/pixeldroid/bdd/Expectation.ls index 15f4410..5af7f10 100644 --- a/lib/src/pixeldroid/bdd/Matcher.ls +++ b/lib/src/pixeldroid/bdd/Expectation.ls @@ -1,35 +1,41 @@ package pixeldroid.bdd { - import pixeldroid.bdd.Thing; + import pixeldroid.bdd.Does; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.MatchResult; - public class Matcher + + public class Expectation { private var positive:Boolean = true; private var absoluteDelta:Number = 0; - private var context:Thing; + private var context:Requirement; private var result:MatchResult; private var value:Object; - public function Matcher(context:Thing, value:Object) + public function Expectation(context:Requirement, value:Object) { this.context = context; this.value = value; + result = new MatchResult(); + result.source = context.currentCallInfo.source; + result.line = context.currentCallInfo.line; + result.method = context.currentCallInfo.method.getName(); } // modifiers - public function get not():Matcher + public function get not():Expectation { positive = !positive; return this; } - public function toBePlusOrMinus(absoluteDelta:Number):Matcher // used with from() + public function toBePlusOrMinus(absoluteDelta:Number):Expectation // used with from() { this.absoluteDelta = absoluteDelta; return this; @@ -41,7 +47,7 @@ package pixeldroid.bdd { result.description = value.getFullTypeName() +" " +rectifiedPrefix("toBeA") +" " +type.getFullName(); - var match:Boolean = (isTypeMatch(value, type) || isSubtypeMatch(value, type)); + var match:Boolean = (Does.typeMatch(value, type) || Does.subtypeMatch(value, type)); result.success = rectifiedMatch( match ); if (!result.success) result.message = "types " +rectifiedSuffix("do", true) +" match."; @@ -112,7 +118,7 @@ package pixeldroid.bdd public function toBeEmpty():void { - if (isTypeMatch(value, String)) + if (Does.typeMatch(value, String)) { var s:String = value as String; @@ -121,7 +127,7 @@ package pixeldroid.bdd result.success = rectifiedMatch( (s.length == 0) ); if (!result.success) result.message = "String " +rectifiedSuffix("is", true) +" empty."; } - else if (isTypeMatch(value, Vector)) + else if (Does.typeMatch(value, Vector)) { var vector:Vector = value as Vector; @@ -140,7 +146,7 @@ package pixeldroid.bdd public function toContain(value2:Object):void { - if (isTypeMatch(value, String)) + if (Does.typeMatch(value, String)) { var string1:String = value as String; var string2:String = value2 as String; @@ -150,7 +156,7 @@ package pixeldroid.bdd result.success = rectifiedMatch( (string1.indexOf(string2) > -1) ); if (!result.success) result.message = "String " +rectifiedSuffix("does", true) +" contain '" +string2 +"'."; } - else if (isTypeMatch(value, Vector)) + else if (Does.typeMatch(value, Vector)) { var vector:Vector = value as Vector; @@ -182,7 +188,7 @@ package pixeldroid.bdd // Loom uses Lua regex patterns: // http://lua-users.org/wiki/PatternsTutorial // http://www.lua.org/manual/5.2/manual.html#6.4.1 - if (isTypeMatch(value, String)) + if (Does.typeMatch(value, String)) { var string1:String = value as String; var string2:String = value2 as String; @@ -204,14 +210,14 @@ package pixeldroid.bdd public function toStartWith(value2:String):void { - if (isTypeMatch(value, String)) + if (Does.typeMatch(value, String)) { var string1:String = value as String; var string2:String = value2 as String; result.description = "'" +string1 +"' " +rectifiedPrefix("toStartWith") +" '" +string2 +"'"; - result.success = rectifiedMatch( (string1.indexOf(string2) == 0) ); + result.success = rectifiedMatch( Does.stringStartWith(string1, string2) ); if (!result.success) result.message = "String " +rectifiedSuffix("does", true) +" start with '" +string2 +"'."; } else @@ -224,14 +230,14 @@ package pixeldroid.bdd public function toEndWith(value2:String):void { - if (isTypeMatch(value, String)) + if (Does.typeMatch(value, String)) { var string1:String = value as String; var string2:String = value2 as String; result.description = "'" +string1 +"' " +rectifiedPrefix("toEndWith") +" '" +string2 +"'"; - result.success = rectifiedMatch( (string1.indexOf(string2) == (string1.length - string2.length)) ); + result.success = rectifiedMatch( Does.stringEndWith(string1, string2) ); if (!result.success) result.message = "String " +rectifiedSuffix("does", true) +" end with '" +string2 +"'."; } else @@ -254,16 +260,6 @@ package pixeldroid.bdd // helpers - private function isTypeMatch(value:Object, type:Type):Boolean - { - return (value.getFullTypeName() == type.getFullName()); - } - - private function isSubtypeMatch(value:Object, type:Type):Boolean - { - return ((value instanceof type) || (value is type) || ((value as type) != null)); - } - private function rectifiedSuffix(phrase:String, flipped:Boolean = false):String { if (flipped) return (!positive ? phrase : phrase +' not'); diff --git a/lib/src/pixeldroid/bdd/Reporter.ls b/lib/src/pixeldroid/bdd/Reporter.ls index 648b96a..9b0c1ff 100644 --- a/lib/src/pixeldroid/bdd/Reporter.ls +++ b/lib/src/pixeldroid/bdd/Reporter.ls @@ -1,14 +1,63 @@ package pixeldroid.bdd { - import pixeldroid.bdd.models.Expectation; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.SpecInfo; + /** + Documents the results of executing a specification. + */ public interface Reporter { + /** + Initialize the reporting session with information about the specification framework. + + Called once before any validation begins. + + @param specInfo Metadata about the specification framework + */ function init(specInfo:SpecInfo):void; + + /** + Start the reporting session for a single specification. + + Called once per spec when testing begins, to provide the name of the test subject and + the total number of expectations to be validated. + + @param name Subject of the test; the thing being described + @param total Number of expectations to be tested + */ function begin(name:String, total:Number):void; - function report(expectation:Expectation, durationSec:Number, index:Number, total:Number):void; - function end(name:String, duration:Number):Boolean; + + /** + Record the results of one requirement. + + Called once per each requirement to be tested. + + @param requirement The requirement tested. Provides access to the `MatchResult` + @param durationSec Seconds elapsed during the execution of this requirement. Floating point value. + @param index Zero-based index of the requirement being reported. Range is [0, total-1] + @param total Number of expectations to be tested + */ + function report(requirement:Requirement, durationSec:Number, index:Number, total:Number):void; + + /** + Complete the reporting session for a single specification. + + Called once per spec after all expectations have been validated. + + @param name Subject of the test; the thing being described + @param durationSec Total length of the spec validation, in seconds. Floating point value. + */ + function end(name:String, durationSec:Number):Boolean; + + /** + Finalize the reporting session altogether. + + Called once after all expectations of all specifications have been validated. + + @param durationSec Total length of the full test of all specifications provided, in seconds. Floating point value. + */ + function finalize(durationSec:Number):void; } } diff --git a/lib/src/pixeldroid/bdd/Spec.ls b/lib/src/pixeldroid/bdd/Spec.ls index 4a5fcab..fc9b677 100644 --- a/lib/src/pixeldroid/bdd/Spec.ls +++ b/lib/src/pixeldroid/bdd/Spec.ls @@ -4,19 +4,25 @@ package pixeldroid.bdd import pixeldroid.bdd.Reporter; import pixeldroid.bdd.Thing; + import pixeldroid.bdd.ThingValidator; import pixeldroid.bdd.models.SpecInfo; import pixeldroid.bdd.reporters.ReporterManager; import pixeldroid.random.Randomizer; + import system.platform.Platform; + public class Spec { - public static const version:String = '1.3.1'; + public static const version:String = '2.0.0'; + + private const things:Vector. = []; + private const validator:ThingValidator = new ThingValidator(); + private const reporters:ReporterManager = new ReporterManager(); - private static var things:Vector. = []; - private static var reporters:ReporterManager = new ReporterManager(); + public function Spec() { } - public static function describe(thingName:String):Thing + public function describe(thingName:String):Thing { var thing:Thing = new Thing(thingName); things.push(thing); @@ -24,31 +30,34 @@ package pixeldroid.bdd return thing; } - public static function addReporter(reporter:Reporter):void + public function addReporter(reporter:Reporter):void { if (reporter) reporters.add(reporter); } - public static function get numReporters():Number + public function get numReporters():Number { return reporters.length; } - public static function execute(seed:Number=-1):Boolean + public function execute(seed:Number=-1):Boolean { - seed = Randomizer.initialize(seed); - Randomizer.shuffle(things); + Debug.assert((numReporters > 0), 'must add at least one reporter to execute a Spec'); + var startTimeMs:Number = Platform.getTime(); var success:Boolean = true; + seed = Randomizer.initialize(seed); + Randomizer.shuffle(things); + reporters.init(new SpecInfo('Spec', version, seed)); - var i:Number; - var n:Number = things.length; - for(i = 0; i < n; i++) - { - if (!things[i].execute(reporters)) success = false; - } + for each(var thing:Thing in things) + if (!validator.validate(thing, reporters)) success = false; + + reporters.finalize((Platform.getTime() - startTimeMs) * .001); + + things.clear(); return success; } diff --git a/lib/src/pixeldroid/bdd/SpecExecutor.ls b/lib/src/pixeldroid/bdd/SpecExecutor.ls index cd2fdb5..29287fe 100644 --- a/lib/src/pixeldroid/bdd/SpecExecutor.ls +++ b/lib/src/pixeldroid/bdd/SpecExecutor.ls @@ -18,30 +18,36 @@ package pixeldroid.bdd public static const FORMAT_CONSOLE:String = 'console'; public static const FORMAT_JUNIT:String = 'junit'; + public static const SPECIFIER_METHOD:String = 'specify'; + public static var seed:Number = -1; private static const SUCCESS:Number = 0; private static const FAILURE:Number = 1; + private static var _specifier:Spec; + public static function addFormat(format:String):void { - Spec.addReporter(reporterByName(format)); + var spec:Spec = specifier; + spec.addReporter(reporterByName(format)); } public static function exec(specs:Vector.):Number { - if (Spec.numReporters == 0) Spec.addReporter(new ConsoleReporter()); + var spec:Spec = specifier; + if (spec.numReporters == 0) spec.addReporter(reporterByName(FORMAT_CONSOLE)); var method:MethodInfo; - for each(var spec:Type in specs) + for each(var type:Type in specs) { - method = spec.getMethodInfoByName('describe'); - Debug.assert(method, 'Could not find describe method on class' +spec.getFullName()); - method.invokeSingle(spec, null); + method = type.getMethodInfoByName(SPECIFIER_METHOD); + Debug.assert(method, 'Could not find method named "' +SPECIFIER_METHOD +'" on class ' +type.getFullName()); + method.invokeSingle(type, spec); } - return Spec.execute(seed) ? SUCCESS : FAILURE; + return spec.execute(seed) ? SUCCESS : FAILURE; } public static function parseArgs():void @@ -56,6 +62,12 @@ package pixeldroid.bdd } + private static function get specifier():Spec + { + if (!_specifier) _specifier = new Spec(); + return _specifier; + } + private static function reporterByName(name:String):Reporter { var r:Reporter; diff --git a/lib/src/pixeldroid/bdd/Thing.ls b/lib/src/pixeldroid/bdd/Thing.ls index 10d36ee..1d401c8 100644 --- a/lib/src/pixeldroid/bdd/Thing.ls +++ b/lib/src/pixeldroid/bdd/Thing.ls @@ -1,77 +1,123 @@ package pixeldroid.bdd { - import pixeldroid.bdd.Matcher; - import pixeldroid.bdd.Reporter; - import pixeldroid.bdd.models.Expectation; - import pixeldroid.bdd.models.MatchResult; - import pixeldroid.random.Randomizer; + import pixeldroid.bdd.Expectation; + import pixeldroid.bdd.ThingValidator; + import pixeldroid.bdd.models.Requirement; + import pixeldroid.platform.CallUtils; - import system.platform.Platform; + import system.CallStackInfo; - public class Thing - { - private var name:String = ''; - private var expectations:Vector. = []; - private var currentExpectation:Expectation; - private var startTimeMs:Number; + /** + A test subject whose behavior will be described through requirements. + Use the `should` method to describe a desired behavior and a function that + validates the behavior with one or more calls to the `expects` method: - public function Thing(name:String) + ```as3 + public static class SampleSpec + { + private static const it:Thing; + + public static function specify(specifier:Spec):void { - this.name = name; + it = specifier.describe('Sample'); + it.should('be enabled by default', initialize_enabled); } - - public function should(declaration:String, validation:Function):void + private static function initialize_enabled():void { - expectations.push(new Expectation(declaration, validation)); + var sample:Sample = new TestSample(); + it.expects(sample.enabled).toBeTruthy(); } + } + ``` - public function execute(reporter:Reporter):Boolean - { - startTimeMs = Platform.getTime(); - Randomizer.shuffle(expectations); + An `asserts` method is also provided, with an intended use of + ensuring expectations have valid data to run, e.g.: + + ```as3 + private static function initialize_with_c_third():void + { + var sample:Sample = new TestSample(); + it.asserts(sample.letters.length).isEqualTo(3).or('letters does not contain three items'); + it.expects(sample.letters[2]).toEqual('c'); + } + ``` + + As such, assertions don't test behavior and are not included in the test results. + */ + public class Thing + { + private var _name:String = ''; + private var validator:ThingValidator; + private const requirements:Vector. = []; - var e:Expectation; - var i:Number; - var n:Number = expectations.length; - var ms:Number; - reporter.begin(name, n); + /** + Instantiate a named test subject. - for (i = 0; i < n; i++) - { - ms = Platform.getTime(); + @param name Name of the test subject (typically its class name) + */ + public function Thing(name:String) + { + _name = name; + } - currentExpectation = expectations[i]; + /** Retrieve the name of the test subject. */ + public function get name():String { return _name; } - // run the validation closure, which has captured this instance in its scope - // it will call in to expects(), which will pass flow on to Matcher - // which will call addResult() - currentExpectation.test(); + /** + Declare an expectation about the behavior of this test subject. - reporter.report(currentExpectation, (Platform.getTime() - ms) * .001, i, n); - } + Declarations should begin with a present tense modal verb that completes the + sentence fragment: "It should ____". - currentExpectation = null; + Validations must include at least one call to `expects` to validate the behavior. - return reporter.end(name, (Platform.getTime() - startTimeMs) * .001); + @param declaration Statement that describes valid behavior + @param validation Function that tests for the desired bahavior, using `expects()` + */ + public function should(declaration:String, validation:Function):void + { + Debug.assert(validation, 'validation function must not be null'); + requirements.push(new Requirement(declaration, validation)); } + /** + provide requirements to a validator for testing. - /// used by closures - public function expects(value:Object):Matcher + @param validator A test execution engine that can process the requirements of this subject's behavior + */ + public function submitForValidation(validator:ThingValidator):void { - var matcher:Matcher = new Matcher(this, value); - return matcher; + this.validator = validator; + this.validator.setRequirements(requirements); } - public function addResult(result:MatchResult):void + /** + Ensure a critical condition is true, or else abort application execution and log an error message to the console. + + @param value A value to set an assertion for + */ + public function asserts(value:Object):Assertion { - currentExpectation.addResult(result); + Debug.assert(validator, 'validator must be initialized via submitForValidation'); + var csi:CallStackInfo = CallUtils.getPriorStackFrame(); + return validator.getAssertion(value, csi); } + /** + Start a value matching chain to compare the provided value to the results of one or more `Expectation`. + + @param value A value to set requirements for + */ + public function expects(value:Object):Expectation + { + Debug.assert(validator, 'validator must be initialized via submitForValidation'); + var csi:CallStackInfo = CallUtils.getPriorStackFrame(); + return validator.getExpectation(value, csi); + } } } diff --git a/lib/src/pixeldroid/bdd/ThingValidator.ls b/lib/src/pixeldroid/bdd/ThingValidator.ls new file mode 100644 index 0000000..79d848a --- /dev/null +++ b/lib/src/pixeldroid/bdd/ThingValidator.ls @@ -0,0 +1,109 @@ + +package pixeldroid.bdd +{ + import pixeldroid.bdd.Assertion; + import pixeldroid.bdd.Expectation; + import pixeldroid.bdd.Reporter; + import pixeldroid.bdd.Thing; + import pixeldroid.bdd.models.Requirement; + import pixeldroid.bdd.models.MatchResult; + import pixeldroid.random.Randomizer; + + import system.CallStackInfo; + import system.platform.Platform; + + + /** + */ + public class ThingValidator + { + private var requirements:Vector.; + private var currentRequirement:Requirement; + private var startTimeMs:Number; + + /** + Provide the requirements to be validated. + + @param value Vector of Requirement instances to be tested + */ + public function setRequirements(value:Vector.):void { requirements = value; } + + /** + Retrieve an assertion for the requirement currently under validation. + + @param value A value to provide to the Assertion + @param csi Reflection info for the calling validation method + */ + public function getAssertion(value:Object, csi:CallStackInfo):Assertion + { + Debug.assert(currentRequirement, 'requirement must be declared via should() prior to calling asserts()'); + currentRequirement.currentCallInfo = csi; + + return new Assertion(currentRequirement, value); + } + + /** + Retrieve an expectation for the requirement currently under validation. + + @param value A value to provide to the Expectation + @param csi Reflection info for the calling validation method + */ + public function getExpectation(value:Object, csi:CallStackInfo):Expectation + { + Debug.assert(currentRequirement, 'requirement must be declared via should() prior to calling expects()'); + currentRequirement.currentCallInfo = csi; + + return new Expectation(currentRequirement, value); + } + + /** + Test all requirements currently described. Progress and results will be sent to the provided reporter. + + @param reporter An implementor of the `Reporter` interface, to receive real-time progress updates and final test results + */ + public function validate(thing:Thing, reporter:Reporter):Boolean + { + thing.submitForValidation(this); + var n:Number = requirements.length; + if (n == 0) return true; + + startTimeMs = Platform.getTime(); + Randomizer.shuffle(requirements); + + var i:Number; + var ms:Number; + + reporter.begin(thing.name, n); + + for (i = 0; i < n; i++) + { + ms = Platform.getTime(); + currentRequirement = requirements[i]; + + // run the validation closure `.test()`, which has captured a Thing instance in its scope + // it will call in to Thing.expects(), + // which will call getExpectation() to retrieve an Expectation linked to the current requirement + // which will process the expectation and call addResult() on the current requirement + currentRequirement.test(); + + if (currentRequirement.numResults == 0) + { + var noop:MatchResult = new MatchResult(); + noop.success = false; + noop.description = 'nothing'; + noop.message = 'requirements must test something'; + currentRequirement.addResult(noop); + } + + // result is added, so can now report it + reporter.report(currentRequirement, (Platform.getTime() - ms) * .001, i, n); + } + + currentRequirement = null; + requirements.clear(); + + return reporter.end(thing.name, (Platform.getTime() - startTimeMs) * .001); + } + + } +} diff --git a/lib/src/pixeldroid/bdd/models/Expectation.ls b/lib/src/pixeldroid/bdd/models/Expectation.ls deleted file mode 100644 index 148dbb2..0000000 --- a/lib/src/pixeldroid/bdd/models/Expectation.ls +++ /dev/null @@ -1,46 +0,0 @@ - -package pixeldroid.bdd.models -{ - import pixeldroid.bdd.models.MatchResult; - - public class Expectation - { - private var _description:String; - private var validation:Function; - private var results:Vector.; - - - public function Expectation(description:String, validation:Function) - { - _description = description; - this.validation = validation; - results = []; - } - - - public function test():void - { - validation(); - } - - public function addResult(result:MatchResult):void - { - results.push(result); - } - - public function getResult(i:Number):MatchResult - { - return results[i]; - } - - public function get numResults():Number - { - return results.length; - } - - public function get description():String - { - return _description; - } - } -} diff --git a/lib/src/pixeldroid/bdd/models/MatchResult.ls b/lib/src/pixeldroid/bdd/models/MatchResult.ls index 7823176..5d6083b 100644 --- a/lib/src/pixeldroid/bdd/models/MatchResult.ls +++ b/lib/src/pixeldroid/bdd/models/MatchResult.ls @@ -1,15 +1,48 @@ package pixeldroid.bdd.models { + + /** + Enscapsulates the components of an expectation comparison result. + */ public class MatchResult { + /** Path to source file containing expectation declaration */ + public var source:String = null; + + /** Line number of expectation declaration in source file */ + public var line:Number = -1; + + /** Method name of expectation declaration in source file */ + public var method:String = null; + + /** Human readable description of the expectation */ public var description:String = ''; + + /** `true` when comparison succeeded, `false` for failure */ public var success:Boolean = true; + + /** Error message from the match comparison, if any. */ public var message:String = ''; + /** + Query for the presence of an error message. + + `true` when the result has an error message, `false` when no non-empty message exists. + */ public function hasMessage():Boolean { return (message && (message.length > 0)); } + + /** + Retrieve a formatted call trace showing source, method and line number of the validation call that generated this result. + + e.g. `./src/spec//AssertionSpec.ls(provide_type_assertion):89` + */ + public function get callTrace():String + { + return (source +'(' +method +'):' +line); + } } } diff --git a/lib/src/pixeldroid/bdd/models/Requirement.ls b/lib/src/pixeldroid/bdd/models/Requirement.ls new file mode 100644 index 0000000..1b86d2e --- /dev/null +++ b/lib/src/pixeldroid/bdd/models/Requirement.ls @@ -0,0 +1,104 @@ + +package pixeldroid.bdd.models +{ + import pixeldroid.bdd.models.MatchResult; + + import system.CallStackInfo; + + + /** + Represents the description and validation of a requirement; collects results from the validation execution. + + Requirements are created by calling the `should()` method of an instance of `Thing`. + The validation of the requirement must contain calls to the `expects()` and `asserts()` methods of the same `Thing`. + + @see pixeldroid.bdd.Thing#should + @see pixeldroid.bdd.Thing#asserts + @see pixeldroid.bdd.Thing#expects + */ + public class Requirement + { + private var _description:String; + private var validation:Function; + private var results:Vector.; + + public var currentCallInfo:CallStackInfo; + + + /** + Create an requirement from a description and validation function. + + _Opinion:_ Descriptions should begin with a present tense modal verb that completes the + sentence fragment: "It should ____". Do not include 'should' in the description, + since the invocation method is called `should()`. + + Example: + + ```as3 + it.should('describe the expected behavior', describe_behavior); + it.should('start with a modal verb', start_with_verb); + it.should('be singular in focus', easy_to_test); + ``` + + @param description A phrase describing desired behavior that completes the sentence fragment: "It should ____" + @param validation A function that tests whether the desired behavior is exhibited + */ + public function Requirement(description:String, validation:Function) + { + _description = description; + this.validation = validation; + results = []; + } + + + /** + Run the validation. + + This will cause results to be added. + */ + public function test():void + { + validation(); + } + + /** + Add the result of an expectation tested during execution of the validation. + + @param result A `MatchResult` instance generated during the execution of an expectation test chain. + */ + public function addResult(result:MatchResult):void + { + results.push(result); + } + + /** + Retrieve the `i`th result. + + @param i Index of the result to retrieve. Valid range is [0, numResults-1]. + */ + public function getResult(i:Number):MatchResult + { + return results[i]; + } + + /** + Query the number of results currently added to the expectation. + + Results are added when the `test()` method is called. + */ + public function get numResults():Number + { + return results.length; + } + + /** + Retrieve the description provided for this expectation. + + A phrase that completes the sentence fragment: "It should ____". + */ + public function get description():String + { + return _description; + } + } +} diff --git a/lib/src/pixeldroid/bdd/models/SpecInfo.ls b/lib/src/pixeldroid/bdd/models/SpecInfo.ls index 7436a78..0ba39a8 100644 --- a/lib/src/pixeldroid/bdd/models/SpecInfo.ls +++ b/lib/src/pixeldroid/bdd/models/SpecInfo.ls @@ -1,13 +1,28 @@ package pixeldroid.bdd.models { + /** + Encapsulates basic meta data for a specifier. + */ public class SpecInfo { + /** Name of the specifier */ public var name:String; + + /** Release version of the specifier code library */ public var version:String; + + /** Value to seed the pseudo-random number generator that defines test order */ public var seed:Number; + /** + Create a new SpecInfo instance. + + @param name Name of the specifier library + @param version Semantic version of the specifier library + @param seed Value to initialize the pseudo-random number generator that defines test order. Providing the same seed for two different executions will result in the same test order for each execution. + */ public function SpecInfo(name:String='Spec', version:String='0.0.0', seed:Number=0) { this.name = name; @@ -15,6 +30,11 @@ package pixeldroid.bdd.models this.seed = seed; } + /** + Generate a human-readable string describing the instance. + + Result will be in the following format: `[ v] seed: ` + */ public function toString():String { return '[' +name +' v' +version +'] seed: ' +seed; diff --git a/lib/src/pixeldroid/bdd/reporters/AnsiReporter.ls b/lib/src/pixeldroid/bdd/reporters/AnsiReporter.ls index a578aa8..f0bcd97 100644 --- a/lib/src/pixeldroid/bdd/reporters/AnsiReporter.ls +++ b/lib/src/pixeldroid/bdd/reporters/AnsiReporter.ls @@ -3,39 +3,73 @@ package pixeldroid.bdd.reporters { import pixeldroid.ansi.ANSI; import pixeldroid.bdd.Reporter; - import pixeldroid.bdd.models.Expectation; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.MatchResult; import pixeldroid.bdd.models.SpecInfo; + /** + Prints results of executing a specification to the console using ANSI codes for colored formatting. + + The format is compact: + + ``` + [Spec v2.0.0] seed: 36440 + + Thing1 ...........................X......................... + ........ + + 1 failure in 61 expectations from 10 requirements. 0.025s. + "should be readable" expected false toBeTruthy but value is not truthy. + ./src/spec//Thing1Spec.ls:123 + + Thing2 .... + + 0 failures in 4 expectations from 3 requirements. 0.001s. + + 1 failure in 65 expectations from 13 requirements. + completed in 0.026s. + ``` + */ public class AnsiReporter implements Reporter { private const lineWidth:Number = 60; - private var failures:Dictionary.>; - private var numSpecs:Number; - private var numAssert:Number; + private var totalFailures:Number; + private var totalExpects:Number; + private var totalReqs:Number; + + private var failures:Dictionary.>; + private var numExpect:Number; + private var numReq:Number; private var ansi:ANSI = new ANSI(); private var progress:String; private var numChars:Number; + /** @inherit */ public function init(specInfo:SpecInfo):void { + totalFailures = 0; + totalExpects = 0; + totalReqs = 0; + + trace(''); + ansi.clear; ansi.faint.add('[').nofaint.add(specInfo.name +' v' +specInfo.version).faint.add(']'); ansi.add(' seed: ').nofaint.fgCyan.add(specInfo.seed.toString()).reset; - trace(''); trace(ansi); } + /** @inherit */ public function begin(name:String, total:Number):void { - numSpecs = total; - numAssert = 0; failures = {}; + numExpect = 0; + numReq = total; progress = ansi.clear.bold.add(name).nobold.add(' ').toString(); numChars = name.length + 1; @@ -44,19 +78,20 @@ package pixeldroid.bdd.reporters trace(''); // overwriting will start on this line } - public function report(e:Expectation, durationSec:Number, index:Number, total:Number):void + /** @inherit */ + public function report(req:Requirement, durationSec:Number, index:Number, total:Number):void { var i:Number; - var n:Number = e.numResults; + var n:Number = req.numResults; var result:MatchResult; - numAssert += n; + numExpect += n; for (i = 0; i < n; i++) { ansi.clear.add(progress); - result = e.getResult(i); + result = req.getResult(i); if (result.success) { ansi.faint.add('.').nofaint; @@ -64,8 +99,8 @@ package pixeldroid.bdd.reporters } else { - if (failures[e]) failures[e].push(i); - else failures[e] = [i]; + if (failures[req]) failures[req].push(i); + else failures[req] = [i]; ansi.bold.fgRed.add('X').nofg.nobold; numChars++; @@ -83,6 +118,7 @@ package pixeldroid.bdd.reporters } } + /** @inherit */ public function end(name:String, durationSec:Number):Boolean { var failMessages:Vector. = collectFailures(); @@ -90,40 +126,58 @@ package pixeldroid.bdd.reporters var success:Boolean = (numFailures == 0); trace(''); - ansi.clear; + ansi.clear; if (success) ansi.fgGreen; else ansi.bold.fgRed; ansi.add(' ' +numFailures +' ' +pluralize('failure', numFailures)).reset; - ansi.faint.add(' in ').nofaint.add(numAssert +' assertions'); - ansi.faint.add(' from ').nofaint.add(numSpecs +' expectations'); + ansi.faint.add(' in ').nofaint.add(numExpect +' ' +pluralize('expectation', numExpect)); + ansi.faint.add(' from ').nofaint.add(numReq +' ' +pluralize('requirement', numReq)); ansi.faint.add('. ' +durationSec +'s.').reset; trace(ansi); + for each (var s:String in failMessages) trace(s); - for each (var s:String in failMessages) - { - trace(s); - } + totalFailures += numFailures; + totalExpects += numExpect; + totalReqs += numReq; return success; } + /** @inherit */ + public function finalize(durationSec:Number):void + { + trace(''); + + ansi.clear; + ansi.faint.add(totalFailures +' ' +pluralize('failure', totalFailures)); + ansi.faint.add(' in ' +totalExpects +' ' +pluralize('expectation', totalExpects)); + ansi.faint.add(' from ' +totalReqs +' ' +pluralize('requirement', totalReqs)); + ansi.faint.add('.').reset; + trace(ansi); + + ansi.clear; + ansi.faint.add('completed in ' +durationSec +'s.').reset; + trace(ansi); + } + private function collectFailures():Vector. { var v:Vector. = []; var result:MatchResult; - for (var e:Expectation in failures) + for (var req:Requirement in failures) { - var resultIndices:Vector. = failures[e]; + var resultIndices:Vector. = failures[req]; for each (var i:Number in resultIndices) { - result = e.getResult(i); - ansi.clear.fgRed.add(' "' +e.description +'" ').faint.add('expected ').nofaint.add(result.description).reset; + result = req.getResult(i); + ansi.clear.fgRed.add(' "' +req.description +'" ').faint.add('expected ').nofaint.add(result.description).reset; if (result.hasMessage()) ansi.fgRed.faint.add(' but ').nofaint.add(result.message).reset; + if (result.source) ansi.fgRed.faint.add('\n ' +result.callTrace).reset; v.push(ansi.toString()); } } diff --git a/lib/src/pixeldroid/bdd/reporters/ConsoleReporter.ls b/lib/src/pixeldroid/bdd/reporters/ConsoleReporter.ls index d81872a..bb0015a 100644 --- a/lib/src/pixeldroid/bdd/reporters/ConsoleReporter.ls +++ b/lib/src/pixeldroid/bdd/reporters/ConsoleReporter.ls @@ -2,48 +2,82 @@ package pixeldroid.bdd.reporters { import pixeldroid.bdd.Reporter; - import pixeldroid.bdd.models.Expectation; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.MatchResult; import pixeldroid.bdd.models.SpecInfo; + /** + Prints results of executing a specification to the console using a simple human readable format: + + ``` + [Spec v2.0.0] seed: 63134 + + Thing1 + -should be versioned + . expect '2.0.0' toPatternMatch '(%d+).(%d+).(%d+)' with 3 capture groups + -should fail specifications whose expectations lack assertions + . expect false toBeFalsey + 0 failures in 2 expectations from 2 requirements. 0.001s. + + Thing2 + -should be readable + X expect false toBeTruthy (./src/spec//Thing2Spec.ls:23) + . expect true toBeTruthy + 1 failure in 2 expectations from 1 requirement. 0.001s. + + 1 failure in 4 expectations from 3 requirements. + completed in 0.002s. + ``` + */ public class ConsoleReporter implements Reporter { + private var totalFailures:Number; + private var totalExpects:Number; + private var totalReqs:Number; + private var numFailures:Number; - private var numSpecs:Number; - private var numAssert:Number; + private var numExpect:Number; + private var numReq:Number; + /** @inherit */ public function init(specInfo:SpecInfo):void { + totalFailures = 0; + totalExpects = 0; + totalReqs = 0; + trace(''); trace(specInfo); } + /** @inherit */ public function begin(name:String, total:Number):void { - numSpecs = total; - numAssert = 0; numFailures = 0; + numExpect = 0; + numReq = total; trace(''); trace(name); } - public function report(e:Expectation, durationSec:Number, index:Number, total:Number):void + /** @inherit */ + public function report(req:Requirement, durationSec:Number, index:Number, total:Number):void { - trace(' -should ' +e.description); + trace(' -should ' +req.description); var i:Number; - var n:Number = e.numResults; + var n:Number = req.numResults; var result:MatchResult; var verdict:String; - numAssert += n; + numExpect += n; for (i = 0; i < n; i++) { - result = e.getResult(i); + result = req.getResult(i); if (result.success) { verdict = '.'; @@ -55,25 +89,44 @@ package pixeldroid.bdd.reporters } trace(verdict +' expect ' +result.description); - if (!result.success) trace(' (' +result.message +')'); + if (!result.success) trace(' ' +result.message, 'see ' +result.callTrace); } } + /** @inherit */ public function end(name:String, durationSec:Number):Boolean { var summary:String = ''; summary += numFailures +' ' +pluralize('failure', numFailures); - summary += ' in ' +numAssert +' assertions'; - summary += ' from ' +numSpecs +' expectations'; + summary += ' in ' +numExpect +' ' +pluralize('expectation', numExpect); + summary += ' from ' +numReq +' ' +pluralize('requirement', numReq); summary += '.'; summary += ' ' +durationSec +'s.'; trace(summary); + totalFailures += numFailures; + totalExpects += numExpect; + totalReqs += numReq; + return (numFailures == 0); } + /** @inherit */ + public function finalize(durationSec:Number):void + { + var summary:String = ''; + summary += totalFailures +' ' +pluralize('failure', totalFailures); + summary += ' in ' +totalExpects +' ' +pluralize('expectation', totalExpects); + summary += ' from ' +totalReqs +' ' +pluralize('requirement', totalReqs); + summary += '.'; + + trace(''); + trace(summary); + trace('completed in ' +durationSec +'s.'); + } + private function pluralize(s:String, n:Number):String { diff --git a/lib/src/pixeldroid/bdd/reporters/JunitReporter.ls b/lib/src/pixeldroid/bdd/reporters/JunitReporter.ls index ef74bb9..a222591 100644 --- a/lib/src/pixeldroid/bdd/reporters/JunitReporter.ls +++ b/lib/src/pixeldroid/bdd/reporters/JunitReporter.ls @@ -2,7 +2,7 @@ package pixeldroid.bdd.reporters { import pixeldroid.bdd.Reporter; - import pixeldroid.bdd.models.Expectation; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.MatchResult; import pixeldroid.bdd.models.SpecInfo; @@ -10,6 +10,14 @@ package pixeldroid.bdd.reporters import system.xml.XMLElement; + /** + Generates an xml report of the results of executing a specification. + + The xml format matches the schema introduced by JUnit, and commonly supported by CI tools like Jenkins. + + @see http://llg.cubic.org/docs/junit/ + @see https://github.com/windyroad/JUnit-Schema + */ public class JunitReporter implements Reporter { private var xml:XMLDocument; @@ -17,6 +25,7 @@ package pixeldroid.bdd.reporters private var numFailures:Number; + /** @inherit */ public function init(specInfo:SpecInfo):void { xml = new XMLDocument(); @@ -27,6 +36,7 @@ package pixeldroid.bdd.reporters xml.linkEndChild(suites); } + /** @inherit */ public function begin(name:String, total:Number):void { numFailures = 0; @@ -36,14 +46,15 @@ package pixeldroid.bdd.reporters suites.setAttribute('tests', total.toString()); } - public function report(e:Expectation, durationSec:Number, index:Number, total:Number):void + /** @inherit */ + public function report(req:Requirement, durationSec:Number, index:Number, total:Number):void { var i:Number; - var n:Number = e.numResults; + var n:Number = req.numResults; var result:MatchResult; var suite:XMLElement = xml.newElement('testsuite'); - suite.setAttribute('name', e.description); + suite.setAttribute('name', req.description); suite.setAttribute('time', durationSec.toString()); var test:XMLElement; @@ -51,7 +62,7 @@ package pixeldroid.bdd.reporters for (i = 0; i < n; i++) { - result = e.getResult(i); + result = req.getResult(i); test = xml.newElement('testcase'); test.setAttribute('name', 'expect ' +result.description); @@ -62,7 +73,7 @@ package pixeldroid.bdd.reporters fail = xml.newElement('failure'); fail.setAttribute('type', 'assertion'); - if (result.hasMessage()) fail.setAttribute('message', result.message); + if (result.hasMessage()) fail.setAttribute('message', result.message +' see ' +result.callTrace); test.linkEndChild(fail); } @@ -73,6 +84,7 @@ package pixeldroid.bdd.reporters suites.linkEndChild(suite); } + /** @inherit */ public function end(name:String, durationSec:Number):Boolean { suites.setAttribute('errors', '0'); @@ -84,6 +96,12 @@ package pixeldroid.bdd.reporters return (numFailures == 0); } + /** @inherit */ + public function finalize(durationSec:Number):void + { + /* no-op */ + } + private function writeFile(fileName:String):void { diff --git a/lib/src/pixeldroid/bdd/reporters/ReporterManager.ls b/lib/src/pixeldroid/bdd/reporters/ReporterManager.ls index 8941be3..368d437 100644 --- a/lib/src/pixeldroid/bdd/reporters/ReporterManager.ls +++ b/lib/src/pixeldroid/bdd/reporters/ReporterManager.ls @@ -2,26 +2,47 @@ package pixeldroid.bdd.reporters { import pixeldroid.bdd.Reporter; - import pixeldroid.bdd.models.Expectation; + import pixeldroid.bdd.models.Requirement; import pixeldroid.bdd.models.SpecInfo; + /** + Delegates calls to multiple individual reporters, allowing for more than one reporter to be attached to a specifier. + */ public class ReporterManager implements Reporter { private var reporters:Vector. = []; + /** Retrieve the number of reporters currently being managed. */ public function get length():Number { return reporters.length; } + /** + Add a reporter to the management group. + A reporter can only be added once; this is an idempotent operation. + + @param reporter An implementer of the Reporter interface + */ public function add(reporter:Reporter):void { - reporters.push(reporter); + if (reporter && !reporters.contains(reporter)) reporters.push(reporter); + } + + /** + Remove a reporter from the management group. + + @param reporter A reference to a previously added implementer of the Reporter interface + */ + public function remove(reporter:Reporter):void + { + reporters.remove(reporter); } + /** @inherit */ public function init(specInfo:SpecInfo):void { for each (var reporter:Reporter in reporters) @@ -30,6 +51,7 @@ package pixeldroid.bdd.reporters } } + /** @inherit */ public function begin(name:String, total:Number):void { for each (var reporter:Reporter in reporters) @@ -38,7 +60,8 @@ package pixeldroid.bdd.reporters } } - public function report(e:Expectation, durationSec:Number, index:Number, total:Number):void + /** @inherit */ + public function report(e:Requirement, durationSec:Number, index:Number, total:Number):void { for each (var reporter:Reporter in reporters) { @@ -46,6 +69,7 @@ package pixeldroid.bdd.reporters } } + /** @inherit */ public function end(name:String, durationSec:Number):Boolean { var success:Boolean = true; @@ -57,6 +81,15 @@ package pixeldroid.bdd.reporters return success; } + + /** @inherit */ + public function finalize(durationSec:Number):void + { + for each (var reporter:Reporter in reporters) + { + reporter.finalize(durationSec); + } + } } } diff --git a/lib/src/pixeldroid/platform/CallUtils.ls b/lib/src/pixeldroid/platform/CallUtils.ls new file mode 100644 index 0000000..18dbfe0 --- /dev/null +++ b/lib/src/pixeldroid/platform/CallUtils.ls @@ -0,0 +1,108 @@ +package pixeldroid.platform +{ + + import system.CallStackInfo; + import system.Debug; + + public final class CallUtils + { + private static var callStack:Vector.; + + /** + Search backwards through the call stack for the most recent caller of the provided method name. + + @param methodName Name of the method to find a caller for + @return CallStackInfo or null + */ + public static function findCallingStackFrame(methodName:string):CallStackInfo + { + callStack = Debug.getCallStack(); + var stackFrame:Number = callStack.length - 1; + var csi:CallStackInfo; + + // search from the top of the callstack (most recently called) + while (stackFrame-- > 0) + { + csi = callStack[stackFrame]; + + if (csi.method.getName() == methodName) + { + csi = callStack[stackFrame + 1]; + return csi; + } + } + + // method not found + csi = null; + return csi; + } + + /** + Retrieve a stack frame some distance relative to the current one. + + For example, assuming in the chain below that method `bar()` calls `getPriorStackFrame()`, then + the valid distance values would be as follows: + ``` + ConsoleApplication.initialize()->Example.run()->Bat.baz()->Foo.bar()->CallUtils.getPriorStackframe() + 3 2 1 0 -1 + ------------------------------------------------------------------^ + ``` + + Out of bound values return null. + + The table below shows the call stack for the example above, and which stack frame a given value + of `n` would retrieve the `CallStackInfo` for: + ``` + n stack frame + - ----------- + 3 ./src/system//Application/ConsoleApplication.ls(initialize):35 + 2 ./src/app//Example.ls(run):22 + 1 ./src/app//Bat.ls(baz):111 + 0 ./src/app//Foo.ls(bar):9 + -1 ./src/.//pixeldroid/platform/CallUtils.ls(getPriorStackFrame):67 + ``` + */ + public static function getPriorStackFrame(n:Number = 1):CallStackInfo + { + if (n < -1) + return null; + + var stackFrame:Number = n + 1; // add one to account for calling this method + callStack = Debug.getCallStack(); + + if (stackFrame >= callStack.length) + return null; + + return callStack[stackFrame]; + } + + /** + Generate a human readable single line trace of the provided method call info. + + The trace line includes method source file, method name, and line number, e.g.: + `./src/.//pixeldroid/platform/CallUtils.ls(toCallTrace):82` + + @param csi `CallStackInfo` instance to create trace line for + */ + public static function toCallTrace(csi:CallStackInfo):String + { + return (csi.source +'(' +csi.method.getName() +'):' +csi.line); + } + + /** + Prepare a vector of single line strings representing a printable version of the current call stack. + + @return Vector. + */ + public static function traceCallStack():Vector. + { + callStack = Debug.getCallStack(); + var lines:Vector. = []; + + while (callStack.length > 0) + lines.push(toCallTrace(callStack.pop())); + + return lines; + } + } +} diff --git a/terminal.png b/terminal.png index 15dc2b6..840731d 100644 Binary files a/terminal.png and b/terminal.png differ diff --git a/test/src/app/SpecTest.ls b/test/src/app/SpecTest.ls index 46f5b80..ccf34f5 100644 --- a/test/src/app/SpecTest.ls +++ b/test/src/app/SpecTest.ls @@ -5,8 +5,11 @@ package import pixeldroid.bdd.SpecExecutor; + import AssertionSpec; + import CallUtilsSpec; import ExpectationSpec; import SpecSpec; + import ThingSpec; public class SpecTest extends ConsoleApplication @@ -16,8 +19,11 @@ package SpecExecutor.parseArgs(); var returnCode:Number = SpecExecutor.exec([ + SpecSpec, + ThingSpec, ExpectationSpec, - SpecSpec + AssertionSpec, + CallUtilsSpec, ]); Process.exit(returnCode); diff --git a/test/src/spec/AssertionSpec.ls b/test/src/spec/AssertionSpec.ls new file mode 100644 index 0000000..1c50af4 --- /dev/null +++ b/test/src/spec/AssertionSpec.ls @@ -0,0 +1,125 @@ +package +{ + import pixeldroid.bdd.Spec; + import pixeldroid.bdd.Thing; + + + public static class AssertionSpec + { + private static var it:Thing; + + public static function specify(specifier:Spec):void + { + it = specifier.describe('Assertion'); + Assertion.asserter = new TestAsserter(); + + it.should('provide a numberness assertion', provide_numberness_assertion); + it.should('provide nullness assertions', provide_nullness_assertions); + it.should('provide emptiness assertions', provide_emptiness_assertions); + it.should('provide equality assertions', provide_equality_assertions); + it.should('provide inequality assertions', provide_inequality_assertions); + it.should('provide a type assertion', provide_type_assertion); + } + + + private static function provide_numberness_assertion():void + { + it.expects( it.asserts(NaN).isNotNaN().or('') ).toBeFalsey(); + it.expects( it.asserts(null).isNotNaN().or('') ).toBeTruthy(); + it.expects( it.asserts(false).isNotNaN().or('') ).toBeTruthy(); + it.expects( it.asserts(0).isNotNaN().or('') ).toBeTruthy(); + it.expects( it.asserts(123.45).isNotNaN().or('') ).toBeTruthy(); + it.expects( it.asserts('').isNotNaN().or('') ).toBeTruthy(); + } + + private static function provide_nullness_assertions():void + { + it.expects( it.asserts(null).isNull().or('') ).toBeTruthy(); + it.expects( it.asserts(false).isNull().or('') ).toBeFalsey(); + it.expects( it.asserts(NaN).isNull().or('') ).toBeFalsey(); + it.expects( it.asserts(0).isNull().or('') ).toBeFalsey(); + it.expects( it.asserts('').isNull().or('') ).toBeFalsey(); + + it.expects( it.asserts(null).isNotNull().or('') ).toBeFalsey(); + it.expects( it.asserts(false).isNotNull().or('') ).toBeTruthy(); + it.expects( it.asserts(NaN).isNotNull().or('') ).toBeTruthy(); + it.expects( it.asserts(0).isNotNull().or('') ).toBeTruthy(); + it.expects( it.asserts('').isNotNull().or('') ).toBeTruthy(); + + } + + private static function provide_emptiness_assertions():void + { + it.expects( it.asserts('').isEmpty().or('') ).toBeTruthy(); + it.expects( it.asserts([]).isEmpty().or('') ).toBeTruthy(); + // it.expects( it.asserts({}).isEmpty().or('') ).toBeTruthy(); // TODO: add empty dictionaries + it.expects( it.asserts('abc').isEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts([1,2]).isEmpty().or('') ).toBeFalsey(); + + it.expects( it.asserts('').isNotEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts([]).isNotEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts('abc').isNotEmpty().or('') ).toBeTruthy(); + it.expects( it.asserts([1,2]).isNotEmpty().or('') ).toBeTruthy(); + + it.expects( it.asserts(null).isEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts(false).isEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts(NaN).isEmpty().or('') ).toBeFalsey(); + it.expects( it.asserts(0).isEmpty().or('') ).toBeFalsey(); + } + + private static function provide_equality_assertions():void + { + it.expects( it.asserts(!false).isEqualTo(!!true).or('') ).toBeTruthy(); + it.expects( it.asserts(6 + 5).isEqualTo(11).or('') ).toBeTruthy(); + it.expects( it.asserts('three'.length).isEqualTo(5).or('') ).toBeTruthy(); + + it.expects( it.asserts(false).isNotEqualTo(true).or('') ).toBeTruthy(); + it.expects( it.asserts(6 * 5).isNotEqualTo(11).or('') ).toBeTruthy(); + it.expects( it.asserts('three'.length).isNotEqualTo(3).or('') ).toBeTruthy(); + } + + private static function provide_inequality_assertions():void + { + it.expects( it.asserts('seven'.length).isGreaterThan('ten'.length).or('') ).toBeTruthy(); + it.expects( it.asserts('six'.length).isLessThan('three'.length).or('') ).toBeTruthy(); + } + + private static function provide_type_assertion():void + { + it.expects( it.asserts(true).isTypeOf(Boolean).or('') ).toBeTruthy(); + it.expects( it.asserts(9).isTypeOf(Number).or('') ).toBeTruthy(); + it.expects( it.asserts('').isTypeOf(String).or('') ).toBeTruthy(); + it.expects( it.asserts([]).isTypeOf(Vector).or('') ).toBeTruthy(); + it.expects( it.asserts({}).isTypeOf(Dictionary).or('') ).toBeTruthy(); + it.expects( it.asserts(function(){}).isTypeOf(Function).or('') ).toBeTruthy(); + it.expects( it.asserts(it).isTypeOf(Thing).or('') ).toBeTruthy(); + it.expects( it.asserts(it).isTypeOf(Object).or('') ).toBeTruthy(); + } + + } + + import pixeldroid.bdd.Assertion; + import pixeldroid.bdd.Asserter; + + private class TestAsserter implements Asserter + { + public var condition:Boolean; + public var source:String; + public var line:Number; + + public function init(condition:Boolean, source:String, line:Number):void + { + this.condition = condition; + this.source = source; + this.line = line; + } + + public function or(message:String):Boolean + { + if (condition) + return true; + + return false; + } + } +} diff --git a/test/src/spec/CallUtilsSpec.ls b/test/src/spec/CallUtilsSpec.ls new file mode 100644 index 0000000..fc7fa89 --- /dev/null +++ b/test/src/spec/CallUtilsSpec.ls @@ -0,0 +1,71 @@ +package +{ + import pixeldroid.bdd.Spec; + import pixeldroid.bdd.Thing; + import pixeldroid.platform.CallUtils; + + import system.CallStackInfo; + import system.Debug; + + + public static class CallUtilsSpec + { + private static var it:Thing; + + public static function specify(specifier:Spec):void + { + it = specifier.describe('CallUtils'); + + it.should('find a stack frame by method name', find_by_name); + it.should('retrieve a stack frame by relative distance', fetch_by_distance); + it.should('handle out of range requests for a stack frame by returning null', null_when_range_invalid); + it.should('prepare a loggable string from a single stack frame', make_loggable_frame); + it.should('prepare a vector of loggable strings from a full stack trace', make_loggable_stack); + } + + + private static function find_by_name():void + { + var callingFrame:CallStackInfo = CallUtils.findCallingStackFrame('find_by_name'); + + it.expects(callingFrame.source).toEndWith('pixeldroid/bdd/models/Requirement.ls'); + it.expects(callingFrame.method.getName()).toEqual('test'); + + callingFrame = CallUtils.findCallingStackFrame('no_such_method'); + it.expects(callingFrame).toBeNull(); + } + + private static function fetch_by_distance():void + { + var callingFrame:CallStackInfo = CallUtils.getPriorStackFrame(0); + + it.expects(callingFrame.method.getName()).toEqual('fetch_by_distance'); + } + + private static function null_when_range_invalid():void + { + var stack:Vector. = Debug.getCallStack(); + var under:Number = -2; + var over:Number = stack.length; + + it.expects(CallUtils.getPriorStackFrame(under)).toBeNull(); + it.expects(CallUtils.getPriorStackFrame(over)).toBeNull(); + } + + private static function make_loggable_frame():void + { + var callingFrame:CallStackInfo = CallUtils.getPriorStackFrame(0); + + it.expects(CallUtils.toCallTrace(callingFrame)).toEqual('./src/spec//CallUtilsSpec.ls(make_loggable_frame):57'); + } + + private static function make_loggable_stack():void + { + var loggableStack:Vector. = CallUtils.traceCallStack(); + + it.asserts(loggableStack.length).isGreaterThan(1); + loggableStack.pop(); // get rid of the call to traceCallStack() + it.expects(loggableStack.pop()).toEqual('./src/spec//CallUtilsSpec.ls(make_loggable_stack):64'); + } + } +} diff --git a/test/src/spec/ExpectationSpec.ls b/test/src/spec/ExpectationSpec.ls index fdffaea..1fd634f 100644 --- a/test/src/spec/ExpectationSpec.ls +++ b/test/src/spec/ExpectationSpec.ls @@ -6,11 +6,12 @@ package public static class ExpectationSpec { - private static const it:Thing = Spec.describe('Expectations'); + private static var it:Thing; - public static function describe():void + public static function specify(specifier:Spec):void { - it.should('be executable', be_executable); + it = specifier.describe('Expectation'); + it.should('provide boolean matchers', provide_boolean_matchers); it.should('provide a negation helper', provide_negation_helper); it.should('provide an equality matcher', provide_equality_matcher); @@ -24,12 +25,6 @@ package } - private static function be_executable():void - { - var f:Function = function(a:Number,b:Number):Number { return a*b; }; - it.expects(f(5,7)).toEqual(5*7); - } - private static function provide_boolean_matchers():void { it.expects(true).toBeTruthy(); @@ -139,5 +134,6 @@ package it.expects(it).toBeA(Thing); it.expects(it).toBeA(Object); } + } } diff --git a/test/src/spec/SpecSpec.ls b/test/src/spec/SpecSpec.ls index eea6669..ee0620c 100644 --- a/test/src/spec/SpecSpec.ls +++ b/test/src/spec/SpecSpec.ls @@ -3,15 +3,21 @@ package import pixeldroid.bdd.Spec; import pixeldroid.bdd.Thing; + import pixeldroid.bdd.Reporter; + public static class SpecSpec { - private static const it:Thing = Spec.describe('Spec'); + private static var it:Thing; - public static function describe():void + public static function specify(specifier:Spec):void { + it = specifier.describe('Spec'); + it.should('be versioned', be_versioned); - it.should('help declare expectations', declare_expectations); + it.should('define specifications', define_specifications); + it.should('fail specifications whose requirements lack expectations', fail_empty_specs); + it.should('support custom reporters via an api', support_reporters); } @@ -20,9 +26,79 @@ package it.expects(Spec.version).toPatternMatch('(%d+).(%d+).(%d+)', 3); } - private static function declare_expectations():void + private static function define_specifications():void + { + var testSpec:Spec = new Spec(); + it.expects(testSpec.describe('Test')).toBeA(Thing); + } + + private static function fail_empty_specs():void + { + var testSpec:Spec = new Spec(); + testSpec.addReporter(new TestReporter()); + + var test:Thing = testSpec.describe('Test'); + test.should('fail empty specs', function() {}); + + it.expects(testSpec.execute()).toBeFalsey(); + } + + private static function support_reporters():void + { + var testSpec:Spec = new Spec(); + var testReporter:TestReporter = new TestReporter(); + testSpec.addReporter(testReporter); + + var test:Thing = testSpec.describe('Test'); + test.should('exercise the reporter api', function() { + test.expects(testReporter).toBeA(Reporter); + }); + + it.expects(testSpec.execute()).toBeTruthy(); + + var apiCalled:Boolean = + testReporter.called['init'] + && testReporter.called['begin'] + && testReporter.called['report'] + && testReporter.called['end'] + && testReporter.called['finalize']; + it.expects(apiCalled).toBeTruthy(); + } + } + + import pixeldroid.bdd.models.Requirement; + import pixeldroid.bdd.models.MatchResult; + import pixeldroid.bdd.models.SpecInfo; + + private class TestReporter implements Reporter + { + private var numFailures:Number = 0; + public var called:Dictionary. = { + 'init': false, + 'begin': false, + 'report': false, + 'end': false, + 'finalize': false + }; + public function init(specInfo:SpecInfo):void { called['init'] = true; } + public function begin(name:String, total:Number):void { called['begin'] = true; } + public function report(req:Requirement, durationSec:Number, index:Number, total:Number):void + { + called['report'] = true; + var i:Number; + var n:Number = req.numResults; + var result:MatchResult; + for (i = 0; i < n; i++) + { + result = req.getResult(i); + if (!result.success) numFailures++; + } + } + public function end(name:String, duration:Number):Boolean { - it.expects('this').not.toEqual('that'); + called['end'] = true; + return (numFailures == 0); } + public function finalize(durationSec:Number):void { called['finalize'] = true; } } } diff --git a/test/src/spec/ThingSpec.ls b/test/src/spec/ThingSpec.ls new file mode 100644 index 0000000..e73a051 --- /dev/null +++ b/test/src/spec/ThingSpec.ls @@ -0,0 +1,88 @@ +package +{ + import pixeldroid.bdd.Assertion; + import pixeldroid.bdd.Expectation; + import pixeldroid.bdd.Spec; + import pixeldroid.bdd.Thing; + import pixeldroid.bdd.models.Requirement; + + + public static class ThingSpec + { + private static var it:Thing; + + public static function specify(specifier:Spec):void + { + it = specifier.describe('Thing'); + + it.should('describe behavior', describe_behavior); + it.should('create matchers to validate requirements', create_matchers); + it.should('validate via execution of real code', be_executable); + } + + + private static function describe_behavior():void + { + var testSpec:Spec = new Spec(); + var test:Thing = testSpec.describe('Test'); + var validator:TestValidator = new TestValidator(); + test.submitForValidation(validator); + + it.expects(validator.numRequirements).toEqual(0); + test.should('create requirements out of declaration,validation pairs', function() {}); + it.expects(validator.numRequirements).toEqual(1); + } + + private static function create_matchers():void + { + var testSpec:Spec = new Spec(); + var test:Thing = testSpec.describe('Test'); + var validator:TestValidator = new TestValidator(); + test.submitForValidation(validator); + + var expectation:Object; + var assertion:Object; + var value:Number = 123; + + test.should('create Expectations and Assertions during validation', function() { + expectation = test.expects(value); + assertion = test.asserts(value); + }); + validator.validate(test, new MockReporter()); + + it.expects(expectation).toBeA(Expectation); + it.expects(assertion).toBeA(Assertion); + } + + private static function be_executable():void + { + var f:Function = function(a:Number,b:Number):Number { return a*b; }; + it.expects(f(5,7)).toEqual(5*7); + } + } + + + import pixeldroid.bdd.ThingValidator; + + private class TestValidator extends ThingValidator + { + private var peek:Vector.; + + override public function setRequirements(value:Vector.):void { peek = value; super.setRequirements(value); } + public function get numRequirements():Number { return peek.length; } + } + + + import pixeldroid.bdd.Reporter; + import pixeldroid.bdd.models.Requirement; + import pixeldroid.bdd.models.SpecInfo; + + private class MockReporter implements Reporter + { + public function init(specInfo:SpecInfo):void {} + public function begin(name:String, total:Number):void {} + public function report(requirement:Requirement, durationSec:Number, index:Number, total:Number):void {} + public function end(name:String, durationSec:Number):Boolean { return true; } + public function finalize(durationSec:Number):void {} + } +}