# encoding: utf-8
# This file is part of ruby-flores.
# Copyright (C) 2015 Jordan Sissel
# 
require "flores/namespace"
require "flores/rspec"

# RSpec helpers for stress testing examples
#
# Setting it up in rspec:
#
#     RSpec.configure do |c|
#       c.extend RSpec::StressIt
#     end
#
# TODO(sissel): Show an example of stress_it and analyze_it
module Flores::RSpec::Analyze
  # Save state after each example so it can be used in analysis after specs are completed.
  #
  # If you use this, you'll want to set your RSpec formatter to
  # Flores::RSpec::Formatter::Analyze
  #
  # Let's show an example that fails sometimes.
  #
  #     describe "Addition of two numbers" do
  #       context "positive numbers" do
  #         analyze_results
  #         let(:a) { Flores::Random.number(1..1000) }
  #     
  #         # Here we make negative numbers possible to cause failure in our test.
  #         let(:b) { Flores::Random.number(-200..1000) }
  #         subject { a + b }
  #     
  #         stress_it "should be positive" do
  #           expect(subject).to(be > 0)
  #         end
  #       end
  #     end
  #
  # And running it:
  #
  #     % rspec -f Flores::RSpec::Formatter::Analyze
  #     Addition of two numbers positive numbers should be positive
  #       98.20% tests successful of 3675 tests
  #       Failure analysis:
  #         1.80% -> [66] RSpec::Expectations::ExpectationNotMetError
  #           Sample exception for {:a=>126.21705882478048, :b=>-139.54814492675024, :subject=>-13.33108610196976}
  #             expected: > 0
  #                  got:   -13.33108610196976
  #           Samples causing RSpec::Expectations::ExpectationNotMetError:
  #             {:a=>90.67298249206425, :b=>-136.6237821353908, :subject=>-45.95079964332655}
  #             {:a=>20.35865155878871, :b=>-39.592417377658876, :subject=>-19.233765818870165}
  #             {:a=>158.07905166101787, :b=>-177.5864470909581, :subject=>-19.50739542994023}
  #             {:a=>31.80445518715138, :b=>-188.51942190504894, :subject=>-156.71496671789757}
  #             {:a=>116.1479954937354, :b=>-146.18477887927958, :subject=>-30.036783385544183}
  def analyze_results
    # TODO(sissel): Would be lovely to figure out how to inject an 'after' for
    # all examples if we are using the Analyze formatter.
    # Then this method could be implied by using the right formatter, or something.
    after do |example|
      example.metadata[:values] = __memoized.clone
    end
  end

  # A formatter to show analysis of an `analyze_it` example. 
  class Analysis < StandardError
    def initialize(results)
      @results = results
    end # def initialize

    def total
      @results.reduce(0) { |m, (_, v)| m + v.length }
    end # def total

    def success_count
      if @results.include?(:passed)
        @results[:passed].length
      else
        0
      end
    end # def success_count

    def success_and_pending_count
      count = 0
      [:passed, :pending].each do |group|
        count += @results[group].length
      end
      count
    end # def success_count

    def percent(count)
      return (count + 0.0) / total
    end # def percent

    def percent_s(count)
      return format("%.2f%%", percent(count) * 100)
    end # def percent_s

    def to_s # rubocop:disable Metrics/AbcSize
      # This method is crazy complex for a formatter. Should refactor this significantly.
      report = []
      if @results[:pending].any?
        # We have pending examples, put a clear message.
        report << "#{percent_s(success_and_pending_count)} (of #{total} total) tests are successful or pending"
      else
        report << "#{percent_s(success_count)} (of #{total} total) tests are successful"
      end
      report += failure_summary if success_and_pending_count < total
      report.join("\n")
    end # def to_s

    # TODO(sissel): All these report/summary/to_s things are an indication that the
    # report formatting belongs in a separate class.
    def failure_summary
      report = ["Failure analysis:"]
      report += @results.sort_by { |_, v| -v.length }.collect do |group, instances|
        next if group == :passed
        next if group == :pending
        error_report(group, instances)
      end.reject(&:nil?).flatten
      report
    end # def failure_summary

    def error_report(error, instances)
      report = error_summary(error, instances)
      report += error_sample_states(error, instances) if instances.size > 1
      report
    end # def error_report

    def error_summary(error, instances)
      sample = instances.sample(1)
      [ 
        "  #{percent_s(instances.length)} -> [#{instances.length}] #{error}",
        "    Sample failure",
        "      Inputs:",
        *render_values(sample.first[0]).map { |x| "        #{x}" },
        "      Exception:",
        sample.first[1].to_s.gsub(/^/, "        ")
      ]
    end # def error_summary

    def render_values(values)
      # values should be an RSpec::Core::MemoizedHelpers::ThreadsafeMemoized
      lets = values.instance_eval { @memoized }
      return ["<nothing>"] if lets.nil?

      lets.sort_by { |k,v| v.to_s.size }.map do |k,v| 
        if v.to_s.size > 50
          v = v.to_s[0, 50] + "..."
        end
        "#{k}=#{v}"
      end
    end


    def error_sample_states(error, instances)
      [ 
        "    Samples causing #{error}:",
        *instances.sample(5).collect { |state, _exception| "      #{state}" }
      ]
    end # def error_sample_states
  end # class Analysis
end # Flores::RSpec::Analyze
