Merge pull request #10851 from opf/implementation/41821-non-working-days-and-duration-in-scheduling
[#41821] Non working days & duration in schedulingpull/10882/head
commit
2aed0da669
@ -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 |
File diff suppressed because it is too large
Load Diff
@ -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 |
@ -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…
Reference in new issue