dexkit 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +50 -18
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +41 -23
  7. data/guides/llm/FORM.md +202 -61
  8. data/guides/llm/OPERATION.md +49 -20
  9. data/guides/llm/QUERY.md +52 -2
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +85 -8
  13. data/lib/dex/event/handler.rb +18 -0
  14. data/lib/dex/event/metadata.rb +16 -9
  15. data/lib/dex/event/processor.rb +1 -1
  16. data/lib/dex/event/test_helpers.rb +88 -0
  17. data/lib/dex/event/trace.rb +14 -27
  18. data/lib/dex/event.rb +2 -7
  19. data/lib/dex/event_test_helpers.rb +1 -86
  20. data/lib/dex/form/context.rb +27 -0
  21. data/lib/dex/form/export.rb +128 -0
  22. data/lib/dex/form/nesting.rb +2 -0
  23. data/lib/dex/form/uniqueness_validator.rb +17 -1
  24. data/lib/dex/form.rb +119 -3
  25. data/lib/dex/id.rb +38 -0
  26. data/lib/dex/operation/async_proxy.rb +13 -2
  27. data/lib/dex/operation/explain.rb +11 -7
  28. data/lib/dex/operation/jobs.rb +5 -4
  29. data/lib/dex/operation/lock_wrapper.rb +15 -2
  30. data/lib/dex/operation/once_wrapper.rb +24 -15
  31. data/lib/dex/operation/record_backend.rb +15 -1
  32. data/lib/dex/operation/record_wrapper.rb +43 -8
  33. data/lib/dex/operation/test_helpers/assertions.rb +359 -0
  34. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  35. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  36. data/lib/dex/operation/test_helpers.rb +160 -0
  37. data/lib/dex/operation/trace_wrapper.rb +20 -0
  38. data/lib/dex/operation/transaction_adapter.rb +29 -68
  39. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  40. data/lib/dex/operation.rb +2 -0
  41. data/lib/dex/query/backend.rb +13 -0
  42. data/lib/dex/query/export.rb +64 -0
  43. data/lib/dex/query.rb +50 -5
  44. data/lib/dex/ref_type.rb +4 -0
  45. data/lib/dex/test_helpers.rb +4 -139
  46. data/lib/dex/test_log.rb +62 -4
  47. data/lib/dex/trace.rb +291 -0
  48. data/lib/dex/type_coercion.rb +4 -1
  49. data/lib/dex/version.rb +1 -1
  50. data/lib/dexkit.rb +9 -5
  51. metadata +16 -5
  52. data/lib/dex/test_helpers/assertions.rb +0 -333
  53. data/lib/dex/test_helpers/execution.rb +0 -28
  54. data/lib/dex/test_helpers/stubbing.rb +0 -59
  55. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -5,6 +5,7 @@ module Dex
5
5
  extend Dex::Concern
6
6
 
7
7
  def _record_wrap
8
+ _record_validate_backend! if _record_enabled? || _record_has_pending_record?
8
9
  interceptor = Operation::HaltInterceptor.new { yield }
9
10
 
10
11
  if _record_has_pending_record?
@@ -34,6 +35,14 @@ module Dex
34
35
  "record expects true, false, or nil, got: #{enabled.inspect}"
35
36
  end
36
37
  end
38
+
39
+ def _record_required_fields(async: false)
40
+ settings = settings_for(:record)
41
+ fields = %w[name status performed_at error_code error_message error_details]
42
+ fields << "params" if async || settings.fetch(:params, true)
43
+ fields << "result" if settings.fetch(:result, true)
44
+ fields
45
+ end
37
46
  end
38
47
 
39
48
  private
@@ -46,6 +55,13 @@ module Dex
46
55
  record_settings.fetch(:enabled, true)
47
56
  end
48
57
 
58
+ def _record_validate_backend!(async: false)
59
+ Dex.record_backend.ensure_fields!(
60
+ self.class.send(:_record_required_fields, async: async),
61
+ feature: async ? "async recording" : "operation recording"
62
+ )
63
+ end
64
+
49
65
  def _record_has_pending_record?
50
66
  defined?(@_dex_record_id) && @_dex_record_id
51
67
  end
@@ -72,7 +88,7 @@ module Dex
72
88
  else
73
89
  _record_success_attrs(interceptor.result)
74
90
  end
75
- Dex.record_backend.update_record(@_dex_record_id, attrs)
91
+ Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
76
92
  rescue => e
77
93
  _record_handle_error(e)
78
94
  end
@@ -92,7 +108,7 @@ module Dex
92
108
  attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
93
109
 
94
110
  if _record_has_pending_record?
95
- Dex.record_backend.update_record(@_dex_record_id, attrs)
111
+ Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
96
112
  else
97
113
  Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
98
114
  end
@@ -100,8 +116,18 @@ module Dex
100
116
  _record_handle_error(e)
101
117
  end
102
118
 
103
- def _record_base_attrs
104
- attrs = { name: self.class.name }
119
+ def _record_base_attrs(include_id: true)
120
+ trace_snapshot = Dex::Trace.current
121
+ actor_frame = Dex::Trace.actor
122
+
123
+ attrs = {
124
+ name: self.class.name,
125
+ trace_id: Dex::Trace.trace_id,
126
+ trace: trace_snapshot,
127
+ actor_type: actor_frame&.dig(:actor_type),
128
+ actor_id: actor_frame&.dig(:id)&.to_s
129
+ }
130
+ attrs[:id] = @_dex_execution_id if include_id && defined?(@_dex_execution_id) && @_dex_execution_id
105
131
  attrs[:params] = _record_params? ? _record_params : nil
106
132
  attrs
107
133
  end
@@ -142,8 +168,8 @@ module Dex
142
168
  else
143
169
  case result
144
170
  when nil then nil
145
- when Hash then result
146
- else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
171
+ when Hash then _record_sanitize_value(result)
172
+ else { "_dex_value" => _record_sanitize_value(result) } # namespaced key so replay can distinguish wrapped primitives from user hashes
147
173
  end
148
174
  end
149
175
  end
@@ -160,10 +186,19 @@ module Dex
160
186
  case value
161
187
  when NilClass, String, Integer, Float, TrueClass, FalseClass then value
162
188
  when Symbol then value.to_s
163
- when Hash then value.transform_values { |v| _record_sanitize_value(v) }
189
+ when Hash
190
+ value.each_with_object({}) do |(key, nested_value), result|
191
+ result[key.to_s] = _record_sanitize_value(nested_value)
192
+ end
164
193
  when Array then value.map { |v| _record_sanitize_value(v) }
165
194
  when Exception then "#{value.class}: #{value.message}"
166
- else value.to_s
195
+ else
196
+ if value.respond_to?(:as_json)
197
+ serialized = value.as_json
198
+ return _record_sanitize_value(serialized) unless serialized.equal?(value)
199
+ end
200
+
201
+ value.to_s
167
202
  end
168
203
  end
169
204
 
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module TestHelpers
6
+ # --- Result assertions ---
7
+
8
+ def assert_ok(result, expected = :_not_given, msg = nil, &block)
9
+ assert result.ok?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
10
+ if expected != :_not_given
11
+ assert_equal expected, result.value, msg || "Ok value mismatch"
12
+ end
13
+ yield result.value if block
14
+ result
15
+ end
16
+
17
+ def refute_ok(result, msg = nil)
18
+ refute result.ok?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
19
+ result
20
+ end
21
+
22
+ def assert_err(result, code = nil, message: nil, details: nil, msg: nil, &block)
23
+ assert result.error?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
24
+ if code
25
+ assert_equal code, result.code, msg || "Error code mismatch.\n#{_dex_format_err(result)}"
26
+ end
27
+ if message
28
+ case message
29
+ when Regexp
30
+ assert_match message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
31
+ else
32
+ assert_equal message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
33
+ end
34
+ end
35
+ details&.each do |key, val|
36
+ assert_equal val, result.details&.dig(key),
37
+ msg || "Error details[:#{key}] mismatch.\n#{_dex_format_err(result)}"
38
+ end
39
+ yield result.error if block
40
+ result
41
+ end
42
+
43
+ def refute_err(result, code = nil, msg: nil)
44
+ if code
45
+ if result.error?
46
+ refute_equal code, result.code,
47
+ msg || "Expected result to not have error code #{code.inspect}, but it does.\n#{_dex_format_err(result)}"
48
+ end
49
+ else
50
+ refute result.error?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
51
+ end
52
+ result
53
+ end
54
+
55
+ # --- One-liner assertions ---
56
+
57
+ def assert_operation(*args, returns: :_not_given, **params)
58
+ klass = _dex_resolve_subject(args)
59
+ result = klass.new(**params).safe.call
60
+ assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
61
+ if returns != :_not_given
62
+ assert_equal returns, result.value, "Return value mismatch"
63
+ end
64
+ result
65
+ end
66
+
67
+ def assert_operation_error(*args, message: nil, details: nil, **params)
68
+ klass, code = _dex_resolve_subject_and_code(args)
69
+ result = klass.new(**params).safe.call
70
+ assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
71
+ if code
72
+ assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
73
+ end
74
+ if message
75
+ case message
76
+ when Regexp
77
+ assert_match message, result.message
78
+ else
79
+ assert_equal message, result.message
80
+ end
81
+ end
82
+ details&.each do |key, val|
83
+ assert_equal val, result.details&.dig(key)
84
+ end
85
+ result
86
+ end
87
+
88
+ # --- Contract assertions ---
89
+
90
+ def assert_params(*args)
91
+ if args.last.is_a?(Hash)
92
+ klass_args, type_hash = _dex_split_class_and_hash(args)
93
+ klass = _dex_resolve_subject(klass_args)
94
+ contract = klass.contract
95
+ type_hash.each do |name, type|
96
+ assert contract.params.key?(name),
97
+ "Expected param #{name.inspect} to be declared on #{klass.name || klass}"
98
+ assert_equal type, contract.params[name],
99
+ "Type mismatch for param #{name.inspect}"
100
+ end
101
+ else
102
+ klass_args, names = _dex_split_class_and_symbols(args)
103
+ klass = _dex_resolve_subject(klass_args)
104
+ contract = klass.contract
105
+ assert_equal names.sort, contract.params.keys.sort,
106
+ "Params mismatch on #{klass.name || klass}.\n Expected: #{names.sort.inspect}\n Actual: #{contract.params.keys.sort.inspect}"
107
+ end
108
+ end
109
+
110
+ def assert_accepts_param(*args)
111
+ klass_args, names = _dex_split_class_and_symbols(args)
112
+ klass = _dex_resolve_subject(klass_args)
113
+ contract = klass.contract
114
+ names.each do |name|
115
+ assert contract.params.key?(name),
116
+ "Expected #{klass.name || klass} to accept param #{name.inspect}, but it doesn't.\n Declared params: #{contract.params.keys.inspect}"
117
+ end
118
+ end
119
+
120
+ def assert_success_type(*args)
121
+ klass = if args.first.is_a?(Class) && args.first < Dex::Operation
122
+ args.shift
123
+ else
124
+ _dex_resolve_subject([])
125
+ end
126
+ expected = args.first
127
+ contract = klass.contract
128
+ assert_equal expected, contract.success,
129
+ "Success type mismatch on #{klass.name || klass}"
130
+ end
131
+
132
+ def assert_error_codes(*args)
133
+ klass_args, codes = _dex_split_class_and_symbols(args)
134
+ klass = _dex_resolve_subject(klass_args)
135
+ contract = klass.contract
136
+ assert_equal codes.sort, contract.errors.sort,
137
+ "Error codes mismatch on #{klass.name || klass}.\n Expected: #{codes.sort.inspect}\n Actual: #{contract.errors.sort.inspect}"
138
+ end
139
+
140
+ def assert_contract(*args, params: nil, success: :_not_given, errors: nil)
141
+ klass = _dex_resolve_subject(args)
142
+ contract = klass.contract
143
+
144
+ if params
145
+ case params
146
+ when Array
147
+ assert_equal params.sort, contract.params.keys.sort, "Contract params mismatch"
148
+ when Hash
149
+ params.each do |name, type|
150
+ assert contract.params.key?(name), "Expected param #{name.inspect}"
151
+ assert_equal type, contract.params[name], "Type mismatch for param #{name.inspect}"
152
+ end
153
+ end
154
+ end
155
+
156
+ if success != :_not_given
157
+ assert_equal success, contract.success, "Contract success type mismatch"
158
+ end
159
+
160
+ if errors
161
+ assert_equal errors.sort, contract.errors.sort, "Contract error codes mismatch"
162
+ end
163
+ end
164
+
165
+ # --- Param validation assertions ---
166
+
167
+ def assert_invalid_params(*args, **params)
168
+ klass = _dex_resolve_subject(args)
169
+ assert_raises(Literal::TypeError) { klass.new(**params) }
170
+ end
171
+
172
+ def assert_valid_params(*args, **params)
173
+ klass = _dex_resolve_subject(args)
174
+ klass.new(**params)
175
+ end
176
+
177
+ # --- Async assertions ---
178
+
179
+ def assert_enqueues_operation(*args, queue: nil, **params)
180
+ _dex_ensure_active_job_test_helper!
181
+ klass = _dex_resolve_subject(args)
182
+ async_opts = queue ? { queue: queue } : {}
183
+ before_count = enqueued_jobs.size
184
+ klass.new(**params).async(**async_opts).call
185
+ new_jobs = enqueued_jobs[before_count..]
186
+ dex_job = new_jobs.find { |j|
187
+ j[:job] == Dex::Operation::DirectJob || j[:job] == Dex::Operation::RecordJob
188
+ }
189
+ assert dex_job,
190
+ "Expected #{klass.name || klass} to enqueue an async job, but none were enqueued"
191
+ end
192
+
193
+ def refute_enqueues_operation(&block)
194
+ _dex_ensure_active_job_test_helper!
195
+ before_count = enqueued_jobs.size
196
+ yield
197
+ after_count = enqueued_jobs.size
198
+ assert_equal before_count, after_count,
199
+ "Expected no operations to be enqueued, but #{after_count - before_count} were"
200
+ end
201
+
202
+ # --- Trace assertions ---
203
+
204
+ def assert_trace_includes(operation_class, msg: nil)
205
+ expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
206
+ trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
207
+
208
+ assert_includes trace_classes, expected,
209
+ msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
210
+ end
211
+
212
+ def assert_trace_actor(type:, id: :_not_given, msg: nil)
213
+ actor = Dex::Trace.actor
214
+ refute_nil actor, msg || "Expected a trace actor, but no actor is set"
215
+ assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
216
+ assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
217
+ actor
218
+ end
219
+
220
+ def assert_trace_depth(expected, msg: nil)
221
+ actual = Dex::Trace.current.size
222
+ assert_equal expected, actual,
223
+ msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
224
+ end
225
+
226
+ # --- Transaction assertions ---
227
+
228
+ def assert_rolls_back(model_class, &block)
229
+ count_before = model_class.count
230
+ assert_raises(Dex::Error) { yield }
231
+ assert_equal count_before, model_class.count,
232
+ "Expected transaction to roll back, but #{model_class.name} count changed from #{count_before} to #{model_class.count}"
233
+ end
234
+
235
+ def assert_commits(model_class, &block)
236
+ count_before = model_class.count
237
+ yield
238
+ assert count_before < model_class.count,
239
+ "Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
240
+ end
241
+
242
+ # --- Guard assertions ---
243
+
244
+ def assert_callable(*args, **params)
245
+ klass = _dex_resolve_subject(args)
246
+ result = klass.callable(**params)
247
+ assert result.ok?, "Expected operation to be callable, but guards failed:\n#{_dex_format_err(result)}"
248
+ result
249
+ end
250
+
251
+ def refute_callable(*args, **params)
252
+ klass_args, codes = _dex_split_class_and_symbols(args)
253
+ klass = _dex_resolve_subject(klass_args)
254
+ code = codes.first
255
+ result = klass.callable(**params)
256
+ refute result.ok?, "Expected operation to NOT be callable, but all guards passed"
257
+ if code
258
+ failed_codes = result.details.map { |f| f[:guard] }
259
+ assert_includes failed_codes, code,
260
+ "Expected guard :#{code} to fail, but it didn't.\n Failed guards: #{failed_codes.inspect}"
261
+ end
262
+ result
263
+ end
264
+
265
+ # --- Batch assertions ---
266
+
267
+ def assert_all_succeed(*args, params_list:)
268
+ klass = _dex_resolve_subject(args)
269
+ results = params_list.map { |p| klass.new(**p).safe.call }
270
+ failures = results.each_with_index.reject { |r, _| r.ok? }
271
+ if failures.any?
272
+ msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
273
+ flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
274
+ end
275
+ results
276
+ end
277
+
278
+ def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
279
+ klass = _dex_resolve_subject(args)
280
+ results = params_list.map { |p| klass.new(**p).safe.call }
281
+ failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
282
+ if failures.any?
283
+ msgs = failures.map { |r, i|
284
+ status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
285
+ " [#{i}] #{params_list[i].inspect} => #{status}"
286
+ }
287
+ flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
288
+ end
289
+ results.each_with_index do |r, i|
290
+ if message
291
+ case message
292
+ when Regexp
293
+ assert_match message, r.message,
294
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
295
+ else
296
+ assert_equal message, r.message,
297
+ "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
298
+ end
299
+ end
300
+ details&.each do |key, val|
301
+ assert_equal val, r.details&.dig(key),
302
+ "Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
303
+ end
304
+ end
305
+ results
306
+ end
307
+
308
+ private
309
+
310
+ def _dex_format_err(result)
311
+ return "(not an error)" unless result.respond_to?(:error?) && result.error?
312
+
313
+ lines = [" code: #{result.code.inspect}"]
314
+ lines << " message: #{result.message.inspect}" if result.message && result.message != result.code.to_s
315
+ lines << " details: #{result.details.inspect}" if result.details
316
+ lines.join("\n")
317
+ end
318
+
319
+ def _dex_format_ok(result)
320
+ return "(not ok)" unless result.respond_to?(:ok?) && result.ok?
321
+
322
+ " value: #{result.value.inspect}"
323
+ end
324
+
325
+ def _dex_resolve_subject_and_code(args)
326
+ if args.first.is_a?(Class) && args.first < Dex::Operation
327
+ klass = args.shift
328
+ code = args.shift
329
+ [klass, code]
330
+ elsif args.first.is_a?(Symbol)
331
+ [_dex_resolve_subject([]), args.shift]
332
+ else
333
+ [_dex_resolve_subject([]), nil]
334
+ end
335
+ end
336
+
337
+ def _dex_split_class_and_symbols(args)
338
+ if args.first.is_a?(Class) && args.first < Dex::Operation
339
+ [args[0..0], args[1..]]
340
+ else
341
+ [[], args]
342
+ end
343
+ end
344
+
345
+ def _dex_split_class_and_hash(args)
346
+ hash = args.pop
347
+ klass_args = args.select { |a| a.is_a?(Class) && a < Dex::Operation }
348
+ [klass_args, hash]
349
+ end
350
+
351
+ def _dex_ensure_active_job_test_helper!
352
+ return if respond_to?(:assert_enqueued_with)
353
+
354
+ raise "assert_enqueues_operation requires ActiveJob::TestHelper. " \
355
+ "Include it in your test class: `include ActiveJob::TestHelper`"
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module TestHelpers
6
+ def call_operation(*args, **params)
7
+ klass = _dex_resolve_subject(args)
8
+ klass.new(**params).safe.call
9
+ end
10
+
11
+ def call_operation!(*args, **params)
12
+ klass = _dex_resolve_subject(args)
13
+ klass.new(**params).call
14
+ end
15
+
16
+ private
17
+
18
+ def _dex_resolve_subject(args)
19
+ if args.first.is_a?(Class) && args.first < Dex::Operation
20
+ args.first
21
+ elsif _dex_test_subject
22
+ _dex_test_subject
23
+ else
24
+ raise ArgumentError,
25
+ "No operation class specified. Pass it as the first argument or use `testing MyOperation` in your test class."
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module TestHelpers
6
+ def stub_operation(klass, returns: nil, error: nil, &block)
7
+ raise ArgumentError, "stub_operation requires a block" unless block
8
+
9
+ opts = if error
10
+ { error: error }
11
+ else
12
+ { returns: returns }
13
+ end
14
+
15
+ Dex::Operation::TestWrapper.register_stub(klass, **opts)
16
+ yield
17
+ ensure
18
+ Dex::Operation::TestWrapper.clear_stub(klass)
19
+ end
20
+
21
+ def spy_on_operation(klass, &block)
22
+ spy = Spy.new(klass)
23
+ yield spy
24
+ spy
25
+ end
26
+
27
+ class Spy
28
+ def initialize(klass)
29
+ @klass = klass
30
+ @started_at = Dex::TestLog.size
31
+ end
32
+
33
+ def calls
34
+ Dex::TestLog.calls[@started_at..].select { |e| e.operation_class == @klass }
35
+ end
36
+
37
+ def called?
38
+ calls.any?
39
+ end
40
+
41
+ def called_once?
42
+ calls.size == 1
43
+ end
44
+
45
+ def call_count
46
+ calls.size
47
+ end
48
+
49
+ def last_result
50
+ calls.last&.result
51
+ end
52
+
53
+ def called_with?(**params)
54
+ calls.any? do |entry|
55
+ params.all? { |k, v| entry.params[k] == v }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end