From 731bed7811bdcc0cfa01778ef3539f35cb36bb0c Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 20 Nov 2024 12:38:42 +0100 Subject: [PATCH 01/33] [42388] Migrate scheduling mode and lags https://community.openproject.org/wp/42388 Scheduling mode is now manual by default. Only successors will be in automatic mode. WIP --- .../automatic_mode/migrate_values_job.rb | 109 ++++++++++++++++ config/locales/en.yml | 2 + ...0095318_update_scheduling_mode_and_lags.rb | 10 ++ .../update_scheduling_mode_and_lags_spec.rb | 117 ++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 app/workers/work_packages/automatic_mode/migrate_values_job.rb create mode 100644 db/migrate/20241120095318_update_scheduling_mode_and_lags.rb create mode 100644 spec/migrations/update_scheduling_mode_and_lags_spec.rb diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb new file mode 100644 index 000000000000..887653409352 --- /dev/null +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -0,0 +1,109 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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::AutomaticMode::MigrateValuesJob < ApplicationJob + def perform + with_temporary_table do + change_scheduling_mode_to_manual_mode + copy_values_to_work_packages_and_update_journals + end + end + + private + + def with_temporary_table + WorkPackage.transaction do + create_temporary_table + yield + ensure + drop_temporary_table + end + end + + def create_temporary_table + execute(<<~SQL.squish) + CREATE UNLOGGED TABLE temp_wp_values + AS SELECT + id, + start_date, + due_date, + schedule_manually + FROM work_packages + SQL + end + + def drop_temporary_table + execute(<<~SQL.squish) + DROP TABLE temp_wp_values + SQL + end + + def change_scheduling_mode_to_manual_mode + execute(<<~SQL.squish) + UPDATE temp_wp_values + SET schedule_manually = true + SQL + end + + def copy_values_to_work_packages_and_update_journals + updated_work_package_ids = copy_values_to_work_packages + create_journals_for_updated_work_packages(updated_work_package_ids) + end + + def copy_values_to_work_packages + results = execute(<<~SQL.squish) + UPDATE work_packages + SET schedule_manually = temp_wp_values.schedule_manually, + lock_version = lock_version + 1, + updated_at = NOW() + FROM temp_wp_values + WHERE work_packages.id = temp_wp_values.id + AND work_packages.schedule_manually IS DISTINCT FROM temp_wp_values.schedule_manually + RETURNING work_packages.id + SQL + results.column_values(0) + end + + def create_journals_for_updated_work_packages(updated_work_package_ids) + cause = { type: "system_update", feature: "scheduling_mode_adjusted" } + WorkPackage.where(id: updated_work_package_ids).find_each do |work_package| + Journals::CreateService + .new(work_package, system_user) + .call(cause:) + end + end + + # Executes an sql statement, shorter. + def execute(sql) + ActiveRecord::Base.connection.execute(sql) + end + + def system_user + @system_user ||= User.system + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1dbbe5934972..9d8b7c414ab9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2198,6 +2198,8 @@ en: Progress calculation automatically set to work-based mode and adjusted with version update. progress_calculation_adjusted: >- Progress calculation automatically adjusted with version update. + scheduling_mode_adjusted: >- + Scheduling mode automatically adjusted with version update. totals_removed_from_childless_work_packages: >- Work and progress totals automatically removed for non-parent work packages with version update. This is a maintenance task and can be safely ignored. diff --git a/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb new file mode 100644 index 000000000000..bd3d0ae94534 --- /dev/null +++ b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb @@ -0,0 +1,10 @@ +class UpdateSchedulingModeAndLags < ActiveRecord::Migration[7.1] + def up + migration_job = WorkPackages::AutomaticMode::MigrateValuesJob + if Rails.env.development? + migration_job.perform_now + else + migration_job.perform_later + end + end +end diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb new file mode 100644 index 000000000000..89da634d80b1 --- /dev/null +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -0,0 +1,117 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" +require Rails.root.join("db/migrate/20241120095318_update_scheduling_mode_and_lags.rb") + +RSpec.describe UpdateSchedulingModeAndLags, type: :model do + # Silencing migration logs, since we are not interested in that during testing + subject(:run_migration) do + perform_enqueued_jobs do + ActiveRecord::Migration.suppress_messages { described_class.new.up } + end + end + + shared_let(:author) { create(:user) } + shared_let(:priority) { create(:priority, name: "Normal") } + shared_let(:project) { create(:project, name: "Main project") } + shared_let(:status_new) { create(:status, name: "New") } + + before_all do + set_factory_default(:user, author) + set_factory_default(:priority, priority) + set_factory_default(:project, project) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status_new) + end + + describe "journal creation" do + context "when scheduling mode is changed by the migration" do + let_work_packages(<<~TABLE) + subject | schedule manually + wp already manual | true + wp automatic | false + TABLE + + before do + run_migration + end + + it "creates a journal entry only for the changed work packages" do + expect(wp_already_manual.journals.count).to eq(1) + expect(wp_automatic.journals.count).to eq(2) + + expect(wp_automatic.last_journal.get_changes) + .to include("schedule_manually" => [false, true], + "cause" => [nil, { "feature" => "scheduling_mode_adjusted", "type" => "system_update" }]) + + aggregate_failures "the journal author is the system user" do + journal = wp_automatic.last_journal + expect(journal.user).to eq(User.system) + end + + aggregate_failures "the lock_version of the work package is incremented" do + previous_lock_version = wp_automatic.lock_version + wp_automatic.reload + expect(wp_automatic.lock_version).to be > previous_lock_version + end + + aggregate_failures "changes the updated_at of the work package" do + expect(wp_automatic.updated_at).not_to eq(wp_automatic.created_at) + expect(wp_automatic.updated_at).to be > wp_automatic.created_at + + first_journal, last_journal = wp_automatic.journals + expect(wp_automatic.updated_at).not_to eq(first_journal.updated_at) + expect(wp_automatic.updated_at).to eq(last_journal.updated_at) + end + end + end + end + + # spec from #42388, "Migration from an earlier version" section + context "for work packages with no predecessors" do + let_work_packages(<<~TABLE) + subject | start date | due date | schedule manually + wp automatic 1 | 2024-11-20 | 2024-11-21 | false + wp automatic 2 | | 2024-11-21 | false + wp automatic 3 | 2024-11-20 | | false + wp automatic 4 | | | false + wp manual 1 | 2024-11-20 | 2024-11-21 | true + wp manual 2 | | 2024-11-21 | true + wp manual 3 | 2024-11-20 | | true + wp manual 4 | | | true + TABLE + + it "switches to manual scheduling" do + run_migration + + table_work_packages.map(&:reload) + expect(table_work_packages).to all(be_schedule_manually) + end + end +end From 7003b2363825a642395ada1f33dbba52edd411d2 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 22 Nov 2024 11:03:38 +0100 Subject: [PATCH 02/33] Define a 'scheduling mode' column in table_helpers More expressive code. --- .../update_scheduling_mode_and_lags_spec.rb | 24 ++--- spec/support/table_helpers/column.rb | 4 + .../column_type/scheduling_mode.rb | 67 +++++++++++++ .../column_type/scheduling_mode_spec.rb | 96 +++++++++++++++++++ 4 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 spec/support/table_helpers/column_type/scheduling_mode.rb create mode 100644 spec/support_spec/table_helpers/column_type/scheduling_mode_spec.rb diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 89da634d80b1..266b5fbef31a 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -53,9 +53,9 @@ describe "journal creation" do context "when scheduling mode is changed by the migration" do let_work_packages(<<~TABLE) - subject | schedule manually - wp already manual | true - wp automatic | false + subject | scheduling mode + wp already manual | manual + wp automatic | automatic TABLE before do @@ -96,15 +96,15 @@ # spec from #42388, "Migration from an earlier version" section context "for work packages with no predecessors" do let_work_packages(<<~TABLE) - subject | start date | due date | schedule manually - wp automatic 1 | 2024-11-20 | 2024-11-21 | false - wp automatic 2 | | 2024-11-21 | false - wp automatic 3 | 2024-11-20 | | false - wp automatic 4 | | | false - wp manual 1 | 2024-11-20 | 2024-11-21 | true - wp manual 2 | | 2024-11-21 | true - wp manual 3 | 2024-11-20 | | true - wp manual 4 | | | true + subject | start date | due date | scheduling mode + wp automatic 1 | 2024-11-20 | 2024-11-21 | automatic + wp automatic 2 | | 2024-11-21 | automatic + wp automatic 3 | 2024-11-20 | | automatic + wp automatic 4 | | | automatic + wp manual 1 | 2024-11-20 | 2024-11-21 | manual + wp manual 2 | | 2024-11-21 | manual + wp manual 3 | 2024-11-20 | | manual + wp manual 4 | | | manual TABLE it "switches to manual scheduling" do diff --git a/spec/support/table_helpers/column.rb b/spec/support/table_helpers/column.rb index 49700ba01bcf..2c5fb667a44a 100644 --- a/spec/support/table_helpers/column.rb +++ b/spec/support/table_helpers/column.rb @@ -36,6 +36,7 @@ require_relative "column_type/percentage" require_relative "column_type/properties" require_relative "column_type/schedule" +require_relative "column_type/scheduling_mode" require_relative "column_type/status" require_relative "column_type/subject" @@ -53,6 +54,7 @@ class Column hierarchy: ColumnType::Hierarchy, properties: ColumnType::Properties, schedule: ColumnType::Schedule, + schedule_manually: ColumnType::SchedulingMode, status: ColumnType::Status, subject: ColumnType::Subject, __fallback__: ColumnType::Generic @@ -88,6 +90,8 @@ def self.attribute_for(header) :due_date when /.*MTWTFSS.*/ :schedule + when /\s*scheduling mode\s*/ + :schedule_manually when /\s*properties\s*/ :properties when /status/, /hierarchy/ diff --git a/spec/support/table_helpers/column_type/scheduling_mode.rb b/spec/support/table_helpers/column_type/scheduling_mode.rb new file mode 100644 index 000000000000..5eea667f3569 --- /dev/null +++ b/spec/support/table_helpers/column_type/scheduling_mode.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 TableHelpers + module ColumnType + # Column to specify scheduling mode of a work package. + # + # Can take values 'manual' or 'automatic' to set `schedule_manually` + # attribute to `true` or `false` respectively. + # + # Example: + # + # | subject | scheduling mode | + # | wp 1 | manual | + # | wp 2 | automatic | + class SchedulingMode < Generic + def format(value) + if value + "manual" + else + "automatic" + end + end + + def parse(raw_value) + case raw_value.downcase.strip + when "" + nil + when "manual", "true" + true + when "automatic", "false" + false + else + raise "Invalid scheduling mode: #{raw_value.strip}. " \ + "Expected 'manual' or 'automatic'." + end + end + end + end +end diff --git a/spec/support_spec/table_helpers/column_type/scheduling_mode_spec.rb b/spec/support_spec/table_helpers/column_type/scheduling_mode_spec.rb new file mode 100644 index 000000000000..71066b450c03 --- /dev/null +++ b/spec/support_spec/table_helpers/column_type/scheduling_mode_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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" + +module TableHelpers::ColumnType + RSpec.describe SchedulingMode do + subject(:column_type) { described_class.new } + def parsed_attributes(table) + work_packages_data = TableHelpers::TableParser.new.parse(table) + work_packages_data.pluck(:attributes) + end + + describe "#parse" do + it "maps 'manual' to `schedule_manually: true`" do + expect(parsed_attributes(<<~TABLE)) + | scheduling mode | + | manual | + TABLE + .to eq([{ schedule_manually: true }]) + end + + it "maps 'automatic' to `schedule_manually: false`" do + expect(parsed_attributes(<<~TABLE)) + | scheduling mode | + | automatic | + TABLE + .to eq([{ schedule_manually: false }]) + end + + it "maps empty value to `schedule_manually: nil` (which means automatic too)" do + expect(parsed_attributes(<<~TABLE)) + | scheduling mode | + | | + TABLE + .to eq([{ schedule_manually: nil }]) + end + + it "can still use 'schedule manually' as column name with `true` and `false` as values" do + expect(parsed_attributes(<<~TABLE)) + | schedule manually | + | true | + | false | + | | + TABLE + .to eq([{ schedule_manually: true }, { schedule_manually: false }, { schedule_manually: nil }]) + end + + it "raises an error if value is invalid" do + expect { parsed_attributes(<<~TABLE) } + | scheduling mode | + | foo | + TABLE + .to raise_error("Invalid scheduling mode: foo. Expected 'manual' or 'automatic'.") + end + end + + describe "#format" do + it "maps `true` to 'manual'" do + expect(column_type.format(true)).to eq "manual" + end + + it "maps `false` and `nil` to 'automatic'" do + expect(column_type.format(false)).to eq "automatic" + expect(column_type.format(nil)).to eq "automatic" + end + end + end +end From c155b402c284123fa02d21b0a98e94dff0b9d183 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 22 Nov 2024 12:17:06 +0100 Subject: [PATCH 03/33] [59539] Migration: followers and parents have automatic scheduling mode https://community.openproject.org/wp/59539 --- .../automatic_mode/migrate_values_job.rb | 31 ++++++++++- .../update_scheduling_mode_and_lags_spec.rb | 54 ++++++++++++++++--- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index 887653409352..80e732487af1 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -29,7 +29,9 @@ class WorkPackages::AutomaticMode::MigrateValuesJob < ApplicationJob def perform with_temporary_table do - change_scheduling_mode_to_manual_mode + change_scheduling_mode_to_manual + change_scheduling_mode_to_automatic_for_followers + change_scheduling_mode_to_automatic_for_parents copy_values_to_work_packages_and_update_journals end end @@ -63,13 +65,38 @@ def drop_temporary_table SQL end - def change_scheduling_mode_to_manual_mode + def change_scheduling_mode_to_manual execute(<<~SQL.squish) UPDATE temp_wp_values SET schedule_manually = true SQL end + def change_scheduling_mode_to_automatic_for_followers + execute(<<~SQL.squish) + UPDATE temp_wp_values + SET schedule_manually = false + WHERE EXISTS ( + SELECT 1 + FROM relations + WHERE relations.from_id = temp_wp_values.id + AND relations.relation_type = 'follows' + ) + SQL + end + + def change_scheduling_mode_to_automatic_for_parents + execute(<<~SQL.squish) + UPDATE temp_wp_values + SET schedule_manually = false + WHERE id IN ( + SELECT DISTINCT parent_id + FROM work_packages + WHERE parent_id IS NOT NULL + ) + SQL + end + def copy_values_to_work_packages_and_update_journals updated_work_package_ids = copy_values_to_work_packages create_journals_for_updated_work_packages(updated_work_package_ids) diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 266b5fbef31a..0f58a4e0a16d 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -35,6 +35,7 @@ perform_enqueued_jobs do ActiveRecord::Migration.suppress_messages { described_class.new.up } end + table_work_packages.map(&:reload) if defined?(table_work_packages) end shared_let(:author) { create(:user) } @@ -58,11 +59,13 @@ wp automatic | automatic TABLE - before do + it "creates a journal entry only for the changed work packages" do + expect(wp_already_manual.journals.count).to eq(1) + expect(wp_automatic.journals.count).to eq(1) + expect(wp_automatic.lock_version).to eq(0) + run_migration - end - it "creates a journal entry only for the changed work packages" do expect(wp_already_manual.journals.count).to eq(1) expect(wp_automatic.journals.count).to eq(2) @@ -76,9 +79,7 @@ end aggregate_failures "the lock_version of the work package is incremented" do - previous_lock_version = wp_automatic.lock_version - wp_automatic.reload - expect(wp_automatic.lock_version).to be > previous_lock_version + expect(wp_automatic.lock_version).to be > 0 end aggregate_failures "changes the updated_at of the work package" do @@ -110,8 +111,47 @@ it "switches to manual scheduling" do run_migration - table_work_packages.map(&:reload) expect(table_work_packages).to all(be_schedule_manually) end end + + # spec from #42388, "Migration from an earlier version" section + context "for work packages following another one" do + let_work_packages(<<~TABLE) + subject | start date | due date | scheduling mode | properties + main | 2024-11-19 | 2024-11-19 | manual | + wp automatic 1 | 2024-11-20 | 2024-11-21 | automatic | follows main + wp automatic 2 | | 2024-11-21 | automatic | follows main + wp automatic 3 | 2024-11-20 | | automatic | follows main + wp automatic 4 | | | automatic | follows main + wp manual 1 | 2024-11-20 | 2024-11-21 | manual | follows main + wp manual 2 | | 2024-11-21 | manual | follows main + wp manual 3 | 2024-11-20 | | manual | follows main + wp manual 4 | | | manual | follows main + TABLE + + # TODO: should work packages without any dates really be switched to manual like the specs say? + it "switches to automatic scheduling" do + run_migration + + expect(main).to be_schedule_manually + expect(table_work_packages - [main]).to all(be_schedule_automatically) + end + end + + # spec from #42388, "Migration from an earlier version" section + context "for parent work packages" do + let_work_packages(<<~TABLE) + hierarchy | scheduling mode | + parent | manual | + child | manual | + TABLE + + it "switches to automatic scheduling" do + run_migration + + expect(parent).to be_schedule_automatically + expect(child).to be_schedule_manually + end + end end From ed3683ffa23dd1b0368a0b429851457d90cae56e Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 22 Nov 2024 16:23:04 +0100 Subject: [PATCH 04/33] [59539] Migration: set lag for follows relations To preserve dates, a lag is set for follows relations when both the predecessor and the follower have dates. --- .../automatic_mode/migrate_values_job.rb | 14 ++++++ .../update_scheduling_mode_and_lags_spec.rb | 49 +++++++++++++++++++ .../table_helpers/column_type/properties.rb | 2 +- spec/support/table_helpers/table.rb | 7 ++- spec/support/table_helpers/table_data.rb | 28 ++++++++--- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index 80e732487af1..3b6b03c32ada 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -32,6 +32,7 @@ def perform change_scheduling_mode_to_manual change_scheduling_mode_to_automatic_for_followers change_scheduling_mode_to_automatic_for_parents + set_lags_for_follows_relations copy_values_to_work_packages_and_update_journals end end @@ -97,6 +98,19 @@ def change_scheduling_mode_to_automatic_for_parents SQL end + def set_lags_for_follows_relations + execute(<<~SQL.squish) + UPDATE relations + SET lag = COALESCE(wp_succ.start_date, wp_succ.due_date) - COALESCE(wp_pred.due_date, wp_pred.start_date) - 1 + FROM work_packages wp_pred, work_packages wp_succ + WHERE relations.relation_type = 'follows' + AND relations.to_id = wp_pred.id + AND relations.from_id = wp_succ.id + AND COALESCE(wp_succ.start_date, wp_succ.due_date) IS NOT NULL + AND COALESCE(wp_pred.due_date, wp_pred.start_date) IS NOT NULL + SQL + end + def copy_values_to_work_packages_and_update_journals updated_work_package_ids = copy_values_to_work_packages create_journals_for_updated_work_packages(updated_work_package_ids) diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 0f58a4e0a16d..a66904e2d066 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -154,4 +154,53 @@ expect(child).to be_schedule_manually end end + + context "for 2 work packages following each other with distant dates" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | properties + predecessor 1 | XX | + follower 1 | XX | follows predecessor 1 + + # only start dates + predecessor 2 | [ | + follower 2 | [ | follows predecessor 2 + + # only due dates + predecessor 3 | ] | + follower 3 | ] | follows predecessor 3 with lag 1 + TABLE + + it "sets a lag to the relation to ensure the distance is kept" do + run_migration + + expect(follower1).to be_schedule_automatically + relations = _table.relations.map(&:reload) + expect(relations.map(&:lag)).to all(eq(2)) + end + end + + context "for 2 work packages following each other with missing dates" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | properties + # only predecessor has dates + predecessor 1 | XX | + follower 1 | | follows predecessor 1 + + # only successor has dates + predecessor 2 | | + follower 2 | XX | follows predecessor 2 + + # none have dates + predecessor 3 | | + follower 3 | | follows predecessor 3 with lag 1 + TABLE + + it "does not change the existing lag" do + run_migration + + expect(follower1).to be_schedule_automatically + relations = _table.relations.map(&:reload) + expect(relations.map(&:lag)).to eq([0, 0, 1]) + end + end end diff --git a/spec/support/table_helpers/column_type/properties.rb b/spec/support/table_helpers/column_type/properties.rb index 2a550c8f1b09..dbe0e919c977 100644 --- a/spec/support/table_helpers/column_type/properties.rb +++ b/spec/support/table_helpers/column_type/properties.rb @@ -73,7 +73,7 @@ def relations_for_raw_value(raw_value) def parse_property(property) case property - when /^follows (\w+)(?: with lag (\d+))?/ + when /^follows (.+?)(?: with lag (\d+))?\s*$/ { relations: { raw: property, diff --git a/spec/support/table_helpers/table.rb b/spec/support/table_helpers/table.rb index 1b5e241fd8f2..7ad26d050763 100644 --- a/spec/support/table_helpers/table.rb +++ b/spec/support/table_helpers/table.rb @@ -28,8 +28,9 @@ module TableHelpers class Table - def initialize(work_packages_by_identifier) + def initialize(work_packages_by_identifier, relations) @work_packages_by_identifier = work_packages_by_identifier + @relations = relations end def work_package(name) @@ -41,6 +42,10 @@ def work_packages @work_packages_by_identifier.values end + def relations + @relations + end + private def normalize_name(name) diff --git a/spec/support/table_helpers/table_data.rb b/spec/support/table_helpers/table_data.rb index 136ba7752fba..2885dba4e48b 100644 --- a/spec/support/table_helpers/table_data.rb +++ b/spec/support/table_helpers/table_data.rb @@ -79,8 +79,8 @@ def work_package_identifiers end def create_work_packages - work_packages_by_identifier = Factory.new(self).create - Table.new(work_packages_by_identifier) + work_packages_by_identifier, relations = Factory.new(self).create + Table.new(work_packages_by_identifier, relations) end def order_like!(other_table) @@ -93,11 +93,14 @@ def order_like!(other_table) end class Factory - attr_reader :table_data, :work_packages_by_identifier + include Identifier + + attr_reader :table_data, :work_packages_by_identifier, :relations def initialize(table_data) @table_data = table_data @work_packages_by_identifier = {} + @relations = [] end def create @@ -108,7 +111,7 @@ def create table_data.work_package_identifiers.each do |identifier| # rubocop:disable Style/CombinableLoops create_follows_relations(identifier) end - work_packages_by_identifier + [work_packages_by_identifier, relations] end def create_work_package(identifier) @@ -123,11 +126,10 @@ def create_work_package(identifier) end def create_follows_relations(identifier) - relations = work_package_relations(identifier) - relations.each do |relation| - predecessor = work_packages_by_identifier[relation[:predecessor].to_sym] + work_package_relations(identifier).each do |relation| + predecessor = find_work_package_by_name(relation[:predecessor]) follower = work_packages_by_identifier[identifier] - FactoryBot.create( + relations << FactoryBot.create( :follows_relation, from: follower, to: predecessor, @@ -136,6 +138,16 @@ def create_follows_relations(identifier) end end + def find_work_package_by_name(name) + identifier = to_identifier(name) + work_package = work_packages_by_identifier[identifier] + if work_package.nil? + raise "Work package with name #{name.inspect} (identifier: #{identifier.inspect}) not found. " \ + "Available work package identifiers: #{work_packages_by_identifier.keys}." + end + work_package + end + def lookup_parent(identifier) if identifier @work_packages_by_identifier[identifier] || create_work_package(identifier) From dcd53406c703f3c206ea795ee808fe69a5382794 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 25 Nov 2024 09:28:27 +0100 Subject: [PATCH 05/33] [59539] Respect non-working days when computing lags in migration `lag` is the number of _working_ days between predecessor and successor dates. --- .../automatic_mode/migrate_values_job.rb | 45 ++++++++++++++++--- .../update_scheduling_mode_and_lags_spec.rb | 35 +++++++++++---- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index 3b6b03c32ada..c7b462e5e8c8 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -99,15 +99,46 @@ def change_scheduling_mode_to_automatic_for_parents end def set_lags_for_follows_relations + working_days = Setting.working_days + + # Here is the algorithm: + # - Take all follows relations with dates + # - Generate a series of dates between the min date and the max date and + # filter for working days + # - Use both information to count the number of working days between + # predecessor and successor dates and update the lag with it execute(<<~SQL.squish) + WITH follows_relations_with_dates AS ( + SELECT + relations.id as id, + COALESCE(wp_pred.due_date, wp_pred.start_date) as pred_date, + COALESCE(wp_succ.start_date, wp_succ.due_date) as succ_date + FROM relations + LEFT JOIN work_packages wp_pred ON relations.to_id = wp_pred.id + LEFT JOIN work_packages wp_succ ON relations.from_id = wp_succ.id + WHERE relation_type = 'follows' + AND COALESCE(wp_pred.due_date, wp_pred.start_date) IS NOT NULL + AND COALESCE(wp_succ.start_date, wp_succ.due_date) IS NOT NULL + ), + working_dates AS ( + SELECT date::date + FROM generate_series( + (SELECT MIN(pred_date) FROM follows_relations_with_dates), + (SELECT MAX(succ_date) FROM follows_relations_with_dates), + '1 day'::interval + ) AS date + WHERE EXTRACT(ISODOW FROM date)::integer IN (#{working_days.join(',')}) + AND NOT date IN (SELECT date FROM non_working_days) + ) UPDATE relations - SET lag = COALESCE(wp_succ.start_date, wp_succ.due_date) - COALESCE(wp_pred.due_date, wp_pred.start_date) - 1 - FROM work_packages wp_pred, work_packages wp_succ - WHERE relations.relation_type = 'follows' - AND relations.to_id = wp_pred.id - AND relations.from_id = wp_succ.id - AND COALESCE(wp_succ.start_date, wp_succ.due_date) IS NOT NULL - AND COALESCE(wp_pred.due_date, wp_pred.start_date) IS NOT NULL + SET lag = ( + SELECT COUNT(*) + FROM working_dates + WHERE date > pred_date + AND date < succ_date + ) + FROM follows_relations_with_dates + WHERE relations.id = follows_relations_with_dates.id SQL end diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index a66904e2d066..81ca39960181 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -156,18 +156,19 @@ end context "for 2 work packages following each other with distant dates" do - let_work_packages(<<~TABLE) + shared_let_work_packages(<<~TABLE) subject | MTWTFSS | properties predecessor 1 | XX | - follower 1 | XX | follows predecessor 1 + follower 1 | XX | follows predecessor 1 # only start dates predecessor 2 | [ | - follower 2 | [ | follows predecessor 2 + follower 2 | [ | follows predecessor 2 # only due dates + # if lag is already set, it's overwritten predecessor 3 | ] | - follower 3 | ] | follows predecessor 3 with lag 1 + follower 3 | ] | follows predecessor 3 with lag 2 TABLE it "sets a lag to the relation to ensure the distance is kept" do @@ -175,7 +176,25 @@ expect(follower1).to be_schedule_automatically relations = _table.relations.map(&:reload) - expect(relations.map(&:lag)).to all(eq(2)) + expect(relations.map(&:lag)).to all(eq(3)) + end + + context "when there are non-working days between the dates" do + before do + # Wednesday is a recurring non-working day + set_non_working_week_days("wednesday") + # Thursday is a fixed non-working day + thursday = Date.current.next_occurring(:monday) + 3.days + create(:non_working_day, date: thursday) + end + + it "computes the lag correctly by excluding non-working days" do + run_migration + + expect(follower1).to be_schedule_automatically + relations = _table.relations.map(&:reload) + expect(relations.map(&:lag)).to all(eq(1)) + end end end @@ -188,11 +207,11 @@ # only successor has dates predecessor 2 | | - follower 2 | XX | follows predecessor 2 + follower 2 | XX | follows predecessor 2 # none have dates predecessor 3 | | - follower 3 | | follows predecessor 3 with lag 1 + follower 3 | | follows predecessor 3 with lag 2 TABLE it "does not change the existing lag" do @@ -200,7 +219,7 @@ expect(follower1).to be_schedule_automatically relations = _table.relations.map(&:reload) - expect(relations.map(&:lag)).to eq([0, 0, 1]) + expect(relations.map(&:lag)).to eq([0, 0, 2]) end end end From d63c9c18380b464399f7412499de7157d7055d9c Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 25 Nov 2024 16:36:06 +0100 Subject: [PATCH 06/33] [59539] Only set lag for the closest relation in the migration --- .../automatic_mode/migrate_values_job.rb | 14 ++++++++++++-- .../update_scheduling_mode_and_lags_spec.rb | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index c7b462e5e8c8..39293396afe6 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -103,6 +103,7 @@ def set_lags_for_follows_relations # Here is the algorithm: # - Take all follows relations with dates + # - Filter to keep only the closest relation for a same successor # - Generate a series of dates between the min date and the max date and # filter for working days # - Use both information to count the number of working days between @@ -111,6 +112,7 @@ def set_lags_for_follows_relations WITH follows_relations_with_dates AS ( SELECT relations.id as id, + relations.from_id as succ_id, COALESCE(wp_pred.due_date, wp_pred.start_date) as pred_date, COALESCE(wp_succ.start_date, wp_succ.due_date) as succ_date FROM relations @@ -120,6 +122,14 @@ def set_lags_for_follows_relations AND COALESCE(wp_pred.due_date, wp_pred.start_date) IS NOT NULL AND COALESCE(wp_succ.start_date, wp_succ.due_date) IS NOT NULL ), + closest_follows_relations_with_dates AS ( + SELECT DISTINCT ON (succ_id) + id, + pred_date, + succ_date + FROM follows_relations_with_dates + ORDER BY succ_id, pred_date DESC + ), working_dates AS ( SELECT date::date FROM generate_series( @@ -137,8 +147,8 @@ def set_lags_for_follows_relations WHERE date > pred_date AND date < succ_date ) - FROM follows_relations_with_dates - WHERE relations.id = follows_relations_with_dates.id + FROM closest_follows_relations_with_dates + WHERE relations.id = closest_follows_relations_with_dates.id SQL end diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 81ca39960181..0dcf3ae5209a 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -222,4 +222,21 @@ expect(relations.map(&:lag)).to eq([0, 0, 2]) end end + + context "for a work package following multiple work packages" do + shared_let_work_packages(<<~TABLE) + subject | MTWTFSS | properties + predecessor 1 | XX | + predecessor 2 | XX | + predecessor 3 | X | + follower | XX | follows predecessor 1, follows predecessor 2, follows predecessor 3 + TABLE + + it "sets a lag only to the closest relation" do + run_migration + + relations = _table.relations.map(&:reload) + expect(relations.map(&:lag)).to eq([0, 2, 0]) + end + end end From 45d6a3a4106cbbb5ec01d13f7c54a3affb5859a8 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 27 Nov 2024 12:14:59 +0100 Subject: [PATCH 07/33] [59539] Keep manual scheduling mode in migration The rule is: it never switches from manual to automatic scheduling mode. It only switches from automatic to manual, and only if keeping automatic is not possible because it would mean losing the dates. The code and specs have been updated to reflect this. A materialized view is used to reuse the data in multiple different queries. --- .../automatic_mode/migrate_values_job.rb | 91 ++++++-------- .../update_scheduling_mode_and_lags_spec.rb | 116 ++++++++++++++---- 2 files changed, 133 insertions(+), 74 deletions(-) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index 39293396afe6..0fee67054806 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -28,10 +28,8 @@ class WorkPackages::AutomaticMode::MigrateValuesJob < ApplicationJob def perform - with_temporary_table do - change_scheduling_mode_to_manual - change_scheduling_mode_to_automatic_for_followers - change_scheduling_mode_to_automatic_for_parents + with_temporary_tables do + change_independent_childless_work_packages_scheduling_mode_to_manual set_lags_for_follows_relations copy_values_to_work_packages_and_update_journals end @@ -39,16 +37,16 @@ def perform private - def with_temporary_table + def with_temporary_tables WorkPackage.transaction do - create_temporary_table + create_temporary_tables yield ensure - drop_temporary_table + drop_temporary_tables end end - def create_temporary_table + def create_temporary_tables execute(<<~SQL.squish) CREATE UNLOGGED TABLE temp_wp_values AS SELECT @@ -58,42 +56,44 @@ def create_temporary_table schedule_manually FROM work_packages SQL - end - - def drop_temporary_table execute(<<~SQL.squish) - DROP TABLE temp_wp_values + CREATE MATERIALIZED VIEW follows_relations + AS SELECT + relations.id as id, + relations.from_id as succ_id, + COALESCE(wp_pred.due_date, wp_pred.start_date) as pred_date, + COALESCE(wp_succ.start_date, wp_succ.due_date) as succ_date, + wp_succ.schedule_manually as succ_schedule_manually + FROM relations + LEFT JOIN work_packages wp_pred ON relations.to_id = wp_pred.id + LEFT JOIN work_packages wp_succ ON relations.from_id = wp_succ.id + WHERE relation_type = 'follows' SQL + execute("CREATE INDEX ON follows_relations (succ_id)") end - def change_scheduling_mode_to_manual - execute(<<~SQL.squish) - UPDATE temp_wp_values - SET schedule_manually = true - SQL + def drop_temporary_tables + execute("DROP TABLE temp_wp_values") + execute("DROP MATERIALIZED VIEW follows_relations") end - def change_scheduling_mode_to_automatic_for_followers + # Change the scheduling mode to manual for: + # - non-successor (independent) and non-parent (childless) work packages + # - successor work packages with dates but without any predecessor with dates + def change_independent_childless_work_packages_scheduling_mode_to_manual execute(<<~SQL.squish) UPDATE temp_wp_values - SET schedule_manually = false - WHERE EXISTS ( + SET schedule_manually = true + WHERE NOT EXISTS ( + SELECT 1 + FROM follows_relations + WHERE follows_relations.succ_id = temp_wp_values.id + AND (follows_relations.pred_date IS NOT NULL + OR follows_relations.succ_date IS NULL) + ) AND NOT EXISTS ( SELECT 1 - FROM relations - WHERE relations.from_id = temp_wp_values.id - AND relations.relation_type = 'follows' - ) - SQL - end - - def change_scheduling_mode_to_automatic_for_parents - execute(<<~SQL.squish) - UPDATE temp_wp_values - SET schedule_manually = false - WHERE id IN ( - SELECT DISTINCT parent_id FROM work_packages - WHERE parent_id IS NOT NULL + WHERE work_packages.parent_id = temp_wp_values.id ) SQL end @@ -109,32 +109,21 @@ def set_lags_for_follows_relations # - Use both information to count the number of working days between # predecessor and successor dates and update the lag with it execute(<<~SQL.squish) - WITH follows_relations_with_dates AS ( - SELECT - relations.id as id, - relations.from_id as succ_id, - COALESCE(wp_pred.due_date, wp_pred.start_date) as pred_date, - COALESCE(wp_succ.start_date, wp_succ.due_date) as succ_date - FROM relations - LEFT JOIN work_packages wp_pred ON relations.to_id = wp_pred.id - LEFT JOIN work_packages wp_succ ON relations.from_id = wp_succ.id - WHERE relation_type = 'follows' - AND COALESCE(wp_pred.due_date, wp_pred.start_date) IS NOT NULL - AND COALESCE(wp_succ.start_date, wp_succ.due_date) IS NOT NULL - ), - closest_follows_relations_with_dates AS ( + WITH closest_follows_relations_with_dates AS ( SELECT DISTINCT ON (succ_id) id, pred_date, succ_date - FROM follows_relations_with_dates + FROM follows_relations + WHERE pred_date IS NOT NULL + AND succ_date IS NOT NULL ORDER BY succ_id, pred_date DESC ), working_dates AS ( SELECT date::date FROM generate_series( - (SELECT MIN(pred_date) FROM follows_relations_with_dates), - (SELECT MAX(succ_date) FROM follows_relations_with_dates), + (SELECT MIN(pred_date) FROM closest_follows_relations_with_dates), + (SELECT MAX(succ_date) FROM closest_follows_relations_with_dates), '1 day'::interval ) AS date WHERE EXTRACT(ISODOW FROM date)::integer IN (#{working_days.join(',')}) diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 0dcf3ae5209a..bab471f28052 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -94,8 +94,11 @@ end end - # spec from #42388, "Migration from an earlier version" section - context "for work packages with no predecessors" do + # spec from #59539, "Migration from an earlier version" section: + # + # > - For work packages with no predecessors (or with no relations at all), they will be + # > switched to manual scheduling. + context "for work packages with no predecessors nor children" do let_work_packages(<<~TABLE) subject | start date | due date | scheduling mode wp automatic 1 | 2024-11-20 | 2024-11-21 | automatic @@ -115,43 +118,110 @@ end end - # spec from #42388, "Migration from an earlier version" section - context "for work packages following another one" do + # spec from #59539, "Migration from an earlier version" section: + # + # > - Manually scheduled work packages remain so. + context "for manually scheduled work packages following another one" do let_work_packages(<<~TABLE) subject | start date | due date | scheduling mode | properties - main | 2024-11-19 | 2024-11-19 | manual | - wp automatic 1 | 2024-11-20 | 2024-11-21 | automatic | follows main - wp automatic 2 | | 2024-11-21 | automatic | follows main - wp automatic 3 | 2024-11-20 | | automatic | follows main - wp automatic 4 | | | automatic | follows main - wp manual 1 | 2024-11-20 | 2024-11-21 | manual | follows main - wp manual 2 | | 2024-11-21 | manual | follows main - wp manual 3 | 2024-11-20 | | manual | follows main - wp manual 4 | | | manual | follows main + main | | | manual | + wp 1 | 2024-11-20 | 2024-11-21 | manual | follows main + wp 2 | | 2024-11-21 | manual | follows main + wp 3 | 2024-11-20 | | manual | follows main + wp 4 | | | manual | follows main + TABLE + + it "remains manually scheduled" do + run_migration + + expect(table_work_packages).to all(be_schedule_manually) + end + end + + # spec from #59539, "Migration from an earlier version" section + # + # > - If the successor is in automatic scheduling mode, has dates and some predecessors + # > have dates too: + # > - The successor remains in automatic mode + context "for automatically scheduled work packages following another one having dates" do + let_work_packages(<<~TABLE) + subject | start date | due date | scheduling mode | properties + pred with dates | 2024-11-19 | 2024-11-19 | manual | + pred without dates | | | manual | + wp 1 | 2024-11-20 | 2024-11-21 | automatic | follows pred with dates, follows pred without dates + wp 2 | | 2024-11-21 | automatic | follows pred with dates, follows pred without dates + wp 3 | 2024-11-20 | | automatic | follows pred with dates, follows pred without dates + wp 4 | | | automatic | follows pred with dates, follows pred without dates + TABLE + + it "remains automatically scheduled" do + run_migration + + expect([wp1, wp2, wp3, wp4]).to all(be_schedule_automatically) + end + end + + # spec from #59539, "Migration from an earlier version" section + # > - If the successor is in automatic scheduling mode and has no dates + # > - The successor remains in automatic mode and continues to have no dates, + # > regardless of having predecessor with dates or not. + # > - If the successor is in automatic scheduling mode, has dates and none of the + # > predecessors have any dates + # > - The successor is switched to manual mode to preserve its dates and duration + context "for automatically scheduled work packages without dates following another one" do + let_work_packages(<<~TABLE) + subject | start date | due date | scheduling mode | properties + pred without dates | | | manual | + succ | | | automatic | follows pred without dates + TABLE + + it "remains automatically scheduled and continues to have no dates" do + run_migration + + expect(succ).to be_schedule_automatically + end + end + + # spec from #59539, "Migration from an earlier version" section + # + # > - If the successor is in automatic scheduling mode, has dates and none of the + # > predecessors have any dates + # > - The successor is switched to manual mode to preserve its dates and duration + context "for automatically scheduled work packages following another one having no dates" do + let_work_packages(<<~TABLE) + subject | start date | due date | scheduling mode | properties + pred without dates | | | manual | + succ 1 | 2024-11-20 | 2024-11-21 | automatic | follows pred without dates + succ 2 | | 2024-11-21 | automatic | follows pred without dates + succ 3 | 2024-11-20 | | automatic | follows pred without dates TABLE - # TODO: should work packages without any dates really be switched to manual like the specs say? - it "switches to automatic scheduling" do + it "switches to manual scheduling to preserve its dates and duration" do run_migration - expect(main).to be_schedule_manually - expect(table_work_packages - [main]).to all(be_schedule_automatically) + expect([succ1, succ2, succ3]).to all(be_schedule_manually) end end # spec from #42388, "Migration from an earlier version" section + # + # > - Manually scheduled work packages remain so. + # > - If the relationship is parent-child, there are no changes to dates; the parent + # > remains in automatic mode. context "for parent work packages" do let_work_packages(<<~TABLE) - hierarchy | scheduling mode | - parent | manual | - child | manual | + hierarchy | scheduling mode | + parent_automatic | automatic | + child1 | manual | + parent_manual | manual | + child2 | manual | TABLE - it "switches to automatic scheduling" do + it "keep their scheduling mode" do run_migration - expect(parent).to be_schedule_automatically - expect(child).to be_schedule_manually + expect(parent_automatic).to be_schedule_automatically + expect(parent_manual).to be_schedule_manually end end From ca5c6acf2e19c1837e6bdb972c6b8c047b3bb35c Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 28 Nov 2024 16:56:20 +0100 Subject: [PATCH 08/33] [59539] Make work packages manually scheduled by default The `schedule_manually` column is also non-nullable now. This includes the following changes: - Automatically scheduled parent dates are and `ignore_non_working_days` attributes are now always derived from children's values, even if the children are scheduled manually. It's more natural. Without that, adding a child to a work package would not change the parent's dates. As a consequence, the parent can start on a non-working day if one of its children is manually scheduled, ignores non-working days, and starts on a non-working day. That's why the parent's `ignore_non_working_days` attribute is now also derived from all its children regardless of the scheduling mode. If the parent is manually scheduled, its dates and it's ability to ignore non-working days will still be defined independently from its children. - Fix tests broken by scheduling mode being manual by default. The tests had to be adapted to explicitly set scheduling mode to automatic for followers and parents, and sometimes even follower's children. Without it, work packages would not be rescheduled automatically. - Replace schedule helpers with table helpers. Schedule helpers helped well, but table helpers are more flexible and support more column types. - Add "days counting" and "scheduling mode" columns to table helpers. "days counting" to set `ignore_non_working_days` attribute. - "all days" value maps to `ignore_non_working_days: true`. - "working days" value maps to `ignore_non_working_days: false`. "scheduling mode" to set `schedule_manually` attribute. - "manual" value maps to `schedule_manually: true`. - "automatic" value maps to `schedule_manually: false`. --- .../work_packages/scopes/for_scheduling.rb | 21 +- .../work_packages/update_ancestors_service.rb | 4 +- ...0095318_update_scheduling_mode_and_lags.rb | 10 + .../team_planner_remove_event_spec.rb | 1 + .../team_planner_user_interaction_spec.rb | 3 +- .../work_packages/bulk_controller_spec.rb | 2 +- spec/features/admin/working_days_spec.rb | 30 +- .../datepicker_follows_relation_spec.rb | 4 +- .../datepicker/datepicker_parent_spec.rb | 47 +- .../scheduling/scheduling_mode_spec.rb | 6 + .../scheduling/manual_scheduling_spec.rb | 7 +- .../specific_work_package_schema_spec.rb | 17 +- .../update_scheduling_mode_and_lags_spec.rb | 38 +- spec/models/relation_spec.rb | 108 +- .../work_package_acts_as_journalized_spec.rb | 84 +- .../covering_dates_and_days_of_week_spec.rb | 64 +- .../scopes/for_scheduling_spec.rb | 248 +++- .../v3/work_packages/create_resource_spec.rb | 8 +- .../create_service_integration_spec.rb | 6 +- .../schedule_dependency/dependency_spec.rb | 99 +- .../set_schedule_service_spec.rb | 57 +- .../set_schedule_service_working_days_spec.rb | 1133 +++++++++-------- .../update_ancestors_service_spec.rb | 45 +- .../update_service_integration_spec.rb | 139 +- .../datepicker/work_package_datepicker.rb | 12 +- spec/support/schedule_helpers/chart.rb | 263 ---- .../support/schedule_helpers/chart_builder.rb | 147 --- .../schedule_helpers/chart_representer.rb | 82 -- .../schedule_helpers/example_methods.rb | 115 -- spec/support/schedule_helpers/let_schedule.rb | 74 -- spec/support/schedule_helpers/schedule.rb | 67 - .../schedule_helpers/schedule_builder.rb | 74 -- spec/support/table_helpers/column.rb | 4 + .../column_type/days_counting.rb} | 52 +- .../table_helpers/column_type/properties.rb | 3 +- .../table_helpers/column_type/schedule.rb | 3 +- spec/support/table_helpers/example_methods.rb | 36 + spec/support/table_helpers/table.rb | 4 + .../schedule_helpers/chart_builder_spec.rb | 189 --- .../chart_representer_spec.rb | 269 ---- .../schedule_helpers/chart_spec.rb | 249 ---- .../schedule_helpers/example_methods_spec.rb | 190 --- .../schedule_helpers/let_schedule_spec.rb | 71 -- .../column_type/days_counting_spec.rb | 99 ++ .../table_helpers/example_methods_spec.rb | 77 ++ .../apply_working_days_change_job_spec.rb | 772 +++++------ 46 files changed, 1922 insertions(+), 3111 deletions(-) delete mode 100644 spec/support/schedule_helpers/chart.rb delete mode 100644 spec/support/schedule_helpers/chart_builder.rb delete mode 100644 spec/support/schedule_helpers/chart_representer.rb delete mode 100644 spec/support/schedule_helpers/example_methods.rb delete mode 100644 spec/support/schedule_helpers/let_schedule.rb delete mode 100644 spec/support/schedule_helpers/schedule.rb delete mode 100644 spec/support/schedule_helpers/schedule_builder.rb rename spec/support/{schedule_helpers.rb => table_helpers/column_type/days_counting.rb} (52%) delete mode 100644 spec/support_spec/schedule_helpers/chart_builder_spec.rb delete mode 100644 spec/support_spec/schedule_helpers/chart_representer_spec.rb delete mode 100644 spec/support_spec/schedule_helpers/chart_spec.rb delete mode 100644 spec/support_spec/schedule_helpers/example_methods_spec.rb delete mode 100644 spec/support_spec/schedule_helpers/let_schedule_spec.rb create mode 100644 spec/support_spec/table_helpers/column_type/days_counting_spec.rb create mode 100644 spec/support_spec/table_helpers/example_methods_spec.rb diff --git a/app/models/work_packages/scopes/for_scheduling.rb b/app/models/work_packages/scopes/for_scheduling.rb index 686d6895eb86..06deb9eaa040 100644 --- a/app/models/work_packages/scopes/for_scheduling.rb +++ b/app/models/work_packages/scopes/for_scheduling.rb @@ -51,6 +51,11 @@ module ForScheduling # statement works, is that a work package is considered to be scheduled # manually if *all* of its descendants are scheduled manually. # + # One notable exception is if one of the manually scheduled children is + # the origin work package of the rescheduling. In this case, the parent is + # also subject to reschedule as the origin work package dates may have + # changed. + # # For example in case of the hierarchy: # A and B <- hierarchy (C is parent of both A and B) - C <- hierarchy - D # * A and B are work packages @@ -157,21 +162,25 @@ def for_scheduling(work_packages) def scheduling_paths_sql(work_packages) values = work_packages.map do |wp| ::OpenProject::SqlSanitization - .sanitize "(:id, false, false)", + .sanitize "(:id, false, false, true)", id: wp.id end.join(", ") <<~SQL.squish - to_schedule (id, manually) AS ( + to_schedule (id, manually, hierarchy_up, origin) AS ( - SELECT * FROM (VALUES#{values}) AS t(id, manually, hierarchy_up) + SELECT * FROM (VALUES#{values}) AS t(id, manually, hierarchy_up, origin) UNION SELECT relations.from_id id, - (related_work_packages.schedule_manually OR COALESCE(descendants.manually, false)) manually, - relations.hierarchy_up + (related_work_packages.schedule_manually + OR (COALESCE(descendants.manually, false) + AND NOT (to_schedule.origin AND relations.hierarchy_up)) + ) manually, + relations.hierarchy_up, + false origin FROM to_schedule JOIN LATERAL @@ -196,7 +205,7 @@ def scheduling_paths_sql(work_packages) FROM work_package_hierarchies WHERE - NOT to_schedule.manually + (NOT to_schedule.manually OR to_schedule.origin) AND ((work_package_hierarchies.ancestor_id = to_schedule.id AND NOT to_schedule.hierarchy_up AND work_package_hierarchies.generations = 1) OR (work_package_hierarchies.descendant_id = to_schedule.id AND work_package_hierarchies.generations > 0)) ) relations ON relations.to_id = to_schedule.id diff --git a/app/services/work_packages/update_ancestors_service.rb b/app/services/work_packages/update_ancestors_service.rb index 877833bd8582..24aa3c4fb0f0 100644 --- a/app/services/work_packages/update_ancestors_service.rb +++ b/app/services/work_packages/update_ancestors_service.rb @@ -209,9 +209,7 @@ def modified_attributes_justify_derivation?(attributes) end def ignore_non_working_days_of_descendants(ancestor, loader) - children = loader - .children_of(ancestor) - .reject(&:schedule_manually) + children = loader.children_of(ancestor) if children.any? children.any?(&:ignore_non_working_days) diff --git a/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb index bd3d0ae94534..1023745ccf7b 100644 --- a/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb +++ b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb @@ -1,5 +1,9 @@ class UpdateSchedulingModeAndLags < ActiveRecord::Migration[7.1] def up + change_column_default :work_packages, :schedule_manually, from: false, to: true + execute "UPDATE work_packages SET schedule_manually = false WHERE schedule_manually IS NULL" + change_column_null :work_packages, :schedule_manually, false + migration_job = WorkPackages::AutomaticMode::MigrateValuesJob if Rails.env.development? migration_job.perform_now @@ -7,4 +11,10 @@ def up migration_job.perform_later end end + + def down + change_column_default :work_packages, :schedule_manually, from: true, to: false + # Keep the not-null constraint when rolling back + change_column_null :work_packages, :schedule_manually, false + end end diff --git a/modules/team_planner/spec/features/team_planner_remove_event_spec.rb b/modules/team_planner/spec/features/team_planner_remove_event_spec.rb index 359a6fc57838..53238cceeb10 100644 --- a/modules/team_planner/spec/features/team_planner_remove_event_spec.rb +++ b/modules/team_planner/spec/features/team_planner_remove_event_spec.rb @@ -61,6 +61,7 @@ project:, subject: "Parent work package", assigned_to: other_user, + schedule_manually: false, # because parent of child_wp start_date: Time.zone.today.beginning_of_week.next_occurring(:wednesday), due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday), derived_start_date: Time.zone.today.beginning_of_week.next_occurring(:wednesday), diff --git a/modules/team_planner/spec/features/team_planner_user_interaction_spec.rb b/modules/team_planner/spec/features/team_planner_user_interaction_spec.rb index 416f2800db9b..9a855d80e44a 100644 --- a/modules/team_planner/spec/features/team_planner_user_interaction_spec.rb +++ b/modules/team_planner/spec/features/team_planner_user_interaction_spec.rb @@ -29,7 +29,7 @@ require "spec_helper" require_relative "shared_context" -RSpec.describe "Team planner drag&dop and resizing", +RSpec.describe "Team planner drag&drop and resizing", :js, :selenium, with_ee: %i[team_planner_view], @@ -46,6 +46,7 @@ create(:work_package, project:, assigned_to: other_user, + schedule_manually: false, # because parent of second_wp start_date: Time.zone.today.beginning_of_week.next_occurring(:tuesday), due_date: Time.zone.today.beginning_of_week.next_occurring(:thursday)) end diff --git a/spec/controllers/work_packages/bulk_controller_spec.rb b/spec/controllers/work_packages/bulk_controller_spec.rb index 716c4568b68e..9803995f7352 100644 --- a/spec/controllers/work_packages/bulk_controller_spec.rb +++ b/spec/controllers/work_packages/bulk_controller_spec.rb @@ -565,7 +565,7 @@ end let(:new_parent) do - create(:work_package, project: project1) + create(:work_package, schedule_manually: false, project: project1) end before do diff --git a/spec/features/admin/working_days_spec.rb b/spec/features/admin/working_days_spec.rb index 425d29008939..8e0a4b33d456 100644 --- a/spec/features/admin/working_days_spec.rb +++ b/spec/features/admin/working_days_spec.rb @@ -34,12 +34,12 @@ shared_let(:week_days) { week_with_saturday_and_sunday_as_weekend } shared_let(:admin) { create(:admin) } - let_schedule(<<~CHART) - days | MTWTFSSmtwtfss | - earliest_work_package | XXXXX | - second_work_package | XX..XX | - follower | XXX | follows earliest_work_package, follows second_work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmtwtfss | scheduling mode | properties + earliest_work_package | XXXXX | manual | + second_work_package | XX..XX | manual | + follower | XXX | automatic | follows earliest_work_package, follows second_work_package + TABLE let(:dialog) { Components::ConfirmationDialog.new } let(:datepicker) { Components::DatepickerModal.new } @@ -83,12 +83,12 @@ def working_days_setting expect(working_days_setting).to eq([1, 2, 3, 4, 5]) - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSSmtwtfss | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSSmtwtfss | earliest_work_package | XXXXX | second_work_package | XX..XX | follower | XXX | - CHART + TABLE end it "updates the values and saves the settings" do @@ -114,12 +114,12 @@ def working_days_setting expect(working_days_setting).to eq([2, 3, 4]) - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSSmtwtfssmtwt | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSSmtwtfssmtwt | earliest_work_package | XXX....XX | second_work_package | X....XXX | follower | XXX | - CHART + TABLE # The updated work packages will have a journal entry informing about the change wp_page = Pages::FullWorkPackage.new(earliest_work_package) @@ -154,12 +154,12 @@ def working_days_setting expect(page).to have_unchecked_field "Sunday" expect(working_days_setting).to eq([1, 2, 3, 4, 5]) - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSSmtwtfss | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSSmtwtfss | earliest_work_package | XXXXX | second_work_package | XX..XX | follower | XXX | - CHART + TABLE end it "shows an error when a previous change to the working days configuration isn't processed yet" do diff --git a/spec/features/work_packages/datepicker/datepicker_follows_relation_spec.rb b/spec/features/work_packages/datepicker/datepicker_follows_relation_spec.rb index 75bb0ebe55be..f2227df0c49e 100644 --- a/spec/features/work_packages/datepicker/datepicker_follows_relation_spec.rb +++ b/spec/features/work_packages/datepicker/datepicker_follows_relation_spec.rb @@ -79,7 +79,7 @@ end context "if the follower is a task" do - let!(:follower) { create(:work_package, type:, project:) } + let!(:follower) { create(:work_package, type:, project:, schedule_manually: false) } let!(:relation) { create(:follows_relation, from: follower, to: predecessor) } let(:date_field) { work_packages_page.edit_field(:combinedDate) } @@ -87,7 +87,7 @@ end context "if the follower is a milestone" do - let!(:follower) { create(:work_package, type: milestone_type, project:) } + let!(:follower) { create(:work_package, type: milestone_type, project:, schedule_manually: false) } let!(:relation) { create(:follows_relation, from: follower, to: predecessor) } let(:date_field) { work_packages_page.edit_field(:date) } diff --git a/spec/features/work_packages/datepicker/datepicker_parent_spec.rb b/spec/features/work_packages/datepicker/datepicker_parent_spec.rb index d39af634704e..17aaa6c74d55 100644 --- a/spec/features/work_packages/datepicker/datepicker_parent_spec.rb +++ b/spec/features/work_packages/datepicker/datepicker_parent_spec.rb @@ -62,7 +62,7 @@ datepicker.expect_visible end - context "with the child having set dates" do + context "with the child having set dates and the parent being scheduled automatically" do let(:child_attributes) do { start_date: "2021-02-01", @@ -71,17 +71,46 @@ } end - it "disables the non working days options" do - datepicker.expect_ignore_non_working_days_disabled - datepicker.expect_scheduling_mode false + context "when the parent is scheduled automatically" do + let(:parent_attributes) do + { + schedule_manually: false + } + end - first_monday = Time.zone.today.beginning_of_month.next_occurring(:monday) - datepicker.expect_disabled(first_monday) + it "disables the non-working days options" do + datepicker.expect_ignore_non_working_days_disabled + datepicker.expect_automatic_scheduling_mode - datepicker.toggle_scheduling_mode - datepicker.expect_scheduling_mode true + first_monday = Time.zone.today.beginning_of_month.next_occurring(:monday) + datepicker.expect_disabled(first_monday) - datepicker.expect_not_disabled(first_monday) + datepicker.toggle_scheduling_mode + datepicker.expect_manual_scheduling_mode + + datepicker.expect_not_disabled(first_monday) + end + end + + context "when the parent is scheduled manually" do + let(:parent_attributes) do + { + schedule_manually: true + } + end + + it "enables the non-working days options" do + datepicker.expect_ignore_non_working_days_enabled + datepicker.expect_manual_scheduling_mode + + first_monday = Time.zone.today.beginning_of_month.next_occurring(:monday) + datepicker.expect_not_disabled(first_monday) + + datepicker.toggle_scheduling_mode + datepicker.expect_automatic_scheduling_mode + + datepicker.expect_disabled(first_monday) + end end end end diff --git a/spec/features/work_packages/scheduling/scheduling_mode_spec.rb b/spec/features/work_packages/scheduling/scheduling_mode_spec.rb index 816cfc664f8a..4de33ee99eda 100644 --- a/spec/features/work_packages/scheduling/scheduling_mode_spec.rb +++ b/spec/features/work_packages/scheduling/scheduling_mode_spec.rb @@ -52,6 +52,7 @@ let!(:wp) do create(:work_package, project:, + schedule_manually: false, # because parent of wp_child and follows wp_pre start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05"), parent: wp_parent) @@ -59,12 +60,14 @@ let!(:wp_parent) do create(:work_package, project:, + schedule_manually: false, # because parent of wp start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05")) end let!(:wp_child) do create(:work_package, project:, + schedule_manually: false, # because needed to have rescheduling working start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05"), parent: wp) @@ -80,6 +83,7 @@ let!(:wp_suc) do create(:work_package, project:, + schedule_manually: false, # because parent of wp_suc_child and follows wp start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10"), parent: wp_suc_parent).tap do |suc| @@ -89,12 +93,14 @@ let!(:wp_suc_parent) do create(:work_package, project:, + schedule_manually: false, # because parent of wp_suc start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10")) end let!(:wp_suc_child) do create(:work_package, project:, + schedule_manually: false, # because needed to have rescheduling working start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10"), parent: wp_suc) diff --git a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb index 206306393551..bc60351eee80 100644 --- a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb +++ b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb @@ -10,7 +10,8 @@ create(:work_package, project:, type:, - subject: "Parent") + subject: "Parent", + schedule_manually: false) end let!(:child) do @@ -117,8 +118,4 @@ expect(parent.due_date.iso8601).to eq("2020-07-25") end end - - context "with a user allowed to view only" do - let(:role) { create(:project_role, permissions: %i[view_work_packages]) } - end end diff --git a/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb b/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb index d429d80fa309..6008b134cf45 100644 --- a/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb @@ -213,6 +213,10 @@ end context "when scheduled automatically" do + before do + work_package.schedule_manually = false + end + it "is not writable" do expect(subject).not_to be_writable(:start_date) end @@ -246,6 +250,10 @@ end context "when scheduled automatically" do + before do + work_package.schedule_manually = false + end + it "is not writable" do expect(subject).not_to be_writable(:due_date) end @@ -277,11 +285,18 @@ allow(work_package.type).to receive(:is_milestone?).and_return(true) end - it "is not writable when the work package is a parent" do + it "is not writable when the work package is a parent scheduled automatically" do allow(work_package).to receive(:leaf?).and_return(false) + work_package.schedule_manually = false expect(subject).not_to be_writable(:date) end + it "is writable when the work package is a parent scheduled manually" do + allow(work_package).to receive(:leaf?).and_return(false) + work_package.schedule_manually = true + expect(subject).to be_writable(:date) + end + it "is writable when the work package is a leaf" do allow(work_package).to receive(:leaf?).and_return(true) expect(subject).to be_writable(:date) diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index bab471f28052..8f51d07c450b 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -227,18 +227,18 @@ context "for 2 work packages following each other with distant dates" do shared_let_work_packages(<<~TABLE) - subject | MTWTFSS | properties - predecessor 1 | XX | - follower 1 | XX | follows predecessor 1 + subject | MTWTFSS | scheduling mode | properties + predecessor 1 | XX | manual | + follower 1 | XX | automatic | follows predecessor 1 # only start dates - predecessor 2 | [ | - follower 2 | [ | follows predecessor 2 + predecessor 2 | [ | manual | + follower 2 | [ | automatic | follows predecessor 2 # only due dates # if lag is already set, it's overwritten - predecessor 3 | ] | - follower 3 | ] | follows predecessor 3 with lag 2 + predecessor 3 | ] | manual | + follower 3 | ] | automatic | follows predecessor 3 with lag 2 TABLE it "sets a lag to the relation to ensure the distance is kept" do @@ -270,18 +270,18 @@ context "for 2 work packages following each other with missing dates" do let_work_packages(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | scheduling mode | properties # only predecessor has dates - predecessor 1 | XX | - follower 1 | | follows predecessor 1 + predecessor 1 | XX | manual | + follower 1 | | automatic | follows predecessor 1 # only successor has dates - predecessor 2 | | - follower 2 | XX | follows predecessor 2 + predecessor 2 | | manual | + follower 2 | XX | automatic | follows predecessor 2 # none have dates - predecessor 3 | | - follower 3 | | follows predecessor 3 with lag 2 + predecessor 3 | | manual | + follower 3 | | automatic | follows predecessor 3 with lag 2 TABLE it "does not change the existing lag" do @@ -295,11 +295,11 @@ context "for a work package following multiple work packages" do shared_let_work_packages(<<~TABLE) - subject | MTWTFSS | properties - predecessor 1 | XX | - predecessor 2 | XX | - predecessor 3 | X | - follower | XX | follows predecessor 1, follows predecessor 2, follows predecessor 3 + subject | MTWTFSS | scheduling mode | properties + predecessor 1 | XX | manual | + predecessor 2 | XX | manual | + predecessor 3 | X | manual | + follower | XX | automatic | follows predecessor 1, follows predecessor 2, follows predecessor 3 TABLE it "sets a lag only to the closest relation" do diff --git a/spec/models/relation_spec.rb b/spec/models/relation_spec.rb index 6d8681a5f407..53e145c7e070 100644 --- a/spec/models/relation_spec.rb +++ b/spec/models/relation_spec.rb @@ -131,38 +131,44 @@ end describe "#successor_soonest_start" do + let(:monday) { Date.current.next_occurring(:monday) } + let(:tuesday) { monday + 1.day } + let(:wednesday) { monday + 2.days } + let(:thursday) { monday + 3.days } + let(:friday) { monday + 4.days } + context "with a follows relation" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | properties main | ] | follower | | follows main - CHART + TABLE it "returns predecessor due_date + 1" do - relation = schedule.follows_relation(from: "follower", to: "main") - expect(relation.successor_soonest_start).to eq(schedule.tuesday) + relation = _table.relation(successor: "follower") + expect(relation.successor_soonest_start).to eq(tuesday) end end context "with a follows relation with predecessor having only start date" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | properties main | [ | follower | | follows main - CHART + TABLE it "returns predecessor start_date + 1" do - relation = schedule.follows_relation(from: "follower", to: "main") - expect(relation.successor_soonest_start).to eq(schedule.tuesday) + relation = _table.relation(successor: "follower") + expect(relation.successor_soonest_start).to eq(tuesday) end end context "with a non-follows relation" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | main | X | related | | - CHART + TABLE let(:relation) { create(:relation, from: main, to: related) } it "returns nil" do @@ -171,77 +177,77 @@ end context "with a follows relation with a lag" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | properties main | X | follower_a | | follows main with lag 0 follower_b | | follows main with lag 1 follower_c | | follows main with lag 3 - CHART + TABLE it "returns predecessor due_date + lag + 1" do - relation_a = schedule.follows_relation(from: "follower_a", to: "main") - expect(relation_a.successor_soonest_start).to eq(schedule.tuesday) + relation_a = _table.relation(successor: "follower_a") + expect(relation_a.successor_soonest_start).to eq(tuesday) - relation_b = schedule.follows_relation(from: "follower_b", to: "main") - expect(relation_b.successor_soonest_start).to eq(schedule.wednesday) + relation_b = _table.relation(successor: "follower_b") + expect(relation_b.successor_soonest_start).to eq(wednesday) - relation_c = schedule.follows_relation(from: "follower_c", to: "main") - expect(relation_c.successor_soonest_start).to eq(schedule.friday) + relation_c = _table.relation(successor: "follower_c") + expect(relation_c.successor_soonest_start).to eq(friday) end end context "with a follows relation with a lag and with non-working days in the lag period" do - let_schedule(<<~CHART) - days | MTWTFSSmtw | - main | X░ ░ ░░ ░ | + let_work_packages(<<~TABLE) + subject | MTWTFSSmtw | properties + main | X░ ░ ░░ ░ | follower_lag0 | ░ ░ ░░ ░ | follows main with lag 0 follower_lag1 | ░ ░ ░░ ░ | follows main with lag 1 follower_lag2 | ░ ░ ░░ ░ | follows main with lag 2 follower_lag3 | ░ ░ ░░ ░ | follows main with lag 3 - CHART + TABLE it "returns a date such as the number of working days between both work package is equal to the lag" do set_work_week("monday", "wednesday", "friday") - relation_lag0 = schedule.follows_relation(from: "follower_lag0", to: "main") - expect(relation_lag0.successor_soonest_start).to eq(schedule.wednesday) + relation_lag0 = _table.relation(successor: "follower_lag0") + expect(relation_lag0.successor_soonest_start).to eq(wednesday) - relation_lag1 = schedule.follows_relation(from: "follower_lag1", to: "main") - expect(relation_lag1.successor_soonest_start).to eq(schedule.friday) + relation_lag1 = _table.relation(successor: "follower_lag1") + expect(relation_lag1.successor_soonest_start).to eq(friday) - relation_lag2 = schedule.follows_relation(from: "follower_lag2", to: "main") - expect(relation_lag2.successor_soonest_start).to eq(schedule.monday + 7.days) + relation_lag2 = _table.relation(successor: "follower_lag2") + expect(relation_lag2.successor_soonest_start).to eq(monday + 7.days) - relation_lag3 = schedule.follows_relation(from: "follower_lag3", to: "main") - expect(relation_lag3.successor_soonest_start).to eq(schedule.wednesday + 7.days) + relation_lag3 = _table.relation(successor: "follower_lag3") + expect(relation_lag3.successor_soonest_start).to eq(wednesday + 7.days) end end - context "with a follows relation with a lag, non-working days, and follower ignoring non-working days" do - let_schedule(<<~CHART) - days | MTWTFSSmtw | - main | X░ ░ ░░ ░ | - follower_lag0 | ░ ░ ░░ ░ | follows main with lag 0, working days include weekends - follower_lag1 | ░ ░ ░░ ░ | follows main with lag 1, working days include weekends - follower_lag2 | ░ ░ ░░ ░ | follows main with lag 2, working days include weekends - follower_lag3 | ░ ░ ░░ ░ | follows main with lag 3, working days include weekends - CHART + context "with a follows relation with a lag, non-working days, and followers ignoring non-working days" do + let_work_packages(<<~TABLE) + subject | MTWTFSSmtw | days counting | properties + main | X░ ░ ░░ ░ | working days only | + follower_lag0 | ░ ░ ░░ ░ | all days | follows main with lag 0 + follower_lag1 | ░ ░ ░░ ░ | all days | follows main with lag 1 + follower_lag2 | ░ ░ ░░ ░ | all days | follows main with lag 2 + follower_lag3 | ░ ░ ░░ ░ | all days | follows main with lag 3 + TABLE it "returns predecessor due_date + lag + 1 (like without non-working days)" do set_work_week("monday", "wednesday", "friday") - relation_lag0 = schedule.follows_relation(from: "follower_lag0", to: "main") - expect(relation_lag0.successor_soonest_start).to eq(schedule.tuesday) + relation_lag0 = _table.relation(successor: "follower_lag0") + expect(relation_lag0.successor_soonest_start).to eq(tuesday) - relation_lag1 = schedule.follows_relation(from: "follower_lag1", to: "main") - expect(relation_lag1.successor_soonest_start).to eq(schedule.wednesday) + relation_lag1 = _table.relation(successor: "follower_lag1") + expect(relation_lag1.successor_soonest_start).to eq(wednesday) - relation_lag2 = schedule.follows_relation(from: "follower_lag2", to: "main") - expect(relation_lag2.successor_soonest_start).to eq(schedule.thursday) + relation_lag2 = _table.relation(successor: "follower_lag2") + expect(relation_lag2.successor_soonest_start).to eq(thursday) - relation_lag3 = schedule.follows_relation(from: "follower_lag3", to: "main") - expect(relation_lag3.successor_soonest_start).to eq(schedule.friday) + relation_lag3 = _table.relation(successor: "follower_lag3") + expect(relation_lag3.successor_soonest_start).to eq(friday) end end end diff --git a/spec/models/work_package/work_package_acts_as_journalized_spec.rb b/spec/models/work_package/work_package_acts_as_journalized_spec.rb index 076ee1464f18..dfcca6fbf18b 100644 --- a/spec/models/work_package/work_package_acts_as_journalized_spec.rb +++ b/spec/models/work_package/work_package_acts_as_journalized_spec.rb @@ -61,22 +61,22 @@ it "notes the changes to subject" do expect(work_package.last_journal.details[:subject]) - .to contain_exactly(nil, work_package.subject) + .to eq([nil, work_package.subject]) end it "notes the changes to project" do expect(work_package.last_journal.details[:project_id]) - .to contain_exactly(nil, work_package.project_id) + .to eq([nil, work_package.project_id]) end it "notes the description" do expect(work_package.last_journal.details[:description]) - .to contain_exactly(nil, work_package.description) + .to eq([nil, work_package.description]) end it "notes the scheduling mode" do expect(work_package.last_journal.details[:schedule_manually]) - .to contain_exactly(nil, false) + .to eq([nil, true]) end it "has the timestamp of the work package update time for created_at" do @@ -174,7 +174,7 @@ work_package.assigned_to = User.current work_package.responsible = User.current work_package.parent = parent_work_package - work_package.schedule_manually = true + work_package.schedule_manually = false work_package.save! end @@ -191,68 +191,26 @@ end end - shared_examples_for "old value" do - subject { work_package.last_journal.old_value_for(property) } - - it { is_expected.to eq(expected_value) } - end - - shared_examples_for "new value" do - subject { work_package.last_journal.new_value_for(property) } - - it { is_expected.to eq(expected_value) } - end - - describe "journaled value for" do - describe "description" do - let(:property) { "description" } - - context "for old value" do - let(:expected_value) { "Description" } - - it_behaves_like "old value" - end - - context "for new value" do - let(:expected_value) { "changed" } - - it_behaves_like "new value" - end - end - - describe "schedule_manually" do - let(:property) { "schedule_manually" } - - context "for old value" do - let(:expected_value) { false } - - it_behaves_like "old value" - end - - context "for new value" do - let(:expected_value) { true } - - it_behaves_like "new value" - end - end - - describe "duration" do - let(:property) { "duration" } - - context "for old value" do - let(:expected_value) { 1 } - - it_behaves_like "old value" - end - - context "for new value" do - let(:expected_value) { 8 } - - it_behaves_like "new value" + shared_examples_for "journaled value for" do |property:, expected_old_value:, expected_new_value:| + context "for #{property}", :aggregate_failures do + it "tracks the change from old value #{expected_old_value.inspect} to new value #{expected_new_value.inspect}" do + journal = work_package.last_journal + expect(journal.old_value_for(property)).to eq(expected_old_value) + expect(journal.new_value_for(property)).to eq(expected_new_value) end end end + include_examples "journaled value for", property: "description", + expected_old_value: "Description", + expected_new_value: "changed" + include_examples "journaled value for", property: "schedule_manually", + expected_old_value: true, + expected_new_value: false + include_examples "journaled value for", property: "duration", + expected_old_value: 1, + expected_new_value: 8 + describe "adding journal with a missing journal and an existing journal" do before do allow(WorkPackages::UpdateContract).to receive(:new).and_return(NoopContract.new) diff --git a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb index 56db1379d26a..2d998f621f91 100644 --- a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb +++ b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb @@ -50,9 +50,9 @@ end it "returns work packages having start date or due date being in the given days of week" do - schedule = - create_schedule(<<~CHART) - days | MTWTFSS | + table = + create_table(<<~TABLE) + subject | MTWTFSS | covered1 | XX | covered2 | XX | covered3 | X | @@ -62,41 +62,41 @@ not_covered2 | X | not_covered3 | XX | not_covered4 | | - CHART + TABLE expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday])) .to contain_exactly( - schedule.work_package("covered1"), - schedule.work_package("covered2"), - schedule.work_package("covered3"), - schedule.work_package("covered4"), - schedule.work_package("covered5") + table.work_package("covered1"), + table.work_package("covered2"), + table.work_package("covered3"), + table.work_package("covered4"), + table.work_package("covered5") ) end it "returns work packages having days between start date and due date being in the given days of week" do - schedule = - create_schedule(<<~CHART) - days | MTWTFSS | + table = + create_table(<<~TABLE) + subject | MTWTFSS | covered1 | XXXX | covered2 | XXX | not_covered1 | XX | not_covered2 | X | - CHART + TABLE expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :wednesday])) .to contain_exactly( - schedule.work_package("covered1"), - schedule.work_package("covered2") + table.work_package("covered1"), + table.work_package("covered2") ) end context "if work package ignores non working days" do it "does not returns it" do - create_schedule(<<~CHART) - days | MTWTFSS | - not_covered | XXXXXXX | working days include weekends - CHART + create_table(<<~TABLE) + subject | MTWTFSS | days counting + not_covered | XXXXXXX | all days + TABLE expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:wednesday])) .to eq([]) @@ -104,47 +104,47 @@ end it "does not return work packages having follows relation covering the given days of week" do - create_schedule(<<~CHART) - days | MTWTFSS | + create_table(<<~TABLE) + subject | MTWTFSS | properties not_covered1 | X | follower1 | X | follows not_covered1 not_covered2 | X | follower2 | X | follows not_covered2 - CHART + TABLE expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :thursday])) .to eq([]) end it "does not return work packages having follows relation with lag covering the given days of week" do - create_schedule(<<~CHART) - days | MTWTFSS | + create_table(<<~TABLE) + subject | MTWTFSS | properties not_covered1 | X | follower1 | X | follows not_covered1 with lag 3 not_covered2 | X | follower2 | X | follows not_covered2 with lag 1 - CHART + TABLE expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :thursday])) .to eq([]) end it "accepts a single day of week or an array of days" do - schedule = - create_schedule(<<~CHART) - days | MTWTFSS | + table = + create_table(<<~TABLE) + subject | MTWTFSS | covered | X | not_covered | X | - CHART + TABLE single_value = day_args[:tuesday].transform_values { |v| Array(v).first } expect(WorkPackage.covering_dates_and_days_of_week(**single_value)) - .to eq([schedule.work_package("covered")]) + .to eq([table.work_package("covered")]) expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday])) - .to eq([schedule.work_package("covered")]) + .to eq([table.work_package("covered")]) expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :wednesday])) - .to eq([schedule.work_package("covered")]) + .to eq([table.work_package("covered")]) end end diff --git a/spec/models/work_packages/scopes/for_scheduling_spec.rb b/spec/models/work_packages/scopes/for_scheduling_spec.rb index fb9c4ed6609a..ba81c895e306 100644 --- a/spec/models/work_packages/scopes/for_scheduling_spec.rb +++ b/spec/models/work_packages/scopes/for_scheduling_spec.rb @@ -31,72 +31,72 @@ RSpec.describe WorkPackages::Scopes::ForScheduling, "allowed scope" do create_shared_association_defaults_for_work_package_factory - let(:project) { create(:project) } - let(:origin) { create(:work_package, project:) } + shared_let(:origin) { create(:work_package, subject: "origin") } + let(:predecessor) do - create(:work_package, project:).tap do |pre| + create(:work_package, subject: "predecessor").tap do |pre| create(:follows_relation, from: origin, to: pre) end end let(:parent) do - create(:work_package, project:).tap do |par| + create(:work_package, subject: "parent", schedule_manually: false).tap do |par| origin.update(parent: par) end end let(:grandparent) do - create(:work_package, project:).tap do |grand| + create(:work_package, subject: "grandparent", schedule_manually: false).tap do |grand| parent.update(parent: grand) end end let(:successor) do - create(:work_package, project:).tap do |suc| + create(:work_package, subject: "successor", schedule_manually: false).tap do |suc| create(:follows_relation, from: suc, to: origin) end end let(:successor2) do - create(:work_package, project:).tap do |suc| + create(:work_package, subject: "successor2", schedule_manually: false).tap do |suc| create(:follows_relation, from: suc, to: origin) end end let(:successor_parent) do - create(:work_package, project:).tap do |par| + create(:work_package, subject: "successor_parent", schedule_manually: false).tap do |par| successor.update(parent: par) end end let(:successor_child) do - create(:work_package, project:, parent: successor) + create(:work_package, subject: "successor_child", parent: successor) end let(:successor_grandchild) do - create(:work_package, project:, parent: successor_child) + create(:work_package, subject: "successor_grandchild", parent: successor_child) end let(:successor_child2) do - create(:work_package, project:, parent: successor) + create(:work_package, subject: "successor_child2", parent: successor) end let(:successor_successor) do - create(:work_package, project:).tap do |suc| + create(:work_package, subject: "successor_successor", schedule_manually: false).tap do |suc| create(:follows_relation, from: suc, to: successor) end end let(:parent_successor) do - create(:work_package, project:).tap do |suc| + create(:work_package, subject: "parent_successor", schedule_manually: false).tap do |suc| create(:follows_relation, from: suc, to: parent) end end let(:parent_successor_parent) do - create(:work_package, project:).tap do |par| + create(:work_package, subject: "parent_successor_parent", schedule_manually: false).tap do |par| parent_successor.update(parent: par) end end let(:parent_successor_child) do - create(:work_package, project:, parent: parent_successor) + create(:work_package, subject: "parent_successor_child", parent: parent_successor) end let(:blocker) do - create(:work_package, project:).tap do |blo| + create(:work_package, subject: "blocker").tap do |blo| create(:relation, relation_type: "blocks", from: blo, to: origin) end end let(:includer) do - create(:work_package, project:).tap do |inc| + create(:work_package, subject: "includer").tap do |inc| create(:relation, relation_type: "includes", from: inc, to: origin) end end @@ -115,55 +115,92 @@ end end - context "for a work package with a predecessor" do - let!(:existing_work_packages) { [predecessor] } + shared_examples "direct relations behaviors" do + context "with a predecessor" do + let!(:existing_work_packages) { [predecessor] } - it "is empty" do - expect(WorkPackage.for_scheduling([origin])) - .to be_empty + it "is empty" do + expect(WorkPackage.for_scheduling([origin])) + .to be_empty + end end - end - context "for a work package with a parent" do - let!(:existing_work_packages) { [parent] } + context "with a parent scheduled automatically" do + let!(:existing_work_packages) { [parent] } - it "consists of the parent" do - expect(WorkPackage.for_scheduling([origin])) - .to contain_exactly(parent) + it "consists of the parent" do + expect(WorkPackage.for_scheduling([origin])) + .to contain_exactly(parent) + end end - end - context "for a work package with a successor" do - let!(:existing_work_packages) { [successor] } + context "with a parent scheduled manually" do + let!(:existing_work_packages) { [parent] } - it "consists of the successor" do - expect(WorkPackage.for_scheduling([origin])) - .to contain_exactly(successor) + before do + parent.update_column(:schedule_manually, true) + end + + it "is empty" do + expect(WorkPackage.for_scheduling([origin])) + .to be_empty + end end - end - context "for a work package with a blocking work package" do - let!(:existing_work_packages) { [blocker] } + context "with a successor" do + let!(:existing_work_packages) { [successor] } - it "is empty" do - expect(WorkPackage.for_scheduling([origin])) - .to be_empty + it "consists of the successor" do + expect(WorkPackage.for_scheduling([origin])) + .to contain_exactly(successor) + end + end + + context "with a blocking work package" do + let!(:existing_work_packages) { [blocker] } + + it "is empty" do + expect(WorkPackage.for_scheduling([origin])) + .to be_empty + end + end + + context "with an including work package" do + let!(:existing_work_packages) { [includer] } + + it "is empty" do + expect(WorkPackage.for_scheduling([origin])) + .to be_empty + end end end - context "for a work package with an including work package" do - let!(:existing_work_packages) { [includer] } + context "for an automatically scheduled work package" do + before do + origin.update_column(:schedule_manually, false) + end - it "is empty" do - expect(WorkPackage.for_scheduling([origin])) - .to be_empty + include_examples "direct relations behaviors" + end + + context "for a manually scheduled work package" do + before do + origin.update_column(:schedule_manually, true) end + + include_examples "direct relations behaviors" end context "for a work package with a successor which has parent and child" do let!(:existing_work_packages) { [successor, successor_child, successor_parent] } context "with all scheduled automatically" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + it "consists of the successor, its child and parent" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_parent) @@ -181,9 +218,10 @@ end end - context "with the successor's parent scheduled manually" do + context "with the successor's parent scheduled manually and child scheduled automatically" do before do successor_parent.update_column(:schedule_manually, true) + successor_child.update_column(:schedule_manually, false) end it "consists of the successor and its child" do @@ -212,15 +250,22 @@ end context "with all scheduled automatically" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + it "consists of the successor, its child and parent and the successor successor" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_parent, successor_successor) end end - context "with successor parent scheduled manually" do + context "with successor parent scheduled manually and child scheduled automatically" do before do successor_parent.update_column(:schedule_manually, true) + successor_child.update_column(:schedule_manually, false) end it "consists of the successor, its child and successor successor" do @@ -249,7 +294,13 @@ end context "with all scheduled automatically" do - it "consists of the successor, its child and parent" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + + it "consists of the successor and its parent" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_parent) end @@ -294,24 +345,31 @@ let!(:existing_work_packages) { [successor, successor_child, successor_child2, successor_successor] } context "with all scheduled automatically" do - it "consists of the successor, its child and the successor˚s successor" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + + it "consists of the successor, its child and the successor's successor" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_child2, successor_successor) end end - context "with one of the successor`s children scheduled manually" do + context "with one of the successor's children scheduled manually and one automatically" do before do successor_child2.update_column(:schedule_manually, true) + successor_child.update_column(:schedule_manually, false) end - it "consists of the successor, its automatically scheduled child and the successor˚s successor" do + it "consists of the successor, its automatically scheduled child and the successor's successor" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor_child, successor, successor_successor) end end - context "with both of the successor`s children scheduled manually" do + context "with both of the successor's children scheduled manually" do before do successor_child.update_column(:schedule_manually, true) successor_child2.update_column(:schedule_manually, true) @@ -343,9 +401,15 @@ end context "for a work package with a parent which has a successor which has parent and child" do - let!(:existing_work_packages) { [parent, parent_successor, parent_successor_child, parent_successor_parent] } + let!(:existing_work_packages) { [parent, parent_successor, parent_successor_parent, parent_successor_child] } context "with all scheduled automatically" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + it "consists of the parent, self and the whole parent successor hierarchy" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(parent, parent_successor, parent_successor_parent, parent_successor_child) @@ -390,6 +454,12 @@ let!(:existing_work_packages) { [successor, successor_successor] } context "with all scheduled automatically" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + it "consists of both successors" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_successor) @@ -423,15 +493,22 @@ let!(:existing_work_packages) { [successor, successor_child, successor_grandchild] } context "with all scheduled automatically" do - it "consists of both successors" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + + it "consists of the successor and its 2 descendants" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_grandchild) end end - context "with the successor's child scheduled manually" do + context "with the successor's child scheduled manually and grand child scheduled automatically" do before do successor_child.update_column(:schedule_manually, true) + successor_grandchild.update_column(:schedule_manually, false) end it "contains the successor" do @@ -443,42 +520,60 @@ context "for a work package with a successor that has a child and two grandchildren" do let(:successor_grandchild2) do - create(:work_package, project:, parent: successor_child) + create(:work_package, subject: "successor_grandchild2", parent: successor_child) end let!(:existing_work_packages) { [successor, successor_child, successor_grandchild, successor_grandchild2] } context "with all scheduled automatically" do - it "consists of the successor with its descendants" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + + it "consists of the successor with its 3 descendants" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_grandchild, successor_grandchild2) end end - context "with one of the successor's grandchildren scheduled manually" do + context "with the successor's child scheduled automatically, " \ + "one of the successor's grandchildren scheduled manually " \ + "and the other one scheduled automatically" do before do + successor_child.update_column(:schedule_manually, false) successor_grandchild.update_column(:schedule_manually, true) + successor_grandchild2.update_column(:schedule_manually, false) end - it "contains the successor and the non automatically scheduled descendants" do + it "contains the successor and the automatically scheduled descendants" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor, successor_child, successor_grandchild2) end end - context "with both of the successor's grandchildren scheduled manually" do + context "with the successor's child scheduled automatically " \ + "and both of the successor's grandchildren scheduled manually" do before do + successor_child.update_column(:schedule_manually, false) successor_grandchild.update_column(:schedule_manually, true) successor_grandchild2.update_column(:schedule_manually, true) end - it "includes successor" do + # It should return an empty array as the successor dates will always be + # its manually scheduled child's dates, but it does not cause any harm + # to return the successor. It will be processed for rescheduling but + # none of its dates will change. + # + # The SQL is quite complex and I am not sure it's worth fixing. + it "consists of the successor" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor) end end - context "with both of the successor's grandchildren and child scheduled manually" do + context "with the successor's child and both of the successor's grandchildren scheduled manually" do before do successor_child.update_column(:schedule_manually, true) successor_grandchild.update_column(:schedule_manually, true) @@ -491,11 +586,20 @@ end end - context "with the successor's child scheduled manually" do + context "with the successor's child scheduled manually " \ + "and both of the successor's grandchildren scheduled automatically" do before do successor_child.update_column(:schedule_manually, true) + successor_grandchild.update_column(:schedule_manually, false) + successor_grandchild2.update_column(:schedule_manually, false) end + # It should return an empty array as the successor child should be + # considered manually scheduled, but it does not cause any harm to + # return the successor. It will be processed for rescheduling but none + # of its dates will change. + # + # The SQL is quite complex and I am not sure if it is worth fixing. it "contains the successor" do expect(WorkPackage.for_scheduling([origin])) .to contain_exactly(successor) @@ -505,17 +609,25 @@ context "for a work package with a sibling and a successor that also has a sibling" do let(:sibling) do - create(:work_package, project:, parent:) + create(:work_package, subject: "sibling", parent:) end let(:successor_sibling) do - create(:work_package, project:, parent: successor_parent) + create(:work_package, subject: "successor_sibling", parent: successor_parent) end let!(:existing_work_packages) { [parent, sibling, successor, successor_parent, successor_sibling] } - it "contains the successor and the parents but not the siblings" do - expect(WorkPackage.for_scheduling([origin])) - .to contain_exactly(successor, parent, successor_parent) + context "with all scheduled automatically" do + before do + existing_work_packages.each do |wp| + wp.update_column(:schedule_manually, false) + end + end + + it "contains the successor and the parents but not the siblings" do + expect(WorkPackage.for_scheduling([origin])) + .to contain_exactly(successor, parent, successor_parent) + end end end end diff --git a/spec/requests/api/v3/work_packages/create_resource_spec.rb b/spec/requests/api/v3/work_packages/create_resource_spec.rb index de07c44385a5..94e62e3e3592 100644 --- a/spec/requests/api/v3/work_packages/create_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/create_resource_spec.rb @@ -187,7 +187,7 @@ # mind the () for the super call, those are required in rspec's super let(:parameters) { super().merge(scheduleManually: true) } - it "sets the scheduling mode to true" do + it "sets the scheduling mode to manual (schedule_manually: true)" do expect(work_package.schedule_manually).to be true end end @@ -195,14 +195,14 @@ context "with false" do let(:parameters) { super().merge(scheduleManually: false) } - it "sets the scheduling mode to false" do + it "sets the scheduling mode to automatic (schedule_manually: false)" do expect(work_package.schedule_manually).to be false end end context "with scheduleManually absent" do - it "sets the scheduling mode to false (default)" do - expect(work_package.schedule_manually).to be false + it "sets the scheduling mode to manual (schedule_manually: true, the default)" do + expect(work_package.schedule_manually).to be true end end end diff --git a/spec/services/work_packages/create_service_integration_spec.rb b/spec/services/work_packages/create_service_integration_spec.rb index 7649de1538b5..bf4e69b81b22 100644 --- a/spec/services/work_packages/create_service_integration_spec.rb +++ b/spec/services/work_packages/create_service_integration_spec.rb @@ -50,6 +50,7 @@ let(:parent) do create(:work_package, subject: "parent", + schedule_manually: false, project:, type:) end @@ -100,6 +101,7 @@ let(:parent) do create(:work_package, project:, + schedule_manually: false, start_date: "2024-01-01", due_date: "2024-01-10", type: create(:type)) @@ -176,7 +178,7 @@ end it "reports on invalid attachments and sets the new if everything is valid" do - result = instance.call(**attributes.merge(attachment_ids: [other_users_attachment.id])) + result = instance.call(**attributes, attachment_ids: [other_users_attachment.id]) expect(result) .to be_failure @@ -191,7 +193,7 @@ expect(other_users_attachment.reload.container) .to be_nil - result = instance.call(**attributes.merge(attachment_ids: [users_attachment.id])) + result = instance.call(**attributes, attachment_ids: [users_attachment.id]) expect(result) .to be_success diff --git a/spec/services/work_packages/schedule_dependency/dependency_spec.rb b/spec/services/work_packages/schedule_dependency/dependency_spec.rb index e62dabfd6ed5..25f6b394d1fc 100644 --- a/spec/services/work_packages/schedule_dependency/dependency_spec.rb +++ b/spec/services/work_packages/schedule_dependency/dependency_spec.rb @@ -34,7 +34,7 @@ # +Dependency+ instance per work package that may need to change due to the # move. These dependencies are the subjects under test. RSpec.describe WorkPackages::ScheduleDependency::Dependency do - subject(:dependency) { dependency_for(work_package_used_in_dependency) } + subject(:dependency) { checked_dependency_for(work_package_used_in_dependency) } create_shared_association_defaults_for_work_package_factory @@ -43,36 +43,54 @@ let(:schedule_dependency) { WorkPackages::ScheduleDependency.new(work_package) } def dependency_for(work_package) - dependency = schedule_dependency.dependencies[work_package] - if dependency.nil? - available = schedule_dependency.dependencies.keys.map(&:subject).map(&:inspect).to_sentence - raise ArgumentError, "Unable to find dependency for work package #{work_package.subject.inspect}; " \ - "ScheduleDependency instance has dependencies for work packages #{available}" - end + schedule_dependency.dependencies[work_package] + end - dependency + def checked_dependency_for(work_package) + dependency_for(work_package).tap do |dependency| + if dependency.nil? + available = schedule_dependency.dependencies.keys.map { _1.subject.inspect }.to_sentence + raise ArgumentError, "Unable to find dependency for work package #{work_package.subject.inspect}; " \ + "ScheduleDependency instance has dependencies for work packages #{available}" + end + end end def create_predecessor_of(work_package, **attributes) - create(:work_package, subject: "predecessor of #{work_package.subject}", **attributes).tap do |predecessor| + work_package.update_column(:schedule_manually, false) + create(:work_package, + subject: "predecessor of #{work_package.subject}", + schedule_manually: true, + **attributes).tap do |predecessor| create(:follows_relation, from: work_package, to: predecessor) end end def create_follower_of(work_package, **attributes) - create(:work_package, subject: "follower of #{work_package.subject}", **attributes).tap do |follower| + create(:work_package, + subject: "follower of #{work_package.subject}", + schedule_manually: false, + **attributes).tap do |follower| create(:follows_relation, from: follower, to: work_package) end end def create_parent_of(work_package) - create(:work_package, subject: "parent of #{work_package.subject}").tap do |parent| + create(:work_package, + subject: "parent of #{work_package.subject}", + schedule_manually: false).tap do |parent| work_package.update(parent:) end end - def create_child_of(work_package) - create(:work_package, subject: "child of #{work_package.subject}", parent: work_package) + def create_child_of(work_package, **attributes) + work_package.update_column(:schedule_manually, false) + child_attributes = attributes.reverse_merge( + subject: "child of #{work_package.subject}", + parent: work_package, + schedule_manually: true + ) + create(:work_package, **child_attributes) end describe "#dependent_ids" do @@ -100,9 +118,9 @@ def create_child_of(work_package) end end - context "when the work_package has a follower which has a child" do + context "when the work_package has a follower which has a child automatically scheduled" do let!(:follower) { create_follower_of(work_package) } - let!(:follower_child) { create_child_of(follower) } + let!(:follower_child) { create_child_of(follower, schedule_manually: false) } context "for dependency of the child" do let(:work_package_used_in_dependency) { follower_child } @@ -121,6 +139,25 @@ def create_child_of(work_package) end end + context "when the work_package has a follower which has a child manually scheduled" do + let!(:follower) { create_follower_of(work_package) } + let!(:follower_child) { create_child_of(follower, schedule_manually: true) } + + context "for dependency of the child" do + it "has no dependency as its date do not depend on any other work package" do + expect(dependency_for(follower_child)).to be_nil + end + end + + context "for dependency of the follower" do + let(:work_package_used_in_dependency) { follower } + + it "has its dates do not depend on the moved work package but on the follower child" do + expect(dependency_for(follower)).to be_nil + end + end + end + context "when the work_package has multiple parents and followers" do let!(:first_follower) { create_follower_of(work_package) } let!(:second_follower) { create_follower_of(work_package) } @@ -187,10 +224,10 @@ def create_child_of(work_package) end end - context "when has a predecessor which has a parent and a child" do + context "when has a follower which has a parent and a child automatically scheduled" do let!(:follower) { create_follower_of(work_package) } let!(:follower_parent) { create_parent_of(follower) } - let!(:follower_child) { create_child_of(follower) } + let!(:follower_child) { create_child_of(follower, schedule_manually: false) } context "for dependency of the follower child" do let(:work_package_used_in_dependency) { follower_child } @@ -208,6 +245,28 @@ def create_child_of(work_package) end end end + + context "when has a follower which has a parent and a child manually scheduled" do + let!(:follower) { create_follower_of(work_package) } + let!(:follower_parent) { create_parent_of(follower) } + let!(:follower_child) { create_child_of(follower, schedule_manually: true) } + + context "for dependency of the follower child" do + let(:work_package_used_in_dependency) { follower_child } + + it "has no dependency as its dates do not depend on any other work package (it's manually scheduled)" do + expect(dependency_for(follower_child)).to be_nil + end + end + + context "for dependency of the follower parent" do + let(:work_package_used_in_dependency) { follower_parent } + + it "has no dependency as its dates depend on the follower child, and this one is manually scheduled" do + expect(dependency_for(follower_parent)).to be_nil + end + end + end end end @@ -215,7 +274,7 @@ def create_child_of(work_package) let(:work_package_used_in_dependency) { work_package } before do - work_package.update(due_date: Time.zone.today) + work_package.update(due_date: Date.current) end context "with a moved predecessor" do @@ -228,13 +287,13 @@ def create_child_of(work_package) context "with an unmoved predecessor" do it "returns the soonest start date from the predecessors" do follower = create_follower_of(work_package) - unmoved_follower_predecessor = create_predecessor_of(follower, due_date: Time.zone.today + 4.days) + unmoved_follower_predecessor = create_predecessor_of(follower, due_date: Date.current + 4.days) expect(dependency_for(follower).soonest_start_date).to eq(unmoved_follower_predecessor.due_date + 1.day) end end context "with non working days" do - let!(:tomorrow_we_do_not_work!) { create(:non_working_day, date: Time.zone.tomorrow) } + let!(:tomorrow_we_do_not_work!) { create(:non_working_day, date: Date.tomorrow) } it "returns the soonest start date being a working day" do follower = create_follower_of(work_package) diff --git a/spec/services/work_packages/set_schedule_service_spec.rb b/spec/services/work_packages/set_schedule_service_spec.rb index 29762ad4de22..208c5b28d967 100644 --- a/spec/services/work_packages/set_schedule_service_spec.rb +++ b/spec/services/work_packages/set_schedule_service_spec.rb @@ -89,6 +89,7 @@ def create_follower(start_date, due_date, predecessors, parent: nil) work_package = create(:work_package, subject: "follower of #{predecessors.keys.map(&:subject).to_sentence}", + schedule_manually: false, start_date:, due_date:, parent:) @@ -106,6 +107,7 @@ def create_follower(start_date, due_date, predecessors, parent: nil) def create_parent(child, start_date: child.start_date, due_date: child.due_date) create(:work_package, subject: "parent of #{child.subject}", + schedule_manually: false, start_date:, due_date:).tap do |parent| child.parent = parent @@ -113,12 +115,13 @@ def create_parent(child, start_date: child.start_date, due_date: child.due_date) end end - def create_child(parent, start_date, due_date) + def create_child(parent, start_date, due_date, **attributes) create(:work_package, subject: "child of #{parent.subject}", start_date:, due_date:, - parent:) + parent:, + **attributes) end subject { instance.call(attributes) } @@ -453,9 +456,9 @@ def create_child(parent, start_date, due_date) end end - context "with only a parent" do + context "with only a parent scheduled automatically" do let!(:parent_work_package) do - create(:work_package).tap do |parent| + create(:work_package, subject: "parent", schedule_manually: false).tap do |parent| work_package.parent = parent work_package.save end @@ -475,6 +478,7 @@ def create_child(parent, start_date, due_date) let!(:parent_work_package) do create(:work_package, subject: "parent of #{work_package.subject}", + schedule_manually: false, start_date: Time.zone.today, due_date: Time.zone.today + 1.day).tap do |parent| work_package.parent = parent @@ -591,7 +595,7 @@ def create_child(parent, start_date, due_date) end end - context "with a single successor having a child" do + context "with a single successor having a child scheduled manually" do let(:child_start_date) { follower1_start_date } let(:child_due_date) { follower1_due_date } @@ -602,6 +606,32 @@ def create_child(parent, start_date, due_date) child_work_package] end + context "when moving forward" do + before do + work_package.due_date = Time.zone.today + 5.days + end + + # does not reschedules the child, so the follower keeps its dates + it_behaves_like "does not reschedule" + end + end + + context "with a single successor having a child scheduled automatically" do + let(:child_start_date) { follower1_start_date } + let(:child_due_date) { follower1_due_date } + + let(:child_work_package) do + create_child(following_work_package1, + child_start_date, + child_due_date, + schedule_manually: false) + end + + let!(:following) do + [following_work_package1, + child_work_package] + end + context "when moving forward" do before do work_package.due_date = Time.zone.today + 5.days @@ -616,7 +646,7 @@ def create_child(parent, start_date, due_date) end end - context "with a single successor having two children" do + context "with a single successor having two children scheduled automatically" do let(:follower1_start_date) { work_package_due_date + 1.day } let(:follower1_due_date) { work_package_due_date + 10.days } let(:child1_start_date) { follower1_start_date } @@ -624,8 +654,18 @@ def create_child(parent, start_date, due_date) let(:child2_start_date) { follower1_start_date + 8.days } let(:child2_due_date) { follower1_due_date } - let(:child1_work_package) { create_child(following_work_package1, child1_start_date, child1_due_date) } - let(:child2_work_package) { create_child(following_work_package1, child2_start_date, child2_due_date) } + let(:child1_work_package) do + create_child(following_work_package1, + child1_start_date, + child1_due_date, + schedule_manually: false) + end + let(:child2_work_package) do + create_child(following_work_package1, + child2_start_date, + child2_due_date, + schedule_manually: false) + end let!(:following) do [following_work_package1, @@ -865,6 +905,7 @@ def create_child(parent, start_date, due_date) def create_hierarchy(parent, nb_children_by_levels) nb_children, *remaining_levels = nb_children_by_levels children = create_list(:work_package, nb_children, parent:) + parent.update(schedule_manually: false) if remaining_levels.any? children.each do |child| create_hierarchy(child, remaining_levels) diff --git a/spec/services/work_packages/set_schedule_service_working_days_spec.rb b/spec/services/work_packages/set_schedule_service_working_days_spec.rb index 40fea3fc684d..efd102b97271 100644 --- a/spec/services/work_packages/set_schedule_service_working_days_spec.rb +++ b/spec/services/work_packages/set_schedule_service_working_days_spec.rb @@ -42,943 +42,982 @@ context "with a single successor" do context "when moving successor will cover non-working days" do - let_work_packages(<<~CHART) - subject | MTWTFSS | properties - work_package | XX | - follower | XXX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower | XXX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXX | - CHART + TABLE end it "extends to a later due date to keep the same duration" do - expect_work_packages(subject.all_results, <<~CHART) + expect_work_packages(subject.all_results, <<~TABLE) subject | MTWTFSS | work_package | XXXX | follower | X..XX | - CHART + TABLE expect(follower.duration).to eq(3) end end context "when moved predecessor covers non-working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - follower | XXX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower | XXX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XX..XX | - CHART + TABLE end it "extends to a later due date to keep the same duration" do - expect_schedule(subject.all_results, <<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX..XX | follower | XXX | - CHART + TABLE expect(follower.duration).to eq(3) end end context "when predecessor moved forward" do context "on a day in the middle on working days with the follower having only start date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | [ | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | [ | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXX | - CHART + TABLE end it "reschedules follower to start the next day after its predecessor due date" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XXXX | follower | [ | - CHART + TABLE end end context "on a day just before non working days with the follower having only start date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | [ | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | [ | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXXX | - CHART + TABLE end it "reschedules follower to start after the non working days" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XXXXX | follower | [ | - CHART + TABLE end end context "on a day in the middle of working days with the follower having only due date and no space in between" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | ] | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | ] | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower to start and end right after its predecessor with a default duration of 1 day" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X | - CHART + TABLE end end context "on a day in the middle of working days with the follower having only due date and much space in between" do - let_schedule(<<~CHART) - days | MTWTFSSmt | - work_package | ] | - follower | ] | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmt | scheduling mode | properties + work_package | ] | manual | + follower | ] | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower to start after its predecessor without needing to change the end date" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X..XX | - CHART + TABLE end end context "on a day just before non-working day with the follower having only due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | ] | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | ] | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower to start and end after the non working days with a default duration of 1 day" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X | - CHART + TABLE end end context "with the follower having some space left" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | X..XX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | X..XX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXXX | - CHART + TABLE end it "reschedules follower to start the next working day after its predecessor due date" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XXXXX | follower | XXX | - CHART + TABLE end end context "with the follower having enough space left to not be moved at all" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | XXX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | XXX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXXX..X | - CHART + TABLE end it "does not move follower" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XXXXX..X | - CHART - expect_schedule([follower], <<~CHART) - | MTWTFSS | + TABLE + expect_work_packages([follower], <<~TABLE) + subject | MTWTFSS | follower | XXX | - CHART + TABLE end end context "with the follower having some space left and a lag" do - let_schedule(<<~CHART) - days | MTWTFSSmtwtfss | - work_package | X | - follower | XXX | follows work_package with lag 3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmtwtfss | scheduling mode | properties + work_package | X | manual | + follower | XXX | automatic | follows work_package with lag 3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXXX..X | - CHART + TABLE end it "reschedules the follower to start after the lag" do - expect_schedule(subject.all_results, <<~CHART) - | MTWTFSSmtwtfss | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSmtwtfss | work_package | XXXXX..X | follower | X..XX | - CHART + TABLE end end context "with the follower having a lag overlapping non-working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | XX | follows work_package with lag 2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | XX | automatic | follows work_package with lag 2 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | X | - CHART + TABLE end it "reschedules the follower to start after the non-working days and the lag" do - expect(subject.all_results).to match_schedule(<<~CHART) - | MTWTFSSmtwt | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSmtwt | work_package | X | follower | XX | - CHART + TABLE end end end context "when predecessor moved backwards" do context "on a day right before some non-working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | XX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | XX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | X | - CHART + TABLE end it "does not move the follower" do - expect(subject.all_results).to match_schedule(<<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | X | - CHART + TABLE end end context "on a day before non-working days the follower having space between" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | X | - follower | X | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | X | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | X | - CHART + TABLE end it "does not move the follower" do - expect(subject.all_results).to match_schedule(<<~CHART) - | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | X | - CHART + TABLE end end context "with the follower having another relation limiting movement" do - let_schedule(<<~CHART) - days | mtwtfssmtwtfssMTWTFSS | - work_package | X | - follower | XX | follows work_package, follows annoyer with lag 2 - annoyer | XX..XX | - CHART + let_work_packages(<<~TABLE) + subject | mtwtfssmtwtfssMTWTFSS | scheduling mode | properties + work_package | X | manual | + follower | XX | automatic | follows work_package, follows annoyer with lag 2 + annoyer | XX..XX | manual | + TABLE before do - change_schedule([work_package], <<~CHART) - days | mtwtfssmtwtfssMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | mtwtfssmtwtfssMTWTFSS | work_package | X | - CHART + TABLE end it "does not move the follower" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | mtwtfssmtwtfssMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | mtwtfssmtwtfssMTWTFSS | work_package | X | - CHART + TABLE end end end context "when removing the dates on the moved predecessor" do context "with the follower having start and due dates" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - follower | XXX | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower | XXX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | | - CHART + TABLE end it "does not reschedule and follower keeps its dates" do - expect_schedule(subject.all_results, <<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | | - CHART - expect_schedule([follower], <<~CHART) - days | MTWTFSS | + TABLE + expect_work_packages([follower], <<~TABLE) + subject | MTWTFSS | follower | XXX | - CHART + TABLE end end context "with the follower having only a due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - follower | ] | follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower | ] | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | | - CHART + TABLE end it "does not reschedule and follower keeps its dates" do - expect_schedule(subject.all_results, <<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | | - CHART - expect_schedule([follower], <<~CHART) - days | MTWTFSS | + TABLE + expect_work_packages([follower], <<~TABLE) + subject | MTWTFSS | follower | ] | - CHART + TABLE end end end context "when only creating the relation between predecessor and follower" do context "with follower having no dates" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX | follower | | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "schedules follower to start right after its predecessor and does not set the due date" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | [ | - CHART + TABLE end end context "with follower having only due date before predecessor due date" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX | follower | ] | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "reschedules follower to start right after its predecessor and end the same day" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | X | - CHART + TABLE end end context "with follower having only start date before predecessor due date" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX | follower | [ | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "reschedules follower to start right after its predecessor and leaves the due date unset" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | [ | - CHART + TABLE end end context "with follower having both start and due dates before predecessor due date" do - let_schedule(<<~CHART) - days | mtwtfssMTWTFSS | + let_work_packages(<<~TABLE) + subject | mtwtfssMTWTFSS | work_package | XX | follower | X..XXX | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "reschedules follower to start right after its predecessor and keeps the duration" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | XXX..X | - CHART + TABLE end end context "with follower having due date long after predecessor due date" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX | follower | ] | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "reschedules follower to start right after its predecessor and end the same day" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | XXX | - CHART + TABLE end end context "with predecessor and follower having no dates" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | | follower | | - CHART + TABLE before do create(:follows_relation, from: follower, to: work_package) + follower.update_column(:schedule_manually, false) end it "does not reschedule any work package" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | | - CHART + TABLE end end end context "with the successor having another predecessor which has no dates" do context "when moved forward" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XXX | follows work_package, follows other_predecessor - other_predecessor | | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | XXX | automatic | follows work_package, follows other_predecessor + other_predecessor | | manual | + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower without influence from the other predecessor" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X..XX | - CHART + TABLE end end context "when moved backwards" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XXX | follows work_package, follows other_predecessor - other_predecessor | | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | XXX | automatic | follows work_package, follows other_predecessor + other_predecessor | | manual | + TABLE before do - change_schedule([work_package], <<~CHART) - days | mtwtfssMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end it "does not move the follower" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | mtwtfssMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end end end context "with successor having only duration" do context "when setting dates on predecessor" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | | - follower | | duration 3, follows work_package - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration | scheduling mode | properties + work_package | | | manual | + follower | | 3 | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XX | - CHART + TABLE end it "schedules successor to start after predecessor and keeps the duration (#44479)" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | follower | X..XX | - CHART + TABLE end end end end context "with a parent" do - let_schedule(<<~CHART) - days | MTWTFSS | - parent | | - work_package | ] | child of parent - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode + parent | | automatic + work_package | ] | manual + TABLE before do - change_schedule([work_package], <<~CHART) - days | mtwtfssMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | XXX..X | - CHART + TABLE end it "reschedules parent to have the same dates as the child" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | mtwtfssMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | mtwtfssMTWTFSS | parent | XXX..X | work_package | XXX..X | - CHART + TABLE end end context "with a parent having a follower" do - let_schedule(<<~CHART) - days | MTWTFSS | - parent | XX | - work_package | ] | child of parent - parent_follower | X..XX | follows parent - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + parent | XX | automatic | + work_package | ] | manual | + parent_follower | X..XX | automatic | follows parent + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XXXXX | - CHART + TABLE end it "reschedules parent to have the same dates as the child, and parent follower to start right after parent" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | parent | XXXXX | work_package | XXXXX | parent_follower | XXX | - CHART + TABLE end end context "with a single successor having a parent" do context "when moving forward" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XX | follows work_package, child of follower_parent - follower_parent | XX | - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower_parent | XX | automatic | + follower | XX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower and follower parent to start right after the moved predecessor" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X..X | follower_parent | X..X | - CHART + TABLE end end context "when moving forward with the parent having another child not being moved" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XX | follows work_package, child of follower_parent - follower_sibling | XXX | child of follower_parent - follower_parent | XXXX | - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower_parent | XXXX | automatic | + follower | XX | automatic | follows work_package + follower_sibling | XXX | manual | + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower to start right after the moved predecessor, and follower parent spans on its two children" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | X..X | - follower_parent | XXX..X | - CHART + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | ] | + follower_parent | XXX..X | + follower | X..X | + TABLE + expect_work_packages([follower_sibling], <<~TABLE) + subject | MTWTFSS | + follower_sibling | XXX | + TABLE end end context "when moving backwards" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XX | follows work_package, child of follower_parent - follower_parent | XX | - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower_parent | XX | automatic | + follower | XX | automatic | follows work_package + TABLE before do - change_schedule([work_package], <<~CHART) - days | mtwtfssMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end it "does not reschedule the followers" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | mtwtfssMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end end context "when moving backwards with the parent having another child not being moved" do - let_schedule(<<~CHART) - days | mtwtfssMTWTFSS | - work_package | ] | - follower | XX | follows work_package, child of follower_parent - follower_sibling | XXX | child of follower_parent - follower_parent | XXXX | - CHART + let_work_packages(<<~TABLE) + hierarchy | mtwtfssMTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower_parent | XXXX | automatic | + follower | XX | automatic | follows work_package + follower_sibling | XXX | manual | + TABLE before do - change_schedule([work_package], <<~CHART) - days | mtwtfssMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end it "does not rechedule the followers or the other child" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | mtwtfssMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | mtwtfssMTWTFSS | work_package | ] | - CHART + TABLE end end end - context "with a single successor having a child" do + context "with a single successor having a child in automatic scheduling mode" do context "when moving forward" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XX | follows work_package - follower_child | XX | child of follower - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | XX | automatic | follows work_package + follower_child | XX | automatic | + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules follower and follower child to start right after the moved predecessor" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | X..X | follower_child | X..X | - CHART + TABLE + end + end + end + + context "with a single successor having a child in manual scheduling mode" do + context "when moving forward" do + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | XX | automatic | follows work_package + follower_child | XX | manual | + TABLE + + before do + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | + work_package | ] | + TABLE + end + + it "does not reschedule follower as dates depend on follower child which is manually scheduled" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | ] | + TABLE end end end - context "with a single successor having two children" do + context "with a single successor having two children automatically scheduled" do context "when creating the follows relation while follower starts 1 day after moved due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XXXX..XXXXX..XX | - follower_child1 | XXX | child of follower - follower_child2 | X..XX | child of follower - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + follower | XXXX..XXXXX..XX | automatic | + follower_child1 | XXX | automatic | + follower_child2 | X..XX | automatic | + TABLE before do create(:follows_relation, from: follower, to: work_package) end it "does not need to reschedule anything" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end end context "when creating the follows relation while follower starts 3 days after moved due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XX..XXXXX..XXXX | - follower_child1 | XX..X | child of follower - follower_child2 | XXX | child of follower - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | + work_package | ] | manual | + follower | XX..XXXXX..XXXX | automatic | + follower_child1 | XX..X | automatic | + follower_child2 | XXX | automatic | + TABLE before do create(:follows_relation, from: follower, to: work_package) end it "does not need to reschedule anything" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end end context "when creating the follows relation and follower first child starts before moved due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | X..XXXXX..XXXX | - follower_child1 | X..XXXX | child of follower - follower_child2 | X..XXXX | child of follower - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | + work_package | ] | manual | + follower | X..XXXXX..XXXX | automatic | + follower_child1 | X..XXXX | automatic | + follower_child2 | X..XXXX | automatic | + TABLE before do create(:follows_relation, from: follower, to: work_package) end it "reschedules first child and reduces follower parent duration as the children can be executed at the same time" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | ] | follower | XXXX..XXXX | follower_child1 | XXXX..X | - CHART + TABLE + expect_work_packages([follower_child2], <<~TABLE) + subject | MTWTFSS | + follower_child2 | X..XXXX | + TABLE end end context "when creating the follows relation and both follower children start before moved due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XXX..XXXXX..X | - follower_child1 | X | child of follower - follower_child2 | X..XXXXX..X | child of follower - CHART + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | + work_package | ] | manual | + follower | XXX..XXXXX..X | automatic | + follower_child1 | X | automatic | + follower_child2 | X..XXXXX..X | automatic | + TABLE before do create(:follows_relation, from: follower, to: work_package) end it "reschedules both children and reduces follower parent duration" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - follower | XXXX..XXX | - follower_child1 | X | child of follower - follower_child2 | XXXX..XXX | child of follower - CHART + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | ] | + follower | XXXX..XXX | + follower_child1 | X | + follower_child2 | XXXX..XXX | + TABLE end end end context "with a chain of followers" do context "when moving forward" do - let_schedule(<<~CHART) - days | MTWTFSSm sm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | X..XXXX | follows follower1 - follower3 | X..XXXX | follows follower2 - follower4 | X..X | follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | X..XXXX | automatic | follows follower1 + follower3 | X..XXXX | automatic | follows follower2 + follower4 | X..X | automatic | follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules each follower forward by the same delta" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSSm sm sm | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSm sm sm | work_package | ] | - follower1 | X..XX | follows work_package - follower2 | XXX..XX | follows follower1 - follower3 | XXXX..X | follows follower2 - follower4 | XX | follows follower3 - CHART + follower1 | X..XX | + follower2 | XXX..XX | + follower3 | XXXX..X | + follower4 | XX | + TABLE end end context "when moving forward with some space between the followers" do - let_schedule(<<~CHART) - days | MTWTFSSm sm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | XXXX | follows follower1 - follower3 | XXX..XX | follows follower2 - follower4 | XX | follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | XXXX | automatic | follows follower1 + follower3 | XXX..XX | automatic | follows follower2 + follower4 | XX | automatic | follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules only the first followers as the others don't need to move" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSSm sm | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSm sm | work_package | ] | follower1 | X..XX | follower2 | XXX..X | - CHART + TABLE end end context "when moving forward with some lag and spaces between the followers" do - let_schedule(<<~CHART) - days | MTWTFSSm sm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | XXXX | follows follower1 with lag 3 - follower3 | XXX..XX | follows follower2 - follower4 | XX | follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | XXXX | automatic | follows follower1 with lag 3 + follower3 | XXX..XX | automatic | follows follower2 + follower4 | XX | automatic | follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules all the followers keeping the lag and compacting the extra spaces" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSSm sm sm sm | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSm sm sm sm | work_package | ] | follower1 | X..XX | follower2 | XXXX | follower3 | X..XXXX | follower4 | X..X | - CHART + TABLE end end context "when moving forward due to days and predecessor due date now being non-working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - follower1 | X | follows work_package - follower2 | XX | follows follower1 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower1 | X | automatic | follows work_package + follower2 | XX | automatic | follows follower1 + TABLE before do # Tuesday, Thursday, and Friday are now non-working days. So work_package @@ -988,29 +1027,29 @@ # Below instructions reproduce the conditions in which such scheduling # must happen. set_non_working_week_days("tuesday", "thursday", "friday") - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | X.X | - CHART + TABLE end it "reschedules all the followers keeping the lag and compacting the extra spaces" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSSm w m | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSm w m | work_package | X.X | follower1 | X | follower2 | X....X | - CHART + TABLE end end context "when moving forward due to days and predecessor start date now being non-working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - follower1 | X | follows work_package - follower2 | XX | follows follower1 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + follower1 | X | automatic | follows work_package + follower2 | XX | automatic | follows follower1 + TABLE before do # Monday, Thursday, and Friday are now non-working days. So work_package @@ -1020,100 +1059,100 @@ # Below instructions reproduce the conditions in which such scheduling # must happen. set_non_working_week_days("monday", "thursday", "friday") - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | XX | - CHART + TABLE end it "reschedules all the followers without crossing each other" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS tw tw | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS tw tw | work_package | XX | follower1 | X | follower2 | X.....X | - CHART + TABLE end end context "when moving backwards" do - let_schedule(<<~CHART) - days | MTWTFSSm sm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | X..XXX | follows follower1 - follower3 | XXX..XX | follows follower2 - follower4 | XX | follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | X..XXX | automatic | follows follower1 + follower3 | XXX..XX | automatic | follows follower2 + follower4 | XX | automatic | follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | m sMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | m sMTWTFSS | work_package | ] | - CHART + TABLE end it "does not reschedule any followers" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | m sMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | m sMTWTFSS | work_package | ] | - CHART + TABLE end end end context "with a chain of followers with two paths leading to the same follower in the end" do context "when moving forward" do - let_schedule(<<~CHART) - days | MTWTFSSm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | X..XXXX | follows follower1 - follower3 | XX..X | follows work_package - follower4 | X..XX | follows follower2, follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | X..XXXX | automatic | follows follower1 + follower3 | XX..X | automatic | follows work_package + follower4 | X..XX | automatic | follows follower2, follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | MTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | MTWTFSS | work_package | ] | - CHART + TABLE end it "reschedules followers while satisfying all constraints" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSSm sm sm | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSSm sm sm | work_package | ] | follower1 | XXX | follower2 | XX..XXX | follower3 | XXX | follower4 | XX..X | - CHART + TABLE end end context "when moving backwards" do - let_schedule(<<~CHART) - days | MTWTFSSm sm | - work_package | ] | - follower1 | XXX | follows work_package - follower2 | X..XXXX | follows follower1 - follower3 | XX..X | follows work_package - follower4 | X..XX | follows follower2, follows follower3 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSm sm | scheduling mode | properties + work_package | ] | manual | + follower1 | XXX | automatic | follows work_package + follower2 | X..XXXX | automatic | follows follower1 + follower3 | XX..X | automatic | follows work_package + follower4 | X..XX | automatic | follows follower2, follows follower3 + TABLE before do - change_schedule([work_package], <<~CHART) - days | m sMTWTFSS | + change_work_packages([work_package], <<~TABLE) + subject | m sMTWTFSS | work_package | ] | - CHART + TABLE end it "does not reschedule any followers" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | m sMTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | m sMTWTFSS | work_package | ] | - CHART + TABLE end end end @@ -1122,12 +1161,12 @@ let(:changed_attributes) { [:parent] } context "without dates and with the parent being restricted in its ability to be moved" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | | - new_parent | | follows new_parent_predecessor with lag 3 - new_parent_predecessor | X | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic |follows new_parent_predecessor with lag 3 + TABLE before do work_package.parent = new_parent @@ -1135,21 +1174,21 @@ end it "schedules parent to start and end at soonest working start date and the child to start at the parent start" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | [ | new_parent | X | - CHART + TABLE end end context "without dates, with a duration and with the parent being restricted in its ability to be moved" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | | duration 4 - new_parent | | follows new_parent_predecessor with lag 3 - new_parent_predecessor | X | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration | scheduling mode | properties + work_package | | 4 | manual | + new_parent_predecessor | X | | manual | + new_parent | | | automatic | follows new_parent_predecessor with lag 3 + TABLE before do work_package.parent = new_parent @@ -1158,21 +1197,21 @@ it "schedules the moved work package to start at the parent soonest date and sets due date to keep the same duration " \ "and schedules the parent dates to match the child dates" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XXXX | new_parent | XXXX | - CHART + TABLE end end context "with the parent being restricted in its ability to be moved and with a due date before parent constraint" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - new_parent | | follows new_parent_predecessor with lag 3 - new_parent_predecessor | X | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE before do work_package.parent = new_parent @@ -1180,21 +1219,21 @@ end it "schedules the moved work package to start and end at the parent soonest working start date" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | X | new_parent | X | - CHART + TABLE end end context "with the parent being restricted in its ability to be moved and with a due date after parent constraint" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ] | - new_parent | | follows new_parent_predecessor with lag 3 - new_parent_predecessor | X | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | ] | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE before do work_package.parent = new_parent @@ -1202,21 +1241,21 @@ end it "schedules the moved work package to start at the parent soonest working start date and keep the due date" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | X..XX | new_parent | X..XX | - CHART + TABLE end end context "with the parent being restricted but work package already has both dates set" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XX | - new_parent | | follows new_parent_predecessor with lag 3 - new_parent_predecessor | X | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + work_package | XX | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE before do work_package.parent = new_parent @@ -1224,11 +1263,11 @@ end it "does not reschedule the moved work package, and sets new parent dates to child dates" do - expect(subject.all_results).to match_schedule(<<~CHART) - days | MTWTFSS | + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | work_package | XX | new_parent | XX | - CHART + TABLE end end end diff --git a/spec/services/work_packages/update_ancestors_service_spec.rb b/spec/services/work_packages/update_ancestors_service_spec.rb index 1590a5af0d88..d4ba6b3be443 100644 --- a/spec/services/work_packages/update_ancestors_service_spec.rb +++ b/spec/services/work_packages/update_ancestors_service_spec.rb @@ -1050,25 +1050,31 @@ def call_update_ancestors_service(work_package) describe "ignore_non_working_days propagation" do shared_let(:grandgrandparent) do create(:work_package, - subject: "grandgrandparent") + subject: "grandgrandparent", + schedule_manually: false) end shared_let(:grandparent) do create(:work_package, subject: "grandparent", - parent: grandgrandparent) + parent: grandgrandparent, + schedule_manually: false) end shared_let(:parent) do create(:work_package, subject: "parent", - parent: grandparent) + parent: grandparent, + schedule_manually: false) end shared_let(:sibling) do create(:work_package, subject: "sibling", + schedule_manually: true, parent:) end shared_let(:work_package) do - create(:work_package) + create(:work_package, + subject: "main", + schedule_manually: true) end subject do @@ -1081,8 +1087,6 @@ def call_update_ancestors_service(work_package) .call(%i(parent)) end - let(:new_parent) { parent } - context "for the previous ancestors (parent removed)" do let(:new_parent) { nil } @@ -1127,13 +1131,16 @@ def call_update_ancestors_service(work_package) end end - context "for the new ancestors where the grandparent is on manual scheduling" do + context "for the new ancestors where the initiator is ignoring non-working days " \ + "and the grandparent is on manual scheduling" do + let(:new_parent) { parent } + before do - [grandgrandparent, work_package].each do |wp| + [work_package].each do |wp| wp.update_column(:ignore_non_working_days, true) end - [grandparent, parent, sibling].each do |wp| + [grandgrandparent, grandparent, parent, sibling].each do |wp| wp.update_column(:ignore_non_working_days, false) end @@ -1147,12 +1154,13 @@ def call_update_ancestors_service(work_package) .to be_success end - it "returns the former ancestors in the dependent results" do + it "returns the updated new ancestors in the dependent results where only the parent is updated" do expect(subject.dependent_results.map(&:result)) .to contain_exactly(parent) end - it "sets the ignore_non_working_days property of the new ancestors" do + it "updates the parent's ignore_non_working_days attribute to true " \ + "and does not propagate to the grandparent because it is manually scheduled" do subject expect(parent.reload.ignore_non_working_days) @@ -1162,7 +1170,7 @@ def call_update_ancestors_service(work_package) .to be_falsey expect(grandgrandparent.reload.ignore_non_working_days) - .to be_truthy + .to be_falsey expect(sibling.reload.ignore_non_working_days) .to be_falsey @@ -1170,6 +1178,8 @@ def call_update_ancestors_service(work_package) end context "for the new ancestors where the parent is on manual scheduling" do + let(:new_parent) { parent } + before do [grandgrandparent, grandparent, work_package].each do |wp| wp.update_column(:ignore_non_working_days, true) @@ -1189,22 +1199,23 @@ def call_update_ancestors_service(work_package) .to be_success end - it "returns the former ancestors in the dependent results" do + it "returns the updated new ancestors in the dependent results" do expect(subject.dependent_results.map(&:result)) - .to be_empty + .to contain_exactly(grandparent, grandgrandparent) end - it "sets the ignore_non_working_days property of the new ancestors" do + it "sets the ignore_non_working_days property of the grand parent and grand grand parent to " \ + "match the parent's value because it's manually scheduled and that's where inheritance chain starts" do subject expect(parent.reload.ignore_non_working_days) .to be_falsey expect(grandparent.reload.ignore_non_working_days) - .to be_truthy + .to be_falsey expect(grandgrandparent.reload.ignore_non_working_days) - .to be_truthy + .to be_falsey expect(sibling.reload.ignore_non_working_days) .to be_falsey diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index 134dd0359578..4335adb28fb9 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -53,26 +53,31 @@ end let(:work_package) do create(:work_package, - work_package_attributes) + subject: "initial", + **work_package_attributes) end let(:parent_work_package) do create(:work_package, - work_package_attributes).tap do |w| + subject: "parent", + schedule_manually: false, + **work_package_attributes).tap do |w| w.children << work_package work_package.reload end end let(:grandparent_work_package) do create(:work_package, - work_package_attributes).tap do |w| + subject: "grandparent", + schedule_manually: false, + **work_package_attributes).tap do |w| w.children << parent_work_package end end let(:sibling1_attributes) do - work_package_attributes.merge(parent: parent_work_package) + work_package_attributes.merge(subject: "sibling1", parent: parent_work_package) end let(:sibling2_attributes) do - work_package_attributes.merge(parent: parent_work_package) + work_package_attributes.merge(subject: "sibling2", parent: parent_work_package) end let(:sibling1_work_package) do create(:work_package, @@ -83,16 +88,18 @@ sibling2_attributes) end let(:child_attributes) do - work_package_attributes.merge(parent: work_package) + work_package_attributes.merge(subject: "child", parent: work_package) end let(:child_work_package) do + child_attributes[:parent].update_column(:schedule_manually, false) create(:work_package, child_attributes) end let(:grandchild_attributes) do - work_package_attributes.merge(parent: child_work_package) + work_package_attributes.merge(subject: "grandchild", parent: child_work_package) end let(:grandchild_work_package) do + grandchild_attributes[:parent].update_column(:schedule_manually, false) create(:work_package, grandchild_attributes) end @@ -656,6 +663,7 @@ let(:following_attributes) do work_package_attributes.merge(parent: following_parent_work_package, subject: "following", + schedule_manually: false, start_date: Time.zone.today + 6.days, due_date: Time.zone.today + 20.days) end @@ -667,6 +675,7 @@ end let(:following_parent_attributes) do work_package_attributes.merge(subject: "following_parent", + schedule_manually: false, start_date: Time.zone.today + 6.days, due_date: Time.zone.today + 20.days) end @@ -677,6 +686,7 @@ let(:following2_attributes) do work_package_attributes.merge(parent: following2_parent_work_package, subject: "following2", + schedule_manually: false, start_date: Time.zone.today + 21.days, due_date: Time.zone.today + 25.days) end @@ -686,6 +696,7 @@ end let(:following2_parent_attributes) do work_package_attributes.merge(subject: "following2_parent", + schedule_manually: false, start_date: Time.zone.today + 21.days, due_date: Time.zone.today + 25.days) end @@ -698,6 +709,7 @@ let(:following3_attributes) do work_package_attributes.merge(subject: "following3", parent: following3_parent_work_package, + schedule_manually: false, start_date: Time.zone.today + 26.days, due_date: Time.zone.today + 30.days) end @@ -709,6 +721,7 @@ end let(:following3_parent_attributes) do work_package_attributes.merge(subject: "following3_parent", + schedule_manually: false, start_date: Time.zone.today + 26.days, due_date: Time.zone.today + 36.days) end @@ -719,6 +732,7 @@ let(:following3_sibling_attributes) do work_package_attributes.merge(parent: following3_parent_work_package, subject: "following3_sibling", + schedule_manually: false, start_date: Time.zone.today + 32.days, due_date: Time.zone.today + 36.days) end @@ -817,7 +831,7 @@ end let(:parent_work_package) do - create(:work_package, work_package_attributes) + create(:work_package, schedule_manually: false, **work_package_attributes) end let(:expected_parent_dates) do @@ -860,6 +874,7 @@ author_id: user.id, status_id: status.id, priority:, + schedule_manually: false, start_date: Time.zone.today + 3.days, due_date: Time.zone.today + 9.days } @@ -896,6 +911,7 @@ work_package_attributes.merge( subject: "new parent", parent: nil, + schedule_manually: false, start_date: Time.zone.today + 10.days, due_date: Time.zone.today + 12.days ) @@ -956,17 +972,17 @@ end end - describe "changing the parent with the parent being restricted in moving to an earlier date" do + describe "changing the parent with the parent having a predecessor restricting it moving to an earlier date" do # there is actually some time between the new parent and its predecessor let(:new_parent_attributes) do work_package_attributes.merge( subject: "new parent", parent: nil, + schedule_manually: false, start_date: Time.zone.today + 8.days, due_date: Time.zone.today + 14.days ) end - let(:attributes) { { parent: new_parent_work_package } } let(:new_parent_work_package) do create(:work_package, new_parent_attributes) end @@ -1001,38 +1017,78 @@ new_parent_predecessor_work_package.reload end - it "reschedules the parent and the work package while adhering to the limitation imposed by the predecessor" do - expect(subject) - .to be_success + context "when the work package is automatically scheduled" do + let(:attributes) { { parent: new_parent_work_package, schedule_manually: false } } - # sets the parent and adapts the dates - # The dates are overwritten as the new parent is unable - # to move to the dates of its new child because of the follows relation. - work_package.reload - expect(work_package.parent) - .to eql new_parent_work_package - expect(work_package.start_date) - .to eql new_parent_predecessor_attributes[:due_date] + 1.day - expect(work_package.due_date) - .to eql new_parent_predecessor_attributes[:due_date] + 4.days + it "reschedules the parent and the work package while adhering to the limitation imposed by the predecessor" do + expect(subject) + .to be_success - # adapts the parent's dates but adheres to its limitations - # due to the follows relationship - new_parent_work_package.reload - expect(new_parent_work_package.start_date) - .to eql new_parent_predecessor_attributes[:due_date] + 1.day - expect(new_parent_work_package.due_date) - .to eql new_parent_predecessor_attributes[:due_date] + 4.days + # sets the parent and adapts the dates + # The dates are overwritten as the new parent is unable + # to move to the dates of its new child because of the follows relation. + work_package.reload + expect(work_package.parent) + .to eq new_parent_work_package + expect(work_package.start_date) + .to eq new_parent_predecessor_attributes[:due_date] + 1.day + expect(work_package.due_date) + .to eq new_parent_predecessor_attributes[:due_date] + 4.days + + # adapts the parent's dates but adheres to its limitations + # due to the follows relationship + new_parent_work_package.reload + expect(new_parent_work_package.start_date) + .to eq new_parent_predecessor_attributes[:due_date] + 1.day + expect(new_parent_work_package.due_date) + .to eq new_parent_predecessor_attributes[:due_date] + 4.days + + # The parent's predecessor is unchanged + new_parent_predecessor_work_package.reload + expect(new_parent_predecessor_work_package.start_date) + .to eq new_parent_predecessor_work_package[:start_date] + expect(new_parent_predecessor_work_package.due_date) + .to eq new_parent_predecessor_work_package[:due_date] + + expect(subject.all_results.uniq) + .to contain_exactly(work_package, new_parent_work_package) + end + end - # leaves the parent's predecessor unchanged - new_parent_work_package.reload - expect(new_parent_work_package.start_date) - .to eql new_parent_predecessor_attributes[:due_date] + 1.day - expect(new_parent_work_package.due_date) - .to eql new_parent_predecessor_attributes[:due_date] + 4.days + context "when the work package is manually scheduled" do + let(:attributes) { { parent: new_parent_work_package, schedule_manually: true } } - expect(subject.all_results.uniq) - .to contain_exactly(work_package, new_parent_work_package) + it "sets parent's dates to be the same as the work package despite the predecessor constraints" do + expect(subject) + .to be_success + + # sets the parent and do not change the dates as it is manually scheduled + work_package.reload + expect(work_package.parent) + .to eq new_parent_work_package + expect(work_package.start_date) + .to eq work_package_attributes[:start_date] + expect(work_package.due_date) + .to eq work_package_attributes[:due_date] + + # The parent dates are the same as its child. The follows relation is + # ignored as children dates always take precedence over relations. + new_parent_work_package.reload + expect(new_parent_work_package.start_date) + .to eq work_package_attributes[:start_date] + expect(new_parent_work_package.due_date) + .to eq work_package_attributes[:due_date] + + # The parent's predecessor is unchanged + new_parent_predecessor_work_package.reload + expect(new_parent_predecessor_work_package.start_date) + .to eq new_parent_predecessor_work_package[:start_date] + expect(new_parent_predecessor_work_package.due_date) + .to eq new_parent_predecessor_work_package[:due_date] + + expect(subject.all_results.uniq) + .to contain_exactly(work_package, new_parent_work_package) + end end end @@ -1056,6 +1112,7 @@ author_id: user.id, status_id: status.id, priority:, + schedule_manually: false, start_date: Time.zone.today, due_date: Time.zone.today + 10.days } end @@ -1097,6 +1154,7 @@ expect(work_package.due_date) .to eql work_package_attributes[:due_date] + # parent is rescheduled to the sibling's dates parent_work_package.reload expect(parent_work_package.start_date) .to eql sibling_attributes[:start_date] @@ -1222,12 +1280,13 @@ end describe "removing an invalid parent" do - # The parent does not have a required custom field set but will need to be touched since. - # the dates, inherited from its children (and then the only remaining child) will have to be updated. + # The parent does not have a required custom field set but will need to be touched since + # the dates, inherited from its children (and then the only remaining child), will have to be updated. let!(:parent) do create(:work_package, type: project.types.first, project:, + schedule_manually: false, start_date: Time.zone.today - 1.day, due_date: Time.zone.today + 5.days) end diff --git a/spec/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index be15ddb1279a..a00c759ae77e 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -133,12 +133,20 @@ def expect_duration_highlighted def expect_scheduling_mode(manually) if manually - expect(container).to have_checked_field("scheduling", visible: :all) + expect_manual_scheduling_mode else - expect(container).to have_unchecked_field("scheduling", visible: :all) + expect_automatic_scheduling_mode end end + def expect_manual_scheduling_mode + expect(container).to have_checked_field("scheduling", visible: :all) + end + + def expect_automatic_scheduling_mode + expect(container).to have_unchecked_field("scheduling", visible: :all) + end + def toggle_scheduling_mode find("label", text: "Manual scheduling").click end diff --git a/spec/support/schedule_helpers/chart.rb b/spec/support/schedule_helpers/chart.rb deleted file mode 100644 index 227b24691002..000000000000 --- a/spec/support/schedule_helpers/chart.rb +++ /dev/null @@ -1,263 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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, and information to render it. - # - # The work package information are: - # * subject - # * parent - # * start_date - # * due_date - # * duration - # * ignore_non_working_days - # - # The relations information are limited to follows relations and are retrieved - # with +#predecessors_by_follower+ - # - # The rendering information are: - # * chart origin (the monday displayed in the header line) - # * max and min date - # * first column size - # - # 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 = Date.current.next_occurring(: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 - order = order.map(&:to_sym) - 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.lags_between = lags_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 lag_between(predecessor:, follower:) - lags_between.fetch([predecessor, follower]) - end - - def add_work_package(attributes) - attributes[:start_date] ||= WorkPackage.column_defaults["start_date"] - attributes[:due_date] ||= WorkPackage.column_defaults["due_date"] - 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 set_ignore_non_working_days(name, ignore_non_working_days) - attributes = work_package_attributes(name.to_sym) - attributes[:ignore_non_working_days] = ignore_non_working_days - end - - def add_follows_relation(predecessor:, follower:, lag:) - predecessors_by_follower(follower) << predecessor - lags_between[[predecessor, follower]] = lag - 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 - - def compact_dates - @first_day, @last_day = work_packages_attributes.pluck(:start_date, :due_date).flatten.compact.minmax - @monday = ([@first_day, @last_day, @monday].compact.first - 1).next_occurring(:monday) - extend_calendar_range(@monday, @monday + 6) - self - end - - protected - - attr_writer :work_packages_attributes, - :id_column_size, - :first_day, - :last_day, - :predecessors_by_followers, - :lags_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 predecessors_by_followers - @predecessors_by_followers ||= Hash.new { |h, k| h[k] = [] } - end - - def lags_between - @lags_between ||= Hash.new(0) - end - - def parent_by_child - @parent_by_child ||= {} - end - end -end diff --git a/spec/support/schedule_helpers/chart_builder.rb b/spec/support/schedule_helpers/chart_builder.rb deleted file mode 100644 index 0eeabfe1781b..000000000000 --- a/spec/support/schedule_helpers/chart_builder.rb +++ /dev/null @@ -1,147 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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 lag (\d+))?/ - chart.add_follows_relation( - predecessor: $1.to_sym, - follower: name.to_sym, - lag: $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) - when /^working days work week$/ - chart.set_ignore_non_working_days(name, false) - when /^working days include weekends$/ - chart.set_ignore_non_working_days(name, true) - else - spell_checker = DidYouMean::SpellChecker.new( - dictionary: [ - "follows :wp", - "follows :wp with lag :int", - "child of :wp", - "duration :int", - "working days work week", - "working days include weekends" - ] - ) - suggestions = spell_checker.correct(property).map(&:inspect).join(" ") - did_you_mean = " Did you mean #{suggestions} instead?" if suggestions.present? - raise "unable to parse property #{property.inspect} for line #{name.inspect}.#{did_you_mean}" - 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 diff --git a/spec/support/schedule_helpers/chart_representer.rb b/spec/support/schedule_helpers/chart_representer.rb deleted file mode 100644 index b41545079afe..000000000000 --- a/spec/support/schedule_helpers/chart_representer.rb +++ /dev/null @@ -1,82 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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 = "%s | %s |".freeze - - def self.normalized_to_s(expected_chart, actual_chart) - normalize_ignore_non_working_days_information(expected_chart, actual_chart) - order = expected_chart.work_package_names - id_column_size = [expected_chart, actual_chart].map(&:id_column_size).max - first_day = [expected_chart, actual_chart].map(&:first_day).min - last_day = [expected_chart, actual_chart].map(&:last_day).max - [expected_chart, actual_chart] - .map { |chart| chart.with(order:, id_column_size:, first_day:, last_day:) } - .map(&:to_s) - end - - # Define ignore_non_working_days attribute from +actual_chart+ when not - # explicitly set in +expected_chart+. - def self.normalize_ignore_non_working_days_information(expected_chart, actual_chart) - expected_chart.work_packages_attributes.each do |work_package_attributes| - next if work_package_attributes.has_key?(:ignore_non_working_days) - - name = work_package_attributes[:name] - actual_attributes = actual_chart.work_package_attributes(name) - next if actual_attributes.nil? || !actual_attributes.has_key?(:ignore_non_working_days) - - work_package_attributes[:ignore_non_working_days] = actual_attributes[:ignore_non_working_days] - end - 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_column_size}s | %-#{@days_column_size}s |" - rows.map do |row| - line_template % { id: row[0], days: row[1] } - end.join("\n") - end - end -end diff --git a/spec/support/schedule_helpers/example_methods.rb b/spec/support/schedule_helpers/example_methods.rb deleted file mode 100644 index 0d96acb9334d..000000000000 --- a/spec/support/schedule_helpers/example_methods.rb +++ /dev/null @@ -1,115 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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 - # Create work packages and relations from a visual chart representation. - # - # For instance: - # - # create_schedule(<<~CHART) - # days | MTWTFSS | - # main | XX | - # follower | XXX | follows main - # start_only | [ | - # due_only | ] | - # CHART - # - # is equivalent to: - # - # create(:work_package, subject: 'main', start_date: next_monday, due_date: next_monday + 1.day) - # create(:work_package, subject: 'follower', start_date: next_monday + 2.days, due_date: next_monday + 4.days) } - # create(:work_package, subject: 'start_only', start_date: next_monday + 1.day) } - # create(:work_package, subject: 'due_only', due_date: next_monday + 3.days) } - # create(:follows_relation, from: follower, to: main, lag: 0) } - # - def create_schedule(chart_representation) - chart = Chart.for(chart_representation) - ScheduleBuilder.from_chart(chart) - end - - # 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. - # - # It uses +match_schedule+ internally. - # - # 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 - # expect(work_packages).to match_schedule(<<~CHART) - # days | MTWTFSS | - # main | XX | - # follower | XXX | - # CHART - # end - def expect_schedule(work_packages, chart) - expect(work_packages).to match_schedule(chart) - end - end -end diff --git a/spec/support/schedule_helpers/let_schedule.rb b/spec/support/schedule_helpers/let_schedule.rb deleted file mode 100644 index 71e0d6ad4ea6..000000000000 --- a/spec/support/schedule_helpers/let_schedule.rb +++ /dev/null @@ -1,74 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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. - # - # It uses +create_schedule+ internally and is useful to have direct access - # to the created work packages. - # - # For instance: - # - # let_schedule(<<~CHART) - # days | MTWTFSS | - # main | XX | - # follower | XXX | follows main - # start_only | [ | - # due_only | ] | - # CHART - # - # is equivalent to: - # - # let!(:schedule) do - # create_schedule(chart) - # end - # let(:main) do - # schedule.work_package(:main) - # end - # let(:follower) do - # schedule.work_package(:follower) - # end - # let(:start_only) do - # schedule.work_package(:start_only) - # end - # let(:due_only) do - # schedule.work_package(:due_only) - # end - def let_schedule(chart_representation) - # 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) { create_schedule(chart_representation) } - - chart = Chart.for(chart_representation) - chart.work_package_names.each do |name| - let(name) { schedule.work_package(name) } - end - end - end -end diff --git a/spec/support/schedule_helpers/schedule.rb b/spec/support/schedule_helpers/schedule.rb deleted file mode 100644 index fa7767c10034..000000000000 --- a/spec/support/schedule_helpers/schedule.rb +++ /dev/null @@ -1,67 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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 Schedule - def initialize(work_packages, follows_relations) - @work_packages = work_packages - @follows_relations = follows_relations - end - - def work_package(name) - name = normalize_name(name) - @work_packages[name] - end - - def follows_relation(from:, to:) - from = normalize_name(from) - to = normalize_name(to) - @follows_relations[from:, to:] - end - - def monday - Date.current.next_occurring(:monday) - end - - %i[tuesday wednesday thursday friday saturday sunday].each do |day_name| - define_method(day_name) { monday.next_occurring(day_name) } - end - - private - - def normalize_name(name) - symbolic_name = name.to_sym - return symbolic_name if @work_packages.has_key?(symbolic_name) - - spell_checker = DidYouMean::SpellChecker.new(dictionary: @work_packages.keys.map(&:to_s)) - suggestions = spell_checker.correct(name).map(&:inspect).join(" ") - did_you_mean = " Did you mean #{suggestions} instead?" if suggestions.present? - raise "No work package with name #{name.inspect} in schedule.#{did_you_mean}" - end - end -end diff --git a/spec/support/schedule_helpers/schedule_builder.rb b/spec/support/schedule_helpers/schedule_builder.rb deleted file mode 100644 index 38114d4ce322..000000000000 --- a/spec/support/schedule_helpers/schedule_builder.rb +++ /dev/null @@ -1,74 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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 ScheduleBuilder - def self.from_chart(chart) - creator = new(chart) - chart.work_package_names.each do |name| - creator.create_work_package(name) - creator.create_follows_relations(name) - end - Schedule.new(creator.work_packages, creator.follows_relations) - end - - attr_reader :chart, :work_packages, :follows_relations - - def initialize(chart) - @chart = chart - @work_packages = {} - @follows_relations = {} - end - - def create_work_package(name) - work_packages[name] ||= begin - attributes = chart - .work_package_attributes(name) - .excluding(:name) - .merge(parent: parent_of(name)) - FactoryBot.create(:work_package, attributes) - end - end - - def create_follows_relations(follower) - chart.predecessors_by_follower(follower).each do |predecessor| - follows_relations[from: follower, to: predecessor] = - FactoryBot.create(:follows_relation, - from: create_work_package(follower), - to: create_work_package(predecessor), - lag: chart.lag_between(predecessor:, follower:)) - end - end - - def parent_of(name) - if chart.parent(name) - create_work_package(chart.parent(name)) - end - end - end -end diff --git a/spec/support/table_helpers/column.rb b/spec/support/table_helpers/column.rb index 2c5fb667a44a..378c37862168 100644 --- a/spec/support/table_helpers/column.rb +++ b/spec/support/table_helpers/column.rb @@ -31,6 +31,7 @@ require_relative "identifier" require_relative "column_type/generic" require_relative "column_type/with_identifier_metadata" +require_relative "column_type/days_counting" require_relative "column_type/duration" require_relative "column_type/hierarchy" require_relative "column_type/percentage" @@ -52,6 +53,7 @@ class Column done_ratio: ColumnType::Percentage, derived_done_ratio: ColumnType::Percentage, hierarchy: ColumnType::Hierarchy, + ignore_non_working_days: ColumnType::DaysCounting, properties: ColumnType::Properties, schedule: ColumnType::Schedule, schedule_manually: ColumnType::SchedulingMode, @@ -90,6 +92,8 @@ def self.attribute_for(header) :due_date when /.*MTWTFSS.*/ :schedule + when /\s*days counting\s*/ + :ignore_non_working_days when /\s*scheduling mode\s*/ :schedule_manually when /\s*properties\s*/ diff --git a/spec/support/schedule_helpers.rb b/spec/support/table_helpers/column_type/days_counting.rb similarity index 52% rename from spec/support/schedule_helpers.rb rename to spec/support/table_helpers/column_type/days_counting.rb index a39f45398ad9..5f40f9f52451 100644 --- a/spec/support/schedule_helpers.rb +++ b/spec/support/table_helpers/column_type/days_counting.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH +# Copyright (C) 2012-2024 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. @@ -26,23 +28,39 @@ # 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) +module TableHelpers + module ColumnType + # Column to specify how days are counted for duration. + # + # Can take values 'all days' or 'working days only' to set `ignore_non_working_days` + # attribute to `true` or `false` respectively. + # + # Example: + # + # | subject | days counting | + # | wp 1 | all days | + # | wp 2 | working days only | + class DaysCounting < Generic + def format(value) + if value + "all days" + else + "working days only" + end + end - @expected, @actual = ScheduleHelpers::ChartRepresenter.normalized_to_s(expected_chart, actual_chart) - - values_match? @expected, @actual + def parse(raw_value) + case raw_value.downcase.strip + when "all days", "true" + true + when "working days only", "false" + false + else + raise "Invalid value for 'days counting' column: #{raw_value.strip.inspect}. " \ + "Expected 'all days' (ignore_non_working_days: true) " \ + "or 'working days only' (ignore_non_working_days: false)." + end + end end - - diffable - attr_reader :expected, :actual end end diff --git a/spec/support/table_helpers/column_type/properties.rb b/spec/support/table_helpers/column_type/properties.rb index dbe0e919c977..d940af695e10 100644 --- a/spec/support/table_helpers/column_type/properties.rb +++ b/spec/support/table_helpers/column_type/properties.rb @@ -43,7 +43,8 @@ module ColumnType # | follower | follows main with lag 2 | # | follower2 | follows follower | # - # Adapted from original implementation in `spec/support/schedule_helpers/chart_builder.rb`. + # Adapted from (now deleted) original implementation + # in `spec/support/schedule_helpers/chart_builder.rb`. class Properties < Generic def attributes_for_work_package(_attribute, _work_package) {} diff --git a/spec/support/table_helpers/column_type/schedule.rb b/spec/support/table_helpers/column_type/schedule.rb index 072691365370..5abe95ce5b26 100644 --- a/spec/support/table_helpers/column_type/schedule.rb +++ b/spec/support/table_helpers/column_type/schedule.rb @@ -52,7 +52,8 @@ module ColumnType # | due date only | ] | # | no dates | | # - # Adapted from original implementation in `spec/support/schedule_helpers/chart_builder.rb`. + # Adapted from (now deleted) original implementation + # in `spec/support/schedule_helpers/chart_builder.rb`. class Schedule < Generic def attributes_for_work_package(_attribute, work_package) { diff --git a/spec/support/table_helpers/example_methods.rb b/spec/support/table_helpers/example_methods.rb index e55c693a0355..b9286e5ab277 100644 --- a/spec/support/table_helpers/example_methods.rb +++ b/spec/support/table_helpers/example_methods.rb @@ -49,6 +49,42 @@ def create_table(table_representation) table_data.create_work_packages end + # Change the given work packages according to the given table representation. + # Work packages are changed without being saved. + # + # The first column gives the identifier of the work package to update, so it + # cannot be used to update the subject or the hierarchy. + # + # For instance: + # + # before do + # update_work_packages([main], <<~TABLE) + # subject | MTWTFSS | scheduling mode | + # main | XX | manual | + # TABLE + # end + # + # is equivalent to: + # + # before do + # main.start_date = monday + # main.due_date = tuesday + # main.schedule_manually = true + # end + def change_work_packages(work_packages, table_representation) + TableData.for(table_representation).work_packages_data.pluck(: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.without(:subject).each do |attribute, value| + work_package.send(:"#{attribute}=", value) + end + end + end + # Expect the given work packages to match a visual table representation. # # It uses +match_table+ internally. It does not reload the work packages diff --git a/spec/support/table_helpers/table.rb b/spec/support/table_helpers/table.rb index 7ad26d050763..d31a1e0debba 100644 --- a/spec/support/table_helpers/table.rb +++ b/spec/support/table_helpers/table.rb @@ -42,6 +42,10 @@ def work_packages @work_packages_by_identifier.values end + def relation(successor:) + @relations.find { |relation| relation.follows? && relation.from.subject == successor } + end + def relations @relations end diff --git a/spec/support_spec/schedule_helpers/chart_builder_spec.rb b/spec/support_spec/schedule_helpers/chart_builder_spec.rb deleted file mode 100644 index b2992159629c..000000000000 --- a/spec/support_spec/schedule_helpers/chart_builder_spec.rb +++ /dev/null @@ -1,189 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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" - -RSpec.describe ScheduleHelpers::ChartBuilder 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) } - - 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 " 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.lag_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.lag_between(predecessor: :main, follower: :follower)).to eq(0) - end - end - - describe "follows with lag " do - it "adds a follows relation to the named with a lag" do - chart = builder.parse(<<~CHART) - days | MTWTFSS | - main | | - follower | | follows main with lag 3 - CHART - expect(chart.predecessors_by_follower(:follower)).to eq([:main]) - expect(chart.lag_between(predecessor: :main, follower: :follower)).to eq(3) - end - end - - describe "child of " 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 " 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 - - describe "working days work week" do - it "sets ignore_non_working_days to false for the work package" do - chart = builder.parse(<<~CHART) - days | MTWTFSS | - main | | working days work week - CHART - expect(chart.work_package_attributes(:main)).to include(ignore_non_working_days: false) - end - end - - describe "working days include weekends" do - it "sets ignore_non_working_days to true for the work package" do - chart = builder.parse(<<~CHART) - days | MTWTFSS | - main | | working days include weekends - CHART - expect(chart.work_package_attributes(:main)).to include(ignore_non_working_days: true) - 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 diff --git a/spec/support_spec/schedule_helpers/chart_representer_spec.rb b/spec/support_spec/schedule_helpers/chart_representer_spec.rb deleted file mode 100644 index 2a7f003ee659..000000000000 --- a/spec/support_spec/schedule_helpers/chart_representer_spec.rb +++ /dev/null @@ -1,269 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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" - -RSpec.describe ScheduleHelpers::ChartRepresenter do - describe "#normalized_to_s" do - shared_let(:week_days) { week_with_saturday_and_sunday_as_weekend } - - 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 - - context "when expected chart does not have working days information" do - def to_headers(charts) - charts.map { _1.split("\n").first } - end - - it "gets it from actual chart information" do - # in real tests, actual will probably be created from WorkPackage instances - actual_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | XXXXX | working days include weekends - other | X..X | working days work week - CHART - expected_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | XXXXX | - other | X..X | - CHART - - normalized_expected, normalized_actual = - described_class - .normalized_to_s(expected_chart, actual_chart) - - expect(normalized_actual).to eq(normalized_expected) - end - - it "ignores working days information for extra work packages not defined in actual" do - initial_actual_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | X..X | - CHART - initial_expected_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | X..X | - extra | | - CHART - - expect { described_class.normalized_to_s(initial_expected_chart, initial_actual_chart) } - .not_to raise_error - end - end - - context "when expected chart has different working days information from actual" do - def to_headers(charts) - charts.map { _1.split("\n").first } - end - - it "use each information from each side" do - # in real tests, actual will probably be created from WorkPackage instances - actual_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | XXXXXXXX | working days include weekends - other | X..X | working days work week - foo | X..X | - CHART - expected_chart = - ScheduleHelpers::ChartBuilder.new.parse(<<~CHART) - days | MTWTFSS | - main | XXXXX..X | working days work week - other | XXXX | working days include weekends - foo | X..X | - CHART - - normalized_expected, normalized_actual = - described_class - .normalized_to_s(expected_chart, actual_chart) - - expect(normalized_actual).to eq(<<~CHART.strip) - days | MTWTFSS | - main | XXXXXXXX | - other | X..X | - foo | X..X | - CHART - expect(normalized_expected).to eq(<<~CHART.strip) - days | MTWTFSS | - main | XXXXX..X | - other | XXXX | - foo | X..X | - CHART - end - end - end -end diff --git a/spec/support_spec/schedule_helpers/chart_spec.rb b/spec/support_spec/schedule_helpers/chart_spec.rb deleted file mode 100644 index aa7d42434fd6..000000000000 --- a/spec/support_spec/schedule_helpers/chart_spec.rb +++ /dev/null @@ -1,249 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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" - -RSpec.describe ScheduleHelpers::Chart 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) } - - subject(:chart) { described_class.new } - - before do - travel_to(fake_today) - end - - describe "#first_day" do - it "returns the first day represented on the graph, which is next Monday" do - expect(chart.first_day).to eq(monday) - 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 - - 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 - end - - describe "#last_day" 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 - - 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 - - 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 - end - - describe "#compact_dates" do - it "makes the chart dates fit with the work packages dates" do - chart.add_work_package(subject: "wp1", start_date: friday - 21.days, due_date: tuesday - 14.days) - chart.add_work_package(subject: "wp2", start_date: wednesday - 14.days) - chart.add_work_package(subject: "wp3", due_date: thursday - 14.days) - chart.add_work_package(subject: "wp4", due_date: thursday - 14.days) - - expect { chart.compact_dates } - .to change { [chart.monday, chart.first_day, chart.last_day] } - .from([monday, friday - 21.days, sunday]) - .to([monday - 14.days, friday - 21.days, sunday - 14.days]) - end - - it "does nothing if there are no work packages" do - expect { chart.compact_dates } - .not_to change { [chart.monday, chart.first_day, chart.last_day] } - end - - it "does nothing if none of the work packages have any dates" do - chart.add_work_package(subject: "wp1") - chart.add_work_package(subject: "wp2") - chart.add_work_package(subject: "wp3") - - expect { chart.compact_dates } - .not_to change { [chart.monday, chart.first_day, chart.last_day] } - 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" do - shared_let(:week_days) { week_with_saturday_and_sunday_as_weekend } - - 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 diff --git a/spec/support_spec/schedule_helpers/example_methods_spec.rb b/spec/support_spec/schedule_helpers/example_methods_spec.rb deleted file mode 100644 index 23c3f2b4a52d..000000000000 --- a/spec/support_spec/schedule_helpers/example_methods_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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" - -RSpec.describe ScheduleHelpers::ExampleMethods do - create_shared_association_defaults_for_work_package_factory - - describe "create_schedule" do - let(:monday) { Date.current.next_occurring(:monday) } - let(:tuesday) { monday + 1.day } - - # rubocop:disable RSpec/ExampleLength - it "creates work packages from the given chart" do - schedule = create_schedule(<<~CHART) - days | MTWTFSS | - main | XX | - start_only | [ | - due_only | ] | - no_dates | | - CHART - - expect(WorkPackage.count).to eq(4) - expect(schedule.work_package("main")).to have_attributes( - subject: "main", - start_date: monday, - due_date: tuesday, - duration: 2 - ) - expect(schedule.work_package("start_only")).to have_attributes( - subject: "start_only", - start_date: monday, - due_date: nil, - duration: nil - ) - expect(schedule.work_package("due_only")).to have_attributes( - subject: "due_only", - start_date: nil, - due_date: tuesday, - duration: nil - ) - expect(schedule.work_package("no_dates")).to have_attributes( - subject: "no_dates", - start_date: nil, - due_date: nil, - duration: nil - ) - end - # rubocop:enable RSpec/ExampleLength - - it "creates parent/child relations from the given chart" do - schedule = create_schedule(<<~CHART) - days | MTWTFSS | - main | | - child | | child of main - CHART - expect(schedule.work_package("main")).to have_attributes( - children: [schedule.work_package("child")] - ) - expect(schedule.work_package("child")).to have_attributes( - parent: schedule.work_package("main") - ) - end - - it "creates follows relations from the given chart" do - schedule = create_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX | - follower | X | follows predecessor with lag 2 - CHART - expect(Relation.count).to eq(1) - expect(schedule.follows_relation(from: "follower", to: "predecessor")).to be_an_instance_of(Relation) - expect(schedule.follows_relation(from: "follower", to: "predecessor")).to have_attributes( - relation_type: "follows", - lag: 2, - from: schedule.work_package("follower"), - to: schedule.work_package("predecessor") - ) - end - end - - describe "change_schedule" 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(:thursday) { Date.new(2022, 6, 23) } - let(:friday) { Date.new(2022, 6, 24) } - - 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 - - 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 a work package name in the chart cannot be found in the given work packages" do - expect do - expect_schedule([main], <<~CHART) - | MTWTFSS | - main | XX | - other | XXXX | - CHART - end.to raise_error(RSpec::Expectations::ExpectationNotMetError) - end - end -end diff --git a/spec/support_spec/schedule_helpers/let_schedule_spec.rb b/spec/support_spec/schedule_helpers/let_schedule_spec.rb deleted file mode 100644 index d6a6f69372eb..000000000000 --- a/spec/support_spec/schedule_helpers/let_schedule_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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" - -RSpec.describe ScheduleHelpers::LetSchedule do - create_shared_association_defaults_for_work_package_factory - - describe "let_schedule" do - let_schedule(<<~CHART) - days | MTWTFSS | - main | XX | - follower | XXX | follows main with lag 2 - child | | child of main - CHART - - 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.monday, - due_date: schedule.tuesday - ) - expect(follower).to have_attributes( - subject: "follower", - start_date: schedule.wednesday, - due_date: schedule.friday - ) - expect(child).to have_attributes( - subject: "child", - start_date: nil, - due_date: nil - ) - end - - it "creates follows relations between work packages" do - expect(follower.follows_relations.count).to eq(1) - expect(follower.follows_relations.first.to).to eq(main) - end - - it "creates parent / child relations" do - expect(child.parent).to eq(main) - end - end -end diff --git a/spec/support_spec/table_helpers/column_type/days_counting_spec.rb b/spec/support_spec/table_helpers/column_type/days_counting_spec.rb new file mode 100644 index 000000000000..d80fd10fdef3 --- /dev/null +++ b/spec/support_spec/table_helpers/column_type/days_counting_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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" + +module TableHelpers::ColumnType + RSpec.describe DaysCounting do + subject(:column_type) { described_class.new } + + def parsed_attributes(table) + work_packages_data = TableHelpers::TableParser.new.parse(table) + work_packages_data.pluck(:attributes) + end + + describe "#parse" do + it "maps 'all days' to `ignore_non_working_days: true`" do + expect(parsed_attributes(<<~TABLE)) + | days counting | + | all days | + TABLE + .to eq([{ ignore_non_working_days: true }]) + end + + it "maps 'working days only' to `ignore_non_working_days: false`" do + expect(parsed_attributes(<<~TABLE)) + | days counting | + | working days only | + TABLE + .to eq([{ ignore_non_working_days: false }]) + end + + it "raises an error if value is empty" do + expect { parsed_attributes(<<~TABLE) } + | days counting | + | | + TABLE + .to raise_error("Invalid value for 'days counting' column: \"\". " \ + "Expected 'all days' (ignore_non_working_days: true) " \ + "or 'working days only' (ignore_non_working_days: false).") + end + + it "can still use 'ignore_non_working_days' as column name with `true` and `false` as values" do + expect(parsed_attributes(<<~TABLE)) + | ignore_non_working_days | + | true | + | false | + TABLE + .to eq([{ ignore_non_working_days: true }, { ignore_non_working_days: false }]) + end + + it "raises an error if value is invalid" do + expect { parsed_attributes(<<~TABLE) } + | days counting | + | foo | + TABLE + .to raise_error("Invalid value for 'days counting' column: \"foo\". " \ + "Expected 'all days' (ignore_non_working_days: true) " \ + "or 'working days only' (ignore_non_working_days: false).") + end + end + + describe "#format" do + it "maps `true` to 'all days'" do + expect(column_type.format(true)).to eq "all days" + end + + it "maps `false` to 'working days only'" do + expect(column_type.format(false)).to eq "working days only" + end + end + end +end diff --git a/spec/support_spec/table_helpers/example_methods_spec.rb b/spec/support_spec/table_helpers/example_methods_spec.rb new file mode 100644 index 000000000000..18f44454e40c --- /dev/null +++ b/spec/support_spec/table_helpers/example_methods_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" + +module TableHelpers + RSpec.describe ExampleMethods do + create_shared_association_defaults_for_work_package_factory + + describe "change_work_packages" 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(:thursday) { Date.new(2022, 6, 23) } + let(:friday) { Date.new(2022, 6, 24) } + + before do + travel_to(fake_today) + end + + it "applies attribute changes to a group of work packages from a visual table representation" do + main = build_stubbed(:work_package, subject: "main") + second = build_stubbed(:work_package, subject: "second") + change_work_packages([main, second], <<~TABLE) + subject | MTWTFSS | scheduling mode | + main | XX | manual | + second | XX | automatic | + TABLE + expect(main.start_date).to eq(monday) + expect(main.due_date).to eq(tuesday) + expect(main.schedule_manually).to be(true) + expect(second.start_date).to eq(thursday) + expect(second.due_date).to eq(friday) + expect(second.schedule_manually).to be(false) + 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_work_packages([main], <<~TABLE) + subject | MTWTFSS | + main | XX | + TABLE + 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 +end diff --git a/spec/workers/work_packages/apply_working_days_change_job_spec.rb b/spec/workers/work_packages/apply_working_days_change_job_spec.rb index 8fa073cace0f..5e84b0be6dd8 100644 --- a/spec/workers/work_packages/apply_working_days_change_job_spec.rb +++ b/spec/workers/work_packages/apply_working_days_change_job_spec.rb @@ -67,14 +67,14 @@ context "with non-working weekday settings" do context "when a work package includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | work_package_on_start | XX ░░ | work_package_on_due | XXX ░░ | wp_start_only | [ ░░ | wp_due_only | ] ░░ | - CHART + TABLE before do set_non_working_week_days("wednesday") @@ -83,14 +83,14 @@ it "moves the finish date to the corresponding number of now-excluded days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XX▓XX░░ | work_package_on_start | ░XX░░ | work_package_on_due | XX▓X ░░ | wp_start_only | ░[ ░░ | wp_due_only | ░] ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -111,10 +111,10 @@ end context "when a work package was scheduled to start on a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX ░░ | - CHART + TABLE before do set_non_working_week_days("wednesday") @@ -123,10 +123,10 @@ it "moves the start date to the earliest working day in the future, " \ "and the finish date changes by consequence [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | ░XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -143,10 +143,10 @@ end context "when a work package includes a date that is no more a non-working day" do - let_schedule(<<~CHART) - days | fssMTWTFSS | + let_work_packages(<<~TABLE) + subject | fssMTWTFSS | work_package | X▓▓XX ░░ | - CHART + TABLE before do set_working_week_days("saturday") @@ -154,10 +154,10 @@ it "moves the finish date backwards to the corresponding number of now-included days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | fssMTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | fssMTWTFSS | work_package | XX▓X ░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -174,11 +174,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | working days work week - follower | XXX░ | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | XX ░░ | working days only | manual | + follower | XXX░ | all days | automatic | follows predecessor + TABLE before do set_non_working_week_days("wednesday") @@ -186,11 +186,11 @@ it "moves the follower start date by consequence of the predecessor dates shift [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | X▓X ░░ | follower | ░ XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -208,11 +208,11 @@ end context "when a follower has a predecessor with lag covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | - follower | X ░░ | follows predecessor with lag 1 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX ░░ | manual | + follower | X ░░ | automatic | follows predecessor with lag 1 + TABLE before do set_non_working_week_days("wednesday") @@ -220,11 +220,11 @@ it "moves the follower start date forward to keep the lag to 1 day" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -244,11 +244,11 @@ end context "with work packages without dates following each other with lag" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | ░░ | - follower | ░░ | follows predecessor with lag 5 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | ░░ | manual | + follower | ░░ | automatic | follows predecessor with lag 5 + TABLE before do set_non_working_week_days("wednesday") @@ -256,11 +256,11 @@ it "does not move anything (obviously) and does not crash either" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | ░ ░░ | follower | ░ ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -271,11 +271,11 @@ end context "when a follower has a predecessor with lag covering multiple days with different working changes" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X ░ ░░ | - follower | ░ X░░ | follows predecessor with lag 2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | X ░ ░░ | manual | + follower | ░ X░░ | automatic | follows predecessor with lag 2 + TABLE let(:work_week) { set_work_week("monday", "tuesday", "thursday", "friday") } before do @@ -285,11 +285,11 @@ it "correctly handles the changes" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | X░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -300,11 +300,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X▓X ░░ | working days work week - follower | ░ XXX | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | X▓X ░░ | working days only | manual | + follower | ░ XXX | all days | automatic | follows predecessor + TABLE let(:work_week) { set_work_week("monday", "tuesday", "thursday", "friday") } before do @@ -314,11 +314,11 @@ it "does not move the follower backwards" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | XX ░░ | follower | XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -338,11 +338,11 @@ end context "when a follower has a predecessor with a non-working day between them that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX░ ░░ | - follower | ░XX░░ | follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX░ ░░ | manual | + follower | ░XX░░ | automatic | follows predecessor + TABLE let(:work_week) { set_work_week("monday", "tuesday", "thursday", "friday") } before do @@ -351,11 +351,11 @@ it "does not move the follower" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX | follower | XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -366,10 +366,10 @@ end context "when a work package has working days include weekends, and includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XXXX ░░ | working days include weekends - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | + work_package | XXXX ░░ | all days | + TABLE before do set_non_working_week_days("wednesday") @@ -377,10 +377,10 @@ it "does not move any dates" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -391,10 +391,10 @@ end context "when a work package only has a duration" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ░░ | duration 3 days - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration + work_package | ░░ | 3 days + TABLE before do set_non_working_week_days("wednesday") @@ -413,28 +413,28 @@ end context "when having multiple work packages following each other, and having days becoming non working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | X ░░ | follows wp3 - wp3 | XXX ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | X ░░ | automatic | follows wp3 + wp3 | XXX ░░ | manual | + TABLE before do set_non_working_week_days("tuesday", "wednesday", "friday") end - it "updates them only once most of the time" do + it "updates them only once most of the time", :aggregate_failures do expect { subject } .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -464,12 +464,12 @@ # * follower wp3 gets rescheduled and moves to next Monday # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X ░░ | - wp2 | XX ░░ | - wp3 | X░░ | follows wp1, follows wp2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X ░░ | manual | + wp2 | XX ░░ | manual | + wp3 | X░░ | automatic | follows wp1, follows wp2 + TABLE before do set_non_working_week_days("tuesday", "wednesday", "friday") @@ -480,12 +480,12 @@ .to change { WorkPackage.order(:subject).pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 2]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwt | - wp1 | ░░X░░░ ░░ | - wp2 | ░░X▓▓▓X░░ | - wp3 | ░░ ░░░ ░░X | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwt | + wp1 | ░░X░░░ ░░ | + wp2 | ░░X▓▓▓X░░ | + wp3 | ░░ ░░░ ░░X | + TABLE end it_behaves_like "journal updates with cause" do @@ -502,12 +502,12 @@ end context "when having multiple work packages following each other, and having days becoming working days" do - let_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | follows wp2 - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | follows wp3 - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | + TABLE let(:work_week) { set_work_week("monday", "thursday") } @@ -517,12 +517,12 @@ it "keeps the same start dates and updates them only once" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 - wp3 | XXX ░░ ░░ ░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | properties + wp1 | ░░ ░░XXX ░░ | follows wp2 + wp2 | ░░ X ░░ ░░ | follows wp3 + wp3 | XXX ░░ ░░ ░░ | + TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) end @@ -543,12 +543,12 @@ end context "when having multiple work packages following each other and first one only has a due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | XX ░░ | follows wp3 - wp3 | ] ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | XX ░░ | automatic | follows wp3 + wp3 | ] ░░ | manual | + TABLE before do set_non_working_week_days("tuesday", "wednesday", "friday") @@ -559,12 +559,12 @@ .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSm t ssm t ssm | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | - wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSm t ssm t ssm | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | + wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -581,10 +581,10 @@ end context "when turning Sunday into a working day" do - let_schedule(<<~CHART) - days | MTWTFSSm | + let_work_packages(<<~TABLE) + subject | MTWTFSSm | work_package | X▓▓X | - CHART + TABLE before do set_working_week_days("Sunday") @@ -610,14 +610,14 @@ let!(:previous_non_working_days) { week_with_saturday_and_sunday_as_non_working_day } context "when a work package includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | work_package_on_start | XX ░░ | work_package_on_due | XXX ░░ | wp_start_only | [ ░░ | wp_due_only | ] ░░ | - CHART + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -626,14 +626,14 @@ it "moves the finish date to the corresponding number of now-excluded days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XX▓XX░░ | work_package_on_start | ░XX░░ | work_package_on_due | XX▓X ░░ | wp_start_only | ░[ ░░ | wp_due_only | ░] ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -655,10 +655,10 @@ end context "when a work package was scheduled to start on a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX ░░ | - CHART + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -667,10 +667,10 @@ it "moves the start date to the earliest working day in the future, " \ "and the finish date changes by consequence [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | ░XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -688,10 +688,10 @@ end context "when a work package includes a date that is no more a non-working day" do - let_schedule(<<~CHART) - days | fssMTWTFSS | + let_work_packages(<<~TABLE) + subject | fssMTWTFSS | work_package | X▓▓XX ░░ | - CHART + TABLE before do set_working_days(monday.next_occurring(:saturday)) @@ -699,10 +699,10 @@ it "moves the finish date backwards to the corresponding number of now-included days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | fssMTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | fssMTWTFSS | work_package | XX▓X ░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -720,11 +720,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | working days work week - follower | XXX░ | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | XX ░░ | working days only | manual | + follower | XXX░ | all days | automatic | follows predecessor + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -732,11 +732,11 @@ it "moves the follower start date by consequence of the predecessor dates shift [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | X▓X ░░ | follower | ░ XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -754,11 +754,11 @@ end context "when a follower has a predecessor with lag covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | - follower | X ░░ | follows predecessor with lag 1 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX ░░ | manual | + follower | X ░░ | automatic | follows predecessor with lag 1 + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -766,11 +766,11 @@ it "moves the follower start date forward to keep the lag to 1 day" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -790,11 +790,11 @@ end context "with work packages without dates following each other with lag" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | ░░ | - follower | ░░ | follows predecessor with lag 5 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | ░░ | manual | + follower | ░░ | automatic | follows predecessor with lag 5 + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -802,11 +802,11 @@ it "does not move anything (obviously) and does not crash either" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | ░ ░░ | follower | ░ ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -817,11 +817,11 @@ end context "when a follower has a predecessor with lag covering multiple days with different working changes" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X ░ ░░ | - follower | ░ X░░ | follows predecessor with lag 2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | X ░ ░░ | manual | + follower | ░ X░░ | automatic | follows predecessor with lag 2 + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } @@ -836,11 +836,11 @@ it "correctly handles the changes" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | X░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -851,11 +851,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X▓X ░░ | working days work week - follower | ░ XXX | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | X▓X ░░ | working days only | manual | + follower | ░ XXX | all days | automatic | follows predecessor + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } let!(:previous_non_working_days) do @@ -869,11 +869,11 @@ it "does not move the follower backwards" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | XX ░░ | follower | XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -893,11 +893,11 @@ end context "when a follower has a predecessor with a non-working day between them that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX░ ░░ | - follower | ░XX░░ | follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX░ ░░ | manual | + follower | ░XX░░ | automatic | follows predecessor + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } let!(:previous_non_working_days) do @@ -910,11 +910,11 @@ it "does not move the follower" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX | follower | XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -925,10 +925,10 @@ end context "when a work package has working days include weekends, and includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XXXX ░░ | working days include weekends - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | + work_package | XXXX ░░ | all days | + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -936,10 +936,10 @@ it "does not move any dates" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -950,10 +950,10 @@ end context "when a work package only has a duration" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ░░ | duration 3 days - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration + work_package | ░░ | 3 days + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -972,12 +972,12 @@ end context "when having multiple work packages following each other, and having days becoming non working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | X ░░ | follows wp3 - wp3 | XXX ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | X ░░ | automatic | follows wp3 + wp3 | XXX ░░ | manual | + TABLE let(:non_working_days) do [ @@ -998,12 +998,12 @@ .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -1029,12 +1029,12 @@ # * follower wp3 gets rescheduled and moves to next Monday # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X ░░ | - wp2 | XX ░░ | - wp3 | X░░ | follows wp1, follows wp2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X ░░ | manual | + wp2 | XX ░░ | manual | + wp3 | X░░ | automatic | follows wp1, follows wp2 + TABLE let(:non_working_days) do [ @@ -1053,12 +1053,12 @@ .to change { WorkPackage.order(:subject).pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 2]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwt | - wp1 | ░░X░░░ ░░ | - wp2 | ░░X▓▓▓X░░ | - wp3 | ░░ ░░░ ░░X | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwt | + wp1 | ░░X░░░ ░░ | + wp2 | ░░X▓▓▓X░░ | + wp3 | ░░ ░░░ ░░X | + TABLE end it_behaves_like "journal updates with cause" do @@ -1075,12 +1075,12 @@ end context "when having multiple work packages following each other, and having days becoming working days" do - let_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | follows wp2 - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | follows wp3 - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | + TABLE let(:non_working_days) do [ @@ -1102,12 +1102,12 @@ it "keeps the same start dates and updates them only once" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 - wp3 | XXX ░░ ░░ ░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | properties + wp1 | ░░ ░░XXX ░░ | follows wp2 + wp2 | ░░ X ░░ ░░ | follows wp3 + wp3 | XXX ░░ ░░ ░░ | + TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) end @@ -1128,12 +1128,12 @@ end context "when having multiple work packages following each other and first one only has a due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | XX ░░ | follows wp3 - wp3 | ] ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | XX ░░ | automatic | follows wp3 + wp3 | ] ░░ | manual | + TABLE let(:non_working_days) do [ @@ -1154,12 +1154,12 @@ .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSm t ssm t ssm | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | - wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSm t ssm t ssm | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | + wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -1177,10 +1177,10 @@ end context "when turning Sunday into a working day" do - let_schedule(<<~CHART) - days | MTWTFSSm | + let_work_packages(<<~TABLE) + subject | MTWTFSSm | work_package | X▓▓X | - CHART + TABLE let(:non_working_days) do [monday.next_occurring(:sunday), next_monday.next_occurring(:sunday)] @@ -1211,14 +1211,14 @@ # Leaving them as is, we get a mix of non-working days and weekday settings. context "when a work package includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | work_package_on_start | XX ░░ | work_package_on_due | XXX ░░ | wp_start_only | [ ░░ | wp_due_only | ] ░░ | - CHART + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1227,14 +1227,14 @@ it "moves the finish date to the corresponding number of now-excluded days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XX▓XX░░ | work_package_on_start | ░XX░░ | work_package_on_due | XX▓X ░░ | wp_start_only | ░[ ░░ | wp_due_only | ░] ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1255,10 +1255,10 @@ end context "when a work package was scheduled to start on a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | + let_work_packages(<<~TABLE) + subject | MTWTFSS | work_package | XX ░░ | - CHART + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1267,10 +1267,10 @@ it "moves the start date to the earliest working day in the future, " \ "and the finish date changes by consequence [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | ░XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1287,10 +1287,10 @@ end context "when a work package includes a date that is no more a non-working day" do - let_schedule(<<~CHART) - days | fssMTWTFSS | + let_work_packages(<<~TABLE) + subject | fssMTWTFSS | work_package | X▓▓XX ░░ | - CHART + TABLE let!(:previous_non_working_days) { week_with_saturday_and_sunday_as_non_working_day } @@ -1301,10 +1301,10 @@ it "moves the finish date backwards to the corresponding number of now-included days to maintain duration [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | fssMTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | fssMTWTFSS | work_package | XX▓X ░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1322,11 +1322,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | working days work week - follower | XXX░ | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | XX ░░ | working days only | manual | + follower | XXX░ | all days | automatic | follows predecessor + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1334,11 +1334,11 @@ it "moves the follower start date by consequence of the predecessor dates shift [#31992]" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | X▓X ░░ | follower | ░ XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1356,11 +1356,11 @@ end context "when a follower has a predecessor with lag covering a day that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX ░░ | - follower | X ░░ | follows predecessor with lag 1 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX ░░ | manual | + follower | X ░░ | automatic | follows predecessor with lag 1 + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1368,11 +1368,11 @@ it "moves the follower start date forward to keep the lag to 1 day" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1392,11 +1392,11 @@ end context "with work packages without dates following each other with lag" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | ░░ | - follower | ░░ | follows predecessor with lag 5 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | ░░ | manual | + follower | ░░ | automatic | follows predecessor with lag 5 + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1404,11 +1404,11 @@ it "does not move anything (obviously) and does not crash either" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | ░ ░░ | follower | ░ ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1418,12 +1418,12 @@ end end - context "when a follower has a predecessor with delay covering multiple days with different working changes" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X ░ ░░ | - follower | ░ X░░ | follows predecessor with delay 2 - CHART + context "when a follower has a predecessor with lag covering multiple days with different working changes" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | X ░ ░░ | manual | + follower | ░ X░░ | automatic | follows predecessor with lag 2 + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } @@ -1438,11 +1438,11 @@ it "correctly handles the changes" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | X░ ░░ | follower | ░ X░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1453,11 +1453,11 @@ end context "when a follower has a predecessor with dates covering a day that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | X▓X ░░ | working days work week - follower | ░ XXX | working days include weekends, follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | scheduling mode | properties + predecessor | X▓X ░░ | working days only | manual | + follower | ░ XXX | all days | automatic | follows predecessor + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } let!(:previous_non_working_days) do @@ -1471,11 +1471,11 @@ it "does not move the follower backwards" do subject - expect_schedule(WorkPackage.all, <<~CHART) - days | MTWTFSS | + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | predecessor | XX ░░ | follower | XXX | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1495,11 +1495,11 @@ end context "when a follower has a predecessor with a non-working day between them that is now a working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - predecessor | XX░ ░░ | - follower | ░XX░░ | follows predecessor - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + predecessor | XX░ ░░ | manual | + follower | ░XX░░ | automatic | follows predecessor + TABLE let(:non_working_day) { create(:non_working_day, date: next_monday.next_occurring(:wednesday)) } let!(:previous_non_working_days) do @@ -1512,11 +1512,11 @@ it "does not move the follower" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | predecessor | XX | follower | XX░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1527,10 +1527,10 @@ end context "when a work package has working days include weekends, and includes a date that is now a non-working day" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | XXXX ░░ | working days include weekends - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | days counting | + work_package | XXXX ░░ | all days | + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1538,10 +1538,10 @@ it "does not move any dates" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSS | + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSS | work_package | XXXX ░░ | - CHART + TABLE end it_behaves_like "journal updates with cause" do @@ -1552,10 +1552,10 @@ end context "when a work package only has a duration" do - let_schedule(<<~CHART) - days | MTWTFSS | - work_package | ░░ | duration 3 days - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration + work_package | ░░ | 3 days + TABLE before do set_non_working_days(next_monday.next_occurring(:wednesday)) @@ -1574,12 +1574,12 @@ end context "when having multiple work packages following each other, and having days becoming non working days" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | X ░░ | follows wp3 - wp3 | XXX ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | X ░░ | automatic | follows wp3 + wp3 | XXX ░░ | manual | + TABLE let(:non_working_days) do [ @@ -1600,12 +1600,12 @@ .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -1631,12 +1631,12 @@ # * follower wp3 gets rescheduled and moves to next Monday # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X ░░ | - wp2 | XX ░░ | - wp3 | X░░ | follows wp1, follows wp2 - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X ░░ | manual | + wp2 | XX ░░ | manual | + wp3 | X░░ | automatic | follows wp1, follows wp2 + TABLE let(:non_working_days) do [ @@ -1655,12 +1655,12 @@ .to change { WorkPackage.order(:subject).pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 2]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwt | - wp1 | ░░X░░░ ░░ | - wp2 | ░░X▓▓▓X░░ | - wp3 | ░░ ░░░ ░░X | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwt | + wp1 | ░░X░░░ ░░ | + wp2 | ░░X▓▓▓X░░ | + wp3 | ░░ ░░░ ░░X | + TABLE end it_behaves_like "journal updates with cause" do @@ -1677,12 +1677,12 @@ end context "when having multiple work packages following each other, and having days becoming working days" do - let_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | follows wp2 - wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | follows wp3 - wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 + wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 + wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | + TABLE let(:non_working_days) do [ @@ -1704,12 +1704,12 @@ it "keeps the same start dates and updates them only once" do subject - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSmtwtfssmtwtfss | - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 - wp3 | XXX ░░ ░░ ░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSmtwtfssmtwtfss | properties + wp1 | ░░ ░░XXX ░░ | follows wp2 + wp2 | ░░ X ░░ ░░ | follows wp3 + wp3 | XXX ░░ ░░ ░░ | + TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) end @@ -1730,12 +1730,12 @@ end context "when having multiple work packages following each other and first one only has a due date" do - let_schedule(<<~CHART) - days | MTWTFSS | - wp1 | X▓▓XX | follows wp2 - wp2 | XX ░░ | follows wp3 - wp3 | ] ░░ | - CHART + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | properties + wp1 | X▓▓XX | automatic | follows wp2 + wp2 | XX ░░ | automatic | follows wp3 + wp3 | ] ░░ | manual | + TABLE let(:non_working_days) do [ @@ -1756,12 +1756,12 @@ .to change { WorkPackage.pluck(:lock_version) } .from([0, 0, 0]) .to([1, 1, 1]) - expect(WorkPackage.all).to match_schedule(<<~CHART) - days | MTWTFSSm t ssm t ssm | - wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | - wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | - wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | - CHART + expect(WorkPackage.all).to match_table(<<~TABLE) + subject | MTWTFSSm t ssm t ssm | + wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | + wp2 | ░░ ░░░X▓▓X░░░ ░░ ░░░ | + wp3 | ░░]░░░ ░░ ░░░ ░░ ░░░ | + TABLE end it_behaves_like "journal updates with cause" do @@ -1778,10 +1778,10 @@ end context "when turning Sunday into a working day" do - let_schedule(<<~CHART) - days | MTWTFSSm | + let_work_packages(<<~TABLE) + subject | MTWTFSSm | work_package | X▓▓X | - CHART + TABLE let!(:previous_non_working_days) { week_with_saturday_and_sunday_as_non_working_day } From b58a3ffc5066280cd616ebdceecfdd495bf0cc6f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 10 Dec 2024 16:31:07 +0100 Subject: [PATCH 09/33] Show non-working days in the rendered table of table helpers --- .../work_packages/shared/working_days.rb | 6 +++ .../table_helpers/column_type/schedule.rb | 3 +- .../table_helpers/table_representer.rb | 33 +++++++++++-- .../table_helpers/table_representer_spec.rb | 47 +++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/app/services/work_packages/shared/working_days.rb b/app/services/work_packages/shared/working_days.rb index ecaf0a887548..5bc2179e01b4 100644 --- a/app/services/work_packages/shared/working_days.rb +++ b/app/services/work_packages/shared/working_days.rb @@ -29,6 +29,12 @@ module WorkPackages module Shared class WorkingDays + class << self + def clear_cache + RequestStore.delete(:work_package_non_working_dates) + end + end + # Returns number of working days between two dates, excluding weekend days # and non working days. def duration(start_date, due_date) diff --git a/spec/support/table_helpers/column_type/schedule.rb b/spec/support/table_helpers/column_type/schedule.rb index 5abe95ce5b26..19c325f36d59 100644 --- a/spec/support/table_helpers/column_type/schedule.rb +++ b/spec/support/table_helpers/column_type/schedule.rb @@ -58,7 +58,8 @@ class Schedule < Generic def attributes_for_work_package(_attribute, work_package) { start_date: work_package.start_date, - due_date: work_package.due_date + due_date: work_package.due_date, + ignore_non_working_days: work_package.ignore_non_working_days } end diff --git a/spec/support/table_helpers/table_representer.rb b/spec/support/table_helpers/table_representer.rb index 31b1123c9d42..aa6171ecbe8c 100644 --- a/spec/support/table_helpers/table_representer.rb +++ b/spec/support/table_helpers/table_representer.rb @@ -36,6 +36,7 @@ def initialize(tables_data:, columns:) end def render(table_data) + WorkPackages::Shared::WorkingDays.clear_cache columns .map { |column| formatted_cells_for_column(column, table_data) } .transpose @@ -53,8 +54,9 @@ def get_header_and_values(column, table_data) header = schedule_column_representer.column_title start_dates = table_data.values_for_attribute(:start_date) due_dates = table_data.values_for_attribute(:due_date) - values = start_dates.zip(due_dates).map do |start_date, due_date| - schedule_column_representer.span(start_date, due_date) + ignore_non_working_days_values = ignore_non_working_days_values(table_data) + values = start_dates.zip(due_dates, ignore_non_working_days_values).map do |start_date, due_date, ignore_non_working_days| + schedule_column_representer.span(start_date, due_date, ignore_non_working_days) end else header = column.title @@ -65,6 +67,18 @@ def get_header_and_values(column, table_data) [header, *values] end + # look into the other tables to find the ignore_non_working_days values of + # work packages for the given table data + def ignore_non_working_days_values(table_data) + master_values = table_data.work_packages_data.pluck(:attributes).pluck(:subject, :ignore_non_working_days).to_h + other_values = tables_data.reject { _1 == table_data } + .map { _1.work_packages_data.pluck(:attributes).pluck(:subject, :ignore_non_working_days).to_h.compact } + .reduce({}) { |acc, element| acc.reverse_merge(element) } + master_values.map do |subject, ignore_non_working_days| + ignore_non_working_days.nil? ? other_values[subject] : ignore_non_working_days + end + end + def normalize_width(cells, column) header, *values = cells width = column_width(column) @@ -118,7 +132,7 @@ def column_title spaced_at(monday, "MTWTFSS") end - def span(start_date, due_date) + def span(start_date, due_date, ignore_non_working_days) if start_date.nil? && due_date.nil? " " * column_size elsif due_date.nil? @@ -126,7 +140,10 @@ def span(start_date, due_date) elsif start_date.nil? spaced_at(due_date, "]") else - span = "X" * (start_date..due_date).count + days = days_for(ignore_non_working_days) + span = (start_date..due_date).map do |date| + days.working?(date) ? "X" : "." + end.join spaced_at(start_date, span) end end @@ -136,6 +153,14 @@ def spaced_at(date, text) spaced = (" " * nb_days) + text spaced.ljust(column_size) end + + def days_for(ignore_non_working_days) + if ignore_non_working_days + WorkPackages::Shared::AllDays.new + else + WorkPackages::Shared::WorkingDays.new + end + end end end end diff --git a/spec/support_spec/table_helpers/table_representer_spec.rb b/spec/support_spec/table_helpers/table_representer_spec.rb index 575b4c91b51e..26fe75ba5941 100644 --- a/spec/support_spec/table_helpers/table_representer_spec.rb +++ b/spec/support_spec/table_helpers/table_representer_spec.rb @@ -131,6 +131,53 @@ module TableHelpers TABLE end + context "when non working days are defined" do + let(:table) do + <<~TABLE + | subject | MTWTFSS | days counting + | wp1 | XXXXXXX | working days only + | wp2 | XXXXXXX | all days + TABLE + end + + before do + set_non_working_week_days("wednesday", "thursday") + end + + it "renders the non working days as dots `.` for work packages taking non-working days into account" do + expect(representer.render(table_data)).to eq <<~TABLE + | MTWTFSS | + | XX..XXX | + | XXXXXXX | + TABLE + end + + context "when using a second table which does not have the ignore_non_working_days attribute knowledge" do + let(:twin_table) do + <<~TABLE + | subject | MTWTFSS | + | wp1 | XXXXXXX | + | wp2 | XXXXXXX | + TABLE + end + let(:twin_table_data) { TableData.for(twin_table) } + let(:tables_data) { [table_data, twin_table_data] } + + it "renders the non working days as dots `.` for both tables" do + expect(representer.render(table_data)).to eq <<~TABLE + | MTWTFSS | + | XX..XXX | + | XXXXXXX | + TABLE + expect(representer.render(twin_table_data)).to eq <<~TABLE + | MTWTFSS | + | XX..XXX | + | XXXXXXX | + TABLE + end + end + end + context "when using a second table for the size" do let(:twin_table) do <<~TABLE From 24e06e88e78fd4a6f84843bc91b33b1c01a82149 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 10 Dec 2024 16:30:53 +0100 Subject: [PATCH 10/33] [59539] Start as soon as possible in automatic scheduling mode The original start date of the work package is discarded. The soonest start date is always used instead. --- .../work_packages/set_schedule_service.rb | 2 +- .../set_schedule_service_spec.rb | 131 ++++++++++++------ .../set_schedule_service_working_days_spec.rb | 99 +++++++------ .../apply_working_days_change_job_spec.rb | 88 ++++++------ 4 files changed, 185 insertions(+), 135 deletions(-) diff --git a/app/services/work_packages/set_schedule_service.rb b/app/services/work_packages/set_schedule_service.rb index f019d28cc3eb..8969656962d4 100644 --- a/app/services/work_packages/set_schedule_service.rb +++ b/app/services/work_packages/set_schedule_service.rb @@ -147,7 +147,7 @@ def reschedule_by_descendants(scheduled, dependency) def reschedule_by_predecessors(scheduled, dependency) return unless dependency.soonest_start_date - new_start_date = [scheduled.start_date, dependency.soonest_start_date].compact.max + new_start_date = dependency.soonest_start_date new_due_date = determine_due_date(scheduled, new_start_date) set_dates(scheduled, new_start_date, new_due_date) assign_cause_for_journaling(scheduled, :predecessor) diff --git a/spec/services/work_packages/set_schedule_service_spec.rb b/spec/services/work_packages/set_schedule_service_spec.rb index 208c5b28d967..7ddf7e61492b 100644 --- a/spec/services/work_packages/set_schedule_service_spec.rb +++ b/spec/services/work_packages/set_schedule_service_spec.rb @@ -117,7 +117,7 @@ def create_parent(child, start_date: child.start_date, due_date: child.due_date) def create_child(parent, start_date, due_date, **attributes) create(:work_package, - subject: "child of #{parent.subject}", + subject: "child #{parent.children.count + 1} of #{parent.subject}", start_date:, due_date:, parent:, @@ -127,16 +127,13 @@ def create_child(parent, start_date, due_date, **attributes) subject { instance.call(attributes) } shared_examples_for "reschedules" do - before do - subject - end + it "successfully updates the following work packages", :aggregate_failures do # rubocop:disable RSpec/ExampleLength + expect(subject).to be_success - it "is success" do - expect(subject) - .to be_success - end + # returns only the original and the changed work packages + expect(subject.all_results) + .to contain_exactly(work_package, *expected.keys) - it "updates the following work packages" do expected.each do |wp, (start_date, due_date)| expected_cause_type = "work_package_related_changed_times" result = subject.all_results.find { |result_wp| result_wp.id == wp.id } @@ -170,29 +167,15 @@ def create_child(parent, start_date, due_date, **attributes) "to have duration #{duration.inspect}, got #{result.duration.inspect}" end end - - it "returns only the original and the changed work packages" do - expect(subject.all_results) - .to match_array expected.keys + [work_package] - end end shared_examples_for "does not reschedule" do - before do - subject - end - - it "is success" do - expect(subject) - .to be_success - end + it "is successful and does not change any other work packages nor assign any journal cause" do + expect(subject).to be_success - it "does not change any other work packages" do expect(subject.all_results) .to contain_exactly(work_package) - end - it "does not assign a journal cause" do subject.all_results.each do |work_package| expect(work_package.journal_cause).to be_blank end @@ -266,7 +249,7 @@ def create_child(parent, start_date, due_date, **attributes) end end - context "when moving forward with the follower having enough space left to not be moved at all" do + context "when moving forward with the follower having enough space left to start earlier" do let(:follower1_start_date) { Time.zone.today + 10.days } let(:follower1_due_date) { Time.zone.today + 12.days } @@ -274,7 +257,11 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today + 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [Time.zone.today + 6.days, Time.zone.today + 8.days] } + end + end end context "when moving forward with the follower having some space left and a lag" do @@ -301,6 +288,7 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today + 5.days end + # no need to reschedule: the successor is already right after its predecessor it_behaves_like "does not reschedule" end @@ -309,7 +297,11 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [Time.zone.today - 4.days, Time.zone.today - 2.days] } + end + end end context "when moving backwards with space between" do @@ -320,7 +312,11 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [Time.zone.today - 4.days, Time.zone.today - 2.days] } + end + end end context 'when moving backwards with the follower having no start date (which should not happen) \ @@ -426,9 +422,9 @@ def create_child(parent, start_date, due_date, **attributes) create_follower(follower1_start_date, follower1_due_date, { work_package => follower1_lag, - another_successor => 0 }) + another_predecessor => 0 }) end - let(:another_successor) do + let(:another_predecessor) do create(:work_package, start_date: nil, due_date: nil) @@ -451,7 +447,11 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [Time.zone.today - 4.days, Time.zone.today - 2.days] } + end + end end end end @@ -591,7 +591,12 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [Time.zone.today - 4.days, Time.zone.today - 2.days], + parent_following_work_package1 => [Time.zone.today - 4.days, Time.zone.today - 2.days] } + end + end end end @@ -651,7 +656,7 @@ def create_child(parent, start_date, due_date, **attributes) let(:follower1_due_date) { work_package_due_date + 10.days } let(:child1_start_date) { follower1_start_date } let(:child1_due_date) { follower1_start_date + 3.days } - let(:child2_start_date) { follower1_start_date + 8.days } + let(:child2_start_date) { follower1_due_date - 1.day } let(:child2_due_date) { follower1_due_date } let(:child1_work_package) do @@ -674,7 +679,17 @@ def create_child(parent, start_date, due_date, **attributes) end context "with unchanged dates (e.g. when creating a follows relation) and successor starting 1 day after scheduled" do - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { + # child1 is not rescheduled as it is already starting right after the moved work package + # child2 is rescheduled right after the moved work package + child2_work_package => [work_package_due_date + 1.day, work_package_due_date + 2.days], + # following is rescheduled to match its 2 children's dates + following_work_package1 => [work_package_due_date + 1.day, work_package_due_date + 4.days] + } + end + end end context "with unchanged dates (e.g. when creating a follows relation) and successor starting 3 days after scheduled" do @@ -682,10 +697,20 @@ def create_child(parent, start_date, due_date, **attributes) let(:follower1_due_date) { follower1_start_date + 10.days } let(:child1_start_date) { follower1_start_date } let(:child1_due_date) { follower1_start_date + 6.days } - let(:child2_start_date) { follower1_start_date + 8.days } + let(:child2_start_date) { follower1_due_date - 1.day } let(:child2_due_date) { follower1_due_date } - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { + # child1 and child2 rescheduled right after the moved work package + child1_work_package => [work_package_due_date + 1.day, work_package_due_date + 7.days], + child2_work_package => [work_package_due_date + 1.day, work_package_due_date + 2.days], + # following is rescheduled to match its 2 children's dates + following_work_package1 => [work_package_due_date + 1.day, work_package_due_date + 7.days] + } + end + end end context "with unchanged dates (e.g. when creating a follows relation) and successor's first child needs to be rescheduled" do @@ -693,19 +718,23 @@ def create_child(parent, start_date, due_date, **attributes) let(:follower1_due_date) { work_package_due_date + 10.days } let(:child1_start_date) { follower1_start_date } let(:child1_due_date) { follower1_start_date + 6.days } - let(:child2_start_date) { follower1_start_date + 8.days } + let(:child2_start_date) { follower1_due_date - 5.days } let(:child2_due_date) { follower1_due_date } # following parent is reduced in length as the children allow to be executed at the same time it_behaves_like "reschedules" do let(:expected) do - { following_work_package1 => [work_package_due_date + 1.day, follower1_due_date], - child1_work_package => [work_package_due_date + 1.day, follower1_start_date + 10.days] } + { # child1 and child2 rescheduled right after the moved work package + child1_work_package => [work_package_due_date + 1.day, work_package_due_date + 7.days], + child2_work_package => [work_package_due_date + 1.day, work_package_due_date + 6.days], + # following is rescheduled to match its 2 children's dates + following_work_package1 => [work_package_due_date + 1.day, work_package_due_date + 7.days] + } end end end - context 'with unchanged dates (e.g. when creating a follows relation) and successor\s children need to be rescheduled' do + context "with unchanged dates (e.g. when creating a follows relation) and successor's children need to be rescheduled" do let(:follower1_start_date) { work_package_due_date - 8.days } let(:follower1_due_date) { work_package_due_date + 10.days } let(:child1_start_date) { follower1_start_date } @@ -767,7 +796,8 @@ def create_child(parent, start_date, due_date, **attributes) it_behaves_like "reschedules" do let(:expected) do { following_work_package1 => [Time.zone.today + 6.days, Time.zone.today + 8.days], - following_work_package2 => [Time.zone.today + 9.days, Time.zone.today + 12.days] } + following_work_package2 => [Time.zone.today + 9.days, Time.zone.today + 12.days], + following_work_package3 => [Time.zone.today + 13.days, Time.zone.today + 14.days] } end end end @@ -777,7 +807,13 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [work_package.due_date + 1.day, work_package.due_date + 3.days], + following_work_package2 => [work_package.due_date + 4.days, work_package.due_date + 8.days], + following_work_package3 => [work_package.due_date + 9.days, work_package.due_date + 10.days] } + end + end end end @@ -826,7 +862,14 @@ def create_child(parent, start_date, due_date, **attributes) work_package.due_date = Time.zone.today - 5.days end - it_behaves_like "does not reschedule" + it_behaves_like "reschedules" do + let(:expected) do + { following_work_package1 => [work_package.due_date + 1.day, work_package.due_date + 3.days], + following_work_package2 => [work_package.due_date + 4.days, work_package.due_date + 8.days], + following_work_package3 => [work_package.due_date + 1.day, work_package.due_date + 4.days], + following_work_package4 => [work_package.due_date + 9.days, work_package.due_date + 10.days] } + end + end end end diff --git a/spec/services/work_packages/set_schedule_service_working_days_spec.rb b/spec/services/work_packages/set_schedule_service_working_days_spec.rb index efd102b97271..262e8dfd2410 100644 --- a/spec/services/work_packages/set_schedule_service_working_days_spec.rb +++ b/spec/services/work_packages/set_schedule_service_working_days_spec.rb @@ -242,14 +242,11 @@ TABLE end - it "does not move follower" do + it "moves follower to the soonest working day after its predecessor due date" do expect_work_packages(subject.all_results, <<~TABLE) subject | MTWTFSS | work_package | XXXXX..X | - TABLE - expect_work_packages([follower], <<~TABLE) - subject | MTWTFSS | - follower | XXX | + follower | XXX | TABLE end end @@ -311,15 +308,16 @@ before do change_work_packages([work_package], <<~TABLE) - subject | MTWTFSS | - work_package | X | + subject | MTWTFSS | + work_package | X | TABLE end - it "does not move the follower" do + it "moves follower to the soonest working day after its predecessor due date" do expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | X | + subject | MTWTFSS | + work_package | X | + follower | XX | TABLE end end @@ -340,8 +338,9 @@ it "does not move the follower" do expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | X | + subject | MTWTFSS | + work_package | X | + follower | X | TABLE end end @@ -362,9 +361,11 @@ end it "does not move the follower" do - expect_work_packages(subject.all_results, <<~TABLE) + expect_work_packages(subject.all_results + [annoyer], <<~TABLE) subject | mtwtfssmtwtfssMTWTFSS | work_package | X | + follower | X..X | + annoyer | XX..XX | TABLE end end @@ -521,7 +522,7 @@ follower.update_column(:schedule_manually, false) end - it "reschedules follower to start right after its predecessor and end the same day" do + it "reschedules follower to start right after its predecessor and end at the already defined due date" do expect_work_packages(subject.all_results, <<~TABLE) subject | MTWTFSS | work_package | XX | @@ -591,10 +592,11 @@ TABLE end - it "does not move the follower" do + it "rescheduled follower to start as soon as possible without influence from the other predecessor" do expect_work_packages(subject.all_results, <<~TABLE) subject | mtwtfssMTWTFSS | work_package | ] | + follower | XX..X | TABLE end end @@ -745,10 +747,12 @@ TABLE end - it "does not reschedule the followers" do + it "reschedules follower and follower parent to start right after the moved predecessor" do expect_work_packages(subject.all_results, <<~TABLE) subject | mtwtfssMTWTFSS | work_package | ] | + follower_parent | X..X | + follower | X..X | TABLE end end @@ -769,10 +773,13 @@ TABLE end - it "does not rechedule the followers or the other child" do - expect_work_packages(subject.all_results, <<~TABLE) + it "reschedules follower to start right after the moved predecessor, and follower parent spans on its two children" do + expect_work_packages(subject.all_results + [follower_sibling], <<~TABLE) subject | mtwtfssMTWTFSS | work_package | ] | + follower_parent | XXX..XXXXX | + follower | XX | + follower_sibling| XXX | TABLE end end @@ -831,13 +838,13 @@ end context "with a single successor having two children automatically scheduled" do - context "when creating the follows relation while follower starts 1 day after moved due date" do + context "when creating the follows relation while successor starts right after moved work package due date" do let_work_packages(<<~TABLE) hierarchy | MTWTFSS | scheduling mode | properties work_package | ] | manual | follower | XXXX..XXXXX..XX | automatic | follower_child1 | XXX | automatic | - follower_child2 | X..XX | automatic | + follower_child2 | X..XXXXX..XX | automatic | follows follower_child1 TABLE before do @@ -854,21 +861,24 @@ context "when creating the follows relation while follower starts 3 days after moved due date" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | + hierarchy | MTWTFSS | scheduling mode | properties work_package | ] | manual | follower | XX..XXXXX..XXXX | automatic | follower_child1 | XX..X | automatic | - follower_child2 | XXX | automatic | + follower_child2 | XXXX..XXXX | automatic | follows follower_child1 TABLE before do create(:follows_relation, from: follower, to: work_package) end - it "does not need to reschedule anything" do + it "reschedules followers to start right after the predecessor (moving backward)" do expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | ] | + subject | MTWTFSS | + work_package | ] | + follower | XXXX..XXXXX..XX | + follower_child1 | XXX | + follower_child2 | X..XXXXX..XX | TABLE end end @@ -879,7 +889,7 @@ work_package | ] | manual | follower | X..XXXXX..XXXX | automatic | follower_child1 | X..XXXX | automatic | - follower_child2 | X..XXXX | automatic | + follower_child2 | X..XXXX | manual | TABLE before do @@ -887,14 +897,11 @@ end it "reschedules first child and reduces follower parent duration as the children can be executed at the same time" do - expect_work_packages(subject.all_results, <<~TABLE) + expect_work_packages(subject.all_results + [follower_child2], <<~TABLE) subject | MTWTFSS | work_package | ] | follower | XXXX..XXXX | follower_child1 | XXXX..X | - TABLE - expect_work_packages([follower_child2], <<~TABLE) - subject | MTWTFSS | follower_child2 | X..XXXX | TABLE end @@ -972,12 +979,14 @@ TABLE end - it "reschedules only the first followers as the others don't need to move" do + it "reschedules all followers to start as soon as possible" do expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSSm sm | - work_package | ] | - follower1 | X..XX | - follower2 | XXX..X | + subject | MTWTFSSm sm sm | + work_package | ] | + follower1 | X..XX | + follower2 | XXX..X | + follower3 | XXXX..X | + follower4 | XX | TABLE end end @@ -1092,10 +1101,14 @@ TABLE end - it "does not reschedule any followers" do + it "reschedules followers to start as soon as possible" do expect_work_packages(subject.all_results, <<~TABLE) - subject | m sMTWTFSS | - work_package | ] | + subject | m sMTWTFSSm sm | + work_package | ] | + follower1 | X..XX | + follower2 | XXX..X | + follower3 | XXXX..X | + follower4 | XX | TABLE end end @@ -1148,10 +1161,14 @@ TABLE end - it "does not reschedule any followers" do + it "reschedules followers to start as soon as possible while satisfying all constraints" do expect_work_packages(subject.all_results, <<~TABLE) - subject | m sMTWTFSS | - work_package | ] | + subject | m sMTWTFSS | + work_package | ] | + follower1 | XX..X | + follower2 | XXXX..X | + follower3 | XX..X | + follower4 | XXX | TABLE end end diff --git a/spec/workers/work_packages/apply_working_days_change_job_spec.rb b/spec/workers/work_packages/apply_working_days_change_job_spec.rb index 5e84b0be6dd8..b2cea7d4205e 100644 --- a/spec/workers/work_packages/apply_working_days_change_job_spec.rb +++ b/spec/workers/work_packages/apply_working_days_change_job_spec.rb @@ -311,22 +311,19 @@ set_working_week_days("wednesday") end - it "does not move the follower backwards" do + it "moves the follower backwards" do subject expect_work_packages(WorkPackage.all, <<~TABLE) subject | MTWTFSS | predecessor | XX ░░ | - follower | XXX | + follower | XXX░ | TABLE end it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [predecessor] - end - let(:unchanged_work_packages) do - [follower] + [predecessor, follower] end let(:changed_days) do { @@ -337,7 +334,8 @@ end end - context "when a follower has a predecessor with a non-working day between them that is now a working day" do + context "when a follower has a predecessor with a non-working day between them that is now a working day", + skip: "TODO!!!" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | properties predecessor | XX░ ░░ | manual | @@ -349,18 +347,21 @@ set_working_week_days("wednesday") end - it "does not move the follower" do + it "moves the follower backwards" do subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSS | - predecessor | XX | - follower | XX░░ | + predecessor | XX ░░ | + follower | XX ░░ | TABLE end it_behaves_like "journal updates with cause" do + let(:changed_work_packages) do + [follower] + end let(:unchanged_work_packages) do - [predecessor, follower] + [predecessor] end end end @@ -515,23 +516,20 @@ set_working_week_days("tuesday", "wednesday", "friday") end - it "keeps the same start dates and updates them only once" do + it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | properties - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 - wp3 | XXX ░░ ░░ ░░ | + subject | MTWTFSSmtwtfssmtwtfss | properties + wp1 | X░░XX ░░ ░░ | follows wp2 + wp2 | X ░░ ░░ ░░ | follows wp3 + wp3 | XXX ░░ ░░ ░░ | TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) end it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [wp1, wp3] - end - let(:unchanged_work_packages) do - [wp2] + [wp1, wp2, wp3] end let(:changed_days) do { @@ -866,22 +864,19 @@ set_working_days(non_working_day.date) end - it "does not move the follower backwards" do + it "moves the follower backwards" do subject expect_work_packages(WorkPackage.all, <<~TABLE) subject | MTWTFSS | predecessor | XX ░░ | - follower | XXX | + follower | XXX░ | TABLE end it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [predecessor] - end - let(:unchanged_work_packages) do - [follower] + [predecessor, follower] end let(:changed_days) do { @@ -892,7 +887,8 @@ end end - context "when a follower has a predecessor with a non-working day between them that is now a working day" do + context "when a follower has a predecessor with a non-working day between them that is now a working day", + skip: "TODO!!!" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | properties predecessor | XX░ ░░ | manual | @@ -912,14 +908,17 @@ subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSS | - predecessor | XX | - follower | XX░░ | + predecessor | XX ░░ | + follower | XX ░░ | TABLE end it_behaves_like "journal updates with cause" do + let(:changed_work_packages) do + [follower] + end let(:unchanged_work_packages) do - [predecessor, follower] + [predecessor] end end end @@ -1100,12 +1099,12 @@ set_working_days(*non_working_days) end - it "keeps the same start dates and updates them only once" do + it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSSmtwtfssmtwtfss | properties - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 + wp1 | X░░XX ░░ ░░ | follows wp2 + wp2 | X ░░ ░░ ░░ | follows wp3 wp3 | XXX ░░ ░░ ░░ | TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) @@ -1113,10 +1112,7 @@ it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [wp1, wp3] - end - let(:unchanged_work_packages) do - [wp2] + [wp1, wp2, wp3] end let(:changed_days) do { @@ -1474,16 +1470,13 @@ expect_work_packages(WorkPackage.all, <<~TABLE) subject | MTWTFSS | predecessor | XX ░░ | - follower | XXX | + follower | XXX░ | TABLE end it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [predecessor] - end - let(:unchanged_work_packages) do - [follower] + [predecessor, follower] end let(:changed_days) do { @@ -1702,12 +1695,12 @@ set_working_days(*non_working_days) end - it "keeps the same start dates and updates them only once" do + it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSSmtwtfssmtwtfss | properties - wp1 | ░░ ░░XXX ░░ | follows wp2 - wp2 | ░░ X ░░ ░░ | follows wp3 + wp1 | X░░XX ░░ ░░ | follows wp2 + wp2 | X ░░ ░░ ░░ | follows wp3 wp3 | XXX ░░ ░░ ░░ | TABLE expect(WorkPackage.pluck(:lock_version)).to all(be <= 1) @@ -1715,10 +1708,7 @@ it_behaves_like "journal updates with cause" do let(:changed_work_packages) do - [wp1, wp3] - end - let(:unchanged_work_packages) do - [wp2] + [wp1, wp2, wp3] end let(:changed_days) do { From 47f852b2c8a613a6c122092ed287cabf163badf7 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 11 Dec 2024 12:30:36 +0100 Subject: [PATCH 11/33] Fix broken feature test --- .../scheduling/scheduling_mode_spec.rb | 95 +++++++++++-------- .../work_packages/abstract_work_package.rb | 4 + 2 files changed, 61 insertions(+), 38 deletions(-) diff --git a/spec/features/work_packages/scheduling/scheduling_mode_spec.rb b/spec/features/work_packages/scheduling/scheduling_mode_spec.rb index 4de33ee99eda..52bc369361c1 100644 --- a/spec/features/work_packages/scheduling/scheduling_mode_spec.rb +++ b/spec/features/work_packages/scheduling/scheduling_mode_spec.rb @@ -52,6 +52,7 @@ let!(:wp) do create(:work_package, project:, + subject: "wp", schedule_manually: false, # because parent of wp_child and follows wp_pre start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05"), @@ -60,6 +61,7 @@ let!(:wp_parent) do create(:work_package, project:, + subject: "wp_parent", schedule_manually: false, # because parent of wp start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05")) @@ -67,6 +69,7 @@ let!(:wp_child) do create(:work_package, project:, + subject: "wp_child", schedule_manually: false, # because needed to have rescheduling working start_date: Date.parse("2016-01-01"), due_date: Date.parse("2016-01-05"), @@ -75,6 +78,7 @@ let!(:wp_pre) do create(:work_package, project:, + subject: "wp_pre", start_date: Date.parse("2015-12-15"), due_date: Date.parse("2015-12-31")).tap do |pre| create(:follows_relation, from: wp, to: pre) @@ -83,6 +87,7 @@ let!(:wp_suc) do create(:work_package, project:, + subject: "wp_suc", schedule_manually: false, # because parent of wp_suc_child and follows wp start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10"), @@ -93,6 +98,7 @@ let!(:wp_suc_parent) do create(:work_package, project:, + subject: "wp_suc_parent", schedule_manually: false, # because parent of wp_suc start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10")) @@ -100,6 +106,7 @@ let!(:wp_suc_child) do create(:work_package, project:, + subject: "wp_suc_child", schedule_manually: false, # because needed to have rescheduling working start_date: Date.parse("2016-01-06"), due_date: Date.parse("2016-01-10"), @@ -108,6 +115,13 @@ let(:work_packages_page) { Pages::SplitWorkPackage.new(wp, project) } let(:combined_field) { work_packages_page.edit_field(:combinedDate) } + # get a simplified table showing dates and durations for easier debugging. + let(:query) do + create(:query_with_view_work_packages_table, + user: current_user, + project:, + column_names: ["id", "subject", "start_date", "due_date", "duration"]) + end def expect_dates(work_package, start_date, due_date) work_package.reload @@ -118,7 +132,7 @@ def expect_dates(work_package, start_date, due_date) current_user { create(:admin) } before do - work_packages_page.visit! + work_packages_page.visit_query(query) work_packages_page.ensure_page_loaded end @@ -129,7 +143,7 @@ def expect_dates(work_package, start_date, due_date) # work package is manually scheduled combined_field.activate!(expect_open: false) combined_field.expect_active! - combined_field.toggle_scheduling_mode + combined_field.toggle_scheduling_mode # toggle to manual mode combined_field.update(%w[2016-01-05 2016-01-10], save: false) combined_field.expect_duration 6 combined_field.save! @@ -142,7 +156,8 @@ def expect_dates(work_package, start_date, due_date) expect_dates(wp, "2016-01-05", "2016-01-10") expect(wp.schedule_manually).to be_truthy - # is not moved because it is a child + # is not moved because it wp_pre is an indirect predecessor through its + # parent wp, so it needs to start right after wp_pre expect_dates(wp_child, "2016-01-01", "2016-01-05") # The due date is moved backwards because its child was moved @@ -162,36 +177,37 @@ def expect_dates(work_package, start_date, due_date) # and all work packages that are dependent to be rescheduled again. combined_field.activate!(expect_open: false) combined_field.expect_active! - combined_field.toggle_scheduling_mode + combined_field.toggle_scheduling_mode # toggle to automatic mode combined_field.save! work_packages_page.expect_and_dismiss_toaster message: "Successful update." - # Moved backward again as the child determines the dates again + # wp_child had not been moved in the first place + expect_dates(wp_child, "2016-01-01", "2016-01-05") + + # wp Moved backward again as the child determines the dates again expect_dates(wp, "2016-01-01", "2016-01-05") expect(wp.schedule_manually).to be_falsey - # Had not been moved in the first place - expect_dates(wp_child, "2016-01-01", "2016-01-05") - - # As the child now again takes up the same time interval as the grandchild, - # the interval is shortened again. + # As the child (wp) now again takes up the same time interval as the + # grandchild (wp_child), the interval is shortened again. expect_dates(wp_parent, "2016-01-01", "2016-01-05") - # does not move backwards, as it just increases the gap between wp and wp_suc - expect_dates(wp_suc, "2016-01-11", "2016-01-15") + # wp_suc_child moves backwards to start as soon as possible after its + # indirect predecessor (wp) as it is in automatic scheduling mode + expect_dates(wp_suc_child, "2016-01-06", "2016-01-10") - # does not move backwards either - expect_dates(wp_suc_parent, "2016-01-11", "2016-01-15") + # wp_suc moves backwards as well because it follows its child dates (wp_suc_child) + expect_dates(wp_suc, "2016-01-06", "2016-01-10") - # does not move backwards either because its parent did not move - expect_dates(wp_suc_child, "2016-01-11", "2016-01-15") + # wp_suc_parent moves backwards as well because it follows its child dates (wp_suc) + expect_dates(wp_suc_parent, "2016-01-06", "2016-01-10") # Switching back to manual scheduling but this time backward will lead to the work package # and all work packages that are dependent to be rescheduled again. combined_field.activate!(expect_open: false) combined_field.expect_active! - combined_field.toggle_scheduling_mode + combined_field.toggle_scheduling_mode # toggle to manual mode # The calendar needs some time to get initialized. sleep 2 @@ -207,21 +223,24 @@ def expect_dates(work_package, start_date, due_date) expect_dates(wp, "2015-12-20", "2015-12-31") expect(wp.schedule_manually).to be_truthy - # is not moved because it is a child + # child is not moved because it dates depend on its indirect predecessor, + # wp_pred, rather than on its parent, wp. expect_dates(wp_child, "2016-01-01", "2016-01-05") - # The start date is moved forward because its child was moved - # but the due date remains unchanged as its grandchild stays put. + # wp_parent start date is moved backwards because its child (wp) was moved + # backwards but the due date remains unchanged as its grandchild (wp_child) + # stays put. expect_dates(wp_parent, "2015-12-20", "2016-01-05") - # does not move backwards, as it just increases the gap between wp and wp_suc - expect_dates(wp_suc, "2016-01-11", "2016-01-15") + # wp_suc_child moves backwards to start as soon as possible after its + # indirect predecessor (wp) + expect_dates(wp_suc_child, "2016-01-01", "2016-01-05") - # does not move backwards either - expect_dates(wp_suc_parent, "2016-01-11", "2016-01-15") + # wp_suc moves backwards to follow its child dates (wp_suc_child) + expect_dates(wp_suc, "2016-01-01", "2016-01-05") - # does not move backwards either because its parent did not move - expect_dates(wp_suc_child, "2016-01-11", "2016-01-15") + # wp_suc_parent moves backwards to follow its child dates (wp_suc) + expect_dates(wp_suc_parent, "2016-01-01", "2016-01-05") # Switching back to automatic scheduling will lead to the work package # and all work packages that are dependent to be rescheduled again to @@ -233,24 +252,24 @@ def expect_dates(work_package, start_date, due_date) work_packages_page.expect_and_dismiss_toaster message: "Successful update." - # Moved backwards again as the child determines the dates again + # child Had not been moved in the first place + expect_dates(wp_child, "2016-01-01", "2016-01-05") + + # wp Moved forward again as the child determines the dates again expect_dates(wp, "2016-01-01", "2016-01-05") expect(wp.schedule_manually).to be_falsey - # Had not been moved in the first place - expect_dates(wp_child, "2016-01-01", "2016-01-05") - - # As the child now again takes up the same time interval as the grandchild, - # the interval is shortened again. + # As the child (wp) now again takes up the same time interval as the + # grandchild (wp_child), the interval is shortened again. expect_dates(wp_parent, "2016-01-01", "2016-01-05") - # does not move - expect_dates(wp_suc, "2016-01-11", "2016-01-15") + # wp_suc_child moves forward to start right after its indirect predecessor (wp) + expect_dates(wp_suc_child, "2016-01-06", "2016-01-10") - # does not move either - expect_dates(wp_suc_parent, "2016-01-11", "2016-01-15") + # wp_suc moves forwards to follow its child dates (wp_suc_child) + expect_dates(wp_suc, "2016-01-06", "2016-01-10") - # does not move either because its parent did not move - expect_dates(wp_suc_child, "2016-01-11", "2016-01-15") + # wp_suc_parent moves forwards to follow its child dates (wp_suc) + expect_dates(wp_suc_parent, "2016-01-06", "2016-01-10") end end diff --git a/spec/support/pages/work_packages/abstract_work_package.rb b/spec/support/pages/work_packages/abstract_work_package.rb index 751b601e3b7e..117c26646662 100644 --- a/spec/support/pages/work_packages/abstract_work_package.rb +++ b/spec/support/pages/work_packages/abstract_work_package.rb @@ -43,6 +43,10 @@ def create_page? is_a?(AbstractWorkPackageCreate) end + def visit_query(query) + visit("#{path}?query_id=#{query.id}") + end + def visit_tab!(tab) visit path(tab) end From 703d07752d33fa28dce6531d75eb58b4dccf88f7 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 11 Dec 2024 17:16:10 +0100 Subject: [PATCH 12/33] Simplify method calls State is stored in instance variables instead of being passed around as parameters and return values. --- .../apply_working_days_change_job.rb | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/app/workers/work_packages/apply_working_days_change_job.rb b/app/workers/work_packages/apply_working_days_change_job.rb index 6d7ca1353fb7..64352f32b794 100644 --- a/app/workers/work_packages/apply_working_days_change_job.rb +++ b/app/workers/work_packages/apply_working_days_change_job.rb @@ -34,59 +34,60 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob total_limit: 1 ) + attr_accessor :previous_working_days, :previous_non_working_days + def perform(user_id:, previous_working_days:, previous_non_working_days:) + self.previous_working_days = previous_working_days + self.previous_non_working_days = previous_non_working_days + user = User.find(user_id) User.execute_as user do - wd_update = Journal::CausedByWorkingDayChanges.new( - working_days: changed_days(previous_working_days), - non_working_days: changed_non_working_dates(previous_non_working_days) - ) - - updated_work_package_ids = collect_id_for_each(applicable_work_package(previous_working_days, - previous_non_working_days)) do |work_package| - apply_change_to_work_package(user, work_package, wd_update) + for_each_work_package(applicable_work_packages) do |work_package| + apply_change_to_work_package(work_package) end - applicable_predecessor(updated_work_package_ids).each do |predecessor| - apply_change_to_predecessor(user, predecessor, wd_update) + applicable_predecessors.find_each do |predecessor| + apply_change_to_predecessor(predecessor) end end end private - def apply_change_to_work_package(user, work_package, cause) + def journal_cause + @journal_cause ||= Journal::CausedByWorkingDayChanges.new( + working_days: changed_days, + non_working_days: changed_non_working_dates + ) + end + + def apply_change_to_work_package(work_package) WorkPackages::UpdateService - .new(user:, model: work_package, contract_class: EmptyContract, cause_of_rescheduling: cause) - .call(duration: work_package.duration, journal_cause: cause) # trigger a recomputation of start and due date + .new(user: User.current, model: work_package, contract_class: EmptyContract, cause_of_rescheduling: journal_cause) + .call(duration: work_package.duration, journal_cause:) # trigger a recomputation of start and due date .all_results end - def apply_change_to_predecessor(user, predecessor, initiated_by) + def apply_change_to_predecessor(predecessor) schedule_result = WorkPackages::SetScheduleService - .new(user:, work_package: predecessor, initiated_by:) + .new(user: User.current, work_package: predecessor, initiated_by: journal_cause) .call # The SetScheduleService does not save. It has to be done by the caller. - schedule_result.dependent_results.map do |dependent_result| - work_package = dependent_result.result - work_package.save - - work_package - end + schedule_result.dependent_results.map(&:result).each(&:save) end - def applicable_work_package(previous_working_days, previous_non_working_days) - days_of_week = changed_days(previous_working_days).keys - dates = changed_non_working_dates(previous_non_working_days).keys + def applicable_work_packages + days_of_week = changed_days.keys + dates = changed_non_working_dates.keys WorkPackage .covering_dates_and_days_of_week(days_of_week:, dates:) .order(WorkPackage.arel_table[:start_date].asc.nulls_first, WorkPackage.arel_table[:due_date].asc) end - def changed_days(previous_working_days) + def changed_days previous = Set.new(previous_working_days) current = Set.new(Setting.working_days) @@ -95,7 +96,7 @@ def changed_days(previous_working_days) (previous ^ current).index_with { |day| current.include?(day) } end - def changed_non_working_dates(previous_non_working_days) + def changed_non_working_dates previous = Set.new(previous_non_working_days) current = Set.new(NonWorkingDay.pluck(:date)) @@ -104,15 +105,22 @@ def changed_non_working_dates(previous_non_working_days) (previous ^ current).index_with { |day| current.exclude?(day) } end - def applicable_predecessor(excluded) + def applicable_predecessors WorkPackage .where(id: Relation.follows_with_lag.select(:to_id)) - .where.not(id: excluded) + .where.not(id: already_processed_work_package_ids) + end + + def for_each_work_package(scope) + scope.pluck(:id).each do |id| + next if already_processed_work_package_ids.include?(id) + + processed_work_packages = yield(WorkPackage.find(id)) + already_processed_work_package_ids.merge(processed_work_packages.pluck(:id)) + end end - def collect_id_for_each(scope) - scope.pluck(:id).map do |id| - yield(WorkPackage.find(id)).pluck(:id) - end.flatten + def already_processed_work_package_ids + @already_processed_work_package_ids ||= Set.new end end From 704bc6e1da8e3ea9157c6ccfb7d9971c93e03dbb Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 12 Dec 2024 16:12:09 +0100 Subject: [PATCH 13/33] table helpers: rename properties column to predecessors It was only used to specify predecessors, so it makes more sense, and it now allows a shorter syntax: "follows wp1, wp2, wp3". --- spec/features/admin/working_days_spec.rb | 2 +- .../update_scheduling_mode_and_lags_spec.rb | 14 ++-- spec/models/relation_spec.rb | 10 +-- .../covering_dates_and_days_of_week_spec.rb | 4 +- .../set_schedule_service_working_days_spec.rb | 82 +++++++++---------- spec/support/table_helpers/column.rb | 8 +- .../{properties.rb => predecessors.rb} | 39 +++++---- ...roperties_spec.rb => predecessors_spec.rb} | 52 ++++++++---- .../table_helpers/table_data_spec.rb | 2 +- .../apply_working_days_change_job_spec.rb | 66 +++++++-------- 10 files changed, 150 insertions(+), 129 deletions(-) rename spec/support/table_helpers/column_type/{properties.rb => predecessors.rb} (69%) rename spec/support_spec/table_helpers/column_type/{properties_spec.rb => predecessors_spec.rb} (58%) diff --git a/spec/features/admin/working_days_spec.rb b/spec/features/admin/working_days_spec.rb index 8e0a4b33d456..7d6b9885d7f2 100644 --- a/spec/features/admin/working_days_spec.rb +++ b/spec/features/admin/working_days_spec.rb @@ -35,7 +35,7 @@ shared_let(:admin) { create(:admin) } let_work_packages(<<~TABLE) - subject | MTWTFSSmtwtfss | scheduling mode | properties + subject | MTWTFSSmtwtfss | scheduling mode | predecessors earliest_work_package | XXXXX | manual | second_work_package | XX..XX | manual | follower | XXX | automatic | follows earliest_work_package, follows second_work_package diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index 8f51d07c450b..e9aec2586b66 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -123,7 +123,7 @@ # > - Manually scheduled work packages remain so. context "for manually scheduled work packages following another one" do let_work_packages(<<~TABLE) - subject | start date | due date | scheduling mode | properties + subject | start date | due date | scheduling mode | predecessors main | | | manual | wp 1 | 2024-11-20 | 2024-11-21 | manual | follows main wp 2 | | 2024-11-21 | manual | follows main @@ -145,7 +145,7 @@ # > - The successor remains in automatic mode context "for automatically scheduled work packages following another one having dates" do let_work_packages(<<~TABLE) - subject | start date | due date | scheduling mode | properties + subject | start date | due date | scheduling mode | predecessors pred with dates | 2024-11-19 | 2024-11-19 | manual | pred without dates | | | manual | wp 1 | 2024-11-20 | 2024-11-21 | automatic | follows pred with dates, follows pred without dates @@ -170,7 +170,7 @@ # > - The successor is switched to manual mode to preserve its dates and duration context "for automatically scheduled work packages without dates following another one" do let_work_packages(<<~TABLE) - subject | start date | due date | scheduling mode | properties + subject | start date | due date | scheduling mode | predecessors pred without dates | | | manual | succ | | | automatic | follows pred without dates TABLE @@ -189,7 +189,7 @@ # > - The successor is switched to manual mode to preserve its dates and duration context "for automatically scheduled work packages following another one having no dates" do let_work_packages(<<~TABLE) - subject | start date | due date | scheduling mode | properties + subject | start date | due date | scheduling mode | predecessors pred without dates | | | manual | succ 1 | 2024-11-20 | 2024-11-21 | automatic | follows pred without dates succ 2 | | 2024-11-21 | automatic | follows pred without dates @@ -227,7 +227,7 @@ context "for 2 work packages following each other with distant dates" do shared_let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor 1 | XX | manual | follower 1 | XX | automatic | follows predecessor 1 @@ -270,7 +270,7 @@ context "for 2 work packages following each other with missing dates" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors # only predecessor has dates predecessor 1 | XX | manual | follower 1 | | automatic | follows predecessor 1 @@ -295,7 +295,7 @@ context "for a work package following multiple work packages" do shared_let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor 1 | XX | manual | predecessor 2 | XX | manual | predecessor 3 | X | manual | diff --git a/spec/models/relation_spec.rb b/spec/models/relation_spec.rb index 53e145c7e070..bec70d6c5e34 100644 --- a/spec/models/relation_spec.rb +++ b/spec/models/relation_spec.rb @@ -139,7 +139,7 @@ context "with a follows relation" do let_work_packages(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | predecessors main | ] | follower | | follows main TABLE @@ -152,7 +152,7 @@ context "with a follows relation with predecessor having only start date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | predecessors main | [ | follower | | follows main TABLE @@ -178,7 +178,7 @@ context "with a follows relation with a lag" do let_work_packages(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | predecessors main | X | follower_a | | follows main with lag 0 follower_b | | follows main with lag 1 @@ -199,7 +199,7 @@ context "with a follows relation with a lag and with non-working days in the lag period" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtw | properties + subject | MTWTFSSmtw | predecessors main | X░ ░ ░░ ░ | follower_lag0 | ░ ░ ░░ ░ | follows main with lag 0 follower_lag1 | ░ ░ ░░ ░ | follows main with lag 1 @@ -226,7 +226,7 @@ context "with a follows relation with a lag, non-working days, and followers ignoring non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtw | days counting | properties + subject | MTWTFSSmtw | days counting | predecessors main | X░ ░ ░░ ░ | working days only | follower_lag0 | ░ ░ ░░ ░ | all days | follows main with lag 0 follower_lag1 | ░ ░ ░░ ░ | all days | follows main with lag 1 diff --git a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb index 2d998f621f91..36341d63e856 100644 --- a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb +++ b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb @@ -105,7 +105,7 @@ it "does not return work packages having follows relation covering the given days of week" do create_table(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | predecessors not_covered1 | X | follower1 | X | follows not_covered1 not_covered2 | X | @@ -118,7 +118,7 @@ it "does not return work packages having follows relation with lag covering the given days of week" do create_table(<<~TABLE) - subject | MTWTFSS | properties + subject | MTWTFSS | predecessors not_covered1 | X | follower1 | X | follows not_covered1 with lag 3 not_covered2 | X | diff --git a/spec/services/work_packages/set_schedule_service_working_days_spec.rb b/spec/services/work_packages/set_schedule_service_working_days_spec.rb index 262e8dfd2410..b51ebe3fded1 100644 --- a/spec/services/work_packages/set_schedule_service_working_days_spec.rb +++ b/spec/services/work_packages/set_schedule_service_working_days_spec.rb @@ -43,7 +43,7 @@ context "with a single successor" do context "when moving successor will cover non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower | XXX | automatic | follows work_package TABLE @@ -67,7 +67,7 @@ context "when moved predecessor covers non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower | XXX | automatic | follows work_package TABLE @@ -92,7 +92,7 @@ context "when predecessor moved forward" do context "on a day in the middle on working days with the follower having only start date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | [ | automatic | follows work_package TABLE @@ -115,7 +115,7 @@ context "on a day just before non working days with the follower having only start date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | [ | automatic | follows work_package TABLE @@ -138,7 +138,7 @@ context "on a day in the middle of working days with the follower having only due date and no space in between" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | ] | automatic | follows work_package TABLE @@ -161,7 +161,7 @@ context "on a day in the middle of working days with the follower having only due date and much space in between" do let_work_packages(<<~TABLE) - subject | MTWTFSSmt | scheduling mode | properties + subject | MTWTFSSmt | scheduling mode | predecessors work_package | ] | manual | follower | ] | automatic | follows work_package TABLE @@ -184,7 +184,7 @@ context "on a day just before non-working day with the follower having only due date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | ] | automatic | follows work_package TABLE @@ -207,7 +207,7 @@ context "with the follower having some space left" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | X..XX | automatic | follows work_package TABLE @@ -230,7 +230,7 @@ context "with the follower having enough space left to not be moved at all" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | XXX | automatic | follows work_package TABLE @@ -253,7 +253,7 @@ context "with the follower having some space left and a lag" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtwtfss | scheduling mode | properties + subject | MTWTFSSmtwtfss | scheduling mode | predecessors work_package | X | manual | follower | XXX | automatic | follows work_package with lag 3 TABLE @@ -276,7 +276,7 @@ context "with the follower having a lag overlapping non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | XX | automatic | follows work_package with lag 2 TABLE @@ -301,7 +301,7 @@ context "when predecessor moved backwards" do context "on a day right before some non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | XX | automatic | follows work_package TABLE @@ -324,7 +324,7 @@ context "on a day before non-working days the follower having space between" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | X | automatic | follows work_package TABLE @@ -347,7 +347,7 @@ context "with the follower having another relation limiting movement" do let_work_packages(<<~TABLE) - subject | mtwtfssmtwtfssMTWTFSS | scheduling mode | properties + subject | mtwtfssmtwtfssMTWTFSS | scheduling mode | predecessors work_package | X | manual | follower | XX | automatic | follows work_package, follows annoyer with lag 2 annoyer | XX..XX | manual | @@ -374,7 +374,7 @@ context "when removing the dates on the moved predecessor" do context "with the follower having start and due dates" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower | XXX | automatic | follows work_package TABLE @@ -400,7 +400,7 @@ context "with the follower having only a due date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower | ] | automatic | follows work_package TABLE @@ -555,7 +555,7 @@ context "with the successor having another predecessor which has no dates" do context "when moved forward" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XXX | automatic | follows work_package, follows other_predecessor other_predecessor | | manual | @@ -579,7 +579,7 @@ context "when moved backwards" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XXX | automatic | follows work_package, follows other_predecessor other_predecessor | | manual | @@ -605,7 +605,7 @@ context "with successor having only duration" do context "when setting dates on predecessor" do let_work_packages(<<~TABLE) - subject | MTWTFSS | duration | scheduling mode | properties + subject | MTWTFSS | duration | scheduling mode | predecessors work_package | | | manual | follower | | 3 | automatic | follows work_package TABLE @@ -653,7 +653,7 @@ context "with a parent having a follower" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors parent | XX | automatic | work_package | ] | manual | parent_follower | X..XX | automatic | follows parent @@ -679,7 +679,7 @@ context "with a single successor having a parent" do context "when moving forward" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower_parent | XX | automatic | follower | XX | automatic | follows work_package @@ -704,7 +704,7 @@ context "when moving forward with the parent having another child not being moved" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower_parent | XXXX | automatic | follower | XX | automatic | follows work_package @@ -734,7 +734,7 @@ context "when moving backwards" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower_parent | XX | automatic | follower | XX | automatic | follows work_package @@ -759,7 +759,7 @@ context "when moving backwards with the parent having another child not being moved" do let_work_packages(<<~TABLE) - hierarchy | mtwtfssMTWTFSS | scheduling mode | properties + hierarchy | mtwtfssMTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower_parent | XXXX | automatic | follower | XX | automatic | follows work_package @@ -788,7 +788,7 @@ context "with a single successor having a child in automatic scheduling mode" do context "when moving forward" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XX | automatic | follows work_package follower_child | XX | automatic | @@ -815,7 +815,7 @@ context "with a single successor having a child in manual scheduling mode" do context "when moving forward" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XX | automatic | follows work_package follower_child | XX | manual | @@ -840,7 +840,7 @@ context "with a single successor having two children automatically scheduled" do context "when creating the follows relation while successor starts right after moved work package due date" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XXXX..XXXXX..XX | automatic | follower_child1 | XXX | automatic | @@ -861,7 +861,7 @@ context "when creating the follows relation while follower starts 3 days after moved due date" do let_work_packages(<<~TABLE) - hierarchy | MTWTFSS | scheduling mode | properties + hierarchy | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | follower | XX..XXXXX..XXXX | automatic | follower_child1 | XX..X | automatic | @@ -935,7 +935,7 @@ context "with a chain of followers" do context "when moving forward" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm sm | scheduling mode | properties + subject | MTWTFSSm sm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | X..XXXX | automatic | follows follower1 @@ -964,7 +964,7 @@ context "when moving forward with some space between the followers" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm sm | scheduling mode | properties + subject | MTWTFSSm sm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | XXXX | automatic | follows follower1 @@ -993,7 +993,7 @@ context "when moving forward with some lag and spaces between the followers" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm sm | scheduling mode | properties + subject | MTWTFSSm sm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | XXXX | automatic | follows follower1 with lag 3 @@ -1022,7 +1022,7 @@ context "when moving forward due to days and predecessor due date now being non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower1 | X | automatic | follows work_package follower2 | XX | automatic | follows follower1 @@ -1054,7 +1054,7 @@ context "when moving forward due to days and predecessor start date now being non-working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | follower1 | X | automatic | follows work_package follower2 | XX | automatic | follows follower1 @@ -1086,7 +1086,7 @@ context "when moving backwards" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm sm | scheduling mode | properties + subject | MTWTFSSm sm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | X..XXX | automatic | follows follower1 @@ -1117,7 +1117,7 @@ context "with a chain of followers with two paths leading to the same follower in the end" do context "when moving forward" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm | scheduling mode | properties + subject | MTWTFSSm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | X..XXXX | automatic | follows follower1 @@ -1146,7 +1146,7 @@ context "when moving backwards" do let_work_packages(<<~TABLE) - subject | MTWTFSSm sm | scheduling mode | properties + subject | MTWTFSSm sm | scheduling mode | predecessors work_package | ] | manual | follower1 | XXX | automatic | follows work_package follower2 | X..XXXX | automatic | follows follower1 @@ -1179,7 +1179,7 @@ context "without dates and with the parent being restricted in its ability to be moved" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | | manual | new_parent_predecessor | X | manual | new_parent | | automatic |follows new_parent_predecessor with lag 3 @@ -1201,7 +1201,7 @@ context "without dates, with a duration and with the parent being restricted in its ability to be moved" do let_work_packages(<<~TABLE) - subject | MTWTFSS | duration | scheduling mode | properties + subject | MTWTFSS | duration | scheduling mode | predecessors work_package | | 4 | manual | new_parent_predecessor | X | | manual | new_parent | | | automatic | follows new_parent_predecessor with lag 3 @@ -1224,7 +1224,7 @@ context "with the parent being restricted in its ability to be moved and with a due date before parent constraint" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | new_parent_predecessor | X | manual | new_parent | | automatic | follows new_parent_predecessor with lag 3 @@ -1246,7 +1246,7 @@ context "with the parent being restricted in its ability to be moved and with a due date after parent constraint" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | ] | manual | new_parent_predecessor | X | manual | new_parent | | automatic | follows new_parent_predecessor with lag 3 @@ -1268,7 +1268,7 @@ context "with the parent being restricted but work package already has both dates set" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors work_package | XX | manual | new_parent_predecessor | X | manual | new_parent | | automatic | follows new_parent_predecessor with lag 3 diff --git a/spec/support/table_helpers/column.rb b/spec/support/table_helpers/column.rb index 378c37862168..ac712a1de6ee 100644 --- a/spec/support/table_helpers/column.rb +++ b/spec/support/table_helpers/column.rb @@ -35,7 +35,7 @@ require_relative "column_type/duration" require_relative "column_type/hierarchy" require_relative "column_type/percentage" -require_relative "column_type/properties" +require_relative "column_type/predecessors" require_relative "column_type/schedule" require_relative "column_type/scheduling_mode" require_relative "column_type/status" @@ -54,7 +54,7 @@ class Column derived_done_ratio: ColumnType::Percentage, hierarchy: ColumnType::Hierarchy, ignore_non_working_days: ColumnType::DaysCounting, - properties: ColumnType::Properties, + predecessors: ColumnType::Predecessors, schedule: ColumnType::Schedule, schedule_manually: ColumnType::SchedulingMode, status: ColumnType::Status, @@ -96,8 +96,8 @@ def self.attribute_for(header) :ignore_non_working_days when /\s*scheduling mode\s*/ :schedule_manually - when /\s*properties\s*/ - :properties + when /\s*predecessors\s*/ + :predecessors when /status/, /hierarchy/ to_identifier(header) else diff --git a/spec/support/table_helpers/column_type/properties.rb b/spec/support/table_helpers/column_type/predecessors.rb similarity index 69% rename from spec/support/table_helpers/column_type/properties.rb rename to spec/support/table_helpers/column_type/predecessors.rb index d940af695e10..24ef01cd9435 100644 --- a/spec/support/table_helpers/column_type/properties.rb +++ b/spec/support/table_helpers/column_type/predecessors.rb @@ -30,35 +30,38 @@ module TableHelpers module ColumnType - # Column to add properties to work packages like "follows wp1 with lag 2". + # Column to add predecessors to work packages like "wp1, wp2 with lag 2, wp3". # - # Supported properties: + # Supported texts: + # - :wp + # - :wp with lag :int # - follows :wp # - follows :wp with lag :int + # They can be combined by separated them with commas: "follows wp1, wp2 with lag 2, wp3". # # Example: # - # | subject | properties | + # | subject | predecessors | # | main | | # | follower | follows main with lag 2 | - # | follower2 | follows follower | + # | follower2 | follows follower, main | # # Adapted from (now deleted) original implementation # in `spec/support/schedule_helpers/chart_builder.rb`. - class Properties < Generic + class Predecessors < Generic def attributes_for_work_package(_attribute, _work_package) {} end def extract_data(_attribute, raw_header, work_package_data, _work_packages_data) - properties = work_package_data.dig(:row, raw_header) - properties = properties.split(",").map(&:strip).compact_blank - parse_properties(properties) + predecessors = work_package_data.dig(:row, raw_header) + predecessors = predecessors.split(",").map(&:strip).compact_blank + parse_predecessors(predecessors) end - def parse_properties(properties) - properties.reduce({}) do |data, property| - case parse_property(property) + def parse_predecessors(predecessors) + predecessors.reduce({}) do |data, predecessor| + case parse_predecessor(predecessor) in {relations: relation} data[:relations] ||= [] data[:relations] << relation @@ -72,12 +75,12 @@ def relations_for_raw_value(raw_value) {} end - def parse_property(property) - case property - when /^follows (.+?)(?: with lag (\d+))?\s*$/ + def parse_predecessor(predecessor) + case predecessor + when /^(?:follows)?\s*(.+?)(?: with lag (\d+))?\s*$/ { relations: { - raw: property, + raw: predecessor, type: :follows, predecessor: $1, lag: $2.to_i @@ -86,13 +89,15 @@ def parse_property(property) else spell_checker = DidYouMean::SpellChecker.new( dictionary: [ + ":wp", + ":wp with lag :int", "follows :wp", "follows :wp with lag :int" ] ) - suggestions = spell_checker.correct(property).map(&:inspect).join(" ") + suggestions = spell_checker.correct(predecessor).map(&:inspect).join(" ") did_you_mean = " Did you mean #{suggestions} instead?" if suggestions.present? - raise "unable to parse property #{property.inspect}.#{did_you_mean}" + raise "unable to parse predecessor #{predecessor.inspect}.#{did_you_mean}" end end end diff --git a/spec/support_spec/table_helpers/column_type/properties_spec.rb b/spec/support_spec/table_helpers/column_type/predecessors_spec.rb similarity index 58% rename from spec/support_spec/table_helpers/column_type/properties_spec.rb rename to spec/support_spec/table_helpers/column_type/predecessors_spec.rb index 094fa7e305e1..b62f486b85f1 100644 --- a/spec/support_spec/table_helpers/column_type/properties_spec.rb +++ b/spec/support_spec/table_helpers/column_type/predecessors_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" module TableHelpers::ColumnType - RSpec.describe Properties do + RSpec.describe Predecessors do subject(:column_type) { described_class.new } def parsed_data(table) @@ -41,14 +41,14 @@ def parsed_data(table) describe "empty" do it "stores nothing when empty" do work_package_data = parsed_data(<<~TABLE).first - | properties | - | | + | predecessors | + | | TABLE expect(work_package_data[:relations]).to be_nil expect(work_package_data[:attributes]).to be_empty work_package_data = parsed_data(<<~TABLE).first - | properties + | predecessors | TABLE expect(work_package_data[:relations]).to be_nil @@ -56,34 +56,50 @@ def parsed_data(table) end end - describe "follows [with lag ]" do + describe "[follows] [with lag ]" do it "stores follows relations in work_package_data" do - work_package_data = parsed_data(<<~TABLE).first - | properties | + work_package_data = parsed_data(<<~TABLE).pluck(:relations) + | predecessors | | follows main with lag 3 | + | main with lag 3 | TABLE - expect(work_package_data[:relations]) - .to eq([{ raw: "follows main with lag 3", type: :follows, predecessor: "main", lag: 3 }]) + expect(work_package_data) + .to eq([ + [{ raw: "follows main with lag 3", type: :follows, predecessor: "main", lag: 3 }], + [{ raw: "main with lag 3", type: :follows, predecessor: "main", lag: 3 }] + ]) end it "has a default lag of 0 days when not specified" do - work_package_data = parsed_data(<<~TABLE).first - | properties | + work_package_data = parsed_data(<<~TABLE).pluck(:relations) + | predecessors | | follows main | + | main | TABLE - expect(work_package_data[:relations]) - .to eq([{ raw: "follows main", type: :follows, predecessor: "main", lag: 0 }]) + expect(work_package_data) + .to eq([ + [{ raw: "follows main", type: :follows, predecessor: "main", lag: 0 }], + [{ raw: "main", type: :follows, predecessor: "main", lag: 0 }] + ]) end it "can store multiple relations" do - work_package_data = parsed_data(<<~TABLE).first - | properties | + work_package_data = parsed_data(<<~TABLE).pluck(:relations) + | predecessors | | follows wp1, follows wp2 | + | follows wp1, wp2, wp3 | TABLE - expect(work_package_data[:relations]) + expect(work_package_data) .to eq([ - { raw: "follows wp1", type: :follows, predecessor: "wp1", lag: 0 }, - { raw: "follows wp2", type: :follows, predecessor: "wp2", lag: 0 } + [ + { raw: "follows wp1", type: :follows, predecessor: "wp1", lag: 0 }, + { raw: "follows wp2", type: :follows, predecessor: "wp2", lag: 0 } + ], + [ + { raw: "follows wp1", type: :follows, predecessor: "wp1", lag: 0 }, + { raw: "wp2", type: :follows, predecessor: "wp2", lag: 0 }, + { raw: "wp3", type: :follows, predecessor: "wp3", lag: 0 } + ] ]) end end diff --git a/spec/support_spec/table_helpers/table_data_spec.rb b/spec/support_spec/table_helpers/table_data_spec.rb index edb060e9e61f..680153fc6fde 100644 --- a/spec/support_spec/table_helpers/table_data_spec.rb +++ b/spec/support_spec/table_helpers/table_data_spec.rb @@ -161,7 +161,7 @@ module TableHelpers it "creates relations between work packages out of the table data" do table_representation = <<~TABLE - subject | properties + subject | predecessors main | follower | follows main with lag 2 TABLE diff --git a/spec/workers/work_packages/apply_working_days_change_job_spec.rb b/spec/workers/work_packages/apply_working_days_change_job_spec.rb index b2cea7d4205e..bd5df32c6def 100644 --- a/spec/workers/work_packages/apply_working_days_change_job_spec.rb +++ b/spec/workers/work_packages/apply_working_days_change_job_spec.rb @@ -175,7 +175,7 @@ context "when a follower has a predecessor with dates covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | XX ░░ | working days only | manual | follower | XXX░ | all days | automatic | follows predecessor TABLE @@ -209,7 +209,7 @@ context "when a follower has a predecessor with lag covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX ░░ | manual | follower | X ░░ | automatic | follows predecessor with lag 1 TABLE @@ -245,7 +245,7 @@ context "with work packages without dates following each other with lag" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | ░░ | manual | follower | ░░ | automatic | follows predecessor with lag 5 TABLE @@ -272,7 +272,7 @@ context "when a follower has a predecessor with lag covering multiple days with different working changes" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | X ░ ░░ | manual | follower | ░ X░░ | automatic | follows predecessor with lag 2 TABLE @@ -301,7 +301,7 @@ context "when a follower has a predecessor with dates covering a day that is now a working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | X▓X ░░ | working days only | manual | follower | ░ XXX | all days | automatic | follows predecessor TABLE @@ -337,7 +337,7 @@ context "when a follower has a predecessor with a non-working day between them that is now a working day", skip: "TODO!!!" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX░ ░░ | manual | follower | ░XX░░ | automatic | follows predecessor TABLE @@ -415,7 +415,7 @@ context "when having multiple work packages following each other, and having days becoming non working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | X ░░ | automatic | follows wp3 wp3 | XXX ░░ | manual | @@ -466,7 +466,7 @@ # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X ░░ | manual | wp2 | XX ░░ | manual | wp3 | X░░ | automatic | follows wp1, follows wp2 @@ -504,7 +504,7 @@ context "when having multiple work packages following each other, and having days becoming working days" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | predecessors wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | @@ -519,7 +519,7 @@ it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | properties + subject | MTWTFSSmtwtfssmtwtfss | predecessors wp1 | X░░XX ░░ ░░ | follows wp2 wp2 | X ░░ ░░ ░░ | follows wp3 wp3 | XXX ░░ ░░ ░░ | @@ -542,7 +542,7 @@ context "when having multiple work packages following each other and first one only has a due date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | XX ░░ | automatic | follows wp3 wp3 | ] ░░ | manual | @@ -719,7 +719,7 @@ context "when a follower has a predecessor with dates covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | XX ░░ | working days only | manual | follower | XXX░ | all days | automatic | follows predecessor TABLE @@ -753,7 +753,7 @@ context "when a follower has a predecessor with lag covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX ░░ | manual | follower | X ░░ | automatic | follows predecessor with lag 1 TABLE @@ -789,7 +789,7 @@ context "with work packages without dates following each other with lag" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | ░░ | manual | follower | ░░ | automatic | follows predecessor with lag 5 TABLE @@ -816,7 +816,7 @@ context "when a follower has a predecessor with lag covering multiple days with different working changes" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | X ░ ░░ | manual | follower | ░ X░░ | automatic | follows predecessor with lag 2 TABLE @@ -850,7 +850,7 @@ context "when a follower has a predecessor with dates covering a day that is now a working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | X▓X ░░ | working days only | manual | follower | ░ XXX | all days | automatic | follows predecessor TABLE @@ -890,7 +890,7 @@ context "when a follower has a predecessor with a non-working day between them that is now a working day", skip: "TODO!!!" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX░ ░░ | manual | follower | ░XX░░ | automatic | follows predecessor TABLE @@ -972,7 +972,7 @@ context "when having multiple work packages following each other, and having days becoming non working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | X ░░ | automatic | follows wp3 wp3 | XXX ░░ | manual | @@ -1029,7 +1029,7 @@ # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X ░░ | manual | wp2 | XX ░░ | manual | wp3 | X░░ | automatic | follows wp1, follows wp2 @@ -1075,7 +1075,7 @@ context "when having multiple work packages following each other, and having days becoming working days" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | predecessors wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | @@ -1102,7 +1102,7 @@ it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | properties + subject | MTWTFSSmtwtfssmtwtfss | predecessors wp1 | X░░XX ░░ ░░ | follows wp2 wp2 | X ░░ ░░ ░░ | follows wp3 wp3 | XXX ░░ ░░ ░░ | @@ -1125,7 +1125,7 @@ context "when having multiple work packages following each other and first one only has a due date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | XX ░░ | automatic | follows wp3 wp3 | ] ░░ | manual | @@ -1319,7 +1319,7 @@ context "when a follower has a predecessor with dates covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | XX ░░ | working days only | manual | follower | XXX░ | all days | automatic | follows predecessor TABLE @@ -1353,7 +1353,7 @@ context "when a follower has a predecessor with lag covering a day that is now a non-working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX ░░ | manual | follower | X ░░ | automatic | follows predecessor with lag 1 TABLE @@ -1389,7 +1389,7 @@ context "with work packages without dates following each other with lag" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | ░░ | manual | follower | ░░ | automatic | follows predecessor with lag 5 TABLE @@ -1416,7 +1416,7 @@ context "when a follower has a predecessor with lag covering multiple days with different working changes" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | X ░ ░░ | manual | follower | ░ X░░ | automatic | follows predecessor with lag 2 TABLE @@ -1450,7 +1450,7 @@ context "when a follower has a predecessor with dates covering a day that is now a working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | days counting | scheduling mode | properties + subject | MTWTFSS | days counting | scheduling mode | predecessors predecessor | X▓X ░░ | working days only | manual | follower | ░ XXX | all days | automatic | follows predecessor TABLE @@ -1489,7 +1489,7 @@ context "when a follower has a predecessor with a non-working day between them that is now a working day" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors predecessor | XX░ ░░ | manual | follower | ░XX░░ | automatic | follows predecessor TABLE @@ -1568,7 +1568,7 @@ context "when having multiple work packages following each other, and having days becoming non working days" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | X ░░ | automatic | follows wp3 wp3 | XXX ░░ | manual | @@ -1625,7 +1625,7 @@ # * wp2 will move from Wednesday-Thursday to Thursday-nextMonday too and its followers will be rescheduled too # * follower wp3 gets rescheduled *again* and moves to next Thursday let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X ░░ | manual | wp2 | XX ░░ | manual | wp3 | X░░ | automatic | follows wp1, follows wp2 @@ -1671,7 +1671,7 @@ context "when having multiple work packages following each other, and having days becoming working days" do let_work_packages(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | properties + subject | MTWTFSSmtwtfssmtwtfss | scheduling mode | predecessors wp1 | ░░ ░░░ ░░ ░░░X▓▓X▓▓▓X | automatic | follows wp2 wp2 | ░░ ░░░ ░░X░░░ ░░ ░░░ | automatic | follows wp3 wp3 | X▓▓X▓▓▓X░░ ░░░ ░░ ░░░ | manual | @@ -1698,7 +1698,7 @@ it "reschedules them to start as soon as possible and updates them only once" do subject expect(WorkPackage.all).to match_table(<<~TABLE) - subject | MTWTFSSmtwtfssmtwtfss | properties + subject | MTWTFSSmtwtfssmtwtfss | predecessors wp1 | X░░XX ░░ ░░ | follows wp2 wp2 | X ░░ ░░ ░░ | follows wp3 wp3 | XXX ░░ ░░ ░░ | @@ -1721,7 +1721,7 @@ context "when having multiple work packages following each other and first one only has a due date" do let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | properties + subject | MTWTFSS | scheduling mode | predecessors wp1 | X▓▓XX | automatic | follows wp2 wp2 | XX ░░ | automatic | follows wp3 wp3 | ] ░░ | manual | From 5017043ab0407bd87837ed8cb60d0208d9e7ddbc Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 13 Dec 2024 08:58:02 +0100 Subject: [PATCH 14/33] [59539] Correctly reschedule predecessors needing relations rescheduling Edge case not detected by the previous algorithm: relation has no lag but covers non-working days changed into working days. The successors need to be rescheduled, which is done by rescheduling from the predecessor. --- .../scopes/covering_dates_and_days_of_week.rb | 114 ++++-- .../apply_working_days_change_job.rb | 5 +- .../covering_dates_and_days_of_week_spec.rb | 332 +++++++++++------- .../apply_working_days_change_job_spec.rb | 37 +- 4 files changed, 325 insertions(+), 163 deletions(-) diff --git a/app/models/work_packages/scopes/covering_dates_and_days_of_week.rb b/app/models/work_packages/scopes/covering_dates_and_days_of_week.rb index 87fb3bdda656..7ccb5a27df58 100644 --- a/app/models/work_packages/scopes/covering_dates_and_days_of_week.rb +++ b/app/models/work_packages/scopes/covering_dates_and_days_of_week.rb @@ -39,19 +39,61 @@ module WorkPackages::Scopes::CoveringDatesAndDaysOfWeek # @param days_of_week number[] An array of the ISO days of the week to # consider. 1 is Monday, 7 is Sunday. def covering_dates_and_days_of_week(days_of_week: [], dates: []) + work_packages_periods_cte = work_packages_periods_cte_for_covering_work_packages + where_covers_periods(work_packages_periods_cte, days_of_week, dates) + end + + def predecessors_needing_relations_rescheduling(days_of_week: [], dates: []) + work_packages_periods_cte = work_packages_periods_cte_for_predecessors_needing_relations_rescheduling + where_covers_periods(work_packages_periods_cte, days_of_week, dates) + end + + private + + def where_covers_periods(work_packages_periods_cte, days_of_week, dates) days_of_week = Array(days_of_week) dates = Array(dates) return none if days_of_week.empty? && dates.empty? - where("id IN (#{query(days_of_week, dates)})") - end + covering_work_packages_query_sql = <<~SQL.squish + -- select work packages dates + WITH + -- cte returning a table with work package id, period start_date and end_date + #{work_packages_periods_cte}, - private + -- All days between the start date of a work package and its due date + covered_dates AS ( + SELECT + id, + generate_series(work_packages_periods.start_date, + work_packages_periods.end_date, + '1 day') AS date + FROM work_packages_periods + ), + + -- All days between the start date of a work package and its due date including the day of the week for each date + covered_dates_and_wday AS ( + SELECT + id, + date, + EXTRACT(isodow FROM date) dow + FROM covered_dates + ) + + -- select id of work packages covering the given days + SELECT id FROM covered_dates_and_wday + WHERE dow IN (:days_of_week) OR date IN (:dates) + SQL + + covering_work_packages_query_sql = sanitize_sql([covering_work_packages_query_sql, { days_of_week:, dates: }]) + + where("id IN (#{covering_work_packages_query_sql})") + end - def query(days_of_week, dates) - sql = <<~SQL.squish - -- select work packages dates with their followers dates - WITH work_packages_with_dates AS ( + def work_packages_periods_cte_for_covering_work_packages + <<~SQL.squish + -- select work packages dates + work_packages_with_dates AS ( SELECT work_packages.id, work_packages.start_date AS work_package_start_date, work_packages.due_date AS work_package_due_date @@ -68,30 +110,48 @@ def query(days_of_week, dates) LEAST(work_package_start_date, work_package_due_date) AS start_date, GREATEST(work_package_start_date, work_package_due_date) AS end_date FROM work_packages_with_dates + ) + SQL + end + + def work_packages_periods_cte_for_predecessors_needing_relations_rescheduling + <<~SQL.squish + follows_relations + AS (SELECT + relations.id as id, + relations.to_id as pred_id, + relations.from_id as succ_id, + COALESCE(wp_pred.due_date, wp_pred.start_date) + INTERVAL '1 DAY' as pred_date, + COALESCE(wp_succ.start_date, wp_succ.due_date) - INTERVAL '1 DAY' as succ_date, + wp_succ.schedule_manually as succ_schedule_manually + FROM relations + LEFT JOIN work_packages wp_pred ON relations.to_id = wp_pred.id + LEFT JOIN work_packages wp_succ ON relations.from_id = wp_succ.id + WHERE relation_type = 'follows' ), - -- All days between the start date of a work package and its due date - covered_dates AS ( - SELECT - id, - generate_series(work_packages_periods.start_date, - work_packages_periods.end_date, - '1 day') AS date - FROM work_packages_periods + -- select automatic follows relations. A relation is automatic if the + -- successor is scheduled automatically and both successor and + -- predecessor have dates + -- also excluded relations that have no duration (predecessor and successor "touch" each other) + automatic_follows_relations AS ( + SELECT * + FROM follows_relations + WHERE succ_schedule_manually = false + AND pred_date IS NOT NULL + AND succ_date IS NOT NULL + AND pred_date <= succ_date ), - -- All days between the start date of a work package and its due date including the day of the week for each date - covered_dates_and_wday AS ( - SELECT - id, - date, - EXTRACT(isodow FROM date) dow - FROM covered_dates + -- keep only the longest relation for each successor + -- get the predecessor id and the relation period for each relation + work_packages_periods AS ( + SELECT DISTINCT ON (succ_id) + pred_id as id, + pred_date as start_date, + succ_date as end_date + FROM automatic_follows_relations + ORDER BY succ_id, pred_date ASC ) - -- select id of work packages covering the given days - SELECT id FROM covered_dates_and_wday - WHERE dow IN (:days_of_week) OR date IN (:dates) SQL - - sanitize_sql([sql, { days_of_week:, dates: }]) end end end diff --git a/app/workers/work_packages/apply_working_days_change_job.rb b/app/workers/work_packages/apply_working_days_change_job.rb index 64352f32b794..5c393924fa26 100644 --- a/app/workers/work_packages/apply_working_days_change_job.rb +++ b/app/workers/work_packages/apply_working_days_change_job.rb @@ -106,8 +106,11 @@ def changed_non_working_dates end def applicable_predecessors + days_of_week = changed_days.keys + dates = changed_non_working_dates.keys + WorkPackage - .where(id: Relation.follows_with_lag.select(:to_id)) + .predecessors_needing_relations_rescheduling(days_of_week:, dates:) .where.not(id: already_processed_work_package_ids) end diff --git a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb index 36341d63e856..5a27d503bbee 100644 --- a/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb +++ b/spec/models/work_packages/scopes/covering_dates_and_days_of_week_spec.rb @@ -31,162 +31,242 @@ RSpec.describe WorkPackages::Scopes::CoveringDatesAndDaysOfWeek do create_shared_association_defaults_for_work_package_factory - shared_let(:next_monday) { Date.current.next_occurring(:monday) } - - shared_examples_for "covering days" do - # Construct the keyword arguments for the `#covering_dates_and_days_of_week` method. - # It builds the expected day values based on the argument type provided (`:dates`, `:days_of_week`) - # and based on the day values provided. - # By providing the `expected_days`, the specific tetscase can decide which days are expected. - let(:day_args) do - ->(*expected_days) do - days.keys.reduce(Hash.new { |hsh, key| hsh[key] = [] }) do |day_args, dt| - Array(expected_days).each do |day| - day_args[dt] << days[dt][day] if days[dt][day] - end - day_args + # Constructs the keyword arguments for the `#covering_dates_and_days_of_week` method. + # It's a `{ days_of_week:, dates: }` hash for given days of week built differently + # depending on the `days_args_strategy` value. + # + # `days_args_strategy` can take the following values: + # - `:days_of_week_only`: returns `{ days_of_week: ..., dates: [] }` so it + # only contains days of week + # - `:dates_only`: returns `{ days_of_week: [], dates: ... }` so it only + # contains specific dates for the specified days of weeks over the next 2 + # weeks + # - `:mixed`: returns `{ days_of_week: ..., dates: ... }` so it contains a mix of days + # of week and specific dates: Monday, Wednesday, Friday and Sunday are days of week, and + # Tuesday, Thursday and Saturday are dates + def day_args(*days_of_week_as_symbols) + next_monday = Date.current.next_occurring(:monday) + values = days_of_week_as_symbols.map { |dow| next_monday.next_occurring(dow.to_sym) } + .flat_map { |day| [day, day + 7.days] } + + case days_args_strategy + when :days_of_week_only + days_of_week = values.map(&:cwday).uniq + dates = [] + when :dates_only + days_of_week = [] + dates = values + when :mixed + # Monday, Wednesday, Friday and Sunday as days of week + # Tuesday, Thursday and Saturday as dates + days_of_week = values.map(&:cwday).uniq.filter(&:odd?) + dates = values.filter { |day| day.cwday.even? } + end + { days_of_week:, dates: } + end + + shared_context "with the days of week" do + let(:days_args_strategy) { :days_of_week_only } + end + + shared_context "with specific dates" do + let(:days_args_strategy) { :dates_only } + end + + shared_context "with days of week and specific dates mixed" do + let(:days_args_strategy) { :mixed } + end + + for_each_context "with the days of week", + "with specific dates", + "with days of week and specific dates mixed" do + describe "#covering_dates_and_days_of_week" do + it "returns work packages having start date or due date being in the given days of week" do + table = + create_table(<<~TABLE) + subject | MTWTFSS | + covered1 | XX | + covered2 | XX | + covered3 | X | + covered4 | [ | + covered5 | ] | + not_covered1 | X | + not_covered2 | X | + not_covered3 | XX | + not_covered4 | | + TABLE + + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday))) + .to contain_exactly( + table.work_package("covered1"), + table.work_package("covered2"), + table.work_package("covered3"), + table.work_package("covered4"), + table.work_package("covered5") + ) + end + + it "returns work packages having days between start date and due date being in the given days of week" do + table = + create_table(<<~TABLE) + subject | MTWTFSS | + covered1 | XXXX | + covered2 | XXX | + not_covered1 | XX | + not_covered2 | X | + TABLE + + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday, :wednesday))) + .to contain_exactly( + table.work_package("covered1"), + table.work_package("covered2") + ) + end + + context "if work package ignores non working days" do + it "does not returns it" do + create_table(<<~TABLE) + subject | MTWTFSS | days counting + not_covered | XXXXXXX | all days + TABLE + + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:wednesday))) + .to eq([]) end end - end - it "returns work packages having start date or due date being in the given days of week" do - table = + it "does not return work packages having follows relation covering the given days of week" do create_table(<<~TABLE) - subject | MTWTFSS | - covered1 | XX | - covered2 | XX | - covered3 | X | - covered4 | [ | - covered5 | ] | + subject | MTWTFSS | predecessors not_covered1 | X | - not_covered2 | X | - not_covered3 | XX | - not_covered4 | | + follower1 | X | not_covered1 + not_covered2 | X | + follower2 | X | not_covered2 TABLE - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday])) - .to contain_exactly( - table.work_package("covered1"), - table.work_package("covered2"), - table.work_package("covered3"), - table.work_package("covered4"), - table.work_package("covered5") - ) - end + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday, :thursday))) + .to eq([]) + end - it "returns work packages having days between start date and due date being in the given days of week" do - table = + it "does not return work packages having follows relation with lag covering the given days of week" do create_table(<<~TABLE) - subject | MTWTFSS | - covered1 | XXXX | - covered2 | XXX | - not_covered1 | XX | + subject | MTWTFSS | predecessors + not_covered1 | X | + follower1 | X | not_covered1 with lag 3 not_covered2 | X | + follower2 | X | not_covered2 with lag 1 TABLE - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :wednesday])) - .to contain_exactly( - table.work_package("covered1"), - table.work_package("covered2") - ) + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday, :thursday))) + .to eq([]) + end + + it "accepts a single day of week or an array of days" do + table = + create_table(<<~TABLE) + subject | MTWTFSS | + covered | X | + not_covered | X | + TABLE + + single_value = day_args(:tuesday).transform_values { |v| Array(v).first } + + expect(WorkPackage.covering_dates_and_days_of_week(**single_value)) + .to eq([table.work_package("covered")]) + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday))) + .to eq([table.work_package("covered")]) + expect(WorkPackage.covering_dates_and_days_of_week(**day_args(:tuesday, :wednesday))) + .to eq([table.work_package("covered")]) + end end - context "if work package ignores non working days" do - it "does not returns it" do + describe "#predecessors_needing_relations_rescheduling" do + it "returns nothing if no days of week or dates are provided" do create_table(<<~TABLE) - subject | MTWTFSS | days counting - not_covered | XXXXXXX | all days + subject | MTWTFSS | scheduling mode | predecessors + covered1 | XX | manual | + succ1 | XX | automatic | covered1 TABLE - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:wednesday])) + expect(WorkPackage.predecessors_needing_relations_rescheduling(**day_args).pluck(:subject)) .to eq([]) end - end - it "does not return work packages having follows relation covering the given days of week" do - create_table(<<~TABLE) - subject | MTWTFSS | predecessors - not_covered1 | X | - follower1 | X | follows not_covered1 - not_covered2 | X | - follower2 | X | follows not_covered2 - TABLE - - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :thursday])) - .to eq([]) - end + it "returns work packages being predecessors in a relation covering the given days" do + create_table(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + covered1 | XX ░ | manual | + succ1 | ░ XX | automatic | covered1 - it "does not return work packages having follows relation with lag covering the given days of week" do - create_table(<<~TABLE) - subject | MTWTFSS | predecessors - not_covered1 | X | - follower1 | X | follows not_covered1 with lag 3 - not_covered2 | X | - follower2 | X | follows not_covered2 with lag 1 - TABLE - - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :thursday])) - .to eq([]) - end + covered2 | XX░ | manual | + succ2 | ░XX | automatic | covered2 - it "accepts a single day of week or an array of days" do - table = - create_table(<<~TABLE) - subject | MTWTFSS | - covered | X | - not_covered | X | + not_covered3 | XX░ | manual | + succ3 | XX | automatic | not_covered3 + + not_covered4 | XX | manual | + succ4 | ░XX | automatic | not_covered4 + + not_covered5 | XX | manual | + succ5 | ░ XX | automatic | not_covered5 + + not_covered6 | XX ░ | manual | + succ6 | XX | automatic | not_covered6 TABLE - single_value = day_args[:tuesday].transform_values { |v| Array(v).first } + expect(WorkPackage.predecessors_needing_relations_rescheduling(**day_args(:wednesday)).pluck(:subject)) + .to contain_exactly( + "covered1", + "covered2" + ) + end - expect(WorkPackage.covering_dates_and_days_of_week(**single_value)) - .to eq([table.work_package("covered")]) - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday])) - .to eq([table.work_package("covered")]) - expect(WorkPackage.covering_dates_and_days_of_week(**day_args[:tuesday, :wednesday])) - .to eq([table.work_package("covered")]) - end - end + it "does not return non-impacting predecessors from a chain of successors" do + create_table(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + pred | X ░ | manual | + succ1 | X ░ | automatic | pred + succ2 | X░ | automatic | succ1 + succ3 | ░X | automatic | succ2 + succ4 | ░ XX | automatic | succ3 + TABLE - context "with the days of week" do - let(:days) do - { - days_of_week: { - tuesday: 2, - wednesday: 3, - thursday: 4 - } - } - end + expect(WorkPackage.predecessors_needing_relations_rescheduling(**day_args(:thursday)).pluck(:subject)) + .to contain_exactly( + "succ2" + ) + end - it_behaves_like "covering days" - end + it "returns each impacting predecessor in a chain of successors" do + create_table(<<~TABLE) + subject | MTWTFSSmtwtfss | scheduling mode | predecessors + pred | X ░ ░ | manual | + succ1 | X ░ ░ | automatic | pred + succ2 | ░ X ░ | automatic | succ1 + succ3 | ░ X ░ | automatic | succ2 + succ4 | ░ ░ X | automatic | succ3 + TABLE - context "with specific dates" do - let(:days) do - { - dates: { - tuesday: next_monday.next_occurring(:tuesday), - wednesday: next_monday.next_occurring(:wednesday), - thursday: next_monday.next_occurring(:thursday) - } - } - end + expect(WorkPackage.predecessors_needing_relations_rescheduling(**day_args(:friday)).pluck(:subject)) + .to contain_exactly( + "succ1", + "succ3" + ) + end - it_behaves_like "covering days" - end + it "when there are multiple follows relations to the same successor, " \ + "it returns only the farthest predecessor for each successor" do + create_table(<<~TABLE) + subject | MTWTFSSmtwtfss | scheduling mode | predecessors + pred1 | X ░ ░ | manual | + pred2 | X ░ ░ | manual | + pred3 | ░ X ░ | manual | + succ | ░ X ░ | automatic | pred1, pred2, pred3 + TABLE - context "with days of week and specific dates mixed" do - let(:days) do - { - days_of_week: { wednesday: 3 }, - dates: { - tuesday: next_monday.next_occurring(:tuesday), - thursday: next_monday.next_occurring(:thursday) - } - } + expect(WorkPackage.predecessors_needing_relations_rescheduling(**day_args(:friday)).pluck(:subject)) + .to contain_exactly("pred2") + end end - - it_behaves_like "covering days" end end diff --git a/spec/workers/work_packages/apply_working_days_change_job_spec.rb b/spec/workers/work_packages/apply_working_days_change_job_spec.rb index bd5df32c6def..90ea4680aedc 100644 --- a/spec/workers/work_packages/apply_working_days_change_job_spec.rb +++ b/spec/workers/work_packages/apply_working_days_change_job_spec.rb @@ -45,7 +45,7 @@ shared_examples_for "journal updates with cause" do let(:changed_work_packages) { [] } let(:unchanged_work_packages) { [] } - let(:changed_days) { raise "need to specify note" } + let(:changed_days) { raise "need to specify `let(:changed_days)`" } it "adds journal entries to changed work packages" do subject @@ -334,8 +334,7 @@ end end - context "when a follower has a predecessor with a non-working day between them that is now a working day", - skip: "TODO!!!" do + context "when a follower has a predecessor with a non-working day between them that is now a working day" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | predecessors predecessor | XX░ ░░ | manual | @@ -363,6 +362,12 @@ let(:unchanged_work_packages) do [predecessor] end + let(:changed_days) do + { + "working_days" => { "3" => true }, + "non_working_days" => {} + } + end end end @@ -887,8 +892,7 @@ end end - context "when a follower has a predecessor with a non-working day between them that is now a working day", - skip: "TODO!!!" do + context "when a follower has a predecessor with a non-working day between them that is now a working day" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | predecessors predecessor | XX░ ░░ | manual | @@ -904,7 +908,7 @@ set_working_days(non_working_day.date) end - it "does not move the follower" do + it "moves the follower backwards" do subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSS | @@ -920,6 +924,12 @@ let(:unchanged_work_packages) do [predecessor] end + let(:changed_days) do + { + "working_days" => {}, + "non_working_days" => { non_working_day.date.iso8601 => true } + } + end end end @@ -1503,18 +1513,27 @@ set_working_days(non_working_day.date) end - it "does not move the follower" do + it "moves the follower backwards" do subject expect(WorkPackage.all).to match_table(<<~TABLE) subject | MTWTFSS | predecessor | XX | - follower | XX░░ | + follower | XX ░░ | TABLE end it_behaves_like "journal updates with cause" do let(:unchanged_work_packages) do - [predecessor, follower] + [predecessor] + end + let(:changed_work_packages) do + [follower] + end + let(:changed_days) do + { + "working_days" => {}, + "non_working_days" => { non_working_day.date.iso8601 => true } + } end end end From 3cc12214cde2455c1af24059fa0721a7b8aa2acb Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 13 Dec 2024 17:12:53 +0100 Subject: [PATCH 15/33] refactor: reword some tests cases Before changing them for a new behavior. --- .../set_attributes_service_spec.rb | 303 +++++++----------- 1 file changed, 117 insertions(+), 186 deletions(-) diff --git a/spec/services/work_packages/set_attributes_service_spec.rb b/spec/services/work_packages/set_attributes_service_spec.rb index 71e4c6ebd75d..11ce2f68b01f 100644 --- a/spec/services/work_packages/set_attributes_service_spec.rb +++ b/spec/services/work_packages/set_attributes_service_spec.rb @@ -523,40 +523,40 @@ end context "for start_date & due_date & duration" do - context "with a parent" do + context "with a parent scheduled manually" do let(:expected_attributes) { {} } let(:work_package) { new_work_package } let(:parent) do build_stubbed(:work_package, + schedule_manually: true, start_date: parent_start_date, due_date: parent_due_date) end let(:parent_start_date) { Time.zone.today - 5.days } let(:parent_due_date) { Time.zone.today + 10.days } - context "with the parent having dates and not providing own dates" do + context "with the parent having dates and work package not providing its own dates" do let(:call_attributes) { { parent: } } + let(:parent_start_date) { Time.zone.today - 5.days } + let(:parent_due_date) { Time.zone.today + 10.days } - it_behaves_like "service call" do - it "sets the start_date to the parent`s start_date" do - subject - - expect(work_package.start_date) - .to eql parent_start_date - end - - it "sets the due_date to the parent`s due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", description: "sets the start and due dates to the parent's dates" do + let(:expected_attributes) do + { + start_date: parent_start_date, + due_date: parent_due_date + } end end end - context "with the parent having dates and not providing own dates and with the parent`s" \ - "soonest_start being before the start_date (e.g. because the parent is manually scheduled)" do + context "with the parent having dates and work package not providing its own dates " \ + "and with the parent's soonest_start being later than its start_date " \ + "(e.g. because the parent is manually scheduled and his predecessor " \ + "due date is later than its own start date)" do let(:call_attributes) { { parent: } } + let(:parent_start_date) { Time.zone.today - 5.days } + let(:parent_due_date) { Time.zone.today + 10.days } before do allow(parent) @@ -564,221 +564,151 @@ .and_return(parent_start_date + 3.days) end - it_behaves_like "service call" do - it "sets the start_date to the parent`s start_date" do - subject - - expect(work_package.start_date) - .to eql parent_start_date - end - - it "sets the due_date to the parent`s due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", description: "sets the start and due dates to the parent's dates" do + let(:expected_attributes) do + { + start_date: parent_start_date, + due_date: parent_due_date + } end end end - context "with the parent having start date (no due) and not providing own dates" do + context "with the parent having start date (no due) and work package not providing its own dates" do let(:call_attributes) { { parent: } } let(:parent_due_date) { nil } - it_behaves_like "service call" do - it "sets the start_date to the parent`s start_date" do - subject - - expect(work_package.start_date) - .to eql parent_start_date - end - - it "sets the due_date to nil" do - subject - - expect(work_package.due_date) - .to be_nil + it_behaves_like "service call", description: "sets the start to the parent's start date and sets due date to nil" do + let(:expected_attributes) do + { + start_date: parent_start_date, + due_date: nil + } end end end - context "with the parent having due date (no start) and not providing own dates" do + context "with the parent having due date (no start) and work package not providing its own dates" do let(:call_attributes) { { parent: } } let(:parent_start_date) { nil } - it_behaves_like "service call" do - it "sets the start_date to nil" do - subject - - expect(work_package.start_date) - .to be_nil - end - - it "sets the due_date to the parent`s due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", description: "sets the start to the parent's start date and sets due date to nil" do + let(:expected_attributes) do + { + start_date: nil, + due_date: parent_due_date + } end end end - context "with the parent having dates but providing own dates" do + context "with the parent having dates but work package providing its own dates" do let(:call_attributes) { { parent:, start_date: Time.zone.today, due_date: Time.zone.today + 1.day } } - it_behaves_like "service call" do - it "sets the start_date to the provided date" do - subject - - expect(work_package.start_date) - .to eql Time.zone.today - end - - it "sets the due_date to the provided date" do - subject - - expect(work_package.due_date) - .to eql Time.zone.today + 1.day + it_behaves_like "service call", description: "sets the start and due dates to the provided dates" do + let(:expected_attributes) do + { + start_date: Time.zone.today, + due_date: Time.zone.today + 1.day + } end end end - context "with the parent having dates but providing own start_date" do + context "with the parent having dates but work package providing its own start_date" do let(:call_attributes) { { parent:, start_date: Time.zone.today } } - it_behaves_like "service call" do - it "sets the start_date to the provided date" do - subject - - expect(work_package.start_date) - .to eql Time.zone.today - end - - it "sets the due_date to the parent's due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", + description: "sets the start_date to the provided date and the due_date to the parent's due_date" do + let(:expected_attributes) do + { + start_date: Time.zone.today, + due_date: parent_due_date + } end end end - context "with the parent having dates but providing own due_date" do + context "with the parent having dates but work package providing its own due_date" do let(:call_attributes) { { parent:, due_date: Time.zone.today + 4.days } } - it_behaves_like "service call" do - it "sets the start_date to the parent's start date" do - subject - - expect(work_package.start_date) - .to eql parent_start_date - end - - it "sets the due_date to the provided date" do - subject - - expect(work_package.due_date) - .to eql Time.zone.today + 4.days + it_behaves_like "service call", + description: "sets the start_date to the parent's start date and the due_date to the provided date" do + let(:expected_attributes) do + { + start_date: parent_start_date, + due_date: Time.zone.today + 4.days + } end end end - context "with the parent having dates but providing own empty start_date" do + context "with the parent having dates but work package providing its own empty start_date" do let(:call_attributes) { { parent:, start_date: nil } } - it_behaves_like "service call" do - it "sets the start_date to nil" do - subject - - expect(work_package.start_date) - .to be_nil - end - - it "sets the due_date to the parent's due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", + description: "sets the start_date to nil and the due_date to the parent's due_date" do + let(:expected_attributes) do + { + start_date: nil, + due_date: parent_due_date + } end end end - context "with the parent having dates but providing own empty due_date" do + context "with the parent having dates but work package providing its own empty due_date" do let(:call_attributes) { { parent:, due_date: nil } } - it_behaves_like "service call" do - it "sets the start_date to the parent's start date" do - subject - - expect(work_package.start_date) - .to eql parent_start_date - end - - it "sets the due_date to nil" do - subject - - expect(work_package.due_date) - .to be_nil + it_behaves_like "service call", + description: "sets the start_date to the parent's start date and the due_date to nil" do + let(:expected_attributes) do + { + start_date: parent_start_date, + due_date: nil + } end end end - context "with the parent having dates but providing a start date that is before parent`s due date`" do + context "with the parent having dates but work package providing its own start date that is before parent's due date" do let(:call_attributes) { { parent:, start_date: parent_due_date - 4.days } } - it_behaves_like "service call" do - it "sets the start_date to the provided date" do - subject - - expect(work_package.start_date) - .to eql parent_due_date - 4.days - end - - it "sets the due_date to the parent's due_date" do - subject - - expect(work_package.due_date) - .to eql parent_due_date + it_behaves_like "service call", + description: "sets the start_date to the provided date and the due_date to the parent's due_date" do + let(:expected_attributes) do + { + start_date: parent_due_date - 4.days, + due_date: parent_due_date + } end end end - context "with the parent having dates but providing a start date that is after the parent`s due date`" do + context "with the parent having dates but work package providing its own start date that is after the parent's due date" do let(:call_attributes) { { parent:, start_date: parent_due_date + 1.day } } - it_behaves_like "service call" do - it "sets the start_date to the provided date" do - subject - - expect(work_package.start_date) - .to eql parent_due_date + 1.day - end - - it "leaves the due date empty" do - subject - - expect(work_package.due_date) - .to be_nil + it_behaves_like "service call", + description: "sets the start_date to the provided date and the due_date to nil" do + let(:expected_attributes) do + { + start_date: parent_due_date + 1.day, + due_date: nil + } end end end - context "with the parent having dates but providing a due date that is before the parent`s start date`" do + context "with the parent having dates but work package providing its own due date that is before the parent's start date" do let(:call_attributes) { { parent:, due_date: parent_start_date - 3.days } } - it_behaves_like "service call" do - it "leaves the start date empty" do - subject - - expect(work_package.start_date) - .to be_nil - end - - it "set the due date to the provided date" do - subject - - expect(work_package.due_date) - .to eql parent_start_date - 3.days + it_behaves_like "service call", + description: "leaves the start date empty and sets the due date to the provided date" do + let(:expected_attributes) do + { + start_date: nil, + due_date: parent_start_date - 3.days + } end end end @@ -787,20 +717,8 @@ let(:call_attributes) { { parent_id: -1 } } let(:work_package) { build_stubbed(:work_package, start_date: Time.zone.today, due_date: Time.zone.today + 2.days) } - it_behaves_like "service call" do - it "sets the start_date to the parent`s start_date" do - subject - - expect(work_package.start_date) - .to eql Time.zone.today - end - - it "sets the due_date to the parent`s due_date" do - subject - - expect(work_package.due_date) - .to eql Time.zone.today + 2.days - end + it_behaves_like "service call", description: "keeps its own dates" do + let(:expected_kept_attributes) { %w[start_date due_date] } end end end @@ -1876,6 +1794,19 @@ end end + context "when the soonest start date is earlier than the current start date" do + let(:soonest_start) { Time.zone.today - 3.days } + + it_behaves_like "service call" do + it "sets the start date to the soonest possible start date" do + subject + + expect(work_package.start_date).to eql(Time.zone.today - 3.days) + expect(work_package.due_date).to eql(Time.zone.today + 2.days) + end + end + end + context "when the soonest start date is a non-working day" do shared_let(:working_days) { week_with_saturday_and_sunday_as_weekend } let(:saturday) { Time.zone.today.beginning_of_week.next_occurring(:saturday) } @@ -1939,7 +1870,7 @@ .and_return([child]) end - context "when the child`s start date is after soonest_start" do + context "when the child's start date is after soonest_start" do it_behaves_like "service call" do it "sets the dates to the child dates" do subject @@ -1950,7 +1881,7 @@ end end - context "when the child`s start date is before soonest_start" do + context "when the child's start date is before soonest_start" do let(:soonest_start) { Time.zone.today + 3.days } it_behaves_like "service call" do From 853b2d4cc39f85644d17e08526899864c5c7e762 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 16 Dec 2024 11:25:10 +0100 Subject: [PATCH 16/33] [59539] Update seeding data to be compatible with new automatic scheduling mode --- app/models/relation.rb | 17 +- app/seeders/demo_data/work_package_seeder.rb | 11 +- app/seeders/standard.yml | 161 ++++++++-------- app/services/work_packages/shared/all_days.rb | 7 + .../work_packages/shared/working_days.rb | 12 +- modules/bim/app/seeders/bim.yml | 177 +++++++++++------- .../seeders/root_seeder_bim_edition_spec.rb | 5 + .../demo_data/work_package_seeder_spec.rb | 49 ++++- spec/seeders/root_seeder_shared_examples.rb | 55 ++++++ .../root_seeder_standard_edition_spec.rb | 5 + .../work_packages/shared/all_days_spec.rb | 2 + .../shared/shared_examples_days.rb | 77 ++++++++ .../work_packages/shared/working_days_spec.rb | 2 + 13 files changed, 427 insertions(+), 153 deletions(-) diff --git a/app/models/relation.rb b/app/models/relation.rb index 947312b1c213..f96967d3c8fa 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -134,10 +134,23 @@ def label_for(work_package) TYPES[relation_type] ? TYPES[relation_type][key] : :unknown end + def predecessor = to + def predecessor_id = to_id + def successor = from + def successor_id = from_id + + def predecessor_date + predecessor.due_date || predecessor.start_date + end + + def successor_date + successor.start_date || successor.due_date + end + def successor_soonest_start - if follows? && (to.start_date || to.due_date) + if follows? && predecessor_date days = WorkPackages::Shared::Days.for(from) - relation_start_date = (to.due_date || to.start_date) + 1.day + relation_start_date = predecessor_date + 1.day days.soonest_working_day(relation_start_date, lag:) end end diff --git a/app/seeders/demo_data/work_package_seeder.rb b/app/seeders/demo_data/work_package_seeder.rb index efee28ae1cdf..93006c89c365 100644 --- a/app/seeders/demo_data/work_package_seeder.rb +++ b/app/seeders/demo_data/work_package_seeder.rb @@ -199,6 +199,7 @@ def work_package_attributes due_date:, duration:, ignore_non_working_days:, + schedule_manually:, estimated_hours: } end @@ -213,7 +214,7 @@ def initialize(attributes) def start_date days_ahead = attributes["start"] || 0 - Time.zone.today.monday + days_ahead.days + Date.current.monday + days_ahead.days end def due_date @@ -230,6 +231,14 @@ def ignore_non_working_days .any? { |date| working_days.non_working?(date) } end + def schedule_manually + if attributes["schedule_manually"].nil? + WorkPackage.column_defaults["schedule_manually"] + else + ActiveModel::Type::Boolean.new.cast(attributes["schedule_manually"]) + end + end + def estimated_hours attributes["estimated_hours"]&.to_i end diff --git a/app/seeders/standard.yml b/app/seeders/standard.yml index 0e00dea26de8..eeabbfbc046e 100644 --- a/app/seeders/standard.yml +++ b/app/seeders/standard.yml @@ -243,7 +243,7 @@ projects: options: t_name: 'Milestones' queryId: '##query.id:demo_project__query__milestones' - # For all dates, the reference is the monday of the current week + # For all dates, the reference is the Monday of the current week # So # * start: 0 references Monday # * start: 4 references Friday @@ -255,13 +255,15 @@ projects: status: :default_status_closed type: :default_type_milestone duration: 1 - schedule_manually: false + schedule_manually: true - start: 0 t_subject: Organize open source conference reference: :organize_open_source_conference description: '' status: :default_status_in_progress type: :default_type_phase + duration: 15 + schedule_manually: false children: - start: 0 t_subject: Set date and location of conference @@ -269,6 +271,8 @@ projects: description: '' status: :default_status_in_progress type: :default_type_task + duration: 4 + schedule_manually: false children: - start: 0 t_subject: Send invitation to speakers @@ -288,8 +292,6 @@ projects: status: :default_status_new type: :default_type_task duration: 4 - duration: 4 - schedule_manually: false - start: 4 t_subject: Invite attendees to conference reference: :invite_attendees_to_conference @@ -297,10 +299,10 @@ projects: status: :default_status_new type: :default_type_task duration: 1 + schedule_manually: false relations: - to: :set_date_and_location_of_conference type: follows - schedule_manually: false - start: 4 t_subject: Setup conference website reference: :setup_conference_website @@ -308,12 +310,10 @@ projects: status: :default_status_new type: :default_type_task duration: 11 + schedule_manually: false relations: - to: :set_date_and_location_of_conference type: follows - schedule_manually: false - duration: 15 - schedule_manually: true - start: 15 t_subject: Conference description: '' @@ -330,6 +330,8 @@ projects: description: '' status: :default_status_to_be_scheduled type: :default_type_phase + duration: 11 + schedule_manually: false children: - start: 21 t_subject: Upload presentations to website @@ -338,7 +340,6 @@ projects: status: :default_status_new type: :default_type_task duration: 10 - schedule_manually: false - start: 31 t_subject: Party for conference supporters :-) t_description: |- @@ -349,19 +350,16 @@ projects: status: :default_status_new type: :default_type_task duration: 1 - schedule_manually: false - duration: 11 - schedule_manually: false - start: 32 t_subject: End of project description: status: :default_status_new type: :default_type_milestone duration: 1 + schedule_manually: false relations: - to: :follow_up_tasks type: follows - schedule_manually: false t_wiki: | _In this wiki you can collaboratively create and edit pages and sub-pages to create a project wiki._ @@ -619,6 +617,7 @@ projects: type: :default_type_epic start: 0 duration: 29 + schedule_manually: false children: - t_subject: Newsletter registration form status: :default_status_in_progress @@ -638,6 +637,7 @@ projects: story_points: 3 start: 28 duration: 1 + schedule_manually: false children: - t_subject: Create wireframes for new landing page status: :default_status_in_progress @@ -659,6 +659,7 @@ projects: version: :scrum_project__version__sprint_1 position: 3 story_points: 5 + schedule_manually: false children: - t_subject: Make screenshots for feature tour status: :default_status_closed @@ -705,6 +706,7 @@ projects: story_points: 3 start: 25 duration: 1 + schedule_manually: false children: - t_subject: Set up navigation concept for website. status: :default_status_in_specification @@ -724,12 +726,13 @@ projects: status: :default_status_in_progress type: :default_type_phase start: 14 - duration: 3 + duration: 4 - t_subject: Release v1.0 status: :default_status_new type: :default_type_milestone start: 18 duration: 1 + schedule_manually: false relations: - to: :develop_v1_0 type: follows @@ -738,12 +741,13 @@ projects: status: :default_status_new type: :default_type_phase start: 21 - duration: 3 + duration: 4 - t_subject: Release v1.1 status: :default_status_new type: :default_type_milestone start: 25 duration: 1 + schedule_manually: false relations: - to: :develop_v1_1 type: follows @@ -752,12 +756,13 @@ projects: status: :default_status_new type: :default_type_phase start: 28 - duration: 3 + duration: 4 - t_subject: Release v2.0 status: :default_status_new type: :default_type_milestone start: 32 duration: 1 + schedule_manually: false relations: - to: :develop_v2_0 type: follows @@ -940,80 +945,80 @@ types: workflows: - type: :default_type_task statuses: - - :default_status_new - - :default_status_in_progress - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_in_progress + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_milestone statuses: - - :default_status_new - - :default_status_to_be_scheduled - - :default_status_scheduled - - :default_status_in_progress - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_to_be_scheduled + - :default_status_scheduled + - :default_status_in_progress + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_phase statuses: - - :default_status_new - - :default_status_to_be_scheduled - - :default_status_scheduled - - :default_status_in_progress - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_to_be_scheduled + - :default_status_scheduled + - :default_status_in_progress + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_feature statuses: - - :default_status_new - - :default_status_in_specification - - :default_status_specified - - :default_status_in_progress - - :default_status_developed - - :default_status_in_testing - - :default_status_tested - - :default_status_test_failed - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_in_specification + - :default_status_specified + - :default_status_in_progress + - :default_status_developed + - :default_status_in_testing + - :default_status_tested + - :default_status_test_failed + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_epic statuses: - - :default_status_new - - :default_status_in_specification - - :default_status_specified - - :default_status_in_progress - - :default_status_developed - - :default_status_in_testing - - :default_status_tested - - :default_status_test_failed - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_in_specification + - :default_status_specified + - :default_status_in_progress + - :default_status_developed + - :default_status_in_testing + - :default_status_tested + - :default_status_test_failed + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_user_story statuses: - - :default_status_new - - :default_status_in_specification - - :default_status_specified - - :default_status_in_progress - - :default_status_developed - - :default_status_in_testing - - :default_status_tested - - :default_status_test_failed - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_in_specification + - :default_status_specified + - :default_status_in_progress + - :default_status_developed + - :default_status_in_testing + - :default_status_tested + - :default_status_test_failed + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed - type: :default_type_bug statuses: - - :default_status_new - - :default_status_confirmed - - :default_status_in_progress - - :default_status_developed - - :default_status_in_testing - - :default_status_tested - - :default_status_test_failed - - :default_status_on_hold - - :default_status_rejected - - :default_status_closed + - :default_status_new + - :default_status_confirmed + - :default_status_in_progress + - :default_status_developed + - :default_status_in_testing + - :default_status_tested + - :default_status_test_failed + - :default_status_on_hold + - :default_status_rejected + - :default_status_closed welcome: t_title: "Welcome to OpenProject!" diff --git a/app/services/work_packages/shared/all_days.rb b/app/services/work_packages/shared/all_days.rb index bcceae1539f9..0824e79c354c 100644 --- a/app/services/work_packages/shared/all_days.rb +++ b/app/services/work_packages/shared/all_days.rb @@ -36,6 +36,13 @@ def duration(start_date, due_date) (start_date..due_date).count end + # Returns the number of working days between a predecessor date and + # successor date, exclusive. + def lag(predecessor_date, successor_date) + # lag is *always* excluding non-working days (at least for now) + WorkingDays.new.lag(predecessor_date, successor_date) + end + def start_date(due_date, duration) return nil unless due_date && duration raise ArgumentError, "duration must be strictly positive" if duration.is_a?(Integer) && duration <= 0 diff --git a/app/services/work_packages/shared/working_days.rb b/app/services/work_packages/shared/working_days.rb index 5bc2179e01b4..cdf007b285d5 100644 --- a/app/services/work_packages/shared/working_days.rb +++ b/app/services/work_packages/shared/working_days.rb @@ -35,14 +35,22 @@ def clear_cache end end - # Returns number of working days between two dates, excluding weekend days - # and non working days. + # Returns number of working days between two dates, inclusive, excluding + # weekend days and non working days. def duration(start_date, due_date) return nil unless start_date && due_date (start_date..due_date).count { working?(_1) } end + # Returns the number of working days between a predecessor date and + # successor date, exclusive. + def lag(predecessor_date, successor_date) + return nil unless predecessor_date && successor_date + + duration(predecessor_date + 1.day, successor_date - 1.day) + end + def start_date(due_date, duration) assert_strictly_positive_duration(duration) return nil unless due_date && duration diff --git a/modules/bim/app/seeders/bim.yml b/modules/bim/app/seeders/bim.yml index 904432c6487a..c4b0e419ff5f 100644 --- a/modules/bim/app/seeders/bim.yml +++ b/modules/bim/app/seeders/bim.yml @@ -538,6 +538,12 @@ projects: and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__planners + :duration: 15 + :schedule_manually: false + :relations: + - :to: :project_kick_off_construction_project + :type: follows :children: - :start: 7 :t_subject: Gathering first project information @@ -556,7 +562,7 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 4 + :duration: 5 - :start: 14 :t_subject: Summarize the results :reference: :summarize_the_results @@ -576,7 +582,8 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 4 + :duration: 5 + :schedule_manually: false :relations: - :to: :gathering_first_project_information :type: follows @@ -589,20 +596,19 @@ projects: :type: :default_type_milestone :assigned_to: :group__planners :duration: 1 + :schedule_manually: false :relations: - :to: :summarize_the_results :type: follows - :assigned_to: :group__planners - :duration: 14 - :relations: - - :to: :project_kick_off_construction_project - :type: follows - :start: 22 :t_subject: Preliminary planning :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__planners + :duration: 18 + :schedule_manually: false :children: - :start: 22 :t_subject: Developing first draft @@ -612,7 +618,7 @@ projects: * Create a useful overview of the results * Check what has been done and summarize the results - * Communicate all the relevant results with the customer + * Communicate all the relevant results with the customer * Identify the fundamental boundary conditions of the project ## Description @@ -623,7 +629,8 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 10 + :duration: 11 + :schedule_manually: false :relations: - :to: :end_of_basic_evaluation :type: follows @@ -635,7 +642,7 @@ projects: * Create a useful overview of the results * Check what has been done and summarize the results - * Communicate all the relevant results with the customer + * Communicate all the relevant results with the customer * Identify the fundamental boundary conditions of the project ## Description @@ -646,12 +653,11 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 4 + :duration: 5 + :schedule_manually: false :relations: - :to: :developing_first_draft :type: follows - :assigned_to: :group__planners - :duration: 17 - :start: 42 :t_subject: Passing of preliminary planning :reference: :passing_of_preliminary_planning @@ -661,17 +667,21 @@ projects: :type: :default_type_milestone :assigned_to: :group__planners :duration: 1 + :schedule_manually: false :relations: - :to: :summarize_results :type: follows - - :start: 44 + - :start: 43 :t_subject: Design planning :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__planners + :duration: 46 + :schedule_manually: false :children: - - :start: 44 + - :start: 43 :t_subject: Finishing design :reference: :finishing_design :t_description: |- @@ -690,10 +700,11 @@ projects: :type: :default_type_task :assigned_to: :group__planners :duration: 45 + :schedule_manually: false :relations: - :to: :passing_of_preliminary_planning :type: follows - - :start: 90 + - :start: 88 :t_subject: Design freeze :reference: :design_freeze :t_description: This type is hierarchically a parent of the types "Clash" @@ -702,18 +713,23 @@ projects: :type: :default_type_milestone :assigned_to: :group__planners :duration: 1 + :schedule_manually: false :relations: - :to: :finishing_design :type: follows - :assigned_to: :group__planners - :duration: 46 - - :start: 98 + - :start: 91 :t_subject: Construction phase :description: '' :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__planners + :duration: 118 + :schedule_manually: false + :relations: + - :to: :design_freeze + :type: follows :children: - - :start: 98 + - :start: 91 :t_subject: Start constructing :reference: :start_constructing :t_description: |- @@ -732,7 +748,7 @@ projects: :type: :default_type_task :assigned_to: :group__planners :duration: 31 - - :start: 130 + - :start: 122 :t_subject: Foundation :t_description: |- ## Goal @@ -749,6 +765,7 @@ projects: :type: :default_type_task :assigned_to: :group__planners :duration: 25 + :schedule_manually: false :relations: - :to: :start_constructing :type: follows @@ -825,7 +842,7 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 18 + :duration: 19 - :start: 208 :t_subject: House warming party :t_description: |- @@ -841,14 +858,10 @@ projects: :status: :default_status_new :type: :default_type_milestone :duration: 1 + :schedule_manually: false :relations: - :to: :final_touches :type: follows - :assigned_to: :group__planners - :duration: 110 - :relations: - - :to: :design_freeze - :type: follows demo-bim-project: t_name: (Demo) BIM project identifier: demo-bim-project @@ -1010,6 +1023,8 @@ projects: and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :duration: 38 + :schedule_manually: false :children: - :start: 15 :t_subject: Gathering the project specific data and information for the BIM model @@ -1031,11 +1046,12 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__planners - :duration: 14 + :duration: 11 + :schedule_manually: false :relations: - :to: :project_kick_off_creating_bim_model :type: follows - - :start: 33 + - :start: 28 :t_subject: Creating the BIM execution plan :reference: :creating_bim_execution_plan :t_description: |- @@ -1052,24 +1068,24 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__lead_bim_coordinators - :duration: 18 + :duration: 24 + :schedule_manually: false :relations: - :to: :gathering_project_data_and_info :type: follows - :start: 52 :t_subject: Completion of the BIM execution plan - :reference: :completion_of_bim_execution_plan :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_resolved :type: :default_type_milestone :assigned_to: :group__bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :creating_bim_execution_plan :type: follows :assigned_to: :group__bim_managers - :duration: 37 - :start: 53 :t_subject: End of preparation phase :reference: :end_of_preparation_phase @@ -1079,17 +1095,24 @@ projects: :type: :default_type_milestone :assigned_to: :group__bim_managers :duration: 1 + :schedule_manually: false :relations: - :to: :project_preparation :type: follows - - :start: 54 + - :start: 56 :t_subject: Creating initial BIM model :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__architects + :duration: 10 + :schedule_manually: false + :relations: + - :to: :end_of_preparation_phase + :type: follows :children: - - :start: 54 + - :start: 56 :t_subject: Modelling initial BIM model :reference: :modelling_initial_bim_model :t_description: |- @@ -1106,11 +1129,12 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__bim_modellers - :duration: 7 + :duration: 5 + :schedule_manually: false :relations: - :to: :end_of_preparation_phase :type: follows - - :start: 62 + - :start: 63 :t_subject: Initial, internal model check and revising :reference: :initial_internal_model_check_and_revising :t_description: |- @@ -1126,6 +1150,7 @@ projects: :type: :default_type_task :assigned_to: :group__lead_bim_coordinators :duration: 2 + :schedule_manually: false :relations: - :to: :modelling_initial_bim_model :type: follows @@ -1138,20 +1163,22 @@ projects: :type: :default_type_milestone :assigned_to: :group__bim_modellers :duration: 1 + :schedule_manually: false :relations: - :to: :initial_internal_model_check_and_revising :type: follows - :assigned_to: :group__architects - :duration: 11 - :relations: - - :to: :completion_of_bim_execution_plan - :type: follows - :start: 66 :t_subject: Modelling, first cycle :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__bim_coordinators + :duration: 9 + :schedule_manually: false + :relations: + - :to: :submitting_initial_bim_model + :type: follows :children: - :start: 66 :t_subject: Referencing external BIM models @@ -1170,6 +1197,7 @@ projects: :type: :default_type_task :assigned_to: :group__bim_modellers :duration: 1 + :schedule_manually: false :relations: - :to: :submitting_initial_bim_model :type: follows @@ -1190,10 +1218,11 @@ projects: :type: :default_type_task :assigned_to: :group__bim_modellers :duration: 6 + :schedule_manually: false :relations: - :to: :referencing_external_bim_models :type: follows - - :start: 74 + - :start: 73 :t_subject: First Cycle, internal model check and revising :reference: :first_cycle_internal_model_check_and_revising :t_description: |- @@ -1209,10 +1238,11 @@ projects: :type: :default_type_task :assigned_to: :group__bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :modelling_of_the_bim_model :type: follows - - :start: 76 + - :start: 74 :t_subject: Submitting BIM model :reference: :submitting_bim_model :t_description: This type is hierarchically a parent of the types "Clash" @@ -1221,20 +1251,22 @@ projects: :type: :default_type_milestone :assigned_to: :group__bim_modellers :duration: 1 + :schedule_manually: false :relations: - :to: :first_cycle_internal_model_check_and_revising :type: follows - :assigned_to: :group__bim_coordinators - :duration: 10 - :relations: - - :to: :submitting_initial_bim_model - :type: follows - :start: 77 :t_subject: Coordination, first cycle :t_description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__lead_bim_coordinators + :duration: 5 + :schedule_manually: false + :relations: + - :to: :submitting_bim_model + :type: follows :children: - :start: 77 :t_subject: Coordinate the different BIM models @@ -1261,7 +1293,8 @@ projects: :type: :default_type_phase :assigned_to: :group__bim_coordinators :duration: 4 - - :start: 82 + :schedule_manually: false + - :start: 81 :t_subject: Finishing coordination, first cycle :reference: :finishing_coordination_first_cycle :t_description: This type is hierarchically a parent of the types "Clash" @@ -1270,15 +1303,11 @@ projects: :type: :default_type_milestone :assigned_to: :group__lead_bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :issue_management_first_cycle :type: follows - :assigned_to: :group__lead_bim_coordinators - :duration: 5 - :relations: - - :to: :submitting_bim_model - :type: follows - - :start: 83 + - :start: 84 :t_subject: Modelling & coordinating, second cycle :reference: :modelling_and_coordinating_second_cycle :t_description: "## Goal\r\n\r\n* ...\r\n\r\n## Description\r\n\r\n* ..." @@ -1286,10 +1315,11 @@ projects: :type: :default_type_phase :assigned_to: :group__lead_bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :finishing_coordination_first_cycle :type: follows - - :start: 84 + - :start: 85 :t_subject: Modelling & coordinating, ... cycle :reference: :modelling_and_coordinating_another_cycle :t_description: This type is hierarchically a parent of the types "Clash" @@ -1298,10 +1328,11 @@ projects: :type: :default_type_phase :assigned_to: :group__lead_bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :modelling_and_coordinating_second_cycle :type: follows - - :start: 85 + - :start: 86 :t_subject: Modelling & coordinating, (n-th minus 1) cycle :reference: :modelling_and_coordinating_penultimate_cycle :t_description: "## Goal\r\n\r\n* ...\r\n\r\n## Description\r\n\r\n* ..." @@ -1309,10 +1340,11 @@ projects: :type: :default_type_phase :assigned_to: :group__lead_bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :modelling_and_coordinating_another_cycle :type: follows - - :start: 86 + - :start: 87 :t_subject: Modelling & coordinating n-th cycle :reference: :modelling_and_coordinating_last_cycle :t_description: This type is hierarchically a parent of the types "Clash" @@ -1321,10 +1353,11 @@ projects: :type: :default_type_phase :assigned_to: :group__bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :modelling_and_coordinating_penultimate_cycle :type: follows - - :start: 87 + - :start: 88 :t_subject: Finishing modelling & coordinating, n-th cycle :reference: :finishing_modelling_and_coordinating :t_description: This type is hierarchically a parent of the types "Clash" @@ -1333,16 +1366,23 @@ projects: :type: :default_type_milestone :assigned_to: :group__bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :modelling_and_coordinating_last_cycle :type: follows - - :start: 88 + - :start: 91 :t_subject: Use model for construction phase :description: '' :status: :default_status_new :type: :default_type_phase + :assigned_to: :group__bim_coordinators + :duration: 110 + :schedule_manually: false + :relations: + - :to: :finishing_modelling_and_coordinating + :type: follows :children: - - :start: 88 + - :start: 91 :t_subject: Handover model for construction crew :t_description: |- ## Goal @@ -1377,7 +1417,7 @@ projects: :status: :default_status_new :type: :default_type_task :assigned_to: :group__bim_coordinators - :duration: 101 + :duration: 102 - :start: 200 :t_subject: Finish construction :reference: :finish_construction @@ -1386,14 +1426,10 @@ projects: :type: :default_type_milestone :assigned_to: :group__bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :construct_building :type: follows - :assigned_to: :group__bim_coordinators - :duration: 112 - :relations: - - :to: :finishing_modelling_and_coordinating - :type: follows - :start: 98 :t_subject: Issue management, construction phase :reference: :issue_management_construction_phase @@ -1402,7 +1438,8 @@ projects: :type: :default_type_phase :assigned_to: :group__planners :duration: 88 - - :start: 210 + :schedule_manually: false + - :start: 203 :t_subject: Handover for Facility Management :reference: :handover_for_facility_management :t_description: |- @@ -1421,15 +1458,17 @@ projects: :type: :default_type_milestone :assigned_to: :group__lead_bim_coordinators :duration: 1 + :schedule_manually: false :relations: - :to: :finish_construction :type: follows - - :start: 211 + - :start: 204 :t_subject: Asset Management :t_description: Enjoy your building :) :status: :default_status_new :type: :default_type_phase :duration: 1 + :schedule_manually: false :relations: - :to: :handover_for_facility_management :type: follows diff --git a/modules/bim/spec/seeders/root_seeder_bim_edition_spec.rb b/modules/bim/spec/seeders/root_seeder_bim_edition_spec.rb index 892c1ce4ebce..2a62148f5672 100644 --- a/modules/bim/spec/seeders/root_seeder_bim_edition_spec.rb +++ b/modules/bim/spec/seeders/root_seeder_bim_edition_spec.rb @@ -36,6 +36,10 @@ with_config: { edition: "bim" } do include RootSeederTestHelpers + before_all do + week_with_saturday_and_sunday_as_weekend + end + shared_examples "creates BIM demo data" do def group_name(reference) root_seeder.seed_data.find_reference(reference)["name"] @@ -104,6 +108,7 @@ def group_name(reference) include_examples "it creates records", model: Status, expected_count: 4 include_examples "it creates records", model: TimeEntryActivity, expected_count: 3 include_examples "it creates records", model: Workflow, expected_count: 273 + include_examples "it is compatible with the automatic scheduling mode" end describe "demo data" do diff --git a/spec/seeders/demo_data/work_package_seeder_spec.rb b/spec/seeders/demo_data/work_package_seeder_spec.rb index b4c4be56a677..1887407ebe97 100644 --- a/spec/seeders/demo_data/work_package_seeder_spec.rb +++ b/spec/seeders/demo_data/work_package_seeder_spec.rb @@ -194,6 +194,32 @@ def work_package_data(**attributes) end end + context "with work package data with schedule_manually" do + let(:work_packages_data) do + [ + work_package_data(schedule_manually: false), + work_package_data(schedule_manually: true) + ] + end + + it "sets schedule_manually to the given value" do + expect(WorkPackage.first.schedule_manually).to be(false) + expect(WorkPackage.second.schedule_manually).to be(true) + end + end + + context "with work package data without schedule_manually" do + let(:work_packages_data) do + [ + work_package_data(schedule_manually: nil) + ] + end + + it "sets schedule_manually to true, its default value" do + expect(WorkPackage.first.schedule_manually).to be(true) + end + end + context "with a parent relation by reference" do let(:work_packages_data) do [ @@ -204,7 +230,8 @@ def work_package_data(**attributes) it "creates a parent-child relation between work packages" do expect(WorkPackage.count).to eq(2) - expect(WorkPackage.second.parent).to eq(WorkPackage.first) + parent, child = WorkPackage.order(:id).to_a + expect(child.parent).to eq(parent) end end @@ -244,6 +271,26 @@ def work_package_data(**attributes) end end + context "with a relations array" do + let(:work_packages_data) do + [ + work_package_data(subject: "predecessor", reference: :predecessor), + work_package_data(subject: "related", reference: :related), + work_package_data(subject: "successor", relations: [{ to: :predecessor, type: "follows" }, + { to: :related, type: "relates" }]) + ] + end + + it "creates relations between work packages" do + expect(WorkPackage.count).to eq(3) + predecessor, related, successor = WorkPackage.order(:id).to_a + expect(successor.relations.pluck(:relation_type, :to_id)).to contain_exactly( + ["follows", predecessor.id], + ["relates", related.id] + ) + end + end + context "with a work package description referencing a work package with ##wp:ref notation" do let(:work_packages_data) do [ diff --git a/spec/seeders/root_seeder_shared_examples.rb b/spec/seeders/root_seeder_shared_examples.rb index 0b025afe9f52..4fdf7b29ae3d 100644 --- a/spec/seeders/root_seeder_shared_examples.rb +++ b/spec/seeders/root_seeder_shared_examples.rb @@ -28,6 +28,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ +Date::DATE_FORMATS[:wday_iso_date] = "%a %Y-%m-%d" # Fri 2022-08-05 + RSpec.shared_examples "no email deliveries" do it "does not perform any email deliveries" do perform_enqueued_jobs @@ -42,3 +44,56 @@ expect(model.count).to eq(expected_count) end end + +RSpec.shared_examples "it is compatible with the automatic scheduling mode" do + # rubocop:disable Layout/LineContinuationLeadingSpace + it "has successors in automatic mode with dates matching closest predecessor's dates and relation lag", :aggregate_failures do + days = WorkPackages::Shared::WorkingDays.new + relations = Relation.follows.includes(:from, :to).to_a + .sort_by!(&:predecessor_date) + .reverse! + .uniq(&:successor_id) + relations.each do |relation| + predecessor = relation.to + successor = relation.from + pred_date = relation.predecessor_date + succ_date = relation.successor_date + next if pred_date.nil? && succ_date.nil? + + expect(successor).to be_schedule_automatically, + "Expected successor '#{successor.subject}' to be scheduled automatically" + + expected_lag = days.lag(pred_date, succ_date) + relation_lag = relation.lag || 0 + message = + "Relation from predecessor '#{predecessor.subject}' (date: #{pred_date.to_fs(:wday_iso_date)})\n" \ + " to successor '#{successor.subject}' (date: #{succ_date.to_fs(:wday_iso_date)})\n" \ + "has invalid lag #{relation_lag} (expected #{expected_lag})\n" \ + "Try the following:\n" \ + "- Adjust successor dates to start #{(succ_date - days.soonest_working_day(pred_date + 1)).to_i} day(s) earlier\n" \ + "- Increase predecessor duration by #{expected_lag - relation_lag}\n" + expect(expected_lag).to eq(relation_lag), message + end + end + + it "has parents in automatic mode with dates matching children's dates", :aggregate_failures do + parents = WorkPackage.where(id: WorkPackage.select(:parent_id).distinct).includes(:children) + parents.each do |parent| + expected_start_date = parent.children.minimum(:start_date) + if expected_start_date + expect(parent.start_date) + .to eq(expected_start_date), + "Expected parent '#{parent.subject}' to have start date #{expected_start_date.to_fs(:wday_iso_date)}" + end + expected_due_date = parent.children.maximum(:due_date) + if expected_due_date + expect(parent.due_date) + .to eq(expected_due_date), + "Expected parent '#{parent.subject}' to have due date #{expected_due_date.to_fs(:wday_iso_date)}" + end + expect(parent).to be_schedule_automatically, + "Expected parent '#{parent.subject}' to be scheduled automatically" + end + end + # rubocop:enable Layout/LineContinuationLeadingSpace +end diff --git a/spec/seeders/root_seeder_standard_edition_spec.rb b/spec/seeders/root_seeder_standard_edition_spec.rb index 9f416a03afe6..828db18f39de 100644 --- a/spec/seeders/root_seeder_standard_edition_spec.rb +++ b/spec/seeders/root_seeder_standard_edition_spec.rb @@ -36,6 +36,10 @@ with_config: { edition: "standard" } do include RootSeederTestHelpers + before_all do + week_with_saturday_and_sunday_as_weekend + end + shared_examples "creates standard demo data" do it "creates the system user" do expect(SystemUser.where(admin: true).count).to eq 1 @@ -137,6 +141,7 @@ include_examples "it creates records", model: TimeEntryActivity, expected_count: 6 include_examples "it creates records", model: Workflow, expected_count: 1758 include_examples "it creates records", model: Meeting, expected_count: 1 + include_examples "it is compatible with the automatic scheduling mode" end describe "demo data" do diff --git a/spec/services/work_packages/shared/all_days_spec.rb b/spec/services/work_packages/shared/all_days_spec.rb index 1be534fc3f54..26e3a8a8e861 100644 --- a/spec/services/work_packages/shared/all_days_spec.rb +++ b/spec/services/work_packages/shared/all_days_spec.rb @@ -67,6 +67,8 @@ end end + include_examples "lag computation excluding non-working days" + describe "#start_date" do it "returns the start date for a due date and a duration" do expect(subject.start_date(sunday_2022_07_31, 1)).to eq(sunday_2022_07_31) diff --git a/spec/services/work_packages/shared/shared_examples_days.rb b/spec/services/work_packages/shared/shared_examples_days.rb index 5317c5805902..513107a5fdce 100644 --- a/spec/services/work_packages/shared/shared_examples_days.rb +++ b/spec/services/work_packages/shared/shared_examples_days.rb @@ -71,6 +71,19 @@ end end +RSpec.shared_examples "it returns lag" do |expected_lag, predecessor_date, successor_date| + from_date_format = "%a %-d" + from_date_format += " %b" if [predecessor_date.month, predecessor_date.year] != [successor_date.month, successor_date.year] + from_date_format += " %Y" if predecessor_date.year != successor_date.year + + it "from predecessor date #{predecessor_date.strftime(from_date_format)} " \ + "to successor date #{successor_date.to_fs(:wday_date)} " \ + "=> #{expected_lag}" \ + do + expect(subject.lag(predecessor_date, successor_date)).to eq(expected_lag) + end +end + RSpec.shared_examples "start_date" do |due_date:, duration:, expected:| it "start_date(#{due_date.to_fs(:wday_date)}, #{duration}) => #{expected.to_fs(:wday_date)}" do expect(subject.start_date(due_date, duration)).to eq(expected) @@ -94,3 +107,67 @@ expect(subject.soonest_working_day(date, lag:)).to eq(expected) end end + +RSpec.shared_examples "lag computation excluding non-working days" do + describe "#lag" do + 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) + + it "returns the working days between a predecessor date and successor date" do + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(5) + end + + it "can't be negative" do + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31 + 1)).to eq(0) + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31)).to eq(0) + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31 - 1)).to eq(0) + end + + context "without any week days created" do + it "considers all days as working days and returns the number of days between two dates, exclusive" do + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31 + 6)).to eq(5) + expect(subject.lag(sunday_2022_07_31, sunday_2022_07_31 + 50)).to eq(49) + end + end + + context "with weekend days (Saturday and Sunday)", :weekend_saturday_sunday do + include_examples "it returns lag", 0, sunday_2022_07_31, monday_2022_08_01 + include_examples "it returns lag", 4, sunday_2022_07_31, Date.new(2022, 8, 5) # Friday + include_examples "it returns lag", 5, sunday_2022_07_31, Date.new(2022, 8, 6) # Saturday + include_examples "it returns lag", 5, sunday_2022_07_31, Date.new(2022, 8, 7) # Sunday + include_examples "it returns lag", 5, sunday_2022_07_31, Date.new(2022, 8, 8) # Monday + include_examples "it returns lag", 6, sunday_2022_07_31, Date.new(2022, 8, 9) # Tuesday + + include_examples "it returns lag", 3, monday_2022_08_01, Date.new(2022, 8, 5) # Friday + include_examples "it returns lag", 4, monday_2022_08_01, Date.new(2022, 8, 6) # Saturday + include_examples "it returns lag", 4, monday_2022_08_01, Date.new(2022, 8, 7) # Sunday + include_examples "it returns lag", 4, monday_2022_08_01, Date.new(2022, 8, 8) # Monday + include_examples "it returns lag", 5, monday_2022_08_01, Date.new(2022, 8, 9) # Tuesday + + include_examples "it returns lag", 1, wednesday_2022_08_03, Date.new(2022, 8, 5) # Friday + include_examples "it returns lag", 2, wednesday_2022_08_03, Date.new(2022, 8, 6) # Saturday + include_examples "it returns lag", 2, wednesday_2022_08_03, Date.new(2022, 8, 7) # Sunday + include_examples "it returns lag", 2, wednesday_2022_08_03, Date.new(2022, 8, 8) # Monday + include_examples "it returns lag", 3, wednesday_2022_08_03, Date.new(2022, 8, 9) # Tuesday + end + + context "with some 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 lag", 0, Date.new(2022, 12, 24), Date.new(2022, 12, 26) + include_examples "it returns lag", 1, Date.new(2022, 12, 24), Date.new(2022, 12, 27) + include_examples "it returns lag", 6, Date.new(2022, 12, 24), Date.new(2023, 1, 2) + end + + context "without predecessor date" do + it "returns nil" do + expect(subject.lag(nil, sunday_2022_07_31)).to be_nil + end + end + + context "without successor date" do + it "returns nil" do + expect(subject.lag(sunday_2022_07_31, nil)).to be_nil + end + end + end +end diff --git a/spec/services/work_packages/shared/working_days_spec.rb b/spec/services/work_packages/shared/working_days_spec.rb index d8132903a99d..e3c2d5e1feb5 100644 --- a/spec/services/work_packages/shared/working_days_spec.rb +++ b/spec/services/work_packages/shared/working_days_spec.rb @@ -91,6 +91,8 @@ end end + include_examples "lag computation excluding non-working days" + describe "#start_date" do it "returns the start date for a due date and a duration" do expect(subject.start_date(monday_2022_08_01, 1)).to eq(monday_2022_08_01) From e1a73322d189d597dc09b56bec08367b804f82f3 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 17 Dec 2024 16:48:28 +0100 Subject: [PATCH 17/33] [59539] switch scheduling mode when modifying follows relations When a work package becomes a successor of another work package, its scheduling mode is switched to automatic if it has no children so that it can be scheduled as soon as possible automatically. Similarly, when a work package is no longer a successor of any other work package, its scheduling mode is switched to manual if it has no children and no dates so that it can keep its current dates. --- app/models/relation.rb | 6 + .../work_packages/scopes/for_scheduling.rb | 31 +- app/services/relations/base_service.rb | 41 ++- app/services/relations/delete_service.rb | 30 +- .../work_packages/schedule_dependency.rb | 18 +- .../work_packages/set_schedule_service.rb | 7 +- .../scheduling_mode_switching_spec.rb | 331 ++++++++++++++++++ spec/support/table_helpers/column.rb | 10 +- ...edecessors.rb => predecessor_relations.rb} | 27 +- .../column_type/related_to_relations.rb | 69 ++++ spec/support/table_helpers/table.rb | 19 +- spec/support/table_helpers/table_data.rb | 18 +- ..._spec.rb => predecessor_relations_spec.rb} | 20 +- .../column_type/related_to_relations_spec.rb | 87 +++++ .../table_helpers/table_data_spec.rb | 19 +- 15 files changed, 672 insertions(+), 61 deletions(-) create mode 100644 spec/services/relations/scheduling_mode_switching_spec.rb rename spec/support/table_helpers/column_type/{predecessors.rb => predecessor_relations.rb} (85%) create mode 100644 spec/support/table_helpers/column_type/related_to_relations.rb rename spec/support_spec/table_helpers/column_type/{predecessors_spec.rb => predecessor_relations_spec.rb} (80%) create mode 100644 spec/support_spec/table_helpers/column_type/related_to_relations_spec.rb diff --git a/app/models/relation.rb b/app/models/relation.rb index f96967d3c8fa..94d9f5a09f54 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -103,6 +103,12 @@ class Relation < ApplicationRecord scope :follows_with_lag, -> { follows.where("lag > 0") } + scope :of_successor, + ->(work_package) { where(from: work_package) } + + scope :not_of_predecessor, + ->(work_package) { where.not(to: work_package) } + validates :lag, numericality: { allow_nil: true } validates :to, uniqueness: { scope: :from } diff --git a/app/models/work_packages/scopes/for_scheduling.rb b/app/models/work_packages/scopes/for_scheduling.rb index 06deb9eaa040..08b9ca96b861 100644 --- a/app/models/work_packages/scopes/for_scheduling.rb +++ b/app/models/work_packages/scopes/for_scheduling.rb @@ -30,6 +30,7 @@ module WorkPackages::Scopes module ForScheduling extend ActiveSupport::Concern + using CoreExtensions::SquishSql class_methods do # Fetches all work packages that need to be evaluated for eventual @@ -108,13 +109,13 @@ module ForScheduling # @param work_packages WorkPackage[] A set of work packages for which the # set of related work packages that might be subject to reschedule is # fetched. - def for_scheduling(work_packages) + def for_scheduling(work_packages, switching_to_automatic_mode: []) return none if work_packages.empty? sql = <<~SQL.squish WITH RECURSIVE - #{scheduling_paths_sql(work_packages)} + #{scheduling_paths_sql(work_packages, switching_to_automatic_mode:)} SELECT id FROM to_schedule @@ -159,7 +160,11 @@ def for_scheduling(work_packages) # # Paths whose ending work package is marked to be manually scheduled are # not joined with any more. - def scheduling_paths_sql(work_packages) + def scheduling_paths_sql(work_packages, switching_to_automatic_mode: []) + automatic_ids = switching_to_automatic_mode.map do |wp| + ::OpenProject::SqlSanitization.sanitize("(:id)", id: wp.id) + end.join(", ") + values = work_packages.map do |wp| ::OpenProject::SqlSanitization .sanitize "(:id, false, false, true)", @@ -167,6 +172,13 @@ def scheduling_paths_sql(work_packages) end.join(", ") <<~SQL.squish + -- All work packages that are switching to automatic scheduling mode + -- but are still seen as manually scheduled from the database's perspective. + switching_to_automatic_mode (id) AS ( + SELECT id::bigint FROM (VALUES #{automatic_ids.presence || '(NULL)'}) AS t(id) + ), + + -- recursively fetch all work packages that are eligible for rescheduling to_schedule (id, manually, hierarchy_up, origin) AS ( SELECT * FROM (VALUES#{values}) AS t(id, manually, hierarchy_up, origin) @@ -175,9 +187,14 @@ def scheduling_paths_sql(work_packages) SELECT relations.from_id id, - (related_work_packages.schedule_manually - OR (COALESCE(descendants.manually, false) - AND NOT (to_schedule.origin AND relations.hierarchy_up)) + ( + ( + related_work_packages.schedule_manually + AND switching_to_automatic_mode.id IS NULL + ) OR ( + COALESCE(descendants.manually, false) + AND NOT (to_schedule.origin AND relations.hierarchy_up) + ) ) manually, relations.hierarchy_up, false origin @@ -211,6 +228,8 @@ def scheduling_paths_sql(work_packages) ) relations ON relations.to_id = to_schedule.id LEFT JOIN work_packages related_work_packages ON relations.from_id = related_work_packages.id + LEFT JOIN switching_to_automatic_mode + ON related_work_packages.id = switching_to_automatic_mode.id LEFT JOIN LATERAL ( SELECT descendant_hierarchies.ancestor_id from_id, diff --git a/app/services/relations/base_service.rb b/app/services/relations/base_service.rb index 7a657b5d4ab8..1fe76bf3e7d0 100644 --- a/app/services/relations/base_service.rb +++ b/app/services/relations/base_service.rb @@ -33,6 +33,7 @@ class Relations::BaseService < BaseServices::BaseCallable attr_accessor :user def initialize(user:) + super() self.user = user end @@ -54,20 +55,22 @@ def update_relation(model, attributes) end def set_defaults(model) - if Relation::TYPE_FOLLOWS == model.relation_type + if model.follows? model.lag ||= 0 else model.lag = nil end end - def reschedule(model) + def reschedule(relation) schedule_result = WorkPackages::SetScheduleService - .new(user:, work_package: model.to) + .new(user:, + work_package: relation.predecessor, + switching_to_automatic_mode: switching_to_automatic_mode(relation)) .call - # The to-work_package will not be altered by the schedule service so - # we do not have to save the result of the service. + # The predecessor work package will not be altered by the schedule service so + # we do not have to save the result of the service, only the dependent results. save_result = if schedule_result.success? schedule_result.dependent_results.all? { |dr| !dr.result.changed? || dr.result.save(validate: false) } end || false @@ -76,4 +79,32 @@ def reschedule(model) schedule_result end + + def switching_to_automatic_mode(relation) + if should_switch_successor_to_automatic_mode?(relation) + [relation.successor] + else + [] + end + end + + def should_switch_successor_to_automatic_mode?(relation) + relation.follows? \ + && creating? \ + && last_successor_relation?(relation) \ + && has_no_children?(relation.successor) + end + + def creating? + self.class.name.include?("Create") + end + + def last_successor_relation?(relation) + Relation.follows.of_successor(relation.successor) + .not_of_predecessor(relation.predecessor).none? + end + + def has_no_children?(work_package) + !WorkPackage.exists?(parent: work_package) + end end diff --git a/app/services/relations/delete_service.rb b/app/services/relations/delete_service.rb index 51074fda7e7f..6d89133a4abf 100644 --- a/app/services/relations/delete_service.rb +++ b/app/services/relations/delete_service.rb @@ -26,4 +26,32 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Relations::DeleteService < BaseServices::Delete; end +class Relations::DeleteService < BaseServices::Delete + def after_perform(_result) + result = super + if result.success? && successor_must_switch_to_manual_mode? + deleted_relation.successor.update(schedule_manually: true) + end + result + end + + private + + def deleted_relation + model + end + + def successor_must_switch_to_manual_mode? + deleted_relation.follows? \ + && successor_has_dates? \ + && was_last_relation_to_the_successor? + end + + def successor_has_dates? + deleted_relation.successor.start_date.present? || deleted_relation.successor.due_date.present? + end + + def was_last_relation_to_the_successor? + Relation.follows.of_successor(deleted_relation.successor).none? + end +end diff --git a/app/services/work_packages/schedule_dependency.rb b/app/services/work_packages/schedule_dependency.rb index ed6976377a0b..a87980fd37f5 100644 --- a/app/services/work_packages/schedule_dependency.rb +++ b/app/services/work_packages/schedule_dependency.rb @@ -41,10 +41,11 @@ # package, but are necessary to accurately determine the new start and due # dates of the moving work packages. class WorkPackages::ScheduleDependency - attr_accessor :dependencies + attr_accessor :dependencies, :switching_to_automatic_mode - def initialize(moved_work_packages) + def initialize(moved_work_packages, switching_to_automatic_mode: []) self.moved_work_packages = Array(moved_work_packages) + self.switching_to_automatic_mode = Array(switching_to_automatic_mode) preload_scheduling_data @@ -136,7 +137,7 @@ def create_dependencies def moving_work_packages @moving_work_packages ||= WorkPackage - .for_scheduling(moved_work_packages) + .for_scheduling(moved_work_packages, switching_to_automatic_mode:) end # All work packages preloaded during initialization. @@ -166,6 +167,8 @@ def preload_scheduling_data # rehydrate the predecessors and followers of follows relations rehydrate_follows_relations + + fix_switching_to_automatic_mode_work_packages end # Returns all the descendants of moved and moving work packages that are not @@ -207,4 +210,13 @@ def rehydrate_follows_relations relation.to = work_package_by_id(relation.to_id) end end + + def fix_switching_to_automatic_mode_work_packages + ids = switching_to_automatic_mode.map(&:id) + known_work_packages.each do |work_package| + if ids.include?(work_package.id) + work_package.schedule_manually = false + end + end + end end diff --git a/app/services/work_packages/set_schedule_service.rb b/app/services/work_packages/set_schedule_service.rb index 8969656962d4..a6b2108c4900 100644 --- a/app/services/work_packages/set_schedule_service.rb +++ b/app/services/work_packages/set_schedule_service.rb @@ -27,12 +27,13 @@ #++ class WorkPackages::SetScheduleService - attr_accessor :user, :work_packages, :initiated_by + attr_accessor :user, :work_packages, :initiated_by, :switching_to_automatic_mode - def initialize(user:, work_package:, initiated_by: nil) + def initialize(user:, work_package:, initiated_by: nil, switching_to_automatic_mode: []) self.user = user self.work_packages = Array(work_package) self.initiated_by = initiated_by + self.switching_to_automatic_mode = switching_to_automatic_mode end def call(changed_attributes = %i(start_date due_date)) @@ -95,7 +96,7 @@ def schedule_by_parent def schedule_following altered = [] - WorkPackages::ScheduleDependency.new(work_packages).in_schedule_order do |scheduled, dependency| + WorkPackages::ScheduleDependency.new(work_packages, switching_to_automatic_mode:).in_schedule_order do |scheduled, dependency| reschedule(scheduled, dependency) altered << scheduled if scheduled.changed? diff --git a/spec/services/relations/scheduling_mode_switching_spec.rb b/spec/services/relations/scheduling_mode_switching_spec.rb new file mode 100644 index 000000000000..821bb3f8d624 --- /dev/null +++ b/spec/services/relations/scheduling_mode_switching_spec.rb @@ -0,0 +1,331 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" + +RSpec.describe "Scheduling mode switching", # rubocop:disable RSpec/DescribeClass + with_settings: { journal_aggregation_time_minutes: 0 } do + create_shared_association_defaults_for_work_package_factory + + shared_let(:user) { create(:admin) } + + context "when creating a non-follows relation" do + context "with 2 manually scheduled work packages" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + + before do + attributes = { + "relation_type" => "relates", + "from_id" => succ.id, + "to_id" => pred.id + } + Relations::CreateService.new(user:).call(attributes) + end + + it "keeps work package scheduling mode" do + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + expect(pred.journals.count).to eq(1) + expect(succ.journals.count).to eq(1) + end + end + end + + context "when creating a follows relation" do + context "with 2 manually scheduled work packages" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + + before do + attributes = { + "relation_type" => "follows", + "from_id" => succ.id, + "to_id" => pred.id + } + Relations::CreateService.new(user:).call(attributes) + end + + it "switches successor scheduling mode to automatic and reschedules it accordingly" do + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | automatic | + TABLE + expect(pred.journals.count).to eq(1) + expect(succ.journals.count).to eq(2) + end + end + + context "with a precedes relation with 2 manually scheduled work packages" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + + before do + attributes = { + "relation_type" => "precedes", + "from_id" => pred.id, + "to_id" => succ.id + } + Relations::CreateService.new(user:).call(attributes) + end + + it "switches successor scheduling mode to automatic and reschedules it accordingly" do + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | automatic | + TABLE + expect(pred.journals.count).to eq(1) + expect(succ.journals.count).to eq(2) + end + end + + context "with work package being manually scheduled and having an already existing predecessor" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | predecessors + | work package | XX | manual | existing pred + | existing pred | XX | manual | + | another pred | XX | manual | + TABLE + + before do + attributes = { + "relation_type" => "follows", + "from_id" => work_package.id, # successor + "to_id" => another_pred.id # predecessor + } + Relations::CreateService.new(user:).call(attributes) + end + + it "keeps work package scheduling mode (manual) and does not reschedule" do + expect_work_packages_after_reload([work_package, existing_pred, another_pred], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | work package | XX | manual | + | existing pred | XX | manual | + | another pred | XX | manual | + TABLE + expect(work_package.journals.count).to eq(1) + end + end + + context "with work package being manually scheduled and having already at least one child" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | + | work package | XX | manual | + | existing child | XX | manual | + | pred | XX | manual | + TABLE + + before do + attributes = { + "relation_type" => "follows", + "from_id" => work_package.id, # successor + "to_id" => pred.id # predecessor + } + Relations::CreateService.new(user:).call(attributes) + end + + it "keeps work package scheduling mode (manual) and does not reschedule" do + expect_work_packages_after_reload([work_package, existing_child, pred], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | work package | XX | manual | + | existing child | XX | manual | + | pred | XX | manual | + TABLE + expect(work_package.journals.count).to eq(1) + end + end + end + + ## TODO: Add tests for changing the relation type (currently supported by API only) + context "when updating an existing follows relation" do + context "with 2 manually scheduled work packages" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | predecessors + | pred | XX | manual | + | succ | XX | manual | pred + TABLE + + before do + relation = Relation.last + update_attributes = { + "description" => "my description" + } + Relations::UpdateService.new(user:, model: relation).call(update_attributes) + end + + it "keeps work package scheduling mode" do + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + expect(Relation.last.description).to eq("my description") + expect(pred.journals.count).to eq(1) + expect(succ.journals.count).to eq(1) + end + end + end + + context "when deleting a non-follows relation" do + context "with an automatically scheduled successor for which it's the last relation" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | related to + | wp1 | XX | manual | + | wp2 | XX | automatic | wp1 + TABLE + + before do + relation = Relation.last + Relations::DeleteService.new(user:, model: relation).call + end + + it "does not switch work package scheduling mode" do + expect(Relation.count).to eq(0) + expect_work_packages_after_reload([wp1, wp2], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | wp1 | XX | manual | + | wp2 | XX | automatic | + TABLE + expect(wp2.journals.count).to eq(1) + end + end + end + + # TODO: Add the case where two relations exist, one is deleted and the successor needs rescheduling + context "when deleting a follows relation" do + context "with an automatically scheduled successor for which it's the last relation" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | predecessors + | pred | XX | manual | + | succ | XX | automatic | pred + TABLE + + before do + relation = Relation.last + Relations::DeleteService.new(user:, model: relation).call + end + + it "switches work package to manual scheduling mode and keeps the dates" do + expect(Relation.count).to eq(0) + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + TABLE + expect(succ.journals.count).to eq(2) + end + end + + context "with an automatically scheduled successor without any dates for which it's the last relation" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | predecessors + | pred | | manual | + | succ | | automatic | pred + TABLE + + before do + relation = _table.relation(predecessor: "pred", successor: "succ") + Relations::DeleteService.new(user:, model: relation).call + end + + it "keeps work package scheduling mode" do + expect(Relation.count).to eq(0) + expect_work_packages_after_reload([pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | | manual | + | succ | | automatic | + TABLE + expect(succ.journals.count).to eq(1) # no modifications + end + end + + context "with an automatically scheduled successor for which it's not the last relation" do + let_work_packages(<<~TABLE) + | subject | MTWTFSS | scheduling mode | predecessors + | pred | XX | manual | + | another pred | XX | manual | + | succ | XX | automatic | pred, another pred + TABLE + + before do + relation = _table.relation(predecessor: "pred", successor: "succ") + Relations::DeleteService.new(user:, model: relation).call + end + + it "keeps work package scheduling mode" do + expect(Relation.count).to eq(1) + expect_work_packages_after_reload([pred, another_pred, succ], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | another pred | XX | manual | + | succ | XX | automatic | + TABLE + expect(succ.journals.count).to eq(1) # no modifications + end + end + + context "with an automatically scheduled successor which has at least one child" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | predecessors + | pred | XX | manual | + | succ | XX | automatic | pred + | child | XX | manual | + TABLE + + before do + relation = Relation.last + Relations::DeleteService.new(user:, model: relation).call + end + + it "switches work package to manual scheduling mode and keeps the dates" do + expect(Relation.count).to eq(0) + expect_work_packages_after_reload([pred, succ, child], <<~TABLE) + | subject | MTWTFSS | scheduling mode | + | pred | XX | manual | + | succ | XX | manual | + | child | XX | manual | + TABLE + expect(succ.journals.count).to eq(2) + end + end + end +end diff --git a/spec/support/table_helpers/column.rb b/spec/support/table_helpers/column.rb index ac712a1de6ee..6ef79ca360a8 100644 --- a/spec/support/table_helpers/column.rb +++ b/spec/support/table_helpers/column.rb @@ -35,7 +35,8 @@ require_relative "column_type/duration" require_relative "column_type/hierarchy" require_relative "column_type/percentage" -require_relative "column_type/predecessors" +require_relative "column_type/predecessor_relations" +require_relative "column_type/related_to_relations" require_relative "column_type/schedule" require_relative "column_type/scheduling_mode" require_relative "column_type/status" @@ -54,7 +55,8 @@ class Column derived_done_ratio: ColumnType::Percentage, hierarchy: ColumnType::Hierarchy, ignore_non_working_days: ColumnType::DaysCounting, - predecessors: ColumnType::Predecessors, + predecessor_relations: ColumnType::PredecessorRelations, + related_to_relations: ColumnType::RelatedToRelations, schedule: ColumnType::Schedule, schedule_manually: ColumnType::SchedulingMode, status: ColumnType::Status, @@ -97,7 +99,9 @@ def self.attribute_for(header) when /\s*scheduling mode\s*/ :schedule_manually when /\s*predecessors\s*/ - :predecessors + :predecessor_relations + when /\s*relate[ds][ _]to\s*/ + :related_to_relations when /status/, /hierarchy/ to_identifier(header) else diff --git a/spec/support/table_helpers/column_type/predecessors.rb b/spec/support/table_helpers/column_type/predecessor_relations.rb similarity index 85% rename from spec/support/table_helpers/column_type/predecessors.rb rename to spec/support/table_helpers/column_type/predecessor_relations.rb index 24ef01cd9435..a81e249fbd25 100644 --- a/spec/support/table_helpers/column_type/predecessors.rb +++ b/spec/support/table_helpers/column_type/predecessor_relations.rb @@ -48,7 +48,7 @@ module ColumnType # # Adapted from (now deleted) original implementation # in `spec/support/schedule_helpers/chart_builder.rb`. - class Predecessors < Generic + class PredecessorRelations < Generic def attributes_for_work_package(_attribute, _work_package) {} end @@ -60,31 +60,20 @@ def extract_data(_attribute, raw_header, work_package_data, _work_packages_data) end def parse_predecessors(predecessors) - predecessors.reduce({}) do |data, predecessor| - case parse_predecessor(predecessor) - in {relations: relation} - data[:relations] ||= [] - data[:relations] << relation - end - data + relations = predecessors.map do |predecessor| + parse_predecessor(predecessor) end - end - - def relations_for_raw_value(raw_value) - raw_value.split - {} + { relations: }.compact_blank end def parse_predecessor(predecessor) case predecessor when /^(?:follows)?\s*(.+?)(?: with lag (\d+))?\s*$/ { - relations: { - raw: predecessor, - type: :follows, - predecessor: $1, - lag: $2.to_i - } + raw: predecessor, + type: :follows, + with: $1, + lag: $2.to_i } else spell_checker = DidYouMean::SpellChecker.new( diff --git a/spec/support/table_helpers/column_type/related_to_relations.rb b/spec/support/table_helpers/column_type/related_to_relations.rb new file mode 100644 index 000000000000..7c91b03ed6d1 --- /dev/null +++ b/spec/support/table_helpers/column_type/related_to_relations.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 TableHelpers + module ColumnType + # Column to add relates/related to relations to work packages. + # + # Supported texts: + # - :wp + # + # They can be combined by separated them with commas: "wp1, wp2". + # + # Example: + # + # | subject | related to | + # | main | | + # | other one | main | + class RelatedToRelations < Generic + def attributes_for_work_package(_attribute, _work_package) + {} + end + + def extract_data(_attribute, raw_header, work_package_data, _work_packages_data) + relations = + work_package_data.dig(:row, raw_header) + .split(",") + .map(&:strip) + .compact_blank + .map { |name| make_related_to_relation(name) } + { relations: }.compact_blank + end + + def make_related_to_relation(name) + { + raw: name, + type: :relates, + with: name + } + end + end + end +end diff --git a/spec/support/table_helpers/table.rb b/spec/support/table_helpers/table.rb index d31a1e0debba..aab27841cd6a 100644 --- a/spec/support/table_helpers/table.rb +++ b/spec/support/table_helpers/table.rb @@ -42,8 +42,23 @@ def work_packages @work_packages_by_identifier.values end - def relation(successor:) - @relations.find { |relation| relation.follows? && relation.from.subject == successor } + # Finds a relation by its predecessor and/or successor. + # + # Example: + # + # relation(successor: "succ") + # + # will return the first created follows/precedes relation having the successor with subject "succ". + # + # @param predecessor [String, nil] the predecessor's subject name + # @param successor [String, nil] the successor's subjectname + # @return [Relation, nil] the relation or nil if no relation matches + def relation(predecessor: nil, successor: nil) + @relations.find do |relation| + relation.follows? \ + && (predecessor.nil? || relation.predecessor.subject == predecessor) \ + && (successor.nil? || relation.successor.subject == successor) + end end def relations diff --git a/spec/support/table_helpers/table_data.rb b/spec/support/table_helpers/table_data.rb index 2885dba4e48b..b02e13bdce70 100644 --- a/spec/support/table_helpers/table_data.rb +++ b/spec/support/table_helpers/table_data.rb @@ -109,7 +109,7 @@ def create end # create relations only after having created all work packages table_data.work_package_identifiers.each do |identifier| # rubocop:disable Style/CombinableLoops - create_follows_relations(identifier) + create_relations(identifier) end [work_packages_by_identifier, relations] end @@ -125,15 +125,17 @@ def create_work_package(identifier) end end - def create_follows_relations(identifier) + def create_relations(identifier) work_package_relations(identifier).each do |relation| - predecessor = find_work_package_by_name(relation[:predecessor]) - follower = work_packages_by_identifier[identifier] + to = find_work_package_by_name(relation[:with]) + from = work_packages_by_identifier[identifier] + extra_attributes = { lag: relation[:lag] }.compact relations << FactoryBot.create( - :follows_relation, - from: follower, - to: predecessor, - lag: relation[:lag] + :relation, + relation_type: relation[:type], + from:, + to:, + **extra_attributes ) end end diff --git a/spec/support_spec/table_helpers/column_type/predecessors_spec.rb b/spec/support_spec/table_helpers/column_type/predecessor_relations_spec.rb similarity index 80% rename from spec/support_spec/table_helpers/column_type/predecessors_spec.rb rename to spec/support_spec/table_helpers/column_type/predecessor_relations_spec.rb index b62f486b85f1..376479aca199 100644 --- a/spec/support_spec/table_helpers/column_type/predecessors_spec.rb +++ b/spec/support_spec/table_helpers/column_type/predecessor_relations_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" module TableHelpers::ColumnType - RSpec.describe Predecessors do + RSpec.describe PredecessorRelations do subject(:column_type) { described_class.new } def parsed_data(table) @@ -65,8 +65,8 @@ def parsed_data(table) TABLE expect(work_package_data) .to eq([ - [{ raw: "follows main with lag 3", type: :follows, predecessor: "main", lag: 3 }], - [{ raw: "main with lag 3", type: :follows, predecessor: "main", lag: 3 }] + [{ raw: "follows main with lag 3", type: :follows, with: "main", lag: 3 }], + [{ raw: "main with lag 3", type: :follows, with: "main", lag: 3 }] ]) end @@ -78,8 +78,8 @@ def parsed_data(table) TABLE expect(work_package_data) .to eq([ - [{ raw: "follows main", type: :follows, predecessor: "main", lag: 0 }], - [{ raw: "main", type: :follows, predecessor: "main", lag: 0 }] + [{ raw: "follows main", type: :follows, with: "main", lag: 0 }], + [{ raw: "main", type: :follows, with: "main", lag: 0 }] ]) end @@ -92,13 +92,13 @@ def parsed_data(table) expect(work_package_data) .to eq([ [ - { raw: "follows wp1", type: :follows, predecessor: "wp1", lag: 0 }, - { raw: "follows wp2", type: :follows, predecessor: "wp2", lag: 0 } + { raw: "follows wp1", type: :follows, with: "wp1", lag: 0 }, + { raw: "follows wp2", type: :follows, with: "wp2", lag: 0 } ], [ - { raw: "follows wp1", type: :follows, predecessor: "wp1", lag: 0 }, - { raw: "wp2", type: :follows, predecessor: "wp2", lag: 0 }, - { raw: "wp3", type: :follows, predecessor: "wp3", lag: 0 } + { raw: "follows wp1", type: :follows, with: "wp1", lag: 0 }, + { raw: "wp2", type: :follows, with: "wp2", lag: 0 }, + { raw: "wp3", type: :follows, with: "wp3", lag: 0 } ] ]) end diff --git a/spec/support_spec/table_helpers/column_type/related_to_relations_spec.rb b/spec/support_spec/table_helpers/column_type/related_to_relations_spec.rb new file mode 100644 index 000000000000..7a3109329204 --- /dev/null +++ b/spec/support_spec/table_helpers/column_type/related_to_relations_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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" + +module TableHelpers::ColumnType + RSpec.describe RelatedToRelations do + subject(:column_type) { described_class.new } + + def parsed_data(table) + TableHelpers::TableParser.new.parse(table) + end + + describe "empty" do + it "stores nothing when empty" do + work_package_data = parsed_data(<<~TABLE).first + | related to | + | | + TABLE + expect(work_package_data[:relations]).to be_nil + expect(work_package_data[:attributes]).to be_empty + + work_package_data = parsed_data(<<~TABLE).first + | relates to + | + TABLE + expect(work_package_data[:relations]).to be_nil + expect(work_package_data[:attributes]).to be_empty + end + end + + describe "" do + it "stores related_to relations in work_package_data" do + work_package_data = parsed_data(<<~TABLE).pluck(:relations) + | related to | + | main | + TABLE + expect(work_package_data) + .to eq([ + [{ raw: "main", type: :relates, with: "main" }] + ]) + end + + it "can store multiple relations" do + work_package_data = parsed_data(<<~TABLE).pluck(:relations) + | related to | + | wp1, wp2, wp3 | + TABLE + expect(work_package_data) + .to eq([ + [ + { raw: "wp1", type: :relates, with: "wp1" }, + { raw: "wp2", type: :relates, with: "wp2" }, + { raw: "wp3", type: :relates, with: "wp3" } + ] + ]) + end + end + end +end diff --git a/spec/support_spec/table_helpers/table_data_spec.rb b/spec/support_spec/table_helpers/table_data_spec.rb index 680153fc6fde..581437a8f689 100644 --- a/spec/support_spec/table_helpers/table_data_spec.rb +++ b/spec/support_spec/table_helpers/table_data_spec.rb @@ -159,7 +159,7 @@ module TableHelpers ) end - it "creates relations between work packages out of the table data" do + it "creates 'follows' relations between work packages out of the table data" do table_representation = <<~TABLE subject | predecessors main | @@ -176,6 +176,23 @@ module TableHelpers expect(follower.follows_relations.first.lag).to eq(2) end + it "creates 'relates' relations between work packages out of the table data" do + table_representation = <<~TABLE + subject | related to + main | + other | main + TABLE + + table_data = described_class.for(table_representation) + table = table_data.create_work_packages + expect(table.work_packages.count).to eq(2) + main = table.work_package(:main) + other = table.work_package(:other) + expect(other.relations.relates.count).to eq(1) + expect(other.relations.relates.first.to).to eq(main) + expect(other.relations.relates.first.lag).to be_nil + end + it "raises an error if a given status name does not exist" do table_representation = <<~TABLE subject | status | From 93703e73b3fbb3a58066414bbf49c7740a1e8cc5 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 17 Dec 2024 18:30:54 +0100 Subject: [PATCH 18/33] Fix unit tests broken by previous commit --- spec/services/relations/create_service_spec.rb | 4 +++- spec/services/relations/update_service_spec.rb | 4 +++- spec/services/work_packages/set_schedule_service_spec.rb | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/services/relations/create_service_spec.rb b/spec/services/relations/create_service_spec.rb index 9310cacfa23a..2c349e97848d 100644 --- a/spec/services/relations/create_service_spec.rb +++ b/spec/services/relations/create_service_spec.rb @@ -39,11 +39,13 @@ let(:work_package1) do build_stubbed(:work_package, + subject: "work_package1", due_date: work_package1_due_date, start_date: work_package1_start_date) end let(:work_package2) do build_stubbed(:work_package, + subject: "work_package2", due_date: work_package2_due_date, start_date: work_package2_start_date) end @@ -111,7 +113,7 @@ before do expect(WorkPackages::SetScheduleService) .to receive(:new) - .with(user:, work_package: work_package1) + .with(user:, work_package: work_package1, switching_to_automatic_mode: [work_package2]) .and_return(set_schedule_service) expect(set_schedule_service) diff --git a/spec/services/relations/update_service_spec.rb b/spec/services/relations/update_service_spec.rb index 6006b5e9a2be..6f6c6b53e49d 100644 --- a/spec/services/relations/update_service_spec.rb +++ b/spec/services/relations/update_service_spec.rb @@ -39,11 +39,13 @@ let(:work_package1) do build_stubbed(:work_package, + subject: "work_package1", due_date: work_package1_due_date, start_date: work_package1_start_date) end let(:work_package2) do build_stubbed(:work_package, + subject: "work_package2", due_date: work_package2_due_date, start_date: work_package2_start_date) end @@ -107,7 +109,7 @@ before do expect(WorkPackages::SetScheduleService) .to receive(:new) - .with(user:, work_package: work_package1) + .with(user:, work_package: work_package1, switching_to_automatic_mode: []) .and_return(set_schedule_service) expect(set_schedule_service) diff --git a/spec/services/work_packages/set_schedule_service_spec.rb b/spec/services/work_packages/set_schedule_service_spec.rb index 7ddf7e61492b..453f804db727 100644 --- a/spec/services/work_packages/set_schedule_service_spec.rb +++ b/spec/services/work_packages/set_schedule_service_spec.rb @@ -529,9 +529,9 @@ def create_child(parent, start_date, due_date, **attributes) before do allow(WorkPackage) .to receive(:for_scheduling) - .and_wrap_original do |method, *args| + .and_wrap_original do |method, *args, **kwargs| wanted_order = [sibling_follower_of_work_package, follower_of_parent_work_package, parent_work_package] - method.call(*args).in_order_of(:id, wanted_order.map(&:id)) + method.call(*args, **kwargs).in_order_of(:id, wanted_order.map(&:id)) end end From 969cf23a1f1c4c1fc3ac0a056c1f7c37ddb36964 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 24 Dec 2024 10:47:31 +0100 Subject: [PATCH 19/33] refactor: rewrite with positive assertions --- app/contracts/work_packages/base_contract.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 552d27bc9c2f..041814757852 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -101,19 +101,19 @@ class BaseContract < ::ModelContract attribute :schedule_manually attribute :ignore_non_working_days, writable: ->(*) { - !automatically_scheduled_parent? + leaf_or_manually_scheduled? } attribute :start_date, writable: ->(*) { - !automatically_scheduled_parent? + leaf_or_manually_scheduled? } do validate_after_soonest_start(:start_date) end attribute :due_date, writable: ->(*) { - !automatically_scheduled_parent? + leaf_or_manually_scheduled? } do validate_after_soonest_start(:due_date) end @@ -627,8 +627,8 @@ def calculated_duration @calculated_duration ||= WorkPackages::Shared::Days.for(model).duration(model.start_date, model.due_date) end - def automatically_scheduled_parent? - !model.leaf? && !model.schedule_manually? + def leaf_or_manually_scheduled? + model.leaf? || model.schedule_manually? end end end From faee36145aa8193cade7257072aada7f76bb2a20 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 24 Dec 2024 12:13:40 +0100 Subject: [PATCH 20/33] table_helpers: Fix issue with empty tables --- .../table_helpers/table_representer.rb | 2 +- .../table_helpers/table_representer_spec.rb | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/support/table_helpers/table_representer.rb b/spec/support/table_helpers/table_representer.rb index aa6171ecbe8c..e14dab904475 100644 --- a/spec/support/table_helpers/table_representer.rb +++ b/spec/support/table_helpers/table_representer.rb @@ -102,7 +102,7 @@ def column_widths else values = tables_data.flat_map { _1.values_for_attribute(column.attribute) } values_max_size = values.map { column.format(_1).size }.max - [column.title.size, values_max_size].max + [column.title.size, values_max_size].compact.max end end end diff --git a/spec/support_spec/table_helpers/table_representer_spec.rb b/spec/support_spec/table_helpers/table_representer_spec.rb index 26fe75ba5941..bf979773eb8d 100644 --- a/spec/support_spec/table_helpers/table_representer_spec.rb +++ b/spec/support_spec/table_helpers/table_representer_spec.rb @@ -68,6 +68,19 @@ module TableHelpers end end + context "when there are no work packages" do + let(:table_data) do + TableData.from_work_packages([], columns) + end + let(:columns) { [Column.for("subject")] } + + it "renders no rows" do + expect(representer.render(table_data)).to eq <<~TABLE + | subject | + TABLE + end + end + describe "subject column" do let(:columns) { [Column.for("subject")] } @@ -131,6 +144,18 @@ module TableHelpers TABLE end + context "when there are no work packages" do + let(:table_data) do + TableData.from_work_packages([], columns) + end + + it "renders no rows" do + expect(representer.render(table_data)).to eq <<~TABLE + | MTWTFSS | + TABLE + end + end + context "when non working days are defined" do let(:table) do <<~TABLE From e248f18af10f407ff58e2b97a157af0805fa290f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 24 Dec 2024 12:25:34 +0100 Subject: [PATCH 21/33] refactor: make test output and code less verbose And I spotted one wrong test that will need to be updated. --- .../set_attributes_service_spec.rb | 90 ++++++++++--------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/spec/services/work_packages/set_attributes_service_spec.rb b/spec/services/work_packages/set_attributes_service_spec.rb index 11ce2f68b01f..94ff6cd35007 100644 --- a/spec/services/work_packages/set_attributes_service_spec.rb +++ b/spec/services/work_packages/set_attributes_service_spec.rb @@ -47,7 +47,7 @@ end let(:work_package) do - wp = build_stubbed(:work_package, project:, status: status_0_pct_complete) + wp = build_stubbed(:work_package, project:, subject: "work_package", status: status_0_pct_complete) wp.type = initial_type wp.clear_changes_information @@ -1784,12 +1784,12 @@ context "when the soonest start date is later than the current start date" do let(:soonest_start) { Time.zone.today + 3.days } - it_behaves_like "service call" do - it "sets the start date to the soonest possible start date" do - subject - - expect(work_package.start_date).to eql(Time.zone.today + 3.days) - expect(work_package.due_date).to eql(Time.zone.today + 8.days) + include_examples "service call", description: "sets the start date to the soonest possible start date" do + let(:expected_attributes) do + { + start_date: Time.zone.today + 3.days, + due_date: Time.zone.today + 8.days + } end end end @@ -1797,12 +1797,12 @@ context "when the soonest start date is earlier than the current start date" do let(:soonest_start) { Time.zone.today - 3.days } - it_behaves_like "service call" do - it "sets the start date to the soonest possible start date" do - subject - - expect(work_package.start_date).to eql(Time.zone.today - 3.days) - expect(work_package.due_date).to eql(Time.zone.today + 2.days) + include_examples "service call", description: "sets the start date to the soonest possible start date" do + let(:expected_attributes) do + { + start_date: Time.zone.today - 3.days, + due_date: Time.zone.today + 2.days + } end end end @@ -1817,14 +1817,13 @@ work_package.ignore_non_working_days = false end - it_behaves_like "service call" do - it "sets the start date to the soonest possible start date being a working day" do - subject - - expect(work_package).to have_attributes( + include_examples "service call", + description: "sets the start date to the soonest possible start date being a working day" do + let(:expected_attributes) do + { start_date: next_monday, due_date: next_monday + 7.days - ) + } end end end @@ -1832,12 +1831,12 @@ context "when the soonest start date is before the current start date" do let(:soonest_start) { Time.zone.today - 3.days } - it_behaves_like "service call" do - it "sets the start date to the soonest possible start date" do - subject - - expect(work_package.start_date).to eql(soonest_start) - expect(work_package.due_date).to eql(Time.zone.today + 2.days) + include_examples "service call", description: "sets the start date to the soonest possible start date" do + let(:expected_attributes) do + { + start_date: soonest_start, + due_date: Time.zone.today + 2.days + } end end end @@ -1845,12 +1844,12 @@ context "when the soonest start date is nil" do let(:soonest_start) { nil } - it_behaves_like "service call" do - it "sets the start date to the soonest possible start date" do - subject - - expect(work_package.start_date).to eql(Time.zone.today) - expect(work_package.due_date).to eql(Time.zone.today + 5.days) + include_examples "service call", description: "sets the start date to the soonest possible start date" do + let(:expected_attributes) do + { + start_date: Time.zone.today, + due_date: Time.zone.today + 5.days + } end end end @@ -1858,10 +1857,10 @@ context "when the work package also has a child" do let(:child) do build_stubbed(:work_package, + subject: "child", start_date: child_start_date, due_date: child_due_date) end - let(:child_start_date) { Time.zone.today + 2.days } let(:child_due_date) { Time.zone.today + 10.days } before do @@ -1871,25 +1870,30 @@ end context "when the child's start date is after soonest_start" do - it_behaves_like "service call" do - it "sets the dates to the child dates" do - subject + let(:child_start_date) { Time.zone.today + 2.days } + let(:soonest_start) { Time.zone.today + 1.day } - expect(work_package.start_date).to eql(Time.zone.today + 2.days) - expect(work_package.due_date).to eql(Time.zone.today + 10.days) + include_examples "service call", description: "sets the dates to the child dates" do + let(:expected_attributes) do + { + start_date: Time.zone.today + 2.days, + due_date: Time.zone.today + 10.days + } end end end context "when the child's start date is before soonest_start" do + let(:child_start_date) { Time.zone.today + 2.days } let(:soonest_start) { Time.zone.today + 3.days } - it_behaves_like "service call" do - it "sets the dates to soonest date and to the duration of the child" do - subject - - expect(work_package.start_date).to eql(Time.zone.today + 3.days) - expect(work_package.due_date).to eql(Time.zone.today + 11.days) + # TODO: Update this: this is no longer true. Now a parent always inherits dates from its children. + include_examples "service call", description: "sets the dates to soonest date and to the duration of the child" do + let(:expected_attributes) do + { + start_date: Time.zone.today + 3.days, + due_date: Time.zone.today + 11.days + } end end end From 7d6487a56099a3e44eff7c4419dcb0387bc44f39 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 6 Jan 2025 15:39:45 +0100 Subject: [PATCH 22/33] Add missing magic comment `# frozen_string_literal: true` --- app/workers/work_packages/automatic_mode/migrate_values_job.rb | 2 ++ db/migrate/20241120095318_update_scheduling_mode_and_lags.rb | 2 ++ spec/migrations/update_scheduling_mode_and_lags_spec.rb | 2 ++ spec/services/relations/scheduling_mode_switching_spec.rb | 2 ++ 4 files changed, 8 insertions(+) diff --git a/app/workers/work_packages/automatic_mode/migrate_values_job.rb b/app/workers/work_packages/automatic_mode/migrate_values_job.rb index 0fee67054806..0b87555c834b 100644 --- a/app/workers/work_packages/automatic_mode/migrate_values_job.rb +++ b/app/workers/work_packages/automatic_mode/migrate_values_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb index 1023745ccf7b..bd1aa5378ba5 100644 --- a/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb +++ b/db/migrate/20241120095318_update_scheduling_mode_and_lags.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UpdateSchedulingModeAndLags < ActiveRecord::Migration[7.1] def up change_column_default :work_packages, :schedule_manually, from: false, to: true diff --git a/spec/migrations/update_scheduling_mode_and_lags_spec.rb b/spec/migrations/update_scheduling_mode_and_lags_spec.rb index e9aec2586b66..693058f25571 100644 --- a/spec/migrations/update_scheduling_mode_and_lags_spec.rb +++ b/spec/migrations/update_scheduling_mode_and_lags_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/spec/services/relations/scheduling_mode_switching_spec.rb b/spec/services/relations/scheduling_mode_switching_spec.rb index 821bb3f8d624..85a02f5f3dfd 100644 --- a/spec/services/relations/scheduling_mode_switching_spec.rb +++ b/spec/services/relations/scheduling_mode_switching_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH From c21bb7529b24563fa7393122d5269722d490be27 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 6 Jan 2025 15:49:33 +0100 Subject: [PATCH 23/33] Move some tests at the `UpdateService` integration test level Setting attributes like `parent` and then expecting its own dates to be updated is more an `UpdateService` concern. The fact that it is handled in a `SetAttributesService` and `SetScheduleService` to update its parent's dates is an implementation detail. This should allow refactoring the `SetScheduleService` and `SetAttributesService` to handle dates assignment only in the SetAttributesService. --- .../set_schedule_service_working_days_spec.rb | 115 ------------------ .../update_service_integration_spec.rb | 100 +++++++++++++++ 2 files changed, 100 insertions(+), 115 deletions(-) diff --git a/spec/services/work_packages/set_schedule_service_working_days_spec.rb b/spec/services/work_packages/set_schedule_service_working_days_spec.rb index b51ebe3fded1..bba6a500b6cb 100644 --- a/spec/services/work_packages/set_schedule_service_working_days_spec.rb +++ b/spec/services/work_packages/set_schedule_service_working_days_spec.rb @@ -1173,119 +1173,4 @@ end end end - - context "when setting the parent" do - let(:changed_attributes) { [:parent] } - - context "without dates and with the parent being restricted in its ability to be moved" do - let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | predecessors - work_package | | manual | - new_parent_predecessor | X | manual | - new_parent | | automatic |follows new_parent_predecessor with lag 3 - TABLE - - before do - work_package.parent = new_parent - work_package.save - end - - it "schedules parent to start and end at soonest working start date and the child to start at the parent start" do - expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | [ | - new_parent | X | - TABLE - end - end - - context "without dates, with a duration and with the parent being restricted in its ability to be moved" do - let_work_packages(<<~TABLE) - subject | MTWTFSS | duration | scheduling mode | predecessors - work_package | | 4 | manual | - new_parent_predecessor | X | | manual | - new_parent | | | automatic | follows new_parent_predecessor with lag 3 - TABLE - - before do - work_package.parent = new_parent - work_package.save - end - - it "schedules the moved work package to start at the parent soonest date and sets due date to keep the same duration " \ - "and schedules the parent dates to match the child dates" do - expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | XXXX | - new_parent | XXXX | - TABLE - end - end - - context "with the parent being restricted in its ability to be moved and with a due date before parent constraint" do - let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | predecessors - work_package | ] | manual | - new_parent_predecessor | X | manual | - new_parent | | automatic | follows new_parent_predecessor with lag 3 - TABLE - - before do - work_package.parent = new_parent - work_package.save - end - - it "schedules the moved work package to start and end at the parent soonest working start date" do - expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | X | - new_parent | X | - TABLE - end - end - - context "with the parent being restricted in its ability to be moved and with a due date after parent constraint" do - let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | predecessors - work_package | ] | manual | - new_parent_predecessor | X | manual | - new_parent | | automatic | follows new_parent_predecessor with lag 3 - TABLE - - before do - work_package.parent = new_parent - work_package.save - end - - it "schedules the moved work package to start at the parent soonest working start date and keep the due date" do - expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | X..XX | - new_parent | X..XX | - TABLE - end - end - - context "with the parent being restricted but work package already has both dates set" do - let_work_packages(<<~TABLE) - subject | MTWTFSS | scheduling mode | predecessors - work_package | XX | manual | - new_parent_predecessor | X | manual | - new_parent | | automatic | follows new_parent_predecessor with lag 3 - TABLE - - before do - work_package.parent = new_parent - work_package.save - end - - it "does not reschedule the moved work package, and sets new parent dates to child dates" do - expect_work_packages(subject.all_results, <<~TABLE) - subject | MTWTFSS | - work_package | XX | - new_parent | XX | - TABLE - end - end - end end diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index 4335adb28fb9..69708183261b 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -972,6 +972,106 @@ end end + context "when setting the parent" do + let(:attributes) { { parent: new_parent } } + + before do + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type) + set_factory_default(:user, user) + + set_non_working_week_days("saturday", "sunday") + end + + context "without dates and with the parent being restricted in its ability to be moved" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + work_package | | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE + + it "schedules parent to start and end at soonest working start date and the child to start at the parent start" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | [ | + new_parent | X | + TABLE + end + end + + context "without dates, with a duration and with the parent being restricted in its ability to be moved" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | duration | scheduling mode | predecessors + work_package | | 4 | manual | + new_parent_predecessor | X | | manual | + new_parent | | | automatic | follows new_parent_predecessor with lag 3 + TABLE + + it "schedules the moved work package to start at the parent soonest date and sets due date to keep the same duration " \ + "and schedules the parent dates to match the child dates" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | XXXX | + new_parent | XXXX | + TABLE + end + end + + context "with the parent being restricted in its ability to be moved and with a due date before parent constraint" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + work_package | ] | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE + + it "schedules the moved work package to start and end at the parent soonest working start date" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | X | + new_parent | X | + TABLE + end + end + + context "with the parent being restricted in its ability to be moved and with a due date after parent constraint" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + work_package | ] | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE + + it "schedules the moved work package to start at the parent soonest working start date and keep the due date" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | X..XX | + new_parent | X..XX | + TABLE + end + end + + context "with the parent being restricted but work package already has both dates set" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + work_package | XX | manual | + new_parent_predecessor | X | manual | + new_parent | | automatic | follows new_parent_predecessor with lag 3 + TABLE + + it "does not reschedule the moved work package, and sets new parent dates to child dates" do + expect_work_packages(subject.all_results, <<~TABLE) + subject | MTWTFSS | + work_package | XX | + new_parent | XX | + TABLE + end + end + end + describe "changing the parent with the parent having a predecessor restricting it moving to an earlier date" do # there is actually some time between the new parent and its predecessor let(:new_parent_attributes) do From 94661a2e5e46313151bec397afb778af6044647b Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 7 Jan 2025 09:37:13 +0100 Subject: [PATCH 24/33] refactor: rewrite test using schedule helpers --- .../update_service_integration_spec.rb | 149 ++++++------------ 1 file changed, 45 insertions(+), 104 deletions(-) diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index 69708183261b..071d8b6bde57 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -1072,122 +1072,63 @@ end end - describe "changing the parent with the parent having a predecessor restricting it moving to an earlier date" do - # there is actually some time between the new parent and its predecessor - let(:new_parent_attributes) do - work_package_attributes.merge( - subject: "new parent", - parent: nil, - schedule_manually: false, - start_date: Time.zone.today + 8.days, - due_date: Time.zone.today + 14.days - ) - end - let(:new_parent_work_package) do - create(:work_package, new_parent_attributes) - end - - let(:new_parent_predecessor_attributes) do - work_package_attributes.merge( - subject: "new parent predecessor", - parent: nil, - start_date: Time.zone.today + 1.day, - due_date: Time.zone.today + 4.days - ) - end - let(:new_parent_predecessor_work_package) do - create(:work_package, new_parent_predecessor_attributes).tap do |wp| - create(:follows_relation, from: new_parent_work_package, to: wp) - end - end - - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - start_date: Time.zone.today, - due_date: Time.zone.today + 3.days } - end - + describe "setting an automatically scheduled parent having a predecessor restricting it moving to an earlier date" do before do - work_package.reload - new_parent_work_package.reload - new_parent_predecessor_work_package.reload + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type) + set_factory_default(:user, user) end - context "when the work package is automatically scheduled" do - let(:attributes) { { parent: new_parent_work_package, schedule_manually: false } } - - it "reschedules the parent and the work package while adhering to the limitation imposed by the predecessor" do - expect(subject) - .to be_success + context "when the work package is automatically scheduled with dates set" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | XXX | automatic | + TABLE + let(:attributes) { { parent: new_parent } } - # sets the parent and adapts the dates - # The dates are overwritten as the new parent is unable - # to move to the dates of its new child because of the follows relation. - work_package.reload - expect(work_package.parent) - .to eq new_parent_work_package - expect(work_package.start_date) - .to eq new_parent_predecessor_attributes[:due_date] + 1.day - expect(work_package.due_date) - .to eq new_parent_predecessor_attributes[:due_date] + 4.days - - # adapts the parent's dates but adheres to its limitations - # due to the follows relationship - new_parent_work_package.reload - expect(new_parent_work_package.start_date) - .to eq new_parent_predecessor_attributes[:due_date] + 1.day - expect(new_parent_work_package.due_date) - .to eq new_parent_predecessor_attributes[:due_date] + 4.days - - # The parent's predecessor is unchanged - new_parent_predecessor_work_package.reload - expect(new_parent_predecessor_work_package.start_date) - .to eq new_parent_predecessor_work_package[:start_date] - expect(new_parent_predecessor_work_package.due_date) - .to eq new_parent_predecessor_work_package[:due_date] - - expect(subject.all_results.uniq) - .to contain_exactly(work_package, new_parent_work_package) + it "reschedules the work package and the parent to start ASAP while being limited by the predecessor" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + + # The work_package dates are moved to the parent's soonest start date. + # The parent dates are the same as its child. + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | XXX | automatic + work_package | XXX | automatic + TABLE end end - context "when the work package is manually scheduled" do - let(:attributes) { { parent: new_parent_work_package, schedule_manually: true } } + context "when the work package is manually scheduled with dates set" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | XXX | manual | + TABLE + let(:attributes) { { parent: new_parent } } it "sets parent's dates to be the same as the work package despite the predecessor constraints" do - expect(subject) - .to be_success - - # sets the parent and do not change the dates as it is manually scheduled - work_package.reload - expect(work_package.parent) - .to eq new_parent_work_package - expect(work_package.start_date) - .to eq work_package_attributes[:start_date] - expect(work_package.due_date) - .to eq work_package_attributes[:due_date] + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + # The work_package dates are not changed as it's manually scheduled. # The parent dates are the same as its child. The follows relation is # ignored as children dates always take precedence over relations. - new_parent_work_package.reload - expect(new_parent_work_package.start_date) - .to eq work_package_attributes[:start_date] - expect(new_parent_work_package.due_date) - .to eq work_package_attributes[:due_date] - - # The parent's predecessor is unchanged - new_parent_predecessor_work_package.reload - expect(new_parent_predecessor_work_package.start_date) - .to eq new_parent_predecessor_work_package[:start_date] - expect(new_parent_predecessor_work_package.due_date) - .to eq new_parent_predecessor_work_package[:due_date] - - expect(subject.all_results.uniq) - .to contain_exactly(work_package, new_parent_work_package) + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | XXX | automatic + work_package | XXX | manual + TABLE end end end From 4115cf4b756cf0fd24a29d6a9089c101d3a6738e Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 7 Jan 2025 10:47:01 +0100 Subject: [PATCH 25/33] refactor: replace condition term with guard clause --- app/contracts/work_packages/base_contract.rb | 4 +++- app/services/work_packages/set_attributes_service.rb | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 041814757852..5826c4fa9268 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -227,7 +227,9 @@ def assignable_assignees attr_reader :can def validate_after_soonest_start(date_attribute) - if !model.schedule_manually? && before_soonest_start?(date_attribute) + return if model.schedule_manually? + + if before_soonest_start?(date_attribute) message = I18n.t("activerecord.errors.models.work_package.attributes.start_date.violates_relationships", soonest_start: model.soonest_start) diff --git a/app/services/work_packages/set_attributes_service.rb b/app/services/work_packages/set_attributes_service.rb index b7fe5d9312f8..9fcdd5efccec 100644 --- a/app/services/work_packages/set_attributes_service.rb +++ b/app/services/work_packages/set_attributes_service.rb @@ -362,9 +362,11 @@ def initialize_unset_custom_values end def new_start_date + return if work_package.schedule_manually? + current_start_date = work_package.start_date || work_package.due_date - return unless current_start_date && work_package.schedule_automatically? + return if current_start_date.nil? min_start = new_start_date_from_parent || new_start_date_from_self min_start = days.soonest_working_day(min_start) From 3414286a032b5de2406e6a34ba97e6595b78b822 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 7 Jan 2025 10:57:58 +0100 Subject: [PATCH 26/33] Make test assertion clearer --- .../set_attributes_service_spec.rb | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/spec/services/work_packages/set_attributes_service_spec.rb b/spec/services/work_packages/set_attributes_service_spec.rb index 94ff6cd35007..2c6769487a11 100644 --- a/spec/services/work_packages/set_attributes_service_spec.rb +++ b/spec/services/work_packages/set_attributes_service_spec.rb @@ -853,8 +853,48 @@ end end - context "with start date changed" do - let(:work_package) { build_stubbed(:work_package, start_date: Time.zone.today, due_date: Time.zone.today + 5.days) } + context "with start date changed on a manually scheduled work package" do + let(:work_package) do + build_stubbed(:work_package, schedule_manually: true, + start_date: Time.zone.today, + due_date: Time.zone.today + 5.days) + end + let(:call_attributes) { { start_date: Time.zone.today + 1.day } } + let(:expected_attributes) { {} } + + it_behaves_like "service call" do + it "sets the start date value" do + subject + + expect(work_package.start_date) + .to eq(Time.zone.today + 1.day) + end + + it "keeps the due date value" do + subject + + expect(work_package.due_date) + .to eq(Time.zone.today + 5.days) + end + + it "updates the duration" do + subject + + expect(work_package.duration) + .to eq 5 + end + end + end + + # TODO: update this test: on an automatically scheduled work package, the + # start date is set to the soonest start date and cannot be changed. An + # error is to be expected! + context "with start date changed on an automatically scheduled work package" do + let(:work_package) do + build_stubbed(:work_package, schedule_manually: false, + start_date: Time.zone.today, + due_date: Time.zone.today + 5.days) + end let(:call_attributes) { { start_date: Time.zone.today + 1.day } } let(:expected_attributes) { {} } From cd0a62046b8737cbbf713aded9368e9f3deb941e Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 7 Jan 2025 15:48:09 +0100 Subject: [PATCH 27/33] Move dates handling to `SetAttributesService` Instead of doing it in both the `SetScheduleService` and `SetAttributesService`, and have the risk of having different business logic (and there is a different handling currently), the logic is slowly removed from the `SetScheduleService` and moved to the `SetAttributesService` instead. This only concerns the work package being updated. For dependent work packages needing to be rescheduled (ancestors and followers), the `SetScheduleService` is still used. Relevant tests have been moved from `spec/services/work_packages/set_schedule_service_spec.rb` to `spec/services/work_packages/update_service_integration_spec.rb`. --- .../work_packages/set_attributes_service.rb | 32 ++-- .../work_packages/set_schedule_service.rb | 24 --- .../set_schedule_service_spec.rb | 67 --------- .../update_service_integration_spec.rb | 140 +++++++++++++++++- 4 files changed, 159 insertions(+), 104 deletions(-) diff --git a/app/services/work_packages/set_attributes_service.rb b/app/services/work_packages/set_attributes_service.rb index 9fcdd5efccec..81551ed1cc4b 100644 --- a/app/services/work_packages/set_attributes_service.rb +++ b/app/services/work_packages/set_attributes_service.rb @@ -362,17 +362,19 @@ def initialize_unset_custom_values end def new_start_date - return if work_package.schedule_manually? + if work_package.schedule_manually? + # Weird rule from SetScheduleService: if the work package does not have a + # start date, it inherits it from the parent soonest start, regardless of + # scheduling mode. + return if work_package.start_date - current_start_date = work_package.start_date || work_package.due_date - - return if current_start_date.nil? - - min_start = new_start_date_from_parent || new_start_date_from_self - min_start = days.soonest_working_day(min_start) + days.soonest_working_day(new_start_date_from_parent) + else + # current_start_date = work_package.start_date || work_package.due_date + # return if current_start_date.nil? - if min_start && (min_start > current_start_date || work_package.schedule_manually_changed?) - min_start + min_start = new_start_date_from_parent || new_start_date_from_self + days.soonest_working_day(min_start) end end @@ -391,7 +393,17 @@ def new_start_date_from_self def new_due_date(min_start) duration = children_duration || work_package.duration - days.due_date(min_start, duration) + return unless work_package.due_date || duration + + due_date = + if duration + days.due_date(min_start, duration) + else + work_package.due_date + end + + # if due date is before start date, then start is used as due date. + [min_start, due_date].max end def work_package diff --git a/app/services/work_packages/set_schedule_service.rb b/app/services/work_packages/set_schedule_service.rb index a6b2108c4900..601df69e5096 100644 --- a/app/services/work_packages/set_schedule_service.rb +++ b/app/services/work_packages/set_schedule_service.rb @@ -39,10 +39,6 @@ def initialize(user:, work_package:, initiated_by: nil, switching_to_automatic_m def call(changed_attributes = %i(start_date due_date)) altered = [] - if %i(parent parent_id).intersect?(changed_attributes) - altered += schedule_by_parent - end - if %i(start_date due_date parent parent_id).intersect?(changed_attributes) altered += schedule_following end @@ -58,25 +54,6 @@ def call(changed_attributes = %i(start_date due_date)) private - # rubocop:disable Metrics/AbcSize - def schedule_by_parent - work_packages - .select { |wp| wp.start_date.nil? && wp.parent } - .each do |wp| - days = WorkPackages::Shared::Days.for(wp) - wp.start_date = days.soonest_working_day(wp.parent.soonest_start) - if wp.due_date || wp.duration - wp.due_date = [ - wp.start_date, - days.due_date(wp.start_date, wp.duration), - wp.due_date - ].compact.max - assign_cause_for_journaling(wp, :parent) - end - end - end - # rubocop:enable Metrics/AbcSize - # Finds all work packages that need to be rescheduled because of a # rescheduling of the service's work package and reschedules them. # @@ -194,7 +171,6 @@ def assign_cause_for_journaling(work_package, relation) def assign_cause_initiated_by_work_package(work_package, _relation) # For now we only track a generic cause, and not a specialized reason depending on the relation # work_package.journal_cause = case relation - # when :parent then Journal::CausedByWorkPackageParentChange.new(initiated_by) # when :children then Journal::CausedByWorkPackageChildChange.new(initiated_by) # when :predecessor then Journal::CausedByWorkPackagePredecessorChange.new(initiated_by) # end diff --git a/spec/services/work_packages/set_schedule_service_spec.rb b/spec/services/work_packages/set_schedule_service_spec.rb index 453f804db727..6e175079dbd6 100644 --- a/spec/services/work_packages/set_schedule_service_spec.rb +++ b/spec/services/work_packages/set_schedule_service_spec.rb @@ -873,73 +873,6 @@ def create_child(parent, start_date, due_date, **attributes) end end - context "when setting the parent" do - let(:new_parent_work_package) { create(:work_package) } - let(:attributes) { [:parent] } - - before do - allow(new_parent_work_package) - .to receive(:soonest_start) - .and_return(soonest_date) - allow(work_package) - .to receive(:parent) - .and_return(new_parent_work_package) - end - - context "with the parent being restricted in its ability to be moved" do - let(:soonest_date) { Time.zone.today + 3.days } - - it "sets the start date and due date to the earliest possible date" do - subject - - expect(work_package.start_date).to eql(Time.zone.today + 3.days) - expect(work_package.due_date).to eql(Time.zone.today + 3.days) - end - - it "does not change the due date if after the newly set start date" do - work_package.due_date = Time.zone.today + 5.days - subject - - expect(work_package.start_date).to eql(Time.zone.today + 3.days) - expect(work_package.due_date).to eql(Time.zone.today + 5.days) - end - end - - context "with the parent being restricted but work package already having dates set" do - let(:soonest_date) { Time.zone.today + 3.days } - - before do - work_package.start_date = Time.zone.today + 4.days - work_package.due_date = Time.zone.today + 5.days - end - - it "sets the dates to provided dates" do - subject - - expect(work_package.start_date).to eql(Time.zone.today + 4.days) - expect(work_package.due_date).to eql(Time.zone.today + 5.days) - end - end - - context "with the parent being restricted but the attributes define an earlier date" do - let(:soonest_date) { Time.zone.today + 3.days } - - before do - work_package.start_date = Time.zone.today + 1.day - work_package.due_date = Time.zone.today + 2.days - end - - # This would be invalid but the dates should be set nevertheless - # so we can have a correct error handling. - it "sets the dates to provided dates" do - subject - - expect(work_package.start_date).to eql(Time.zone.today + 1.day) - expect(work_package.due_date).to eql(Time.zone.today + 2.days) - end - end - end - context "with deep hierarchy of work packages" do before do work_package.due_date = Time.zone.today - 5.days diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index 071d8b6bde57..05dadfd46712 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -848,7 +848,8 @@ } end - let(:attributes) { { parent: parent_work_package } } + # `schedule_manually: true` is the default value. Adding it here anyway for explicitness. + let(:attributes) { { parent: parent_work_package, schedule_manually: true } } it "sets the parent and child dates correctly" do expect(subject) @@ -972,7 +973,7 @@ end end - context "when setting the parent" do + context "when being manually scheduled and setting the parent" do let(:attributes) { { parent: new_parent } } before do @@ -1081,7 +1082,8 @@ set_factory_default(:user, user) end - context "when the work package is automatically scheduled with dates set" do + context "when the work package is automatically scheduled with both dates set " \ + "and start date is before predecessor's due date" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | predecessors new_parent_predecessor | XX | manual | @@ -1106,6 +1108,112 @@ end end + context "when the work package is automatically scheduled with both dates set after predecessor's due date" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | XXX | automatic | + TABLE + let(:attributes) { { parent: new_parent } } + + it "reschedules the work package to start ASAP and keeps the duration; " \ + "the parent is rescheduled like its child" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + + # The work_package dates are moved to the parent's soonest start date. + # The parent dates are the same as its child. + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | XXX | automatic + work_package | XXX | automatic + TABLE + end + end + + context "when the work package is automatically scheduled without any dates set" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | | automatic | + TABLE + let(:attributes) { { parent: new_parent } } + + it "reschedules the work package to start ASAP and leaves its due date unset; " \ + "the parent is rescheduled to start ASAP too and end on the same day (use child start date as due date)" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + + # The work_package start date is set to the parent's soonest start date. + # Both parent dates are the same as its child start date. + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | X | automatic + work_package | [ | automatic + TABLE + end + end + + context "when the work package is automatically scheduled with only a due date being set before predecessor's due date" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | ] | automatic | + TABLE + let(:attributes) { { parent: new_parent } } + + it "reschedules the work package to start ASAP and changes the due date to be the same as start date; " \ + "the parent is rescheduled like its child" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + + # The work_package start date is set to the parent's soonest start date. + # The work_package due date is moved to the same as start date (can't start earlier). + # The parent dates are the same as its child. + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | X | automatic + work_package | X | automatic + TABLE + end + end + + context "when the work package is automatically scheduled with only a due date being set after predecessor's due date" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | ] | automatic | + TABLE + let(:attributes) { { parent: new_parent } } + + it "reschedules the work package to start ASAP and keeps the due date; " \ + "the parent is rescheduled like its child" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("work_package", "new_parent") + + # The work_package start date is set to the parent's soonest start date. + # The work_package due date is kept. + # The parent dates are the same as its child. + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | XXXXX | automatic + work_package | XXXXX | automatic + TABLE + end + end + context "when the work package is manually scheduled with dates set" do let_work_packages(<<~TABLE) subject | MTWTFSS | scheduling mode | predecessors @@ -1131,6 +1239,32 @@ TABLE end end + + context "when the work package is automatically scheduled, has a child and no dates" do + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + new_parent_predecessor | XX | manual | + new_parent | XXXXXX | automatic | new_parent_predecessor + work_package | | automatic | + child | | automatic | + TABLE + let(:attributes) { { parent: new_parent } } + + it "sets work package and child start date to be soonest start, " \ + "and parent's start and due dates to be work package start date" do + expect(subject).to be_success + expect(work_package.reload.parent).to eq new_parent + expect(subject.all_results.map(&:subject)).to contain_exactly("child", "work_package", "new_parent") + + expect_work_packages(subject.all_results + [new_parent_predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode + new_parent_predecessor | XX | manual + new_parent | X | automatic + work_package | [ | automatic + child | [ | automatic + TABLE + end + end end describe "removing the parent on a work package which precedes its sibling" do From c42d17df24cbbdedf5537557b2cfa0526d47985a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 8 Jan 2025 14:16:05 +0100 Subject: [PATCH 28/33] [59539] Reschedule when switching parent to automatic mode When a parent work package switches from manual to automatic scheduling mode, the children dates need to be known to be able to set parent dates. So a scheduling is done first in the `SetAttributesService` to get children's dates so that parent dates can be set. Then the parent work package is saved (in `UpdateService`), and all dependent work packages are rescheduled (again). --- app/contracts/work_packages/base_contract.rb | 1 + .../get_rescheduled_children_dates_service.rb | 156 ++++++++++++++++++ .../work_packages/set_attributes_service.rb | 20 ++- .../set_attributes_service_spec.rb | 86 +++++----- .../update_service_integration_spec.rb | 116 +++++++++++++ 5 files changed, 335 insertions(+), 44 deletions(-) create mode 100644 app/services/work_packages/get_rescheduled_children_dates_service.rb diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 5826c4fa9268..4ab9a9c5cfb3 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -228,6 +228,7 @@ def assignable_assignees def validate_after_soonest_start(date_attribute) return if model.schedule_manually? + return if model.children.any? if before_soonest_start?(date_attribute) message = I18n.t("activerecord.errors.models.work_package.attributes.start_date.violates_relationships", diff --git a/app/services/work_packages/get_rescheduled_children_dates_service.rb b/app/services/work_packages/get_rescheduled_children_dates_service.rb new file mode 100644 index 000000000000..7c5ef3ae43b8 --- /dev/null +++ b/app/services/work_packages/get_rescheduled_children_dates_service.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +# Service to get the new dates of an automatically scheduled parent from its +# children once the children have been rescheduled. +# +# It simulates a rescheduling and then returns the dates the parent should have: +# the earliest start date and the latest due date of the children. +class WorkPackages::GetRescheduledChildrenDatesService + attr_accessor :work_package + + ParentDates = Data.define(:start_date, :due_date) + + def initialize(work_package:) + self.work_package = Array(work_package).first + end + + def call(changed_attributes = %i(start_date due_date)) + if %i(start_date due_date parent parent_id).intersect?(changed_attributes) + schedule_following + end + + children = @dependencies.children_by_parent_id(work_package.id) + start_date = children.min_by(&:start_date).start_date + due_date = children.max_by(&:due_date).due_date + + ServiceResult.success(result: ParentDates.new(start_date:, due_date:)) + end + + private + + # Finds all work packages that need to be rescheduled because of a + # rescheduling of the service's work package and reschedules them. + # + # The order of the rescheduling is important as successors' dates are + # calculated based on their predecessors' dates and ancestors' dates based on + # their children's dates. + # + # Thus, the work packages following (having a follows relation, direct or + # transitively) the service's work package are first all loaded, and then + # sorted by their need to be scheduled before one another: + # + # - predecessors are scheduled before their successors + # - children/descendants are scheduled before their parents/ancestors + # + # Manually scheduled work packages are not encountered at this point as they + # are filtered out when fetching the work packages eligible for rescheduling. + def schedule_following + work_packages = [work_package] + work_packages << work_package.parent if work_package.parent && work_package.parent_id_changed? + @dependencies = WorkPackages::ScheduleDependency.new(work_packages, switching_to_automatic_mode: [work_package]) + @dependencies.in_schedule_order do |scheduled, dependency| + reschedule(scheduled, dependency) + end + end + + # Schedules work packages based on either + # - their descendants if they are parents + # - their predecessors (or predecessors of their ancestors) if they are + # leaves + def reschedule(scheduled, dependency) + if dependency.has_descendants? + reschedule_by_descendants(scheduled, dependency) + else + reschedule_by_predecessors(scheduled, dependency) + end + end + + # Inherits the start/due_date from the descendants of this work package. + # + # Only parent work packages are scheduled like this. start_date receives the + # minimum of the dates (start_date and due_date) of the descendants due_date + # receives the maximum of the dates (start_date and due_date) of the + # descendants + def reschedule_by_descendants(scheduled, dependency) + set_dates(scheduled, dependency.start_date, dependency.due_date) + end + + # Calculates the dates of a work package based on its follows relations. + # + # The start date of a work package is constrained by its direct and indirect + # predecessors, as it must start strictly after all predecessors finish. + # + # The follows relations of ancestors are considered to be equal to own follows + # relations as they inhibit moving a work package just the same. Only leaf + # work packages are calculated like this. + # + # work package is moved to a later date: + # - following work packages are moved forward only to ensure they start + # after their predecessor's finish date. They may not need to move at all + # when there a time buffer between a follower and its predecessors + # (predecessors can also be acquired transitively by ancestors) + # + # work package moved to an earlier date: + # - following work packages do not move at all. + def reschedule_by_predecessors(scheduled, dependency) + return unless dependency.soonest_start_date + + new_start_date = dependency.soonest_start_date + new_due_date = determine_due_date(scheduled, new_start_date) + set_dates(scheduled, new_start_date, new_due_date) + end + + def determine_due_date(work_package, start_date) + # due date is set only if the moving work package already has one or has a + # duration. If not, due date is nil (and duration will be nil too). + return unless work_package.due_date || work_package.duration + + due_date = + if work_package.duration + days(work_package).due_date(start_date, work_package.duration) + else + work_package.due_date + end + + # if due date is before start date, then start is used as due date. + [start_date, due_date].max + end + + def set_dates(work_package, start_date, due_date) + work_package.start_date = start_date + work_package.due_date = due_date + work_package.duration = days(work_package).duration(start_date, due_date) + end + + def days(work_package) + WorkPackages::Shared::Days.for(work_package) + end +end diff --git a/app/services/work_packages/set_attributes_service.rb b/app/services/work_packages/set_attributes_service.rb index 81551ed1cc4b..b1aee6855d33 100644 --- a/app/services/work_packages/set_attributes_service.rb +++ b/app/services/work_packages/set_attributes_service.rb @@ -266,7 +266,24 @@ def update_project_dependent_attributes def update_dates unify_milestone_dates + if work_package.children.any? + update_dates_from_rescheduled_children + else + update_dates_from_self + end + end + + def update_dates_from_rescheduled_children + return if work_package.schedule_manually? + # do a schedule call to get the children rescheduled before getting their dates + service = WorkPackages::GetRescheduledChildrenDatesService.new(work_package:) + parent_dates = service.call.result + work_package.start_date = parent_dates.start_date + work_package.due_date = parent_dates.due_date + end + + def update_dates_from_self min_start = new_start_date return unless min_start @@ -370,9 +387,6 @@ def new_start_date days.soonest_working_day(new_start_date_from_parent) else - # current_start_date = work_package.start_date || work_package.due_date - # return if current_start_date.nil? - min_start = new_start_date_from_parent || new_start_date_from_self days.soonest_working_day(min_start) end diff --git a/spec/services/work_packages/set_attributes_service_spec.rb b/spec/services/work_packages/set_attributes_service_spec.rb index 2c6769487a11..80a9eb61f9ce 100644 --- a/spec/services/work_packages/set_attributes_service_spec.rb +++ b/spec/services/work_packages/set_attributes_service_spec.rb @@ -1801,21 +1801,26 @@ end context "when switching back to automatic scheduling" do + shared_let(:project) { create(:project) } + let(:predecessor) do + create(:work_package, + subject: "predecessor", + project:, + ignore_non_working_days: true, + schedule_manually: true, + start_date: soonest_start - 1.day, + due_date: soonest_start - 1.day) + end let(:work_package) do - wp = build_stubbed(:work_package, - project:, - ignore_non_working_days: true, - schedule_manually: true, - start_date: Time.zone.today, - due_date: Time.zone.today + 5.days) - wp.type = build_stubbed(:type) - wp.clear_changes_information - - allow(wp) - .to receive(:soonest_start) - .and_return(soonest_start) - - wp + create(:work_package, + subject: "work_package", + project:, + ignore_non_working_days: true, + schedule_manually: true, + start_date: Time.zone.today, + due_date: Time.zone.today + 5.days) do |wp| + create(:follows_relation, from: wp, to: predecessor) + end end let(:call_attributes) { { schedule_manually: false } } let(:expected_attributes) { {} } @@ -1881,8 +1886,11 @@ end end - context "when the soonest start date is nil" do - let(:soonest_start) { nil } + context "when the soonest start date is nil (no predecessors)" do + before do + work_package # create the relation + predecessor.destroy # destroy the predecessor AND the relation + end include_examples "service call", description: "sets the start date to the soonest possible start date" do let(:expected_attributes) do @@ -1894,45 +1902,41 @@ end end - context "when the work package also has a child" do - let(:child) do - build_stubbed(:work_package, - subject: "child", - start_date: child_start_date, - due_date: child_due_date) + context "when the work package also has a manually scheduled child" do + let!(:child) do + create(:work_package, + subject: "child", + parent: work_package, + schedule_manually: true, + start_date: child_start_date, + due_date: child_due_date) end + let(:child_start_date) { Time.zone.today + 2.days } let(:child_due_date) { Time.zone.today + 10.days } - before do - allow(work_package) - .to receive(:children) - .and_return([child]) - end - - context "when the child's start date is after soonest_start" do - let(:child_start_date) { Time.zone.today + 2.days } - let(:soonest_start) { Time.zone.today + 1.day } + context "when the soonest start is before the child's start date" do + let(:soonest_start) { child_start_date - 1.day } - include_examples "service call", description: "sets the dates to the child dates" do + include_examples "service call", + description: "sets the dates to the child dates, without moving parent to soonest start" do let(:expected_attributes) do { - start_date: Time.zone.today + 2.days, - due_date: Time.zone.today + 10.days + start_date: child_start_date, + due_date: child_due_date } end end end - context "when the child's start date is before soonest_start" do - let(:child_start_date) { Time.zone.today + 2.days } - let(:soonest_start) { Time.zone.today + 3.days } + context "when the soonest start is after the child's start date" do + let(:soonest_start) { child_start_date + 1.day } - # TODO: Update this: this is no longer true. Now a parent always inherits dates from its children. - include_examples "service call", description: "sets the dates to soonest date and to the duration of the child" do + include_examples "service call", + description: "sets the dates to the child dates, regardless of the soonest date" do let(:expected_attributes) do { - start_date: Time.zone.today + 3.days, - due_date: Time.zone.today + 11.days + start_date: child_start_date, + due_date: child_due_date } end end diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index 05dadfd46712..b172deca4652 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -1267,6 +1267,122 @@ end end + context "when switching scheduling mode to automatic" do + let(:attributes) { { schedule_manually: false } } + + before do + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type) + set_factory_default(:user, user) + + set_non_working_week_days("saturday", "sunday") + end + + context "when the work package has a manually scheduled child " \ + "and a predecessor restricting it moving to an earlier date" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | predecessors + | predecessor | XX | manual | + | work_package | XXX | manual | predecessor + | child | X | manual | + TABLE + + it "sets the dates to the child dates, despite the predecessor" do + expect(subject).to be_success + + expect_work_packages_after_reload([work_package, predecessor, child], <<~TABLE) + | subject | MTWTFSS | scheduling mode + | predecessor | XX | manual + | work_package | X | automatic + | child | X | manual + TABLE + end + end + + context "when the work package has an automatically scheduled child " \ + "and a predecessor restricting it moving to an earlier date" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | predecessors + | predecessor | XX | manual | + | child_predecessor | X | manual | + | work_package | XXXX | manual | predecessor + | child | XXX | automatic | child_predecessor + TABLE + + it "sets the dates to start after the predecessor" do + expect(subject).to be_success + + expect_work_packages_after_reload([predecessor, child_predecessor, work_package, child], <<~TABLE) + | subject | MTWTFSS | scheduling mode + | predecessor | XX | manual + | child_predecessor | X | manual + | work_package | X..XX | automatic + | child | X..XX | automatic + TABLE + end + end + + context "when the work package has an automatically scheduled child, " \ + "a second manually scheduled child and a predecessor restricting it moving to an earlier date" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | predecessors + | predecessor | XX | manual | + | work_package | XXXX | manual | predecessor + | child1 | X | manual | + | child2 | XX | automatic | child1 + TABLE + + it "reschedule the automatic child to start after the predecessor and parent dates span over both children dates" do + expect(subject).to be_success + + expect_work_packages_after_reload([predecessor, work_package, child1, child2], <<~TABLE) + | subject | MTWTFSS | scheduling mode + | predecessor | XX | manual + | work_package | XXXXX..X | automatic + | child1 | X | manual + | child2 | X..X | automatic + TABLE + end + end + end + + context "when setting dates" do + before do + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type) + set_factory_default(:user, user) + + set_non_working_week_days("saturday", "sunday") + end + + context "on a manually scheduled parent having a predecessor" do + let_work_packages(<<~TABLE) + | hierarchy | MTWTFSS | scheduling mode | predecessors + | predecessor | XX | manual | + | work_package | XXX | manual | predecessor + | child | X | manual | + TABLE + + # change due date of work package from Wednesday to Friday + let(:attributes) { { due_date: work_package.due_date + 2.days } } + + it "sets the dates to the given dates" do + expect(subject).to be_success + + expect_work_packages_after_reload([work_package, predecessor, child], <<~TABLE) + | subject | MTWTFSS | scheduling mode + | predecessor | XX | manual + | work_package | XXXXX | manual + | child | X | manual + TABLE + end + end + end + describe "removing the parent on a work package which precedes its sibling" do let(:work_package_attributes) do { project_id: project.id, From cc62435f84afecd0a5ebe6be91bdeb89f53bb45f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 15 Jan 2025 15:11:40 +0100 Subject: [PATCH 29/33] refactor: make test execute faster * Use `shared_let` for speed * Use `let_work_packages` for more readable tests --- .../update_service_integration_spec.rb | 710 ++++++------------ 1 file changed, 239 insertions(+), 471 deletions(-) diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index b172deca4652..f383b5743920 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -31,36 +31,46 @@ require "spec_helper" RSpec.describe WorkPackages::UpdateService, "integration", type: :model do - let(:user) do + shared_let(:type) { create(:type_standard) } + shared_let(:project_types) { [type] } + shared_let(:project) do + create(:project, types: project_types) + end + shared_let(:default_status) { create(:default_status, name: "default_status") } + shared_let(:non_default_status) { create(:status, name: "non_default_status") } + + shared_let(:role) do + create(:project_role, + permissions: %i[ + view_work_packages + edit_work_packages + add_work_packages + move_work_packages + manage_subtasks + ]) + end + shared_let(:user) do create(:user, member_with_roles: { project => role }) end - let(:role) { create(:project_role, permissions:) } - let(:permissions) do - %i(view_work_packages edit_work_packages add_work_packages move_work_packages manage_subtasks) + shared_let(:status) { default_status } + shared_let(:priority) { create(:priority) } + + before_all do + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type) + set_factory_default(:user, user) end - let(:status) { create(:default_status) } - let(:type) { create(:type_standard) } - let(:project_types) { [type] } - let(:project) { create(:project, types: project_types) } - let(:priority) { create(:priority) } - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority: } - end - let(:work_package) do + shared_let(:work_package, refind: true, reload: false) do create(:work_package, - subject: "initial", - **work_package_attributes) + subject: "work_package") end let(:parent_work_package) do create(:work_package, subject: "parent", - schedule_manually: false, - **work_package_attributes).tap do |w| + schedule_manually: false).tap do |w| w.children << work_package work_package.reload end @@ -68,40 +78,32 @@ let(:grandparent_work_package) do create(:work_package, subject: "grandparent", - schedule_manually: false, - **work_package_attributes).tap do |w| + schedule_manually: false).tap do |w| w.children << parent_work_package end end - let(:sibling1_attributes) do - work_package_attributes.merge(subject: "sibling1", parent: parent_work_package) - end - let(:sibling2_attributes) do - work_package_attributes.merge(subject: "sibling2", parent: parent_work_package) - end + let(:sibling1_attributes) { {} } + let(:sibling2_attributes) { {} } let(:sibling1_work_package) do create(:work_package, - sibling1_attributes) + subject: "sibling1", + parent: parent_work_package, + **sibling1_attributes) end let(:sibling2_work_package) do create(:work_package, - sibling2_attributes) - end - let(:child_attributes) do - work_package_attributes.merge(subject: "child", parent: work_package) + subject: "sibling2", + parent: parent_work_package, + **sibling2_attributes) end + let(:child_attributes) { {} } let(:child_work_package) do - child_attributes[:parent].update_column(:schedule_manually, false) + parent = work_package + parent.update_column(:schedule_manually, false) create(:work_package, - child_attributes) - end - let(:grandchild_attributes) do - work_package_attributes.merge(subject: "grandchild", parent: child_work_package) - end - let(:grandchild_work_package) do - grandchild_attributes[:parent].update_column(:schedule_manually, false) - create(:work_package, - grandchild_attributes) + subject: "child", + parent:, + **child_attributes) end let(:instance) do described_class.new(user:, @@ -125,29 +127,28 @@ end context "when updating the project" do - let(:target_project) do - p = create(:project, - types: target_types, - parent: target_parent) + shared_let(:target_project) do + create(:project, + types: project_types, + parent: nil) + end + let(:target_permissions) { [:move_work_packages] } + let(:attributes) { { project_id: target_project.id } } + + before do create(:member, user:, - project: p, + project: target_project, roles: [create(:project_role, permissions: target_permissions)]) - - p end - let(:attributes) { { project_id: target_project.id } } - let(:target_permissions) { [:move_work_packages] } - let(:target_parent) { nil } - let(:target_types) { [type] } - it "is is success and updates the project" do + it "is success and updates the project" do expect(subject).to be_success expect(work_package.reload.project).to eql target_project end - context "with missing permissions" do + context "with missing :move_work_packages permission" do let(:target_permissions) { [] } it "is failure" do @@ -250,10 +251,9 @@ project:, sharing:) end - let(:work_package) do - create(:work_package, - version:, - project:) + + before do + work_package.update(version:) end context "with an unshared version" do @@ -279,8 +279,8 @@ end context "when moving the work package in project hierarchy" do - let(:target_parent) do - project + before do + target_project.update(parent: project) end context "with an unshared version" do @@ -293,7 +293,7 @@ end end - context "with a shared version" do + context "with a hierarchy shared version" do let(:sharing) { "tree" } it "keeps the version" do @@ -308,18 +308,28 @@ end describe "type" do - let(:target_types) { [type, other_type] } - let(:other_type) { create(:type) } - let(:default_type) { type } - let(:project_types) { [type, other_type] } - let!(:workflow_type) do + shared_let(:other_type) { create(:type) } + shared_let(:default_type) { type } + shared_let(:workflow_type) do create(:workflow, type: default_type, role:, old_status_id: status.id) end - let!(:workflow_other_type) do + shared_let(:workflow_other_type) do create(:workflow, type: other_type, role:, old_status_id: status.id) end + before do + project.types << other_type + + # reset types of target project + # types will be added in each context depending on the test. + target_project.types.delete_all + end + context "with the type existing in the target project" do + before do + target_project.types << type + end + it "keeps the type" do expect(subject) .to be_success @@ -330,7 +340,9 @@ end context "with a default type existing in the target project" do - let(:target_types) { [other_type, default_type] } + before do + target_project.types << default_type + end it "uses the default type" do expect(subject) @@ -342,7 +354,9 @@ end context "with only non default types" do - let(:target_types) { [other_type] } + before do + target_project.types << other_type + end it "is unsuccessful" do expect(subject) @@ -351,7 +365,9 @@ end context "with an invalid type being provided" do - let(:target_types) { [type] } + before do + target_project.types << type + end let(:attributes) do { project: target_project, @@ -396,15 +412,22 @@ end describe "inheriting dates" do - let(:attributes) { { start_date: Time.zone.today - 8.days, due_date: Time.zone.today + 12.days } } + let(:attributes) do + { + start_date: Time.zone.today - 8.days, + due_date: Time.zone.today + 12.days + } + end let(:sibling1_attributes) do - work_package_attributes.merge(start_date: Time.zone.today - 5.days, - due_date: Time.zone.today + 10.days, - parent: parent_work_package) + { + start_date: Time.zone.today - 5.days, + due_date: Time.zone.today + 10.days + } end let(:sibling2_attributes) do - work_package_attributes.merge(due_date: Time.zone.today + 16.days, - parent: parent_work_package) + { + due_date: Time.zone.today + 16.days + } end before do @@ -449,22 +472,22 @@ end describe "inheriting done_ratio" do - let(:attributes) { { estimated_hours: 10.0, remaining_hours: 5.0 } } - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority: } + let(:attributes) do + { + estimated_hours: 10.0, + remaining_hours: 5.0 + } end - let(:sibling1_attributes) do - work_package_attributes.merge(parent: parent_work_package) + # no estimated or remaining hours + {} end let(:sibling2_attributes) do - work_package_attributes.merge(estimated_hours: 100.0, - remaining_hours: 25.0, - parent: parent_work_package) + { + estimated_hours: 100.0, + remaining_hours: 25.0, + parent: parent_work_package + } end before do @@ -515,15 +538,17 @@ let(:attributes) { { estimated_hours: 7 } } let(:sibling1_attributes) do # no estimated hours - work_package_attributes.merge(parent: parent_work_package) + {} end let(:sibling2_attributes) do - work_package_attributes.merge(estimated_hours: 5, - parent: parent_work_package) + { + estimated_hours: 5 + } end let(:child_attributes) do - work_package_attributes.merge(estimated_hours: 10, - parent: work_package) + { + estimated_hours: 10 + } end before do @@ -608,7 +633,7 @@ describe "closing duplicates on closing status" do let(:status_closed) do create(:status, - is_closed: true).tap do |status_closed| + is_closed: true) do |status_closed| create(:workflow, old_status: status, new_status: status_closed, @@ -617,8 +642,7 @@ end end let!(:duplicate_work_package) do - create(:work_package, - work_package_attributes).tap do |wp| + create(:work_package, subject: "duplicate") do |wp| create(:relation, relation_type: Relation::TYPE_DUPLICATES, from: wp, to: work_package) end end @@ -647,191 +671,69 @@ # + + + | # work_package +-follows- following_work_package following2_work_package +-follows- following3_work_package + # following3_sibling_work_package - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - start_date: Time.zone.today, - due_date: Time.zone.today + 5.days } - end + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + work_package | XXXXXX | manual | + following_parent_work_package | XXXXXXXXXXXXXXX | automatic | + following_work_package | XXXXXXXXXXXXXXX | automatic | work_package + following2_parent_work_package | XXXXX | automatic | following_parent_work_package + following2_work_package | XXXXX | automatic | + following3_parent_work_package | XXXXXXXXXXX | automatic | + following3_work_package | XXXXX | automatic | following2_work_package + following3_sibling_work_package | XXXXX | manual | + TABLE let(:attributes) do - { start_date: Time.zone.today + 5.days, - due_date: Time.zone.today + 10.days } - end - let(:following_attributes) do - work_package_attributes.merge(parent: following_parent_work_package, - subject: "following", - schedule_manually: false, - start_date: Time.zone.today + 6.days, - due_date: Time.zone.today + 20.days) - end - let(:following_work_package) do - create(:work_package, - following_attributes).tap do |wp| - create(:follows_relation, from: wp, to: work_package) - end - end - let(:following_parent_attributes) do - work_package_attributes.merge(subject: "following_parent", - schedule_manually: false, - start_date: Time.zone.today + 6.days, - due_date: Time.zone.today + 20.days) - end - let(:following_parent_work_package) do - create(:work_package, - following_parent_attributes) - end - let(:following2_attributes) do - work_package_attributes.merge(parent: following2_parent_work_package, - subject: "following2", - schedule_manually: false, - start_date: Time.zone.today + 21.days, - due_date: Time.zone.today + 25.days) - end - let(:following2_work_package) do - create(:work_package, - following2_attributes) - end - let(:following2_parent_attributes) do - work_package_attributes.merge(subject: "following2_parent", - schedule_manually: false, - start_date: Time.zone.today + 21.days, - due_date: Time.zone.today + 25.days) - end - let(:following2_parent_work_package) do - create(:work_package, - following2_parent_attributes).tap do |wp| - create(:follows_relation, from: wp, to: following_parent_work_package) - end - end - let(:following3_attributes) do - work_package_attributes.merge(subject: "following3", - parent: following3_parent_work_package, - schedule_manually: false, - start_date: Time.zone.today + 26.days, - due_date: Time.zone.today + 30.days) - end - let(:following3_work_package) do - create(:work_package, - following3_attributes).tap do |wp| - create(:follows_relation, from: wp, to: following2_work_package) - end - end - let(:following3_parent_attributes) do - work_package_attributes.merge(subject: "following3_parent", - schedule_manually: false, - start_date: Time.zone.today + 26.days, - due_date: Time.zone.today + 36.days) - end - let(:following3_parent_work_package) do - create(:work_package, - following3_parent_attributes) - end - let(:following3_sibling_attributes) do - work_package_attributes.merge(parent: following3_parent_work_package, - subject: "following3_sibling", - schedule_manually: false, - start_date: Time.zone.today + 32.days, - due_date: Time.zone.today + 36.days) - end - let(:following3_sibling_work_package) do - create(:work_package, - following3_sibling_attributes) + { + start_date: work_package.start_date + 5.days, + due_date: work_package.due_date + 5.days + } end - before do - work_package - following_parent_work_package - following_work_package - following2_parent_work_package - following2_work_package - following3_parent_work_package - following3_work_package - following3_sibling_work_package - end + let(:monday) { Date.current.next_occurring(:monday) } - # rubocop:disable RSpec/ExampleLength - # rubocop:disable RSpec/MultipleExpectations it "propagates the changes to start/finish date along" do expect(subject) .to be_success - work_package.reload(select: %i(start_date due_date)) - expect(work_package.start_date) - .to eql Time.zone.today + 5.days - - expect(work_package.due_date) - .to eql Time.zone.today + 10.days - - following_work_package.reload(select: %i(start_date due_date)) - expect(following_work_package.start_date) - .to eql Time.zone.today + 11.days - expect(following_work_package.due_date) - .to eql Time.zone.today + 25.days - - following_parent_work_package.reload(select: %i(start_date due_date)) - expect(following_parent_work_package.start_date) - .to eql Time.zone.today + 11.days - expect(following_parent_work_package.due_date) - .to eql Time.zone.today + 25.days - - following2_parent_work_package.reload(select: %i(start_date due_date)) - expect(following2_parent_work_package.start_date) - .to eql Time.zone.today + 26.days - expect(following2_parent_work_package.due_date) - .to eql Time.zone.today + 30.days - - following2_work_package.reload(select: %i(start_date due_date)) - expect(following2_work_package.start_date) - .to eql Time.zone.today + 26.days - expect(following2_work_package.due_date) - .to eql Time.zone.today + 30.days - - following3_work_package.reload(select: %i(start_date due_date)) - expect(following3_work_package.start_date) - .to eql Time.zone.today + 31.days - expect(following3_work_package.due_date) - .to eql Time.zone.today + 35.days - - following3_parent_work_package.reload(select: %i(start_date due_date)) - expect(following3_parent_work_package.start_date) - .to eql Time.zone.today + 31.days - expect(following3_parent_work_package.due_date) - .to eql Time.zone.today + 36.days - - following3_sibling_work_package.reload(select: %i(start_date due_date)) - expect(following3_sibling_work_package.start_date) - .to eql Time.zone.today + 32.days - expect(following3_sibling_work_package.due_date) - .to eql Time.zone.today + 36.days - # Returns changed work packages expect(subject.all_results) - .to contain_exactly(work_package, following_parent_work_package, following_work_package, following2_parent_work_package, - following2_work_package, following3_parent_work_package, following3_work_package) + .to contain_exactly(work_package, + following_parent_work_package, following_work_package, + following2_parent_work_package, following2_work_package, + following3_parent_work_package, following3_work_package) + + expect_work_packages(subject.all_results + [following3_sibling_work_package], <<~TABLE) + subject | MTWTFSS | + work_package | XXXXXX | + following_parent_work_package | XXXXXXXXXXXXXXX | + following_work_package | XXXXXXXXXXXXXXX | + following2_parent_work_package | XXXXX | + following2_work_package | XXXXX | + following3_parent_work_package | XXXXXX | + following3_work_package | XXXXX | + following3_sibling_work_package | XXXXX | + TABLE end - # rubocop:enable RSpec/ExampleLength - # rubocop:enable RSpec/MultipleExpectations end describe "rescheduling work packages with a parent having a follows relation (Regression #43220)" do - let(:predecessor_work_package_attributes) do - work_package_attributes.merge( + let(:predecessor_attributes) do + { start_date: Time.zone.today + 1.day, due_date: Time.zone.today + 3.days - ) + } end let!(:predecessor_work_package) do - create(:work_package, predecessor_work_package_attributes).tap do |wp| + create(:work_package, + subject: "predecessor", + **predecessor_attributes) do |wp| create(:follows_relation, from: parent_work_package, to: wp) end end let(:parent_work_package) do - create(:work_package, schedule_manually: false, **work_package_attributes) + create(:work_package, subject: "parent", schedule_manually: false) end let(:expected_parent_dates) do @@ -867,109 +769,36 @@ end describe "changing the parent" do - let(:former_parent_attributes) do - { - subject: "former parent", - project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - schedule_manually: false, - start_date: Time.zone.today + 3.days, - due_date: Time.zone.today + 9.days - } - end - let(:attributes) { { parent: new_parent_work_package } } - let(:former_parent_work_package) do - create(:work_package, former_parent_attributes) - end - - let(:former_sibling_attributes) do - work_package_attributes.merge( - subject: "former sibling", - parent: former_parent_work_package, - start_date: Time.zone.today + 3.days, - due_date: Time.zone.today + 6.days - ) - end - let(:former_sibling_work_package) do - create(:work_package, former_sibling_attributes) - end + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode + former_parent_work_package | XXXXXXX | automatic + work_package | XXX | manual + former_sibling_work_package | XXXX | manual + new_parent_work_package | XXX | automatic + new_sibling_work_package | XXX | manual + TABLE - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - parent: former_parent_work_package, - start_date: Time.zone.today + 7.days, - due_date: Time.zone.today + 9.days } - end - - let(:new_parent_attributes) do - work_package_attributes.merge( - subject: "new parent", - parent: nil, - schedule_manually: false, - start_date: Time.zone.today + 10.days, - due_date: Time.zone.today + 12.days - ) - end - let(:new_parent_work_package) do - create(:work_package, new_parent_attributes) - end - - let(:new_sibling_attributes) do - work_package_attributes.merge( - subject: "new sibling", - parent: new_parent_work_package, - start_date: Time.zone.today + 10.days, - due_date: Time.zone.today + 12.days - ) - end - let(:new_sibling_work_package) do - create(:work_package, new_sibling_attributes) - end - - before do - work_package.reload - former_parent_work_package.reload - former_sibling_work_package.reload - new_parent_work_package.reload - new_sibling_work_package.reload - end + let(:attributes) { { parent: new_parent_work_package } } it "changes the parent reference and reschedules former and new parent" do expect(subject) .to be_success - # sets the parent and leaves the dates unchanged - work_package.reload - expect(work_package.parent) - .to eql new_parent_work_package - expect(work_package.start_date) - .to eql work_package_attributes[:start_date] - expect(work_package.due_date) - .to eql work_package_attributes[:due_date] - - # updates the former parent's dates based on the only remaining child (former sibling) - former_parent_work_package.reload - expect(former_parent_work_package.start_date) - .to eql former_sibling_attributes[:start_date] - expect(former_parent_work_package.due_date) - .to eql former_sibling_attributes[:due_date] - - # updates the new parent's dates based on the moved work package and its now sibling - new_parent_work_package.reload - expect(new_parent_work_package.start_date) - .to eql work_package_attributes[:start_date] - expect(new_parent_work_package.due_date) - .to eql new_sibling_attributes[:due_date] - expect(subject.all_results.uniq) .to contain_exactly(work_package, former_parent_work_package, new_parent_work_package) + + expect(work_package.reload.parent).to eq(new_parent_work_package) + + expect_work_packages(subject.all_results + [former_sibling_work_package, new_sibling_work_package], <<~TABLE) + subject | MTWTFSS | + # updates the former parent's dates based on the only remaining child (former sibling) + former_parent_work_package | XXXX | + former_sibling_work_package | XXXX | + # updates the new parent's dates based on the moved work package and its now sibling + new_parent_work_package | XXXXXX | + work_package | XXX | + new_sibling_work_package | XXX | + TABLE end end @@ -977,12 +806,6 @@ let(:attributes) { { parent: new_parent } } before do - set_factory_default(:priority, priority) - set_factory_default(:project_with_types, project) - set_factory_default(:status, status) - set_factory_default(:type, type) - set_factory_default(:user, user) - set_non_working_week_days("saturday", "sunday") end @@ -1074,14 +897,6 @@ end describe "setting an automatically scheduled parent having a predecessor restricting it moving to an earlier date" do - before do - set_factory_default(:priority, priority) - set_factory_default(:project_with_types, project) - set_factory_default(:status, status) - set_factory_default(:type, type) - set_factory_default(:user, user) - end - context "when the work package is automatically scheduled with both dates set " \ "and start date is before predecessor's due date" do let_work_packages(<<~TABLE) @@ -1271,12 +1086,6 @@ let(:attributes) { { schedule_manually: false } } before do - set_factory_default(:priority, priority) - set_factory_default(:project_with_types, project) - set_factory_default(:status, status) - set_factory_default(:type, type) - set_factory_default(:user, user) - set_non_working_week_days("saturday", "sunday") end @@ -1350,12 +1159,6 @@ context "when setting dates" do before do - set_factory_default(:priority, priority) - set_factory_default(:project_with_types, project) - set_factory_default(:status, status) - set_factory_default(:type, type) - set_factory_default(:user, user) - set_non_working_week_days("saturday", "sunday") end @@ -1384,47 +1187,14 @@ end describe "removing the parent on a work package which precedes its sibling" do - let(:work_package_attributes) do - { project_id: project.id, - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - parent: parent_work_package, - start_date: Time.zone.today, - due_date: Time.zone.today + 3.days } - end - let(:attributes) { { parent: nil } } - - let(:parent_attributes) do - { project_id: project.id, - subject: "parent", - type_id: type.id, - author_id: user.id, - status_id: status.id, - priority:, - schedule_manually: false, - start_date: Time.zone.today, - due_date: Time.zone.today + 10.days } - end + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + parent_work_package | XXXXXXXXXXX | automatic | + work_package | XXXX | manual | + sibling_work_package | XXXXXXX | automatic | work_package + TABLE - let(:parent_work_package) do - create(:work_package, parent_attributes) - end - - let(:sibling_attributes) do - work_package_attributes.merge( - subject: "sibling", - start_date: Time.zone.today + 4.days, - due_date: Time.zone.today + 10.days - ) - end - - let(:sibling_work_package) do - create(:work_package, sibling_attributes).tap do |wp| - create(:follows_relation, from: wp, to: work_package) - end - end + let(:attributes) { { parent: nil } } before do work_package.reload @@ -1435,25 +1205,18 @@ it "removes the parent and reschedules it" do expect(subject) .to be_success - - # work package itself is unchanged (except for the parent) - work_package.reload - expect(work_package.parent) - .to be_nil - expect(work_package.start_date) - .to eql work_package_attributes[:start_date] - expect(work_package.due_date) - .to eql work_package_attributes[:due_date] - - # parent is rescheduled to the sibling's dates - parent_work_package.reload - expect(parent_work_package.start_date) - .to eql sibling_attributes[:start_date] - expect(parent_work_package.due_date) - .to eql sibling_attributes[:due_date] - expect(subject.all_results.uniq) .to contain_exactly(work_package, parent_work_package) + expect(work_package.reload.parent).to be_nil + + expect_work_packages(subject.all_results + [sibling_work_package], <<~TABLE) + subject | MTWTFSS | scheduling mode + # work package itself is unchanged (except for the parent) + work_package | XXXX | manual + # parent is rescheduled to the sibling's dates + parent_work_package | XXXXXXX | automatic + sibling_work_package | XXXXXXX | automatic + TABLE end end @@ -1540,13 +1303,18 @@ end describe "Changing type to one that does not have the current status (Regression #27780)" do - let(:type) { create(:type_with_workflow) } - let(:new_type) { create(:type) } - let(:project_types) { [type, new_type] } + shared_let(:new_type) { create(:type) } + let(:attributes) { { type: new_type } } + before do + project.types << new_type + end + context "when the work package does NOT have default status" do - let(:status) { create(:status) } + before do + work_package.update(status: non_default_status) + end it "assigns the default status" do expect(subject).to be_success @@ -1556,14 +1324,16 @@ end context "when the work package does have default status" do - let(:status) { create(:default_status) } - let!(:workflow_type) do - create(:workflow, type: new_type, role:, old_status_id: status.id) + before do + create(:workflow, type: new_type, role:, old_status_id: default_status.id) + work_package.update(status: default_status) end - it "does not set the status" do + it "does not change the status" do expect(subject).to be_success + expect(new_type.statuses).to include(default_status) + expect(work_package) .not_to be_saved_change_to_status_id end @@ -1573,10 +1343,10 @@ describe "removing an invalid parent" do # The parent does not have a required custom field set but will need to be touched since # the dates, inherited from its children (and then the only remaining child), will have to be updated. + let!(:delete_me) { work_package } let!(:parent) do create(:work_package, type: project.types.first, - project:, schedule_manually: false, start_date: Time.zone.today - 1.day, due_date: Time.zone.today + 5.days) @@ -1590,7 +1360,6 @@ let!(:sibling) do create(:work_package, type: project.types.first, - project:, parent:, start_date: Time.zone.today + 1.day, due_date: Time.zone.today + 5.days, @@ -1598,23 +1367,26 @@ end let!(:attributes) { { parent: nil } } - let(:work_package_attributes) do - { + before do + # must use `update` as we are using `shared_let` + work_package.update( start_date: Time.zone.today - 1.day, due_date: Time.zone.today + 1.day, - project:, type: project.types.first, parent:, custom_field.attribute_name => 8 - } + ) end it "removes the parent successfully and reschedules the parent" do + expect(parent.valid?).to be(false) expect(subject).to be_success expect(work_package.reload.parent).to be_nil - expect(parent.reload.start_date) + parent.reload + expect(parent.valid?).to be(false) + expect(parent.start_date) .to eql(sibling.start_date) expect(parent.due_date) .to eql(sibling.due_date) @@ -1623,7 +1395,8 @@ describe "updating an invalid work package" do # The work package does not have a required custom field set. - let(:custom_field) do + let!(:delete_me) { work_package } + let(:mandatory_custom_field) do create(:integer_wp_custom_field, is_required: true, is_for_all: true, default_value: nil) do |cf| project.types.first.custom_fields << cf project.work_package_custom_fields << cf @@ -1631,30 +1404,25 @@ end let(:attributes) { { subject: "A new subject" } } - let(:work_package_attributes) do - { + before do + work_package.update( subject: "The old subject", - project:, type: project.types.first - } - end - - before do - # Creating the custom field after the work package is already saved. - work_package - custom_field + ) + # Creating the mandatory custom field after the work package is already saved. + # That turns the work package invalid as the mandatory custom field is not set. + mandatory_custom_field end it "is a failure and does not save the change" do expect(subject).to be_failure expect(work_package.reload.subject) - .to eql work_package_attributes[:subject] + .to eq "The old subject" end end describe "updating the type (custom field resetting)" do - let(:project_types) { [type, new_type] } let(:new_type) { create(:type) } let!(:custom_field_of_current_type) do create(:integer_wp_custom_field, default_value: nil) do |cf| @@ -1672,12 +1440,12 @@ { type: new_type } end - let(:work_package_attributes) do - { - type:, - project:, + before do + project.types << new_type + work_package.update( + type: type, custom_field_of_current_type.attribute_name => 5 - } + ) end it "is success, removes the existing custom field value and sets the default for the new one" do From 214352c266f0a667ce2154a6e9f399e762da9537 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 15 Jan 2025 17:55:42 +0100 Subject: [PATCH 30/33] [59539] Correctly reschedule parent when it no longer has children But only if it has a predecessor. If it does not have any predecessor nor child, then it can't stay in automatic mode. --- app/services/work_packages/update_service.rb | 33 +++++++++++++---- .../update_service_integration_spec.rb | 37 ++++++++++++++++--- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/app/services/work_packages/update_service.rb b/app/services/work_packages/update_service.rb index bed33bd22148..68aabe92cd60 100644 --- a/app/services/work_packages/update_service.rb +++ b/app/services/work_packages/update_service.rb @@ -129,17 +129,34 @@ def reset_custom_values(work_package) end def reschedule_related(work_package) - rescheduled = if work_package.saved_change_to_parent_id? && work_package.parent_id_before_last_save - reschedule_former_siblings(work_package).dependent_results - else - [] - end + moved_work_packages = [work_package] + + # if parent changed, we find a child or a predecessor of the former parent to + # give it to the SetScheduleService so that the former parent is rescheduled. + if parent_just_changed?(work_package) + former_parent_id = work_package.parent_id_before_last_save + some_child_or_predecessor = find_some_child_or_predecessor(former_parent_id) + if some_child_or_predecessor + moved_work_packages << some_child_or_predecessor + else # rubocop:disable Style/EmptyElse + # aha! switch former parent to manual mode? + end + end - rescheduled + reschedule(work_package, [work_package]).dependent_results + reschedule(work_package, moved_work_packages).dependent_results end - def reschedule_former_siblings(work_package) - reschedule(work_package, WorkPackage.where(parent_id: work_package.parent_id_before_last_save)) + def parent_just_changed?(work_package) + work_package.saved_change_to_parent_id? && work_package.parent_id_before_last_save + end + + def find_some_child_or_predecessor(former_parent_id) + former_parent = WorkPackage.find(former_parent_id) + if a_child = former_parent.children.first + a_child + elsif a_relation = Relation.follows.of_successor(former_parent).first + a_relation.predecessor + end end def reschedule(work_package, work_packages) diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index f383b5743920..a272b502b908 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -1196,12 +1196,6 @@ let(:attributes) { { parent: nil } } - before do - work_package.reload - parent_work_package.reload - sibling_work_package.reload - end - it "removes the parent and reschedules it" do expect(subject) .to be_success @@ -1220,6 +1214,37 @@ end end + context "when removing the last child of an automatically scheduled parent" do + let(:attributes) { { parent: nil } } + + describe "when the parent has predecessors and successors" do + let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + predecessor | X | manual | + parent | XXX | automatic | predecessor + work_package | XXX | manual | + successor | X | automatic | parent + TABLE + + it "keeps former parent duration and moves it to its soonest start date, and successors are rescheduled" do + expect(subject) + .to be_success + expect(subject.all_results.pluck(:subject)) + .to contain_exactly("work_package", "parent", "successor") + expect(work_package.reload.parent).to be_nil + + expect_work_packages(subject.all_results + [predecessor], <<~TABLE) + subject | MTWTFSS | scheduling mode | + predecessor | X | manual | + parent | XXX | automatic | + successor | X | automatic | + + work_package | XXX | manual | + TABLE + end + end + end + describe "replacing the attachments" do let!(:old_attachment) do create(:attachment, container: work_package) From 2474fb80884996c5f8c04f48a2e69f45d1bd268f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 16 Jan 2025 09:41:10 +0100 Subject: [PATCH 31/33] Remove leftovers --- spec/services/work_packages/update_service_integration_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/services/work_packages/update_service_integration_spec.rb b/spec/services/work_packages/update_service_integration_spec.rb index a272b502b908..c68d5ecb8361 100644 --- a/spec/services/work_packages/update_service_integration_spec.rb +++ b/spec/services/work_packages/update_service_integration_spec.rb @@ -1368,7 +1368,6 @@ describe "removing an invalid parent" do # The parent does not have a required custom field set but will need to be touched since # the dates, inherited from its children (and then the only remaining child), will have to be updated. - let!(:delete_me) { work_package } let!(:parent) do create(:work_package, type: project.types.first, @@ -1420,7 +1419,6 @@ describe "updating an invalid work package" do # The work package does not have a required custom field set. - let!(:delete_me) { work_package } let(:mandatory_custom_field) do create(:integer_wp_custom_field, is_required: true, is_for_all: true, default_value: nil) do |cf| project.types.first.custom_fields << cf From c764e9aacf68e9f8a2f2a30d2af3c82f977ba4b3 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 16 Jan 2025 11:06:04 +0100 Subject: [PATCH 32/33] [59539] Reschedule successor when deleting a follows relation When there are multiple predecessors for one work package, and one of them is deleted, the successor must be rescheduled. Common rescheduling code with `CreateService` and `UpdateService` has been moved to `Relations::Concerns::Rescheduling` module. --- app/services/relations/base_service.rb | 47 +----- .../relations/concerns/rescheduling.rb | 79 +++++++++ app/services/relations/delete_service.rb | 32 +++- spec/factories/type_factory.rb | 4 + .../services/relations/delete_service_spec.rb | 150 ++++++++++++++++++ spec/support/table_helpers/table.rb | 17 +- 6 files changed, 279 insertions(+), 50 deletions(-) create mode 100644 app/services/relations/concerns/rescheduling.rb create mode 100644 spec/services/relations/delete_service_spec.rb diff --git a/app/services/relations/base_service.rb b/app/services/relations/base_service.rb index 1fe76bf3e7d0..b30d5e26b905 100644 --- a/app/services/relations/base_service.rb +++ b/app/services/relations/base_service.rb @@ -29,6 +29,7 @@ class Relations::BaseService < BaseServices::BaseCallable include Contracted include Shared::ServiceContext + include Relations::Concerns::Rescheduling attr_accessor :user @@ -61,50 +62,4 @@ def set_defaults(model) model.lag = nil end end - - def reschedule(relation) - schedule_result = WorkPackages::SetScheduleService - .new(user:, - work_package: relation.predecessor, - switching_to_automatic_mode: switching_to_automatic_mode(relation)) - .call - - # The predecessor work package will not be altered by the schedule service so - # we do not have to save the result of the service, only the dependent results. - save_result = if schedule_result.success? - schedule_result.dependent_results.all? { |dr| !dr.result.changed? || dr.result.save(validate: false) } - end || false - - schedule_result.success = save_result - - schedule_result - end - - def switching_to_automatic_mode(relation) - if should_switch_successor_to_automatic_mode?(relation) - [relation.successor] - else - [] - end - end - - def should_switch_successor_to_automatic_mode?(relation) - relation.follows? \ - && creating? \ - && last_successor_relation?(relation) \ - && has_no_children?(relation.successor) - end - - def creating? - self.class.name.include?("Create") - end - - def last_successor_relation?(relation) - Relation.follows.of_successor(relation.successor) - .not_of_predecessor(relation.predecessor).none? - end - - def has_no_children?(work_package) - !WorkPackage.exists?(parent: work_package) - end end diff --git a/app/services/relations/concerns/rescheduling.rb b/app/services/relations/concerns/rescheduling.rb new file mode 100644 index 000000000000..6707a35c40c9 --- /dev/null +++ b/app/services/relations/concerns/rescheduling.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Relations::Concerns + module Rescheduling + def reschedule(relation) + schedule_result = WorkPackages::SetScheduleService + .new(user:, + work_package: relation.predecessor, + switching_to_automatic_mode: switching_to_automatic_mode(relation)) + .call + + # The predecessor work package will not be altered by the schedule service so + # we do not have to save the result of the service, only the dependent results. + save_result = if schedule_result.success? + schedule_result.dependent_results.all? { |dr| !dr.result.changed? || dr.result.save(validate: false) } + end || false + + schedule_result.success = save_result + + schedule_result + end + + def switching_to_automatic_mode(relation) + if should_switch_successor_to_automatic_mode?(relation) + [relation.successor] + else + [] + end + end + + def should_switch_successor_to_automatic_mode?(relation) + relation.follows? \ + && creating? \ + && first_successor_relation?(relation) \ + && has_no_children?(relation.successor) + end + + def creating? + self.class.name.include?("Create") + end + + def first_successor_relation?(relation) + Relation.follows.of_successor(relation.successor) + .not_of_predecessor(relation.predecessor).none? + end + + def has_no_children?(work_package) + !WorkPackage.exists?(parent: work_package) + end + end +end diff --git a/app/services/relations/delete_service.rb b/app/services/relations/delete_service.rb index 6d89133a4abf..0b65b0ec7215 100644 --- a/app/services/relations/delete_service.rb +++ b/app/services/relations/delete_service.rb @@ -27,16 +27,29 @@ #++ class Relations::DeleteService < BaseServices::Delete + include Relations::Concerns::Rescheduling + def after_perform(_result) result = super - if result.success? && successor_must_switch_to_manual_mode? - deleted_relation.successor.update(schedule_manually: true) + if result.success? + update_related_result = update_related + if update_related_result + result.merge!(update_related_result) + end end result end private + def update_related + if successor_must_switch_to_manual_mode? + switch_successor_to_manual_scheduling + elsif successor_must_be_rescheduled? + reschedule_successor + end + end + def deleted_relation model end @@ -47,6 +60,21 @@ def successor_must_switch_to_manual_mode? && was_last_relation_to_the_successor? end + def switch_successor_to_manual_scheduling + deleted_relation.successor.update(schedule_manually: true) + ServiceResult.success(dependent_results: [ServiceResult.success(result: deleted_relation.successor)]) + end + + def successor_must_be_rescheduled? + deleted_relation.follows? \ + && !was_last_relation_to_the_successor? + end + + def reschedule_successor + some_sibling_relation = Relation.follows.of_successor(deleted_relation.successor).first + reschedule(some_sibling_relation) + end + def successor_has_dates? deleted_relation.successor.start_date.present? || deleted_relation.successor.due_date.present? end diff --git a/spec/factories/type_factory.rb b/spec/factories/type_factory.rb index 27c3beb06ce5..878755a291f2 100644 --- a/spec/factories/type_factory.rb +++ b/spec/factories/type_factory.rb @@ -58,6 +58,10 @@ end end + trait :default do + is_default { true } + end + factory :type_standard, class: "::Type" do name { "None" } is_standard { true } diff --git a/spec/services/relations/delete_service_spec.rb b/spec/services/relations/delete_service_spec.rb new file mode 100644 index 000000000000..c7ca086233ff --- /dev/null +++ b/spec/services/relations/delete_service_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" + +RSpec.describe Relations::DeleteService do + # this mandatory custom field is used to make work packages invalid in tests + shared_let(:mandatory_custom_field) do + create(:integer_wp_custom_field, is_required: true, is_for_all: true, default_value: nil) + end + shared_let(:priority) { create(:priority) } + shared_let(:type_task) { create(:type_task, :default) } + shared_let(:type_with_mandatory_cf) do + create(:type, + name: "Type with mandatory custom field", + custom_fields: [mandatory_custom_field]) + end + shared_let(:project) do + create(:project, + types: [type_task, type_with_mandatory_cf], + work_package_custom_fields: [mandatory_custom_field]) + end + shared_let(:status) { create(:status) } + shared_let(:user) { create(:user) } + shared_let(:admin) { create(:admin) } + + before_all do + set_factory_default(:priority, priority) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status) + set_factory_default(:type, type_task) + set_factory_default(:user, user) + end + + shared_let(:admin) { create(:admin) } + + subject(:delete_service_result) do + described_class.new(user: admin, model: relation_to_delete).call + end + + context "for predecessors/successors relations" do + context "when the successor no longer has any predecessors" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + predecessor | XX | manual | + work_package | X | automatic | predecessor + TABLE + + let(:relation_to_delete) { _table.relation(predecessor: predecessor) } + + it "removes the relation and switches the work package to manual scheduling mode" do + expect(delete_service_result).to be_success + expect(subject.all_results).to contain_exactly(relation_to_delete, work_package) + + expect(work_package.relations.count).to eq 0 + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | scheduling mode + predecessor | XX | manual + work_package | X | manual + TABLE + end + + context "when the successor is invalid (missing required custom field for instance)" do + before do + work_package.update_attribute(:type, type_with_mandatory_cf) + end + + it "still updates correctly" do + # ensure the work package is invalid as intended + expect(work_package).not_to be_valid + + expect(delete_service_result).to be_success + + # work package has been changed to manual scheduling though still invalid + expect { work_package.reload }.to change(work_package, :schedule_manually) + expect(work_package).not_to be_valid + end + end + end + + context "when a successor has two predecessors and the closest relation is deleted" do + let_work_packages(<<~TABLE) + subject | MTWTFSS | scheduling mode | predecessors + predecessor1 | XX | manual | + predecessor2 | X | manual | + work_package | X | automatic | predecessor1, predecessor2 + TABLE + + let(:relation_to_delete) { _table.relation(predecessor: predecessor2) } + + it "removes the relation and reschedules the successor" do + expect(delete_service_result).to be_success + expect(subject.all_results).to contain_exactly(relation_to_delete, work_package) + + expect(work_package.relations.count).to eq 1 + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | MTWTFSS | scheduling mode + predecessor1 | XX | manual + predecessor2 | X | manual + # successor has been rescheduled to start right after predecessor1 + work_package | X | automatic + TABLE + end + + context "when the successor is invalid (missing required custom field for instance)" do + before do + work_package.update_attribute(:type, type_with_mandatory_cf) + end + + it "still updates correctly" do + # ensure the work package is invalid as intended + expect(work_package).not_to be_valid + + expect(delete_service_result).to be_success + + # work package has been rescheduled though still invalid + expect { work_package.reload }.to change(work_package, :start_date) + expect(work_package).not_to be_valid + end + end + end + end +end diff --git a/spec/support/table_helpers/table.rb b/spec/support/table_helpers/table.rb index aab27841cd6a..8f5e22cf476e 100644 --- a/spec/support/table_helpers/table.rb +++ b/spec/support/table_helpers/table.rb @@ -56,8 +56,8 @@ def work_packages def relation(predecessor: nil, successor: nil) @relations.find do |relation| relation.follows? \ - && (predecessor.nil? || relation.predecessor.subject == predecessor) \ - && (successor.nil? || relation.successor.subject == successor) + && (predecessor.nil? || relation.predecessor.subject == subject_of(predecessor)) \ + && (successor.nil? || relation.successor.subject == subject_of(successor)) end end @@ -67,6 +67,19 @@ def relations private + def subject_of(object) + case object + when nil + nil + when String + object + when WorkPackage + object.subject + else + raise "Cannot find subject for #{object.inspect}" + end + end + def normalize_name(name) symbolic_name = name.to_sym return symbolic_name if @work_packages_by_identifier.has_key?(symbolic_name) From ee699b6f0d0760f089ba5f33d72370ec59a19d7f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 20 Jan 2025 09:25:01 +0100 Subject: [PATCH 33/33] Make WorkPackage factory use default factory for type if one is defined With TestProf, it's possible to define a default factory for a given factory using `FactoryBot.set_factory_default`. It activates if a factory is set for a given field, for instance `author factory: :user`. For work packages, there is no such factory defined for `type` field because we have a `after_build` hook to use the first project's type. Nevertheless, when a default factory has been set, we want to use it instead, especially as getting the first type is not deterministic when there are multiple types defined. This commit makes the WorkPackage factory use this factory default for the `type` field if one is defined. This fixes flaky test `spec/services/relations/delete_service_spec.rb` where the wrong type would be used instead of the intended one. I also updated the position of the type with mandatory custom field to be after the type task so that it would work even without the default factory. --- spec/factories/work_package_factory.rb | 2 +- spec/services/relations/delete_service_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/factories/work_package_factory.rb b/spec/factories/work_package_factory.rb index 97099bc76ed6..7535241326c9 100644 --- a/spec/factories/work_package_factory.rb +++ b/spec/factories/work_package_factory.rb @@ -74,7 +74,7 @@ end callback(:after_build) do |work_package, evaluator| - work_package.type = work_package.project.types.first unless work_package.type + work_package.type ||= TestProf::FactoryBot.get_factory_default(:type) || work_package.project.types.first custom_values = evaluator.custom_values || {} diff --git a/spec/services/relations/delete_service_spec.rb b/spec/services/relations/delete_service_spec.rb index c7ca086233ff..4cde48c1dc13 100644 --- a/spec/services/relations/delete_service_spec.rb +++ b/spec/services/relations/delete_service_spec.rb @@ -39,6 +39,7 @@ shared_let(:type_task) { create(:type_task, :default) } shared_let(:type_with_mandatory_cf) do create(:type, + position: type_task.position + 1, name: "Type with mandatory custom field", custom_fields: [mandatory_custom_field]) end