]> source.dussan.org Git - redmine.git/commitdiff
Rewrite the Gantt chart. #6276
authorEric Davis <edavis@littlestreamsoftware.com>
Fri, 10 Sep 2010 03:09:02 +0000 (03:09 +0000)
committerEric Davis <edavis@littlestreamsoftware.com>
Fri, 10 Sep 2010 03:09:02 +0000 (03:09 +0000)
This version of the Gantt chart supports nested charts. So Projects,
Versions, and Issues will be nested underneath their parents correctly.

Additional features:

* Move all Gantt code to Redmine::Helpers::Gantt class instead of having it in
  the Gantt class, controller, and view
* Recursive and nest sub-projects
* Recursive and nest versions
* Recursive and nest issues
* Draw a line showing when a Project is active and it's progress
* Draw a line showing when a Version is active and it's progress
* Show a version's % complete
* Change the color of Projects, Versions, and Issues if they are late or
  behind schedule
* Added Project#start_date and #due_date
* Added Project#completed_percent
* Use a mini-gravatar on the Gantt chart
* Added tests for the Gantt rendering

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@4072 e93f8b46-1217-0410-a6f0-8f06a7374b81

29 files changed:
app/controllers/gantts_controller.rb
app/controllers/issues_controller.rb
app/helpers/application_helper.rb
app/helpers/gantt_helper.rb [new file with mode: 0644]
app/helpers/issues_helper.rb
app/models/issue.rb
app/models/project.rb
app/models/version.rb
app/views/gantts/show.html.erb
lib/redmine/export/pdf.rb
lib/redmine/helpers/gantt.rb
public/images/milestone.png [deleted file]
public/images/milestone_done.png [new file with mode: 0644]
public/images/milestone_late.png [new file with mode: 0644]
public/images/milestone_todo.png [new file with mode: 0644]
public/images/project_marker.png [new file with mode: 0644]
public/images/task_done.png
public/images/version_marker.png [new file with mode: 0644]
public/stylesheets/application.css
test/functional/gantts_controller_test.rb
test/object_daddy_helpers.rb
test/unit/helpers/application_helper_test.rb
test/unit/issue_test.rb
test/unit/lib/redmine/helpers/gantt_test.rb [new file with mode: 0644]
test/unit/project_test.rb
test/unit/version_test.rb
vendor/plugins/gravatar/Rakefile
vendor/plugins/gravatar/lib/gravatar.rb
vendor/plugins/gravatar/spec/gravatar_spec.rb

index 6a6071e86504587d76d59746e26d8cb88c14354a..50fd8c13dc603271568491a74113ae785be9bea4 100644 (file)
@@ -4,6 +4,7 @@ class GanttsController < ApplicationController
 
   rescue_from Query::StatementInvalid, :with => :query_statement_invalid
 
+  helper :gantt
   helper :issues
   helper :projects
   helper :queries
@@ -14,32 +15,17 @@ class GanttsController < ApplicationController
   
   def show
     @gantt = Redmine::Helpers::Gantt.new(params)
+    @gantt.project = @project
     retrieve_query
     @query.group_by = nil
-    if @query.valid?
-      events = []
-      # Issues that have start and due dates
-      events += @query.issues(:include => [:tracker, :assigned_to, :priority],
-                              :order => "start_date, due_date",
-                              :conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
-                              )
-      # Issues that don't have a due date but that are assigned to a version with a date
-      events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
-                              :order => "start_date, effective_date",
-                              :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
-                              )
-      # Versions
-      events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
-                                   
-      @gantt.events = events
-    end
+    @gantt.query = @query if @query.valid?
     
     basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
     
     respond_to do |format|
       format.html { render :action => "show", :layout => !request.xhr? }
-      format.png  { send_data(@gantt.to_image(@project), :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
-      format.pdf  { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
+      format.png  { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
+      format.pdf  { send_data(@gantt.to_pdf, :type => 'application/pdf', :filename => "#{basename}.pdf") }
     end
   end
 
index 0364e307c3166bc52e46ab2fd88a11f49437e23f..9f58cb0a189762abba658bd7561855a47b7f0ae6 100644 (file)
@@ -47,6 +47,7 @@ class IssuesController < ApplicationController
   include SortHelper
   include IssuesHelper
   helper :timelog
+  helper :gantt
   include Redmine::Export::PDF
 
   verify :method => [:post, :delete],
index 0fb44a22bfeed3bc6f4f60050ed71d20a962913b..34b17c760a9c3e6c91d6ee8113cc0d6760b2501a 100644 (file)
@@ -121,6 +121,11 @@ module ApplicationHelper
 
     link_to(text, {:controller => 'repositories', :action => 'revision', :id => project, :rev => revision}, :title => l(:label_revision_id, revision))
   end
+  
+  def link_to_project(project, options={})
+    options[:class] ||= 'project'
+    link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => options[:class])
+  end
 
   # Generates a link to a project if active
   # Examples:
@@ -832,6 +837,8 @@ module ApplicationHelper
         email = $1
       end
       return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
+    else
+      ''
     end
   end
 
diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb
new file mode 100644 (file)
index 0000000..38f3765
--- /dev/null
@@ -0,0 +1,24 @@
+# redMine - project management software
+# Copyright (C) 2006  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module GanttHelper
+  def number_of_issues_on_versions(gantt)
+    versions = gantt.events.collect {|event| (event.is_a? Version) ? event : nil}.compact
+
+    versions.sum {|v| v.fixed_issues.for_gantt.with_query(@query).count}
+  end
+end
index 6178229868396f939451b14e86ff82c2c730be85..284aae91a40986982a92f9a9fca7b7027dd2d58e 100644 (file)
@@ -35,8 +35,10 @@ module IssuesHelper
     @cached_label_due_date ||= l(:field_due_date)
     @cached_label_assigned_to ||= l(:field_assigned_to)
     @cached_label_priority ||= l(:field_priority)
-    
+    @cached_label_project ||= l(:field_project)
+
     link_to_issue(issue) + "<br /><br />" +
+      "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />" +
       "<strong>#{@cached_label_status}</strong>: #{issue.status.name}<br />" +
       "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
       "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
index 7d0682df11565108e4212d0ea56596e251989c58..80db481085e4f05c0e93df9a6da4d061ecde42c0 100644 (file)
@@ -62,10 +62,28 @@ class Issue < ActiveRecord::Base
   
   named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
 
-  named_scope :recently_updated, :order => "#{self.table_name}.updated_on DESC"
+  named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
   named_scope :with_limit, lambda { |limit| { :limit => limit} }
   named_scope :on_active_project, :include => [:status, :project, :tracker],
                                   :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
+  named_scope :for_gantt, lambda {
+    {
+      :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
+      :order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
+    }
+  }
+
+  named_scope :without_version, lambda {
+    {
+      :conditions => { :fixed_version_id => nil}
+    }
+  }
+
+  named_scope :with_query, lambda {|query|
+    {
+      :conditions => Query.merge_conditions(query.statement)
+    }
+  }
 
   before_create :default_assign
   before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
@@ -357,6 +375,13 @@ class Issue < ActiveRecord::Base
   def overdue?
     !due_date.nil? && (due_date < Date.today) && !status.is_closed?
   end
+
+  # Is the amount of work done less than it should for the due date
+  def behind_schedule?
+    return false if start_date.nil? || due_date.nil?
+    done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
+    return done_date <= Date.today
+  end
   
   # Users the issue can be assigned to
   def assignable_users
index 931f89b55adfdb0d588dad0f243f93c6e0e4ef09..5ef7915def75cd6bd5d47394511a11d8ce70bbc4 100644 (file)
@@ -412,6 +412,50 @@ class Project < ActiveRecord::Base
   def short_description(length = 255)
     description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
   end
+
+  # The earliest start date of a project, based on it's issues and versions
+  def start_date
+    if module_enabled?(:issue_tracking)
+      [
+       issues.minimum('start_date'),
+       shared_versions.collect(&:effective_date),
+       shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
+      ].flatten.compact.min
+    end
+  end
+
+  # The latest due date of an issue or version
+  def due_date
+    if module_enabled?(:issue_tracking)
+      [
+       issues.maximum('due_date'),
+       shared_versions.collect(&:effective_date),
+       shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
+      ].flatten.compact.max
+    end
+  end
+
+  def overdue?
+    active? && !due_date.nil? && (due_date < Date.today)
+  end
+
+  # Returns the percent completed for this project, based on the
+  # progress on it's versions.
+  def completed_percent(options={:include_subprojects => false})
+    if options.delete(:include_subprojects)
+      total = self_and_descendants.collect(&:completed_percent).sum
+
+      total / self_and_descendants.count
+    else
+      if versions.count > 0
+        total = versions.collect(&:completed_pourcent).sum
+
+        total / versions.count
+      else
+        100
+      end
+    end
+  end
   
   # Return true if this project is allowed to do the specified action.
   # action can be:
index 07e66434dd255397c900d8b8ccf512aa98acb09a..c3969fe87e817c97d80f12fb6e8eae96e582568e 100644 (file)
@@ -73,6 +73,18 @@ class Version < ActiveRecord::Base
   def completed?
     effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
   end
+
+  def behind_schedule?
+    if completed_pourcent == 100
+      return false
+    elsif due_date && fixed_issues.present? && fixed_issues.minimum('start_date') # TODO: should use #start_date but that method is wrong...
+      start_date = fixed_issues.minimum('start_date')
+      done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
+      return done_date <= Date.today
+    else
+      false # No issues so it's not late
+    end
+  end
   
   # Returns the completion percentage of this version based on the amount of open/closed issues
   # and the time spent on the open issues.
index 5d4ef0dbf9e2040b50bf613b02c2190cd0a19156..ce8c67b26343d332bc5dccd14af7a136c569d8d9 100644 (file)
@@ -1,3 +1,4 @@
+<% @gantt.view = self %>
 <h2><%= l(:label_gantt) %></h2>
 
 <% form_tag(gantt_path(:month => params[:month], :year => params[:year], :months => params[:months]), :method => :put, :id => 'query_form') do %>
@@ -55,11 +56,12 @@ if @gantt.zoom >1
     end
 end
 
+# Width of the entire chart    
 g_width = (@gantt.date_to - @gantt.date_from + 1)*zoom
-g_height = [(20 * @gantt.events.length + 6)+150, 206].max
+# Collect the number of issues on Versions
+g_height = [(20 * (@gantt.number_of_rows + 6))+150, 206].max
 t_height = g_height + headers_height
 %>
-
 <table width="100%" style="border:0; border-collapse: collapse;">
 <tr>
 <td style="width:<%= subject_width %>px; padding:0px;">
@@ -67,26 +69,10 @@ t_height = g_height + headers_height
 <div style="position:relative;height:<%= t_height + 24 %>px;width:<%= subject_width + 1 %>px;">
 <div style="right:-2px;width:<%= subject_width %>px;height:<%= headers_height %>px;background: #eee;" class="gantt_hdr"></div>
 <div style="right:-2px;width:<%= subject_width %>px;height:<%= t_height %>px;border-left: 1px solid #c0c0c0;overflow:hidden;" class="gantt_hdr"></div>
-<%
-#
-# Tasks subjects
-#
-top = headers_height + 8
-@gantt.events.each do |i|
-left = 4 + (i.is_a?(Issue) ? i.level * 16 : 0)
- %>
-    <div style="position: absolute;line-height:1.2em;height:16px;top:<%= top %>px;left:<%= left %>px;overflow:hidden;"><small>    
-    <% if i.is_a? Issue %>
-       <%= h("#{i.project} -") unless @project && @project == i.project %>
-       <%= link_to_issue i %>
-       <% else %>
-               <span class="icon icon-package">
-               <%= link_to_version i %>
-               </span>
-       <% end %>       
-       </small></div>
-    <% top = top + 20
-end %>
+<% top = headers_height + 8 %>
+
+<%= @gantt.subjects(:headers_height => headers_height, :top => top, :g_width => g_width) %>
+
 </div>
 </td>
 <td>
@@ -164,53 +150,9 @@ if show_days
        end
 end %>
 
-<%
-#
-# Tasks
-#
-top = headers_height + 10
-@gantt.events.each do |i| 
-  if i.is_a? Issue 
-       i_start_date = (i.start_date >= @gantt.date_from ? i.start_date : @gantt.date_from )
-       i_end_date = (i.due_before <= @gantt.date_to ? i.due_before : @gantt.date_to )
-       
-       i_done_date = i.start_date + ((i.due_before - i.start_date+1)*i.done_ratio/100).floor
-       i_done_date = (i_done_date <= @gantt.date_from ? @gantt.date_from : i_done_date )
-       i_done_date = (i_done_date >= @gantt.date_to ? @gantt.date_to : i_done_date )
-       
-       i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
-       
-       i_left = ((i_start_date - @gantt.date_from)*zoom).floor         
-       i_width = ((i_end_date - i_start_date + 1)*zoom).floor - 2                  # total width of the issue (- 2 for left and right borders)
-       d_width = ((i_done_date - i_start_date)*zoom).floor - 2                     # done width
-       l_width = i_late_date ? ((i_late_date - i_start_date+1)*zoom).floor - 2 : 0 # delay width
-  css = "task " + (i.leaf? ? 'leaf' : 'parent')
-       %>
-       <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;" class="<%= css %> task_todo"><div class="left"></div>&nbsp;<div class="right"></div></div>
-       <% if l_width > 0 %>
-           <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= l_width %>px;" class="<%= css %> task_late">&nbsp;</div>
-       <% end %>
-       <% if d_width > 0 %>
-           <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= d_width %>px;" class="<%= css %> task_done">&nbsp;</div>
-       <% end %>
-       <div style="top:<%= top %>px;left:<%= i_left + i_width + 8 %>px;background:#fff;" class="<%= css %>">
-       <%= i.status.name %>
-       <%= (i.done_ratio).to_i %>%
-       </div>
-       <div class="tooltip" style="position: absolute;top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;height:12px;">
-       <span class="tip">
-    <%= render_issue_tooltip i %>
-       </span></div>
-<% else 
-    i_left = ((i.start_date - @gantt.date_from)*zoom).floor
-    %>
-    <div style="top:<%= top %>px;left:<%= i_left %>px;width:15px;" class="task milestone">&nbsp;</div>
-       <div style="top:<%= top %>px;left:<%= i_left + 12 %>px;background:#fff;" class="task">
-               <strong><%= format_version_name i %></strong>
-       </div>
-<% end %>
-       <% top = top + 20
-end %>
+<% top = headers_height + 10 %>
+
+<%= @gantt.lines(:top => top, :zoom => zoom, :g_width => g_width ) %>
 
 <%
 #
index 9b1f2b8a22900a877935d54ad7613ea1fdd11fdc..27905b2bbbd73a3a2a36906fca818da4f1e7471a 100644 (file)
@@ -184,7 +184,7 @@ module Redmine
         end
         pdf.Output
       end
-      
+
       # Returns a PDF string of a single issue
       def issue_to_pdf(issue)
         pdf = IFPDF.new(current_language)
@@ -208,7 +208,7 @@ module Redmine
         pdf.SetFontStyle('',9)
         pdf.Cell(60,5, issue.priority.to_s,"RT")        
         pdf.Ln
-          
+        
         pdf.SetFontStyle('B',9)
         pdf.Cell(35,5, l(:field_author) + ":","L")
         pdf.SetFontStyle('',9)
@@ -238,14 +238,14 @@ module Redmine
         pdf.SetFontStyle('',9)
         pdf.Cell(60,5, format_date(issue.due_date),"RB")
         pdf.Ln
-          
+        
         for custom_value in issue.custom_field_values
           pdf.SetFontStyle('B',9)
           pdf.Cell(35,5, custom_value.custom_field.name + ":","L")
           pdf.SetFontStyle('',9)
           pdf.MultiCell(155,5, (show_value custom_value),"R")
         end
-          
+        
         pdf.SetFontStyle('B',9)
         pdf.Cell(35,5, l(:field_subject) + ":","LTB")
         pdf.SetFontStyle('',9)
@@ -311,187 +311,7 @@ module Redmine
         end
         pdf.Output
       end
-      
-      # Returns a PDF string of a gantt chart
-      def gantt_to_pdf(gantt, project)
-        pdf = IFPDF.new(current_language)
-        pdf.SetTitle("#{l(:label_gantt)} #{project}")
-        pdf.AliasNbPages
-        pdf.footer_date = format_date(Date.today)
-        pdf.AddPage("L")
-        pdf.SetFontStyle('B',12)
-        pdf.SetX(15)
-        pdf.Cell(70, 20, project.to_s)
-        pdf.Ln
-        pdf.SetFontStyle('B',9)
-        
-        subject_width = 100
-        header_heigth = 5
-        
-        headers_heigth = header_heigth
-        show_weeks = false
-        show_days = false
-        
-        if gantt.months < 7
-          show_weeks = true
-          headers_heigth = 2*header_heigth
-          if gantt.months < 3
-            show_days = true
-            headers_heigth = 3*header_heigth
-          end
-        end
-        
-        g_width = 280 - subject_width
-        zoom = (g_width) / (gantt.date_to - gantt.date_from + 1)
-        g_height = 120
-        t_height = g_height + headers_heigth
-        
-        y_start = pdf.GetY
-        
-        # Months headers
-        month_f = gantt.date_from
-        left = subject_width
-        height = header_heigth
-        gantt.months.times do 
-          width = ((month_f >> 1) - month_f) * zoom 
-          pdf.SetY(y_start)
-          pdf.SetX(left)
-          pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
-          left = left + width
-          month_f = month_f >> 1
-        end  
-        
-        # Weeks headers
-        if show_weeks
-          left = subject_width
-          height = header_heigth
-          if gantt.date_from.cwday == 1
-            # gantt.date_from is monday
-            week_f = gantt.date_from
-          else
-            # find next monday after gantt.date_from
-            week_f = gantt.date_from + (7 - gantt.date_from.cwday + 1)
-            width = (7 - gantt.date_from.cwday + 1) * zoom-1
-            pdf.SetY(y_start + header_heigth)
-            pdf.SetX(left)
-            pdf.Cell(width + 1, height, "", "LTR")
-            left = left + width+1
-          end
-          while week_f <= gantt.date_to
-            width = (week_f + 6 <= gantt.date_to) ? 7 * zoom : (gantt.date_to - week_f + 1) * zoom
-            pdf.SetY(y_start + header_heigth)
-            pdf.SetX(left)
-            pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
-            left = left + width
-            week_f = week_f+7
-          end
-        end
-        
-        # Days headers
-        if show_days
-          left = subject_width
-          height = header_heigth
-          wday = gantt.date_from.cwday
-          pdf.SetFontStyle('B',7)
-          (gantt.date_to - gantt.date_from + 1).to_i.times do 
-            width = zoom
-            pdf.SetY(y_start + 2 * header_heigth)
-            pdf.SetX(left)
-            pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
-            left = left + width
-            wday = wday + 1
-            wday = 1 if wday > 7
-          end
-        end
-        
-        pdf.SetY(y_start)
-        pdf.SetX(15)
-        pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
-        
-        # Tasks
-        top = headers_heigth + y_start
-        pdf.SetFontStyle('B',7)
-        gantt.events.each do |i|
-          pdf.SetY(top)
-          pdf.SetX(15)
-          
-          text = ""
-          if i.is_a? Issue
-            text = "#{i.tracker} #{i.id}: #{i.subject}"
-          else
-            text = i.name
-          end
-          text = "#{i.project} - #{text}" unless project && project == i.project
-          pdf.Cell(subject_width-15, 5, text, "LR")
-        
-          pdf.SetY(top + 0.2)
-          pdf.SetX(subject_width)
-          pdf.SetFillColor(255, 255, 255)
-          pdf.Cell(g_width, 4.6, "", "LR", 0, "", 1)
-          pdf.SetY(top+1.5)
-          
-          if i.is_a? Issue
-            i_start_date = (i.start_date >= gantt.date_from ? i.start_date : gantt.date_from )
-            i_end_date = (i.due_before <= gantt.date_to ? i.due_before : gantt.date_to )
-            
-            i_done_date = i.start_date + ((i.due_before - i.start_date+1)*i.done_ratio/100).floor
-            i_done_date = (i_done_date <= gantt.date_from ? gantt.date_from : i_done_date )
-            i_done_date = (i_done_date >= gantt.date_to ? gantt.date_to : i_done_date )
-            
-            i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
-            
-            i_left = ((i_start_date - gantt.date_from)*zoom) 
-            i_width = ((i_end_date - i_start_date + 1)*zoom)
-            d_width = ((i_done_date - i_start_date)*zoom)
-            l_width = ((i_late_date - i_start_date+1)*zoom) if i_late_date
-            l_width ||= 0
-          
-            pdf.SetX(subject_width + i_left)
-            pdf.SetFillColor(200,200,200)
-            pdf.Cell(i_width, 2, "", 0, 0, "", 1)
-          
-            if l_width > 0
-              pdf.SetY(top+1.5)
-              pdf.SetX(subject_width + i_left)
-              pdf.SetFillColor(255,100,100)
-              pdf.Cell(l_width, 2, "", 0, 0, "", 1)
-            end 
-            if d_width > 0
-              pdf.SetY(top+1.5)
-              pdf.SetX(subject_width + i_left)
-              pdf.SetFillColor(100,100,255)
-              pdf.Cell(d_width, 2, "", 0, 0, "", 1)
-            end
-            
-            pdf.SetY(top+1.5)
-            pdf.SetX(subject_width + i_left + i_width)
-            pdf.Cell(30, 2, "#{i.status} #{i.done_ratio}%")
-          else
-            i_left = ((i.start_date - gantt.date_from)*zoom) 
-            
-            pdf.SetX(subject_width + i_left)
-            pdf.SetFillColor(50,200,50)
-            pdf.Cell(2, 2, "", 0, 0, "", 1) 
-        
-            pdf.SetY(top+1.5)
-            pdf.SetX(subject_width + i_left + 3)
-            pdf.Cell(30, 2, "#{i.name}")
-          end
-          
-          top = top + 5
-          pdf.SetDrawColor(200, 200, 200)
-          pdf.Line(15, top, subject_width+g_width, top)
-          if pdf.GetY() > 180
-            pdf.AddPage("L")
-            top = 20
-            pdf.Line(15, top, subject_width+g_width, top)
-          end
-          pdf.SetDrawColor(0, 0, 0)
-        end
-        
-        pdf.Line(15, top, subject_width+g_width, top)
-        pdf.Output
-      end
+
     end
   end
 end
index 96ba4db7c0ac2020343d35613c1ce82cf2fb45c8..33a4e1c2c1e4509d734500cca6bac2751969e91f 100644 (file)
@@ -19,11 +19,28 @@ module Redmine
   module Helpers
     # Simple class to handle gantt chart data
     class Gantt
-      attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :events
-    
+      include ERB::Util
+      include Redmine::I18n
+
+      # :nodoc:
+      # Some utility methods for the PDF export
+      class PDF
+        MaxCharactorsForSubject = 45
+        TotalWidth = 280
+        LeftPaneWidth = 100
+
+        def self.right_pane_width
+          TotalWidth - LeftPaneWidth
+        end
+      end
+
+      attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months
+      attr_accessor :query
+      attr_accessor :project
+      attr_accessor :view
+      
       def initialize(options={})
         options = options.dup
-        @events = []
         
         if options[:year] && options[:year].to_i >0
           @year_from = options[:year].to_i
@@ -52,31 +69,6 @@ module Redmine
         @date_to = (@date_from >> @months) - 1
       end
       
-      
-      def events=(e)
-        @events = e
-        # Adds all ancestors
-        root_ids = e.select {|i| i.is_a?(Issue) && i.parent_id? }.collect(&:root_id).uniq
-        if root_ids.any?
-          # Retrieves all nodes
-          parents = Issue.find_all_by_root_id(root_ids, :conditions => ["rgt - lft > 1"])
-          # Only add ancestors
-          @events += parents.select {|p| @events.detect {|i| i.is_a?(Issue) && p.is_ancestor_of?(i)}}
-        end
-        @events.uniq!
-        # Sort issues by hierarchy and start dates
-        @events.sort! {|x,y| 
-          if x.is_a?(Issue) && y.is_a?(Issue)
-            gantt_issue_compare(x, y, @events)
-          else
-            gantt_start_compare(x, y)
-          end
-        }
-        # Removes issues that have no start or end date
-        @events.reject! {|i| i.is_a?(Issue) && (i.start_date.nil? || i.due_before.nil?) }
-        @events
-      end
-      
       def params
         { :zoom => zoom, :year => year_from, :month => month_from, :months => months }
       end
@@ -88,10 +80,652 @@ module Redmine
       def params_next
         { :year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months }
       end
-      
+
+            ### Extracted from the HTML view/helpers
+      # Returns the number of rows that will be rendered on the Gantt chart
+      def number_of_rows
+        if @project
+          return number_of_rows_on_project(@project)
+        else
+          Project.roots.inject(0) do |total, project|
+            total += number_of_rows_on_project(project)
+          end
+        end
+      end
+
+      # Returns the number of rows that will be used to list a project on
+      # the Gantt chart.  This will recurse for each subproject.
+      def number_of_rows_on_project(project)
+        # Remove the project requirement for Versions because it will
+        # restrict issues to only be on the current project.  This
+        # ends up missing issues which are assigned to shared versions.
+        @query.project = nil if @query.project
+
+        # One Root project
+        count = 1
+        # Issues without a Version
+        count += project.issues.for_gantt.without_version.with_query(@query).count
+
+        # Versions
+        count += project.versions.count
+
+        # Issues on the Versions
+        project.versions.each do |version|
+          count += version.fixed_issues.for_gantt.with_query(@query).count
+        end
+
+        # Subprojects
+        project.children.each do |subproject|
+          count += number_of_rows_on_project(subproject)
+        end
+
+        count
+      end
+
+      # Renders the subjects of the Gantt chart, the left side.
+      def subjects(options={})
+        options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
+
+        output = ''
+        if @project
+          output << render_project(@project, options)
+        else
+          Project.roots.each do |project|
+            output << render_project(project, options)
+          end
+        end
+
+        output
+      end
+
+      # Renders the lines of the Gantt chart, the right side
+      def lines(options={})
+        options = {:indent => 4, :render => :line, :format => :html}.merge(options)
+        output = ''
+
+        if @project
+          output << render_project(@project, options)
+        else
+          Project.roots.each do |project|
+            output << render_project(project, options)
+          end
+        end
+        
+        output
+      end
+
+      def render_project(project, options={})
+        options[:top] = 0 unless options.key? :top
+        options[:indent_increment] = 20 unless options.key? :indent_increment
+        options[:top_increment] = 20 unless options.key? :top_increment
+
+        output = ''
+        # Project Header
+        project_header = if options[:render] == :subject
+                           subject_for_project(project, options)
+                         else
+                           # :line
+                           line_for_project(project, options)
+                         end
+        output << project_header if options[:format] == :html
+        
+        options[:top] += options[:top_increment]
+        options[:indent] += options[:indent_increment]
+        
+        # Second, Issues without a version
+        issues = project.issues.for_gantt.without_version.with_query(@query)
+        if issues
+          issue_rendering = render_issues(issues, options)
+          output << issue_rendering if options[:format] == :html
+        end
+
+        # Third, Versions
+        project.versions.sort.each do |version|
+          version_rendering = render_version(version, options)
+          output << version_rendering if options[:format] == :html
+        end
+
+        # Fourth, subprojects
+        project.children.each do |project|
+          subproject_rendering = render_project(project, options)
+          output << subproject_rendering if options[:format] == :html
+        end
+
+        # Remove indent to hit the next sibling
+        options[:indent] -= options[:indent_increment]
+        
+        output
+      end
+
+      def render_issues(issues, options={})
+        output = ''
+        issues.each do |i|
+          issue_rendering = if options[:render] == :subject
+                              subject_for_issue(i, options)
+                            else
+                              # :line
+                              line_for_issue(i, options)
+                            end
+          output << issue_rendering if options[:format] == :html
+          options[:top] += options[:top_increment]
+        end
+        output
+      end
+
+      def render_version(version, options={})
+        output = ''
+        # Version header
+        version_rendering = if options[:render] == :subject
+                              subject_for_version(version, options)
+                            else
+                              # :line
+                              line_for_version(version, options)
+                            end
+
+        output << version_rendering if options[:format] == :html
+        
+        options[:top] += options[:top_increment]
+
+        # Remove the project requirement for Versions because it will
+        # restrict issues to only be on the current project.  This
+        # ends up missing issues which are assigned to shared versions.
+        @query.project = nil if @query.project
+        
+        issues = version.fixed_issues.for_gantt.with_query(@query)
+        if issues
+          # Indent issues
+          options[:indent] += options[:indent_increment]
+          output << render_issues(issues, options)
+          options[:indent] -= options[:indent_increment]
+        end
+
+        output
+      end
+
+      def subject_for_project(project, options)
+        case options[:format]
+        when :html
+          output = ''
+
+          output << "<div class='project-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small>    "
+          if project.is_a? Project
+            output << "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
+            output << view.link_to_project(project)
+            output << '</span>'
+          else
+            ActiveRecord::Base.logger.debug "Gantt#subject_for_project was not given a project"
+            ''
+          end
+          output << "</small></div>"
+
+          output
+        when :image
+          
+          options[:image].fill('black')
+          options[:image].stroke('transparent')
+          options[:image].stroke_width(1)
+          options[:image].text(options[:indent], options[:top] + 2, project.name)
+        when :pdf
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(15)
+          
+          char_limit = PDF::MaxCharactorsForSubject - options[:indent]
+          options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{project.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
+        
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(options[:subject_width])
+          options[:pdf].Cell(options[:g_width], 5, "", "LR")
+        end
+      end
+
+      def line_for_project(project, options)
+        # Skip versions that don't have a start_date
+        if project.is_a?(Project) && project.start_date
+          options[:zoom] ||= 1
+          options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
+
+          
+          case options[:format]
+          when :html
+            output = ''
+            i_left = ((project.start_date - self.date_from)*options[:zoom]).floor
+
+            start_date = project.start_date
+            start_date ||= self.date_from
+            start_left = ((start_date - self.date_from)*options[:zoom]).floor
+
+            i_end_date = ((project.due_date <= self.date_to) ? project.due_date : self.date_to )
+            i_done_date = start_date + ((project.due_date - start_date+1)* project.completed_percent(:include_subprojects => true)/100).floor
+            i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
+            i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
+            
+            i_late_date = [i_end_date, Date.today].min if start_date < Date.today
+            i_end = ((i_end_date - self.date_from) * options[:zoom]).floor
+
+            i_width = (i_end - i_left + 1).floor - 2                  # total width of the issue (- 2 for left and right borders)
+            d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2                     # done width
+            l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
+
+            # Bar graphic
+
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if i_end > 0 && i_left <= options[:g_width]
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task project_todo'>&nbsp;</div>"
+            end
+            
+            if l_width > 0 && i_left <= options[:g_width]
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task project_late'>&nbsp;</div>"
+            end
+            if d_width > 0 && i_left <= options[:g_width]
+              output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task project_done'>&nbsp;</div>"
+            end
+
+            
+            # Starting diamond
+            if start_left <= options[:g_width] && start_left > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task project-line starting'>&nbsp;</div>"
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;' class='task label'>"
+              output << "</div>"
+            end
+
+            # Ending diamond
+            # Don't show items too far ahead
+            if i_end <= options[:g_width] && i_end > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task project-line ending'>&nbsp;</div>"
+            end
+
+            # DIsplay the Project name and %
+            if i_end <= options[:g_width]
+              # Display the status even if it's floated off to the left
+              status_px = i_end + 12 # 12px for the diamond
+              status_px = 0 if status_px <= 0
+
+              output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label project-name'>"
+              output << "<strong>#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%</strong>"
+              output << "</div>"
+            end
+
+            output
+          when :image
+            options[:image].stroke('transparent')
+            i_left = options[:subject_width] + ((project.due_date - self.date_from)*options[:zoom]).floor
+
+            # Make sure negative i_left doesn't overflow the subject
+            if i_left > options[:subject_width]
+              options[:image].fill('blue')
+              options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)        
+              options[:image].fill('black')
+              options[:image].text(i_left + 11, options[:top] + 1, project.name)
+            end
+          when :pdf
+            options[:pdf].SetY(options[:top]+1.5)
+            i_left = ((project.due_date - @date_from)*options[:zoom])
+
+            # Make sure negative i_left doesn't overflow the subject
+            if i_left > 0
+              options[:pdf].SetX(options[:subject_width] + i_left)
+              options[:pdf].SetFillColor(50,50,200)
+              options[:pdf].Cell(2, 2, "", 0, 0, "", 1) 
+        
+              options[:pdf].SetY(options[:top]+1.5)
+              options[:pdf].SetX(options[:subject_width] + i_left + 3)
+              options[:pdf].Cell(30, 2, "#{project.name}")
+            end
+          end
+        else
+          ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
+          ''
+        end
+      end
+
+      def subject_for_version(version, options)
+        case options[:format]
+        when :html
+          output = ''
+          output << "<div class='version-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small>    "
+          if version.is_a? Version
+            output << "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
+            output << view.link_to_version(version)
+            output << '</span>'
+          else
+            ActiveRecord::Base.logger.debug "Gantt#subject_for_version was not given a version"
+            ''
+          end
+          output << "</small></div>"
+
+          output
+        when :image
+          options[:image].fill('black')
+          options[:image].stroke('transparent')
+          options[:image].stroke_width(1)
+          options[:image].text(options[:indent], options[:top] + 2, version.name)
+        when :pdf
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(15)
+          
+          char_limit = PDF::MaxCharactorsForSubject - options[:indent]
+          options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{version.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
+        
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(options[:subject_width])
+          options[:pdf].Cell(options[:g_width], 5, "", "LR")
+        end
+      end
+
+      def line_for_version(version, options)
+        # Skip versions that don't have a start_date
+        if version.is_a?(Version) && version.start_date
+          options[:zoom] ||= 1
+          options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
+
+          case options[:format]
+          when :html
+            output = ''
+            i_left = ((version.start_date - self.date_from)*options[:zoom]).floor
+            # TODO: or version.fixed_issues.collect(&:start_date).min
+            start_date = version.fixed_issues.minimum('start_date') if version.fixed_issues.present?
+            start_date ||= self.date_from
+            start_left = ((start_date - self.date_from)*options[:zoom]).floor
+
+            i_end_date = ((version.due_date <= self.date_to) ? version.due_date : self.date_to )
+            i_done_date = start_date + ((version.due_date - start_date+1)* version.completed_pourcent/100).floor
+            i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
+            i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
+            
+            i_late_date = [i_end_date, Date.today].min if start_date < Date.today
+
+            i_width = (i_left - start_left + 1).floor - 2                  # total width of the issue (- 2 for left and right borders)
+            d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2                     # done width
+            l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
+
+            i_end = ((i_end_date - self.date_from) * options[:zoom]).floor # Ending pixel
+
+            # Bar graphic
+
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if i_width > 0 && i_left <= options[:g_width]
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task milestone_todo'>&nbsp;</div>"
+            end
+            if l_width > 0 && i_left <= options[:g_width]
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task milestone_late'>&nbsp;</div>"
+            end
+            if d_width > 0 && i_left <= options[:g_width]
+              output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task milestone_done'>&nbsp;</div>"
+            end
+
+            
+            # Starting diamond
+            if start_left <= options[:g_width] && start_left > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task milestone starting'>&nbsp;</div>"
+              output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;background:#fff;' class='task'>"
+              output << "</div>"
+            end
+
+            # Ending diamond
+            # Don't show items too far ahead
+            if i_left <= options[:g_width] && i_end > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task milestone ending'>&nbsp;</div>"
+            end
+
+            # Display the Version name and %
+            if i_end <= options[:g_width]
+              # Display the status even if it's floated off to the left
+              status_px = i_end + 12 # 12px for the diamond
+              status_px = 0 if status_px <= 0
+              
+              output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label version-name'>"
+              output << h("#{version.project} -") unless @project && @project == version.project
+              output << "<strong>#{h version } #{h version.completed_pourcent.to_i.to_s}%</strong>"
+              output << "</div>"
+            end
+
+            output
+          when :image
+            options[:image].stroke('transparent')
+            i_left = options[:subject_width] + ((version.start_date - @date_from)*options[:zoom]).floor
+
+            # Make sure negative i_left doesn't overflow the subject
+            if i_left > options[:subject_width]
+              options[:image].fill('green')
+              options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)        
+              options[:image].fill('black')
+              options[:image].text(i_left + 11, options[:top] + 1, version.name)
+            end
+          when :pdf
+            options[:pdf].SetY(options[:top]+1.5)
+            i_left = ((version.start_date - @date_from)*options[:zoom]) 
+
+            # Make sure negative i_left doesn't overflow the subject
+            if i_left > 0
+              options[:pdf].SetX(options[:subject_width] + i_left)
+              options[:pdf].SetFillColor(50,200,50)
+              options[:pdf].Cell(2, 2, "", 0, 0, "", 1) 
+        
+              options[:pdf].SetY(options[:top]+1.5)
+              options[:pdf].SetX(options[:subject_width] + i_left + 3)
+              options[:pdf].Cell(30, 2, "#{version.name}")
+            end
+          end
+        else
+          ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
+          ''
+        end
+      end
+
+      def subject_for_issue(issue, options)
+        case options[:format]
+        when :html
+          output = ''
+          output << "<div class='tooltip'>"
+          output << "<div class='issue-subject' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small>    "
+          if issue.is_a? Issue
+            css_classes = []
+            css_classes << 'issue-overdue' if issue.overdue?
+            css_classes << 'issue-behind-schedule' if issue.behind_schedule?
+            css_classes << 'icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
+
+            if issue.assigned_to.present?
+              assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
+              output << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string)
+            end
+            output << "<span class='#{css_classes.join(' ')}'>"
+            output << view.link_to_issue(issue)
+            output << ":"
+            output << h(issue.subject)
+            output << '</span>'
+          else
+            ActiveRecord::Base.logger.debug "Gantt#subject_for_issue was not given an issue"
+            ''
+          end
+          output << "</small></div>"
+
+          # Tooltip
+          if issue.is_a? Issue
+            output << "<span class='tip' style='position: absolute;top:#{ options[:top].to_i + 16 }px;left:#{ options[:indent].to_i + 20 }px;'>"
+            output << view.render_issue_tooltip(issue)
+            output << "</span>"
+          end
+
+          output << "</div>"
+          output
+        when :image
+          options[:image].fill('black')
+          options[:image].stroke('transparent')
+          options[:image].stroke_width(1)
+          options[:image].text(options[:indent], options[:top] + 2, issue.subject)
+        when :pdf
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(15)
+          
+          char_limit = PDF::MaxCharactorsForSubject - options[:indent]
+          options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{issue.tracker} #{issue.id}: #{issue.subject}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
+        
+          options[:pdf].SetY(options[:top])
+          options[:pdf].SetX(options[:subject_width])
+          options[:pdf].Cell(options[:g_width], 5, "", "LR")
+        end
+      end
+
+      def line_for_issue(issue, options)
+        # Skip issues that don't have a due_before (due_date or version's due_date)
+        if issue.is_a?(Issue) && issue.due_before
+          case options[:format]
+          when :html
+            output = ''
+            # Handle nil start_dates, rare but can happen.
+            i_start_date =  if issue.start_date && issue.start_date >= self.date_from
+                              issue.start_date
+                            else
+                              self.date_from
+                            end
+
+            i_end_date = ((issue.due_before && issue.due_before <= self.date_to) ? issue.due_before : self.date_to )
+            i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
+            i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
+            i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
+            
+            i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
+            
+            i_left = ((i_start_date - self.date_from)*options[:zoom]).floor    
+            i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor - 2                  # total width of the issue (- 2 for left and right borders)
+            d_width = ((i_done_date - i_start_date)*options[:zoom]).floor - 2                     # done width
+            l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
+            css = "task " + (issue.leaf? ? 'leaf' : 'parent')
+            
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if i_width > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;' class='#{css} task_todo'>&nbsp;</div>"
+            end
+            if l_width > 0
+              output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ l_width }px;' class='#{css} task_late'>&nbsp;</div>"
+            end
+            if d_width > 0
+              output<< "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ d_width }px;' class='#{css} task_done'>&nbsp;</div>"
+            end
+
+            # Display the status even if it's floated off to the left
+            status_px = i_left + i_width + 5
+            status_px = 5 if status_px <= 0
+            
+            output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='#{css} label issue-name'>"
+            output << issue.status.name
+            output << ' '
+            output << (issue.done_ratio).to_i.to_s
+            output << "%"
+            output << "</div>"
+
+            output << "<div class='tooltip' style='position: absolute;top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;height:12px;'>"
+            output << '<span class="tip">'
+            output << view.render_issue_tooltip(issue)
+            output << "</span></div>"
+            output
+          
+          when :image
+            # Handle nil start_dates, rare but can happen.
+            i_start_date =  if issue.start_date && issue.start_date >= @date_from
+                              issue.start_date
+                            else
+                              @date_from
+                            end
+
+            i_end_date = (issue.due_before <= date_to ? issue.due_before : date_to )        
+            i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
+            i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
+            i_done_date = (i_done_date >= date_to ? date_to : i_done_date )        
+            i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
+            
+            i_left = options[:subject_width] + ((i_start_date - @date_from)*options[:zoom]).floor      
+            i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor                  # total width of the issue
+            d_width = ((i_done_date - i_start_date)*options[:zoom]).floor                     # done width
+            l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor : 0 # delay width
+
+            
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if i_width > 0
+              options[:image].fill('grey')
+              options[:image].rectangle(i_left, options[:top], i_left + i_width, options[:top] - 6)
+              options[:image].fill('red')
+              options[:image].rectangle(i_left, options[:top], i_left + l_width, options[:top] - 6) if l_width > 0
+              options[:image].fill('blue')
+              options[:image].rectangle(i_left, options[:top], i_left + d_width, options[:top] - 6) if d_width > 0
+            end
+
+            # Show the status and % done next to the subject if it overflows
+            options[:image].fill('black')
+            if i_width > 0
+              options[:image].text(i_left + i_width + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
+            else
+              options[:image].text(options[:subject_width] + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")            
+            end
+
+          when :pdf
+            options[:pdf].SetY(options[:top]+1.5)
+            # Handle nil start_dates, rare but can happen.
+            i_start_date =  if issue.start_date && issue.start_date >= @date_from
+                          issue.start_date
+                        else
+                          @date_from
+                        end
+
+            i_end_date = (issue.due_before <= @date_to ? issue.due_before : @date_to )
+            
+            i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
+            i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
+            i_done_date = (i_done_date >= @date_to ? @date_to : i_done_date )
+            
+            i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
+            
+            i_left = ((i_start_date - @date_from)*options[:zoom]) 
+            i_width = ((i_end_date - i_start_date + 1)*options[:zoom])
+            d_width = ((i_done_date - i_start_date)*options[:zoom])
+            l_width = ((i_late_date - i_start_date+1)*options[:zoom]) if i_late_date
+            l_width ||= 0
+
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if i_width > 0
+              options[:pdf].SetX(options[:subject_width] + i_left)
+              options[:pdf].SetFillColor(200,200,200)
+              options[:pdf].Cell(i_width, 2, "", 0, 0, "", 1)
+            end
+          
+            if l_width > 0
+              options[:pdf].SetY(options[:top]+1.5)
+              options[:pdf].SetX(options[:subject_width] + i_left)
+              options[:pdf].SetFillColor(255,100,100)
+              options[:pdf].Cell(l_width, 2, "", 0, 0, "", 1)
+            end 
+            if d_width > 0
+              options[:pdf].SetY(options[:top]+1.5)
+              options[:pdf].SetX(options[:subject_width] + i_left)
+              options[:pdf].SetFillColor(100,100,255)
+              options[:pdf].Cell(d_width, 2, "", 0, 0, "", 1)
+            end
+
+            options[:pdf].SetY(options[:top]+1.5)
+
+            # Make sure that negative i_left and i_width don't
+            # overflow the subject
+            if (i_left + i_width) >= 0
+              options[:pdf].SetX(options[:subject_width] + i_left + i_width)
+            else
+              options[:pdf].SetX(options[:subject_width])
+            end
+            options[:pdf].Cell(30, 2, "#{issue.status} #{issue.done_ratio}%")
+          end
+        else
+          ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
+          ''
+        end
+      end
+
       # Generates a gantt image
       # Only defined if RMagick is avalaible
-      def to_image(project, format='PNG')
+      def to_image(format='PNG')
         date_to = (@date_from >> @months)-1    
         show_weeks = @zoom > 1
         show_days = @zoom > 2
@@ -101,7 +735,7 @@ module Redmine
         # width of one day in pixels
         zoom = @zoom*2
         g_width = (@date_to - @date_from + 1)*zoom
-        g_height = 20 * events.length + 20
+        g_height = 20 * number_of_rows + 30
         headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
         height = g_height + headers_heigth
             
@@ -110,21 +744,7 @@ module Redmine
         gc = Magick::Draw.new
         
         # Subjects
-        top = headers_heigth + 20
-        gc.fill('black')
-        gc.stroke('transparent')
-        gc.stroke_width(1)
-        events.each do |i|
-          text = ""
-          if i.is_a? Issue
-            text = "#{i.tracker} #{i.id}: #{i.subject}"
-          else
-            text = i.name
-          end
-          text = "#{i.project} - #{text}" unless project && project == i.project
-          gc.text(4, top + 2, text)
-          top = top + 20
-        end
+        subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
     
         # Months headers
         month_f = @date_from
@@ -202,38 +822,8 @@ module Redmine
             
         # content
         top = headers_heigth + 20
-        gc.stroke('transparent')
-        events.each do |i|      
-          if i.is_a?(Issue)       
-            i_start_date = (i.start_date >= @date_from ? i.start_date : @date_from )
-            i_end_date = (i.due_before <= date_to ? i.due_before : date_to )        
-            i_done_date = i.start_date + ((i.due_before - i.start_date+1)*i.done_ratio/100).floor
-            i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
-            i_done_date = (i_done_date >= date_to ? date_to : i_done_date )        
-            i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
-            
-            i_left = subject_width + ((i_start_date - @date_from)*zoom).floor  
-            i_width = ((i_end_date - i_start_date + 1)*zoom).floor                  # total width of the issue
-            d_width = ((i_done_date - i_start_date)*zoom).floor                     # done width
-            l_width = i_late_date ? ((i_late_date - i_start_date+1)*zoom).floor : 0 # delay width
-      
-            gc.fill('grey')
-            gc.rectangle(i_left, top, i_left + i_width, top - 6)
-            gc.fill('red')
-            gc.rectangle(i_left, top, i_left + l_width, top - 6) if l_width > 0
-            gc.fill('blue')
-            gc.rectangle(i_left, top, i_left + d_width, top - 6) if d_width > 0
-            gc.fill('black')
-            gc.text(i_left + i_width + 5,top + 1, "#{i.status.name} #{i.done_ratio}%")
-          else
-            i_left = subject_width + ((i.start_date - @date_from)*zoom).floor
-            gc.fill('green')
-            gc.rectangle(i_left, top, i_left + 6, top - 6)        
-            gc.fill('black')
-            gc.text(i_left + 11, top + 1, i.name)
-          end
-          top = top + 20
-        end
+        
+        lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
         
         # today red line
         if Date.today >= @date_from and Date.today <= date_to
@@ -246,36 +836,137 @@ module Redmine
         imgl.format = format
         imgl.to_blob
       end if Object.const_defined?(:Magick)
-      
-      private
-      
-      def gantt_issue_compare(x, y, issues)
-        if x.parent_id == y.parent_id
-          gantt_start_compare(x, y)
-        elsif x.is_ancestor_of?(y)
-          -1
-        elsif y.is_ancestor_of?(x)
-          1
-        else
-          ax = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(x) && !i.is_ancestor_of?(y) }.sort_by(&:lft).first
-          ay = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(y) && !i.is_ancestor_of?(x) }.sort_by(&:lft).first
-          if ax.nil? && ay.nil?
-            gantt_start_compare(x, y)
+
+      def to_pdf
+        pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
+        pdf.SetTitle("#{l(:label_gantt)} #{project}")
+        pdf.AliasNbPages
+        pdf.footer_date = format_date(Date.today)
+        pdf.AddPage("L")
+        pdf.SetFontStyle('B',12)
+        pdf.SetX(15)
+        pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
+        pdf.Ln
+        pdf.SetFontStyle('B',9)
+        
+        subject_width = PDF::LeftPaneWidth
+        header_heigth = 5
+        
+        headers_heigth = header_heigth
+        show_weeks = false
+        show_days = false
+        
+        if self.months < 7
+          show_weeks = true
+          headers_heigth = 2*header_heigth
+          if self.months < 3
+            show_days = true
+            headers_heigth = 3*header_heigth
+          end
+        end
+        
+        g_width = PDF.right_pane_width
+        zoom = (g_width) / (self.date_to - self.date_from + 1)
+        g_height = 120
+        t_height = g_height + headers_heigth
+        
+        y_start = pdf.GetY
+        
+        # Months headers
+        month_f = self.date_from
+        left = subject_width
+        height = header_heigth
+        self.months.times do 
+          width = ((month_f >> 1) - month_f) * zoom 
+          pdf.SetY(y_start)
+          pdf.SetX(left)
+          pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
+          left = left + width
+          month_f = month_f >> 1
+        end  
+        
+        # Weeks headers
+        if show_weeks
+          left = subject_width
+          height = header_heigth
+          if self.date_from.cwday == 1
+            # self.date_from is monday
+            week_f = self.date_from
           else
-            gantt_issue_compare(ax || x, ay || y, issues)
+            # find next monday after self.date_from
+            week_f = self.date_from + (7 - self.date_from.cwday + 1)
+            width = (7 - self.date_from.cwday + 1) * zoom-1
+            pdf.SetY(y_start + header_heigth)
+            pdf.SetX(left)
+            pdf.Cell(width + 1, height, "", "LTR")
+            left = left + width+1
+          end
+          while week_f <= self.date_to
+            width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
+            pdf.SetY(y_start + header_heigth)
+            pdf.SetX(left)
+            pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
+            left = left + width
+            week_f = week_f+7
+          end
+        end
+        
+        # Days headers
+        if show_days
+          left = subject_width
+          height = header_heigth
+          wday = self.date_from.cwday
+          pdf.SetFontStyle('B',7)
+          (self.date_to - self.date_from + 1).to_i.times do 
+            width = zoom
+            pdf.SetY(y_start + 2 * header_heigth)
+            pdf.SetX(left)
+            pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
+            left = left + width
+            wday = wday + 1
+            wday = 1 if wday > 7
           end
         end
+        
+        pdf.SetY(y_start)
+        pdf.SetX(15)
+        pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
+        
+        # Tasks
+        top = headers_heigth + y_start
+        pdf_subjects_and_lines(pdf, {
+                                 :top => top,
+                                 :zoom => zoom,
+                                 :subject_width => subject_width,
+                                 :g_width => g_width
+                               })
+
+        
+        pdf.Line(15, top, subject_width+g_width, top)
+        pdf.Output
+
+        
       end
       
-      def gantt_start_compare(x, y)
-        if x.start_date.nil?
-          -1
-        elsif y.start_date.nil?
-          1
+      private
+
+      # Renders both the subjects and lines of the Gantt chart for the
+      # PDF format
+      def pdf_subjects_and_lines(pdf, options = {})
+        subject_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :subject, :format => :pdf, :pdf => pdf}.merge(options)
+        line_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :line, :format => :pdf, :pdf => pdf}.merge(options)
+
+        if @project
+          render_project(@project, subject_options)
+          render_project(@project, line_options)
         else
-          x.start_date <=> y.start_date
+          Project.roots.each do |project|
+            render_project(project, subject_options)
+            render_project(project, line_options)
+          end
         end
       end
+
     end
   end
 end
diff --git a/public/images/milestone.png b/public/images/milestone.png
deleted file mode 100644 (file)
index a89791c..0000000
Binary files a/public/images/milestone.png and /dev/null differ
diff --git a/public/images/milestone_done.png b/public/images/milestone_done.png
new file mode 100644 (file)
index 0000000..5fdcb41
Binary files /dev/null and b/public/images/milestone_done.png differ
diff --git a/public/images/milestone_late.png b/public/images/milestone_late.png
new file mode 100644 (file)
index 0000000..cf922e9
Binary files /dev/null and b/public/images/milestone_late.png differ
diff --git a/public/images/milestone_todo.png b/public/images/milestone_todo.png
new file mode 100644 (file)
index 0000000..4c051c8
Binary files /dev/null and b/public/images/milestone_todo.png differ
diff --git a/public/images/project_marker.png b/public/images/project_marker.png
new file mode 100644 (file)
index 0000000..4124787
Binary files /dev/null and b/public/images/project_marker.png differ
index 954ebedcec558388158c8570a0db7fd02a9e0dcd..5fdcb415c0b002cb8853aea98619fb644e473097 100644 (file)
Binary files a/public/images/task_done.png and b/public/images/task_done.png differ
diff --git a/public/images/version_marker.png b/public/images/version_marker.png
new file mode 100644 (file)
index 0000000..0368ca2
Binary files /dev/null and b/public/images/version_marker.png differ
index 520ed837198228b340237d4aa0c110f26ea7a610..233d94e4545319a9d81924cca6054d31d848785f 100644 (file)
@@ -787,8 +787,10 @@ background-image:url('../images/close_hl.png');
   white-space:nowrap;
 }
 
+.task.label {width:100%;}
+
 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
-.task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }  
+.task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }  
 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
 
 .task_todo.parent { background: #888; border: 1px solid #888; height: 6px;}
@@ -796,7 +798,17 @@ background-image:url('../images/close_hl.png');
 .task_todo.parent .left  { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -5px; left: 0px; top: -1px;}
 .task_todo.parent .right { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-right: -5px; right: 0px; top: -1px;}
 
-.milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
+.milestone { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; }
+.milestone_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
+.milestone_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
+.milestone_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
+.project-line { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; }
+.project_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
+.project_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
+.project_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
+
+.version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
+.version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
 
 /***** Icons *****/
 .icon {
@@ -840,6 +852,7 @@ padding-bottom: 3px;
 .icon-comment  { background-image: url(../images/comment.png); }
 .icon-summary  { background-image: url(../images/lightning.png); }
 .icon-server-authentication { background-image: url(../images/server_key.png); }
+.icon-issue { background-image: url(../images/ticket.png); }
 
 .icon-file { background-image: url(../images/files/default.png); }
 .icon-file.text-plain { background-image: url(../images/files/text.png); }
@@ -898,6 +911,12 @@ td.username img.gravatar {
        margin: 0 1em 1em 0;
 }
 
+/* Used on 12px Gravatar img tags without the icon background */
+.icon-gravatar {
+  float: left;
+  margin-right: 4px;
+}
+
 #activity dt,
 .journal {
        clear: left;
index 24c84f2aeb7dea98f5b3e09c0fb7e344a644c08a..9aba1d5aa6c0383d593ce7faa2c793ff6e0cd127 100644 (file)
@@ -5,20 +5,20 @@ class GanttsControllerTest < ActionController::TestCase
 
   context "#gantt" do
     should "work" do
+      i2 = Issue.find(2)
+      i2.update_attribute(:due_date, 1.month.from_now)
+      
       get :show, :project_id => 1
       assert_response :success
       assert_template 'show.html.erb'
       assert_not_nil assigns(:gantt)
-      events = assigns(:gantt).events
-      assert_not_nil events
       # Issue with start and due dates
       i = Issue.find(1)
       assert_not_nil i.due_date
-      assert events.include?(Issue.find(1))
-      # Issue with without due date but targeted to a version with date
+      assert_select "div a.issue", /##{i.id}/
+      # Issue with on a targeted version should not be in the events but loaded in the html
       i = Issue.find(2)
-      assert_nil i.due_date
-      assert events.include?(i)
+      assert_select "div a.issue", /##{i.id}/
     end
 
     should "work cross project" do
@@ -26,8 +26,8 @@ class GanttsControllerTest < ActionController::TestCase
       assert_response :success
       assert_template 'show.html.erb'
       assert_not_nil assigns(:gantt)
-      events = assigns(:gantt).events
-      assert_not_nil events
+      assert_not_nil assigns(:gantt).query
+      assert_nil assigns(:gantt).project
     end
 
     should "export to pdf" do
index 4a2b85a9e492ace17083d0f9925898532a49a879..7b144b18f7eacfa3f533f04cf43af4b7f6dd8602 100644 (file)
@@ -25,8 +25,9 @@ module ObjectDaddyHelpers
   def Issue.generate_for_project!(project, attributes={})
     issue = Issue.spawn(attributes) do |issue|
       issue.project = project
+      issue.tracker = project.trackers.first unless project.trackers.empty?
+      yield issue if block_given?
     end
-    issue.tracker = project.trackers.first unless project.trackers.empty?
     issue.save!
     issue
   end
index 1936a981b8625e1f19f2abe62a25d89e7b27dde5..2906ec6e42d0941d6b1a3b5b39d3e1f46a848116 100644 (file)
@@ -601,7 +601,7 @@ EXPECTED
     
     # turn off avatars
     Setting.gravatar_enabled = '0'
-    assert_nil avatar(User.find_by_mail('jsmith@somenet.foo'))
+    assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
   end
   
   def test_link_to_user
index e0eb479d96f66df74a9b5809e374cf69f60d1192..dd10e01204723828a6cb4e31b722888bf2e7f5fd 100644 (file)
@@ -510,6 +510,28 @@ class IssueTest < ActiveSupport::TestCase
     assert !Issue.new(:due_date => nil).overdue?
     assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
   end
+
+  context "#behind_schedule?" do
+    should "be false if the issue has no start_date" do
+      assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
+    end
+
+    should "be false if the issue has no end_date" do
+      assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
+    end
+
+    should "be false if the issue has more done than it's calendar time" do
+      assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
+    end
+
+    should "be true if the issue hasn't been started at all" do
+      assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
+    end
+
+    should "be true if the issue has used more calendar time than it's done ratio" do
+      assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
+    end
+  end
   
   def test_assignable_users
     assert_kind_of User, Issue.find(1).assignable_users.first
diff --git a/test/unit/lib/redmine/helpers/gantt_test.rb b/test/unit/lib/redmine/helpers/gantt_test.rb
new file mode 100644 (file)
index 0000000..6b97b08
--- /dev/null
@@ -0,0 +1,703 @@
+# redMine - project management software
+# Copyright (C) 2006-2008  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require File.dirname(__FILE__) + '/../../../../test_helper'
+
+class Redmine::Helpers::GanttTest < ActiveSupport::TestCase
+  # Utility methods and classes so assert_select can be used.
+  class GanttViewTest < ActionView::Base
+    include ActionView::Helpers::UrlHelper
+    include ActionView::Helpers::TextHelper
+    include ActionController::UrlWriter
+    include ApplicationHelper
+    include ProjectsHelper
+    include IssuesHelper
+    
+    def self.default_url_options
+      {:only_path => true }
+    end
+
+  end
+
+  include ActionController::Assertions::SelectorAssertions
+
+  def setup
+    @response = ActionController::TestResponse.new
+    # Fixtures
+    ProjectCustomField.delete_all
+    Project.destroy_all
+
+    User.current = User.find(1)
+  end
+
+  def build_view
+    @view = GanttViewTest.new
+  end
+
+  def html_document
+    HTML::Document.new(@response.body)
+  end
+
+  # Creates a Gantt chart for a 4 week span
+  def create_gantt(project=Project.generate!)
+    @project = project
+    @gantt = Redmine::Helpers::Gantt.new
+    @gantt.project = @project
+    @gantt.query = Query.generate_default!(:project => @project)
+    @gantt.view = build_view
+    @gantt.instance_variable_set('@date_from', 2.weeks.ago.to_date)
+    @gantt.instance_variable_set('@date_to', 2.weeks.from_now.to_date)
+  end
+
+  context "#number_of_rows" do
+
+    context "with one project" do
+      should "return the number of rows just for that project"
+    end
+
+    context "with no project" do
+      should "return the total number of rows for all the projects, resursively"
+    end
+
+  end
+
+  context "#number_of_rows_on_project" do
+    setup do
+      create_gantt
+    end
+    
+    should "clear the @query.project so cross-project issues and versions can be counted" do
+      assert @gantt.query.project
+      @gantt.number_of_rows_on_project(@project)
+      assert_nil @gantt.query.project
+    end
+
+    should "count 1 for the project itself" do
+      assert_equal 1, @gantt.number_of_rows_on_project(@project)
+    end
+
+    should "count the number of issues without a version" do
+      @project.issues << Issue.generate_for_project!(@project, :fixed_version => nil)
+      assert_equal 2, @gantt.number_of_rows_on_project(@project)
+    end
+
+    should "count the number of versions" do
+      @project.versions << Version.generate!
+      @project.versions << Version.generate!
+      assert_equal 3, @gantt.number_of_rows_on_project(@project)
+    end
+
+    should "count the number of issues on versions, including cross-project" do
+      version = Version.generate!
+      @project.versions << version
+      @project.issues << Issue.generate_for_project!(@project, :fixed_version => version)
+      
+      assert_equal 3, @gantt.number_of_rows_on_project(@project)
+    end
+    
+    should "recursive and count the number of rows on each subproject" do
+      @project.versions << Version.generate! # +1
+
+      @subproject = Project.generate!(:enabled_module_names => ['issue_tracking']) # +1
+      @subproject.set_parent!(@project)
+      @subproject.issues << Issue.generate_for_project!(@subproject) # +1
+      @subproject.issues << Issue.generate_for_project!(@subproject) # +1
+
+      @subsubproject = Project.generate!(:enabled_module_names => ['issue_tracking']) # +1
+      @subsubproject.set_parent!(@subproject)
+      @subsubproject.issues << Issue.generate_for_project!(@subsubproject) # +1
+
+      assert_equal 7, @gantt.number_of_rows_on_project(@project) # +1 for self
+    end
+  end
+
+  # TODO: more of an integration test
+  context "#subjects" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => 1.week.from_now.to_date, :sharing => 'none')
+      @project.versions << @version
+
+      @issue = Issue.generate!(:fixed_version => @version,
+                               :subject => "gantt#line_for_project",
+                               :tracker => @tracker,
+                               :project => @project,
+                               :done_ratio => 30,
+                               :start_date => Date.yesterday,
+                               :due_date => 1.week.from_now.to_date)
+      @project.issues << @issue
+
+      @response.body = @gantt.subjects
+    end
+
+    context "project" do
+      should "be rendered" do
+        assert_select "div.project-name a", /#{@project.name}/
+      end
+
+      should "have an indent of 4" do
+        assert_select "div.project-name[style*=left:4px]"
+      end
+    end
+
+    context "version" do
+      should "be rendered" do
+        assert_select "div.version-name a", /#{@version.name}/
+      end
+
+      should "be indented 24 (one level)" do
+        assert_select "div.version-name[style*=left:24px]"
+      end
+    end
+
+    context "issue" do
+      should "be rendered" do
+        assert_select "div.issue-subject", /#{@issue.subject}/
+      end
+
+      should "be indented 44 (two levels)" do
+        assert_select "div.issue-subject[style*=left:44px]"
+      end
+    end
+  end
+
+  context "#lines" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => 1.week.from_now.to_date)
+      @project.versions << @version
+      @issue = Issue.generate!(:fixed_version => @version,
+                               :subject => "gantt#line_for_project",
+                               :tracker => @tracker,
+                               :project => @project,
+                               :done_ratio => 30,
+                               :start_date => Date.yesterday,
+                               :due_date => 1.week.from_now.to_date)
+      @project.issues << @issue
+
+      @response.body = @gantt.lines
+    end
+
+    context "project" do
+      should "be rendered" do
+        assert_select "div.project_todo"
+        assert_select "div.project-line.starting"
+        assert_select "div.project-line.ending"
+        assert_select "div.label.project-name", /#{@project.name}/
+      end
+    end
+
+    context "version" do
+      should "be rendered" do
+        assert_select "div.milestone_todo"
+        assert_select "div.milestone.starting"
+        assert_select "div.milestone.ending"
+        assert_select "div.label.version-name", /#{@version.name}/
+      end
+    end
+
+    context "issue" do
+      should "be rendered" do
+        assert_select "div.task_todo"
+        assert_select "div.label.issue-name", /#{@issue.done_ratio}/
+        assert_select "div.tooltip", /#{@issue.subject}/
+      end
+    end
+  end
+
+  context "#render_project" do
+    should "be tested"
+  end
+
+  context "#render_issues" do
+    should "be tested"
+  end
+
+  context "#render_version" do
+    should "be tested"
+  end
+
+  context "#subject_for_project" do
+    setup do
+      create_gantt
+    end
+    
+    context ":html format" do
+      should "add an absolute positioned div" do
+        @response.body = @gantt.subject_for_project(@project, {:format => :html})
+        assert_select "div[style*=absolute]"
+      end
+
+      should "use the indent option to move the div to the right" do
+        @response.body = @gantt.subject_for_project(@project, {:format => :html, :indent => 40})
+        assert_select "div[style*=left:40]"
+      end
+
+      should "include the project name" do
+        @response.body = @gantt.subject_for_project(@project, {:format => :html})
+        assert_select 'div', :text => /#{@project.name}/
+      end
+
+      should "include a link to the project" do
+        @response.body = @gantt.subject_for_project(@project, {:format => :html})
+        assert_select 'a[href=?]', "/projects/#{@project.identifier}", :text => /#{@project.name}/
+      end
+
+      should "style overdue projects" do
+        @project.enabled_module_names = [:issue_tracking]
+        @project.versions << Version.generate!(:effective_date => Date.yesterday)
+
+        assert @project.overdue?, "Need an overdue project for this test"
+        @response.body = @gantt.subject_for_project(@project, {:format => :html})
+
+        assert_select 'div span.project-overdue'
+      end
+
+
+    end
+
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#line_for_project" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => Date.yesterday)
+      @project.versions << @version
+
+      @project.issues << Issue.generate!(:fixed_version => @version,
+                                         :subject => "gantt#line_for_project",
+                                         :tracker => @tracker,
+                                         :project => @project,
+                                         :done_ratio => 30,
+                                         :start_date => Date.yesterday,
+                                         :due_date => 1.week.from_now.to_date)
+    end
+
+    context ":html format" do
+      context "todo line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_todo[style*=left:52px]"
+        end
+
+        should "be the total width of the project" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_todo[style*=width:31px]"
+        end
+
+      end
+
+      context "late line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_late[style*=left:52px]"
+        end
+
+        should "be the total delayed width of the project" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_late[style*=width:6px]"
+        end
+      end
+
+      context "done line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_done[style*=left:52px]"
+        end
+
+        should "Be the total done width of the project"  do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project_done[style*=left:52px]"
+        end
+      end
+
+      context "starting marker" do
+        should "not appear if the starting point is off the gantt chart" do
+          # Shift the date range of the chart
+          @gantt.instance_variable_set('@date_from', Date.today)
+
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-line.starting", false
+        end
+
+        should "appear at the starting point" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-line.starting[style*=left:52px]"
+        end
+      end
+
+      context "ending marker" do
+        should "not appear if the starting point is off the gantt chart" do
+          # Shift the date range of the chart
+          @gantt.instance_variable_set('@date_to', 2.weeks.ago.to_date)
+
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-line.ending", false
+
+        end
+
+        should "appear at the end of the date range" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-line.ending[style*=left:84px]"
+        end
+      end
+      
+      context "status content" do
+        should "appear at the far left, even if it's far in the past" do
+          @gantt.instance_variable_set('@date_to', 2.weeks.ago.to_date)
+
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-name", /#{@project.name}/
+        end
+
+        should "show the project name" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-name", /#{@project.name}/
+        end
+
+        should "show the percent complete" do
+          @response.body = @gantt.line_for_project(@project, {:format => :html, :zoom => 4})
+          assert_select "div.project-name", /0%/
+        end
+      end
+    end
+
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#subject_for_version" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => Date.yesterday)
+      @project.versions << @version
+
+      @project.issues << Issue.generate!(:fixed_version => @version,
+                                         :subject => "gantt#subject_for_version",
+                                         :tracker => @tracker,
+                                         :project => @project,
+                                         :start_date => Date.today)
+
+    end
+
+    context ":html format" do
+      should "add an absolute positioned div" do
+        @response.body = @gantt.subject_for_version(@version, {:format => :html})
+        assert_select "div[style*=absolute]"
+      end
+
+      should "use the indent option to move the div to the right" do
+        @response.body = @gantt.subject_for_version(@version, {:format => :html, :indent => 40})
+        assert_select "div[style*=left:40]"
+      end
+
+      should "include the version name" do
+        @response.body = @gantt.subject_for_version(@version, {:format => :html})
+        assert_select 'div', :text => /#{@version.name}/
+      end
+
+      should "include a link to the version" do
+        @response.body = @gantt.subject_for_version(@version, {:format => :html})
+        assert_select 'a[href=?]', Regexp.escape("/versions/show/#{@version.to_param}"), :text => /#{@version.name}/
+      end
+
+      should "style late versions" do
+        assert @version.overdue?, "Need an overdue version for this test"
+        @response.body = @gantt.subject_for_version(@version, {:format => :html})
+
+        assert_select 'div span.version-behind-schedule'
+      end
+
+      should "style behind schedule versions" do
+        assert @version.behind_schedule?, "Need a behind schedule version for this test"
+        @response.body = @gantt.subject_for_version(@version, {:format => :html})
+
+        assert_select 'div span.version-behind-schedule'
+      end
+    end
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#line_for_version" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => 1.week.from_now.to_date)
+      @project.versions << @version
+
+      @project.issues << Issue.generate!(:fixed_version => @version,
+                                         :subject => "gantt#line_for_project",
+                                         :tracker => @tracker,
+                                         :project => @project,
+                                         :done_ratio => 30,
+                                         :start_date => Date.yesterday,
+                                         :due_date => 1.week.from_now.to_date)
+    end
+
+    context ":html format" do
+      context "todo line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_todo[style*=left:52px]"
+        end
+
+        should "be the total width of the version" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_todo[style*=width:31px]"
+        end
+
+      end
+
+      context "late line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_late[style*=left:52px]"
+        end
+
+        should "be the total delayed width of the version" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_late[style*=width:6px]"
+        end
+      end
+
+      context "done line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_done[style*=left:52px]"
+        end
+
+        should "Be the total done width of the version"  do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone_done[style*=left:52px]"
+        end
+      end
+
+      context "starting marker" do
+        should "not appear if the starting point is off the gantt chart" do
+          # Shift the date range of the chart
+          @gantt.instance_variable_set('@date_from', Date.today)
+
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone.starting", false
+        end
+
+        should "appear at the starting point" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone.starting[style*=left:52px]"
+        end
+      end
+
+      context "ending marker" do
+        should "not appear if the starting point is off the gantt chart" do
+          # Shift the date range of the chart
+          @gantt.instance_variable_set('@date_to', 2.weeks.ago.to_date)
+
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone.ending", false
+
+        end
+
+        should "appear at the end of the date range" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.milestone.ending[style*=left:84px]"
+        end
+      end
+      
+      context "status content" do
+        should "appear at the far left, even if it's far in the past" do
+          @gantt.instance_variable_set('@date_to', 2.weeks.ago.to_date)
+
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.version-name", /#{@version.name}/
+        end
+
+        should "show the version name" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.version-name", /#{@version.name}/
+        end
+
+        should "show the percent complete" do
+          @response.body = @gantt.line_for_version(@version, {:format => :html, :zoom => 4})
+          assert_select "div.version-name", /30%/
+        end
+      end
+    end
+
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#subject_for_issue" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+
+      @issue = Issue.generate!(:subject => "gantt#subject_for_issue",
+                               :tracker => @tracker,
+                               :project => @project,
+                               :start_date => 3.days.ago.to_date,
+                               :due_date => Date.yesterday)
+      @project.issues << @issue
+
+    end
+
+    context ":html format" do
+      should "add an absolute positioned div" do
+        @response.body = @gantt.subject_for_issue(@issue, {:format => :html})
+        assert_select "div[style*=absolute]"
+      end
+
+      should "use the indent option to move the div to the right" do
+        @response.body = @gantt.subject_for_issue(@issue, {:format => :html, :indent => 40})
+        assert_select "div[style*=left:40]"
+      end
+
+      should "include the issue subject" do
+        @response.body = @gantt.subject_for_issue(@issue, {:format => :html})
+        assert_select 'div', :text => /#{@issue.subject}/
+      end
+
+      should "include a link to the issue" do
+        @response.body = @gantt.subject_for_issue(@issue, {:format => :html})
+        assert_select 'a[href=?]', Regexp.escape("/issues/#{@issue.to_param}"), :text => /#{@tracker.name} ##{@issue.id}/
+      end
+
+      should "style overdue issues" do
+        assert @issue.overdue?, "Need an overdue issue for this test"
+        @response.body = @gantt.subject_for_issue(@issue, {:format => :html})
+
+        assert_select 'div span.issue-overdue'
+      end
+
+    end
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#line_for_issue" do
+    setup do
+      create_gantt
+      @project.enabled_module_names = [:issue_tracking]
+      @tracker = Tracker.generate!
+      @project.trackers << @tracker
+      @version = Version.generate!(:effective_date => 1.week.from_now.to_date)
+      @project.versions << @version
+      @issue = Issue.generate!(:fixed_version => @version,
+                               :subject => "gantt#line_for_project",
+                               :tracker => @tracker,
+                               :project => @project,
+                               :done_ratio => 30,
+                               :start_date => Date.yesterday,
+                               :due_date => 1.week.from_now.to_date)
+      @project.issues << @issue
+    end
+
+    context ":html format" do
+      context "todo line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_todo[style*=left:52px]"
+        end
+
+        should "be the total width of the issue" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_todo[style*=width:34px]"
+        end
+
+      end
+
+      context "late line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_late[style*=left:52px]"
+        end
+
+        should "be the total delayed width of the issue" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_late[style*=width:6px]"
+        end
+      end
+
+      context "done line" do
+        should "start from the starting point on the left" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_done[style*=left:52px]"
+        end
+
+        should "Be the total done width of the issue"  do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.task_done[style*=left:52px]"
+        end
+      end
+
+      context "status content" do
+        should "appear at the far left, even if it's far in the past" do
+          @gantt.instance_variable_set('@date_to', 2.weeks.ago.to_date)
+
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.issue-name"
+        end
+
+        should "show the issue status" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.issue-name", /#{@issue.status.name}/
+        end
+
+        should "show the percent complete" do
+          @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+          assert_select "div.issue-name", /30%/
+        end
+      end
+    end
+
+    should "have an issue tooltip" do
+      @response.body = @gantt.line_for_issue(@issue, {:format => :html, :zoom => 4})
+      assert_select "div.tooltip", /#{@issue.subject}/
+    end
+
+    should "test the PNG format"
+    should "test the PDF format"
+  end
+
+  context "#to_image" do
+    should "be tested"
+  end
+
+  context "#to_pdf" do
+    should "be tested"
+  end
+  
+end
index 7870dc2a57f167c9221f40b095848bbf12750340..b0b5a48377b2aae46d977eda786f520febdd2b0a 100644 (file)
@@ -842,4 +842,122 @@ class ProjectTest < ActiveSupport::TestCase
     
   end
 
+  context "#start_date" do
+    setup do
+      ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
+      @project = Project.generate!(:identifier => 'test0')
+      @project.trackers << Tracker.generate!
+    end
+    
+    should "be nil if there are no issues on the project" do
+      assert_nil @project.start_date
+    end
+
+    should "be nil if issue tracking is disabled" do
+      Issue.generate_for_project!(@project, :start_date => Date.today)
+      @project.enabled_modules.find_all_by_name('issue_tracking').each {|m| m.destroy}
+      @project.reload
+      
+      assert_nil @project.start_date
+    end
+
+    should "be the earliest start date of it's issues" do
+      early = 7.days.ago.to_date
+      Issue.generate_for_project!(@project, :start_date => Date.today)
+      Issue.generate_for_project!(@project, :start_date => early)
+
+      assert_equal early, @project.start_date
+    end
+
+  end
+
+  context "#due_date" do
+    setup do
+      ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
+      @project = Project.generate!(:identifier => 'test0')
+      @project.trackers << Tracker.generate!
+    end
+    
+    should "be nil if there are no issues on the project" do
+      assert_nil @project.due_date
+    end
+
+    should "be nil if issue tracking is disabled" do
+      Issue.generate_for_project!(@project, :due_date => Date.today)
+      @project.enabled_modules.find_all_by_name('issue_tracking').each {|m| m.destroy}
+      @project.reload
+      
+      assert_nil @project.due_date
+    end
+
+    should "be the latest due date of it's issues" do
+      future = 7.days.from_now.to_date
+      Issue.generate_for_project!(@project, :due_date => future)
+      Issue.generate_for_project!(@project, :due_date => Date.today)
+
+      assert_equal future, @project.due_date
+    end
+
+    should "be the latest due date of it's versions" do
+      future = 7.days.from_now.to_date
+      @project.versions << Version.generate!(:effective_date => future)
+      @project.versions << Version.generate!(:effective_date => Date.today)
+      
+
+      assert_equal future, @project.due_date
+
+    end
+
+    should "pick the latest date from it's issues and versions" do
+      future = 7.days.from_now.to_date
+      far_future = 14.days.from_now.to_date
+      Issue.generate_for_project!(@project, :due_date => far_future)
+      @project.versions << Version.generate!(:effective_date => future)
+      
+      assert_equal far_future, @project.due_date
+    end
+
+  end
+
+  context "Project#completed_percent" do
+    setup do
+      ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
+      @project = Project.generate!(:identifier => 'test0')
+      @project.trackers << Tracker.generate!
+    end
+
+    context "no versions" do
+      should "be 100" do
+        assert_equal 100, @project.completed_percent
+      end
+    end
+
+    context "with versions" do
+      should "return 0 if the versions have no issues" do
+        Version.generate!(:project => @project)
+        Version.generate!(:project => @project)
+
+        assert_equal 0, @project.completed_percent
+      end
+
+      should "return 100 if the version has only closed issues" do
+        v1 = Version.generate!(:project => @project)
+        Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
+        v2 = Version.generate!(:project => @project)
+        Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
+
+        assert_equal 100, @project.completed_percent
+      end
+
+      should "return the averaged completed percent of the versions (not weighted)" do
+        v1 = Version.generate!(:project => @project)
+        Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
+        v2 = Version.generate!(:project => @project)
+        Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
+
+        assert_equal 50, @project.completed_percent
+      end
+
+    end
+  end
 end
index 1abb4a2729680a124c55595a2c3a9e6df87eca8c..b30eedaea54f11c9423cb34a38590ffe91e23443 100644 (file)
@@ -104,7 +104,57 @@ class VersionTest < ActiveSupport::TestCase
     assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_pourcent
     assert_progress_equal 25.0/100.0*100, v.closed_pourcent
   end
-  
+
+  context "#behind_schedule?" do
+    setup do
+      ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
+      @project = Project.generate!(:identifier => 'test0')
+      @project.trackers << Tracker.generate!
+
+      @version = Version.generate!(:project => @project, :effective_date => nil)
+    end
+    
+    should "be false if there are no issues assigned" do
+      @version.update_attribute(:effective_date, Date.yesterday)
+      assert_equal false, @version.behind_schedule?
+    end
+
+    should "be false if there is no effective_date" do
+      assert_equal false, @version.behind_schedule?
+    end
+
+    should "be false if all of the issues are ahead of schedule" do
+      @version.update_attribute(:effective_date, 7.days.from_now.to_date)
+      @version.fixed_issues = [
+                               Issue.generate_for_project!(@project, :start_date => 7.days.ago, :done_ratio => 60), # 14 day span, 60% done, 50% time left
+                               Issue.generate_for_project!(@project, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
+                              ]
+      assert_equal 60, @version.completed_pourcent
+      assert_equal false, @version.behind_schedule?
+    end
+
+    should "be true if any of the issues are behind schedule" do
+      @version.update_attribute(:effective_date, 7.days.from_now.to_date)
+      @version.fixed_issues = [
+                               Issue.generate_for_project!(@project, :start_date => 7.days.ago, :done_ratio => 60), # 14 day span, 60% done, 50% time left
+                               Issue.generate_for_project!(@project, :start_date => 7.days.ago, :done_ratio => 20) # 14 day span, 20% done, 50% time left
+                              ]
+      assert_equal 40, @version.completed_pourcent
+      assert_equal true, @version.behind_schedule?
+    end
+
+    should "be false if all of the issues are complete" do
+      @version.update_attribute(:effective_date, 7.days.from_now.to_date)
+      @version.fixed_issues = [
+                               Issue.generate_for_project!(@project, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)), # 7 day span
+                               Issue.generate_for_project!(@project, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
+                              ]
+      assert_equal 100, @version.completed_pourcent
+      assert_equal false, @version.behind_schedule?
+
+    end
+  end
+
   context "#estimated_hours" do
     setup do
       @version = Version.create!(:project_id => 1, :name => '#estimated_hours')
index 9e485491615eead94ec88f3bcc37a9831743f079..e67e5e7f9499a7e08b88656404244b3372d4660c 100644 (file)
@@ -6,7 +6,7 @@ task :default => :spec
 
 desc 'Run all application-specific specs'
 Spec::Rake::SpecTask.new(:spec) do |t|
-  t.rcov = true
+  t.rcov = true
 end
 
 desc "Report code statistics (KLOCs, etc) from the application"
index 6246645bc5cfbff877510142736aeb40c110e81c..9af1fed16afd5bdd98d0c492ed2b66327f16f375 100644 (file)
@@ -26,6 +26,9 @@ module GravatarHelper
     # decorational picture, the alt text should be empty according to the
     # XHTML specs.
     :alt => '',
+
+    # The title text to use for the img tag for the gravatar.
+    :title => '',
     
     # The class to assign to the img tag for the gravatar.
     :class => 'gravatar',
@@ -48,8 +51,8 @@ module GravatarHelper
     def gravatar(email, options={})
       src = h(gravatar_url(email, options))
       options = DEFAULT_OPTIONS.merge(options)
-      [:class, :alt, :size].each { |opt| options[opt] = h(options[opt]) }
-      "<img class=\"#{options[:class]}\" alt=\"#{options[:alt]}\" width=\"#{options[:size]}\" height=\"#{options[:size]}\" src=\"#{src}\" />"      
+      [:class, :alt, :size, :title].each { |opt| options[opt] = h(options[opt]) }
+      "<img class=\"#{options[:class]}\" alt=\"#{options[:alt]}\" title=\"#{options[:title]}\" width=\"#{options[:size]}\" height=\"#{options[:size]}\" src=\"#{src}\" />"
     end
     
     # Returns the base Gravatar URL for the given email hash. If ssl evaluates to true,
@@ -82,4 +85,4 @@ module GravatarHelper
 
   end
   
-end
\ No newline at end of file
+end
index a11d2683a4971daaffeb71a8056e27f390e221fc..6f78d79adaa9e6d67a5cde3e6a66d0d30d02a93c 100644 (file)
@@ -4,34 +4,40 @@ require 'active_support'  # to get "returning"
 require File.dirname(__FILE__) + '/../lib/gravatar'
 include GravatarHelper, GravatarHelper::PublicMethods, ERB::Util
 
-context "gravatar_url with a custom default URL" do
-  setup do
+describe "gravatar_url with a custom default URL" do
+  before(:each) do
     @original_options = DEFAULT_OPTIONS.dup
     DEFAULT_OPTIONS[:default] = "no_avatar.png"
     @url = gravatar_url("somewhere")
   end
   
-  specify "should include the \"default\" argument in the result" do
+  it "should include the \"default\" argument in the result" do
     @url.should match(/&default=no_avatar.png/)
   end
   
-  teardown do
+  after(:each) do
     DEFAULT_OPTIONS.merge!(@original_options)
   end
   
 end
 
-context "gravatar_url with default settings" do
-  setup do
+describe "gravatar_url with default settings" do
+  before(:each) do
     @url = gravatar_url("somewhere")
   end
   
-  specify "should have a nil default URL" do
+  it "should have a nil default URL" do
     DEFAULT_OPTIONS[:default].should be_nil
   end
   
-  specify "should not include the \"default\" argument in the result" do
+  it "should not include the \"default\" argument in the result" do
     @url.should_not match(/&default=/)
   end  
   
-end
\ No newline at end of file
+end
+
+describe "gravatar with a custom title option" do
+  it "should include the title in the result" do
+    gravatar('example@example.com', :title => "This is a title attribute").should match(/This is a title attribute/)
+  end
+end