Skip to content

Commit

Permalink
Work or Remaining work cannot be unset if the other is set
Browse files Browse the repository at this point in the history
This is to ensure that the total work, total remaining work, and total
% Complete are always consistent.

See https://community.openproject.org/wp/40749 for the detailed
specification.
  • Loading branch information
cbliard committed Mar 28, 2024
1 parent 19948f5 commit 0884a6e
Show file tree
Hide file tree
Showing 3 changed files with 69 additions and 82 deletions.
31 changes: 25 additions & 6 deletions app/contracts/work_packages/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ class BaseContract < ::ModelContract
attribute :derived_done_ratio,
writable: false

attribute :estimated_hours
attribute :estimated_hours do
validate_work_is_set_when_remaining_work_is_set
end
attribute :derived_estimated_hours,
writable: false

attribute :remaining_hours do
validate_remaining_hours_doesnt_exceed_estimated_hours
validate_remaining_work_is_lower_than_work
validate_remaining_work_is_set_when_work_is_set
end
attribute :derived_remaining_hours,
writable: false
Expand Down Expand Up @@ -320,8 +323,8 @@ def validate_version_is_assignable
end
end

def validate_remaining_hours_doesnt_exceed_estimated_hours
if both_remaining_and_estimated_hours_present? && remaining_work_exceeds_work?
def validate_remaining_work_is_lower_than_work
if work_set? && remaining_work_set? && remaining_work_exceeds_work?
if model.changed.include?("estimated_hours")
errors.add(:estimated_hours, :cant_be_inferior_to_remaining_work)
end
Expand All @@ -332,8 +335,24 @@ def validate_remaining_hours_doesnt_exceed_estimated_hours
end
end

def both_remaining_and_estimated_hours_present?
model.remaining_hours.present? && model.estimated_hours.present?
def validate_remaining_work_is_set_when_work_is_set
if work_set? && !remaining_work_set?
errors.add(:remaining_hours, :must_be_set_when_work_is_set)
end
end

def validate_work_is_set_when_remaining_work_is_set
if remaining_work_set? && !work_set?
errors.add(:estimated_hours, :must_be_set_when_remaining_work_is_set)
end
end

def work_set?
model.estimated_hours.present?
end

def remaining_work_set?
model.remaining_hours.present?
end

def remaining_work_exceeds_work?
Expand Down
6 changes: 4 additions & 2 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1024,10 +1024,12 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url}
only_same_project_categories_allowed: "The category of a work package must be within the same project as the work package."
does_not_exist: "The specified category does not exist."
estimated_hours:
cant_be_inferior_to_remaining_work: "can't be inferior to 'Remaining work'."
must_be_set_when_remaining_work_is_set: "must be set when 'Remaining work' is set."
only_values_greater_or_equal_zeroes_allowed: "must be >= 0."
cant_be_inferior_to_remaining_work: "can't be inferior to 'Remaining work'"
remaining_hours:
cant_exceed_work: "can't exceed 'Work'"
cant_exceed_work: "can't exceed 'Work'."
must_be_set_when_work_is_set: "must be set when 'Work' is set."
readonly_status: "The work package is in a readonly status so its attributes cannot be changed."
type:
attributes:
Expand Down
114 changes: 40 additions & 74 deletions spec/contracts/work_packages/base_contract_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
require "contracts/shared/model_contract_shared_context"

RSpec.describe WorkPackages::BaseContract do
include_context "ModelContract shared context"

let(:work_package) do
build_stubbed(:work_package,
type:,
Expand Down Expand Up @@ -66,8 +68,6 @@

subject(:contract) { described_class.new(work_package, current_user) }

include_context "ModelContract shared context"

shared_examples_for "invalid if changed" do |attribute|
before do
allow(work_package).to receive(:changed).and_return(changed_values.map(&:to_s))
Expand Down Expand Up @@ -290,76 +290,58 @@
end
end

describe "estimated hours" do
describe "work (estimated hours)" do
let(:estimated_hours) { 1 }
let(:remaining_hours) { 0 }

before do
work_package.estimated_hours = estimated_hours
work_package.remaining_hours = nil
work_package.remaining_hours = remaining_hours
work_package.clear_remaining_hours_change
end

context "when > 0" do
let(:estimated_hours) { 1 }

it "is valid" do
contract.validate

expect(subject.errors.symbols_for(:estimated_hours))
.to be_empty
end
include_examples "contract is valid"
end

context "when 0" do
let(:estimated_hours) { 0 }

it "is valid" do
contract.validate

expect(subject.errors.symbols_for(:estimated_hours))
.to be_empty
end
include_examples "contract is valid"
end

context "when nil" do
context "when nil while remaining work is nil" do
let(:estimated_hours) { nil }
let(:remaining_hours) { nil }

it "is valid" do
contract.validate
include_examples "contract is valid"
end

expect(subject.errors.symbols_for(:estimated_hours))
.to be_empty
end
context "when nil while remaining work is set" do
let(:estimated_hours) { nil }
let(:remaining_hours) { 2.0 }

include_examples "contract is invalid", estimated_hours: :must_be_set_when_remaining_work_is_set
end

context "when < 0" do
let(:estimated_hours) { -1 }
let(:remaining_hours) { -2 }

it "is invalid" do
contract.validate

expect(subject.errors.symbols_for(:estimated_hours))
.to contain_exactly(:only_values_greater_or_equal_zeroes_allowed)
end
include_examples "contract is invalid", estimated_hours: :only_values_greater_or_equal_zeroes_allowed
end

context "when inferior to remaining_hours" do
context "when inferior to remaining work" do
let(:estimated_hours) { 5.0 }
let(:remaining_hours) { 7.0 }

before do
work_package.remaining_hours = 6.0
allow(work_package).to receive(:changed).and_return(%w[estimated_hours])
end

it "is invalid" do
contract.validate

expect(subject.errors.symbols_for(:estimated_hours))
.to contain_exactly(:cant_be_inferior_to_remaining_work)
end
include_examples "contract is invalid", estimated_hours: :cant_be_inferior_to_remaining_work
end
end

describe "derived estimated hours" do
describe "total work (derived estimated hours)" do
let(:changed_values) { [] }
let(:attribute) { :derived_estimated_hours }

Expand All @@ -384,63 +366,47 @@
end
end

describe "remaining hours" do
describe "remaining work (remaining hours)" do
before do
work_package.estimated_hours = 5.0
work_package.clear_remaining_hours_change
work_package.remaining_hours = remaining_hours

allow(work_package).to receive(:changed).and_return(%w[remaining_hours])
end

context "when it's the same as estimated_hours" do
context "when it's the same as work" do
let(:remaining_hours) { 5.0 }

it "is valid" do
contract.validate

expect(subject.errors.symbols_for(:remaining_hours))
.to be_empty
end
include_examples "contract is valid"
end

context "when it's less than estimated_hours" do
context "when it's less than work" do
let(:remaining_hours) { 4.0 }

it "is valid" do
contract.validate

expect(subject.errors.symbols_for(:remaining_hours))
.to be_empty
end
include_examples "contract is valid"
end

context "when it's greater than estimated_hours" do
context "when it's greater than work" do
let(:remaining_hours) { 6.0 }

it "is invalid" do
contract.validate
include_examples "contract is invalid", remaining_hours: :cant_exceed_work
end

expect(subject.errors.symbols_for(:remaining_hours))
.to contain_exactly(:cant_exceed_work)
end
context "when unset" do
let(:remaining_hours) { nil }

include_examples "contract is invalid", remaining_hours: :must_be_set_when_work_is_set
end
end

describe "estimated_hours and remaining_hours" do
context "when changing both and remaining_hours exceeds estimated_hours" do
describe "work and remaining work" do
context "when changing both and remaining work exceeds work" do
before do
work_package.estimated_hours = 5.0
work_package.remaining_hours = 6.0
end

it "is invalid" do
contract.validate

expect(subject.errors.symbols_for(:estimated_hours))
.to contain_exactly(:cant_be_inferior_to_remaining_work)
expect(subject.errors.symbols_for(:remaining_hours))
.to contain_exactly(:cant_exceed_work)
end
include_examples "contract is invalid", estimated_hours: :cant_be_inferior_to_remaining_work,
remaining_hours: :cant_exceed_work
end
end

Expand Down

0 comments on commit 0884a6e

Please sign in to comment.