taski 0.5.0 → 0.7.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Taski
6
+ module Execution
7
+ # WorkerPool manages a pool of worker threads that execute tasks.
8
+ # It provides methods to start, stop, and enqueue tasks for execution.
9
+ #
10
+ # == Responsibilities
11
+ #
12
+ # - Manage worker thread lifecycle (start, shutdown)
13
+ # - Distribute tasks to worker threads via Queue
14
+ # - Execute tasks via callback provided by Executor
15
+ #
16
+ # == API
17
+ #
18
+ # - {#start} - Start all worker threads
19
+ # - {#enqueue} - Add a task to the execution queue
20
+ # - {#shutdown} - Gracefully shutdown all worker threads
21
+ # - {#execution_queue} - Access the underlying Queue (for testing)
22
+ #
23
+ # == Thread Safety
24
+ #
25
+ # WorkerPool uses Queue for thread-safe task distribution.
26
+ # The Queue handles synchronization between the main thread
27
+ # (which enqueues tasks) and worker threads (which pop tasks).
28
+ class WorkerPool
29
+ attr_reader :execution_queue
30
+
31
+ # @param registry [Registry] The task registry for thread tracking
32
+ # @param worker_count [Integer, nil] Number of worker threads (defaults to CPU count)
33
+ # @param on_execute [Proc] Callback to execute a task, receives (task_class, wrapper)
34
+ def initialize(registry:, worker_count: nil, &on_execute)
35
+ @worker_count = worker_count || default_worker_count
36
+ @registry = registry
37
+ @on_execute = on_execute
38
+ @execution_queue = Queue.new
39
+ @workers = []
40
+ end
41
+
42
+ # Start all worker threads.
43
+ def start
44
+ @worker_count.times do
45
+ worker = Thread.new { worker_loop }
46
+ @workers << worker
47
+ @registry.register_thread(worker)
48
+ end
49
+ end
50
+
51
+ # Enqueue a task for execution.
52
+ #
53
+ # @param task_class [Class] The task class to execute
54
+ # @param wrapper [TaskWrapper] The task wrapper
55
+ def enqueue(task_class, wrapper)
56
+ @execution_queue.push({task_class: task_class, wrapper: wrapper})
57
+ debug_log("Enqueued: #{task_class}")
58
+ end
59
+
60
+ # Shutdown all worker threads gracefully.
61
+ def shutdown
62
+ enqueue_shutdown_signals
63
+ @workers.each(&:join)
64
+ end
65
+
66
+ # Enqueue shutdown signals for all workers.
67
+ def enqueue_shutdown_signals
68
+ @worker_count.times { @execution_queue.push(:shutdown) }
69
+ end
70
+
71
+ private
72
+
73
+ def default_worker_count
74
+ Etc.nprocessors.clamp(2, 8)
75
+ end
76
+
77
+ def worker_loop
78
+ loop do
79
+ work_item = @execution_queue.pop
80
+ break if work_item == :shutdown
81
+
82
+ task_class = work_item[:task_class]
83
+ wrapper = work_item[:wrapper]
84
+
85
+ debug_log("Worker executing: #{task_class}")
86
+
87
+ begin
88
+ @on_execute.call(task_class, wrapper)
89
+ rescue => e
90
+ # Log error but don't crash the worker thread.
91
+ # Task-level errors are handled in the execute callback.
92
+ # This catches unexpected errors in the callback itself.
93
+ warn "[WorkerPool] Unexpected error executing #{task_class}: #{e.message}"
94
+ end
95
+ end
96
+ end
97
+
98
+ def debug_log(message)
99
+ return unless ENV["TASKI_DEBUG"]
100
+ puts "[WorkerPool] #{message}"
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/taski/section.rb CHANGED
@@ -24,6 +24,9 @@ module Taski
24
24
  raise "Section #{self.class} does not have an implementation. Override 'impl' method."
25
25
  end
26
26
 
27
+ # Register runtime dependency for clean phase (before register_impl_selection)
28
+ register_runtime_dependency(implementation_class)
29
+
27
30
  # Register selected impl for progress display
28
31
  register_impl_selection(implementation_class)
29
32
 
@@ -43,11 +46,21 @@ module Taski
43
46
 
44
47
  private
45
48
 
49
+ # Register the selected implementation as a runtime dependency.
50
+ # This allows the clean phase to include the dynamically selected impl.
51
+ # Handles nil ExecutionContext gracefully.
52
+ #
53
+ # @param impl_class [Class] The selected implementation class
54
+ def register_runtime_dependency(impl_class)
55
+ context = Execution::ExecutionContext.current
56
+ context&.register_runtime_dependency(self.class, impl_class)
57
+ end
58
+
46
59
  def register_impl_selection(implementation_class)
47
- progress = Taski.progress_display
48
- return unless progress.is_a?(Execution::TreeProgressDisplay)
60
+ context = Execution::ExecutionContext.current
61
+ return unless context
49
62
 
50
- progress.register_section_impl(self.class, implementation_class)
63
+ context.notify_section_impl_selected(self.class, implementation_class)
51
64
  end
52
65
 
53
66
  # @param implementation_class [Class] The implementation task class
@@ -92,6 +92,9 @@ module Taski
92
92
  # Re-analyze the class methods
93
93
  # Preserve impl chain context: methods called from impl should continue
94
94
  # detecting constants as impl candidates
95
+ # Set namespace path from target class name for constant resolution
96
+ @current_namespace_path = @target_task_class.name.split("::")
97
+
95
98
  @class_method_defs.each do |method_name, method_node|
96
99
  next unless new_methods.include?(method_name)
97
100
 
data/lib/taski/task.rb CHANGED
@@ -5,8 +5,34 @@ require_relative "execution/registry"
5
5
  require_relative "execution/task_wrapper"
6
6
 
7
7
  module Taski
8
+ # Base class for all tasks in the Taski framework.
9
+ # Tasks define units of work with dependencies and exported values.
10
+ #
11
+ # @example Defining a simple task
12
+ # class MyTask < Taski::Task
13
+ # exports :result
14
+ #
15
+ # def run
16
+ # @result = "completed"
17
+ # end
18
+ # end
8
19
  class Task
9
20
  class << self
21
+ ##
22
+ # Callback invoked when a subclass is created.
23
+ # Automatically creates a task-specific Error class for each subclass.
24
+ # @param subclass [Class] The newly created subclass.
25
+ def inherited(subclass)
26
+ super
27
+ # Create TaskClass::Error that inherits from Taski::TaskError
28
+ error_class = Class.new(Taski::TaskError)
29
+ subclass.const_set(:Error, error_class)
30
+ end
31
+
32
+ ##
33
+ # Declares exported methods that will be accessible after task execution.
34
+ # Creates instance reader and class accessor methods for each export.
35
+ # @param export_methods [Array<Symbol>] The method names to export.
10
36
  def exports(*export_methods)
11
37
  @exported_methods = export_methods
12
38
 
@@ -16,75 +42,128 @@ module Taski
16
42
  end
17
43
  end
18
44
 
45
+ ##
46
+ # Returns the list of exported method names.
47
+ # @return [Array<Symbol>] The exported method names.
19
48
  def exported_methods
20
49
  @exported_methods ||= []
21
50
  end
22
51
 
23
- # Each call creates a fresh TaskWrapper instance for re-execution support.
52
+ ##
53
+ # Creates a fresh TaskWrapper instance for re-execution support.
24
54
  # Use class methods (e.g., MyTask.result) for cached single execution.
55
+ # @return [Execution::TaskWrapper] A new wrapper for this task.
25
56
  def new
26
- fresh_registry = Execution::Registry.new
27
- task_instance = allocate
28
- task_instance.send(:initialize)
29
- wrapper = Execution::TaskWrapper.new(
30
- task_instance,
31
- registry: fresh_registry
32
- )
33
- # Pre-register to prevent Executor from creating a duplicate wrapper
34
- fresh_registry.register(self, wrapper)
35
- wrapper
57
+ fresh_wrapper
36
58
  end
37
59
 
60
+ ##
61
+ # Returns cached static dependencies for this task class.
62
+ # Dependencies are analyzed from the run method body using static analysis.
63
+ # @return [Set<Class>] The set of task classes this task depends on.
38
64
  def cached_dependencies
39
65
  @dependencies_cache ||= StaticAnalysis::Analyzer.analyze(self)
40
66
  end
41
67
 
68
+ ##
69
+ # Clears the cached dependency analysis.
70
+ # Useful when task code has changed and dependencies need to be re-analyzed.
42
71
  def clear_dependency_cache
43
72
  @dependencies_cache = nil
44
73
  end
45
74
 
46
- def run(context: {})
47
- Taski.start_context(options: context, root_task: self)
48
- validate_no_circular_dependencies!
49
- cached_wrapper.run
75
+ ##
76
+ # Executes the task and all its dependencies.
77
+ # Creates a fresh registry each time for independent execution.
78
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
79
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
80
+ # Must be a positive integer or nil.
81
+ # Use workers: 1 for sequential execution (useful for debugging).
82
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
83
+ # @return [Object] The result of task execution.
84
+ def run(args: {}, workers: nil)
85
+ with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run }
50
86
  end
51
87
 
52
- def clean(context: {})
53
- Taski.start_context(options: context, root_task: self)
54
- validate_no_circular_dependencies!
55
- cached_wrapper.clean
88
+ ##
89
+ # Executes the clean phase for the task and all its dependencies.
90
+ # Clean is executed in reverse dependency order.
91
+ # Creates a fresh registry each time for independent execution.
92
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
93
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
94
+ # Must be a positive integer or nil.
95
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
96
+ def clean(args: {}, workers: nil)
97
+ with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.clean }
56
98
  end
57
99
 
58
- def registry
59
- Taski.global_registry
100
+ ##
101
+ # Execute run followed by clean in a single operation.
102
+ # If run fails, clean is still executed for resource release.
103
+ # Creates a fresh registry for both operations to share.
104
+ #
105
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
106
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
107
+ # Must be a positive integer or nil.
108
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
109
+ # @return [Object] The result of task execution
110
+ def run_and_clean(args: {}, workers: nil)
111
+ with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run_and_clean }
60
112
  end
61
113
 
114
+ ##
115
+ # Resets the task state and progress display.
116
+ # Useful for testing or re-running tasks from scratch.
62
117
  def reset!
63
- registry.reset!
64
- Taski.reset_global_registry!
65
- Taski.reset_context!
118
+ Taski.reset_args!
119
+ Taski.reset_progress_display!
66
120
  @circular_dependency_checked = false
67
121
  end
68
122
 
123
+ ##
124
+ # Renders a static tree representation of the task dependencies.
125
+ # @return [String] The rendered tree string.
69
126
  def tree
70
127
  Execution::TreeProgressDisplay.render_static_tree(self)
71
128
  end
72
129
 
73
130
  private
74
131
 
75
- # Use allocate + initialize instead of new to avoid infinite loop
76
- # since new is overridden to return TaskWrapper
77
- def cached_wrapper
78
- registry.get_or_create(self) do
79
- task_instance = allocate
80
- task_instance.send(:initialize)
81
- Execution::TaskWrapper.new(
82
- task_instance,
83
- registry: registry
84
- )
132
+ ##
133
+ # Sets up execution environment and yields a fresh wrapper.
134
+ # Handles workers validation, args lifecycle, and dependency validation.
135
+ # @param args [Hash] User-defined arguments
136
+ # @param workers [Integer, nil] Number of worker threads
137
+ # @yield [wrapper] Block receiving the fresh wrapper to execute
138
+ # @return [Object] The result of the block
139
+ def with_execution_setup(args:, workers:)
140
+ validate_workers!(workers)
141
+ Taski.with_args(options: args.merge(_workers: workers), root_task: self) do
142
+ validate_no_circular_dependencies!
143
+ yield fresh_wrapper
85
144
  end
86
145
  end
87
146
 
147
+ ##
148
+ # Creates a fresh TaskWrapper with its own registry.
149
+ # Used for class method execution (Task.run) where each call is independent.
150
+ # @return [Execution::TaskWrapper] A new wrapper with fresh registry.
151
+ def fresh_wrapper
152
+ fresh_registry = Execution::Registry.new
153
+ task_instance = allocate
154
+ task_instance.__send__(:initialize)
155
+ wrapper = Execution::TaskWrapper.new(
156
+ task_instance,
157
+ registry: fresh_registry,
158
+ execution_context: Execution::ExecutionContext.current
159
+ )
160
+ fresh_registry.register(self, wrapper)
161
+ wrapper
162
+ end
163
+
164
+ ##
165
+ # Defines an instance reader method for an exported value.
166
+ # @param method [Symbol] The method name to define.
88
167
  def define_instance_reader(method)
89
168
  undef_method(method) if method_defined?(method)
90
169
 
@@ -94,16 +173,43 @@ module Taski
94
173
  end
95
174
  end
96
175
 
176
+ ##
177
+ # Defines a class accessor method for an exported value.
178
+ # When called inside an execution, returns cached value from registry.
179
+ # When called outside execution, creates fresh execution.
180
+ # @param method [Symbol] The method name to define.
97
181
  def define_class_accessor(method)
98
182
  singleton_class.undef_method(method) if singleton_class.method_defined?(method)
99
183
 
100
184
  define_singleton_method(method) do
101
- Taski.start_context(options: {}, root_task: self)
102
- validate_no_circular_dependencies!
103
- cached_wrapper.get_exported_value(method)
185
+ # Check if running inside an execution with a registry
186
+ registry = Taski.current_registry
187
+ if registry
188
+ # Inside execution - get or create wrapper in current registry
189
+ # This handles both pre-registered dependencies and dynamic ones (like Section impl)
190
+ wrapper = registry.get_or_create(self) do
191
+ task_instance = allocate
192
+ task_instance.__send__(:initialize)
193
+ Execution::TaskWrapper.new(
194
+ task_instance,
195
+ registry: registry,
196
+ execution_context: Execution::ExecutionContext.current
197
+ )
198
+ end
199
+ wrapper.get_exported_value(method)
200
+ else
201
+ # Outside execution - fresh execution (top-level call)
202
+ Taski.with_args(options: {}, root_task: self) do
203
+ validate_no_circular_dependencies!
204
+ fresh_wrapper.get_exported_value(method)
205
+ end
206
+ end
104
207
  end
105
208
  end
106
209
 
210
+ ##
211
+ # Validates that no circular dependencies exist in the task graph.
212
+ # @raise [Taski::CircularDependencyError] If circular dependencies are detected.
107
213
  def validate_no_circular_dependencies!
108
214
  return if @circular_dependency_checked
109
215
 
@@ -116,15 +222,90 @@ module Taski
116
222
 
117
223
  @circular_dependency_checked = true
118
224
  end
225
+
226
+ ##
227
+ # Validates the workers parameter.
228
+ # @param workers [Object] The workers parameter to validate.
229
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
230
+ def validate_workers!(workers)
231
+ return if workers.nil?
232
+
233
+ unless workers.is_a?(Integer) && workers >= 1
234
+ raise ArgumentError, "workers must be a positive integer or nil, got: #{workers.inspect}"
235
+ end
236
+ end
119
237
  end
120
238
 
239
+ ##
240
+ # Executes the task's main logic.
241
+ # Subclasses must override this method to implement task behavior.
242
+ # @raise [NotImplementedError] If not overridden in a subclass.
121
243
  def run
122
244
  raise NotImplementedError, "Subclasses must implement the run method"
123
245
  end
124
246
 
247
+ ##
248
+ # Cleans up resources after task execution.
249
+ # Override in subclasses to implement cleanup logic.
125
250
  def clean
126
251
  end
127
252
 
253
+ # Override system() to capture subprocess output through the pipe-based architecture.
254
+ # Uses Kernel.system with :out option to redirect output to the task's pipe.
255
+ # If user provides :out or :err options, they are respected (no automatic redirection).
256
+ # @param args [Array] Command arguments (shell mode if single string, exec mode if array)
257
+ # @param opts [Hash] Options passed to Kernel.system
258
+ # @return [Boolean, nil] true if command succeeded, false if failed, nil if command not found
259
+ def system(*args, **opts)
260
+ write_io = $stdout.respond_to?(:current_write_io) ? $stdout.current_write_io : nil
261
+
262
+ if write_io && !opts.key?(:out)
263
+ # Redirect subprocess output to the task's pipe (stderr merged into stdout)
264
+ Kernel.system(*args, out: write_io, err: [:child, :out], **opts)
265
+ else
266
+ # No capture active or user provided custom :out, use normal system
267
+ Kernel.system(*args, **opts)
268
+ end
269
+ end
270
+
271
+ # Groups related output within a task for organized progress display.
272
+ # The group name is shown in the progress tree as a child of the task.
273
+ # Groups cannot be nested.
274
+ #
275
+ # @param name [String] The group name to display
276
+ # @yield The block to execute within the group
277
+ # @return [Object] The result of the block
278
+ # @raise Re-raises any exception from the block after marking group as failed
279
+ #
280
+ # @example
281
+ # def run
282
+ # group("Preparing") do
283
+ # puts "Checking dependencies..."
284
+ # puts "Validating config..."
285
+ # end
286
+ # group("Deploying") do
287
+ # puts "Uploading files..."
288
+ # end
289
+ # end
290
+ def group(name)
291
+ context = Execution::ExecutionContext.current
292
+ context&.notify_group_started(self.class, name)
293
+ start_time = Time.now
294
+
295
+ begin
296
+ result = yield
297
+ duration_ms = ((Time.now - start_time) * 1000).round(0)
298
+ context&.notify_group_completed(self.class, name, duration: duration_ms)
299
+ result
300
+ rescue => e
301
+ duration_ms = ((Time.now - start_time) * 1000).round(0)
302
+ context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
303
+ raise
304
+ end
305
+ end
306
+
307
+ ##
308
+ # Resets the instance's exported values to nil.
128
309
  def reset!
129
310
  self.class.exported_methods.each do |method|
130
311
  instance_variable_set("@#{method}", nil)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module TestHelper
5
+ # Raised when attempting to mock a class that is not a Taski::Task or Taski::Section subclass.
6
+ class InvalidTaskError < ArgumentError
7
+ end
8
+
9
+ # Raised when attempting to mock a method that is not an exported method of the task.
10
+ class InvalidMethodError < ArgumentError
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ module Taski
6
+ module TestHelper
7
+ # Minitest integration module.
8
+ # Include this in your test class for automatic mock cleanup.
9
+ #
10
+ # @example
11
+ # class MyTaskTest < Minitest::Test
12
+ # include Taski::TestHelper::Minitest
13
+ #
14
+ # def test_something
15
+ # mock_task(FetchData, result: "mocked")
16
+ # # ... test code ...
17
+ # end
18
+ # # Mocks are automatically cleaned up after each test
19
+ # end
20
+ module Minitest
21
+ def self.included(base)
22
+ base.include(Taski::TestHelper)
23
+ end
24
+
25
+ # Reset mocks before each test to ensure clean state.
26
+ def setup
27
+ super
28
+ Taski::TestHelper.reset_mocks!
29
+ end
30
+
31
+ # Reset mocks after each test to prevent pollution.
32
+ def teardown
33
+ Taski::TestHelper.reset_mocks!
34
+ super
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module TestHelper
5
+ # Global registry that stores mock definitions for tests.
6
+ # Uses a Mutex for thread-safety when accessed from worker threads.
7
+ # Mocks should be reset in test setup/teardown to ensure test isolation.
8
+ module MockRegistry
9
+ @mutex = Mutex.new
10
+ @mocks = {}
11
+
12
+ class << self
13
+ # Registers a mock for a task class.
14
+ # If a mock already exists for this class, it is replaced.
15
+ # @param task_class [Class] The task class to mock
16
+ # @param mock_wrapper [MockWrapper] The mock wrapper instance
17
+ def register(task_class, mock_wrapper)
18
+ @mutex.synchronize do
19
+ @mocks[task_class] = mock_wrapper
20
+ end
21
+ end
22
+
23
+ # Retrieves the mock wrapper for a task class, if one exists.
24
+ # @param task_class [Class] The task class to look up
25
+ # @return [MockWrapper, nil] The mock wrapper or nil if not mocked
26
+ def mock_for(task_class)
27
+ @mutex.synchronize do
28
+ @mocks[task_class]
29
+ end
30
+ end
31
+
32
+ # Checks if any mocks are registered.
33
+ # Used for optimization to skip mock lookup in hot paths.
34
+ # @return [Boolean] true if mocks exist
35
+ def mocks_active?
36
+ @mutex.synchronize do
37
+ !@mocks.empty?
38
+ end
39
+ end
40
+
41
+ # Clears all registered mocks.
42
+ # Should be called in test setup/teardown.
43
+ def reset!
44
+ @mutex.synchronize do
45
+ @mocks = {}
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module TestHelper
5
+ # Wraps a mocked task and returns pre-configured values without executing the task.
6
+ # Tracks which methods were accessed for verification in tests.
7
+ class MockWrapper
8
+ attr_reader :task_class, :mock_values
9
+
10
+ # @param task_class [Class] The task class being mocked
11
+ # @param mock_values [Hash{Symbol => Object}] Method names mapped to their return values
12
+ def initialize(task_class, mock_values)
13
+ @task_class = task_class
14
+ @mock_values = mock_values
15
+ @access_counts = Hash.new(0)
16
+ end
17
+
18
+ # Returns the mocked value for a method and records the access.
19
+ # @param method_name [Symbol] The exported method name
20
+ # @return [Object] The pre-configured mock value
21
+ # @raise [KeyError] If method_name was not configured in the mock
22
+ def get_exported_value(method_name)
23
+ unless @mock_values.key?(method_name)
24
+ raise KeyError, "No mock value for method :#{method_name} on #{@task_class}"
25
+ end
26
+
27
+ @access_counts[method_name] += 1
28
+ @mock_values[method_name]
29
+ end
30
+
31
+ # Checks if a method was accessed at least once.
32
+ # @param method_name [Symbol] The method name to check
33
+ # @return [Boolean] true if accessed at least once
34
+ def accessed?(method_name)
35
+ @access_counts[method_name] > 0
36
+ end
37
+
38
+ # Returns the number of times a method was accessed.
39
+ # @param method_name [Symbol] The method name to check
40
+ # @return [Integer] Number of accesses (0 if never accessed)
41
+ def access_count(method_name)
42
+ @access_counts[method_name]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ module Taski
6
+ module TestHelper
7
+ # RSpec integration module.
8
+ # Include this in your RSpec examples for automatic mock cleanup.
9
+ #
10
+ # @example In spec_helper.rb
11
+ # require 'taski/test_helper/rspec'
12
+ #
13
+ # RSpec.configure do |config|
14
+ # config.include Taski::TestHelper::RSpec
15
+ # end
16
+ #
17
+ # @example In individual specs
18
+ # RSpec.describe MyTask do
19
+ # include Taski::TestHelper::RSpec
20
+ #
21
+ # it "processes data" do
22
+ # mock_task(FetchData, result: "mocked")
23
+ # # ... test code ...
24
+ # end
25
+ # end
26
+ module RSpec
27
+ def self.included(base)
28
+ base.include(Taski::TestHelper)
29
+
30
+ # Add before/after hooks when included in RSpec
31
+ if base.respond_to?(:before) && base.respond_to?(:after)
32
+ base.before(:each) { Taski::TestHelper.reset_mocks! }
33
+ base.after(:each) { Taski::TestHelper.reset_mocks! }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end