bond-spy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.yardopts +1 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE +27 -0
  7. data/README.rst +65 -0
  8. data/Rakefile +6 -0
  9. data/bin/bond_reconcile.py +385 -0
  10. data/bin/setup +5 -0
  11. data/bond.gemspec +36 -0
  12. data/lib/bond.rb +469 -0
  13. data/lib/bond/spec_helper.rb +32 -0
  14. data/lib/bond/targetable.rb +210 -0
  15. data/lib/bond/version.rb +3 -0
  16. data/spec/bond_spec.rb +158 -0
  17. data/spec/bond_targetable_spec.rb +202 -0
  18. data/spec/spec_helper.rb +2 -0
  19. data/spec/test_observations/.gitignore +2 -0
  20. data/spec/test_observations/bond_spec/Bond_with_agents_should_call_doers_before_returning_result.json +14 -0
  21. data/spec/test_observations/bond_spec/Bond_with_agents_should_call_the_function_passed_as_result_if_it_is_callable.json +23 -0
  22. data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_a_single_doer_if_filter_criteria_are_met.json +10 -0
  23. data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_multiple_doers.json +13 -0
  24. data/spec/test_observations/bond_spec/Bond_with_agents_should_not_call_doers_of_overriden_agents.json +8 -0
  25. data/spec/test_observations/bond_spec/Bond_with_agents_should_override_old_agents_with_newer_agents.json +0 -0
  26. data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_an_exception_if_specified_by_agent.json +9 -0
  27. data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_the_result_of_the_value_passed_to_exception_if_callable.json +10 -0
  28. data/spec/test_observations/bond_spec/Bond_with_agents_should_work_with_multiple_agents_for_different_spy_points.json +23 -0
  29. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_override_old_agents_with_newer_agents_unless_theott8glo1xn.json +22 -0
  30. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_combinations_of_filters.json +37 -0
  31. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_function_filters.json +16 -0
  32. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_single_key_value_filters_of_all_types.json +121 -0
  33. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_nested_hashes_and_arrays_with_hash_sorting.json +31 -0
  34. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_with_a_spy_point_name.json +12 -0
  35. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_without_a_spy_point_name.json +10 -0
  36. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_continue_is_returned.json +10 -0
  37. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_none_is_returned.json +14 -0
  38. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_passes_through_blocks.json +10 -0
  39. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_returns_nil__and_mocks__when_an_agent_returns_nil.json +11 -0
  40. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_private_methods.json +6 -0
  41. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_protected_methods.json +6 -0
  42. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_class_method.json +7 -0
  43. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_method_with_variabl19nhijeqoo.json +13 -0
  44. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_a6qc3d4el92.json +33 -0
  45. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_aott8glo1xn.json +20 -0
  46. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_required_andbcgjq06had.json +17 -0
  47. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_normal_method.json +7 -0
  48. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_all_optional_keyword_arguments.json +10 -0
  49. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_variable_keyword_arguments.json +22 -0
  50. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_changes_the_spy_point_naj4gnwvcu8n.json +5 -0
  51. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_errors_when_mocking_is_r9j7wklng0z.json +6 -0
  52. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_ignores_excluded_keys.json +10 -0
  53. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_mocks_when_one_is_specified.json +10 -0
  54. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_w19nhijeqoo.json +10 -0
  55. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_ww8esw1qdxc.json +10 -0
  56. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_included_module_methods.json +6 -0
  57. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_module_methods.json +7 -0
  58. data/tutorials/binary_search_tree/bst.rb +120 -0
  59. data/tutorials/binary_search_tree/bst_spec.rb +82 -0
  60. data/tutorials/binary_search_tree/run_tests.sh +4 -0
  61. data/tutorials/binary_search_tree/test_observations/.gitignore +2 -0
  62. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_with_Bond.json +20 -0
  63. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_without_Bond.json +3 -0
  64. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_correctly_delete_nodes_from_the_BST.json +29 -0
  65. data/tutorials/heat_watcher/heat_watcher.rb +107 -0
  66. data/tutorials/heat_watcher/heat_watcher_spec.rb +116 -0
  67. data/tutorials/heat_watcher/run_tests.sh +4 -0
  68. data/tutorials/heat_watcher/test_observations/.gitignore +2 -0
  69. data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_critical_errors.json +142 -0
  70. data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_warnings_and_switch_back_to_OK_status.json +132 -0
  71. metadata +211 -0
data/bin/setup ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
data/bond.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ require_relative 'lib/bond/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'bond-spy'
5
+ spec.version = Bond::VERSION
6
+ spec.date = '2016-01-28'
7
+
8
+ spec.authors = ['George Necula', 'Erik Krogen']
9
+ spec.email = ['necula@cs.berkeley.edu', 'erikkrogen@gmail.com']
10
+ spec.license = 'BSD-2-Clause-FreeBSD'
11
+
12
+ spec.summary = 'A spy-based testing framework'
13
+ spec.homepage = 'http://github.com/necula01/bond'
14
+ spec.description = <<-EOF
15
+ Bond is a small library that can be used to spy values and mock functions
16
+ during tests. Spying is a replacement for writing the assertEquals in your
17
+ test, which are tedious to write and even more tedious to update when your
18
+ test setup or code inevitably changes. With Bond, you separate what is
19
+ being verified, e.g., the variable named output, from what value it should
20
+ have. This way you can quickly spy several variables, even have structured
21
+ values such as lists or dictionaries, and these values are saved into an
22
+ observation log that is saved for future reference. If the test observations
23
+ are different you have the option to interact with a console or visual tool
24
+ to see what has changed, and whether the reference set of observations need
25
+ to be updated.
26
+ EOF
27
+
28
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
29
+ spec.test_files = `git ls-files -z -- spec`.split("\x0")
30
+ spec.bindir = 'bin'
31
+
32
+ spec.required_ruby_version = '>= 2.1'
33
+ spec.add_development_dependency 'bundler', '~> 1.10'
34
+ spec.add_development_dependency 'rake', '~> 10.0'
35
+ spec.add_development_dependency 'rspec', '~> 3.0'
36
+ end
data/lib/bond.rb ADDED
@@ -0,0 +1,469 @@
1
+ require 'singleton'
2
+ require 'json'
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+
6
+ # Singleton class providing the core functionality of Bond. You will generally
7
+ # access this through the {BondTargetable#bond} method exported to you when you
8
+ # `extend` BondTargetable, but it can also be accessed (e.g. for functions not
9
+ # contained in a class/module) via `Bond.instance`.
10
+ class Bond
11
+ include Singleton
12
+ # TODO ETK make this able to use other test frameworks as well
13
+
14
+ # Maximum number of characters to allow in any test file name.
15
+ # File names which would be longer than this will be truncated
16
+ # to 10 fewer characters than the max, and a hash of the full
17
+ # name will be appended to uniquely identify the file.
18
+ MAX_FILE_NAME_LENGTH = 100
19
+
20
+ # Returns true if Bond is currently active (in testing mode), else false.
21
+ # If this returns false, you can safely assume that calls to {#spy} will
22
+ # have no effect.
23
+ def active?
24
+ !@current_test.nil?
25
+ end
26
+
27
+ # Change the settings for Bond, overriding anything which was previously set by
28
+ # {#start_test}. Accepts any of the keyword arguments that {#start_test} does
29
+ # except for `test_name`, which cannot be changed.
30
+ # @param (see #start_test)
31
+ def settings(spy_groups: nil, observation_directory: nil, reconcile: nil)
32
+ raise 'not yet implemented' unless spy_groups.nil? # TODO spy_groups
33
+ @observation_directory = observation_directory unless observation_directory.nil?
34
+ @reconcile = reconcile unless @reconcile.nil?
35
+ end
36
+
37
+ # Enable testing for Bond. When using RSpec, this will be called automatically
38
+ # for you when you `include_context :bond`, to which you can pass all of the
39
+ # same keyword arguments as `start_test`.
40
+ # @param rspec_test The current test that is being run through RSpec.
41
+ # @param test_name [String] The name of the current test. If not provided,
42
+ # the default is the test file concatenated with the full description
43
+ # string of the RSpec test, e.g. for a test within my_class_spec.rb that
44
+ # appeared as:
45
+ #
46
+ # ```
47
+ # describe MyClass do
48
+ # include_context :bond, observation_directory: 'spec/observations'
49
+ #
50
+ # context 'when nothing is wrong' do
51
+ # it 'should work!' do
52
+ # ... test code ...
53
+ # # `include_context :bond` automatically exports a `bond` variable
54
+ # # for you to access for e.g. `spy` and `deploy_agent`
55
+ # bond.spy('spy_point', key: value, ...)
56
+ # ... test code ...
57
+ # end
58
+ # end
59
+ # end
60
+ # ```
61
+ #
62
+ # `test_name` would be 'my_class_spec.MyClass_when_nothing_is_wrong_should_work_'
63
+ # (note that all non-alphanumeric characters except periods are replaced with
64
+ # an underscore).
65
+ # @param spy_groups NOT YET IMPLEMENTED.
66
+ # @param observation_directory [String] Path to the directory where
67
+ # the observations for this test should be stored. The default is
68
+ # a 'test_observations' directory located in the same directory
69
+ # as the file containing the current test. Test observations will
70
+ # be stored within this directory. Any hierarchy (as specified by .)
71
+ # in the test name becomes a directory hierarchy, e.g. a test name of
72
+ # 'bond.my_tests.test_name' would be stored at
73
+ # '`{observation_directory}`/bond/my_tests/test_name.json'
74
+ # @param reconcile The action to take when there are differences found between
75
+ # the reference versions of test output and the current test output.
76
+ # Should be one of:
77
+ #
78
+ # - **`:console`** - interactive console prompts to decide what to merge (default)
79
+ # - `:abort` - don't accept any new changes
80
+ # - `:accept` - accept all new changes
81
+ # - `:kdiff3` - use the kdiff3 graphical merge tool to reconcile differences
82
+ #
83
+ def start_test(rspec_test, test_name: nil, spy_groups: nil,
84
+ observation_directory: nil, reconcile: nil)
85
+
86
+ @observations = []
87
+ @spy_agents = Hash.new { |hash, key|
88
+ hash[key] = []
89
+ }
90
+ @observation_directory = nil
91
+ @current_test = rspec_test
92
+ @reconcile = reconcile
93
+
94
+ if test_name.nil?
95
+ test_file = @current_test.metadata[:file_path]
96
+ # TODO ETK allow other characters besides alphanumeric?
97
+ @test_name = File.basename(test_file, File.extname(test_file)) + '.' +
98
+ @current_test.metadata[:full_description].gsub(/[^A-z0-9.]/, '_')
99
+ else
100
+ @test_name = test_name
101
+ end
102
+
103
+ settings(spy_groups: spy_groups, observation_directory: observation_directory, reconcile: reconcile)
104
+ end
105
+
106
+ # The main entrypoint, used to observe some program state. If Bond is not active,
107
+ # does nothing. Observes all keyword arguments within `observation`, recording them
108
+ # to be written out to a file at the end of the current test. A deep copy
109
+ # of the arguments is made, all hashes (at any level of nesting) are sorted, and
110
+ # any object that is not an Array or Hash will have its `Object#clone` method called.
111
+ # If it is not cloneable (`clone` throws an error), the original object will be used.
112
+ # The arguments are then JSON-serialized. For any object which has a `to_json` method,
113
+ # this will be called to serialize it. Note that `to_json` takes one argument which
114
+ # is a JSON::Ext::Generator::State object; you should pass this object into any `to_json`
115
+ # calls you make within your `to_json` function.
116
+ # @param spy_point_name [#to_s] The name of this spy point. Will be used to subsequently
117
+ # refer to this point for, e.g., {#deploy_agent}. This name also gets printed as
118
+ # part of the observation with the key `__spy_point__`.
119
+ # @param observation Keyword arguments which should be observed.
120
+ # @return if an agent has been set for this spy point, this will return whatever value
121
+ # is specified by that agent. Otherwise, returns `:agent_result_none`.
122
+ def spy(spy_point_name=nil, **observation)
123
+ return :agent_result_none unless active? # If we're not testing, don't do anything
124
+
125
+ spy_point_name = spy_point_name.nil? ? nil : spy_point_name.to_s
126
+
127
+ observation[:__spy_point__] = spy_point_name unless spy_point_name.nil?
128
+ observation = deep_clone_sort_hashes(observation)
129
+ active_agent = spy_point_name.nil? ? nil : @spy_agents[spy_point_name].find { |agent| agent.process?(observation) }
130
+
131
+ res = :agent_result_none
132
+ begin
133
+ unless active_agent.nil?
134
+ active_agent.do(observation)
135
+ res = active_agent.result(observation)
136
+ end
137
+ ensure
138
+ formatted = format_observation(observation, active_agent)
139
+ @observations <<= formatted
140
+ #TODO ETK printing
141
+ puts "Observing: #{formatted} #{", returning <#{res.to_s}>" if res != :agent_result_none}"
142
+ end
143
+
144
+ res
145
+ end
146
+
147
+ # Deploy an agent to watch a specific spy point. The most recently deployed agent
148
+ # will always take precedence over any previous agents for a given spy point.
149
+ # However, if the most recent agent does not apply to the current observation
150
+ # (because its filters do not match), the next most recent agent will be used,
151
+ # and so on. At most one agent will be applied.
152
+ # @param spy_point_name [#to_s] The name of the spy point for which to deploy this agent
153
+ # @param (see SpyAgent#initialize)
154
+ def deploy_agent(spy_point_name, **opts)
155
+ raise 'You must enable testing before using deploy_agent' unless active?
156
+ spy_point_name = spy_point_name.to_s
157
+ @spy_agents[spy_point_name] = @spy_agents[spy_point_name].unshift(SpyAgent.new(**opts))
158
+ end
159
+
160
+ private
161
+
162
+ # Clean up from the current test. Marks Bond as no longer active, and saves
163
+ # all of the current observations to a file specified by {#observation_file_name}.
164
+ # Creates whatever directory structure necessary for this, and adds a .gitignore
165
+ # file to ignore Bond's temporary output (if one does not already exist). Then begins
166
+ # the reconciliation process. If the new observations are different
167
+ # from the reference files, reconciles them using whatever means specified
168
+ # by the `reconcile` setting.
169
+ # @return If the test failed, returns `:test_fail`. If reconciliation fails
170
+ # (new changes are not accepted), returns `:bond_fail`. Else returns `:pass`
171
+ def finish_test
172
+ fname = observation_file_name
173
+ fdir = File.dirname(fname)
174
+ unless File.directory?(fdir)
175
+ FileUtils.mkdir_p(fdir)
176
+ top_git_ignore = File.join(observation_directory, '.gitignore')
177
+ unless File.file?(top_git_ignore)
178
+ # TODO ETK make this configurable in case you don't use git?
179
+ File.open(top_git_ignore, 'w') do |outfile|
180
+ outfile.print("*_now.json\n*.diff\n")
181
+ end
182
+ end
183
+ end
184
+
185
+ test_fail = !@current_test.exception.nil?
186
+
187
+ ref_file = fname + '.json'
188
+ cur_file = fname + '_now.json'
189
+ File.delete(cur_file) if File.exists?(cur_file)
190
+ save_observations(cur_file)
191
+
192
+ reconcile_result = reconcile_observations(ref_file, cur_file,
193
+ test_fail ? 'Test had failure(s)!' : nil)
194
+ return :test_fail if test_fail
195
+ return reconcile_result
196
+ ensure
197
+ @current_test = nil
198
+ end
199
+
200
+ # Reconcile observations, for now using an external Python script.
201
+ # Depending on the `reconcile` setting, will take action to reconcile the differences.
202
+ # @param ref_file [String] Path to the accepted/reference test output.
203
+ # If this does not exist, it will be treated as an empty file.
204
+ # @param cur_file [String] Path to the current test output.
205
+ # @param no_save [nil, String] If not `nil`, `ref_file` will *not* be overwritten
206
+ # and the string will be displayed as the reason why saving is not allowed.
207
+ # @return `:pass` if the reconciliation succeeds, else `:bond_fail`
208
+ def reconcile_observations(ref_file, cur_file, no_save=nil)
209
+ bond_reconcile_script = File.absolute_path(File.join(File.dirname(File.dirname(__FILE__)), 'bin', 'bond_reconcile.py'))
210
+ unless File.exists?(bond_reconcile_script)
211
+ raise "Cannot find the bond_reconcile script: #{bond_reconcile_script}"
212
+ end
213
+
214
+ cmd = "#{Shellwords.shellescape(bond_reconcile_script)} " +
215
+ "--reference #{Shellwords.shellescape(ref_file)} " +
216
+ "--current #{Shellwords.shellescape(cur_file)} " +
217
+ "--test #{Shellwords.shellescape(@test_name)} " +
218
+ (@reconcile.nil? ? '' : "--reconcile #{@reconcile.to_s}") +
219
+ (no_save.nil? ? '' : "--no-save #{Shellwords.shellescape(no_save.to_s)}")
220
+ puts "Running: #{cmd}"
221
+ code = system(cmd)
222
+ code ? :pass : :bond_fail
223
+ end
224
+
225
+ # Save all current observations to a file. Assumes that `@observations`
226
+ # has already been JSON-serialized and outputs them all as a JSON array.
227
+ # @param fname [String] Path where the file should be saved.
228
+ def save_observations(fname)
229
+ File.open(fname, 'w') do |f|
230
+ f.print("[\n#{@observations.join(",\n")}\n]\n")
231
+ end
232
+ end
233
+
234
+ # Return the file name where observations for the current test should be
235
+ # stored. Any hierarchy (as specified by .) in the test name becomes
236
+ # a directory hierarchy, e.g. a test name of 'bond.my_tests.test_name'
237
+ # would be stored at '`{base_directory}`/bond/my_tests/test_name.json'
238
+ # If any portion of the file name (i.e. a directory or file name) is
239
+ # longer than {#MAX_FILE_NAME_LENGTH} - 5 (to account for a possible
240
+ # `.json` extension), reduce the length to 10 characters less than
241
+ # this and fill the remaining 10 characters with a hash of the full name.
242
+ def observation_file_name
243
+ name_array = @test_name.split('.').map do |name|
244
+ if name.length <= MAX_FILE_NAME_LENGTH - 5
245
+ name
246
+ else
247
+ # Using djb2 hash algorithm translated from http://www.cse.yorku.ca/~oz/hash.html
248
+ name_hash = name.chars.inject(5381) { |sum, c| ((sum << 5) + sum) + c.to_i }
249
+ # Take start of name, up to first 10 chars of the hash as base 36 (alphanumerics)
250
+ name[0, MAX_FILE_NAME_LENGTH - 15] + name_hash.to_s(36)[0, 10]
251
+ end
252
+ end
253
+ File.join(observation_directory, name_array)
254
+ end
255
+
256
+ # Return the directory where observations should be stored
257
+ # This can be specified with the `observation_directory` setting.
258
+ # If not set, it will be a 'test_observations' directory located
259
+ # in the same directory as the file containing the current test.
260
+ def observation_directory
261
+ return @observation_directory unless @observation_directory.nil?
262
+ test_file = @current_test.metadata[:file_path]
263
+ File.join(File.dirname(File.absolute_path(test_file)), 'test_observations')
264
+ end
265
+
266
+ # Formats the observation hash. Currently, this just JSON-serializes
267
+ # the hash. If any objects encountered have a `to_json` method, it will
268
+ # be called to serialize the object. Note that `to_json` takes one argument
269
+ # which is a JSON::Ext::Generator::State object; you should pass this object
270
+ # into any `to_json` calls you make within your `to_json` function.
271
+ # @param observation [Hash] Observations to be formatted.
272
+ # @return The formatted hash.
273
+ def format_observation(observation, agent = nil)
274
+ # TODO ETK actually have formatters
275
+ JSON.pretty_generate(observation, indent: ' '*4)
276
+ end
277
+
278
+ # Deep-clones an object while sorting any Hashes at any depth:
279
+ #
280
+ # - Hash: Creates a new hash containing all of the old key-value
281
+ # pairs sorted by key
282
+ # - Array: Creates a new array with the old contents *not* sorted
283
+ # - Other: Attempts to call Object#clone. If this fails (results in #TypeError)
284
+ # then the object is returned as-is (assumes that non-cloneable objects
285
+ # are immutable and thus don't need cloning)
286
+ #
287
+ # @param obj The object to be cloned.
288
+ # @return The deep-clone with hashes sorted.
289
+ def deep_clone_sort_hashes(obj)
290
+ if obj.is_a?(Hash)
291
+ {}.tap do |new|
292
+ obj.sort.each do |k, v|
293
+ new[k] = deep_clone_sort_hashes(v)
294
+ end
295
+ end
296
+ elsif obj.is_a?(Array) # Don't sort arrays, just clone
297
+ obj.map { |x| deep_clone_sort_hashes(x) }
298
+ else
299
+ begin
300
+ obj.clone
301
+ rescue TypeError # Some types, e.g. Fixnum and Symbol, can't be cloned
302
+ obj
303
+ end
304
+ end
305
+ end
306
+
307
+ end
308
+
309
+ # Represents an agent deployed by {Bond#deploy_agent}. Takes
310
+ # action on spy points depending on the options specified upon
311
+ # initialization.
312
+ # @api private
313
+ class SpyAgent
314
+
315
+ # Initialize, setting the options for this SpyAgent.
316
+ # @param opts Key-value pairs that control whether the agent is
317
+ # active and what it does. The following keys are recognized:
318
+ #
319
+ # - Keys that restrict for which invocations of bond.spy this
320
+ # agent is active. All of these conditions must be true for
321
+ # the agent to be the active one:
322
+ #
323
+ # - `key: val` - only when the observation dictionary contains
324
+ # the `key` with the given value
325
+ # - `key__contains: substr` - only when the observation dictionary
326
+ # contains the `key` with a string value that contains the given substr.
327
+ # - `key__startswith: substr` - only when the observation dictionary
328
+ # contains the `key` with a string value that starts with the given substr.
329
+ # - `key__endswith: substr` - only when the observation dictionary contains
330
+ # the `key` with a string value that ends with the given substr.
331
+ # - `filter: func` - only when the given func returns true when passed
332
+ # observation dictionary. The function should not make changes to
333
+ # the observation dictionary. Uses the observation before formatting.
334
+ #
335
+ # - Keys that control what the observer does when processed:
336
+ #
337
+ # - `do: func` - executes the given function with the observation dictionary.
338
+ # func can also be a list of functions, executed in order.
339
+ # The function should not make changes to the observation dictionary.
340
+ # Uses the observation before formatting.
341
+ #
342
+ # - Keys that control what the corresponding spy returns (by default `:agent_result_none`):
343
+ #
344
+ # - `exception: x` - the call to bond.spy throws the given exception. If `x`
345
+ # is a function it is invoked on the observation dictionary to compute
346
+ # the exception to throw. The function should not make changes to the
347
+ # observation dictionary. Uses the observation before formatting.
348
+ # - `result: x` - the call to bond.spy returns the given value. If `x` is a
349
+ # function it is invoked on the observe argument dictionary to compute
350
+ # the value to return. If the function throws an exception then the
351
+ # spied function throws an exception. The function should not make
352
+ # changes to the observation dictionary. Uses the observation before
353
+ # formatting.
354
+ #
355
+ # - Keys that control how the observation is saved. This is processed after all
356
+ # the above functions. **NOT YET AVAILABLE**
357
+ #
358
+ # - `formatter: func` - If specified, a function that is given the observation and
359
+ # can update it in place. The formatted observation is what gets serialized and saved.
360
+ #
361
+ def initialize(**opts)
362
+ # TODO ETK needs formatters
363
+ @result_spec = :agent_result_none
364
+ @exception_spec = nil
365
+ @doers = []
366
+ @filters = []
367
+
368
+ opts.each do |k, v|
369
+ case k.to_s # Convert to string in case it was passed as a symbol
370
+ when 'result'
371
+ @result_spec = v
372
+ when 'exception'
373
+ @exception_spec = v
374
+ when 'do'
375
+ @doers = [*v]
376
+ else # Must be a filter
377
+ @filters <<= SpyAgentFilter.new(k.to_s, v)
378
+ end
379
+ end
380
+ end
381
+
382
+ # Checks if this agent should process this observation
383
+ # @return true iff this agent should process this observation
384
+ def process?(observation)
385
+ @filters.empty? || @filters.all? do |filter|
386
+ filter.accept?(observation)
387
+ end
388
+ end
389
+
390
+ # Carries out all of the actions specified by the `do` option
391
+ def do(observation)
392
+ @doers.each { |doer| doer.call(observation) }
393
+ end
394
+
395
+ # Gets the result that this agent should return, dependent on the
396
+ # `result` and `exception` options. If neither is present,
397
+ # returns `:agent_result_none`.
398
+ def result(observation)
399
+ unless @exception_spec.nil?
400
+ raise @exception_spec.respond_to?(:call) ? @exception_spec.call(observation) : @exception_spec
401
+ end
402
+
403
+ if !@result_spec.nil? && @result_spec.respond_to?(:call)
404
+ @result_spec.call(observation)
405
+ else
406
+ @result_spec
407
+ end
408
+ end
409
+ end
410
+
411
+ # Filters used to determine whether or not a {SpyAgent} should
412
+ # be applied to a given observation.
413
+ # @api private
414
+ class SpyAgentFilter
415
+
416
+ # Initialize this filter.
417
+ # @see Bond#deploy_agent
418
+ # @param filter_key [#to_s] The key for the filter.
419
+ # @param filter_value The value for the filter.
420
+ def initialize(filter_key, filter_value)
421
+ @field_name = nil
422
+ @filter_func = nil
423
+
424
+ filter_key = filter_key.to_s
425
+ if filter_key == 'filter'
426
+ raise 'When using filter, passed value must be callable' unless filter_value.respond_to?(:call)
427
+ @filter_func = filter_value
428
+ return
429
+ end
430
+
431
+ key_parts = filter_key.split('__')
432
+ if key_parts.length == 1
433
+ @field_name = key_parts[0]
434
+ @filter_func = lambda { |val| val == filter_value }
435
+ elsif key_parts.length == 2
436
+ @field_name = key_parts[0]
437
+ case key_parts[1]
438
+ when 'exact','eq'
439
+ @filter_func = lambda { |val| val == filter_value }
440
+ when 'startswith'
441
+ @filter_func = lambda { |val| val.start_with?(filter_value) }
442
+ when 'endswith'
443
+ @filter_func = lambda { |val| val.end_with?(filter_value) }
444
+ when 'contains'
445
+ @filter_func = lambda { |val| val.include?(filter_value) }
446
+ else
447
+ raise "Unknown operator: #{key_parts[1]}"
448
+ end
449
+ else
450
+ raise "Invalid key passed in: #{filter_key}"
451
+ end
452
+ end
453
+
454
+ # Return true iff the provided observation meets this filter.
455
+ def accept?(observation)
456
+ if @field_name.nil?
457
+ @filter_func.call(observation)
458
+ elsif observation.has_key?(@field_name)
459
+ @filter_func.call(observation[@field_name])
460
+ elsif observation.has_key?(@field_name.to_sym)
461
+ @filter_func.call(observation[@field_name.to_sym])
462
+ else
463
+ false
464
+ end
465
+ end
466
+
467
+ end
468
+
469
+ require_relative 'bond/targetable'