diff --git a/Gemfile b/Gemfile index a596b99..a242ea2 100644 --- a/Gemfile +++ b/Gemfile @@ -107,6 +107,12 @@ gem 'tinder', '~> 1.9.2' # HipChat integration gem "hipchat", "~> 0.9.0" +# d3 +gem "d3_rails", "~> 3.1.4" + +# underscore-rails +gem "underscore-rails", "~> 1.4.4" + group :assets do gem "sass-rails" gem "coffee-rails" @@ -177,6 +183,7 @@ group :development, :test do gem 'poltergeist', '~> 1.3.0' gem 'spork', '~> 1.0rc' + gem 'jasmine' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 0af3d51..c14d647 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -69,6 +69,8 @@ GEM celluloid (0.14.0) timers (>= 1.0.0) charlock_holmes (0.6.9.4) + childprocess (0.3.9) + ffi (~> 1.0, >= 1.0.11) chosen-rails (0.9.8) railties (~> 3.0) thor (~> 0.14) @@ -92,6 +94,8 @@ GEM simplecov (>= 0.7) thor crack (0.3.2) + d3_rails (3.1.4) + railties (>= 3.1.0) daemons (1.1.9) database_cleaner (1.0.1) debug_inspector (0.0.2) @@ -216,6 +220,12 @@ GEM multi_xml (>= 0.5.2) httpauth (0.2.0) i18n (0.6.1) + jasmine (1.3.2) + jasmine-core (~> 1.3.1) + rack (~> 1.0) + rspec (>= 1.3.1) + selenium-webdriver (>= 0.1.3) + jasmine-core (1.3.1) journey (1.0.4) jquery-atwho-rails (0.3.0) jquery-rails (2.1.3) @@ -392,6 +402,7 @@ GEM rspec-mocks (~> 2.13.0) ruby-progressbar (1.0.2) rubyntlm (0.1.1) + rubyzip (0.9.9) sanitize (2.0.3) nokogiri (>= 1.4.4, < 1.6) sass (3.2.9) @@ -408,6 +419,11 @@ GEM select2-rails (3.3.1) sass-rails (>= 3.2) thor (~> 0.14) + selenium-webdriver (2.32.1) + childprocess (>= 0.2.5) + multi_json (~> 1.0) + rubyzip + websocket (~> 1.0.4) settingslogic (2.0.9) sexp_processor (4.2.1) shoulda-matchers (2.1.0) @@ -482,6 +498,7 @@ GEM uglifier (2.0.1) execjs (>= 0.3.0) multi_json (~> 1.0, >= 1.0.2) + underscore-rails (1.4.4) virtus (0.5.4) backports (~> 2.6.1) descendants_tracker (~> 0.0.1) @@ -490,6 +507,7 @@ GEM webmock (1.11.0) addressable (>= 2.2.7) crack (>= 0.3.2) + websocket (1.0.7) xpath (2.0.0) nokogiri (~> 1.3) yajl-ruby (1.1.0) @@ -510,6 +528,7 @@ DEPENDENCIES coffee-rails colored coveralls + d3_rails (~> 3.1.4) database_cleaner devise email_spec @@ -536,6 +555,7 @@ DEPENDENCIES haml-rails hipchat (~> 0.9.0) httparty + jasmine jquery-atwho-rails (= 0.3.0) jquery-rails (= 2.1.3) jquery-turbolinks @@ -586,4 +606,5 @@ DEPENDENCIES tinder (~> 1.9.2) turbolinks uglifier + underscore-rails (~> 1.4.4) webmock diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ab5fc1b..0767b82 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -27,3 +27,5 @@ //= require branch-graph //= require ace-src-noconflict/ace //= require_tree . +//= require d3 +//= require underscore diff --git a/app/assets/javascripts/stat_graph.js.coffee b/app/assets/javascripts/stat_graph.js.coffee new file mode 100644 index 0000000..b129619 --- /dev/null +++ b/app/assets/javascripts/stat_graph.js.coffee @@ -0,0 +1,6 @@ +class window.StatGraph + @log: {} + @get_log: -> + @log + @set_log: (data) -> + @log = data diff --git a/app/assets/javascripts/stat_graph_contributors.js.coffee b/app/assets/javascripts/stat_graph_contributors.js.coffee new file mode 100644 index 0000000..12dfe4d --- /dev/null +++ b/app/assets/javascripts/stat_graph_contributors.js.coffee @@ -0,0 +1,61 @@ +class window.ContributorsStatGraph + init: (log) -> + @parsed_log = ContributorsStatGraphUtil.parse_log(log) + @set_current_field("commits") + total_commits = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field) + author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field) + @add_master_graph(total_commits) + @add_authors_graph(author_commits) + @change_date_header() + add_master_graph: (total_data) -> + @master_graph = new ContributorsMasterGraph(total_data) + @master_graph.draw() + add_authors_graph: (author_data) -> + @authors = [] + _.each(author_data, (d) => + author_header = @create_author_header(d) + $(".contributors-list").append(author_header) + @authors[d.author] = author_graph = new ContributorsAuthorGraph(d.dates) + author_graph.draw() + ) + format_author_commit_info: (author) -> + author.commits + " commits " + author.additions + " ++ / " + author.deletions + " --" + create_author_header: (author) -> + list_item = $('
  • ', { + class: 'person' + style: 'display: block;' + }) + author_name = $('

    ' + author.author + '

    ') + author_commit_info_span = $('', { + class: 'commits' + }) + author_commit_info = @format_author_commit_info(author) + author_commit_info_span.text(author_commit_info) + list_item.append(author_name) + list_item.append(author_commit_info_span) + list_item + redraw_master: -> + total_data = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field) + @master_graph.set_data(total_data) + @master_graph.redraw() + redraw_authors: -> + $("ol").html("") + x_domain = ContributorsGraph.prototype.x_domain + author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field, x_domain) + _.each(author_commits, (d) => + @redraw_author_commit_info(d) + $(@authors[d.author].list_item).appendTo("ol") + @authors[d.author].set_data(d.dates) + @authors[d.author].redraw() + ) + set_current_field: (field) -> + @field = field + change_date_header: -> + x_domain = ContributorsGraph.prototype.x_domain + print_date_format = d3.time.format("%B %e %Y"); + print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]); + $("#date_header").text(print); + redraw_author_commit_info: (author) -> + author_list_item = $(@authors[author.author].list_item) + author_commit_info = @format_author_commit_info(author) + author_list_item.find("span").text(author_commit_info) \ No newline at end of file diff --git a/app/assets/javascripts/stat_graph_contributors_graph.js.coffee b/app/assets/javascripts/stat_graph_contributors_graph.js.coffee new file mode 100644 index 0000000..e7a120f --- /dev/null +++ b/app/assets/javascripts/stat_graph_contributors_graph.js.coffee @@ -0,0 +1,166 @@ +class window.ContributorsGraph + MARGIN: + top: 20 + right: 20 + bottom: 30 + left: 50 + x_domain: null + y_domain: null + dates: [] + @set_x_domain: (data) => + @prototype.x_domain = data + @set_y_domain: (data) => + @prototype.y_domain = [0, d3.max(data, (d) -> + d.commits = d.commits ? d.additions ? d.deletions + )] + @init_x_domain: (data) => + @prototype.x_domain = d3.extent(data, (d) -> + d.date + ) + @init_y_domain: (data) => + @prototype.y_domain = [0, d3.max(data, (d) -> + d.commits = d.commits ? d.additions ? d.deletions + )] + @init_domain: (data) => + @init_x_domain(data) + @init_y_domain(data) + @set_dates: (data) => + @prototype.dates = data + set_x_domain: -> + @x.domain(@x_domain) + set_y_domain: -> + @y.domain(@y_domain) + set_domain: -> + @set_x_domain() + @set_y_domain() + create_scale: (width, height) -> + @x = d3.time.scale().range([0, width]).clamp(true) + @y = d3.scale.linear().range([height, 0]).nice() + draw_x_axis: -> + @svg.append("g").attr("class", "x axis").attr("transform", "translate(0, #{@height})") + .call(@x_axis); + draw_y_axis: -> + @svg.append("g").attr("class", "y axis").call(@y_axis) + set_data: (data) -> + @data = data + +class window.ContributorsMasterGraph extends ContributorsGraph + constructor: (@data) -> + @width = 1100 + @height = 125 + @x = null + @y = null + @x_axis = null + @y_axis = null + @area = null + @svg = null + @brush = null + @x_max_domain = null + process_dates: (data) -> + dates = @get_dates(data) + @parse_dates(data) + ContributorsGraph.set_dates(dates) + get_dates: (data) -> + _.pluck(data, 'date') + parse_dates: (data) -> + parseDate = d3.time.format("%Y-%m-%d").parse + data.forEach((d) -> + d.date = parseDate(d.date) + ) + create_scale: -> + super @width, @height + create_axes: -> + @x_axis = d3.svg.axis().scale(@x).orient("bottom") + @y_axis = d3.svg.axis().scale(@y).orient("left") + create_svg: -> + @svg = d3.select("#contributors-master").append("svg") + .attr("width", @width + @MARGIN.left + @MARGIN.right) + .attr("height", @height + @MARGIN.top + @MARGIN.bottom) + .attr("class", "tint-box") + .append("g") + .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")") + create_area: (x, y) -> + @area = d3.svg.area().x((d) -> + x(d.date) + ).y0(@height).y1((d) -> + y(d.commits = d.commits ? d.additions ? d.deletions) + ).interpolate("basis") + create_brush: -> + @brush = d3.svg.brush().x(@x).on("brushend", @update_content); + draw_path: (data) -> + @svg.append("path").datum(data).attr("class", "area").attr("d", @area); + add_brush: -> + @svg.append("g").attr("class", "selection").call(@brush).selectAll("rect").attr("height", @height); + update_content: => + ContributorsGraph.set_x_domain(if @brush.empty() then @x_max_domain else @brush.extent()) + $("#brush_change").trigger('change') + draw: -> + @process_dates(@data) + @create_scale() + @create_axes() + ContributorsGraph.init_domain(@data) + @x_max_domain = @x_domain + @set_domain() + @create_area(@x, @y) + @create_svg() + @create_brush() + @draw_path(@data) + @draw_x_axis() + @draw_y_axis() + @add_brush() + redraw: -> + @process_dates(@data) + ContributorsGraph.set_y_domain(@data) + @set_y_domain() + @svg.select("path").datum(@data) + @svg.select("path").attr("d", @area) + @svg.select(".y.axis").call(@y_axis) + +class window.ContributorsAuthorGraph extends ContributorsGraph + constructor: (@data) -> + @width = 490 + @height = 130 + @x = null + @y = null + @x_axis = null + @y_axis = null + @area = null + @svg = null + @list_item = null + create_scale: -> + super @width, @height + create_axes: -> + @x_axis = d3.svg.axis().scale(@x).orient("bottom").tickFormat(d3.time.format("%m/%d")); + @y_axis = d3.svg.axis().scale(@y).orient("left") + create_area: (x, y) -> + @area = d3.svg.area().x((d) -> + parseDate = d3.time.format("%Y-%m-%d").parse + x(parseDate(d)) + ).y0(@height).y1((d) => + if @data[d]? then y(@data[d]) else y(0) + ).interpolate("basis") + create_svg: -> + @list_item = d3.selectAll(".person")[0].pop() + @svg = d3.select(@list_item).append("svg") + .attr("width", @width + @MARGIN.left + @MARGIN.right) + .attr("height", @height + @MARGIN.top + @MARGIN.bottom) + .attr("class", "spark") + .append("g") + .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")") + draw_path: (data) -> + @svg.append("path").datum(data).attr("class", "area-contributor").attr("d", @area); + draw: -> + @create_scale() + @create_axes() + @set_domain() + @create_area(@x, @y) + @create_svg() + @draw_path(@dates) + @draw_x_axis() + @draw_y_axis() + redraw: -> + @set_domain() + @svg.select("path").datum(@dates) + @svg.select("path").attr("d", @area) + @svg.select(".x.axis").call(@x_axis) + @svg.select(".y.axis").call(@y_axis) diff --git a/app/assets/javascripts/stat_graph_contributors_util.js.coffee b/app/assets/javascripts/stat_graph_contributors_util.js.coffee new file mode 100644 index 0000000..8f81631 --- /dev/null +++ b/app/assets/javascripts/stat_graph_contributors_util.js.coffee @@ -0,0 +1,91 @@ +window.ContributorsStatGraphUtil = + parse_log: (log) -> + total = {} + by_author = {} + for entry in log + @add_date(entry.date, total) unless total[entry.date]? + @add_author(entry.author, by_author) unless by_author[entry.author]? + @add_date(entry.date, by_author[entry.author]) unless by_author[entry.author][entry.date] + @store_data(entry, total[entry.date], by_author[entry.author][entry.date]) + total = _.toArray(total) + by_author = _.toArray(by_author) + total: total, by_author: by_author + + add_date: (date, collection) -> + collection[date] = {} + collection[date].date = date + + add_author: (author, by_author) -> + by_author[author] = {} + by_author[author].author = author + + store_data: (entry, total, by_author) -> + @store_commits(total, by_author) + @store_additions(entry, total, by_author) + @store_deletions(entry, total, by_author) + + store_commits: (total, by_author) -> + @add(total, "commits", 1) + @add(by_author, "commits", 1) + + add: (collection, field, value) -> + collection[field] ?= 0 + collection[field] += value + + store_additions: (entry, total, by_author) -> + entry.additions ?= 0 + @add(total, "additions", entry.additions) + @add(by_author, "additions", entry.additions) + + store_deletions: (entry, total, by_author) -> + entry.deletions ?= 0 + @add(total, "deletions", entry.deletions) + @add(by_author, "deletions", entry.deletions) + + get_total_data: (parsed_log, field) -> + log = parsed_log.total + total_data = @pick_field(log, field) + _.sortBy(total_data, (d) -> + d.date + ) + pick_field: (log, field) -> + total_data = [] + _.each(log, (d) -> + total_data.push(_.pick(d, [field, 'date'])) + ) + total_data + + get_author_data: (parsed_log, field, date_range = null) -> + log = parsed_log.by_author + author_data = [] + + _.each(log, (log_entry) => + parsed_log_entry = @parse_log_entry(log_entry, field, date_range) + if not _.isEmpty(parsed_log_entry.dates) + author_data.push(parsed_log_entry) + ) + + _.sortBy(author_data, (d) -> + d[field] + ).reverse() + + parse_log_entry: (log_entry, field, date_range) -> + parsed_entry = {} + parsed_entry.author = log_entry.author + parsed_entry.dates = {} + parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0 + _.each(_.omit(log_entry, 'author'), (value, key) => + if @in_range(value.date, date_range) + parsed_entry.dates[value.date] = value[field] + parsed_entry.commits += value.commits + parsed_entry.additions += value.additions + parsed_entry.deletions += value.deletions + ) + return parsed_entry + + in_range: (date, date_range) -> + if date_range is null || date_range[0] <= new Date(date) <= date_range[1] + true + else + false + \ No newline at end of file diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 85e43ed..b1a2342 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -37,6 +37,7 @@ @import "sections/wiki.scss"; @import "sections/wall.scss"; @import "sections/dashboard.scss"; +@import "sections/stat_graph.scss"; @import "highlight/white.scss"; @import "highlight/dark.scss"; diff --git a/app/assets/stylesheets/sections/stat_graph.scss b/app/assets/stylesheets/sections/stat_graph.scss new file mode 100644 index 0000000..32b17d8 --- /dev/null +++ b/app/assets/stylesheets/sections/stat_graph.scss @@ -0,0 +1,56 @@ +.tint-box { + border-radius: 6px; + background: #f3f3f3; + position: relative; + margin-bottom: 10px; +} + +.area { + fill: #1db34f; + fill-opacity: 0.5; +} + +.axis { + fill: #aaa; + font-size: 10px; +} + +#contributors .person { + -moz-box-sizing: border-box; + box-sizing: border-box; + float: left; + border-radius: 2px; + margin: 10px; + border: 1px solid #ddd; +} + +.contributors-list { + margin: 0 0 10px 0; + list-style: none; + padding: 0; +} + +#contributors .person .spark { + display: block; + background: #f7f7f7; +} + +#contributors .person .area-contributor { + fill: #f17f49; +} + +.selection rect { + fill: #333; + fill-opacity: 0.1; + stroke: #333; + stroke-width: 1px; + stroke-opacity: 0.4; + shape-rendering: crispedges; + stroke-dasharray: 3 3; +} + +.right{ + float: right; + display: inline-block; + margin-top: 5px; +} diff --git a/app/controllers/stat_graph_controller.rb b/app/controllers/stat_graph_controller.rb new file mode 100644 index 0000000..2a74409 --- /dev/null +++ b/app/controllers/stat_graph_controller.rb @@ -0,0 +1,14 @@ +class StatGraphController < ProjectResourceController + + # Authorize + before_filter :authorize_read_project! + before_filter :authorize_code_access! + before_filter :require_non_empty_project + + def show + @repo = @project.repository + @stats = Gitlab::GitStats.new(@repo.raw, @repo.root_ref) + @log = @stats.parsed_log.to_json + end + +end \ No newline at end of file diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index ec3da96..399bcf5 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -11,6 +11,8 @@ = link_to "Commits", project_commits_path(@project, @ref || @repository.root_ref) = nav_link(controller: %w(graph)) do = link_to "Network", project_graph_path(@project, @ref || @repository.root_ref) + = nav_link(controller: %w(stat_graph)) do + = link_to "Graphs", project_stat_graph_path(@project, @ref || @repository.root_ref) - if @project.issues_enabled = nav_link(controller: %w(issues milestones labels)) do diff --git a/app/views/stat_graph/show.html.haml b/app/views/stat_graph/show.html.haml new file mode 100644 index 0000000..b7b2738 --- /dev/null +++ b/app/views/stat_graph/show.html.haml @@ -0,0 +1,29 @@ +.header.clearfix + .right + %select + %option{:value => "commits"} Commits + %option{:value => "additions"} Additions + %option{:value => "deletions"} Deletions + %h3#date_header + %input#brush_change{:type => "hidden"} + +.graphs + #contributors-master + #contributors.clearfix + %ol.contributors-list.clearfix + +:javascript + controller = new ContributorsStatGraph + controller.init(#{@log}) + + $("select").change( function () { + var field = $(this).val() + controller.set_current_field(field) + controller.redraw_master() + controller.redraw_authors() + }) + + $("#brush_change").change( function () { + controller.change_date_header() + controller.redraw_authors() + }) \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index c802c60..0a1e537 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,6 +190,7 @@ Gitlab::Application.routes.draw do resources :compare, only: [:index, :create] resources :blame, only: [:show], constraints: {id: /.+/} resources :graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/} + resources :stat_graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/} match "/compare/:from...:to" => "compare#show", as: "compare", via: [:get, :post], constraints: {from: /.+/, to: /.+/} scope module: :projects do diff --git a/lib/gitlab/git_stats.rb b/lib/gitlab/git_stats.rb new file mode 100644 index 0000000..1f303f6 --- /dev/null +++ b/lib/gitlab/git_stats.rb @@ -0,0 +1,20 @@ +require 'gitlab/git_stats_log_parser' + +module Gitlab + class GitStats + attr_accessor :repo, :ref + + def initialize repo, ref + @repo, @ref = repo, ref + end + + def log + args = ['--format=%aN%x0a%ad', '--date=short', '--shortstat', '--no-merges'] + repo.git.run(nil, 'log', nil, {}, args) + end + + def parsed_log + LogParser.parse_log(log) + end + end +end diff --git a/lib/gitlab/git_stats_log_parser.rb b/lib/gitlab/git_stats_log_parser.rb new file mode 100644 index 0000000..784e08c --- /dev/null +++ b/lib/gitlab/git_stats_log_parser.rb @@ -0,0 +1,32 @@ +class LogParser + #Parses the log file into a collection of commits + #Data model: {author, date, additions, deletions} + def self.parse_log log_from_git + log = log_from_git.split("\n") + + i = 0 + collection = [] + entry = {} + + while i <= log.size do + pos = i % 4 + case pos + when 0 + unless i == 0 + collection.push(entry) + entry = {} + end + entry[:author] = log[i].to_s + when 1 + entry[:date] = log[i].to_s + when 3 + changes = log[i].split(",") + entry[:additions] = changes[1].to_i unless changes[1].nil? + entry[:deletions] = changes[2].to_i unless changes[2].nil? + end + i += 1 + end + + collection + end +end \ No newline at end of file diff --git a/spec/javascripts/helpers/.gitkeep b/spec/javascripts/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/spec/javascripts/helpers/.gitkeep diff --git a/spec/javascripts/stat_graph_contributors_graph_spec.js b/spec/javascripts/stat_graph_contributors_graph_spec.js new file mode 100644 index 0000000..8d2e203 --- /dev/null +++ b/spec/javascripts/stat_graph_contributors_graph_spec.js @@ -0,0 +1,125 @@ +describe("ContributorsGraph", function () { + describe("#set_x_domain", function () { + it("set the x_domain", function () { + ContributorsGraph.set_x_domain(20) + expect(ContributorsGraph.prototype.x_domain).toEqual(20) + }) + }) + + describe("#set_y_domain", function () { + it("sets the y_domain", function () { + ContributorsGraph.set_y_domain([{commits: 30}]) + expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]) + }) + }) + + describe("#init_x_domain", function () { + it("sets the initial x_domain", function () { + ContributorsGraph.init_x_domain([{date: "2013-01-31"}, {date: "2012-01-31"}]) + expect(ContributorsGraph.prototype.x_domain).toEqual(["2012-01-31", "2013-01-31"]) + }) + }) + + describe("#init_y_domain", function () { + it("sets the initial y_domain", function () { + ContributorsGraph.init_y_domain([{commits: 30}]) + expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]) + }) + }) + + describe("#init_domain", function () { + it("calls init_x_domain and init_y_domain", function () { + spyOn(ContributorsGraph, "init_x_domain") + spyOn(ContributorsGraph, "init_y_domain") + ContributorsGraph.init_domain() + expect(ContributorsGraph.init_x_domain).toHaveBeenCalled() + expect(ContributorsGraph.init_y_domain).toHaveBeenCalled() + }) + }) + + describe("#set_dates", function () { + it("sets the dates", function () { + ContributorsGraph.set_dates("2013-12-01") + expect(ContributorsGraph.prototype.dates).toEqual("2013-12-01") + }) + }) + + describe("#set_x_domain", function () { + it("sets the instance's x domain using the prototype's x_domain", function () { + ContributorsGraph.prototype.x_domain = 20 + var instance = new ContributorsGraph() + instance.x = d3.time.scale().range([0, 100]).clamp(true) + spyOn(instance.x, 'domain') + instance.set_x_domain() + expect(instance.x.domain).toHaveBeenCalledWith(20) + }) + }) + + describe("#set_y_domain", function () { + it("sets the instance's y domain using the prototype's y_domain", function () { + ContributorsGraph.prototype.y_domain = 30 + var instance = new ContributorsGraph() + instance.y = d3.scale.linear().range([100, 0]).nice() + spyOn(instance.y, 'domain') + instance.set_y_domain() + expect(instance.y.domain).toHaveBeenCalledWith(30) + }) + }) + + describe("#set_domain", function () { + it("calls set_x_domain and set_y_domain", function () { + var instance = new ContributorsGraph() + spyOn(instance, 'set_x_domain') + spyOn(instance, 'set_y_domain') + instance.set_domain() + expect(instance.set_x_domain).toHaveBeenCalled() + expect(instance.set_y_domain).toHaveBeenCalled() + }) + }) + + describe("#set_data", function () { + it("sets the data", function () { + var instance = new ContributorsGraph() + instance.set_data("20") + expect(instance.data).toEqual("20") + }) + }) +}) + +describe("ContributorsMasterGraph", function () { + + describe("#process_dates", function () { + it("gets and parses dates", function () { + var graph = new ContributorsMasterGraph() + var data = 'random data here' + spyOn(graph, 'parse_dates') + spyOn(graph, 'get_dates').andReturn("get") + spyOn(ContributorsGraph,'set_dates').andCallThrough() + graph.process_dates(data) + expect(graph.parse_dates).toHaveBeenCalledWith(data) + expect(graph.get_dates).toHaveBeenCalledWith(data) + expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get") + }) + }) + + describe("#get_dates", function () { + it("plucks the date field from data collection", function () { + var graph = new ContributorsMasterGraph() + var data = [{date: "2013-01-01"}, {date: "2012-12-15"}] + expect(graph.get_dates(data)).toEqual(["2013-01-01", "2012-12-15"]) + }) + }) + + describe("#parse_dates", function () { + it("parses the dates", function () { + var graph = new ContributorsMasterGraph() + var parseDate = d3.time.format("%Y-%m-%d").parse + var data = [{date: "2013-01-01"}, {date: "2012-12-15"}] + var correct = [{date: parseDate(data[0].date)}, {date: parseDate(data[1].date)}] + graph.parse_dates(data) + expect(data).toEqual(correct) + }) + }) + + +}) diff --git a/spec/javascripts/stat_graph_contributors_util_spec.js b/spec/javascripts/stat_graph_contributors_util_spec.js new file mode 100644 index 0000000..367f0af --- /dev/null +++ b/spec/javascripts/stat_graph_contributors_util_spec.js @@ -0,0 +1,200 @@ +describe("ContributorsStatGraphUtil", function () { + + describe("#parse_log", function () { + it("returns a correctly parsed log", function () { + var fake_log = [ + {author: "Karlo Soriano", date: "2013-05-09", additions: 471}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}] + + var correct_parsed_log = { + total: [ + {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}], + by_author: + [ + { + author: "Karlo Soriano", + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} + }, + { + author: "Dmitriy Zaporozhets", + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3} + } + ] + } + expect(ContributorsStatGraphUtil.parse_log(fake_log)).toEqual(correct_parsed_log) + }) + }) + + describe("#store_data", function () { + + var fake_entry = {author: "Karlo Soriano", date: "2013-05-09", additions: 471} + var fake_total = {} + var fake_by_author = {} + + it("calls #store_commits", function () { + spyOn(ContributorsStatGraphUtil, 'store_commits') + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.store_commits).toHaveBeenCalled() + }) + + it("calls #store_additions", function () { + spyOn(ContributorsStatGraphUtil, 'store_additions') + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.store_additions).toHaveBeenCalled() + }) + + it("calls #store_deletions", function () { + spyOn(ContributorsStatGraphUtil, 'store_deletions') + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.store_deletions).toHaveBeenCalled() + }) + + }) + + describe("#store_commits", function () { + var fake_total = "fake_total" + var fake_by_author = "fake_by_author" + + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { + spyOn(ContributorsStatGraphUtil, 'add') + ContributorsStatGraphUtil.store_commits(fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "commits", 1], ["fake_by_author", "commits", 1]]) + }) + }) + + describe("#add", function () { + it("adds 1 to current test_field in collection", function () { + var fake_collection = {test_field: 10} + ContributorsStatGraphUtil.add(fake_collection, "test_field", 1) + expect(fake_collection.test_field).toEqual(11) + }) + + it("inits and adds 1 if test_field in collection is not defined", function () { + var fake_collection = {} + ContributorsStatGraphUtil.add(fake_collection, "test_field", 1) + expect(fake_collection.test_field).toEqual(1) + }) + }) + + describe("#store_additions", function () { + var fake_entry = {additions: 10} + var fake_total= "fake_total" + var fake_by_author = "fake_by_author" + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { + spyOn(ContributorsStatGraphUtil, 'add') + ContributorsStatGraphUtil.store_additions(fake_entry, fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "additions", 10], ["fake_by_author", "additions", 10]]) + }) + }) + + describe("#store_deletions", function () { + var fake_entry = {deletions: 10} + var fake_total= "fake_total" + var fake_by_author = "fake_by_author" + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () { + spyOn(ContributorsStatGraphUtil, 'add') + ContributorsStatGraphUtil.store_deletions(fake_entry, fake_total, fake_by_author) + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "deletions", 10], ["fake_by_author", "deletions", 10]]) + }) + }) + + describe("#add_date", function () { + it("adds a date field to the collection", function () { + var fake_date = "2013-10-02" + var fake_collection = {} + ContributorsStatGraphUtil.add_date(fake_date, fake_collection) + expect(fake_collection[fake_date].date).toEqual("2013-10-02") + }) + }) + + describe("#add_author", function () { + it("adds an author field to the collection", function () { + var fake_author = "Author" + var fake_collection = {} + ContributorsStatGraphUtil.add_author(fake_author, fake_collection) + expect(fake_collection[fake_author].author).toEqual("Author") + }) + }) + + describe("#get_total_data", function () { + it("returns the collection sorted via specified field", function () { + var fake_parsed_log = { + total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}], + by_author:[ + { + author: "Karlo Soriano", + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} + }, + { + author: "Dmitriy Zaporozhets", + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3} + } + ]}; + var correct_total_data = [{date: "2013-05-08", commits: 3}, + {date: "2013-05-09", commits: 1}]; + expect(ContributorsStatGraphUtil.get_total_data(fake_parsed_log, "commits")).toEqual(correct_total_data) + }) + }) + + describe("#pick_field", function () { + it("returns the collection with only the specified field and date", function () { + var fake_parsed_log_total = [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}]; + ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits") + var correct_pick_field_data = [{date: "2013-05-09", commits: 1},{date: "2013-05-08", commits: 3}]; + expect(ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits")).toEqual(correct_pick_field_data) + }) + }) + + describe("#get_author_data", function () { + it("returns the log by author sorted by specified field", function () { + var fake_parsed_log = { + total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}], + by_author:[ + { + author: "Karlo Soriano", + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} + }, + { + author: "Dmitriy Zaporozhets", + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3} + } + ]} + var correct_author_data = [{author:"Dmitriy Zaporozhets",dates:{"2013-05-08":3},deletions:7,additions:54,"commits":3}, + {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1}] + expect(ContributorsStatGraphUtil.get_author_data(fake_parsed_log, "commits")).toEqual(correct_author_data) + }) + }) + + describe("#parse_log_entry", function () { + it("adds the corresponding info from the log entry to the author", function () { + var fake_log_entry = { author: "Karlo Soriano", + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} + } + var correct_parsed_log = {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1} + expect(ContributorsStatGraphUtil.parse_log_entry(fake_log_entry, 'commits', null)).toEqual(correct_parsed_log) + }) + }) + + describe("#in_range", function () { + var date = "2013-05-09" + it("returns true if date_range is null", function () { + expect(ContributorsStatGraphUtil.in_range(date, null)).toEqual(true) + }) + it("returns true if date is in range", function () { + var date_range = [new Date("2013-01-01"), new Date("2013-12-12")] + expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(true) + }) + it("returns false if date is not in range", function () { + var date_range = [new Date("1999-12-01"), new Date("2000-12-01")] + expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(false) + }) + }) + + +}) \ No newline at end of file diff --git a/spec/javascripts/stat_graph_spec.js b/spec/javascripts/stat_graph_spec.js new file mode 100644 index 0000000..b888176 --- /dev/null +++ b/spec/javascripts/stat_graph_spec.js @@ -0,0 +1,17 @@ +describe("StatGraph", function () { + + describe("#get_log", function () { + it("returns log", function () { + StatGraph.log = "test"; + expect(StatGraph.get_log()).toBe("test"); + }); + }); + + describe("#set_log", function () { + it("sets the log", function () { + StatGraph.set_log("test"); + expect(StatGraph.log).toBe("test"); + }) + }) + +}); \ No newline at end of file diff --git a/spec/javascripts/support/jasmine.yml b/spec/javascripts/support/jasmine.yml new file mode 100644 index 0000000..9bfa261 --- /dev/null +++ b/spec/javascripts/support/jasmine.yml @@ -0,0 +1,76 @@ +# src_files +# +# Return an array of filepaths relative to src_dir to include before jasmine specs. +# Default: [] +# +# EXAMPLE: +# +# src_files: +# - lib/source1.js +# - lib/source2.js +# - dist/**/*.js +# +src_files: + - assets/application.js + +# stylesheets +# +# Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs. +# Default: [] +# +# EXAMPLE: +# +# stylesheets: +# - css/style.css +# - stylesheets/*.css +# +stylesheets: + - stylesheets/**/*.css + +# helpers +# +# Return an array of filepaths relative to spec_dir to include before jasmine specs. +# Default: ["helpers/**/*.js"] +# +# EXAMPLE: +# +# helpers: +# - helpers/**/*.js +# +helpers: + - helpers/**/*.js + +# spec_files +# +# Return an array of filepaths relative to spec_dir to include. +# Default: ["**/*[sS]pec.js"] +# +# EXAMPLE: +# +# spec_files: +# - **/*[sS]pec.js +# +spec_files: + - '**/*[sS]pec.js' + +# src_dir +# +# Source directory path. Your src_files must be returned relative to this path. Will use root if left blank. +# Default: project root +# +# EXAMPLE: +# +# src_dir: public +# +src_dir: + +# spec_dir +# +# Spec directory path. Your spec_files must be returned relative to this path. +# Default: spec/javascripts +# +# EXAMPLE: +# +# spec_dir: spec/javascripts +# +spec_dir: spec/javascripts diff --git a/spec/javascripts/support/jasmine_helper.rb b/spec/javascripts/support/jasmine_helper.rb new file mode 100644 index 0000000..986a4c1 --- /dev/null +++ b/spec/javascripts/support/jasmine_helper.rb @@ -0,0 +1,11 @@ +#Use this file to set/override Jasmine configuration options +#You can remove it if you don't need it. +#This file is loaded *after* jasmine.yml is interpreted. +# +#Example: using a different boot file. +#Jasmine.configure do |config| +# @config.boot_dir = '/absolute/path/to/boot_dir' +# @config.boot_files = lambda { ['/absolute/path/to/boot_dir/file.js'] } +#end +# + diff --git a/spec/lib/gitlab/git_stats_log_parser_spec.rb b/spec/lib/gitlab/git_stats_log_parser_spec.rb new file mode 100644 index 0000000..c97b322 --- /dev/null +++ b/spec/lib/gitlab/git_stats_log_parser_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' +require 'gitlab/git_stats_log_parser' + + +describe LogParser do + + describe "#self.parse_log" do + context "log_from_git is a valid log" do + it "returns the correct log" do + fake_log = "Karlo Soriano +2013-05-09 + + 14 files changed, 471 insertions(+) +Dmitriy Zaporozhets +2013-05-08 + + 1 file changed, 6 insertions(+), 1 deletion(-) +Dmitriy Zaporozhets +2013-05-08 + + 6 files changed, 19 insertions(+), 3 deletions(-) +Dmitriy Zaporozhets +2013-05-08 + + 3 files changed, 29 insertions(+), 3 deletions(-)"; + + lp = LogParser.parse_log(fake_log) + lp.should eq([ + {author: "Karlo Soriano", date: "2013-05-09", additions: 471}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3}, + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}]) + end + end + end + +end \ No newline at end of file diff --git a/spec/lib/gitlab/git_stats_spec.rb b/spec/lib/gitlab/git_stats_spec.rb new file mode 100644 index 0000000..f9c70da --- /dev/null +++ b/spec/lib/gitlab/git_stats_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::GitStats do + + describe "#parsed_log" do + let(:stats) { Gitlab::GitStats.new(nil, nil) } + before(:each) do + stats.stub(:log).and_return("anything") + end + + context "LogParser#parse_log returns 'test'" do + it "returns 'test'" do + LogParser.stub(:parse_log).and_return("test") + stats.parsed_log.should eq("test") + end + end + end + + describe "#log" do + let(:repo) { Repository.new(nil, nil) } + let(:gs) { Gitlab::GitStats.new(repo.raw, repo.root_ref) } + + before(:each) do + repo.stub(:raw).and_return(nil) + repo.stub(:root_ref).and_return(nil) + repo.raw.stub(:git) + end + + context "repo.git.run returns 'test'" do + it "returns 'test'" do + repo.raw.git.stub(:run).and_return("test") + gs.log.should eq("test") + end + end + end +end \ No newline at end of file -- libgit2 0.21.2