lib/tryouts/testbatch.rb



# lib/tryouts/testbatch.rb

require 'stringio'

class Tryouts
  # Modern TestBatch using Ruby 3.4+ patterns and formatter system
  class TestBatch
    attr_reader :testrun, :failed_count, :container, :status, :results, :formatter, :output_manager

    def initialize(testrun, **options)
      @testrun        = testrun
      @container      = Object.new
      @options        = options
      @formatter      = Tryouts::CLI::FormatterFactory.create_formatter(options)
      @output_manager = options[:output_manager]
      @global_tally   = options[:global_tally]
      @failed_count   = 0
      @status         = :pending
      @results        = []
      @start_time     = nil
    end

    # Main execution pipeline using functional composition
    def run(before_test_hook = nil, &)
      return false if empty?

      @start_time = Time.now
      @output_manager&.execution_phase(test_cases.size)
      @output_manager&.info("Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1)
      @output_manager&.file_start(path, context: @options[:shared_context] ? :shared : :fresh)

      if shared_context?
        @output_manager&.info('Running global setup...', 2)
        execute_global_setup
      end

      idx               = 0
      execution_results = test_cases.map do |test_case|
        @output_manager&.trace("Test #{idx + 1}/#{test_cases.size}: #{test_case.description}", 2)
        idx                     += 1
        result                   = execute_single_test(test_case, before_test_hook, &) # runs the test code
        result
      end

      execute_global_teardown
      finalize_results(execution_results)

      @status = :completed
      !failed?
    end

    def empty?
      @testrun.empty?
    end

    def size
      @testrun.total_tests
    end

    def test_cases
      @testrun.test_cases
    end

    def path
      @testrun.source_file
    end

    def failed?
      @failed_count > 0
    end

    def completed?
      @status == :completed
    end

    private

    # Pattern matching for execution strategy selection
    def execute_single_test(test_case, before_test_hook = nil)
      before_test_hook&.call(test_case)

      # Capture output during test execution
      result          = nil
      captured_output = capture_output do
        result = case @options[:shared_context]
                 when true
                   execute_with_shared_context(test_case)
                 when false, nil
                   execute_with_fresh_context(test_case)
                 else
                   raise 'Invalid execution context configuration'
                 end
      end

      # Add captured output to the result
      result[:captured_output] = captured_output if captured_output && !captured_output.empty?

      process_test_result(result)
      yield(test_case) if block_given?
      result
    end

    # Shared context execution - setup runs once, all tests share state
    def execute_with_shared_context(test_case)
      code  = test_case.code
      path  = test_case.path
      range = test_case.line_range

      result_value        = @container.instance_eval(code, path, range.first + 1)
      expectations_result = evaluate_expectations(test_case, result_value, @container)

      build_test_result(test_case, result_value, expectations_result)
    rescue StandardError => ex
      build_error_result(test_case, ex.message, ex)
    end

    # Fresh context execution - setup runs per test, isolated state
    def execute_with_fresh_context(test_case)
      fresh_container = Object.new

      # Execute setup in fresh context if present
      setup = @testrun.setup
      if setup && !setup.code.empty?
        fresh_container.instance_eval(setup.code, setup.path, 1)
      end

      # Execute test in same fresh context
      code  = test_case.code
      path  = test_case.path
      range = test_case.line_range

      result_value        = fresh_container.instance_eval(code, path, range.first + 1)
      expectations_result = evaluate_expectations(test_case, result_value, fresh_container)

      build_test_result(test_case, result_value, expectations_result)
    rescue StandardError => ex
      build_error_result(test_case, ex.message, ex)
    end

    # Evaluate expectations using pattern matching for clean result handling
    def evaluate_expectations(test_case, actual_result, context)
      if test_case.expectations.empty?
        { passed: true, actual_results: [], expected_results: [] }
      else
        evaluation_results = test_case.expectations.map do |expectation|
          evaluate_single_expectation(expectation, actual_result, context, test_case)
        end

        {
          passed: evaluation_results.all? { |r| r[:passed] },
          actual_results: evaluation_results.map { |r| r[:actual] },
          expected_results: evaluation_results.map { |r| r[:expected] },
        }
      end
    end

    def evaluate_single_expectation(expectation, actual_result, context, test_case)
      path  = test_case.path
      range = test_case.line_range

      expected_value = context.instance_eval(expectation, path, range.first + 1)

      {
        passed: actual_result == expected_value,
        actual: actual_result,
        expected: expected_value,
        expectation: expectation,
      }
    rescue StandardError => ex
      {
        passed: false,
        actual: actual_result,
        expected: "EXPECTED: #{ex.message}",
        expectation: expectation,
      }
    end

    # Build structured test results using pattern matching
    def build_test_result(test_case, result_value, expectations_result)
      if expectations_result[:passed]
        {
          test_case: test_case,
          status: :passed,
          result_value: result_value,
          actual_results: expectations_result[:actual_results],
          error: nil,
        }
      else
        {
          test_case: test_case,
          status: :failed,
          result_value: result_value,
          actual_results: expectations_result[:actual_results],
          error: nil,
        }
      end
    end

    def build_error_result(test_case, message, exception = nil)
      {
        test_case: test_case,
        status: :error,
        result_value: nil,
        actual_results: ["ACTUAL: #{message}"],
        error: exception,
      }
    end

    # Process and display test results using formatter
    def process_test_result(result)
      @results << result

      if [:failed, :error].include?(result[:status])
        @failed_count += 1
      end

      show_test_result(result)

      # Show captured output if any exists
      if result[:captured_output] && !result[:captured_output].empty?
        @output_manager&.test_output(result[:test_case], result[:captured_output])
      end
    end

    # Global setup execution for shared context mode
    def execute_global_setup
      setup = @testrun.setup

      if setup && !setup.code.empty? && @options[:shared_context]
        @output_manager&.setup_start(setup.line_range)

        # Capture setup output instead of letting it print directly
        captured_output = capture_output do
          @container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
        end

        @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
      end
    rescue StandardError => ex
      @global_tally[:total_errors] += 1 if @global_tally
      raise "Global setup failed: #{ex.message}"
    end

    # Global teardown execution
    def execute_global_teardown
      teardown = @testrun.teardown

      if teardown && !teardown.code.empty?
        @output_manager&.teardown_start(teardown.line_range)

        # Capture teardown output instead of letting it print directly
        captured_output = capture_output do
          @container.instance_eval(teardown.code, teardown.path, teardown.line_range.first + 1)
        end

        @output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
      end
    rescue StandardError => ex
      @global_tally[:total_errors] += 1 if @global_tally
      @output_manager&.error("Teardown failed: #{ex.message}")
    end

    # Result finalization and summary display
    def finalize_results(_execution_results)
      @status      = :completed
      elapsed_time = Time.now - @start_time
      show_summary(elapsed_time)
    end


    def show_test_result(result)
      test_case = result[:test_case]
      status    = result[:status]
      actuals   = result[:actual_results]

      @output_manager&.test_result(test_case, status, actuals)
    end

    def show_summary(elapsed_time)
      @output_manager&.batch_summary(size, @failed_count, elapsed_time)
    end

    # Helper methods using pattern matching

    def shared_context?
      @options[:shared_context] == true
    end

    def capture_output
      old_stdout = $stdout
      old_stderr = $stderr
      $stdout    = StringIO.new
      $stderr    = StringIO.new

      yield

      captured = $stdout.string + $stderr.string
      captured.empty? ? nil : captured
    ensure
      $stdout = old_stdout
      $stderr = old_stderr
    end

    def handle_batch_error(exception)
      @status       = :error
      @failed_count = 1

      error_message = "Batch execution failed: #{exception.message}"
      backtrace     = exception.respond_to?(:backtrace) ? exception.backtrace : nil

      @output_manager&.error(error_message, backtrace)
    end
  end
end