# Loads a TML application and performs automated processing when told to do so. # # Note that at any given time, the "current" screen has already been processed. For # example, calling #current_screen immediately after loading a document will # return the first screen that appears in the document, but that screen will have # already been processed. When you call #step or #continue, the next screen will # be processed, and calling #current_screen again will return the name of that screen. # # Whenever user input would be required, the program halts, waiting for you to # simulate that input. This class does not provide a direct interface to simulating # user interaction; instead you should set the related variables if applicable, and # jump directly to the desired screen. # # For a proper high-level simulator that allows you to call methods like #follow_link # and #swipe_card, see Rtml::Test::Simulator instead. # # It is important to bear in mind that for the purposes of this class, "user input" # refers to anything that can't be calculated directly, such as EMV app selection. # class Rtml::Test::TmlApplication include Rtml::Test::BuiltinVariables class SimulationError < StandardError; end # A list of TML elements which generally require user input. This doesn't count # elements that *optionally* require input such as +variant+ -- only those that # usually *require* input such as card parser. USER_INPUT_ELEMENTS = %w(card form submit print) attr_reader :screenflow, :variable_scope, :receipt # accepts the raw TML document. def initialize(tml) @tml = Hpricot::XML(tml) @variable_scope = Rtml::Test::VariableScope.new @screenflow = [] @receipt = Receipt.new declare_variables! end def screens @screens ||= ((@tml / "screen") || []) end def current_screen_id current_screen ? current_screen['id'] : nil end def current_screen return @current_screen if defined?(@current_screen) raise SimulationError, "No screens in current document" if screens.empty? @current_screen = Rtml::Test::Screen.new(screens.first) process_current_screen! @current_screen end def stop_execution! @current_screen = nil end # Returns true if user input is expected at this time. If true, program flow will not proceed. def waiting_for_input? current_screen.input? end # Takes a single "step" in the execution of the program. This has no effect and # returns :waiting if user input is required. # # A step is taken even if the #current_state is :looping, so this can be used to force # execution even if #continue normally wouldn't proceed. # # Returns the ID of the current screen as a string otherwise. def step return current_state if ![:running, :looping].include?(current_state) perform_step! end # Like #step, except that user interaction is ignored as if the user had just pressed # the "enter" button. def step_forward return current_state if ![:running, :looping, :waiting, :display].include?(current_state) perform_step!(:ignore_interaction => true) end def current_state if current_screen.nil? :stopped elsif waiting_for_input? :waiting elsif looping? :looping elsif displaying_content? && !timeout? :display else :running end end def timeout? current_screen.timeout > 0 end def displaying_content? current_screen.display? end # Processes screens until a non-running state is achieved, and # then returns that state. # # If :breakpoint is specified, execution will be suspended at the specified screen and # :break will be returned. # # If :ignore_display is specified, then execution will continue past any elements # that would normally cause execution to halt. Normally, this only occurs if there is a # timeout on the screen displaying the content, since a timeout implies automatic # progression. def continue(options = {}) options = { :breakpoint => options } unless options.kind_of?(Hash) breakpoint = options.delete(:breakpoint) breakpoint = breakpoint.to_s if breakpoint && !breakpoint.kind_of?(String) ignore_display = options.delete(:ignore_display) while (state = current_state) == :running || (ignore_display && state == :display) return :break if current_screen_id == breakpoint if ignore_display && state == :display step_forward else step end end return state end # Forces execution to continue to the next screen, even if the current screen is waiting # for something to happen. def continue_forward(options = {}) continue(options) unless step_forward == :display end alias_method :process, :continue def jump_to_screen(id) unmemoize! id = id.to_s unless id.kind_of?(String) if id[/^tmlvar:(.*)$/] id = variable_scope[$~[1]] end @current_screen = find_screen(id) || raise(Rtml::Errors::ApplicationError, "Tried to jump to missing screen #{id.inspect}") process_current_screen! end def trigger_button_press(which) assert_screen_present which = which.to_s unless which.kind_of?(String) which.downcase! which = 'enter' if which == 'ok' if (uri = current_screen.uri_for_hotkey(which)) jump_to_uri uri elsif which == 'enter' # a special case: it makes the terminal continue forward under most conditions. if uri = current_screen.autoselect_hyperlink visit uri else continue_forward end elsif (defaults = ((@tml / "defaults") || []).first) && %w(cancel menu).include?(which) jump_to_uri defaults[which] else raise Rtml::Errors::ApplicationError, "Keypress has no effect on screen #{current_screen_id.inspect}" end end # If the path resembles a screen name that can be found within the current document, then # #jump_to_screen is called. Otherwise, :new_document is thrown with this path as the argument. # # This is mostly for internal processing, such as for hyperlinks. # # (Does this belong in Rtml::Test::Simulator instead? Time will tell as that class is developed.) # def jump_to_uri(path) if find_screen(path) jump_to_screen path else throw :new_document, path end end def find_screen(id) id = id.to_s unless id.kind_of?(String) id = id[1..-1] if id[0] == ?# (screen_element = screens.select { |s| s['id'] == id }.first) ? Rtml::Test::Screen.new(screen_element) : nil end # Returns an array containing which of the possible screens following this one meet # the current constraints. If there are no screens available, or if user intervention # is required at this time, then an empty array is returned. # # See also Rtml::Test::Screen#choices # # options may include :ignore_interaction => true/false, defaults to false def destinations(options = {}) return @destinations if @destinations return [] if !current_screen.choices.empty? && !options[:ignore_interaction] @destinations = possible_variants.select { |dest| conditions_match?(dest) } end # Returns the first element returned by #destinations, or nil if an empty array would # be returned. # # options may include :ignore_interaction => true/false, defaults to false def next_screen_id(options = {}) (nxt = destinations(options).first) ? nxt[:uri] : nil end # Analyzes a hash as returned by #possible_variants and returns true if the conditions # match the current application state (mostly involving the current value of TML variables). # Conditions based on hotkeys return false since this implies user interaction, and # conditions based on timeouts return true since this implies user interaction never # took place. def conditions_match?(hash) if hash.key?(:hotkey) || hash.key?(:key) false elsif hash.key?(:timeout) true elsif hash.keys == [:uri] # a element should always be true, but evaluated as a last resort true else variable_scope.true_condition?(hash) end end # Returns an array of Hashes representing all possible non-user interaction-requiring variants # from this screen. These are returned regardless of whether user interation is required at this time. # # The hashes are laid out thusly: # { :uri => "destination_uri", :key => "hotkey", :timeout => "seconds", :lo => "left_operand", # :op => "operation", :ro => "right_operand" } # # Note that some of these keys may be omitted, according to the TML rules for the +variant+ element. # # For TML +next+ elements, all keys except :uri are omitted. # def possible_variants current_screen.possible_variants end def declare_variable(name, options = {}) variable_scope.declare_variable(name, options) end def update_variables(hash) variable_scope.update_with(hash) end # Returns true if this application seems to be in an endless loop. def looping? snapshot_matches_previous? end private def assert_screen_present unless current_screen raise Rtml::Errors::ApplicationError, "Could not complete action: no screen. (Has processing been terminated by a dead end?)" end end # Called just before moving from one screen to the next, this method sets the various memoized objects # back to nil so that they are re-evaluated for the next screen. def unmemoize! @possible_variants = nil @destinations = nil @current_screen = nil end def declare_variables! declare_builtin_variables! (@tml / "vardcl").each do |vardec| options = { } %w(type value format perms).each { |key| options[key] = vardec[key] if vardec[key] } declare_variable(vardec['name'], options) end end def process_current_screen! current_screen.setvars.each do |setvar| #screenflow.clear variable_scope.perform_operation_on(setvar['name'], {:lo => setvar['lo'], :op => setvar['op'], :ro => setvar['ro']}.optionalize) end end # see #step, except no state checking whatsoever is performed. # options may include :ignore_interaction => true/false, defaults to false def perform_step!(options = {}) take_snapshot! if next_screen_id(options) jump_to_uri(next_screen_id(options)) else # dead end stop_execution! end current_screen_id end # Takes a snapshot and stores it in the screenflow. def take_snapshot! screenflow << Snapshot.new(current_screen, variable_scope) end # Returns true if a new snapshot would be identical to any previous snapshot. def snapshot_matches_previous? screenflow.include?(Snapshot.new(current_screen, variable_scope)) end class Snapshot attr_reader :screen, :variables def initialize(screen, variables) @screen = screen @variables = variables.snapshot end def ==(a_snapshot) @screen == a_snapshot.screen && @variables == a_snapshot.variables end end class Receipt < String def clear replace("") end end end