cmdx 1.0.0 → 1.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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/prompts/rspec.md +20 -0
  3. data/.cursor/prompts/yardoc.md +8 -0
  4. data/.rubocop.yml +5 -0
  5. data/CHANGELOG.md +101 -49
  6. data/README.md +2 -1
  7. data/docs/ai_prompts.md +10 -0
  8. data/docs/basics/call.md +11 -2
  9. data/docs/basics/chain.md +10 -1
  10. data/docs/basics/context.md +9 -0
  11. data/docs/basics/setup.md +9 -0
  12. data/docs/callbacks.md +14 -37
  13. data/docs/configuration.md +68 -27
  14. data/docs/getting_started.md +11 -0
  15. data/docs/internationalization.md +148 -0
  16. data/docs/interruptions/exceptions.md +10 -1
  17. data/docs/interruptions/faults.md +11 -2
  18. data/docs/interruptions/halt.md +9 -0
  19. data/docs/logging.md +14 -4
  20. data/docs/middlewares.md +53 -43
  21. data/docs/outcomes/result.md +9 -0
  22. data/docs/outcomes/states.md +9 -0
  23. data/docs/outcomes/statuses.md +9 -0
  24. data/docs/parameters/coercions.md +58 -38
  25. data/docs/parameters/defaults.md +10 -1
  26. data/docs/parameters/definitions.md +9 -0
  27. data/docs/parameters/namespacing.md +9 -0
  28. data/docs/parameters/validations.md +8 -67
  29. data/docs/testing.md +22 -13
  30. data/docs/tips_and_tricks.md +9 -0
  31. data/docs/workflows.md +14 -4
  32. data/lib/cmdx/.DS_Store +0 -0
  33. data/lib/cmdx/callback.rb +36 -56
  34. data/lib/cmdx/callback_registry.rb +82 -73
  35. data/lib/cmdx/chain.rb +65 -122
  36. data/lib/cmdx/chain_inspector.rb +22 -115
  37. data/lib/cmdx/chain_serializer.rb +17 -148
  38. data/lib/cmdx/coercion.rb +49 -0
  39. data/lib/cmdx/coercion_registry.rb +94 -0
  40. data/lib/cmdx/coercions/array.rb +18 -36
  41. data/lib/cmdx/coercions/big_decimal.rb +21 -33
  42. data/lib/cmdx/coercions/boolean.rb +21 -40
  43. data/lib/cmdx/coercions/complex.rb +18 -31
  44. data/lib/cmdx/coercions/date.rb +20 -39
  45. data/lib/cmdx/coercions/date_time.rb +22 -39
  46. data/lib/cmdx/coercions/float.rb +19 -32
  47. data/lib/cmdx/coercions/hash.rb +22 -41
  48. data/lib/cmdx/coercions/integer.rb +20 -33
  49. data/lib/cmdx/coercions/rational.rb +20 -32
  50. data/lib/cmdx/coercions/string.rb +23 -31
  51. data/lib/cmdx/coercions/time.rb +24 -40
  52. data/lib/cmdx/coercions/virtual.rb +14 -31
  53. data/lib/cmdx/configuration.rb +57 -171
  54. data/lib/cmdx/context.rb +22 -165
  55. data/lib/cmdx/core_ext/hash.rb +42 -67
  56. data/lib/cmdx/core_ext/module.rb +35 -79
  57. data/lib/cmdx/core_ext/object.rb +63 -98
  58. data/lib/cmdx/correlator.rb +40 -156
  59. data/lib/cmdx/error.rb +37 -202
  60. data/lib/cmdx/errors.rb +165 -202
  61. data/lib/cmdx/fault.rb +55 -158
  62. data/lib/cmdx/faults.rb +26 -137
  63. data/lib/cmdx/immutator.rb +22 -109
  64. data/lib/cmdx/lazy_struct.rb +103 -187
  65. data/lib/cmdx/log_formatters/json.rb +14 -40
  66. data/lib/cmdx/log_formatters/key_value.rb +14 -40
  67. data/lib/cmdx/log_formatters/line.rb +14 -48
  68. data/lib/cmdx/log_formatters/logstash.rb +14 -57
  69. data/lib/cmdx/log_formatters/pretty_json.rb +14 -50
  70. data/lib/cmdx/log_formatters/pretty_key_value.rb +13 -46
  71. data/lib/cmdx/log_formatters/pretty_line.rb +16 -54
  72. data/lib/cmdx/log_formatters/raw.rb +19 -49
  73. data/lib/cmdx/logger.rb +20 -82
  74. data/lib/cmdx/logger_ansi.rb +18 -75
  75. data/lib/cmdx/logger_serializer.rb +24 -114
  76. data/lib/cmdx/middleware.rb +38 -60
  77. data/lib/cmdx/middleware_registry.rb +81 -77
  78. data/lib/cmdx/middlewares/correlate.rb +41 -226
  79. data/lib/cmdx/middlewares/timeout.rb +46 -185
  80. data/lib/cmdx/parameter.rb +120 -198
  81. data/lib/cmdx/parameter_evaluator.rb +231 -0
  82. data/lib/cmdx/parameter_inspector.rb +25 -56
  83. data/lib/cmdx/parameter_registry.rb +59 -84
  84. data/lib/cmdx/parameter_serializer.rb +23 -74
  85. data/lib/cmdx/railtie.rb +24 -107
  86. data/lib/cmdx/result.rb +254 -260
  87. data/lib/cmdx/result_ansi.rb +19 -85
  88. data/lib/cmdx/result_inspector.rb +27 -68
  89. data/lib/cmdx/result_logger.rb +18 -81
  90. data/lib/cmdx/result_serializer.rb +28 -132
  91. data/lib/cmdx/rspec/matchers.rb +28 -0
  92. data/lib/cmdx/rspec/result_matchers/be_executed.rb +42 -0
  93. data/lib/cmdx/rspec/result_matchers/be_failed_task.rb +94 -0
  94. data/lib/cmdx/rspec/result_matchers/be_skipped_task.rb +94 -0
  95. data/lib/cmdx/rspec/result_matchers/be_state_matchers.rb +59 -0
  96. data/lib/cmdx/rspec/result_matchers/be_status_matchers.rb +57 -0
  97. data/lib/cmdx/rspec/result_matchers/be_successful_task.rb +87 -0
  98. data/lib/cmdx/rspec/result_matchers/have_bad_outcome.rb +51 -0
  99. data/lib/cmdx/rspec/result_matchers/have_caused_failure.rb +58 -0
  100. data/lib/cmdx/rspec/result_matchers/have_chain_index.rb +59 -0
  101. data/lib/cmdx/rspec/result_matchers/have_context.rb +86 -0
  102. data/lib/cmdx/rspec/result_matchers/have_empty_metadata.rb +54 -0
  103. data/lib/cmdx/rspec/result_matchers/have_good_outcome.rb +52 -0
  104. data/lib/cmdx/rspec/result_matchers/have_metadata.rb +114 -0
  105. data/lib/cmdx/rspec/result_matchers/have_preserved_context.rb +66 -0
  106. data/lib/cmdx/rspec/result_matchers/have_received_thrown_failure.rb +64 -0
  107. data/lib/cmdx/rspec/result_matchers/have_runtime.rb +78 -0
  108. data/lib/cmdx/rspec/result_matchers/have_thrown_failure.rb +76 -0
  109. data/lib/cmdx/rspec/task_matchers/be_well_formed_task.rb +62 -0
  110. data/lib/cmdx/rspec/task_matchers/have_callback.rb +85 -0
  111. data/lib/cmdx/rspec/task_matchers/have_cmd_setting.rb +68 -0
  112. data/lib/cmdx/rspec/task_matchers/have_executed_callbacks.rb +92 -0
  113. data/lib/cmdx/rspec/task_matchers/have_middleware.rb +46 -0
  114. data/lib/cmdx/rspec/task_matchers/have_parameter.rb +181 -0
  115. data/lib/cmdx/task.rb +213 -425
  116. data/lib/cmdx/task_deprecator.rb +55 -0
  117. data/lib/cmdx/task_processor.rb +245 -0
  118. data/lib/cmdx/task_serializer.rb +22 -70
  119. data/lib/cmdx/utils/ansi_color.rb +13 -89
  120. data/lib/cmdx/utils/log_timestamp.rb +13 -42
  121. data/lib/cmdx/utils/monotonic_runtime.rb +13 -63
  122. data/lib/cmdx/utils/name_affix.rb +21 -71
  123. data/lib/cmdx/validator.rb +48 -0
  124. data/lib/cmdx/validator_registry.rb +86 -0
  125. data/lib/cmdx/validators/exclusion.rb +55 -94
  126. data/lib/cmdx/validators/format.rb +31 -85
  127. data/lib/cmdx/validators/inclusion.rb +65 -110
  128. data/lib/cmdx/validators/length.rb +117 -133
  129. data/lib/cmdx/validators/numeric.rb +123 -130
  130. data/lib/cmdx/validators/presence.rb +38 -79
  131. data/lib/cmdx/version.rb +1 -7
  132. data/lib/cmdx/workflow.rb +46 -339
  133. data/lib/cmdx.rb +1 -1
  134. data/lib/generators/cmdx/install_generator.rb +14 -31
  135. data/lib/generators/cmdx/task_generator.rb +39 -55
  136. data/lib/generators/cmdx/templates/install.rb +61 -11
  137. data/lib/generators/cmdx/workflow_generator.rb +41 -66
  138. data/lib/locales/ar.yml +35 -0
  139. data/lib/locales/cs.yml +35 -0
  140. data/lib/locales/da.yml +35 -0
  141. data/lib/locales/de.yml +35 -0
  142. data/lib/locales/el.yml +35 -0
  143. data/lib/locales/en.yml +19 -20
  144. data/lib/locales/es.yml +19 -20
  145. data/lib/locales/fi.yml +35 -0
  146. data/lib/locales/fr.yml +35 -0
  147. data/lib/locales/he.yml +35 -0
  148. data/lib/locales/hi.yml +35 -0
  149. data/lib/locales/it.yml +35 -0
  150. data/lib/locales/ja.yml +35 -0
  151. data/lib/locales/ko.yml +35 -0
  152. data/lib/locales/nl.yml +35 -0
  153. data/lib/locales/no.yml +35 -0
  154. data/lib/locales/pl.yml +35 -0
  155. data/lib/locales/pt.yml +35 -0
  156. data/lib/locales/ru.yml +35 -0
  157. data/lib/locales/sv.yml +35 -0
  158. data/lib/locales/th.yml +35 -0
  159. data/lib/locales/tr.yml +35 -0
  160. data/lib/locales/vi.yml +35 -0
  161. data/lib/locales/zh.yml +35 -0
  162. metadata +57 -8
  163. data/lib/cmdx/parameter_validator.rb +0 -81
  164. data/lib/cmdx/parameter_value.rb +0 -244
  165. data/lib/cmdx/parameters_inspector.rb +0 -72
  166. data/lib/cmdx/parameters_serializer.rb +0 -115
  167. data/lib/cmdx/rspec/result_matchers.rb +0 -917
  168. data/lib/cmdx/rspec/task_matchers.rb +0 -570
  169. data/lib/cmdx/validators/custom.rb +0 -102
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has no metadata.
4
+ #
5
+ # This matcher checks if a CMDx::Result object's metadata hash is empty.
6
+ # Metadata is typically used to store additional information about task execution
7
+ # such as failure reasons, timing details, error contexts, or other diagnostic data.
8
+ # Testing for empty metadata is useful when verifying that successful tasks execute
9
+ # cleanly without generating unnecessary metadata, or when ensuring default states.
10
+ #
11
+ # @return [Boolean] true if the result's metadata hash is empty
12
+ #
13
+ # @example Testing successful task with no metadata
14
+ # result = SimpleTask.call(data: "valid")
15
+ # expect(result).to have_empty_metadata
16
+ #
17
+ # @example Testing clean task execution
18
+ # result = CalculateTask.call(a: 10, b: 20)
19
+ # expect(result).to be_success.and have_empty_metadata
20
+ #
21
+ # @example Testing default result state
22
+ # result = MyTask.new.result
23
+ # expect(result).to have_empty_metadata
24
+ #
25
+ # @example Negative assertion - expecting metadata to be present
26
+ # result = ValidationTask.call(data: "invalid")
27
+ # expect(result).not_to have_empty_metadata
28
+ #
29
+ # @example Comparing with tasks that set metadata
30
+ # successful_result = CleanTask.call(data: "valid")
31
+ # failed_result = FailingTask.call(data: "invalid")
32
+ # expect(successful_result).to have_empty_metadata
33
+ # expect(failed_result).not_to have_empty_metadata
34
+ #
35
+ # @example Testing metadata cleanup
36
+ # result = ResetTask.call(clear_metadata: true)
37
+ # expect(result).to have_empty_metadata
38
+ RSpec::Matchers.define :have_empty_metadata do
39
+ match do |result|
40
+ result.metadata.empty?
41
+ end
42
+
43
+ failure_message do |result|
44
+ "expected metadata to be empty, but was #{result.metadata}"
45
+ end
46
+
47
+ failure_message_when_negated do |_result|
48
+ "expected metadata not to be empty, but it was"
49
+ end
50
+
51
+ description do
52
+ "have empty metadata"
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has a good outcome.
4
+ #
5
+ # This matcher checks if a CMDx::Result object represents a successful completion,
6
+ # which includes both successful and skipped results. A result has a good outcome when
7
+ # its status is either "success" or "skipped" (i.e., anything other than "failed").
8
+ # This is useful for testing that tasks complete without errors, even if they were
9
+ # skipped due to conditions, as skipped tasks are still considered successful outcomes.
10
+ #
11
+ # @return [Boolean] true if the result has a good outcome (success or skipped)
12
+ #
13
+ # @example Testing successful task outcome
14
+ # result = ProcessDataTask.call(data: "valid")
15
+ # expect(result).to have_good_outcome
16
+ #
17
+ # @example Testing skipped task outcome (still good)
18
+ # result = BackupTask.call(force: false)
19
+ # expect(result).to have_good_outcome # Skipped is still good
20
+ #
21
+ # @example Testing non-error completion paths
22
+ # result = ConditionalTask.call(condition: false)
23
+ # expect(result).to have_good_outcome # Either success or skip is good
24
+ #
25
+ # @example Negative assertion for failed tasks
26
+ # result = ValidationTask.call(data: "invalid")
27
+ # expect(result).not_to have_good_outcome
28
+ #
29
+ # @example Distinguishing from bad outcomes
30
+ # successful_result = CleanTask.call(data: "valid")
31
+ # failed_result = BrokenTask.call(data: "invalid")
32
+ # expect(successful_result).to have_good_outcome
33
+ # expect(failed_result).to have_bad_outcome
34
+ #
35
+ # @example Testing workflow completion
36
+ # workflow_result = ProcessingWorkflow.call(data: "test")
37
+ # expect(workflow_result).to have_good_outcome.and be_complete
38
+ RSpec::Matchers.define :have_good_outcome do
39
+ match(&:good?)
40
+
41
+ failure_message do |result|
42
+ "expected result to have good outcome (success or skipped), but was #{result.status}"
43
+ end
44
+
45
+ failure_message_when_negated do |result|
46
+ "expected result not to have good outcome, but it did (status: #{result.status})"
47
+ end
48
+
49
+ description do
50
+ "have good outcome"
51
+ end
52
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has specific metadata.
4
+ #
5
+ # This matcher checks if a CMDx::Result object's metadata hash contains expected
6
+ # key-value pairs. Metadata is typically used to store additional information about
7
+ # task execution such as failure reasons, timing details, error contexts, or other
8
+ # diagnostic data. The matcher supports both direct value comparisons and RSpec
9
+ # matchers for flexible assertions, and can be chained with `including` for
10
+ # additional metadata expectations.
11
+ #
12
+ # @param expected_metadata [Hash] optional hash of expected metadata key-value pairs
13
+ #
14
+ # @return [Boolean] true if the result's metadata contains all expected pairs
15
+ #
16
+ # @example Testing basic metadata
17
+ # result = FailedTask.call(data: "invalid")
18
+ # expect(result).to have_metadata(reason: "validation_failed", code: 422)
19
+ #
20
+ # @example Using RSpec matchers for flexible assertions
21
+ # result = ProcessingTask.call(data: "test")
22
+ # expect(result).to have_metadata(
23
+ # started_at: be_a(Time),
24
+ # duration: be > 0,
25
+ # user_id: be_present
26
+ # )
27
+ #
28
+ # @example Using the including chain for additional metadata
29
+ # result = ValidationTask.call(data: "invalid")
30
+ # expect(result).to have_metadata(reason: "validation_failed")
31
+ # .including(field: "email", rule: "format")
32
+ #
33
+ # @example Testing failure metadata
34
+ # result = DatabaseTask.call(connection: nil)
35
+ # expect(result).to have_metadata(
36
+ # error_class: "ConnectionError",
37
+ # error_message: include("connection failed"),
38
+ # retry_count: 3
39
+ # )
40
+ #
41
+ # @example Testing skip metadata
42
+ # result = BackupTask.call(force: false)
43
+ # expect(result).to have_metadata(
44
+ # reason: "backup_not_needed",
45
+ # last_backup: be_a(Time),
46
+ # next_backup: be_a(Time)
47
+ # )
48
+ #
49
+ # @example Negative assertion
50
+ # result = CleanTask.call(data: "valid")
51
+ # expect(result).not_to have_metadata(error_code: anything)
52
+ #
53
+ # @example Complex metadata validation
54
+ # result = WorkflowTask.call(data: "complex")
55
+ # expect(result).to have_metadata(
56
+ # steps_completed: be >= 5,
57
+ # total_steps: 10,
58
+ # performance_data: be_a(Hash)
59
+ # ).including(
60
+ # memory_usage: be_within(10).of(100),
61
+ # cpu_time: be_positive
62
+ # )
63
+ RSpec::Matchers.define :have_metadata do |expected_metadata = {}|
64
+ match do |result|
65
+ expected_metadata.all? do |key, value|
66
+ actual_value = result.metadata[key]
67
+ if value.respond_to?(:matches?)
68
+ value.matches?(actual_value)
69
+ else
70
+ actual_value == value
71
+ end
72
+ end
73
+ end
74
+
75
+ chain :including do |metadata|
76
+ @additional_metadata = metadata
77
+ end
78
+
79
+ match do |result|
80
+ all_metadata = expected_metadata.merge(@additional_metadata || {})
81
+ all_metadata.all? do |key, value|
82
+ actual_value = result.metadata[key]
83
+ if value.respond_to?(:matches?)
84
+ value.matches?(actual_value)
85
+ else
86
+ actual_value == value
87
+ end
88
+ end
89
+ end
90
+
91
+ failure_message do |result|
92
+ all_metadata = expected_metadata.merge(@additional_metadata || {})
93
+ mismatches = all_metadata.filter_map do |key, expected_value|
94
+ actual_value = result.metadata[key]
95
+ match_result = if expected_value.respond_to?(:matches?)
96
+ expected_value.matches?(actual_value)
97
+ else
98
+ actual_value == expected_value
99
+ end
100
+ "#{key}: expected #{expected_value}, got #{actual_value}" unless match_result
101
+ end
102
+ "expected metadata to include #{all_metadata}, but #{mismatches.join(', ')}"
103
+ end
104
+
105
+ failure_message_when_negated do |_result|
106
+ all_metadata = expected_metadata.merge(@additional_metadata || {})
107
+ "expected metadata not to include #{all_metadata}, but it did"
108
+ end
109
+
110
+ description do
111
+ all_metadata = expected_metadata.merge(@additional_metadata || {})
112
+ "have metadata #{all_metadata}"
113
+ end
114
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has preserved specific context values.
4
+ #
5
+ # This matcher checks if a CMDx::Result object's context contains values that were
6
+ # preserved from the original input or previous task execution. Unlike `have_context`
7
+ # which tests for side effects and new values, this matcher specifically verifies
8
+ # that certain context attributes retained their expected values throughout task
9
+ # execution, ensuring data integrity and proper context passing between tasks.
10
+ #
11
+ # @param preserved_attributes [Hash] hash of expected preserved context key-value pairs
12
+ #
13
+ # @return [Boolean] true if the context has preserved all expected attributes
14
+ #
15
+ # @example Testing basic context preservation
16
+ # result = ProcessDataTask.call(user_id: 123, data: "input")
17
+ # expect(result).to have_preserved_context(user_id: 123, data: "input")
18
+ #
19
+ # @example Testing workflow context preservation
20
+ # workflow_result = UserWorkflow.call(user_id: 456, email: "user@example.com")
21
+ # expect(workflow_result).to have_preserved_context(
22
+ # user_id: 456,
23
+ # email: "user@example.com"
24
+ # )
25
+ #
26
+ # @example Testing preservation through multiple tasks
27
+ # result = MultiStepTask.call(original_data: "important", temp_data: "process")
28
+ # expect(result).to have_preserved_context(original_data: "important")
29
+ #
30
+ # @example Testing that critical data survives failures
31
+ # result = FailingTask.call(user_id: 789, critical_flag: true)
32
+ # expect(result).to have_preserved_context(
33
+ # user_id: 789,
34
+ # critical_flag: true
35
+ # )
36
+ #
37
+ # @example Negative assertion for modified context
38
+ # result = TransformTask.call(data: "original")
39
+ # expect(result).not_to have_preserved_context(data: "original")
40
+ #
41
+ # @example Testing partial preservation
42
+ # result = SelectiveTask.call(keep_this: "value", change_this: "old")
43
+ # expect(result).to have_preserved_context(keep_this: "value")
44
+ RSpec::Matchers.define :have_preserved_context do |preserved_attributes|
45
+ match do |result|
46
+ preserved_attributes.all? do |key, expected_value|
47
+ result.context.public_send(key) == expected_value
48
+ end
49
+ end
50
+
51
+ failure_message do |result|
52
+ mismatches = preserved_attributes.filter_map do |key, expected_value|
53
+ actual_value = result.context.public_send(key)
54
+ "#{key}: expected #{expected_value}, got #{actual_value}" if actual_value != expected_value
55
+ end
56
+ "expected context to preserve #{preserved_attributes}, but #{mismatches.join(', ')}"
57
+ end
58
+
59
+ failure_message_when_negated do |_result|
60
+ "expected context not to preserve #{preserved_attributes}, but it did"
61
+ end
62
+
63
+ description do
64
+ "preserve context #{preserved_attributes}"
65
+ end
66
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has received a thrown failure.
4
+ #
5
+ # This matcher checks if a CMDx::Result object represents a failure that was
6
+ # thrown from another task and received by this task. This is distinct from
7
+ # failures that were caused by the task itself or thrown by the task to others.
8
+ # A result has received a thrown failure when it's both failed and the failure
9
+ # was propagated from elsewhere in the chain, making this useful for testing
10
+ # error propagation and workflow failure handling.
11
+ #
12
+ # @return [Boolean] true if the result is failed and received a thrown failure
13
+ #
14
+ # @example Testing error propagation in workflows
15
+ # workflow_result = ProcessingWorkflow.call(data: "invalid")
16
+ # receiving_task = workflow_result.chain.find { |r| r.thrown_failure? }
17
+ # expect(receiving_task).to have_received_thrown_failure
18
+ #
19
+ # @example Testing downstream task failure handling
20
+ # result = CleanupTask.call(previous_task_failed: true)
21
+ # expect(result).to have_received_thrown_failure
22
+ #
23
+ # @example Distinguishing failure types in chain
24
+ # workflow_result = MultiStepWorkflow.call(data: "problematic")
25
+ # original_failure = workflow_result.chain.find(&:caused_failure?)
26
+ # received_failure = workflow_result.chain.find(&:thrown_failure?)
27
+ # expect(original_failure).to have_caused_failure
28
+ # expect(received_failure).to have_received_thrown_failure
29
+ #
30
+ # @example Testing error handling middleware
31
+ # result = ErrorHandlingTask.call(upstream_error: error_obj)
32
+ # expect(result).to have_received_thrown_failure
33
+ #
34
+ # @example Negative assertion for self-caused failures
35
+ # result = ValidatingTask.call(data: "invalid")
36
+ # expect(result).not_to have_received_thrown_failure
37
+ #
38
+ # @example Testing workflow interruption propagation
39
+ # workflow_result = InterruptedWorkflow.call(data: "test")
40
+ # interrupted_tasks = workflow_result.chain.select(&:thrown_failure?)
41
+ # interrupted_tasks.each do |task|
42
+ # expect(task).to have_received_thrown_failure
43
+ # end
44
+ RSpec::Matchers.define :have_received_thrown_failure do
45
+ match do |result|
46
+ result.failed? && result.thrown_failure?
47
+ end
48
+
49
+ failure_message do |result|
50
+ if result.failed?
51
+ "expected result to have received thrown failure, but it #{result.caused_failure? ? 'caused' : 'threw'} failure instead"
52
+ else
53
+ "expected result to have received thrown failure, but it was not failed (status: #{result.status})"
54
+ end
55
+ end
56
+
57
+ failure_message_when_negated do |_result|
58
+ "expected result not to have received thrown failure, but it did"
59
+ end
60
+
61
+ description do
62
+ "have received thrown failure"
63
+ end
64
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has runtime information.
4
+ #
5
+ # This matcher checks if a CMDx::Result object has recorded runtime information
6
+ # from task execution. Runtime represents the elapsed time taken to execute the
7
+ # task, measured in seconds as a Float. The matcher can be used to verify that
8
+ # runtime was captured, or to test that runtime meets specific expectations
9
+ # using direct values or RSpec matchers for performance testing.
10
+ #
11
+ # @param expected_runtime [Float, RSpec::Matchers::BuiltIn::BaseMatcher, nil]
12
+ # optional expected runtime value or matcher
13
+ #
14
+ # @return [Boolean] true if the result has runtime and optionally matches expected value
15
+ #
16
+ # @example Testing that runtime was captured
17
+ # result = ProcessDataTask.call(data: "test")
18
+ # expect(result).to have_runtime
19
+ #
20
+ # @example Testing specific runtime value
21
+ # result = QuickTask.call(data: "simple")
22
+ # expect(result).to have_runtime(0.1)
23
+ #
24
+ # @example Testing runtime with RSpec matchers
25
+ # result = ProcessingTask.call(data: "complex")
26
+ # expect(result).to have_runtime(be > 0.5)
27
+ #
28
+ # @example Testing runtime ranges
29
+ # result = OptimizedTask.call(data: "test")
30
+ # expect(result).to have_runtime(be_between(0.1, 1.0))
31
+ #
32
+ # @example Testing performance constraints
33
+ # result = PerformanceCriticalTask.call(data: "large_dataset")
34
+ # expect(result).to have_runtime(be < 2.0)
35
+ #
36
+ # @example Negative assertion for unexecuted tasks
37
+ # result = UnexecutedTask.new.result
38
+ # expect(result).not_to have_runtime
39
+ #
40
+ # @example Testing runtime precision
41
+ # result = PreciseTask.call(data: "test")
42
+ # expect(result).to have_runtime(be_within(0.01).of(0.25))
43
+ RSpec::Matchers.define :have_runtime do |expected_runtime = nil|
44
+ match do |result|
45
+ return false if result.runtime.nil?
46
+ return true if expected_runtime.nil?
47
+
48
+ if expected_runtime.respond_to?(:matches?)
49
+ expected_runtime.matches?(result.runtime)
50
+ else
51
+ result.runtime == expected_runtime
52
+ end
53
+ end
54
+
55
+ failure_message do |result|
56
+ if result.runtime.nil?
57
+ "expected result to have runtime, but it was nil"
58
+ elsif expected_runtime
59
+ "expected result runtime to #{expected_runtime}, but was #{result.runtime}"
60
+ end
61
+ end
62
+
63
+ failure_message_when_negated do |result|
64
+ if expected_runtime
65
+ "expected result runtime not to #{expected_runtime}, but it was #{result.runtime}"
66
+ else
67
+ "expected result not to have runtime, but it was #{result.runtime}"
68
+ end
69
+ end
70
+
71
+ description do
72
+ if expected_runtime
73
+ "have runtime #{expected_runtime}"
74
+ else
75
+ "have runtime"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task result has thrown a failure.
4
+ #
5
+ # This matcher checks if a CMDx::Result object represents a failure that was
6
+ # thrown to another task. This is distinct from failures that were caused by
7
+ # the task itself or received from other tasks. A result has thrown a failure
8
+ # when it's both failed and actively passed the failure to another task in
9
+ # the chain. Optionally verifies that the thrown failure came from a specific
10
+ # original result, useful for testing complex failure propagation scenarios.
11
+ #
12
+ # @param expected_original_result [CMDx::Result, nil] optional original result that was thrown
13
+ #
14
+ # @return [Boolean] true if the result is failed, threw a failure, and optionally matches expected original
15
+ #
16
+ # @example Testing basic failure throwing
17
+ # workflow_result = ProcessingWorkflow.call(data: "invalid")
18
+ # throwing_task = workflow_result.chain.find(&:threw_failure?)
19
+ # expect(throwing_task).to have_thrown_failure
20
+ #
21
+ # @example Testing failure propagation with specific original
22
+ # workflow_result = MultiStepWorkflow.call(data: "problematic")
23
+ # original_failure = workflow_result.chain.find(&:caused_failure?)
24
+ # throwing_task = workflow_result.chain.find(&:threw_failure?)
25
+ # expect(throwing_task).to have_thrown_failure(original_failure)
26
+ #
27
+ # @example Testing middleware failure handling
28
+ # result = ErrorHandlingMiddleware.call(upstream_failure: failure_obj)
29
+ # expect(result).to have_thrown_failure
30
+ #
31
+ # @example Distinguishing failure types in chain
32
+ # workflow_result = FailingWorkflow.call(data: "invalid")
33
+ # caused_task = workflow_result.chain.find(&:caused_failure?)
34
+ # threw_task = workflow_result.chain.find(&:threw_failure?)
35
+ # received_task = workflow_result.chain.find(&:thrown_failure?)
36
+ # expect(caused_task).to have_caused_failure
37
+ # expect(threw_task).to have_thrown_failure
38
+ # expect(received_task).to have_received_thrown_failure
39
+ #
40
+ # @example Negative assertion for self-caused failures
41
+ # result = ValidatingTask.call(data: "invalid")
42
+ # expect(result).not_to have_thrown_failure
43
+ #
44
+ # @example Testing workflow interruption propagation
45
+ # workflow_result = InterruptedWorkflow.call(data: "test")
46
+ # propagating_tasks = workflow_result.chain.select(&:threw_failure?)
47
+ # propagating_tasks.each do |task|
48
+ # expect(task).to have_thrown_failure
49
+ # end
50
+ RSpec::Matchers.define :have_thrown_failure do |expected_original_result = nil|
51
+ match do |result|
52
+ result.failed? &&
53
+ result.threw_failure? &&
54
+ (expected_original_result.nil? || result.threw_failure == expected_original_result)
55
+ end
56
+
57
+ failure_message do |result|
58
+ messages = []
59
+ messages << "expected result to be failed, but was #{result.status}" unless result.failed?
60
+ messages << "expected result to have thrown failure, but it #{result.caused_failure? ? 'caused' : 'received'} failure instead" unless result.threw_failure?
61
+
62
+ messages << "expected to throw failure from #{expected_original_result}, but threw from #{result.threw_failure}" if expected_original_result && result.threw_failure != expected_original_result
63
+
64
+ messages.join(", ")
65
+ end
66
+
67
+ failure_message_when_negated do |_result|
68
+ "expected result not to have thrown failure, but it did"
69
+ end
70
+
71
+ description do
72
+ desc = "have thrown failure"
73
+ desc += " from #{expected_original_result}" if expected_original_result
74
+ desc
75
+ end
76
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task class is well-formed.
4
+ #
5
+ # This matcher checks if a task class meets all the requirements to be a properly
6
+ # structured CMDx::Task. A well-formed task must inherit from CMDx::Task, implement
7
+ # the call method, and have properly initialized registries for parameters, callbacks,
8
+ # and middlewares. This is essential for ensuring task classes will function correctly
9
+ # within the CMDx framework and can be used in workflows.
10
+ #
11
+ # @return [Boolean] true if the task class is well-formed with all required components
12
+ #
13
+ # @example Testing a basic task class
14
+ # class MyTask < CMDx::Task
15
+ # def call; end
16
+ # end
17
+ # expect(MyTask).to be_well_formed_task
18
+ #
19
+ # @example Testing a task with parameters, callbacks and middlewares
20
+ # class ComplexTask < CMDx::Task
21
+ # before_validation :refresh_cache
22
+ # use :middleware, CMDx::Middlewares::Timeout, timeout: 10
23
+ # required :data
24
+ # def call; end
25
+ # end
26
+ # expect(ComplexTask).to be_well_formed_task
27
+ #
28
+ # @example Testing generated task classes
29
+ # task_class = Class.new(CMDx::Task) { def call; end }
30
+ # expect(task_class).to be_well_formed_task
31
+ #
32
+ # @example Negative assertion for malformed tasks
33
+ # class BrokenTask; end # Missing inheritance
34
+ # expect(BrokenTask).not_to be_well_formed_task
35
+ RSpec::Matchers.define :be_well_formed_task do
36
+ match do |task_class|
37
+ (task_class < CMDx::Task) &&
38
+ task_class.instance_methods.include?(:call) &&
39
+ task_class.cmd_parameters.is_a?(CMDx::ParameterRegistry) &&
40
+ task_class.cmd_callbacks.is_a?(CMDx::CallbackRegistry) &&
41
+ task_class.cmd_middlewares.is_a?(CMDx::MiddlewareRegistry)
42
+ end
43
+
44
+ failure_message do |task_class|
45
+ issues = []
46
+ issues << "does not inherit from CMDx::Task" unless task_class < CMDx::Task
47
+ issues << "does not implement call method" unless task_class.instance_methods.include?(:call)
48
+ issues << "does not have parameter registry" unless task_class.cmd_parameters.is_a?(CMDx::ParameterRegistry)
49
+ issues << "does not have callback registry" unless task_class.cmd_callbacks.is_a?(CMDx::CallbackRegistry)
50
+ issues << "does not have middleware registry" unless task_class.cmd_middlewares.is_a?(CMDx::MiddlewareRegistry)
51
+
52
+ "expected task to be well-formed, but #{issues.join(', ')}"
53
+ end
54
+
55
+ failure_message_when_negated do |_task_class|
56
+ "expected task not to be well-formed, but it was"
57
+ end
58
+
59
+ description do
60
+ "be a well-formed task"
61
+ end
62
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for asserting that a task class has a specific callback.
4
+ #
5
+ # This matcher checks if a CMDx::Task class has registered a callback with the
6
+ # specified name. Callbacks are methods that execute before, after, or around
7
+ # the main task logic. The matcher can optionally verify that the callback has
8
+ # a specific callable (method name, proc, or lambda) using the `with_callable`
9
+ # chain method for more precise callback validation.
10
+ #
11
+ # @param callback_name [Symbol, String] the name of the callback to check for
12
+ #
13
+ # @return [Boolean] true if the task has the specified callback and optionally the expected callable
14
+ #
15
+ # @example Testing basic callback presence
16
+ # class MyTask < CMDx::Task
17
+ # before_execution :validate_input
18
+ # def call; end
19
+ # end
20
+ # expect(MyTask).to have_callback(:before_execution)
21
+ #
22
+ # @example Testing callback with specific callable
23
+ # class ProcessTask < CMDx::Task
24
+ # after_execution :log_completion
25
+ # def call; end
26
+ # end
27
+ # expect(ProcessTask).to have_callback(:after_execution).with_callable(:log_completion)
28
+ #
29
+ # @example Testing callbacks with procs
30
+ # class CustomTask < CMDx::Task
31
+ # before_execution -> { puts "Starting" }
32
+ # def call; end
33
+ # end
34
+ # expect(CustomTask).to have_callback(:before_execution)
35
+ #
36
+ # @example Negative assertion
37
+ # class SimpleTask < CMDx::Task
38
+ # def call; end
39
+ # end
40
+ # expect(SimpleTask).not_to have_callback(:before_execution)
41
+ RSpec::Matchers.define :have_callback do |callback_name|
42
+ match do |task_class|
43
+ task_class.cmd_callbacks.registered?(callback_name)
44
+ end
45
+
46
+ chain :with_callable do |callable|
47
+ @expected_callable = callable
48
+ end
49
+
50
+ match do |task_class|
51
+ callbacks_registered = task_class.cmd_callbacks.registered?(callback_name)
52
+ return false unless callbacks_registered
53
+
54
+ if @expected_callable
55
+ task_class.cmd_callbacks.find(callback_name).any? do |callback|
56
+ callback.callable == @expected_callable
57
+ end
58
+ else
59
+ true
60
+ end
61
+ end
62
+
63
+ failure_message do |task_class|
64
+ if @expected_callable
65
+ "expected task to have callback #{callback_name} with callable #{@expected_callable}, but it didn't"
66
+ else
67
+ registered_callbacks = task_class.cmd_callbacks.registered_callbacks
68
+ "expected task to have callback #{callback_name}, but had #{registered_callbacks}"
69
+ end
70
+ end
71
+
72
+ failure_message_when_negated do |_task_class|
73
+ if @expected_callable
74
+ "expected task not to have callback #{callback_name} with callable #{@expected_callable}, but it did"
75
+ else
76
+ "expected task not to have callback #{callback_name}, but it did"
77
+ end
78
+ end
79
+
80
+ description do
81
+ desc = "have callback #{callback_name}"
82
+ desc += " with callable #{@expected_callable}" if @expected_callable
83
+ desc
84
+ end
85
+ end