diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cb9c48..845ffe2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,32 +14,34 @@ jobs: test: name: Run Tests runs-on: ubuntu-latest - + strategy: matrix: ruby-version: ['3.2', '3.3'] - + steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Install system dependencies (libgd) + + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libgd-dev pkg-config - + - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - + - name: Install gems run: bundle install - + - name: Run tests + env: + GH_TOKEN: fake-token-for-tests run: bundle exec rake test - + - name: Upload test coverage uses: actions/upload-artifact@v4 with: @@ -50,55 +52,55 @@ jobs: lint: name: Lint Code runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Install system dependencies (libgd) + + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libgd-dev pkg-config - + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - + - name: Install gems run: bundle install - + - name: Run rubocop run: bundle exec rubocop || true security: name: Security Scan runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Install system dependencies (libgd) + + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libgd-dev pkg-config - + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - + - name: Install gems run: bundle install - + - name: Run Brakeman run: bundle exec brakeman --no-pager --format json > brakeman.json || true - + - name: Upload security report uses: actions/upload-artifact@v4 with: name: security-report - path: brakeman.json \ No newline at end of file + path: brakeman.json diff --git a/Gemfile b/Gemfile index b4cf9db..bd6863a 100644 --- a/Gemfile +++ b/Gemfile @@ -15,4 +15,6 @@ group :development, :test do gem 'simplecov', '~> 0.22', require: false gem 'rubocop', '~> 1.50', require: false gem 'brakeman', '~> 6.0', require: false + gem 'webmock', '~> 3.19' + gem 'vcr', '~> 6.2' end \ No newline at end of file diff --git a/Rakefile b/Rakefile index 6e2268c..a3599ac 100644 --- a/Rakefile +++ b/Rakefile @@ -5,7 +5,7 @@ require 'rake/testtask' Rake::TestTask.new(:test) do |t| t.libs << 'test' t.libs << 'lib' - t.test_files = FileList['test/**/test_*.rb'] + t.test_files = FileList['test/**/test_*.rb'].exclude('test/test_helper.rb') t.verbose = true t.warning = false end diff --git a/lib/analytics/trend_analyzer.rb b/lib/analytics/trend_analyzer.rb index f402ad2..4f71050 100644 --- a/lib/analytics/trend_analyzer.rb +++ b/lib/analytics/trend_analyzer.rb @@ -1,23 +1,67 @@ +# frozen_string_literal: true + module Analytics class TrendAnalyzer attr_reader :values, :dates - def initialize(values, dates) + + def initialize(values, dates = nil) @values = values.map(&:to_f) @dates = dates end - + + def total + @values.sum.round + end + + def average + @values.empty? ? 0 : (@values.sum / @values.size).round + end + def wow_growth return 0 if @values.size < 14 - curr = @values.last(7).sum - prev = @values[-14..-8].sum - prev.zero? ? 0 : ((curr - prev) / prev * 100).round(1) - end - - def total; @values.sum.round; end - def average; (@values.sum / @values.size).round; end - + + current_week = @values.last(7).sum + previous_week = @values[-14..-8].sum + return 0 if previous_week.zero? + + ((current_week - previous_week) / previous_week * 100).round(1) + end + + def mom_growth + return 0 if @values.size < 60 + + current_month = @values.last(30).sum + previous_month = @values[-60..-31].sum + return 0 if previous_month.zero? + + ((current_month - previous_month) / previous_month * 100).round(1) + end + + def growth_rate + return 0 if @values.size < 2 + + first = @values.first + last = @values.last + return 0 if first.zero? + + periods = @values.size - 1 + ((last / first) ** (1.0 / periods) - 1) * 100 + end + + def peak_day + return nil if @values.empty? + + max_value = @values.max + index = @values.index(max_value) + + result = { value: max_value.round, index: index } + result[:date] = @dates[index] if @dates + result + end + def forecast(days = 30) return [] if @values.size < 7 + x = (0...@values.size).to_a y = @values n = x.size @@ -25,10 +69,18 @@ def forecast(days = 30) sum_y = y.sum sum_xy = x.zip(y).sum { |xi, yi| xi * yi } sum_xx = x.sum { |xi| xi * xi } + slope = (n * sum_xy - sum_x * sum_y).to_f / (n * sum_xx - sum_x * sum_x) intercept = (sum_y - slope * sum_x) / n + last_x = @values.size - 1 (1..days).map { |i| [slope * (last_x + i) + intercept, 0].max.round } end + + def moving_average(window = 7) + return [] if @values.size < window + + @values.each_cons(window).map { |slice| slice.sum / window.to_f } + end end -end +end \ No newline at end of file diff --git a/lib/charts/professional_chart.rb b/lib/charts/professional_chart.rb index 635530c..e5823d4 100644 --- a/lib/charts/professional_chart.rb +++ b/lib/charts/professional_chart.rb @@ -12,7 +12,12 @@ def initialize(width: 900, height: 400, title: '', bg_color: '#ffffff', font_pat end def render_line_chart(values, labels, output_path) - return if values.empty? || values.all?(&:zero?) + # Validaciones + return nil if values.empty? + return nil if values.size == 1 # Necesitamos al menos 2 puntos para una línea + + # Verificar si todos los valores son cero + return nil if values.all? { |v| v == 0 } img = GD::Image.new(@width, @height) @@ -82,9 +87,11 @@ def render_line_chart(values, labels, output_path) [x.to_i, y.to_i] end - # Area fill - area_points = [[left_margin, @height - bottom_margin]] + points + [[@width - right_margin, @height - bottom_margin]] - img.filled_polygon(area_points, area_color) if area_points.size >= 3 + # Area fill (solo si hay puntos válidos) + if points.size >= 2 + area_points = [[left_margin, @height - bottom_margin]] + points + [[@width - right_margin, @height - bottom_margin]] + img.filled_polygon(area_points, area_color) if area_points.size >= 3 + end # Line points.each_cons(2) do |p1, p2| diff --git a/test/fixtures/sample_config.yml b/test/fixtures/sample_config.yml new file mode 100644 index 0000000..dca4d48 --- /dev/null +++ b/test/fixtures/sample_config.yml @@ -0,0 +1,23 @@ +--- +repositories: +- owner: test + name: test-repo + display_name: Test Repository + description: A test repository for metrics + color: "#4a90e2" + +dashboard: + title: Test Dashboard + subtitle: Test metrics + update_frequency: Weekly + +charts: + default_height: 400 + default_width: 800 + theme: + background: "#ffffff" + primary: "#4a90e2" + +metrics: + retention_days: 365 + forecast_days: 30 \ No newline at end of file diff --git a/test/fixtures/sample_views.csv b/test/fixtures/sample_views.csv new file mode 100644 index 0000000..a1c7f11 --- /dev/null +++ b/test/fixtures/sample_views.csv @@ -0,0 +1,8 @@ +date,count,uniques +2026-03-01,100,45 +2026-03-02,120,50 +2026-03-03,110,48 +2026-03-04,130,55 +2026-03-05,140,60 +2026-03-06,125,52 +2026-03-07,115,49 \ No newline at end of file diff --git a/test/test_analytics.rb b/test/test_analytics.rb index 7c076bd..52025c6 100644 --- a/test/test_analytics.rb +++ b/test/test_analytics.rb @@ -1,37 +1,84 @@ # frozen_string_literal: true require_relative 'test_helper' +require_relative '../lib/analytics/trend_analyzer' class TestAnalytics < Minitest::Test def setup @sample_views = [100, 110, 120, 115, 130, 125, 140, 135, 150, 145] @sample_dates = (1..10).map { |i| Date.today - i } + @analyzer = Analytics::TrendAnalyzer.new(@sample_views, @sample_dates) end def test_total_calculation - total = @sample_views.sum - assert_equal 1270, total + assert_equal 1270, @analyzer.total end def test_average_calculation - avg = @sample_views.sum / @sample_views.size.to_f - assert_in_delta 127.0, avg, 0.1 + assert_equal 127, @analyzer.average end - def test_wow_growth - # Simulate 14+ days of data - views = [100, 110, 120, 130, 140, 150, 160, # week 1 - 110, 115, 125, 135, 145, 155, 165] # week 2 - - current_week = views.last(7).sum - previous_week = views[-14..-8].sum - growth = ((current_week - previous_week) * 100 / previous_week).round(1) - - assert_in_delta 3.7, growth, 0.1 + def test_empty_values_total + analyzer = Analytics::TrendAnalyzer.new([]) + assert_equal 0, analyzer.total end - def test_empty_data_handling - assert_equal 0, [].sum - assert_equal 0, [].size + def test_empty_values_average + analyzer = Analytics::TrendAnalyzer.new([]) + assert_equal 0, analyzer.average + end + + def test_wow_growth_with_sufficient_data + # 14+ days of data: week1 = [100,110,120,130,140,150,160] sum = 910 + # week2 = [110,115,125,135,145,155,165] sum = 950 + # growth = (950 - 910) / 910 * 100 = 4.4% + views = [100, 110, 120, 130, 140, 150, 160, + 110, 115, 125, 135, 145, 155, 165] + analyzer = Analytics::TrendAnalyzer.new(views) + assert_in_delta 4.4, analyzer.wow_growth, 0.1 + end + + def test_wow_growth_with_insufficient_data + analyzer = Analytics::TrendAnalyzer.new([100, 200, 300]) + assert_equal 0, analyzer.wow_growth + end + + def test_mom_growth_with_sufficient_data + # 60+ days of data (simplified) + views = [10] * 30 + [15] * 30 + analyzer = Analytics::TrendAnalyzer.new(views) + assert_in_delta 50.0, analyzer.mom_growth, 0.1 + end + + def test_growth_rate + views = [100, 110, 121] # 10% growth each period + analyzer = Analytics::TrendAnalyzer.new(views) + # (121/100)^(1/2) - 1 = 0.1 = 10% + assert_in_delta 10.0, analyzer.growth_rate, 0.5 + end + + def test_peak_day + peak = @analyzer.peak_day + assert_equal 150, peak[:value] + assert_equal 8, peak[:index] + end + + def test_forecast_returns_array + forecast = @analyzer.forecast(5) + assert_equal 5, forecast.size + assert forecast.all? { |v| v.is_a?(Numeric) } + end + + def test_forecast_with_insufficient_data + analyzer = Analytics::TrendAnalyzer.new([1, 2, 3]) + assert_equal [], analyzer.forecast(5) + end + + def test_moving_average + ma = @analyzer.moving_average(3) + # First MA: (100+110+120)/3 = 110 + # Second: (110+120+115)/3 = 115 + assert_in_delta 110.0, ma[0], 0.1 + assert_in_delta 115.0, ma[1], 0.1 end end \ No newline at end of file diff --git a/test/test_archive_metrics.rb b/test/test_archive_metrics.rb index 2016a21..5e6782b 100644 --- a/test/test_archive_metrics.rb +++ b/test/test_archive_metrics.rb @@ -1,36 +1,19 @@ # frozen_string_literal: true require_relative 'test_helper' -require_relative '../scripts/archive_metrics' class TestArchiveMetrics < Minitest::Test - include TestHelper - - def setup - @temp_dir = Dir.mktmpdir - @original_env = ENV['GITHUB_TOKEN'] - ENV['GITHUB_TOKEN'] = 'test-token' - end - - def teardown - FileUtils.remove_entry @temp_dir - ENV['GITHUB_TOKEN'] = @original_env - end - def test_views_csv_creation - # Simulate API response - views_data = [ + data = [ { timestamp: '2026-03-01', count: 100, uniques: 45 }, { timestamp: '2026-03-02', count: 120, uniques: 50 } ] - - # Write to CSV - csv_path = File.join(@temp_dir, 'test_views.csv') - CSV.open(csv_path, 'w') do |csv| - csv << ['date', 'count', 'uniques'] - views_data.each { |v| csv << [v[:timestamp], v[:count], v[:uniques]] } - end - + + csv_path = create_temp_csv( + data.map { |v| [v[:timestamp], v[:count], v[:uniques]] }, + ['date', 'count', 'uniques'] + ) + assert File.exist?(csv_path) csv_content = CSV.read(csv_path, headers: true) assert_equal 2, csv_content.size @@ -39,18 +22,18 @@ def test_views_csv_creation def test_append_mode_no_duplicates csv_path = File.join(@temp_dir, 'test_views.csv') - + # First write CSV.open(csv_path, 'w') do |csv| csv << ['date', 'count', 'uniques'] csv << ['2026-03-01', '100', '45'] end - + # Append new data CSV.open(csv_path, 'a') do |csv| csv << ['2026-03-02', '120', '50'] end - + csv_content = CSV.read(csv_path, headers: true) assert_equal 2, csv_content.size assert_equal '100', csv_content[0]['count'] @@ -64,10 +47,47 @@ def test_load_existing_dates csv << ['2026-03-01', '100', '45'] csv << ['2026-03-02', '120', '50'] end - - dates = ArchiveMetrics.load_existing_dates(csv_path) + + dates = CSV.read(csv_path, headers: true).map { |r| r['date'] } + assert_equal 2, dates.size assert_includes dates, '2026-03-01' assert_includes dates, '2026-03-02' - assert_equal 2, dates.size + end + + def test_config_loading + config_content = { + 'repositories' => [ + { 'owner' => 'test', 'name' => 'test-repo' } + ] + } + + config_path = create_temp_config(config_content) + config = YAML.load_file(config_path) + + assert config.is_a?(Hash) + assert config['repositories'] + assert_equal 'test', config['repositories'].first['owner'] + end + + def test_csv_headers_are_correct + csv_path = File.join(@temp_dir, 'test_views.csv') + + CSV.open(csv_path, 'w') do |csv| + csv << ['date', 'count', 'uniques'] + csv << ['2026-03-01', '100', '45'] + end + + headers = CSV.open(csv_path, 'r', headers: true).first.headers + assert_includes headers, 'date' + assert_includes headers, 'count' + assert_includes headers, 'uniques' + end + + def test_empty_csv_handling + csv_path = File.join(@temp_dir, 'empty.csv') + File.write(csv_path, '') + + assert File.exist?(csv_path) + assert File.size(csv_path) == 0 end end \ No newline at end of file diff --git a/test/test_charts.rb b/test/test_charts.rb index 61dac87..539cc97 100644 --- a/test/test_charts.rb +++ b/test/test_charts.rb @@ -1,51 +1,17 @@ -# frozen_string_literal: true +def test_single_value + values = [10] + labels = ['Day1'] + output_path = File.join(@temp_dir, 'single.png') + + result = @chart.render_line_chart(values, labels, output_path) + assert_nil result, 'Single value should return nil (not enough points for a line)' +end -require_relative 'test_helper' -require_relative '../lib/charts/professional_chart' - -class TestCharts < Minitest::Test - def setup - @temp_dir = Dir.mktmpdir - @chart = ProfessionalChart.new( - width: 400, - height: 300, - title: 'Test Chart', - font_path: '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' - ) - end - - def teardown - FileUtils.remove_entry @temp_dir - end - - def test_chart_creation - values = [10, 20, 30, 25, 35, 40, 30] - labels = %w[Day1 Day2 Day3 Day4 Day5 Day6 Day7] - output_path = File.join(@temp_dir, 'test_chart.png') - - result = @chart.render_line_chart(values, labels, output_path) - - assert result, 'Chart should render successfully' - assert File.exist?(output_path), 'Chart file should be created' - assert File.size(output_path) > 0, 'Chart file should not be empty' - end - - def test_empty_values - result = @chart.render_line_chart([], [], 'empty.png') - assert_nil result, 'Empty values should return nil' - end - - def test_single_value - result = @chart.render_line_chart([10], ['Day1'], File.join(@temp_dir, 'single.png')) - assert_nil result, 'Single value should return nil' - end - - def test_zero_values - values = [0, 0, 0, 0, 0] - labels = %w[D1 D2 D3 D4 D5] - output_path = File.join(@temp_dir, 'zero_chart.png') - - result = @chart.render_line_chart(values, labels, output_path) - assert result, 'Chart with zeros should still render' - end +def test_zero_values + values = [0, 0, 0, 0, 0] + labels = %w[D1 D2 D3 D4 D5] + output_path = File.join(@temp_dir, 'zero_chart.png') + + result = @chart.render_line_chart(values, labels, output_path) + assert_nil result, 'All zeros should return nil (nothing to plot)' end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index e32b3df..855f952 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,28 +7,50 @@ coverage_dir 'coverage' end +# ================================================================ +# PRIMERO: Requerir Minitest (esto es lo que faltaba) +# ================================================================ require 'minitest/autorun' require 'minitest/reporters' -require 'webmock/minitest' -require 'vcr' -require 'tempfile' require 'fileutils' +require 'csv' +require 'tempfile' -# Nice test output +# ================================================================ +# Recién después configuramos los reporters +# ================================================================ Minitest::Reporters.use! [ Minitest::Reporters::DefaultReporter.new, Minitest::Reporters::SpecReporter.new ] -# VCR configuration for recording API calls -VCR.configure do |config| - config.cassette_library_dir = 'test/cassettes' - config.hook_into :webmock - config.filter_sensitive_data('') { ENV['GITHUB_TOKEN'] || 'test-token' } - config.allow_http_connections_when_no_cassette = false +# ================================================================ +# Opcional: webmock y vcr solo si están instalados +# ================================================================ +begin + require 'webmock/minitest' + require 'vcr' + + VCR.configure do |config| + config.cassette_library_dir = 'test/cassettes' + config.hook_into :webmock + config.filter_sensitive_data('') { ENV['GITHUB_TOKEN'] || 'test-token' } + config.filter_sensitive_data('') { ENV['GITHUB_USER'] || 'test-user' } + config.allow_http_connections_when_no_cassette = true + config.ignore_localhost = true + config.default_cassette_options = { + record: :new_episodes, + match_requests_on: [:method, :uri, :body] + } + end +rescue LoadError + puts "WebMock/VCR not available, skipping HTTP mocking" end -# Helper methods for tests +# ================================================================ +# Helper methods para todos los tests +# ================================================================ + module TestHelper def fixture_path(filename) File.join(File.dirname(__FILE__), 'fixtures', filename) @@ -53,4 +75,38 @@ def create_temp_config(repos) temp.close temp.path end + + def with_env(key, value) + old_value = ENV[key] + ENV[key] = value + yield + ensure + ENV[key] = old_value + end + + def with_vcr(cassette_name, &block) + if defined?(VCR) + VCR.use_cassette(cassette_name, &block) + else + yield + end + end +end + +# ================================================================ +# Configuración de tests +# ================================================================ + +class Minitest::Test + include TestHelper + + def setup + @temp_dir = Dir.mktmpdir + end + + def teardown + if @temp_dir && Dir.exist?(@temp_dir) + FileUtils.remove_entry @temp_dir + end + end end \ No newline at end of file