Class MUES::Questionnaire
In: lib/mues/filters/questionnaire.rb  (CVS)
Parent: MUES::IOEventFilter

Instances of this class are dynamically-configurable question and answer wizard IO abstractions which can be inserted into a MUES::User‘s MUES::IOEventStream to gather information to perform a specific task.

Methods

Constants

SVNRev = %q$Rev: 1218 $   SVN Revision
SVNId = %q$Id: questionnaire.rb 1218 2004-06-14 06:07:54Z deveiant $   SVN Id
SVNURL = %q$URL: svn+ssh://deveiate.org/usr/local/svn/MUES/trunk/lib/mues/filters/questionnaire.rb $   SVN URL
DefaultSortPosition = 600   Filter sort order

External Aliases

inProgress -> inProgress?
inProgress -> in_progress?
blocked -> blocked?
restartPoint -> restart_point
supportData -> data

Attributes

answers  [RW]  The hash of answers completed so far
blocked  [R]  Returns true if a question is currently blocking further progress
currentStepIndex  [RW]  The index of the current step
finalizer  [RW]  The block to be called when the questionnaire has completed all of its steps. Should be either a Proc or a Method object.
inProgress  [R]  Returns true if the session is in progress (ie., has been started, but is not yet finished).
loaded  [R] 
name  [R]  The name of the questionnaire
path  [R] 
restartPoint  [R]  Continuation object that can be called to resume a blocked questionnaire.
result  [R]  The return value from the finalizer after the questionnaire is finished
steps  [RW]  The steps of the questionnaire
supportData  [RW]  Ancillary support data Hash that can be used to pass objects to validators or the finalizer.

Public Class methods

Load a questionnaire object from a file, searching in the class‘s path.

[Source]

# File lib/mues/filters/questionnaire.rb, line 176
        def self::load( name, &block )
            unless @loaded.key?( name )
                fn = name.dup
                fn << ".rb" unless /\.rb$/ =~ fn
                file = self.path.
                    collect {|dir| File::join( dir, fn )}.
                    find {|path| File::file?( path )} or
                    raise LoadError, "Could not find a Questionnaire named #{name}"
            
                src = File::read( file ).untaint
                questions = eval( src, nil, file, 1 )

                @loaded[name] = new( name, *questions )
            end

            qn = @loaded[name].dup
            qn.finalizer = block if block

            return qn
        end

Create a new Questionnaire object.

[Source]

# File lib/mues/filters/questionnaire.rb, line 204
        def initialize( name, *steps )
            @name = name
            @steps = checkSteps( *(steps.flatten) )
            @stepsMutex = Sync::new
            @currentStepIndex = -1

            # Grab the block given as a Proc if there is one.
            @finalizer = if block_given? then Proc::new else nil end

            @delayedOutputEvents = []
            @delayedOutputEventMutex = Sync::new
            @answers = {}
            @result = nil

            @supportData = {}
            @inProgress = false

            @blocked = false
            @restartPoint = nil

            super( DefaultSortPosition )
        end

Protected Class methods

A factory method that builds a simple one-step confirmation questionnaire, which can be used to confirm dangerous operations, or really any situation where the user only needs to answer one prompt. The answer to the prompt will be set in the :confirm key of the answers hash, or is available via the singleton method answer. If it was a prompt for which a confirming answer begins with a ‘y’ (eg., ‘y’, ‘yes’, ‘Yes’, etc.), the singleton method confirmed? is also provided, which will return true if the user‘s answer matched that criteria.

Example:

    prompt = "Remove %s: Are you sure?" % user.to_s
    confirm = MUES::Questionnaire::Confirmation( prompt ) {|qnaire|
      if qnaire.confirmed?
        MUES::ServerFunctions::unregisterUser( user )
        qnaire.message "Done.\n\n"
      else
        qnaire.error "Aborted.\n\n"
      end
    }

[Source]

# File lib/mues/filters/questionnaire.rb, line 932
        def self.Confirmation( prompt="Are you sure? [yN] ", default="n" )

            # Build the prompt step
            step = {
                :name       => 'confirm',
                :question   => prompt,
                :validator  => /^[yn]/i,
                :default    => default,
                :errorMsg   => "Please answer y or n.\n\n"
            }
            
            # Get the block, if given, and create the questionnaire object.
            block = if block_given? then Proc::new else nil end
            qnaire = MUES::Questionnaire::new( "Confirmation Dialog", step, &block )

            # Add the shortcut singleton method to the questionnaire object.
            def qnaire.answer
                self.answers[:confirm]
            end

            # Add an even shorter shortcut singleton method.
            def qnaire.confirmed?
                self.answer =~ /^y/i ? true : false
            end

            return qnaire
        end

Public Instance methods

Abort the current session with the specified message. This is a method designed to be used by callback-type answerspecs and pre- and post-processes.

[Source]

# File lib/mues/filters/questionnaire.rb, line 564
        def abort( message="Aborted.\n\n" )
            debugMsg 2, "Aborting questionnaire: %s" % self.muesid
            self.queueOutputEvents MUES::OutputEvent::new( message )
            self.clear
            self.finish
        end

Add one or more steps to the end of the questionnaire. Note that if the questionnaire is in progress when doing this, any answers already given will be cleared, progress reset to step 1, and the first question asked again.

[Source]

# File lib/mues/filters/questionnaire.rb, line 406
        def addSteps( *steps )
            newSteps = checkSteps( *steps )
            debugMsg 2, "Adding %d new step/s: [%s]" %
                [ newSteps.length, newSteps.collect{|s| s[:name]}.join(', ') ]

            @stepsMutex.synchronize( Sync::SH ) {
                @stepsMutex.synchronize( Sync::EX ) {
                    @steps += newSteps
                }

                if self.inProgress?
                    self.clear
                    self.askNextQuestion
                end
            }
        end

Reset the questionnaire, discarding answers given up to this point.

[Source]

# File lib/mues/filters/questionnaire.rb, line 543
        def clear
            debugMsg 1, "Clearing current progress"
            @stepsMutex.synchronize( Sync::EX ) {
                @answers = {}
                @currentStepIndex = -1
                @inProgress = false
                @isFinished = false
            }
        end

Get a reference to the current step. Returns nil if the questionnaire has not yet been started.

[Source]

# File lib/mues/filters/questionnaire.rb, line 453
        def currentStep
            @stepsMutex.synchronize( Sync::SH ) {
                return nil unless @currentStepIndex >= 0
                @steps[ @currentStepIndex ]
            }
        end

Report an error

[Source]

# File lib/mues/filters/questionnaire.rb, line 573
        def error( message )
            debugMsg 2, "Sending error message '%s'" % message.chomp
            self.queueOutputEvents MUES::ErrorOutputEvent::new( message )
        end

Mark the questionnaire as finished and prep it for shutdown

[Source]

# File lib/mues/filters/questionnaire.rb, line 341
        def finish
            debugMsg 1, "Finishing %s questionnaire %s" %
                [ self.name, self.muesid ]

            @inProgress = false
            @finalizer = nil
            super()

            debugMsg 2, "@isFinished = %s" % @isFinished.inspect
        end

Process the specified InputEvents as answers to the unfinished steps.

[Source]

# File lib/mues/filters/questionnaire.rb, line 355
        def handleInputEvents( *events )

            if self.inProgress?
                # Interate until we run out of events to process or steps to feed
                # answers to
                until events.empty? || self.blocked? || ! self.inProgress?
                    self.addAnswer( events.shift.data )
                end

                # Drop events if the questionnaire is blocking
                events.clear if self.blocked? && self.inProgress

                # If all the steps have been completed, call the finalizer.
                unless self.inProgress? || self.finished?
                    debugMsg 2, "Last step answered. Calling finalizer."
                    @result = @finalizer.call( self ) if @finalizer
                    debugMsg 3, "Finalizer returned <%s>" % @result.inspect
                    events << result if result.is_a?( MUES::IOEvent ) ||
                        result.is_a?( MUES::IOEventFilter )
                    self.finish
                end
            end

            return super( *events )
        rescue ::Exception => err
            self.error "Internal questionnaire error: %s at %s from %s\n" % 
                [ err.message, *(err.backtrace[0..1]) ]
            self.abort
        end

Buffer the specified OutputEvents until the questionnaire is finished or aborted, but send the ones that have been queued internally.

[Source]

# File lib/mues/filters/questionnaire.rb, line 389
        def handleOutputEvents( *events )
            debugMsg 5, "Called in thread %s via %s" % 
                [ Thread.current.inspect, caller(1).inspect ]
            self.queueDelayedOutputEvents( *events ) unless events.empty?
            events.clear
            results = super( *events )
            debugMsg 3, "Returning #{results.length} output events."
            return results
        end

[Source]

# File lib/mues/filters/questionnaire.rb, line 233
        def inspect
            "<Questionnaire 0x%x: '%s': (%d/%d steps), ans: %p, finalizer: %p>" % [
                self.object_id * 2,
                @name,
                @currentStepIndex + 1,
                @steps.length,
                @answers,
                @finalizer,
            ]
        end

Send an output event with one or more messages to the user. Each message will have a newline appended to it if it doesn‘t already have at least one.

[Source]

# File lib/mues/filters/questionnaire.rb, line 582
        def message( *messages )
            messages.each {|msg| msg << "\n" unless msg[-1] == "\n"}
            self.queueOutputEvents MUES::OutputEvent::new( messages.join("") )
        end

Add non-blocked output events (such as from the filter itself) to the queue that will go on the next io loop.

[Source]

# File lib/mues/filters/questionnaire.rb, line 463
        def queueDelayedOutputEvents( *events )
            checkEachType( events, MUES::OutputEvent )

            debugMsg 3, "Queueing %d output events for later" % events.length
            @delayedOutputEventMutex.synchronize( Sync::EX ) {
                @delayedOutputEvents.push( *events )
            }

            return @delayedOutputEvents.size
        end

Remove one or more steps from the questionnaire. If no steps are specified, all steps are removed. Note that if the questionnaire is in progress when doing this, any answers already given will be cleared, progress reset to step 1, and the first question asked again.

[Source]

# File lib/mues/filters/questionnaire.rb, line 429
        def removeSteps( *steps )
            @stepsMutex.synchronize( Sync::SH ) {
                if steps.empty?
                    debugMsg 2, "Clearing current steps"
                    @stepsMutex.synchronize( Sync::EX ) {
                        @steps.clear
                    }
                else
                    debugMsg 2, "Removing %d steps" % (@steps & steps).length
                    @stepsMutex.synchronize( Sync::EX ) {
                        @steps -= steps
                    }
                end

                if self.inProgress?
                    self.clear
                    self.askNextQuestion unless @steps.empty?
                end
            }
        end

Clear the questionnaire and ask the first question again.

[Source]

# File lib/mues/filters/questionnaire.rb, line 555
        def reset
            self.clear
            self.askNextQuestion
        end

If the Questionnaire is blocked, restart it and use the specified value as the result returned from the last answer.

[Source]

# File lib/mues/filters/questionnaire.rb, line 590
        def restart( value )
            raise "Not blocked" unless self.blocked?
            raise "Blocked, but no continuation set yet" unless @restartPoint

            self.stopBlocking( value )
        end

Skip the next count steps, and increment the step index by count. If a step has an :onSkip pair (the value of which must be an object which answers ‘call’, such as a Proc or a Method), it will be called as it is skipped with the Questionnaire as an argument, and its return value will be used as the value set in the step‘s answer. If the step doesn‘t have an :onSkip key, but does have a :default pair, its value will be used instead. If it has neither key, the answer will be set to ‘:skipped’. This method returns the new step index.

[Source]

# File lib/mues/filters/questionnaire.rb, line 514
        def skipSteps( count=1 )
            debugMsg 2, "Skipping %d steps" % count

            @stepsMutex.synchronize( Sync::SH ) {
                raise "Cannot skip more steps than there are remaining uncompleted" if
                    @currentStepIndex + count >= @steps.length

                count.times do
                    ans = nil
                    @currentStepIndex += 1
                    step = self.currentStep

                    if step.key?( :onSkip )
                        ans = step[:onSkip].call( self )
                    elsif step.key?( :default )
                        ans = step[:default]
                    else
                        ans = :skipped
                    end

                    @answers[ step[:name].intern ] = ans
                end
            }

            return @currentStepIndex
        end

Start filter notifications for the specified stream.

[Source]

# File lib/mues/filters/questionnaire.rb, line 313
        def start( streamObject )
            raise "Questionnaire cannot be started without "\
                "at least one step" if @steps.empty?

            debugMsg 1, "Starting %s questionnaire %s" %
                [ self.name, self.muesid ]
            results = super( streamObject )

            self.askNextQuestion
            return results
        end

Stop the filter notifications for the specified stream, returning any final events which should be dispatched on behalf of the filter.

[Source]

# File lib/mues/filters/questionnaire.rb, line 328
        def stop( streamObject )

            debugMsg 1, "Stopping %s questionnaire %s" %
                [ self.name, self.muesid ]
            results = super( streamObject )
            self.finish

            results.push( MUES::InputEvent::new('') )
            return results
        end

Returns a human-readable String describing the object

[Source]

# File lib/mues/filters/questionnaire.rb, line 287
        def to_s
            if self.inProgress?
                step = @steps[ @currentStepIndex ]
                "%s Questionnaire: %s: %s (%d of %d steps)." % [
                    self.name,
                    step[:name],
                    step.key?(:question) ? step[:question] : step[:name].capitalize,
                    @currentStepIndex + 1,
                    @steps.length,
                ]
            else
                "%s Questionnaire: Not in progress (%d steps)." % [
                    self.name,
                    @steps.length
                ]
            end
        end

Discard the last count answers given and decrement the step index by count. If a step has an :onUndo pair (the value of which must be an object which answers ‘call’, such as a Proc or a Method), it will be called before the step is undone with the Questionnaire as an argument. Returns the new step index.

[Source]

# File lib/mues/filters/questionnaire.rb, line 480
        def undoSteps( count=1 )
            debugMsg 2, "Undoing %d steps" % count

            @stepsMutex.synchronize( Sync::SH ) {
                raise "Cannot undo more steps than are already complete" unless
                    @currentStepIndex >= count

                @stepsMutex.synchronize( Sync::EX ) {
                    count.times do
                        @currentStepIndex -= 1
                        step = self.currentStep
                        
                        if step.key?( :onUndo )
                            step[:onUndo].call( self )
                        end
                        @answers.delete( step[:name].intern )
                    end
                }
            }

            return @currentStepIndex
        end

Protected Instance methods

Set the specified input data (a String) as the answer for the current step, if it validates.

[Source]

# File lib/mues/filters/questionnaire.rb, line 673
        def addAnswer( data )
            checkType( data, ::String )
            debugMsg 2, "Adding answer '%s' for step %d" %
                [ data, @currentStepIndex ]

            @stepsMutex.synchronize( Sync::SH ) {
                @restartPoint = nil
                step = self.currentStep
                result = self.validateAnswer( step, data )

                # A blocking question returns a Continuation that is called to
                # unblock (but only from a Proc/Method validator -- see
                # #runProcValidator for the jump point)
                if ( result.is_a?(Continuation) )
                    @restartPoint = result

                # Any non-false result means to move to the next question
                elsif result
                    self.answers[ step[:name].intern ] = result
                    self.askNextQuestion or @inProgress = false

                # If the result was nil or false but the questionnaire's still
                # valid, reask the current question.
                elsif self.inProgress?
                    self.reaskCurrentQuestion
                end
            }

        end

Insert the question for the next step into the stream.

[Source]

# File lib/mues/filters/questionnaire.rb, line 603
        def askNextQuestion
            event = nil

            @stepsMutex.synchronize( Sync::SH ) {

                @stepsMutex.synchronize( Sync::EX ) {
                    @inProgress = true
                    @currentStepIndex += 1
                }

                debugMsg 2, "Asking question %d" % @currentStepIndex

                # If there's a next step, ask its question
                if (( step = self.currentStep ))

                    # If the step has a question value, use it, otherwise use the
                    # capitalized name.
                    if step.key?( :question )
                        if step[:question].kind_of? MUES::OutputEvent
                            event = step[:question].dup
                        else
                            if step[:question].respond_to? :call
                                text = step[:question].call( self )
                            else
                                text = step[:question].to_s
                            end

                            if step[ :hidden ]
                                event = MUES::HiddenInputPromptEvent::new( text )
                            else
                                event = MUES::PromptEvent::new( text )
                            end
                        end
                    else
                        event = MUES::PromptEvent::new( step[:name].capitalize + ": " )
                    end

                    debugMsg 4, "Question event is <%s>" % event.inspect

                # Otherwise, we're all out of questions
                else
                    event = nil
                    debugMsg 2, "Last step reached for %s questionnaire %s" %
                        [ self.name, self.muesid ]
                end
            }

            if event
                self.queueOutputEvents( event )
                return true
            else
                return false
            end
        end

Check each of the specified steps for validity.

[Source]

# File lib/mues/filters/questionnaire.rb, line 892
        def checkSteps( *steps )
            checkEachResponse( steps, :[], :key? )

            debugMsg 3, "Checking %d steps for sanity." % steps.length

            if steps.find {|step| !step.key?( :name )}
                raise ArgumentError, "Invalid step: doesn't have a name key."
            end

            return steps
        end

Handle empty input conditions

[Source]

# File lib/mues/filters/questionnaire.rb, line 744
        def handleEmptyInput( step )
            if step.key?( :default )
                result = step[:default]
                debugMsg 3, "Empty data with no validator -- using default '%s'" % result
            else
                debugMsg 2, "Empty data with no validator and no default -- aborting"
                self.abort
                result = nil
            end

            return result
        end

Insert the question for the current step

[Source]

# File lib/mues/filters/questionnaire.rb, line 660
        def reaskCurrentQuestion
            @stepsMutex.synchronize( Sync::SH ) {
                @stepsMutex.synchronize( Sync::EX ) {
                    @currentStepIndex -= 1 unless @currentStepIndex < 0
                }

                self.askNextQuestion
            }
        end

Run an Array step validator - Succeeds (and returns the data unmodified) if array.include? the data.

[Source]

# File lib/mues/filters/questionnaire.rb, line 845
        def runArrayValidator( array, data, step )
            result = nil

            if data.empty?
                result = handleEmptyInput( step )

            elsif array.include?( data )
                result = data

            else
                self.error( step[:errorMsg] || "Invalid input. Must "\
                    "be one of:\n  %s." %
                    array.sort.join(',') )
                result = nil
            end

            return result
        end

Run a Hash step validator - If the data is a key of the hash, succeed and use the corresponding value as the answer. Try both the string and the symbolified string when matching keys.

[Source]

# File lib/mues/filters/questionnaire.rb, line 868
        def runHashValidator( hash, data, step )
            result = nil

            if data.empty?
                result = handleEmptyInput( step )

            elsif hash.has_key?( data )
                result = hash[ data ]

            elsif hash.has_key?( data.intern )
                result = hash[ data.intern ]

            else
                self.error( step[:errorMsg] || "Invalid input. Must "\
                    "be one of:\n  %s." %
                    hash.keys.sort.join(',') )
                result = nil
            end                  

            return result
        end

Run a Proc/Method step validator - Call the given obj (which can be anything that responds to call) with the specified data; if the return value is false, validation failed. If it‘s true, use the original data. Otherwise returns whatever the validator returns as-is.

[Source]

# File lib/mues/filters/questionnaire.rb, line 798
        def runProcValidator( obj, data, step )
            result = obj.call( self, data.dup )

            # Handle a blocking question by creating and returning a
            # Continuation instead of the actual result.
            if step[:blocking]

                # Block further progress and discard any events that come in
                self.startBlocking
                result = callcc {|cc| cc}

            end

            result = data if result.equal?( true )
            result = nil if self.finished?

            return result
        end

Run a Regexp step validator - Succeed if the data matches the regexp; if the regexp has capturing paren-groups, returns the array of captures instead of the whole match.

[Source]

# File lib/mues/filters/questionnaire.rb, line 821
        def runRegexpValidator( regexp, data, step )
            result = nil

            if data.empty?
                result = handleEmptyInput( step )

            elsif (( match = regexp.match( data ) ))
                if match.size > 1
                    result = match.captures
                else
                    result = data
                end
            else
                self.error( step[:errorMsg] || "Invalid input. Must "\
                    "match /#{regexp.to_s}/" )
                result = nil
            end

            return result
        end

Use the specified step‘s validator (a Proc, a Method, a Regexp, an Array, or a Hash) to validate the given data. Returns the validated answer data on success, and false if it fails to validate.

[Source]

# File lib/mues/filters/questionnaire.rb, line 761
        def runStepValidator( data, step )
            checkType( data, ::String )

            validator = step[:validator]
            debugMsg 3, "Validating answer '%s' with a %s validator" %
                [ data, validator.class.name ]

            result = nil

            # Handle the various types of validators.
            case validator
            when Proc, Method
                result = self.runProcValidator( validator, data, step )
                
            when Regexp
                result = self.runRegexpValidator( validator, data, step )

            when Array
                result = self.runArrayValidator( validator, data, step )

            when Hash
                result = self.runHashValidator( validator, data, step )

            # Unhandled validator types
            else
                raise TypeError, "Invalid validator type '#{validator.class.name}'."
            end

            return result
        end

Start blocking on the current answer

[Source]

# File lib/mues/filters/questionnaire.rb, line 729
        def startBlocking
            self.stream.pause
            @blocked = true
        end

Stop blocking on the current answer

[Source]

# File lib/mues/filters/questionnaire.rb, line 736
        def stopBlocking( value )
            @blocked = false
            self.stream.unpause { @restartPoint.call(value) }
            return true
        end

Figure out how to validate the answer for the given step and send it the specified data. Return the result of validation. If the validation failed, the returned result will be nil.

[Source]

# File lib/mues/filters/questionnaire.rb, line 707
        def validateAnswer( step, data )
            result = nil

            # Validate the data if it has a validator.
            if step.key?( :validator )
                result = self.runStepValidator( data, step )
                debugMsg 3, "Validator returned result: %s" % result.inspect

            # Empty input with no validator
            elsif data.empty?
                result = handleEmptyInput( step )

            else
                debugMsg 3, "No validator: Accepting answer as-is"
                result = data
            end

            return result
        end

[Validate]