Merge pull request #10851 from opf/implementation/41821-non-working-days-and-duration-in-scheduling

[#41821] Non working days & duration in scheduling
pull/10882/head
Christophe Bliard 2 years ago committed by GitHub
commit 2aed0da669
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .rubocop.yml
  2. 3
      app/services/work_packages/schedule_dependency/dependency.rb
  3. 6
      app/services/work_packages/set_attributes_service.rb
  4. 71
      app/services/work_packages/set_schedule_service.rb
  5. 69
      app/services/work_packages/shared/all_days.rb
  6. 40
      app/services/work_packages/shared/days.rb
  7. 129
      app/services/work_packages/shared/working_days.rb
  8. 9
      spec/factories/work_package_factory.rb
  9. 7
      spec/features/work_packages/table/duration_field_spec.rb
  10. 2
      spec/models/mail_handler_spec.rb
  11. 13
      spec/services/work_packages/schedule_dependency/dependency_spec.rb
  12. 37
      spec/services/work_packages/set_schedule_service_spec.rb
  13. 1229
      spec/services/work_packages/set_schedule_service_working_days_spec.rb
  14. 185
      spec/services/work_packages/shared/all_days_spec.rb
  15. 64
      spec/services/work_packages/shared/days_spec.rb
  16. 85
      spec/services/work_packages/shared/shared_examples_days.rb
  17. 244
      spec/services/work_packages/shared/working_days_spec.rb
  18. 3
      spec/services/work_packages/update_service_integration_spec.rb
  19. 48
      spec/support/schedule_helpers.rb
  20. 246
      spec/support/schedule_helpers/chart.rb
  21. 131
      spec/support/schedule_helpers/chart_builder.rb
  22. 67
      spec/support/schedule_helpers/chart_representer.rb
  23. 98
      spec/support/schedule_helpers/example_methods.rb
  24. 107
      spec/support/schedule_helpers/let_schedule.rb
  25. 171
      spec/support_spec/schedule_helpers/chart_builder_spec.rb
  26. 192
      spec/support_spec/schedule_helpers/chart_representer_spec.rb
  27. 227
      spec/support_spec/schedule_helpers/chart_spec.rb
  28. 153
      spec/support_spec/schedule_helpers/example_methods_spec.rb
  29. 100
      spec/support_spec/schedule_helpers/let_schedule_spec.rb

@ -124,6 +124,9 @@ Naming/PredicateName:
ForbiddenPrefixes:
- is_
Naming/VariableNumber:
AllowedPatterns:
- '\w_20\d\d' # allow dates like christmas_2022 or date_2034_04_12
# There are valid cases in which to use methods like:
# * update_all
@ -207,6 +210,7 @@ RSpec/NamedSubject:
RSpec/ContextWording:
Prefixes:
- as
- 'on'
- when
- with
- without

@ -51,9 +51,10 @@ class WorkPackages::ScheduleDependency::Dependency
end
def soonest_start_date
follows_relations
soonest_start = follows_relations
.filter_map(&:successor_soonest_start)
.max
WorkPackages::Shared::Days.for(work_package).soonest_working_day(soonest_start)
end
def start_date

@ -182,11 +182,7 @@ class WorkPackages::SetAttributesService < ::BaseServices::SetAttributes
def update_duration
return unless date_changed_but_not_duration?
work_package.duration = if work_package.start_date && work_package.due_date
work_package.due_date - work_package.start_date + 1
else
1
end
work_package.duration = WorkPackages::Shared::Days.for(work_package).duration(work_package.start_date, work_package.due_date)
end
def set_version_to_nil

@ -56,11 +56,23 @@ class WorkPackages::SetScheduleService
private
# rubocop:disable Metrics/AbcSize
def schedule_by_parent
work_packages
.select { |wp| wp.start_date.nil? && wp.parent }
.each { |wp| wp.start_date = wp.parent.soonest_start }
.each do |wp|
days = WorkPackages::Shared::Days.for(wp)
wp.start_date = days.soonest_working_day(wp.parent.soonest_start)
if wp.due_date || wp.duration
wp.due_date = [
wp.start_date,
days.due_date(wp.start_date, wp.duration),
wp.due_date
].compact.max
end
end
end
# rubocop:enable Metrics/AbcSize
# Finds all work packages that need to be rescheduled because of a
# rescheduling of the service's work package and reschedules them.
@ -137,45 +149,61 @@ class WorkPackages::SetScheduleService
elsif !scheduled.start_date && min_start_date
schedule_on_missing_dates(scheduled, min_start_date)
elsif !delta.zero?
reschedule_by_delta(scheduled, delta, min_start_date)
reschedule_by_delta(scheduled, delta, min_start_date, dependency)
end
end
def reschedule_to_date(scheduled, date)
new_start_date = [scheduled.start_date, date].compact.max
set_dates(scheduled,
new_start_date,
(scheduled.due_date && !scheduled.duration.nil?) && (new_start_date + scheduled.duration - 1))
end
def reschedule_by_delta(scheduled, delta, min_start_date)
required_delta = [min_start_date - (scheduled.start_date || min_start_date), [delta, 0].min].max
# a new due date is set only if the moving work package already has one
if scheduled.due_date
new_due_date = [
WorkPackages::Shared::Days.for(scheduled).due_date(new_start_date, scheduled.duration),
new_start_date,
scheduled.due_date
].compact.max
end
scheduled.start_date += required_delta
scheduled.due_date += required_delta if scheduled.due_date
set_dates(scheduled, new_start_date, new_due_date)
end
# If the start_date of scheduled is nil at this point something
# went wrong before. So we fix it now by setting the date.
def schedule_on_missing_dates(scheduled, min_start_date)
min_start_date = WorkPackages::Shared::Days.for(scheduled).soonest_working_day(min_start_date)
set_dates(scheduled,
min_start_date,
scheduled.due_date && scheduled.due_date < min_start_date ? min_start_date : scheduled.due_date)
end
def reschedule_by_delta(scheduled, moved_delta, min_start_date, dependency)
days = WorkPackages::Shared::Days.for(dependency.work_package)
# TODO: can it be moved to dependency?
min_start_delta = days.delta(previous: scheduled.start_date || min_start_date, current: min_start_date)
required_delta = [min_start_delta, [moved_delta, 0].min].max
scheduled_days = WorkPackages::Shared::Days.for(scheduled)
new_start_date = scheduled_days.add_days(scheduled.start_date, required_delta)
new_due_date = scheduled_days.add_days(scheduled.due_date, required_delta) if scheduled.due_date
scheduled.start_date = new_start_date
scheduled.due_date = new_due_date
end
def follows_delta(dependency)
if dependency.moving_predecessors.any?
date_rescheduling_delta(dependency.moving_predecessors.first)
date_rescheduling_delta(dependency.moving_predecessors.first, dependency.work_package)
else
0
end
end
def date_rescheduling_delta(predecessor)
def date_rescheduling_delta(predecessor, follower)
days = WorkPackages::Shared::Days.for(follower)
if predecessor.due_date.present?
predecessor.due_date - (predecessor.due_date_before_last_save || predecessor.due_date_was || predecessor.due_date)
previous_due_date = predecessor.due_date_before_last_save || predecessor.due_date_was || predecessor.due_date
days.delta(previous: previous_due_date, current: predecessor.due_date)
elsif predecessor.start_date.present?
predecessor.start_date - (predecessor.start_date_before_last_save || predecessor.start_date_was || predecessor.start_date)
previous_start_date = predecessor.start_date_before_last_save || predecessor.start_date_was || predecessor.start_date
days.delta(previous: previous_start_date, current: predecessor.start_date)
else
0
end
@ -184,11 +212,8 @@ class WorkPackages::SetScheduleService
def set_dates(work_package, start_date, due_date)
work_package.start_date = start_date
work_package.due_date = due_date
work_package.duration = if start_date && due_date
due_date - start_date + 1
else
# This needs to change to nil once duration can be set
1
end
work_package.duration = WorkPackages::Shared::Days
.for(work_package)
.duration(start_date, due_date)
end
end

@ -0,0 +1,69 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
module Shared
class AllDays
# Returns number of days between two dates, inclusive.
def duration(start_date, due_date)
return no_duration if start_date.nil? || due_date.nil?
(start_date..due_date).count
end
def due_date(start_date, duration)
return nil unless start_date && duration
raise ArgumentError, 'duration must be strictly positive' if duration.is_a?(Integer) && duration <= 0
start_date + duration - 1
end
def add_days(date, count)
date + count.days
end
def soonest_working_day(date)
date
end
def delta(previous:, current:)
current - previous
end
def working?(_date)
true
end
private
def no_duration
OpenProject::FeatureDecisions.work_packages_duration_field_active? ? nil : 1
end
end
end
end

@ -0,0 +1,40 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
module Shared
class Days
# Returns the right day computation instance for the given instance.
def self.for(work_package)
return AllDays.new unless OpenProject::FeatureDecisions.work_packages_duration_field_active?
work_package.ignore_non_working_days ? AllDays.new : WorkingDays.new
end
end
end
end

@ -0,0 +1,129 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
module Shared
class WorkingDays
# Returns number of working days between two dates, excluding weekend days
# and non working days.
def duration(start_date, due_date)
return no_duration unless start_date && due_date
(start_date..due_date).count { working?(_1) }
end
def due_date(start_date, duration)
return nil unless start_date && duration
raise ArgumentError, 'duration must be strictly positive' if duration.is_a?(Integer) && duration <= 0
due_date = start_date
until duration <= 1 && working?(due_date)
due_date += 1
duration -= 1 if working?(due_date)
end
due_date
end
def add_days(date, count)
while count > 0
date += 1
count -= 1 if working?(date)
end
while count < 0
date -= 1
count += 1 if working?(date)
end
date
end
def soonest_working_day(date)
return unless date
until working?(date)
date += 1
end
date
end
def delta(previous:, current:)
delta = 0
direction = previous < current ? 1 : -1
pos = last_pos = previous
while pos != current
pos += direction
if working?(last_pos) && working?(pos)
delta += direction
last_pos = pos
end
end
delta
end
def working?(date)
working_week_day?(date) && working_specific_date?(date)
end
private
def no_duration
OpenProject::FeatureDecisions.work_packages_duration_field_active? ? nil : 1
end
def working_week_day?(date)
working_week_days[date.wday]
end
def working_specific_date?(date)
non_working_dates.exclude?(date)
end
def working_week_days
return @working_week_days if defined?(@working_week_days)
# WeekDay day of the week is stored as ISO, meaning Sunday is 7.
# Ruby Date#wday value for Sunday is 0.
# To make both work, an array of 8 elements is created
# where array[0] = array[7] = value for Sunday
#
# Because the database table for WeekDay could be empty or incomplete
# (like in tests), the initial array is built with all days considered
# working (value is `true`)
@working_week_days = [true] * 8
WeekDay.pluck(:day, :working).each do |day, working|
@working_week_days[day] = working
end
@working_week_days[0] = @working_week_days[7] # value for Sunday is present at index 0 AND index 7
@working_week_days
end
def non_working_dates
@non_working_dates ||= Set.new(NonWorkingDay.pluck(:date))
end
end
end
end

@ -40,14 +40,7 @@ FactoryBot.define do
author factory: :user
created_at { Time.zone.now }
updated_at { Time.zone.now }
duration do
if start_date && due_date
due_date - start_date + 1
else
# This needs to change to nil once duration can be set
1
end
end
duration { WorkPackages::Shared::Days.for(self).duration(start_date&.to_date, due_date&.to_date) }
callback(:after_build) do |work_package, evaluator|
work_package.type = work_package.project.types.first unless work_package.type

@ -5,11 +5,12 @@ describe 'Duration field in the work package table',
js: true do
shared_let(:current_user) { create :admin }
shared_let(:work_package) do
next_monday = Time.zone.today.beginning_of_week.next_occurring(:monday)
create :work_package,
subject: 'moved',
author: current_user,
start_date: Time.zone.today.beginning_of_week.next_occurring(:monday),
due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)
start_date: next_monday,
due_date: next_monday.next_occurring(:thursday)
end
let!(:wp_table) { Pages::WorkPackagesTable.new(work_package.project) }
@ -33,6 +34,6 @@ describe 'Duration field in the work package table',
end
it 'shows the duration as days' do
duration.expect_state_text '3 days'
duration.expect_state_text '4 days'
end
end

@ -794,7 +794,7 @@ describe MailHandler, type: :model do
"status_id" => [original_status.id, resolved_status.id],
"assigned_to_id" => [nil, other_user.id],
"start_date" => [nil, Date.parse("Fri, 01 Jan 2010")],
"duration" => [1, 365],
"duration" => [nil, 365],
"custom_fields_#{float_cf.id}" => [nil, "52.6"]
)
end

@ -59,8 +59,8 @@ RSpec.describe WorkPackages::ScheduleDependency::Dependency do
end
end
def create_follower_of(work_package)
create(:work_package, subject: "follower of #{work_package.subject}").tap do |follower|
def create_follower_of(work_package, **attributes)
create(:work_package, subject: "follower of #{work_package.subject}", **attributes).tap do |follower|
create(:follows_relation, from: follower, to: work_package)
end
end
@ -232,5 +232,14 @@ RSpec.describe WorkPackages::ScheduleDependency::Dependency do
expect(dependency_for(follower).soonest_start_date).to eq(unmoved_follower_predecessor.due_date + 1.day)
end
end
context 'with non working days', with_flag: { work_packages_duration_field_active: true } do
let!(:tomorrow_we_do_not_work!) { create(:non_working_day, date: Time.zone.tomorrow) }
it 'returns the soonest start date being a working day' do
follower = create_follower_of(work_package, ignore_non_working_days: false)
expect(dependency_for(follower).soonest_start_date).to eq(work_package.due_date + 2.days)
end
end
end
end

@ -109,11 +109,12 @@ describe WorkPackages::SetScheduleService do
work_package
end
def create_follower_child(parent, start, due)
create_follower(start,
due,
{},
parent:)
def create_child(parent, start_date, due_date)
create(:work_package,
subject: "child of #{parent.subject}",
start_date:,
due_date:,
parent:)
end
subject { instance.call(attributes) }
@ -142,12 +143,7 @@ describe WorkPackages::SetScheduleService do
.to eql(due_date),
"Expected work package ##{wp.id} '#{wp.subject}' to have due date #{due_date}, got #{result.due_date}"
duration = if start_date && due_date
(due_date - start_date + 1).to_i
else
# This needs to change to nil once duration can be set
1
end
duration = WorkPackages::Shared::AllDays.new.duration(start_date, due_date)
expect(result.duration)
.to eql(duration),
@ -694,7 +690,7 @@ describe WorkPackages::SetScheduleService do
let(:child_start_date) { follower1_start_date }
let(:child_due_date) { follower1_due_date }
let(:child_work_package) { create_follower_child(following_work_package1, child_start_date, child_due_date) }
let(:child_work_package) { create_child(following_work_package1, child_start_date, child_due_date) }
let!(:following) do
[following_work_package1,
@ -723,8 +719,8 @@ describe WorkPackages::SetScheduleService do
let(:child2_start_date) { follower1_start_date + 8.days }
let(:child2_due_date) { follower1_due_date }
let(:child1_work_package) { create_follower_child(following_work_package1, child1_start_date, child1_due_date) }
let(:child2_work_package) { create_follower_child(following_work_package1, child2_start_date, child2_due_date) }
let(:child1_work_package) { create_child(following_work_package1, child1_start_date, child1_due_date) }
let(:child2_work_package) { create_child(following_work_package1, child2_start_date, child2_due_date) }
let!(:following) do
[following_work_package1,
@ -923,13 +919,22 @@ describe WorkPackages::SetScheduleService do
.and_return(new_parent_work_package)
end
context "with the parent being restricted in it's ability to be moved" do
context "with the parent being restricted in its ability to be moved" do
let(:soonest_date) { Time.zone.today + 3.days }
it 'sets the start date to the earliest possible date' do
it 'sets the start date and due date to the earliest possible date' do
subject
expect(work_package.start_date).to eql(Time.zone.today + 3.days)
expect(work_package.due_date).to eql(Time.zone.today + 3.days)
end
it 'does not change the due date if after the newly set start date' do
work_package.due_date = Time.zone.today + 5.days
subject
expect(work_package.start_date).to eql(Time.zone.today + 3.days)
expect(work_package.due_date).to eql(Time.zone.today + 5.days)
end
end

@ -0,0 +1,185 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'rails_helper'
require_relative 'shared_examples_days'
RSpec.describe WorkPackages::Shared::AllDays do
subject { described_class.new }
sunday_2022_07_31 = Date.new(2022, 7, 31)
describe '#duration' do
context 'without any week days created' do
it 'considers all days as working days and returns the number of days between two dates, inclusive' do
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(7)
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 50)).to eq(51)
end
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
it 'considers all days as working days and returns the number of days between two dates, inclusive' do
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(7)
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 50)).to eq(51)
end
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'it returns duration', 365, Date.new(2022, 1, 1), Date.new(2022, 12, 31)
include_examples 'it returns duration', 365 * 2, Date.new(2022, 1, 1), Date.new(2023, 12, 31)
end
context 'without start date', with_flag: { work_packages_duration_field_active: true } do
it 'returns nil' do
expect(subject.duration(nil, sunday_2022_07_31)).to be_nil
end
context 'when work packages duration field is inactive', with_flag: { work_packages_duration_field_active: false } do
it 'returns 1' do
expect(subject.duration(nil, sunday_2022_07_31)).to eq(1)
end
end
end
context 'without due date', with_flag: { work_packages_duration_field_active: true } do
it 'returns nil' do
expect(subject.duration(sunday_2022_07_31, nil)).to be_nil
end
context 'when work packages duration field is inactive', with_flag: { work_packages_duration_field_active: false } do
it 'returns 1' do
expect(subject.duration(sunday_2022_07_31, nil)).to eq(1)
end
end
end
end
describe '#due_date' do
it 'returns the due date for a start date and a duration' do
expect(subject.due_date(sunday_2022_07_31, 1)).to eq(sunday_2022_07_31)
expect(subject.due_date(sunday_2022_07_31, 10)).to eq(sunday_2022_07_31 + 9.days)
end
it 'raises an error if duration is 0 or negative' do
expect { subject.due_date(sunday_2022_07_31, 0) }
.to raise_error ArgumentError, 'duration must be strictly positive'
expect { subject.due_date(sunday_2022_07_31, -10) }
.to raise_error ArgumentError, 'duration must be strictly positive'
end
it 'returns nil if start_date is nil' do
expect(subject.due_date(nil, 1)).to be_nil
end
it 'returns nil if duration is nil' do
expect(subject.due_date(sunday_2022_07_31, nil)).to be_nil
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'due_date', start_date: sunday_2022_07_31, duration: 1, expected: sunday_2022_07_31
include_examples 'due_date', start_date: sunday_2022_07_31, duration: 5, expected: sunday_2022_07_31 + 4.days
include_examples 'due_date', start_date: sunday_2022_07_31, duration: 10, expected: sunday_2022_07_31 + 9.days
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'due_date', start_date: Date.new(2022, 1, 1), duration: 365, expected: Date.new(2022, 12, 31)
include_examples 'due_date', start_date: Date.new(2022, 1, 1), duration: 365 * 2, expected: Date.new(2023, 12, 31)
end
end
describe '#add_days' do
it 'adds the number of days to the date' do
expect(subject.add_days(sunday_2022_07_31, 7)).to eq(Date.new(2022, 8, 7))
end
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 0, expected: Date.new(2022, 6, 15)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 1, expected: Date.new(2022, 6, 16)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 10, expected: Date.new(2022, 6, 25)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 100, expected: Date.new(2022, 9, 23)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 365, expected: Date.new(2023, 6, 15)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: -1, expected: Date.new(2022, 6, 14)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: -10, expected: Date.new(2022, 6, 5)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: -100, expected: Date.new(2022, 3, 7)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: -730, expected: Date.new(2020, 6, 15)
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
it 'adds the number of days to the date' do
expect(subject.add_days(sunday_2022_07_31, 7)).to eq(Date.new(2022, 8, 7))
expect(subject.add_days(sunday_2022_07_31, -7)).to eq(Date.new(2022, 7, 24))
end
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
it 'adds the number of days to the date' do
expect(subject.add_days(Date.new(2022, 1, 1), 365)).to eq(Date.new(2023, 1, 1))
expect(subject.add_days(Date.new(2022, 1, 1), -365)).to eq(Date.new(2021, 1, 1))
end
end
end
describe '#soonest_working_day' do
it 'returns the given day' do
expect(subject.soonest_working_day(sunday_2022_07_31)).to eq(sunday_2022_07_31)
end
it 'returns nil if given date is nil' do
expect(subject.soonest_working_day(nil)).to be_nil
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
it 'returns the given day' do
expect(subject.soonest_working_day(sunday_2022_07_31)).to eq(sunday_2022_07_31)
end
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
it 'returns the given day' do
expect(subject.soonest_working_day(Date.new(2022, 12, 25))).to eq(Date.new(2022, 12, 25))
end
end
end
describe '#delta' do
it 'returns the number of shift from one day to another between two dates' do
expect(subject.delta(previous: Date.new(2022, 8, 1), current: Date.new(2022, 8, 30))).to eq(29)
expect(subject.delta(previous: Date.new(2022, 8, 15), current: Date.new(2022, 8, 1))).to eq(-14)
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
it 'returns the same delta as without them' do
expect(subject.delta(previous: Date.new(2022, 8, 1), current: Date.new(2022, 8, 30))).to eq(29)
end
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
it 'returns the same delta as without them' do
expect(subject.delta(previous: Date.new(2022, 12, 1), current: Date.new(2023, 1, 1))).to eq(31)
end
end
end
end

@ -0,0 +1,64 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'rails_helper'
RSpec.describe WorkPackages::Shared::Days do
subject { described_class.new }
describe '.for', with_flag: { work_packages_duration_field_active: true } do
context 'for a work_package ignoring non working days' do
let(:work_package) { build_stubbed(:work_package, ignore_non_working_days: true) }
it 'returns an AllDays instance' do
expect(described_class.for(work_package)).to be_an_instance_of(WorkPackages::Shared::AllDays)
end
end
context 'for a work_package respecting non working days' do
let(:work_package) { build_stubbed(:work_package, ignore_non_working_days: false) }
it 'returns a WorkingDays instance' do
expect(described_class.for(work_package)).to be_an_instance_of(WorkPackages::Shared::WorkingDays)
end
end
context 'when work packages duration field is inactive', with_flag: { work_packages_duration_field_active: false } do
it 'always returns an AllDays instance' do
work_package = build_stubbed(:work_package)
expect(described_class.for(work_package)).to be_an_instance_of(WorkPackages::Shared::AllDays)
work_package = build_stubbed(:work_package, ignore_non_working_days: true)
expect(described_class.for(work_package)).to be_an_instance_of(WorkPackages::Shared::AllDays)
work_package = build_stubbed(:work_package, ignore_non_working_days: false)
expect(described_class.for(work_package)).to be_an_instance_of(WorkPackages::Shared::AllDays)
end
end
end
end

@ -0,0 +1,85 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
Date::DATE_FORMATS[:wday_date] = '%a %-d %b %Y' # Fri 5 Aug 2022
RSpec.shared_context 'with weekend days Saturday and Sunday' do
shared_let(:week_days) { create(:week_days) }
end
RSpec.shared_context 'with non working days Christmas 2022 and new year 2023' do
shared_let(:christmas) { create(:non_working_day, date: Date.new(2022, 12, 25)) }
shared_let(:new_year_day) { create(:non_working_day, date: Date.new(2023, 1, 1)) }
end
RSpec.configure do |rspec|
rspec.include_context 'with weekend days Saturday and Sunday', :weekend_saturday_sunday
rspec.include_context 'with non working days Christmas 2022 and new year 2023', :christmas_2022_new_year_2023
end
RSpec.shared_examples 'it returns duration' do |expected_duration, start_date, due_date|
from_date_format = '%a %-d'
from_date_format += ' %b' if [start_date.month, start_date.year] != [due_date.month, due_date.year]
from_date_format += ' %Y' if start_date.year != due_date.year
it "from #{start_date.strftime(from_date_format)} " \
"to #{due_date.to_fs(:wday_date)} " \
"=> #{expected_duration}" \
do
expect(subject.duration(start_date, due_date)).to eq(expected_duration)
end
end
RSpec.shared_examples 'due_date' do |start_date:, duration:, expected:|
it "due_date(#{start_date.to_fs(:wday_date)}, #{duration}) => #{expected.to_fs(:wday_date)}" do
expect(subject.due_date(start_date, duration)).to eq(expected)
end
end
RSpec.shared_examples 'add_days returns date' do |date:, count:, expected:|
it "add_days(#{date.to_fs(:wday_date)}, #{count}) => #{expected.to_fs(:wday_date)}" do
expect(subject.add_days(date, count)).to eq(expected)
end
end
RSpec.shared_examples 'soonest working day' do |date:, expected:|
it "soonest_working_day(#{date.to_fs(:wday_date)}) => #{expected.to_fs(:wday_date)}" do
expect(subject.soonest_working_day(date)).to eq(expected)
end
end
RSpec.shared_examples 'delta' do |previous:, current:, expected:|
it "delta(previous: #{previous.to_fs(:wday_date)}, current: #{current.to_fs(:wday_date)}) => #{expected} days" do
expect(subject.delta(previous:, current:)).to eq(expected)
end
# check inverse: delta(a, b) == -delta(b, a)
it "delta(previous: #{current.to_fs(:wday_date)}, current: #{previous.to_fs(:wday_date)}) => #{-expected} days" do
expect(subject.delta(previous: current, current: previous)).to eq(-expected)
end
end

@ -0,0 +1,244 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'rails_helper'
require_relative 'shared_examples_days'
RSpec.describe WorkPackages::Shared::WorkingDays do
subject { described_class.new }
friday_2022_07_29 = Date.new(2022, 7, 29)
saturday_2022_07_30 = Date.new(2022, 7, 30)
sunday_2022_07_31 = Date.new(2022, 7, 31)
monday_2022_08_01 = Date.new(2022, 8, 1)
wednesday_2022_08_03 = Date.new(2022, 8, 3)
describe '#duration' do
it 'returns the duration for a given start date and due date' do
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(7)
end
context 'without any week days created' do
it 'considers all days as working days and returns the number of days between two dates, inclusive' do
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(7)
expect(subject.duration(sunday_2022_07_31, sunday_2022_07_31 + 50)).to eq(51)
end
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'it returns duration', 0, sunday_2022_07_31, sunday_2022_07_31
include_examples 'it returns duration', 5, sunday_2022_07_31, Date.new(2022, 8, 5)
include_examples 'it returns duration', 5, sunday_2022_07_31, Date.new(2022, 8, 6)
include_examples 'it returns duration', 5, sunday_2022_07_31, Date.new(2022, 8, 7)
include_examples 'it returns duration', 6, sunday_2022_07_31, Date.new(2022, 8, 8)
include_examples 'it returns duration', 7, sunday_2022_07_31, Date.new(2022, 8, 9)
include_examples 'it returns duration', 1, monday_2022_08_01, monday_2022_08_01
include_examples 'it returns duration', 5, monday_2022_08_01, Date.new(2022, 8, 5)
include_examples 'it returns duration', 5, monday_2022_08_01, Date.new(2022, 8, 6)
include_examples 'it returns duration', 5, monday_2022_08_01, Date.new(2022, 8, 7)
include_examples 'it returns duration', 6, monday_2022_08_01, Date.new(2022, 8, 8)
include_examples 'it returns duration', 7, monday_2022_08_01, Date.new(2022, 8, 9)
include_examples 'it returns duration', 3, wednesday_2022_08_03, Date.new(2022, 8, 5)
include_examples 'it returns duration', 3, wednesday_2022_08_03, Date.new(2022, 8, 6)
include_examples 'it returns duration', 3, wednesday_2022_08_03, Date.new(2022, 8, 7)
include_examples 'it returns duration', 4, wednesday_2022_08_03, Date.new(2022, 8, 8)
include_examples 'it returns duration', 5, wednesday_2022_08_03, Date.new(2022, 8, 9)
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'it returns duration', 0, Date.new(2022, 12, 25), Date.new(2022, 12, 25)
include_examples 'it returns duration', 1, Date.new(2022, 12, 24), Date.new(2022, 12, 25)
include_examples 'it returns duration', 8, Date.new(2022, 12, 24), Date.new(2023, 1, 2)
end
context 'without start date', with_flag: { work_packages_duration_field_active: true } do
it 'returns nil' do
expect(subject.duration(nil, sunday_2022_07_31)).to be_nil
end
context 'when work packages duration field is inactive', with_flag: { work_packages_duration_field_active: false } do
it 'returns 1' do
expect(subject.duration(nil, sunday_2022_07_31)).to eq(1)
end
end
end
context 'without due date', with_flag: { work_packages_duration_field_active: true } do
it 'returns nil' do
expect(subject.duration(sunday_2022_07_31, nil)).to be_nil
end
context 'when work packages duration field is inactive', with_flag: { work_packages_duration_field_active: false } do
it 'returns 1' do
expect(subject.duration(sunday_2022_07_31, nil)).to eq(1)
end
end
end
end
describe '#due_date' do
it 'returns the due date for a start date and a duration' do
expect(subject.due_date(monday_2022_08_01, 1)).to eq(monday_2022_08_01)
end
it 'raises an error if duration is 0 or negative' do
expect { subject.due_date(monday_2022_08_01, 0) }
.to raise_error ArgumentError, 'duration must be strictly positive'
expect { subject.due_date(monday_2022_08_01, -10) }
.to raise_error ArgumentError, 'duration must be strictly positive'
end
it 'returns nil if start_date is nil' do
expect(subject.due_date(nil, 1)).to be_nil
end
it 'returns nil if duration is nil' do
expect(subject.due_date(monday_2022_08_01, nil)).to be_nil
end
context 'without any week days created' do
it 'returns the due date considering all days as working days' do
expect(subject.due_date(monday_2022_08_01, 1)).to eq(monday_2022_08_01)
expect(subject.due_date(monday_2022_08_01, 7)).to eq(monday_2022_08_01 + 6) # Sunday of same week
end
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'due_date', start_date: monday_2022_08_01, duration: 1, expected: monday_2022_08_01
include_examples 'due_date', start_date: monday_2022_08_01, duration: 5, expected: monday_2022_08_01 + 4.days
include_examples 'due_date', start_date: wednesday_2022_08_03, duration: 10, expected: wednesday_2022_08_03 + 13.days
# really contrived one... Unlikely to happen.
include_examples 'due_date', start_date: saturday_2022_07_30, duration: 1, expected: monday_2022_08_01
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'due_date', start_date: Date.new(2022, 12, 24), duration: 2, expected: Date.new(2022, 12, 26)
include_examples 'due_date', start_date: Date.new(2022, 12, 24), duration: 8, expected: Date.new(2023, 1, 2)
end
end
describe '#add_days' do
it 'when positive, adds the number of working days to the date, ignoring non-working days' do
create(:week_day, day: 5, working: false)
create(:non_working_day, date: wednesday_2022_08_03)
# Wednesday is skipped (non working day)
expect(subject.add_days(monday_2022_08_01, 2)).to eq(Date.new(2022, 8, 4))
# Wednesday is skipped (non working day) + Friday is skipped (non working week day)
expect(subject.add_days(monday_2022_08_01, 7)).to eq(Date.new(2022, 8, 10))
# Wednesday is skipped (non working day) + Friday is skipped twice (non working week day)
expect(subject.add_days(monday_2022_08_01, 14)).to eq(Date.new(2022, 8, 18))
end
it 'when negative, removes the number of working days to the date, ignoring non-working days' do
create(:week_day, day: 5, working: false)
create(:non_working_day, date: sunday_2022_07_31)
# Sunday is skipped (non working day)
expect(subject.add_days(monday_2022_08_01, -1)).to eq(Date.new(2022, 7, 30)) # Saturday
# Sunday is skipped (non working day) + Friday is skipped (non working week day)
expect(subject.add_days(monday_2022_08_01, -2)).to eq(Date.new(2022, 7, 28)) # Thursday
# Sunday is skipped (non working day) + Friday is skipped twice (non working week day)
expect(subject.add_days(monday_2022_08_01, -8)).to eq(Date.new(2022, 7, 21)) # Wednesday
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'add_days returns date', date: saturday_2022_07_30, count: 0, expected: saturday_2022_07_30
include_examples 'add_days returns date', date: saturday_2022_07_30, count: 1, expected: monday_2022_08_01
include_examples 'add_days returns date', date: saturday_2022_07_30, count: -1, expected: friday_2022_07_29
include_examples 'add_days returns date', date: sunday_2022_07_31, count: 0, expected: sunday_2022_07_31
include_examples 'add_days returns date', date: sunday_2022_07_31, count: 1, expected: monday_2022_08_01
include_examples 'add_days returns date', date: sunday_2022_07_31, count: -1, expected: friday_2022_07_29
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: 100, expected: Date.new(2022, 11, 2)
include_examples 'add_days returns date', date: Date.new(2022, 6, 15), count: -100, expected: Date.new(2022, 1, 26)
include_examples 'add_days returns date', date: Date.new(2022, 1, 1), count: 365, expected: Date.new(2023, 5, 26)
include_examples 'add_days returns date', date: Date.new(2022, 12, 31), count: -365, expected: Date.new(2021, 8, 9)
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'add_days returns date', date: Date.new(2022, 12, 24), count: 1, expected: Date.new(2022, 12, 26)
include_examples 'add_days returns date', date: Date.new(2022, 12, 24), count: 7, expected: Date.new(2023, 1, 2)
include_examples 'add_days returns date', date: Date.new(2022, 12, 26), count: -1, expected: Date.new(2022, 12, 24)
include_examples 'add_days returns date', date: Date.new(2023, 1, 2), count: -7, expected: Date.new(2022, 12, 24)
end
end
describe '#soonest_working_day' do
it 'returns the soonest working day from the given day' do
expect(subject.soonest_working_day(sunday_2022_07_31)).to eq(sunday_2022_07_31)
end
it 'returns nil if given date is nil' do
expect(subject.soonest_working_day(nil)).to be_nil
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'soonest working day', date: friday_2022_07_29, expected: friday_2022_07_29
include_examples 'soonest working day', date: saturday_2022_07_30, expected: monday_2022_08_01
include_examples 'soonest working day', date: sunday_2022_07_31, expected: monday_2022_08_01
include_examples 'soonest working day', date: monday_2022_08_01, expected: monday_2022_08_01
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'soonest working day', date: Date.new(2022, 12, 25), expected: Date.new(2022, 12, 26)
include_examples 'soonest working day', date: Date.new(2022, 12, 31), expected: Date.new(2022, 12, 31)
include_examples 'soonest working day', date: Date.new(2023, 1, 1), expected: Date.new(2023, 1, 2)
end
end
describe '#delta' do
it 'returns the number of shift from one working day to another between two dates' do
expect(subject.delta(previous: monday_2022_08_01, current: wednesday_2022_08_03)).to eq(2)
expect(subject.delta(previous: wednesday_2022_08_03, current: monday_2022_08_01)).to eq(-2)
end
context 'with weekend days (Saturday and Sunday)', :weekend_saturday_sunday do
include_examples 'delta', previous: friday_2022_07_29, current: saturday_2022_07_30, expected: 0
include_examples 'delta', previous: saturday_2022_07_30, current: monday_2022_08_01, expected: 0
include_examples 'delta', previous: sunday_2022_07_31, current: monday_2022_08_01, expected: 0
include_examples 'delta', previous: friday_2022_07_29, current: monday_2022_08_01, expected: 1
include_examples 'delta', previous: friday_2022_07_29, current: Date.new(2022, 8, 5), expected: 5
include_examples 'delta', previous: friday_2022_07_29, current: Date.new(2022, 8, 8), expected: 6
end
context 'with non working days (Christmas 2022-12-25 and new year\'s day 2023-01-01)', :christmas_2022_new_year_2023 do
include_examples 'delta', previous: Date.new(2022, 12, 27), current: Date.new(2022, 12, 20), expected: -6
end
end
end

@ -901,7 +901,8 @@ describe WorkPackages::UpdateService, 'integration tests', type: :model, with_ma
# rubocop:enable RSpec/ExampleLength
end
describe 'rescheduling work packages with a parent having a follows relation (Regression #43220)' do
describe 'rescheduling work packages with a parent having a follows relation (Regression #43220)',
with_flag: { work_packages_duration_field_active: true } do
let(:predecessor_work_package_attributes) do
work_package_attributes.merge(
start_date: Time.zone.today + 1.day,

@ -0,0 +1,48 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
Dir[Rails.root.join('spec/support/schedule_helpers/*.rb')].each { |f| require f }
RSpec.configure do |config|
config.extend ScheduleHelpers::LetSchedule
config.include ScheduleHelpers::ExampleMethods
RSpec::Matchers.define :match_schedule do |expected|
match do |actual_work_packages|
expected_chart = ScheduleHelpers::Chart.for(expected)
actual_chart = ScheduleHelpers::Chart.from_work_packages(actual_work_packages)
@expected, @actual = ScheduleHelpers::ChartRepresenter.normalized_to_s(expected_chart, actual_chart)
values_match? @expected, @actual
end
diffable
attr_reader :expected, :actual
end
end

@ -0,0 +1,246 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ScheduleHelpers
# Contains work packages and relations information from a chart
# representation.
#
# The work package information are:
# * subject
# * start_date
# * due_date
#
# The relations information are limited to follows relations and are retrieved
# with +#predecessors_by_follower+
#
# The chart uses different symbols in the timeline to represent a work package
# start and due dates:
# * +X+: a day of the work package duration. The first +X+ is the start date,
# the last +X+ is the due date.
# * +[+: the work package start date. Can be used instead of +X+ when the work
# package has no due date.
# * +]+: the work package due date. Can be used instead of +X+ when the work
# package has no start date.
# * +_+: ignored but useful as a placeholder to highlight particular days, for
# instance to highlight the previous dates of a work package.
class Chart
FIRST_CELL_TEXT = 'days'.freeze
WEEK_DAYS_TEXT = 'MTWTFSS'.freeze
attr_reader :id_column_size, :first_day, :last_day, :monday
def self.for(representation)
builder = ChartBuilder.new
builder.parse(representation)
end
def self.from_work_packages(work_packages)
ChartBuilder.new.use_work_packages(Array(work_packages))
end
def initialize
self.monday = next_monday
self.id_column_size = FIRST_CELL_TEXT.length
end
# duplicates the chart with different representation properties
def with(order: work_package_names, id_column_size: self.id_column_size, first_day: self.first_day, last_day: self.last_day)
chart = Chart.new
extra_names = work_package_names - order
chart.work_packages_attributes = work_packages_attributes.index_by { _1[:name] }.values_at(*(order + extra_names)).compact
chart.monday = monday
chart.id_column_size = id_column_size
chart.first_day = first_day
chart.last_day = last_day
chart.predecessors_by_followers = predecessors_by_followers
chart.delays_between = delays_between
chart.parent_by_child = parent_by_child
chart
end
# Sets the origin of the calendar, represented by +M+ on the first line (M as
# in Monday).
def monday=(monday)
raise ArgumentError, "#{monday} is not a Monday" unless monday.wday == 1
extend_calendar_range(monday, monday + 6.days)
@monday = monday
end
def validate
work_package_names.each do |follower|
predecessors_by_follower(follower).each do |predecessor|
unless work_package_attributes(predecessor)
raise "unable to find predecessor #{predecessor.inspect} " \
"in property \"follows #{predecessor}\" " \
"for work package #{follower.inspect}"
end
end
end
end
def work_packages_attributes
@work_packages_attributes ||= []
end
def work_package_attributes(name)
work_packages_attributes.find { |wpa| wpa[:name] == name.to_sym }
end
def work_package_names
work_packages_attributes.pluck(:name)
end
def predecessors_by_follower(follower)
predecessors_by_followers[follower]
end
def delay_between(predecessor:, follower:)
delays_between.fetch([predecessor, follower])
end
def add_work_package(attributes)
extend_calendar_range(*attributes.values_at(:start_date, :due_date))
extend_id_column_size(*attributes.values_at(:subject))
work_packages_attributes << attributes.merge(name: attributes[:subject].to_sym)
end
def set_duration(name, duration)
unless duration.is_a?(Integer) && duration > 0
raise ArgumentError, "unable to set duration for #{name}: " \
"duration must be a positive integer (got #{duration.inspect})"
end
attributes = work_package_attributes(name.to_sym)
dates_attributes = attributes.slice(:start_date, :due_date).compact
if dates_attributes.any?(&:present?)
raise ArgumentError, "unable to set duration for #{name}: " \
"#{dates_attributes.keys.join(' and ')} is set"
end
attributes[:duration] = duration
end
def add_follows_relation(predecessor:, follower:, delay:)
predecessors_by_follower(follower) << predecessor
delays_between[[predecessor, follower]] = delay
end
def add_parent_relation(parent:, child:)
parent_by_child[child] = parent
end
def parent(name)
parent_by_child[name]
end
def to_s
representer = ChartRepresenter.new(id_column_size:, days_column_size:)
representer.add_row
representer.add_cell(FIRST_CELL_TEXT)
representer.add_cell(spaced_at(monday, WEEK_DAYS_TEXT))
work_package_names.each do |name|
representer.add_row
representer.add_cell(name.to_s)
representer.add_cell(span(work_package_attributes(name)))
end
representer.to_s
end
protected
attr_writer :work_packages_attributes,
:id_column_size,
:first_day,
:last_day,
:predecessors_by_followers,
:delays_between,
:parent_by_child
private
def extend_calendar_range(*dates)
self.first_day = [@first_day, *dates].compact.min
self.last_day = [@last_day, *dates].compact.max
end
def extend_id_column_size(name)
self.id_column_size = [id_column_size, name.length].max
end
def days_column_size
(first_day..last_day).count
end
def spaced_at(date, text)
nb_days = date - first_day
(" " * nb_days) + text
end
def span(attributes)
case attributes
in { start_date: nil, due_date: nil }
''
in { start_date:, due_date: nil }
spaced_at(start_date, '[')
in { start_date: nil, due_date: }
spaced_at(due_date, ']')
in { start_date:, due_date: }
days = days_for(attributes)
span = (start_date..due_date).map do |date|
days.working?(date) ? 'X' : '.'
end.join
spaced_at(start_date, span)
end
end
def days_for(attributes)
if attributes[:ignore_non_working_days]
WorkPackages::Shared::AllDays.new
else
WorkPackages::Shared::WorkingDays.new
end
end
def next_monday
date = Time.zone.today
date += 1.day while date.wday != 1
date
end
def predecessors_by_followers
@predecessors_by_followers ||= Hash.new { |h, k| h[k] = [] }
end
def delays_between
@delays_between ||= Hash.new(0)
end
def parent_by_child
@parent_by_child ||= {}
end
end
end

@ -0,0 +1,131 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ScheduleHelpers
# Builds a +Chart+ instance from a visual chart representation.
#
# Example:
#
# ChartBuilder.new.parse(<<~CHART)
# days | MTWTFSS |
# main | XX |
# follower | XXX | follows main
# start_only | [ |
# due_only | ] |
# no_dates | |
# CHART
class ChartBuilder
attr_reader :chart
def initialize
@chart = Chart.new
end
def parse(representation)
lines = representation.split("\n")
header = lines.shift
parse_header(header)
lines.each do |line|
parse_line(line)
end
chart.validate
chart
end
def use_work_packages(work_packages)
work_packages.each do |work_package|
chart.add_work_package(work_package.slice(:subject, :start_date, :due_date, :ignore_non_working_days))
end
chart
end
private
def parse_header(header)
_, week_days = header.split(' | ', 2)
unless week_days.include?(Chart::WEEK_DAYS_TEXT)
raise ArgumentError,
"First header line of schedule chart must contain #{Chart::WEEK_DAYS_TEXT} to indicate day names and have an origin"
end
@nb_days_from_origin_monday = week_days.index(Chart::WEEK_DAYS_TEXT.first)
end
def parse_line(line)
case line
when ''
# noop
when / \| /
parse_work_package_line(line)
else
raise "unable to parse line #{line.inspect}"
end
end
def parse_work_package_line(line)
name, timespan, properties = line.split(' | ', 3)
name.strip!
attributes = { subject: name }
attributes.update(parse_timespan(timespan))
chart.add_work_package(attributes)
properties.to_s.split(',').map(&:strip).each do |property|
parse_properties(name, property)
end
end
def parse_properties(name, property)
case property
when /^follows (\w+)(?: with delay (\d+))?/
chart.add_follows_relation(
predecessor: $1.to_sym,
follower: name.to_sym,
delay: $2.to_i
)
when /^child of (\w+)/
chart.add_parent_relation(
parent: $1.to_sym,
child: name.to_sym
)
when /^duration (\d+)/
chart.set_duration(name, $1.to_i)
else
raise "unable to parse property #{property.inspect} for line #{name.inspect}"
end
end
def parse_timespan(timespan)
start_pos = timespan.index('[') || timespan.index('X')
due_pos = timespan.rindex(']') || timespan.rindex('X')
{
start_date: start_pos && (chart.monday - @nb_days_from_origin_monday + start_pos),
due_date: due_pos && (chart.monday - @nb_days_from_origin_monday + due_pos)
}
end
end
end

@ -0,0 +1,67 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ScheduleHelpers
class ChartRepresenter
LINE = "%<id>s | %<days>s |".freeze
def self.normalized_to_s(reference_chart, other_chart)
order = reference_chart.work_package_names
id_column_size = [reference_chart, other_chart].map(&:id_column_size).max
first_day = [reference_chart, other_chart].map(&:first_day).min
last_day = [reference_chart, other_chart].map(&:last_day).max
[reference_chart, other_chart]
.map { |chart| chart.with(order:, id_column_size:, first_day:, last_day:) }
.map(&:to_s)
end
def initialize(id_column_size:, days_column_size:)
@id_column_size = id_column_size
@days_column_size = days_column_size
end
def add_row
rows << []
end
def add_cell(text)
rows.last << text
end
def rows
@rows ||= []
end
def to_s
line_template = "%<id>-#{@id_column_size}s | %<days>-#{@days_column_size}s |"
rows.map do |row|
line_template % { id: row[0], days: row[1] }
end.join("\n")
end
end
end

@ -0,0 +1,98 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ScheduleHelpers
module ExampleMethods
# Change the given work packages according to the given chart representation.
# Work packages are changed without being saved.
#
# For instance:
#
# before do
# change_schedule([main], <<~CHART)
# days | MTWTFSS |
# main | XX |
# CHART
# end
#
# is equivalent to:
#
# before do
# main.start_date = monday
# main.due_date = tuesday
# end
def change_schedule(work_packages, chart)
Chart.for(chart).work_packages_attributes.each do |attributes|
work_package = work_packages.find { |wp| wp.subject == attributes[:subject] }
unless work_package
raise ArgumentError, "no work package with subject #{attributes[:subject]} given; " \
"available work packages are #{work_packages.pluck(:subject).to_sentence}"
end
attributes.slice(:start_date, :due_date).each do |attribute, value|
work_package.send(:"#{attribute}=", value)
end
end
end
# Expect the given work packages to match a visual chart representation.
#
# For instance:
#
# it 'is scheduled' do
# expect_schedule(work_packages, <<~CHART)
# days | MTWTFSS |
# main | XX |
# follower | XXX |
# CHART
# end
#
# is equivalent to:
#
# it 'is scheduled' do
# main = work_packages.find { _1.id == main.id } || main
# expect(main.start_date).to eq(next_monday)
# expect(main.due_date).to eq(next_monday + 1.day)
# follower = work_packages.find { _1.id == follower.id } || follower
# expect(follower.start_date).to eq(next_monday + 2.days)
# expect(follower.due_date).to eq(next_monday + 4.days)
# end
def expect_schedule(work_packages, chart)
by_id = work_packages.index_by(&:id)
chart = Chart.for(chart)
chart.work_packages_attributes.each do |attributes|
name = attributes[:name]
raise ArgumentError, "unable to find WorkPackage :#{name}" unless respond_to?(name)
work_package = send(name)
work_package = by_id[work_package.id] if by_id.has_key?(work_package.id)
expect(work_package).to have_attributes(attributes.slice(:subject, :start_date, :due_date))
end
end
end
end

@ -0,0 +1,107 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ScheduleHelpers
module LetSchedule
# Declare work packages and relations from a visual chart representation.
#
# For instance:
#
# let_schedule(<<~CHART)
# days | MTWTFSS |
# main | XX |
# follower | XXX | follows main
# start_only | [ |
# due_only | ] |
# CHART
#
# is equivalent to:
#
# let!(:schedule_chart) do
# chart = <...parse_chart(CHART)...>
# main
# follower
# start_only
# due_only
# relation_follower_follows_main
# chart
# end
# let(:main) do
# create(:work_package, subject: 'main', start_date: next_monday, due_date: next_monday + 1.day)
# end
# let(:follower) do
# create(:work_package, subject: 'follower', start_date: next_monday + 2.days, due_date: next_monday + 4.days) }
# end
# let(:relation_follower_follows_main) do
# create(:follows_relation, from: follower, to: main, delay: 0) }
# end
# let(:start_only) do
# create(:work_package, subject: 'start_only', start_date: next_monday + 1.day) }
# end
# let(:due_only) do
# create(:work_package, subject: 'due_only', due_date: next_monday + 3.days) }
# end
def let_schedule(chart_representation, **extra_attributes)
# To be able to use `travel_to` in a before hook, the dates in the chart
# must be lazy evaluated in a let statement.
let(:schedule_chart) { Chart.for(chart_representation) }
let!(:__evaluate_work_packages_from_schedule_chart) do
schedule_chart.work_package_names.each do |name|
# force evaluation of work package
send(name)
schedule_chart.predecessors_by_follower(name).each do |predecessor|
# force evaluation of relation
send("relation_#{name}_follows_#{predecessor}")
end
end
end
# we still need to parse the chart to get the work package names and relations
chart = Chart.for(chart_representation)
chart.work_package_names.each do |name|
let(name) do
attributes = schedule_chart
.work_package_attributes(name)
.excluding(:name)
.reverse_merge(extra_attributes)
.merge(parent: schedule_chart.parent(name) ? send(schedule_chart.parent(name)) : nil)
create(:work_package, attributes)
end
chart.predecessors_by_follower(name).each do |predecessor|
relation_alias = "relation_#{name}_follows_#{predecessor}"
let(relation_alias) do
create(:follows_relation,
from: send(name),
to: send(predecessor),
delay: schedule_chart.delay_between(predecessor:, follower: name))
end
end
end
end
end
end

@ -0,0 +1,171 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe ScheduleHelpers::ChartBuilder do
include ActiveSupport::Testing::TimeHelpers
let(:fake_today) { Date.new(2022, 6, 16) } # Thursday 16 June 2022
let(:monday) { Date.new(2022, 6, 20) } # Monday 20 June
let(:tuesday) { Date.new(2022, 6, 21) }
let(:wednesday) { Date.new(2022, 6, 22) }
let(:thursday) { Date.new(2022, 6, 23) }
let(:friday) { Date.new(2022, 6, 24) }
let(:saturday) { Date.new(2022, 6, 25) }
let(:sunday) { Date.new(2022, 6, 26) }
subject(:builder) { described_class.new }
describe 'happy path' do
let(:next_tuesday) { tuesday + 7.days }
before do
travel_to(fake_today)
end
it 'reads a chart and convert it into objects with attributes' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
main | XX |
other | XX..XX |
follower | XXX | follows main
start_only | [ |
due_only | ] |
no_dates | |
CHART
expect(chart.work_packages_attributes).to eq(
[
{ name: :main, subject: 'main', start_date: monday, due_date: tuesday },
{ name: :other, subject: 'other', start_date: thursday, due_date: next_tuesday },
{ name: :follower, subject: 'follower', start_date: wednesday, due_date: friday },
{ name: :start_only, subject: 'start_only', start_date: tuesday, due_date: nil },
{ name: :due_only, subject: 'due_only', start_date: nil, due_date: friday },
{ name: :no_dates, subject: 'no_dates', start_date: nil, due_date: nil }
]
)
expect(chart.predecessors_by_follower(:main)).to eq([])
expect(chart.predecessors_by_follower(:other)).to eq([])
expect(chart.predecessors_by_follower(:follower)).to eq([:main])
end
end
describe 'origin day' do
before do
travel_to(fake_today)
end
it 'is identified by the M in MTWTFSS and corresponds to next monday' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
CHART
expect(chart.monday).to eq(monday)
expect(chart.monday).to eq(chart.first_day)
end
it 'is not identified by mtwtfss which can be used as documentation instead' do
chart = builder.parse(<<~CHART)
days | mtwtfssMTWTFSSmtwtfss |
wp | X |
CHART
expect(chart.monday).to eq(monday)
expect(chart.first_day).to eq(chart.work_package_attributes(:wp)[:start_date])
end
end
describe 'properties' do
describe 'follows <name>' do
it 'adds a follows relation to the named' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
main | |
follower | | follows main
CHART
expect(chart.predecessors_by_follower(:follower)).to eq([:main])
expect(chart.delay_between(predecessor: :main, follower: :follower)).to eq(0)
end
it 'can be declared in any order' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
follower | | follows main
main | |
CHART
expect(chart.predecessors_by_follower(:follower)).to eq([:main])
expect(chart.delay_between(predecessor: :main, follower: :follower)).to eq(0)
end
end
describe 'follows <name> with delay <n>' do
it 'adds a follows relation to the named with a delay' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
main | |
follower | | follows main with delay 3
CHART
expect(chart.predecessors_by_follower(:follower)).to eq([:main])
expect(chart.delay_between(predecessor: :main, follower: :follower)).to eq(3)
end
end
describe 'child of <name>' do
it 'sets the parent to the named one' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
parent | | child of grandparent
main | | child of parent
grandparent | |
CHART
expect(chart.parent(:grandparent)).to be_nil
expect(chart.parent(:parent)).to eq(:grandparent)
expect(chart.parent(:main)).to eq(:parent)
end
end
describe 'duration <int>' do
it 'sets the duration of the work package' do
chart = builder.parse(<<~CHART)
days | MTWTFSS |
main | | duration 3
CHART
expect(chart.work_package_attributes(:main)).to include(duration: 3)
end
end
end
describe 'error handling' do
it 'raises an error if the relation references a non-existing work package predecessor' do
expect do
builder.parse(<<~CHART)
| MTWTFSS |
follower | XX | follows main
CHART
end.to raise_error(RuntimeError, /unable to find predecessor :main in property "follows main" for work package :follower/)
end
end
end

@ -0,0 +1,192 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe ScheduleHelpers::ChartRepresenter do
let(:fake_today) { Date.new(2022, 6, 16) } # Thursday 16 June 2022
let(:monday) { Date.new(2022, 6, 20) } # Monday 20 June
let(:tuesday) { Date.new(2022, 6, 21) }
let(:wednesday) { Date.new(2022, 6, 22) }
let(:thursday) { Date.new(2022, 6, 23) }
let(:friday) { Date.new(2022, 6, 24) }
let(:saturday) { Date.new(2022, 6, 25) }
let(:sunday) { Date.new(2022, 6, 26) }
describe '#normalized_to_s' do
let!(:week_days) { create(:week_days) }
context 'when both charts have different work packages items and/or order' do
def to_first_columns(charts)
charts.map { _1.split("\n").map(&:split).map(&:first).join(' ') }
end
it 'returns charts ascii with work packages in same order as the first given chart' do
initial_expected_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | X..X |
other | XXX..X |
CHART
initial_actual_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
other | XXX..X |
main | X..X |
CHART
expected_column, actual_column =
described_class
.normalized_to_s(initial_expected_chart, initial_actual_chart)
.then(&method(:to_first_columns))
expect(actual_column).to eq(expected_column)
end
it 'pushes extra elements of the second chart at the end' do
initial_expected_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | X..X |
other | XXX..X |
CHART
initial_actual_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
extra | |
main | X..X |
other | XXX..X |
CHART
expected_column, actual_column =
described_class
.normalized_to_s(initial_expected_chart, initial_actual_chart)
.then(&method(:to_first_columns))
expect(expected_column).to eq('days main other')
expect(actual_column).to eq('days main other extra')
end
it 'keeps extra elements of the first chart at the same place' do
initial_expected_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | X..X |
extra | |
other | XXX..X |
CHART
initial_actual_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | X..X |
other | XXX..X |
CHART
expected_column, actual_column =
described_class
.normalized_to_s(initial_expected_chart, initial_actual_chart)
.then(&method(:to_first_columns))
expect(expected_column).to eq('days main extra other')
expect(actual_column).to eq('days main other')
end
end
context 'when both charts have different first column width' do
def to_first_cells(charts)
charts.map { _1.split("\n").first.split(" | ").first }
end
it 'returns charts ascii with identical first column width' do
tiny_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
tiny name | XX |
CHART
longer_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
much longer name | XX |
CHART
# tiny_chart as reference chart
first_cell, second_cell =
described_class
.normalized_to_s(tiny_chart, longer_chart)
.then(&method(:to_first_cells))
expect(first_cell).to eq('days ')
expect(first_cell).to eq(second_cell)
# tiny_chart as reference chart
first_cell, second_cell =
described_class
.normalized_to_s(longer_chart, tiny_chart)
.then(&method(:to_first_cells))
expect(first_cell).to eq('days ')
expect(first_cell).to eq(second_cell)
end
end
context 'when both charts cover different time periods' do
def to_headers(charts)
charts.map { _1.split("\n").first }
end
it 'returns charts ascii with identical time periods' do
larger_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | XXXXXXXXXXX |
CHART
shorter_chart =
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | XXX |
CHART
# larger_chart as reference
first_header, second_header =
described_class
.normalized_to_s(larger_chart, shorter_chart)
.then(&method(:to_headers))
expect(first_header).to eq(second_header)
# shorter_chart as reference
first_header, second_header =
described_class
.normalized_to_s(shorter_chart, larger_chart)
.then(&method(:to_headers))
expect(first_header).to eq(second_header)
end
end
end
end

@ -0,0 +1,227 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe ScheduleHelpers::Chart do
include ActiveSupport::Testing::TimeHelpers
let(:fake_today) { Date.new(2022, 6, 16) } # Thursday 16 June 2022
let(:monday) { Date.new(2022, 6, 20) } # Monday 20 June
let(:tuesday) { Date.new(2022, 6, 21) }
let(:wednesday) { Date.new(2022, 6, 22) }
let(:thursday) { Date.new(2022, 6, 23) }
let(:friday) { Date.new(2022, 6, 24) }
let(:saturday) { Date.new(2022, 6, 25) }
let(:sunday) { Date.new(2022, 6, 26) }
subject(:chart) { described_class.new }
before do
travel_to(fake_today)
end
describe '#first_day' do
context 'without work packages' do
it 'returns the first day represented on the graph, which is next Monday' do
expect(chart.first_day).to eq(monday)
end
end
context 'with work packages' do
it 'returns the minimum between work packages dates and origin Monday' do
expect(chart.first_day).to eq(monday)
chart.add_work_package(subject: 'wp1', start_date: tuesday)
expect(chart.first_day).to eq(monday)
chart.add_work_package(subject: 'wp2', start_date: monday - 3.days)
expect(chart.first_day).to eq(monday - 3.days)
chart.add_work_package(subject: 'wp3', start_date: sunday)
expect(chart.first_day).to eq(monday - 3.days)
chart.add_work_package(subject: 'wp4', due_date: monday - 6.days)
expect(chart.first_day).to eq(monday - 6.days)
end
end
it 'can be set to an earlier date by setting the origin monday to an earlier date' do
expect(chart.first_day).to eq(monday)
# no change when origin is moved forward
expect { chart.monday = monday + 14.days }
.not_to change(chart, :first_day)
# change when origin is moved backward
expect { chart.monday = monday - 14.days }
.to change(chart, :first_day).to(monday - 14.days)
end
end
describe '#last_day' do
context 'without work packages' do
it 'returns the last day represented on the graph, which is the Sunday following origin Monday' do
expect(chart.last_day).to eq(sunday)
end
end
context 'with work packages' do
it 'returns the maximum between work packages dates and the Sunday following origin Monday' do
expect(chart.last_day).to eq(sunday)
chart.add_work_package(subject: 'wp1', due_date: tuesday + 7.days)
expect(chart.last_day).to eq(tuesday + 7.days)
chart.add_work_package(subject: 'wp2', start_date: monday - 3.days)
expect(chart.last_day).to eq(tuesday + 7.days)
chart.add_work_package(subject: 'wp3', start_date: monday + 20.days)
expect(chart.last_day).to eq(monday + 20.days)
end
end
it 'can be set to an later date by setting the origin Monday to a later date' do
expect(chart.last_day).to eq(sunday)
# no change when origin is moved backward
expect { chart.monday = monday - 14.days }
.not_to change(chart, :last_day)
# change when origin is moved forward
expect { chart.monday = monday + 14.days }
.to change(chart, :last_day).to(sunday + 14.days)
end
end
describe '#set_duration' do
it 'sets the duration for a work package' do
chart.add_work_package(subject: 'wp')
chart.set_duration('wp', 3)
expect(chart.work_package_attributes('wp')).to include(duration: 3)
end
it 'must set the duration to a positive integer' do
chart.add_work_package(subject: 'wp')
expect { chart.set_duration('wp', 0) }
.to raise_error(ArgumentError, 'unable to set duration for wp: duration must be a positive integer (got 0)')
expect { chart.set_duration('wp', -5) }
.to raise_error(ArgumentError, 'unable to set duration for wp: duration must be a positive integer (got -5)')
expect { chart.set_duration('wp', 'hello') }
.to raise_error(ArgumentError, 'unable to set duration for wp: duration must be a positive integer (got "hello")')
expect { chart.set_duration('wp', '42') }
.to raise_error(ArgumentError, 'unable to set duration for wp: duration must be a positive integer (got "42")')
end
it 'cannot set the duration if the work package has dates' do
chart.add_work_package(subject: 'wp_start', start_date: monday)
expect { chart.set_duration('wp_start', 3) }
.to raise_error(ArgumentError, 'unable to set duration for wp_start: start_date is set')
chart.add_work_package(subject: 'wp_due', due_date: monday)
expect { chart.set_duration('wp_due', 3) }
.to raise_error(ArgumentError, 'unable to set duration for wp_due: due_date is set')
chart.add_work_package(subject: 'wp_both', start_date: monday, due_date: monday)
expect { chart.set_duration('wp_both', 3) }
.to raise_error(ArgumentError, 'unable to set duration for wp_both: start_date and due_date is set')
end
end
describe '#to_s', with_flag: { work_packages_duration_field_active: true } do
let!(:week_days) { create(:week_days) }
context 'with a chart built from ascii representation' do
let(:chart) do
ScheduleHelpers::ChartBuilder.new.parse(<<~CHART)
days | MTWTFSS |
main | X..X |
other | XXX..X |
follower | XXX | follows main
start_only | [ |
due_only | ] |
no_dates | |
CHART
end
it 'returns the same ascii representation without properties information' do
expect(chart.to_s).to eq(<<~CHART.chomp)
days | MTWTFSS |
main | X..X |
other | XXX..X |
follower | XXX |
start_only | [ |
due_only | ] |
no_dates | |
CHART
end
end
context 'with a chart built from real work packages' do
let(:work_package1) { build_stubbed(:work_package, subject: 'main', start_date: monday, due_date: tuesday) }
let(:work_package2) do
build_stubbed(:work_package, subject: 'working_days', ignore_non_working_days: false,
start_date: tuesday, due_date: monday + 7.days)
end
let(:work_package2bis) do
build_stubbed(:work_package, subject: 'all_days', ignore_non_working_days: true,
start_date: tuesday, due_date: monday + 7.days)
end
let(:work_package3) { build_stubbed(:work_package, subject: 'start_only', start_date: monday - 3.days) }
let(:work_package4) { build_stubbed(:work_package, subject: 'due_only', due_date: wednesday) }
let(:work_package5) { build_stubbed(:work_package, subject: 'no_dates') }
let(:chart) do
ScheduleHelpers::ChartBuilder.new.use_work_packages(
[
work_package1,
work_package2,
work_package2bis,
work_package3,
work_package4,
work_package5
]
)
end
it 'returns the same ascii representation without properties information' do
expect(chart.to_s).to eq(<<~CHART.chomp)
days | MTWTFSS |
main | XX |
working_days | XXXX..X |
all_days | XXXXXXX |
start_only | [ |
due_only | ] |
no_dates | |
CHART
end
end
end
end

@ -0,0 +1,153 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe ScheduleHelpers::ExampleMethods do
include ActiveSupport::Testing::TimeHelpers
create_shared_association_defaults_for_work_package_factory
let(:fake_today) { Date.new(2022, 6, 16) } # Thursday 16 June 2022
let(:monday) { Date.new(2022, 6, 20) } # Monday 20 June
let(:tuesday) { Date.new(2022, 6, 21) }
let(:wednesday) { Date.new(2022, 6, 22) }
let(:thursday) { Date.new(2022, 6, 23) }
let(:friday) { Date.new(2022, 6, 24) }
let(:saturday) { Date.new(2022, 6, 25) }
let(:sunday) { Date.new(2022, 6, 26) }
describe 'expect_schedule' do
let_schedule(<<~CHART)
| MTWTFSS |
main | XX |
other | XXX |
CHART
it 'checks the work packages properties according to the given work packages and chart representation' do
expect do
expect_schedule([main, other], <<~CHART)
| MTWTFSS |
main | XX |
other | XXX |
CHART
end.not_to raise_error
end
it 'raises an error if start_date is wrong' do
expect do
expect_schedule([main], <<~CHART)
| MTWTFSS |
main | X |
CHART
end.to raise_error(RSpec::Expectations::ExpectationNotMetError)
end
it 'raises an error if due_date is wrong' do
expect do
expect_schedule([main], <<~CHART)
| MTWTFSS |
main | XXXXX |
CHART
end.to raise_error(RSpec::Expectations::ExpectationNotMetError)
end
it 'raises an error if no work package exists for a given name' do
expect do
expect_schedule([main], <<~CHART)
| MTWTFSS |
unknown | XX |
CHART
end.to raise_error(ArgumentError, "unable to find WorkPackage :unknown")
end
it 'checks against the given work packages rather than the ones from the let! definitions' do
expect do
expect_schedule([], <<~CHART)
| MTWTFSS |
main | XX |
CHART
end.not_to raise_error
end
it 'uses the work package from the let! definitions if it is not given as parameter' do
a_modified_instance_of_main = WorkPackage.find(main.id)
a_modified_instance_of_main.due_date += 2.days
expect do
expect_schedule([main], <<~CHART)
| MTWTFSS |
main | XX |
CHART
end.not_to raise_error
expect do
expect_schedule([a_modified_instance_of_main], <<~CHART)
| MTWTFSS |
main | XXXX |
CHART
end.not_to raise_error
expect do
expect_schedule([], <<~CHART)
| MTWTFSS |
main | XX |
CHART
end.not_to raise_error
end
end
describe 'change_schedule' do
before do
travel_to(fake_today)
end
it 'applies dates changes to a group of work packages from a visual chart representation' do
main = build_stubbed(:work_package, subject: 'main')
second = build_stubbed(:work_package, subject: 'second')
change_schedule([main, second], <<~CHART)
days | MTWTFSS |
main | XX |
second | XX |
CHART
expect(main.start_date).to eq(monday)
expect(main.due_date).to eq(tuesday)
expect(second.start_date).to eq(thursday)
expect(second.due_date).to eq(friday)
end
it 'does not save changes' do
main = create(:work_package, subject: 'main')
expect(main.persisted?).to be(true)
expect(main.has_changes_to_save?).to be(false)
change_schedule([main], <<~CHART)
days | MTWTFSS |
main | XX |
CHART
expect(main.has_changes_to_save?).to be(true)
expect(main.changes).to eq('start_date' => [nil, monday], 'due_date' => [nil, tuesday])
end
end
end

@ -0,0 +1,100 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
describe ScheduleHelpers::LetSchedule do
include ActiveSupport::Testing::TimeHelpers
create_shared_association_defaults_for_work_package_factory
let(:fake_today) { Date.new(2022, 6, 16) } # Thursday 16 June 2022
let(:monday) { Date.new(2022, 6, 20) } # Monday 20 June
let(:tuesday) { Date.new(2022, 6, 21) }
let(:wednesday) { Date.new(2022, 6, 22) }
let(:thursday) { Date.new(2022, 6, 23) }
let(:friday) { Date.new(2022, 6, 24) }
let(:saturday) { Date.new(2022, 6, 25) }
let(:sunday) { Date.new(2022, 6, 26) }
describe 'let_schedule' do
let_schedule(<<~CHART)
days | MTWTFSS |
main | XX |
follower | XXX | follows main with delay 2
child | | child of main
CHART
it 'creates let! call for :schedule_chart which returns the chart' do
next_monday = (Time.zone.today..(Time.zone.today + 7.days)).find { |d| d.wday == 1 }
expect(schedule_chart.first_day).to eq(next_monday)
end
it 'creates let! calls for each work package' do
expect([main, follower, child]).to all(be_an_instance_of(WorkPackage))
expect([main, follower, child]).to all(be_persisted)
expect(main).to have_attributes(
subject: 'main',
start_date: schedule_chart.monday,
due_date: schedule_chart.monday + 1.day
)
expect(follower).to have_attributes(
subject: 'follower',
start_date: schedule_chart.monday + 2.days,
due_date: schedule_chart.monday + 4.days
)
expect(child).to have_attributes(
subject: 'child',
start_date: nil,
due_date: nil
)
end
it 'creates let! calls for follows relations between work packages' do
expect(follower.follows_relations.count).to eq(1)
expect(relation_follower_follows_main).to be_an_instance_of(Relation)
expect(relation_follower_follows_main.delay).to eq(2)
end
it 'creates parent / child relations' do
expect(child.parent).to eq(main)
end
context 'with additional attributes' do
let_schedule(<<~CHART, done_ratio: 50, schedule_manually: true)
days | MTWTFSS |
main | XX |
follower | XXX | follows main
CHART
it 'applies additional attributes to all created work packages' do
expect([main, follower]).to all(have_attributes(done_ratio: 50, schedule_manually: true))
end
end
end
end
Loading…
Cancel
Save