1
0
Fork 0

Cause-based field validation support

master
Mike Gerwitz 2016-04-20 12:12:37 -04:00
commit 4079ac7a8b
4 changed files with 445 additions and 48 deletions

View File

@ -0,0 +1,127 @@
/**
* Validation failure
*
* Copyright (C) 2016 LoVullo Associates, Inc.
*
* This file is part of liza.
*
* liza is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
var Class = require( 'easejs' ).Class,
Field = require( '../field/Field' );
/**
* Represents a field validation failure
*/
module.exports = Class( 'Failure',
{
/**
* Failure field
* @type {Field}
*/
'private _field': null,
/**
* Validation failure reason
* @type {string}
*/
'private _reason': '',
/**
* Field that caused the error
* @type {?Field}
*/
'private _cause': null,
/**
* Create failure with optional reason and cause
*
* The field FIELD is the target of the failure, which might have
* been caused by another field CAUSE. If cause is omitted, it is
* assumed to be FIELD. The string REASON describes the failure.
*
* @param {Field} field failed field
* @param {string=} reason description of failure
* @param {Field=} cause field that triggered the failure
*/
__construct: function( field, reason, cause )
{
if ( !Class.isA( Field, field ) )
{
throw TypeError( "Field expected" );
}
if ( ( cause !== undefined ) && !Class.isA( Field, cause ) )
{
throw TypeError( "Field expected for cause" );
}
this._field = field;
this._reason = ( reason === undefined ) ? '' : ''+reason;
this._cause = cause || field;
},
/**
* Retrieve target of the failure
*
* @return {Field} target field
*/
'public getField': function()
{
return this._field;
},
/**
* Retrieve a description of the failure, or the empty string
*
* @return {string} failure description
*/
'public getReason': function()
{
return this._reason;
},
/**
* Retrieve field that caused the failure
*
* Unless a separate cause was provided during instantiation, the
* failure is assumed to have been caused by the target field itself.
*
* @return {Field} cause of failure
*/
'public getCause': function()
{
return this._cause;
},
/**
* Produce failure reason when converted to a string
*
* This allows the failure to be used in place of the traditional system
* of error strings.
*
* @return {string}
*/
__toString: function()
{
return this._reason;
}
} );

View File

@ -20,7 +20,8 @@
*/
var Class = require( 'easejs' ).Class,
EventEmitter = require( 'events' ).EventEmitter;
EventEmitter = require( 'events' ).EventEmitter,
Failure = require( './Failure' );
/**
@ -55,11 +56,10 @@ module.exports = Class( 'ValidStateMonitor' )
*/
'public update': function( data, failures )
{
var fixed = this.detectFixes( data, this._failures, failures );
var fixed = this.detectFixes( data, this._failures, failures ),
count_new = this.mergeFailures( this._failures, failures );
this.mergeFailures( this._failures, failures );
if ( this.hasFailures() )
if ( this.hasFailures() && ( count_new > 0 ) )
{
this.emit( 'failure', this._failures );
}
@ -136,10 +136,12 @@ module.exports = Class( 'ValidStateMonitor' )
* @param {Object} past past failures to merge with
* @param {Object} failures new failures
*
* @return {undefined}
* @return {number} number of new failures
*/
'virtual protected mergeFailures': function( past, failures )
{
var count_new = 0;
for ( var name in failures )
{
past[ name ] = past[ name ] || [];
@ -148,8 +150,11 @@ module.exports = Class( 'ValidStateMonitor' )
for ( var i in failures[ name ] )
{
past[ name ][ i ] = failures[ name ][ i ];
count_new++;
}
}
return count_new;
},
@ -178,15 +183,7 @@ module.exports = Class( 'ValidStateMonitor' )
for ( var name in past )
{
// we're only interested in detecting fixes on the data that has
// been set
if ( !( data[ name ] ) )
{
continue;
}
var field = data[ name ],
past_fail = past[ name ],
var past_fail = past[ name ],
fail = failures[ name ];
// we must check each individual index because it is possible that
@ -194,17 +191,29 @@ module.exports = Class( 'ValidStateMonitor' )
// this because this is treated as a hash table, not an array)
for ( var i in past_fail )
{
var cause = this._getCause( name, i, past_fail ),
cause_name = cause[ 0 ],
cause_index = cause[ 1 ],
field = data[ cause_name ];
// if datum is unchanged, ignore it
if ( field === undefined )
{
continue;
}
// to be marked as fixed, there must both me no failure and
// there must be data for this index for the field in question
// (if the field wasn't touched, then of course there's no
// failure!)
if ( ( fail === undefined )
|| ( !( fail[ i ] ) && ( field[ i ] !== undefined ) )
|| ( !( fail[ cause_index ] )
&& ( field[ cause_index ] !== undefined ) )
)
{
// looks like it has been resolved
( fixed[ name ] = fixed[ name ] || [] )[ i ] =
data[ name ][ i ];
field[ cause_index ]
has_fixed = true;
@ -216,5 +225,34 @@ module.exports = Class( 'ValidStateMonitor' )
return ( has_fixed )
? fixed
: null;
},
/**
* Produces name and index of the field causing a failure, or NAME
* and INDEX if unavailable
*
* This maintains backwards-compatibility for the old string-based
* system.
*
* @param {string} name field name
* @param {number} index field index
*
* @param {Array.<Failure|string>} past_fail previous failure
*
* @return {Array} name/index tuple of cause field
*/
'private _getCause': function( name, index, past_fail )
{
var failure = past_fail[ index ];
if ( Class.isA( Failure, failure ) )
{
var cause = failure.getCause();
return [ cause.getName(), cause.getIndex() ];
}
return [ name, index ];
}
} );

View File

@ -0,0 +1,118 @@
/**
* Test validation failure
*
* Copyright (C) 2016 LoVullo Associates, Inc.
*
* This file is part of liza.
*
* liza is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
var root = require( '../../' ),
Sut = root.validate.Failure,
expect = require( 'chai' ).expect;
var DummyField = require( 'easejs' ).Class
.implement( root.field.Field )
.extend(
{
getName: function() {},
getIndex: function() {},
} );
describe( 'Failure', function()
{
it( 'throws error when not given Field for failure', function()
{
expect( function()
{
Sut( {} );
} ).to.throw( TypeError );
} );
it( 'throws error when not given a Field for cause', function()
{
expect( function()
{
Sut( DummyField(), '', {} );
} ).to.throw( TypeError );
} );
describe( '#getField', function()
{
it( 'returns original field', function()
{
var field = DummyField();
expect( Sut( field ).getField() )
.to.equal( field );
} );
} );
describe( '#getReason', function()
{
it( 'returns original failure reason', function()
{
var reason = 'solar flares';
expect( Sut( DummyField(), reason ).getReason() )
.to.equal( reason );
} );
it( 'returns empty string by default', function()
{
expect( Sut( DummyField() ).getReason() )
.to.equal( '' );
} );
} );
describe( '#getCause', function()
{
it( 'returns original cause field', function()
{
var cause = DummyField();
expect( Sut( DummyField(), '', cause ).getCause() )
.to.equal( cause );
} );
// in other words: field caused itself to fail
it( 'returns field by default', function()
{
var field = DummyField();
expect( Sut( field ).getCause() )
.to.equal( field );
} );
} );
describe( 'when converted to a string', function()
{
it( 'produces failure reason', function()
{
var reason = 'bogons';
expect( ''+Sut( DummyField(), reason ) )
.to.equal( reason );
} );
} );
} );

View File

@ -19,8 +19,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
var Sut = require( '../../' ).validate.ValidStateMonitor,
expect = require( 'chai' ).expect;
var root = require( '../../' ),
Sut = root.validate.ValidStateMonitor,
expect = require( 'chai' ).expect,
Failure = root.validate.Failure,
Field = root.field.BucketField;
var nocall = function( type )
{
@ -30,6 +34,16 @@ var nocall = function( type )
};
};
var mkfail = function( name, arr )
{
return arr.map( function( value, i )
{
return ( value === undefined )
? undefined
: Failure( Field( name, i ), value );
} );
};
describe( 'ValidStateMonitor', function()
{
@ -49,7 +63,7 @@ describe( 'ValidStateMonitor', function()
Sut()
.on( 'failure', nocall( 'failure' ) )
.on( 'fix', nocall( 'fix' ) )
.update( { foo: [ 'bar' ] }, {} );
.update( { foo: mkfail( 'foo', [ 'bar' ] ) }, {} );
} );
@ -59,40 +73,45 @@ describe( 'ValidStateMonitor', function()
{
it( 'marks failures even when given no data', function( done )
{
var fail = mkfail( 'foo', [ 'bar', 'baz' ] );
Sut()
.on( 'failure', function( failures )
.on( 'failure', function( failures )
{
expect( failures )
.to.deep.equal( { foo: [ 'bar', 'baz' ] } );
.to.deep.equal( { foo: [ fail[ 0 ], fail[ 1 ] ] } );
done();
} )
.on( 'fix', nocall( 'fix' ) )
.update( {}, { foo: [ 'bar', 'baz' ] } );
.update( {}, { foo: fail } );
} );
it( 'marks failures with index gaps', function( done )
{
var fail = mkfail( 'foo', [ undefined, 'baz' ] );
Sut()
.on( 'failure', function( failures )
.on( 'failure', function( failures )
{
expect( failures )
.to.deep.equal( { foo: [ undefined, 'baz' ] } );
.to.deep.equal( { foo: [ undefined, fail[ 1 ] ] } );
done();
} )
.on( 'fix', nocall( 'fix' ) )
.update( {}, { foo: [ undefined, 'baz' ] } );
.update( {}, { foo: fail } );
} );
it( 'retains past failures when setting new', function( done )
{
var sut = Sut();
var sut = Sut(),
fail = mkfail( 'foo', [ 'bar', 'baz' ] );
var test_first = function( failures )
{
expect( failures )
.to.deep.equal( { foo: [ undefined, 'baz' ] } );
.to.deep.equal( { foo: [ undefined, fail[ 1 ] ] } );
sut.once( 'failure', test_second );
};
@ -100,7 +119,7 @@ describe( 'ValidStateMonitor', function()
var test_second = function( failures )
{
expect( failures )
.to.deep.equal( { foo: [ 'bar', 'baz' ] } );
.to.deep.equal( { foo: [ fail[ 0 ], fail[ 1 ] ] } );
done();
};
@ -108,8 +127,25 @@ describe( 'ValidStateMonitor', function()
sut
.once( 'failure', test_first )
.on( 'fix', nocall( 'fix' ) )
.update( {}, { foo: [ undefined, 'baz' ] } )
.update( {}, { foo: [ 'bar' ] } );
.update( {}, { foo: [ undefined, fail[ 1 ] ] } )
.update( {}, { foo: [ fail[ 0 ] ] } );
} );
// deprecated
it( 'accepts failures as string for BC', function( done )
{
var fail = [ 'foo', 'bar' ];
Sut()
.on( 'failure', function( failures )
{
expect( failures )
.to.deep.equal( { foo: fail } );
done();
} )
.on( 'fix', nocall( 'fix' ) )
.update( {}, { foo: fail } );
} );
} );
@ -118,7 +154,8 @@ describe( 'ValidStateMonitor', function()
{
it( 'removes non-failures if field is present', function( done )
{
var data = { foo: [ 'bardata', 'baz' ] };
var data = { foo: [ 'bardata', 'baz' ] },
fail = mkfail( 'foo', [ 'bar', 'baz' ] );
Sut()
.on( 'fix', function( fixed )
@ -127,16 +164,16 @@ describe( 'ValidStateMonitor', function()
.to.deep.equal( { foo: [ 'bardata' ] } );
done();
} )
.update( data, { foo: [ 'bar', 'baz' ] } )
.update( data, { foo: [ '', 'baz' ] } );
.update( data, { foo: [ fail[ 0 ], fail[ 1 ] ] } )
.update( data, { foo: [ undefined, fail[ 1 ] ] } );
} );
it( 'keeps failures if field is missing', function( done )
{
var data = {
bar: [ 'baz', 'quux' ],
};
var data = { bar: [ 'baz', 'quux' ] },
fail_foo = mkfail( 'foo', [ 'bar', 'baz' ] ),
fail_bar = mkfail( 'bar', [ 'moo', 'cow' ] );
Sut()
.on( 'fix', function( fixed )
@ -146,29 +183,104 @@ describe( 'ValidStateMonitor', function()
done();
} )
.update( data, {
foo: [ 'bar', 'baz' ], // does not exist in data
bar: [ 'moo', 'cow' ],
foo: fail_foo, // does not exist in data
bar: fail_bar,
} )
.update( data, {} );
} );
it( 'does not trigger failure event for existing', function()
{
var called = 0;
Sut()
.on( 'failure', function()
{
called++;
} )
.update( {}, { foo: mkfail( 'foo', [ 'bar' ] ) } )
.update( {}, {} ); // do not trigger failure event
expect( called ).to.equal( 1 );
} );
describe( 'given a cause', function()
{
it( 'considers when recognizing fix', function( done )
{
// same index
var data = { cause: [ 'bar' ] },
field = Field( 'foo', 0 ),
cause = Field( 'cause', 0 ),
fail = Failure( field, 'reason', cause );
Sut()
.on( 'fix', function( fixed )
{
expect( fixed )
.to.deep.equal( { foo: [ 'bar' ] } );
done();
} )
.update( data, { foo: [ fail ] } )
.update( data, {} );
} );
it( 'considers different cause index', function( done )
{
// different index
var data = { cause: [ undefined, 'bar' ] },
field = Field( 'foo', 0 ),
cause = Field( 'cause', 1 ),
fail = Failure( field, 'reason', cause );
Sut()
.on( 'fix', function( fixed )
{
expect( fixed )
.to.deep.equal( { foo: [ 'bar' ] } );
done();
} )
.update( data, { foo: [ fail ] } )
.update( data, {} );
} );
it( 'recognizes non-fix', function()
{
// no cause data
var data = { noncause: [ undefined, 'bar' ] },
field = Field( 'foo', 0 ),
cause = Field( 'cause', 1 ),
fail = Failure( field, 'reason', cause );
Sut()
.on( 'fix', nocall )
.update( data, { foo: [ fail ] } )
.update( data, {} );
} );
} );
} );
it( 'can emit both failure and fix', function( done )
{
var data = {
bar: [ 'baz', 'quux' ],
};
var data = { bar: [ 'baz', 'quux' ] },
fail_foo = mkfail( 'foo', [ 'bar' ] );
Sut()
.update( data, {
bar: [ 'moo', 'cow' ] // fail
bar: mkfail( 'bar', [ 'moo', 'cow' ] ) // fail
} )
.on( 'failure', function( failed )
{
expect( failed )
.to.deep.equal( {
foo: [ 'bar' ],
foo: fail_foo,
} );
} )
.on( 'fix', function( fixed )
@ -178,7 +290,7 @@ describe( 'ValidStateMonitor', function()
done();
} )
.update( data, {
foo: [ 'bar' ], // fail
foo: fail_foo, // fail
// fixes bar
} );
} );
@ -198,11 +310,13 @@ describe( 'ValidStateMonitor', function()
it( 'retrieves current failures', function()
{
var fail = mkfail( 'foo', [ 'fail' ] );
expect(
Sut()
.update( {}, { foo: [ 'fail' ] } )
.update( {}, { foo: fail } )
.getFailures()
).to.deep.equal( { foo: [ 'fail' ] } );
).to.deep.equal( { foo: fail } );
} );
} );
@ -220,7 +334,7 @@ describe( 'ValidStateMonitor', function()
{
expect(
Sut()
.update( {}, { foo: [ 'fail' ] } )
.update( {}, { foo: mkfail( 'foo', [ 'bar' ] ) } )
.hasFailures()
).to.be.true;
} );