dexkit 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/guides/llm/OPERATION.md +553 -0
- data/lib/dex/error.rb +17 -0
- data/lib/dex/match.rb +13 -0
- data/lib/dex/operation/async_proxy.rb +115 -0
- data/lib/dex/operation/async_wrapper.rb +35 -0
- data/lib/dex/operation/callback_wrapper.rb +113 -0
- data/lib/dex/operation/jobs.rb +64 -0
- data/lib/dex/operation/lock_wrapper.rb +77 -0
- data/lib/dex/operation/outcome.rb +70 -0
- data/lib/dex/operation/pipeline.rb +60 -0
- data/lib/dex/operation/props_setup.rb +50 -0
- data/lib/dex/operation/record_backend.rb +64 -0
- data/lib/dex/operation/record_wrapper.rb +135 -0
- data/lib/dex/operation/rescue_wrapper.rb +63 -0
- data/lib/dex/operation/result_wrapper.rb +92 -0
- data/lib/dex/operation/safe_wrapper.rb +9 -0
- data/lib/dex/operation/settings.rb +26 -0
- data/lib/dex/operation/transaction_adapter.rb +54 -0
- data/lib/dex/operation/transaction_wrapper.rb +87 -0
- data/lib/dex/operation.rb +192 -0
- data/lib/dex/ref_type.rb +34 -0
- data/lib/dex/test_helpers/assertions.rb +310 -0
- data/lib/dex/test_helpers/execution.rb +28 -0
- data/lib/dex/test_helpers/stubbing.rb +59 -0
- data/lib/dex/test_helpers.rb +146 -0
- data/lib/dex/test_log.rb +55 -0
- data/lib/dex/version.rb +5 -0
- data/lib/dexkit.rb +57 -0
- metadata +160 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# --- Result assertions ---
|
|
6
|
+
|
|
7
|
+
def assert_ok(result, expected = :_not_given, msg = nil, &block)
|
|
8
|
+
assert result.ok?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
|
|
9
|
+
if expected != :_not_given
|
|
10
|
+
assert_equal expected, result.value, msg || "Ok value mismatch"
|
|
11
|
+
end
|
|
12
|
+
yield result.value if block
|
|
13
|
+
result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def refute_ok(result, msg = nil)
|
|
17
|
+
refute result.ok?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
|
|
18
|
+
result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def assert_err(result, code = nil, message: nil, details: nil, msg: nil, &block)
|
|
22
|
+
assert result.error?, msg || "Expected Err, got Ok:\n#{_dex_format_ok(result)}"
|
|
23
|
+
if code
|
|
24
|
+
assert_equal code, result.code, msg || "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
25
|
+
end
|
|
26
|
+
if message
|
|
27
|
+
case message
|
|
28
|
+
when Regexp
|
|
29
|
+
assert_match message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
|
|
30
|
+
else
|
|
31
|
+
assert_equal message, result.message, msg || "Error message mismatch.\n#{_dex_format_err(result)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
details&.each do |key, val|
|
|
35
|
+
assert_equal val, result.details&.dig(key),
|
|
36
|
+
msg || "Error details[:#{key}] mismatch.\n#{_dex_format_err(result)}"
|
|
37
|
+
end
|
|
38
|
+
yield result.error if block
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def refute_err(result, code = nil, msg: nil)
|
|
43
|
+
if code
|
|
44
|
+
if result.error?
|
|
45
|
+
refute_equal code, result.code,
|
|
46
|
+
msg || "Expected result to not have error code #{code.inspect}, but it does.\n#{_dex_format_err(result)}"
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
refute result.error?, msg || "Expected Ok, got Err:\n#{_dex_format_err(result)}"
|
|
50
|
+
end
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- One-liner assertions ---
|
|
55
|
+
|
|
56
|
+
def assert_operation(*args, returns: :_not_given, **params)
|
|
57
|
+
klass = _dex_resolve_subject(args)
|
|
58
|
+
result = klass.new(**params).safe.call
|
|
59
|
+
assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
|
|
60
|
+
if returns != :_not_given
|
|
61
|
+
assert_equal returns, result.value, "Return value mismatch"
|
|
62
|
+
end
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def assert_operation_error(*args, message: nil, details: nil, **params)
|
|
67
|
+
klass, code = _dex_resolve_subject_and_code(args)
|
|
68
|
+
result = klass.new(**params).safe.call
|
|
69
|
+
assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
|
|
70
|
+
if code
|
|
71
|
+
assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
72
|
+
end
|
|
73
|
+
if message
|
|
74
|
+
case message
|
|
75
|
+
when Regexp
|
|
76
|
+
assert_match message, result.message
|
|
77
|
+
else
|
|
78
|
+
assert_equal message, result.message
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
details&.each do |key, val|
|
|
82
|
+
assert_equal val, result.details&.dig(key)
|
|
83
|
+
end
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Contract assertions ---
|
|
88
|
+
|
|
89
|
+
def assert_params(*args)
|
|
90
|
+
if args.last.is_a?(Hash)
|
|
91
|
+
klass_args, type_hash = _dex_split_class_and_hash(args)
|
|
92
|
+
klass = _dex_resolve_subject(klass_args)
|
|
93
|
+
contract = klass.contract
|
|
94
|
+
type_hash.each do |name, type|
|
|
95
|
+
assert contract.params.key?(name),
|
|
96
|
+
"Expected param #{name.inspect} to be declared on #{klass.name || klass}"
|
|
97
|
+
assert_equal type, contract.params[name],
|
|
98
|
+
"Type mismatch for param #{name.inspect}"
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
klass_args, names = _dex_split_class_and_symbols(args)
|
|
102
|
+
klass = _dex_resolve_subject(klass_args)
|
|
103
|
+
contract = klass.contract
|
|
104
|
+
assert_equal names.sort, contract.params.keys.sort,
|
|
105
|
+
"Params mismatch on #{klass.name || klass}.\n Expected: #{names.sort.inspect}\n Actual: #{contract.params.keys.sort.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def assert_accepts_param(*args)
|
|
110
|
+
klass_args, names = _dex_split_class_and_symbols(args)
|
|
111
|
+
klass = _dex_resolve_subject(klass_args)
|
|
112
|
+
contract = klass.contract
|
|
113
|
+
names.each do |name|
|
|
114
|
+
assert contract.params.key?(name),
|
|
115
|
+
"Expected #{klass.name || klass} to accept param #{name.inspect}, but it doesn't.\n Declared params: #{contract.params.keys.inspect}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def assert_success_type(*args)
|
|
120
|
+
klass = if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
121
|
+
args.shift
|
|
122
|
+
else
|
|
123
|
+
_dex_resolve_subject([])
|
|
124
|
+
end
|
|
125
|
+
expected = args.first
|
|
126
|
+
contract = klass.contract
|
|
127
|
+
assert_equal expected, contract.success,
|
|
128
|
+
"Success type mismatch on #{klass.name || klass}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def assert_error_codes(*args)
|
|
132
|
+
klass_args, codes = _dex_split_class_and_symbols(args)
|
|
133
|
+
klass = _dex_resolve_subject(klass_args)
|
|
134
|
+
contract = klass.contract
|
|
135
|
+
assert_equal codes.sort, contract.errors.sort,
|
|
136
|
+
"Error codes mismatch on #{klass.name || klass}.\n Expected: #{codes.sort.inspect}\n Actual: #{contract.errors.sort.inspect}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def assert_contract(*args, params: nil, success: :_not_given, errors: nil)
|
|
140
|
+
klass = _dex_resolve_subject(args)
|
|
141
|
+
contract = klass.contract
|
|
142
|
+
|
|
143
|
+
if params
|
|
144
|
+
case params
|
|
145
|
+
when Array
|
|
146
|
+
assert_equal params.sort, contract.params.keys.sort, "Contract params mismatch"
|
|
147
|
+
when Hash
|
|
148
|
+
params.each do |name, type|
|
|
149
|
+
assert contract.params.key?(name), "Expected param #{name.inspect}"
|
|
150
|
+
assert_equal type, contract.params[name], "Type mismatch for param #{name.inspect}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if success != :_not_given
|
|
156
|
+
assert_equal success, contract.success, "Contract success type mismatch"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if errors
|
|
160
|
+
assert_equal errors.sort, contract.errors.sort, "Contract error codes mismatch"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- Param validation assertions ---
|
|
165
|
+
|
|
166
|
+
def assert_invalid_params(*args, **params)
|
|
167
|
+
klass = _dex_resolve_subject(args)
|
|
168
|
+
assert_raises(Literal::TypeError) { klass.new(**params) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def assert_valid_params(*args, **params)
|
|
172
|
+
klass = _dex_resolve_subject(args)
|
|
173
|
+
klass.new(**params)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- Async assertions ---
|
|
177
|
+
|
|
178
|
+
def assert_enqueues_operation(*args, queue: nil, **params)
|
|
179
|
+
_dex_ensure_active_job_test_helper!
|
|
180
|
+
klass = _dex_resolve_subject(args)
|
|
181
|
+
async_opts = queue ? { queue: queue } : {}
|
|
182
|
+
before_count = enqueued_jobs.size
|
|
183
|
+
klass.new(**params).async(**async_opts).call
|
|
184
|
+
new_jobs = enqueued_jobs[before_count..]
|
|
185
|
+
dex_job = new_jobs.find { |j|
|
|
186
|
+
j[:job] == Dex::Operation::DirectJob || j[:job] == Dex::Operation::RecordJob
|
|
187
|
+
}
|
|
188
|
+
assert dex_job,
|
|
189
|
+
"Expected #{klass.name || klass} to enqueue an async job, but none were enqueued"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def refute_enqueues_operation(&block)
|
|
193
|
+
_dex_ensure_active_job_test_helper!
|
|
194
|
+
before_count = enqueued_jobs.size
|
|
195
|
+
yield
|
|
196
|
+
after_count = enqueued_jobs.size
|
|
197
|
+
assert_equal before_count, after_count,
|
|
198
|
+
"Expected no operations to be enqueued, but #{after_count - before_count} were"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# --- Transaction assertions ---
|
|
202
|
+
|
|
203
|
+
def assert_rolls_back(model_class, &block)
|
|
204
|
+
count_before = model_class.count
|
|
205
|
+
assert_raises(Dex::Error) { yield }
|
|
206
|
+
assert_equal count_before, model_class.count,
|
|
207
|
+
"Expected transaction to roll back, but #{model_class.name} count changed from #{count_before} to #{model_class.count}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def assert_commits(model_class, &block)
|
|
211
|
+
count_before = model_class.count
|
|
212
|
+
yield
|
|
213
|
+
assert count_before < model_class.count,
|
|
214
|
+
"Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- Batch assertions ---
|
|
218
|
+
|
|
219
|
+
def assert_all_succeed(*args, params_list:)
|
|
220
|
+
klass = _dex_resolve_subject(args)
|
|
221
|
+
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
222
|
+
failures = results.each_with_index.reject { |r, _| r.ok? }
|
|
223
|
+
if failures.any?
|
|
224
|
+
msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
|
|
225
|
+
flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
|
|
226
|
+
end
|
|
227
|
+
results
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
|
|
231
|
+
klass = _dex_resolve_subject(args)
|
|
232
|
+
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
233
|
+
failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
|
|
234
|
+
if failures.any?
|
|
235
|
+
msgs = failures.map { |r, i|
|
|
236
|
+
status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
|
|
237
|
+
" [#{i}] #{params_list[i].inspect} => #{status}"
|
|
238
|
+
}
|
|
239
|
+
flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
|
|
240
|
+
end
|
|
241
|
+
results.each_with_index do |r, i|
|
|
242
|
+
if message
|
|
243
|
+
case message
|
|
244
|
+
when Regexp
|
|
245
|
+
assert_match message, r.message,
|
|
246
|
+
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
247
|
+
else
|
|
248
|
+
assert_equal message, r.message,
|
|
249
|
+
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
details&.each do |key, val|
|
|
253
|
+
assert_equal val, r.details&.dig(key),
|
|
254
|
+
"Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
results
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def _dex_format_err(result)
|
|
263
|
+
return "(not an error)" unless result.respond_to?(:error?) && result.error?
|
|
264
|
+
|
|
265
|
+
lines = [" code: #{result.code.inspect}"]
|
|
266
|
+
lines << " message: #{result.message.inspect}" if result.message && result.message != result.code.to_s
|
|
267
|
+
lines << " details: #{result.details.inspect}" if result.details
|
|
268
|
+
lines.join("\n")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def _dex_format_ok(result)
|
|
272
|
+
return "(not ok)" unless result.respond_to?(:ok?) && result.ok?
|
|
273
|
+
|
|
274
|
+
" value: #{result.value.inspect}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def _dex_resolve_subject_and_code(args)
|
|
278
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
279
|
+
klass = args.shift
|
|
280
|
+
code = args.shift
|
|
281
|
+
[klass, code]
|
|
282
|
+
elsif args.first.is_a?(Symbol)
|
|
283
|
+
[_dex_resolve_subject([]), args.shift]
|
|
284
|
+
else
|
|
285
|
+
[_dex_resolve_subject([]), nil]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def _dex_split_class_and_symbols(args)
|
|
290
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
291
|
+
[args[0..0], args[1..]]
|
|
292
|
+
else
|
|
293
|
+
[[], args]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def _dex_split_class_and_hash(args)
|
|
298
|
+
hash = args.pop
|
|
299
|
+
klass_args = args.select { |a| a.is_a?(Class) && a < Dex::Operation }
|
|
300
|
+
[klass_args, hash]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def _dex_ensure_active_job_test_helper!
|
|
304
|
+
return if respond_to?(:assert_enqueued_with)
|
|
305
|
+
|
|
306
|
+
raise "assert_enqueues_operation requires ActiveJob::TestHelper. " \
|
|
307
|
+
"Include it in your test class: `include ActiveJob::TestHelper`"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TestHelpers
|
|
5
|
+
def call_operation(*args, **params)
|
|
6
|
+
klass = _dex_resolve_subject(args)
|
|
7
|
+
klass.new(**params).safe.call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call_operation!(*args, **params)
|
|
11
|
+
klass = _dex_resolve_subject(args)
|
|
12
|
+
klass.new(**params).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def _dex_resolve_subject(args)
|
|
18
|
+
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
19
|
+
args.first
|
|
20
|
+
elsif _dex_test_subject
|
|
21
|
+
_dex_test_subject
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"No operation class specified. Pass it as the first argument or use `testing MyOperation` in your test class."
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TestHelpers
|
|
5
|
+
def stub_operation(klass, returns: nil, error: nil, &block)
|
|
6
|
+
raise ArgumentError, "stub_operation requires a block" unless block
|
|
7
|
+
|
|
8
|
+
opts = if error
|
|
9
|
+
{ error: error }
|
|
10
|
+
else
|
|
11
|
+
{ returns: returns }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Dex::TestWrapper.register_stub(klass, **opts)
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
Dex::TestWrapper.clear_stub(klass)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def spy_on_operation(klass, &block)
|
|
21
|
+
spy = Spy.new(klass)
|
|
22
|
+
yield spy
|
|
23
|
+
spy
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Spy
|
|
27
|
+
def initialize(klass)
|
|
28
|
+
@klass = klass
|
|
29
|
+
@started_at = Dex::TestLog.size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def calls
|
|
33
|
+
Dex::TestLog.calls[@started_at..].select { |e| e.operation_class == @klass }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def called?
|
|
37
|
+
calls.any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def called_once?
|
|
41
|
+
calls.size == 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call_count
|
|
45
|
+
calls.size
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def last_result
|
|
49
|
+
calls.last&.result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def called_with?(**params)
|
|
53
|
+
calls.any? do |entry|
|
|
54
|
+
params.all? { |k, v| entry.params[k] == v }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_log"
|
|
4
|
+
|
|
5
|
+
module Dex
|
|
6
|
+
module TestWrapper
|
|
7
|
+
@_installed = false
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def install!
|
|
11
|
+
return if @_installed
|
|
12
|
+
|
|
13
|
+
Dex::Operation.prepend(self)
|
|
14
|
+
@_installed = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def installed?
|
|
18
|
+
@_installed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Stub registry
|
|
22
|
+
|
|
23
|
+
def stubs
|
|
24
|
+
@_stubs ||= {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_stub(klass)
|
|
28
|
+
stubs[klass]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register_stub(klass, **options)
|
|
32
|
+
stubs[klass] = options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear_stub(klass)
|
|
36
|
+
stubs.delete(klass)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear_all_stubs!
|
|
40
|
+
stubs.clear
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call
|
|
45
|
+
stub = Dex::TestWrapper.find_stub(self.class)
|
|
46
|
+
return _test_apply_stub(stub) if stub
|
|
47
|
+
|
|
48
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
result = nil
|
|
50
|
+
err = nil
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
result = super
|
|
54
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
55
|
+
err = e
|
|
56
|
+
raise
|
|
57
|
+
ensure
|
|
58
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
59
|
+
_test_record_to_log(result, err, duration)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def _test_apply_stub(stub)
|
|
68
|
+
if stub[:error]
|
|
69
|
+
err_opts = stub[:error]
|
|
70
|
+
case err_opts
|
|
71
|
+
when Symbol
|
|
72
|
+
raise Dex::Error.new(err_opts)
|
|
73
|
+
when Hash
|
|
74
|
+
raise Dex::Error.new(err_opts[:code], err_opts[:message], details: err_opts[:details])
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
stub[:returns]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def _test_safe_params
|
|
82
|
+
respond_to?(:to_h) ? to_h : {}
|
|
83
|
+
rescue
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def _test_record_to_log(result, err, duration)
|
|
88
|
+
safe_result = if err
|
|
89
|
+
dex_err = if err.is_a?(Dex::Error)
|
|
90
|
+
err
|
|
91
|
+
else
|
|
92
|
+
Dex::Error.new(:exception, err.message, details: { exception_class: err.class.name })
|
|
93
|
+
end
|
|
94
|
+
Dex::Operation::Err.new(dex_err)
|
|
95
|
+
else
|
|
96
|
+
Dex::Operation::Ok.new(result)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
entry = Dex::TestLog::Entry.new(
|
|
100
|
+
type: "Operation",
|
|
101
|
+
name: self.class.name || self.class.to_s,
|
|
102
|
+
operation_class: self.class,
|
|
103
|
+
params: _test_safe_params,
|
|
104
|
+
result: safe_result,
|
|
105
|
+
duration: duration,
|
|
106
|
+
caller_location: caller_locations(4, 1)&.first
|
|
107
|
+
)
|
|
108
|
+
Dex::TestLog.record(entry)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
module TestHelpers
|
|
113
|
+
def self.included(base)
|
|
114
|
+
Dex::TestWrapper.install!
|
|
115
|
+
base.extend(ClassMethods)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def setup
|
|
119
|
+
super
|
|
120
|
+
Dex::TestLog.clear!
|
|
121
|
+
Dex::TestWrapper.clear_all_stubs!
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module ClassMethods
|
|
125
|
+
def testing(klass)
|
|
126
|
+
@_dex_test_subject = klass
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def _dex_test_subject
|
|
130
|
+
return @_dex_test_subject if defined?(@_dex_test_subject) && @_dex_test_subject
|
|
131
|
+
|
|
132
|
+
superclass._dex_test_subject if superclass.respond_to?(:_dex_test_subject)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def _dex_test_subject
|
|
139
|
+
self.class._dex_test_subject
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
require_relative "test_helpers/execution"
|
|
145
|
+
require_relative "test_helpers/assertions"
|
|
146
|
+
require_relative "test_helpers/stubbing"
|
data/lib/dex/test_log.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TestLog
|
|
5
|
+
Entry = Data.define(:type, :name, :operation_class, :params, :result, :duration, :caller_location)
|
|
6
|
+
|
|
7
|
+
@_entries = []
|
|
8
|
+
@_mutex = Mutex.new
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def record(entry)
|
|
12
|
+
@_mutex.synchronize { @_entries << entry }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def calls
|
|
16
|
+
@_mutex.synchronize { @_entries.dup }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear!
|
|
20
|
+
@_mutex.synchronize { @_entries.clear }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def size
|
|
24
|
+
@_mutex.synchronize { @_entries.size }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
@_mutex.synchronize { @_entries.empty? }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def find(klass, **params)
|
|
32
|
+
@_mutex.synchronize do
|
|
33
|
+
@_entries.select do |entry|
|
|
34
|
+
next false unless entry.operation_class == klass
|
|
35
|
+
params.all? { |k, v| entry.params[k] == v }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def summary
|
|
41
|
+
entries = calls
|
|
42
|
+
return "No operations called." if entries.empty?
|
|
43
|
+
|
|
44
|
+
lines = ["Operations called (#{entries.size}):"]
|
|
45
|
+
entries.each_with_index do |entry, i|
|
|
46
|
+
status = entry.result.ok? ? "OK" : "ERR(#{entry.result.code})"
|
|
47
|
+
duration_ms = entry.duration ? format("%.1fms", entry.duration * 1000) : "n/a"
|
|
48
|
+
lines << " #{i + 1}. #{entry.name} [#{status}] #{duration_ms}"
|
|
49
|
+
lines << " params: #{entry.params.inspect}" unless entry.params.nil? || entry.params.empty?
|
|
50
|
+
end
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/dex/version.rb
ADDED
data/lib/dexkit.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
|
|
5
|
+
require "literal"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
loader = Zeitwerk::Loader.for_gem
|
|
9
|
+
loader.ignore("#{__dir__}/dex")
|
|
10
|
+
loader.setup
|
|
11
|
+
|
|
12
|
+
require_relative "dex/version"
|
|
13
|
+
require_relative "dex/ref_type"
|
|
14
|
+
require_relative "dex/error"
|
|
15
|
+
require_relative "dex/operation"
|
|
16
|
+
|
|
17
|
+
module Dex
|
|
18
|
+
class Configuration
|
|
19
|
+
attr_accessor :record_class, :transaction_adapter
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@record_class = nil
|
|
23
|
+
@transaction_adapter = nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield(configuration)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def record_class
|
|
37
|
+
configuration.record_class
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_backend
|
|
41
|
+
return @record_backend if defined?(@record_backend)
|
|
42
|
+
@record_backend = Operation::RecordBackend.for(record_class)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset_record_backend!
|
|
46
|
+
remove_instance_variable(:@record_backend) if defined?(@record_backend)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def transaction_adapter
|
|
50
|
+
configuration.transaction_adapter
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transaction_adapter=(adapter)
|
|
54
|
+
configuration.transaction_adapter = adapter
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|