Merge pull request #10815 from opf/fix/fix_follower_of_parent_scheduling_bug
Fix scheduling dependency bugpull/10816/head
commit
02b6d206bd
@ -0,0 +1,130 @@ |
||||
#-- 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. |
||||
#++ |
||||
|
||||
class WorkPackages::ScheduleDependency::Dependency |
||||
def initialize(work_package, schedule_dependency) |
||||
self.schedule_dependency = schedule_dependency |
||||
self.work_package = work_package |
||||
end |
||||
|
||||
def ancestors |
||||
@ancestors ||= ancestors_from_preloaded(work_package) |
||||
end |
||||
|
||||
def descendants |
||||
@descendants ||= descendants_from_preloaded(work_package) |
||||
end |
||||
|
||||
def follows_moved |
||||
@follows_moved ||= moved_predecessors_from_preloaded(work_package) |
||||
end |
||||
|
||||
def follows_unmoved |
||||
@follows_unmoved ||= unmoved_predecessors_from_preloaded(work_package) |
||||
end |
||||
|
||||
attr_accessor :work_package, |
||||
:schedule_dependency |
||||
|
||||
# Returns the work package ids that the work package directly depends on. |
||||
# |
||||
# The dates of a work package depend on its descendants and predecessors |
||||
# dates. |
||||
def dependent_ids |
||||
@dependent_ids ||= (descendants + follows_moved.map(&:to)).map(&:id) |
||||
end |
||||
|
||||
def max_date_of_followed |
||||
(follows_moved + follows_unmoved) |
||||
.map(&:successor_soonest_start) |
||||
.compact |
||||
.max |
||||
end |
||||
|
||||
def start_date |
||||
descendants_dates.min |
||||
end |
||||
|
||||
def due_date |
||||
descendants_dates.max |
||||
end |
||||
|
||||
private |
||||
|
||||
def descendants_dates |
||||
(descendants.map(&:due_date) + descendants.map(&:start_date)).compact |
||||
end |
||||
|
||||
def ancestors_from_preloaded(work_package) |
||||
parent = known_work_packages_by_id[work_package.parent_id] |
||||
|
||||
if parent |
||||
[parent] + ancestors_from_preloaded(parent) |
||||
else |
||||
[] |
||||
end |
||||
end |
||||
|
||||
def descendants_from_preloaded(work_package) |
||||
children = known_work_packages_by_parent_id[work_package.id] || [] |
||||
|
||||
children + children.map { |child| descendants_from_preloaded(child) }.flatten |
||||
end |
||||
|
||||
delegate :known_work_packages_by_id, |
||||
:known_work_packages_by_parent_id, |
||||
:scheduled_work_packages_by_id, to: :schedule_dependency |
||||
|
||||
def scheduled_work_packages |
||||
schedule_dependency.work_packages + schedule_dependency.dependencies.keys |
||||
end |
||||
|
||||
def moved_predecessors_from_preloaded(work_package) |
||||
([work_package] + ancestors + descendants) |
||||
.map(&:follows_relations) |
||||
.flatten |
||||
.map do |relation| |
||||
scheduled = scheduled_work_packages_by_id[relation.to_id] |
||||
|
||||
if scheduled |
||||
relation.to = scheduled |
||||
relation |
||||
end |
||||
end |
||||
.compact |
||||
end |
||||
|
||||
def unmoved_predecessors_from_preloaded(work_package) |
||||
([work_package] + ancestors + descendants) |
||||
.map(&:follows_relations) |
||||
.flatten |
||||
.reject do |relation| |
||||
scheduled_work_packages_by_id[relation.to_id].present? |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,69 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2022 the OpenProject GmbH |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See COPYRIGHT and LICENSE files for more details. |
||||
#++ |
||||
|
||||
class WorkPackages::ScheduleDependency::DependencyGraph |
||||
attr_reader :dependencies |
||||
|
||||
def initialize(dependencies) |
||||
@dependencies = dependencies |
||||
@dependent_ids = dependencies.to_h { |dep| [dep.work_package.id, dep.dependent_ids.uniq] } |
||||
end |
||||
|
||||
def schedule_order |
||||
schedule_order = [] |
||||
dependencies.each do |dependency| |
||||
# Find the index of the last dependency the dependency needs to come after. |
||||
index = schedule_order.rindex do |inserted_dependency| |
||||
depends_on?(dependency.work_package, inserted_dependency) |
||||
end |
||||
|
||||
if index |
||||
schedule_order.insert(index + 1, dependency) |
||||
else |
||||
schedule_order.unshift(dependency) |
||||
end |
||||
end |
||||
schedule_order |
||||
end |
||||
|
||||
# Returns true if the given work package depends on the work package of the |
||||
# dependency, either directly or transitively. |
||||
def depends_on?(work_package, dependency) |
||||
to_process_ids = [work_package.id] |
||||
processed_ids = Set.new |
||||
while id = to_process_ids.shift |
||||
processed_ids.add(id) |
||||
dependent_ids = @dependent_ids[id] |
||||
next if dependent_ids.nil? |
||||
return true if dependent_ids.include?(dependency.work_package.id) |
||||
|
||||
to_process_ids.concat(dependent_ids.without(processed_ids)) |
||||
end |
||||
false |
||||
end |
||||
end |
@ -0,0 +1,160 @@ |
||||
#-- 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::ScheduleDependency::Dependency do |
||||
subject { described_class.new(work_package, schedule_dependency) } |
||||
|
||||
let(:work_package) { create(:work_package, subject: 'subject') } |
||||
let(:schedule_dependency) { instance_double(WorkPackages::ScheduleDependency) } |
||||
let(:known_work_packages_by_parent_id) { Hash.new { |h, k| h[k] = [] } } |
||||
let(:known_work_packages_by_id) { { work_package.id => work_package } } |
||||
|
||||
before do |
||||
allow(schedule_dependency) |
||||
.to receive(:known_work_packages_by_parent_id) |
||||
.and_return(known_work_packages_by_parent_id) |
||||
allow(schedule_dependency) |
||||
.to receive(:known_work_packages_by_id) |
||||
.and_return(known_work_packages_by_id) |
||||
allow(schedule_dependency) |
||||
.to receive(:scheduled_work_packages_by_id) |
||||
.and_return(known_work_packages_by_id) |
||||
end |
||||
|
||||
def create_predecessor_of(work_package) |
||||
create(:work_package, subject: "predecessor of #{work_package.subject}").tap do |predecessor| |
||||
create(:follows_relation, from: work_package, to: predecessor) |
||||
known_work_packages_by_id[predecessor.id] = predecessor |
||||
end |
||||
end |
||||
|
||||
def create_follower_of(work_package) |
||||
create(:work_package, subject: "follower of #{work_package.subject}").tap do |follower| |
||||
create(:follows_relation, from: follower, to: work_package) |
||||
known_work_packages_by_id[follower.id] = follower |
||||
end |
||||
end |
||||
|
||||
def create_parent_of(work_package) |
||||
create(:work_package, subject: "parent of #{work_package.subject}", parent: work_package).tap do |parent| |
||||
known_work_packages_by_id[parent.id] = parent |
||||
known_work_packages_by_parent_id[work_package.parent_id] << parent |
||||
end |
||||
end |
||||
|
||||
def create_child_of(work_package) |
||||
create(:work_package, subject: "child of #{work_package.subject}", parent: work_package).tap do |child| |
||||
known_work_packages_by_id[child.id] = child |
||||
known_work_packages_by_parent_id[child.parent_id] << child |
||||
end |
||||
end |
||||
|
||||
describe '#dependent_ids' do |
||||
context 'when the work_package is not related to anything' do |
||||
it 'returns empty array' do |
||||
expect(subject.dependent_ids).to eq([]) |
||||
end |
||||
end |
||||
|
||||
context 'when the work_package has a predecessor' do |
||||
let!(:predecessor) { create_predecessor_of(work_package) } |
||||
|
||||
it 'returns an array with the predecessor id' do |
||||
expect(subject.dependent_ids).to eq([predecessor.id]) |
||||
end |
||||
end |
||||
|
||||
context 'when the work_package has a follower' do |
||||
let!(:follower) { create_follower_of(work_package) } |
||||
|
||||
it 'returns empty array' do |
||||
expect(subject.dependent_ids).to eq([]) |
||||
end |
||||
end |
||||
|
||||
context 'when the work_package has a parent' do |
||||
let!(:parent) { create_parent_of(work_package) } |
||||
|
||||
it 'returns empty array' do |
||||
expect(subject.dependent_ids).to eq([]) |
||||
end |
||||
end |
||||
|
||||
context 'when the work_package has a child' do |
||||
let!(:child) { create_child_of(work_package) } |
||||
|
||||
it 'returns an array with the child id' do |
||||
expect(subject.dependent_ids).to eq([child.id]) |
||||
end |
||||
end |
||||
|
||||
context 'when the work_package has multiple children and predecessors' do |
||||
let!(:child1) { create_child_of(work_package) } |
||||
let!(:child2) { create_child_of(work_package) } |
||||
let!(:predecessor1) { create_predecessor_of(work_package) } |
||||
let!(:predecessor2) { create_predecessor_of(work_package) } |
||||
|
||||
it 'returns an array with the children and the predecessors ids' do |
||||
expect(subject.dependent_ids).to contain_exactly(child1.id, child2.id, predecessor1.id, predecessor2.id) |
||||
end |
||||
end |
||||
|
||||
context 'with more complex relations' do |
||||
context 'when has a child which has a child' do |
||||
let!(:child) { create_child_of(work_package) } |
||||
let!(:child_child) { create_child_of(child) } |
||||
|
||||
it 'returns an array with both children ids' do |
||||
expect(subject.dependent_ids).to contain_exactly(child.id, child_child.id) |
||||
end |
||||
end |
||||
|
||||
context 'when has a predecessor which has a predecessor and a follower' do |
||||
let!(:predecessor) { create_predecessor_of(work_package) } |
||||
let!(:predecessor_predecessor) { create_predecessor_of(predecessor) } |
||||
let!(:predecessor_follower) { create_follower_of(predecessor) } |
||||
|
||||
it 'returns an array with the first predecessor only (not transient)' do |
||||
expect(subject.dependent_ids).to contain_exactly(predecessor.id) |
||||
end |
||||
end |
||||
|
||||
context 'when has a predecessor which has a parent and a child' do |
||||
let!(:predecessor) { create_predecessor_of(work_package) } |
||||
let!(:predecessor_parent) { create_parent_of(predecessor) } |
||||
let!(:predecessor_child) { create_child_of(predecessor) } |
||||
|
||||
it 'returns an array with the predecessor only (not transient)' do |
||||
expect(subject.dependent_ids).to contain_exactly(predecessor.id) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue