mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
396 lines
14 KiB
Ruby
396 lines
14 KiB
Ruby
# 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 UserWorkingHours do
|
|
subject(:working_hours) { build(:user_working_hours) }
|
|
|
|
describe "validations" do
|
|
it { is_expected.to be_valid }
|
|
|
|
it { is_expected.to validate_presence_of(:valid_from) }
|
|
|
|
# The *_hours virtual attributes have a converting setter (hours → minutes), so
|
|
# shoulda-matchers cannot induce invalid states through it. We bypass the setter
|
|
# and write directly to the underlying minute column instead.
|
|
%i[monday tuesday wednesday thursday friday saturday sunday].each do |day|
|
|
describe "##{day}_hours" do
|
|
it "is invalid when exceeding 24 hours" do
|
|
subject.public_send(:"#{day}=", (24.5 * 60).round)
|
|
expect(subject).not_to be_valid
|
|
expect(subject.errors[:"#{day}_hours"]).to be_present
|
|
end
|
|
|
|
it "is invalid when negative" do
|
|
subject.public_send(:"#{day}=", -60)
|
|
expect(subject).not_to be_valid
|
|
expect(subject.errors[:"#{day}_hours"]).to be_present
|
|
end
|
|
|
|
it "is valid at 0 hours" do
|
|
subject.public_send(:"#{day}=", 0)
|
|
expect(subject).to be_valid
|
|
end
|
|
|
|
it "is valid at 24 hours" do
|
|
subject.public_send(:"#{day}=", 24 * 60)
|
|
expect(subject).to be_valid
|
|
end
|
|
end
|
|
end
|
|
|
|
it { is_expected.to validate_presence_of(:availability_factor) }
|
|
|
|
it do
|
|
expect(subject).to validate_numericality_of(:availability_factor).only_integer
|
|
.is_greater_than_or_equal_to(0)
|
|
.is_less_than_or_equal_to(100)
|
|
end
|
|
end
|
|
|
|
describe "hours accessors" do
|
|
subject(:working_hours) { build(:user_working_hours, monday: 480, tuesday: 90, wednesday: 0) }
|
|
|
|
%i[monday tuesday wednesday thursday friday saturday sunday].each do |day|
|
|
describe "##{day}_hours" do
|
|
it "returns the minutes value converted to hours" do
|
|
working_hours.public_send("#{day}=", 150)
|
|
expect(working_hours.public_send("#{day}_hours")).to eq(2.5)
|
|
end
|
|
end
|
|
|
|
describe "##{day}_hours=" do
|
|
it "stores the hours value converted to minutes" do
|
|
working_hours.public_send("#{day}_hours=", 7.5)
|
|
expect(working_hours.public_send(day)).to eq(450)
|
|
end
|
|
|
|
it "rounds fractional minutes" do
|
|
working_hours.public_send("#{day}_hours=", 1.0 / 3)
|
|
expect(working_hours.public_send(day)).to eq(20)
|
|
end
|
|
end
|
|
end
|
|
|
|
# The following tests cover string parsing via `to_hours` for the `monday_hours=` setter.
|
|
# The same parsing logic applies to all `{day}_hours=` setters since they are generated identically.
|
|
describe "#monday_hours= string parsing" do
|
|
subject(:working_hours) { build(:user_working_hours) }
|
|
|
|
{
|
|
"8" => 480,
|
|
"7.5" => 450,
|
|
"7,5" => 450,
|
|
"8h" => 480,
|
|
"7.5h" => 450,
|
|
"7,5h" => 450,
|
|
"7:30" => 450,
|
|
"2h30" => 150,
|
|
"2h30m" => 150,
|
|
"2h 30m" => 150,
|
|
"2h" => 120,
|
|
"30m" => 30
|
|
}.each do |input, expected_minutes|
|
|
it "parses #{input.inspect} to #{expected_minutes} minutes" do
|
|
working_hours.monday_hours = input
|
|
expect(working_hours.monday).to eq(expected_minutes)
|
|
end
|
|
end
|
|
end
|
|
|
|
it "returns 8.0 hours for a full work day of 480 minutes" do
|
|
expect(working_hours.monday_hours).to eq(8.0)
|
|
end
|
|
|
|
it "returns 1.5 hours for 90 minutes" do
|
|
expect(working_hours.tuesday_hours).to eq(1.5)
|
|
end
|
|
|
|
it "returns 0.0 for a non-working day" do
|
|
expect(working_hours.wednesday_hours).to eq(0.0)
|
|
end
|
|
end
|
|
|
|
describe "#weekly_working_hours" do
|
|
it "sums the daily working hours for the week" do
|
|
working_hours.monday = 480
|
|
working_hours.tuesday = 240
|
|
working_hours.wednesday = 0
|
|
working_hours.thursday = 120
|
|
working_hours.friday = 480
|
|
working_hours.saturday = 0
|
|
working_hours.sunday = 0
|
|
|
|
expect(working_hours.weekly_working_hours).to eq(8.0 + 4.0 + 0.0 + 2.0 + 8.0 + 0.0 + 0.0)
|
|
end
|
|
end
|
|
|
|
describe "#effective_weekly_working_hours" do
|
|
it "calculates the effective weekly working hours based on the availability factor" do
|
|
working_hours.monday = 480
|
|
working_hours.tuesday = 240
|
|
working_hours.wednesday = 0
|
|
working_hours.thursday = 120
|
|
working_hours.friday = 480
|
|
working_hours.saturday = 0
|
|
working_hours.sunday = 0
|
|
|
|
working_hours.availability_factor = 50
|
|
expect(working_hours.effective_weekly_working_hours).to eq(((8.0 + 4.0 + 0.0 + 2.0 + 8.0) / 2.0).round(2))
|
|
end
|
|
end
|
|
|
|
describe ".valid_for_date" do
|
|
let(:user) { create(:user) }
|
|
let!(:old_hours) { create(:user_working_hours, user:, valid_from: 30.days.ago) }
|
|
let!(:recent_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) }
|
|
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) }
|
|
|
|
it "returns the most recent record valid on the given date" do
|
|
expect(described_class.for_user(user).valid_for_date(Date.current)).to eq(recent_hours)
|
|
end
|
|
|
|
it "returns the correct record for a past date" do
|
|
expect(described_class.for_user(user).valid_for_date(20.days.ago.to_date)).to eq(old_hours)
|
|
end
|
|
|
|
it "returns nil when no record is valid for the given date" do
|
|
expect(described_class.for_user(user).valid_for_date(31.days.ago.to_date)).to be_nil
|
|
end
|
|
|
|
it "does not return future records" do
|
|
expect(described_class.for_user(user).valid_for_date(Date.current)).not_to eq(future_hours)
|
|
end
|
|
end
|
|
|
|
describe ".current" do
|
|
let(:user) { create(:user) }
|
|
let!(:past_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) }
|
|
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) }
|
|
|
|
it "returns the currently valid record" do
|
|
expect(described_class.for_user(user).current).to eq(past_hours)
|
|
end
|
|
|
|
it "does not return future records" do
|
|
expect(described_class.for_user(user).current).not_to eq(future_hours)
|
|
end
|
|
end
|
|
|
|
describe ".past" do
|
|
let(:user) { create(:user) }
|
|
let!(:older_hours) { create(:user_working_hours, user:, valid_from: 20.days.ago) }
|
|
let!(:recent_past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) }
|
|
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) }
|
|
|
|
it "returns records with valid_from before today" do
|
|
expect(described_class.for_user(user).past).to contain_exactly(older_hours, recent_past_hours)
|
|
end
|
|
|
|
it "orders results descending by valid_from" do
|
|
expect(described_class.for_user(user).past).to eq([recent_past_hours, older_hours])
|
|
end
|
|
|
|
it "excludes future records" do
|
|
expect(described_class.for_user(user).past).not_to include(future_hours)
|
|
end
|
|
end
|
|
|
|
describe ".upcoming" do
|
|
let(:user) { create(:user) }
|
|
let!(:past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) }
|
|
let!(:near_future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) }
|
|
let!(:far_future_hours) { create(:user_working_hours, user:, valid_from: 20.days.from_now) }
|
|
|
|
it "returns records with valid_from from today onwards" do
|
|
expect(described_class.for_user(user).upcoming).to contain_exactly(near_future_hours, far_future_hours)
|
|
end
|
|
|
|
it "orders results ascending by valid_from" do
|
|
expect(described_class.for_user(user).upcoming).to eq([near_future_hours, far_future_hours])
|
|
end
|
|
|
|
it "excludes past records" do
|
|
expect(described_class.for_user(user).upcoming).not_to include(past_hours)
|
|
end
|
|
end
|
|
|
|
describe "#working_day_ranges" do
|
|
def build_hours(**day_minutes)
|
|
attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }
|
|
build(:user_working_hours, **attrs, **day_minutes)
|
|
end
|
|
|
|
it "returns Monday-Friday for a standard work week regardless of hour differences" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360)
|
|
expect(wh.working_day_ranges).to eq("Monday-Friday")
|
|
end
|
|
|
|
it "splits ranges at non-working days" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
|
|
expect(wh.working_day_ranges).to eq("Monday-Tuesday, Thursday-Friday")
|
|
end
|
|
|
|
it "returns a single day name when only one day is working" do
|
|
wh = build_hours(wednesday: 480)
|
|
expect(wh.working_day_ranges).to eq("Wednesday")
|
|
end
|
|
|
|
it "returns an empty string when no days are working" do
|
|
wh = build_hours
|
|
expect(wh.working_day_ranges).to eq("")
|
|
end
|
|
|
|
context "with German locale" do
|
|
around { |example| I18n.with_locale(:de) { example.run } }
|
|
|
|
it "uses full German day names" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
|
|
expect(wh.working_day_ranges).to eq("Montag-Dienstag, Donnerstag-Freitag")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#working_days_summary" do
|
|
def build_hours(**day_minutes)
|
|
attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }
|
|
build(:user_working_hours, **attrs, **day_minutes)
|
|
end
|
|
|
|
it "returns Mon-Fri 8h when all working days share the same hours" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480)
|
|
expect(wh.working_days_summary).to eq("Mon-Fri 8h")
|
|
end
|
|
|
|
it "returns Mon-Thu 8h, Fri 6h when one day differs" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360)
|
|
expect(wh.working_days_summary).to eq("Mon-Thu 8h, Fri 6h")
|
|
end
|
|
|
|
it "returns separate segments when multiple groups alternate" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360)
|
|
expect(wh.working_days_summary).to eq("Mon-Tue 8h, Wed 6h, Thu 8h, Fri 6h")
|
|
end
|
|
|
|
it "splits into separate ranges when days are missing in the middle" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
|
|
expect(wh.working_days_summary).to eq("Mon-Tue 8h, Thu-Fri 8h")
|
|
end
|
|
|
|
it "returns an empty string when no days are working" do
|
|
wh = build_hours
|
|
expect(wh.working_days_summary).to eq("")
|
|
end
|
|
|
|
it "returns a single day label when only one day is working" do
|
|
wh = build_hours(friday: 480)
|
|
expect(wh.working_days_summary).to eq("Fri 8h")
|
|
end
|
|
|
|
it "formats fractional hours without trailing zeros" do
|
|
wh = build_hours(monday: 450, tuesday: 450)
|
|
expect(wh.working_days_summary).to eq("Mon-Tue 7.5h")
|
|
end
|
|
|
|
it "formats whole hours without a decimal" do
|
|
wh = build_hours(monday: 480)
|
|
expect(wh.working_days_summary).to eq("Mon 8h")
|
|
end
|
|
|
|
it "includes weekend days when they are working days" do
|
|
wh = build_hours(saturday: 240, sunday: 240)
|
|
expect(wh.working_days_summary).to eq("Sat-Sun 4h")
|
|
end
|
|
|
|
it "handles a single weekend day" do
|
|
wh = build_hours(saturday: 480)
|
|
expect(wh.working_days_summary).to eq("Sat 8h")
|
|
end
|
|
|
|
context "with German locale" do
|
|
around { |example| I18n.with_locale(:de) { example.run } }
|
|
|
|
it "uses German abbreviations for a simple Mon-Fri range" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480)
|
|
expect(wh.working_days_summary).to eq("Mo-Fr 8h")
|
|
end
|
|
|
|
it "uses German abbreviations when one day differs" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360)
|
|
expect(wh.working_days_summary).to eq("Mo-Do 8h, Fr 6h")
|
|
end
|
|
|
|
it "uses German abbreviations when days are missing in the middle" do
|
|
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
|
|
expect(wh.working_days_summary).to eq("Mo-Di 8h, Do-Fr 8h")
|
|
end
|
|
|
|
it "uses German abbreviations for weekend days" do
|
|
wh = build_hours(saturday: 240, sunday: 240)
|
|
expect(wh.working_days_summary).to eq("Sa-So 4h")
|
|
end
|
|
|
|
it "uses a comma as the decimal separator for fractional hours" do
|
|
wh = build_hours(monday: 450, tuesday: 450)
|
|
expect(wh.working_days_summary).to eq("Mo-Di 7,5h")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".visible" do
|
|
let(:user) { create(:user) }
|
|
let(:other_user) { create(:user) }
|
|
let!(:user_hours) { create(:user_working_hours, user:) }
|
|
let!(:other_hours) { create(:user_working_hours, user: other_user) }
|
|
|
|
context "when the viewer has :manage_working_times permission" do
|
|
let(:viewer) { create(:user, global_permissions: [:manage_working_times]) }
|
|
|
|
it "returns all records" do
|
|
expect(described_class.visible(viewer)).to contain_exactly(user_hours, other_hours)
|
|
end
|
|
end
|
|
|
|
context "when the viewer has no special permissions" do
|
|
let(:viewer) { create(:user) }
|
|
let!(:viewer_hours) { create(:user_working_hours, user: viewer) }
|
|
|
|
it "returns only their own records" do
|
|
expect(described_class.visible(viewer)).to contain_exactly(viewer_hours)
|
|
end
|
|
|
|
it "excludes other users' records" do
|
|
expect(described_class.visible(viewer)).not_to include(user_hours, other_hours)
|
|
end
|
|
end
|
|
end
|
|
end
|