Files
openproject/spec/lib/chronic_duration_spec.rb
T

480 lines
18 KiB
Ruby

# frozen_string_literal: true
# Copied from https://gitlab.com/gitlab-org/ruby/gems/gitlab-chronic-duration
# version 0.12.0
#
# Copyright (c) Henry Poydar
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# NOTE:
# Changes to this file should be kept in sync with
# frontend/src/app/shared/helpers/chronic_duration.js.
require "rspec_helper"
require "chronic_duration"
INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes hours_colon_minutes].freeze
RSpec.describe ChronicDuration do
describe ".parse" do
exemplars = {
"1:20" => 60 + 20,
"1:20.51" => 60 + 20.51,
"4:01:01" => (4 * 3600) + 60 + 1,
"3 mins 4 sec" => (3 * 60) + 4,
"3 Mins 4 Sec" => (3 * 60) + 4,
"2 hrs 20 min" => (2 * 3600) + (20 * 60),
"2h20min" => (2 * 3600) + (20 * 60),
"6 mos 1 day" => (6 * 30 * 24 * 3600) + (24 * 3600),
"1 year 6 mos 1 day" => (1 * 31557600) + (6 * 30 * 24 * 3600) + (24 * 3600),
"2.5 hrs" => 2.5 * 3600,
"47 yrs 6 mos and 4.5d" => (47 * 31557600) + (6 * 30 * 24 * 3600) + (4.5 * 24 * 3600),
"3 weeks and, 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"3 weeks, plus 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"3 weeks with 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"1 month" => 3600 * 24 * 30,
"2 months" => 3600 * 24 * 30 * 2,
"18 months" => 3600 * 24 * 30 * 18,
"1 year 6 months" => (3600 * 24 * (365.25 + (6 * 30))).to_i,
"day" => 3600 * 24,
"minute 30s" => 90
}
context "when string can't be parsed" do
let(:raise_exception) { false }
before do
allow(described_class).to receive(:raise_exceptions).and_return(raise_exception)
end
it "returns nil" do
expect(described_class.parse("gobblygoo")).to be_nil
end
it "cannot parse zero" do
expect(described_class.parse("0")).to be_nil
end
context "when @@raise_exceptions set to true" do
let(:raise_exception) { true }
it "raises with ChronicDuration::DurationParseError" do
expect { described_class.parse("23 gobblygoos") }.to raise_error(ChronicDuration::DurationParseError)
end
context "when passing `raise_exceptions: false` as an option" do
it "overrides @@raise_exception and returns nil" do
expect(described_class.parse("gobblygoos", raise_exceptions: false)).to be_nil
end
end
end
context "when passing `raise_exceptions: true` as an option" do
it "overrides @@raise_exception and raises with ChronicDuration::DurationParseError" do
expect { described_class.parse("23 gobblygoos", raise_exceptions: true) }
.to raise_error(ChronicDuration::DurationParseError)
end
end
end
it "returns zero if the string parses as zero and the keep_zero option is true" do
expect(described_class.parse("0", keep_zero: true)).to eq(0)
end
it "returns a float if seconds are in decimals" do
expect(described_class.parse("12 mins 3.141 seconds")).to be_a(Float)
end
it "returns an integer unless the seconds are in decimals" do
expect(described_class.parse("12 mins 3 seconds")).to be_a(Integer)
end
it "is able to parse minutes by default" do
expect(described_class.parse("5", default_unit: "minutes")).to eq(300)
end
# Tests for intelligent unit inference
context "when using intelligent unit inference" do
it "interprets subsequent numbers without units as next smaller unit" do
expect(described_class.parse("2 hours 15", default_unit: "hours")).to eq(8100) # 2h 15m = 8100s
end
it "handles multiple numbers without units in descending order" do
expect(described_class.parse("2 hours 15 30", default_unit: "hours")).to eq(8130) # 2h 15m 30s = 8130s
end
it "works with different starting units" do
expect(described_class.parse("1 day 5", default_unit: "days")).to eq(104400) # 1d 5h = 104400s
expect(described_class.parse("3 minutes 45", default_unit: "minutes")).to eq(225) # 3m 45s = 225s
expect(described_class.parse("1 week 2", default_unit: "weeks")).to eq(777600) # 1w 2d = 777600s
end
it "falls back to default unit when no previous unit exists" do
expect(described_class.parse("5", default_unit: "minutes")).to eq(300) # 5m = 300s
expect(described_class.parse("10", default_unit: "hours")).to eq(36000) # 10h = 36000s
end
it "handles mixed explicit and implicit units" do
expect(described_class.parse("1 hour 30 20 seconds", default_unit: "hours")).to eq(5420) # 1h 30m 20s = 5420s
end
it "keeps using smallest unit when already at seconds" do
expect(described_class.parse("3 minutes 45 10", default_unit: "minutes")).to eq(235) # 3m 45s 10s = 235s
end
it "works with fractional numbers" do
expect(described_class.parse("2.5 hours 30", default_unit: "hours")).to eq(10800) # 2.5h 30m = 10800s
expect(described_class.parse("1 hour 15.5", default_unit: "hours")).to eq(4530) # 1h 15.5m = 4530s
end
it "maintains backward compatibility with existing formats" do
expect(described_class.parse("2 hours 20 minutes")).to eq(8400) # Explicit units should still work
expect(described_class.parse("1 day 5 hours 30 minutes")).to eq(106200) # Multiple explicit units
end
it "handles edge cases correctly" do
# Multiple numbers without units at the beginning (should use default)
expect(described_class.parse("5 10", default_unit: "minutes")).to eq(900) # 5m + 10m = 900s
# Unit at the end with numbers before - first number uses default, then intelligent inference kicks in
expect(described_class.parse("2 15 minutes")).to eq(902) # 2s (default) + 15m = 902s
# Unit at beginning gets implicit "1", then intelligent inference
expect(described_class.parse("minutes 2 15")).to eq(77) # 1m + 2s + 15s = 77s
end
end
exemplars.each do |k, v|
it "parses a duration like #{k}" do
expect(described_class.parse(k)).to eq(v)
end
end
context "with :hours_per_day and :days_per_month params" do
it "uses provided :hours_per_day" do
expect(described_class.parse("1d", hours_per_day: 24)).to eq(24 * 60 * 60)
expect(described_class.parse("1d", hours_per_day: 8)).to eq(8 * 60 * 60)
end
it "uses provided :days_per_month" do
expect(described_class.parse("1mo", days_per_month: 30)).to eq(30 * 24 * 60 * 60)
expect(described_class.parse("1mo", days_per_month: 20)).to eq(20 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 30)).to eq(7 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 20)).to eq(5 * 24 * 60 * 60)
end
it "uses provided both :hours_per_day and :days_per_month" do
expect(described_class.parse("1mo", days_per_month: 30, hours_per_day: 24)).to eq(30 * 24 * 60 * 60)
expect(described_class.parse("1mo", days_per_month: 20, hours_per_day: 8)).to eq(20 * 8 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 30, hours_per_day: 24)).to eq(7 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 20, hours_per_day: 8)).to eq(5 * 8 * 60 * 60)
end
end
end
describe ".output" do
exemplars = {
(60 + 20) =>
{
micro: "1m20s",
short: "1m 20s",
default: "1 min 20 secs",
long: "1 minute 20 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
hours_colon_minutes: "0:02 h",
chrono: "1:20"
},
(60 + 20.51) =>
{
micro: "1m20.51s",
short: "1m 20.51s",
default: "1 min 20.51 secs",
long: "1 minute 20.51 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
hours_colon_minutes: "0:02 h",
chrono: "1:20.51"
},
(60 + 20.51928) =>
{
micro: "1m20.51928s",
short: "1m 20.51928s",
default: "1 min 20.51928 secs",
long: "1 minute 20.51928 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
hours_colon_minutes: "0:02 h",
chrono: "1:20.51928"
},
((4 * 3600) + 60 + 1) =>
{
micro: "4h1m1s",
short: "4h 1m 1s",
default: "4 hrs 1 min 1 sec",
long: "4 hours 1 minute 1 second",
days_and_hours: "4.02h",
hours_only: "4.02h",
hours_and_minutes: "4h 1m",
hours_colon_minutes: "4:02 h",
chrono: "4:01:01"
},
((2 * 3600) + (20 * 60)) =>
{
micro: "2h20m",
short: "2h 20m",
default: "2 hrs 20 mins",
long: "2 hours 20 minutes",
days_and_hours: "2.33h",
hours_only: "2.33h",
hours_and_minutes: "2h 20m",
hours_colon_minutes: "2:20 h",
chrono: "2:20:00"
},
((8 * 24 * 3600) + (3 * 3600) + (30 * 60)) =>
{
micro: "8d3h30m",
short: "8d 3h 30m",
default: "8 days 3 hrs 30 mins",
long: "8 days 3 hours 30 minutes",
days_and_hours: "8d 3.5h",
hours_only: "195.5h",
hours_and_minutes: "195h 30m",
hours_colon_minutes: "195:30 h",
chrono: "8:03:30:00"
},
((6 * 30 * 24 * 3600) + (24 * 3600)) =>
{
micro: "6mo1d",
short: "6mo 1d",
default: "6 mos 1 day",
long: "6 months 1 day",
days_and_hours: "181d 0h",
hours_only: "4344h",
hours_and_minutes: "4344h",
hours_colon_minutes: "4344:00 h",
chrono: "6:01:00:00:00" # Yuck. FIXME
},
((365.25 * 24 * 3600) + (24 * 3600)).to_i =>
{
micro: "1y1d",
short: "1y 1d",
default: "1 yr 1 day",
long: "1 year 1 day",
days_and_hours: "366d 0h",
hours_only: "8790h",
hours_and_minutes: "8790h",
hours_colon_minutes: "8790:00 h",
chrono: "1:00:01:00:00:00"
},
((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i =>
{
micro: "3y1d",
short: "3y 1d",
default: "3 yrs 1 day",
long: "3 years 1 day",
days_and_hours: "1096d 0h",
hours_only: "26322h",
hours_and_minutes: "26322h",
hours_colon_minutes: "26322:00 h",
chrono: "3:00:01:00:00:00"
},
((6 * 365.25 * 24 * 3600) + (3 * 3600)).to_i =>
{
micro: "6y3h",
short: "6y 3h",
default: "6 yrs 3 hrs",
long: "6 years 3 hours",
days_and_hours: "2191d 3h",
hours_only: "52599h",
hours_and_minutes: "52599h",
hours_colon_minutes: "52599:00 h",
chrono: "6:00:00:03:00:00"
},
(3600 * 24 * 30 * 18) =>
{
micro: "18mo",
short: "18mo",
default: "18 mos",
long: "18 months",
days_and_hours: "540d 0h",
hours_only: "12960h",
hours_and_minutes: "12960h",
hours_colon_minutes: "12960:00 h",
chrono: "18:00:00:00:00"
}
}
exemplars.each do |k, v|
v.each do |key, val|
it "properly outputs a duration of #{k} seconds as #{val} using the #{key} format option" do
expect(described_class.output(k, format: key)).to eq(val)
end
end
end
keep_zero_exemplars = {
true =>
{
micro: "0s",
short: "0s",
default: "0 secs",
long: "0 seconds",
days_and_hours: "0h",
hours_colon_minutes: "0:00 h",
chrono: "0"
},
false =>
{
micro: nil,
short: nil,
default: nil,
long: nil,
days_and_hours: "0h",
hours_colon_minutes: "0:00 h",
chrono: "0"
}
}
keep_zero_exemplars.each do |k, v|
v.each do |key, val|
it "outputs properly a duration of 0 seconds as #{val.nil? ? 'nil' : val} using the #{key} format option, " \
"if the keep_zero option is #{k}" do
expect(described_class.output(0, format: key, keep_zero: k)).to eq(val)
end
end
end
it "returns weeks when needed" do
expect(described_class.output(45 * 24 * 60 * 60, weeks: true)).to match(/.*wk.*/)
end
it "returns hours and minutes only when :hours_only option specified" do
expect(described_class.output((395 * 24 * 60 * 60) + (15 * 60), limit_to_hours: true)).to eq("9480 hrs 15 mins")
end
context "with :hours_per_day and :days_per_month params" do
it "uses provided :hours_per_day" do
expect(described_class.output(24 * 60 * 60, hours_per_day: 24)).to eq("1 day")
expect(described_class.output(24 * 60 * 60, hours_per_day: 8)).to eq("3 days")
end
it "uses provided :days_per_month" do
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 30)).to eq("1 wk")
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 20)).to eq("1 wk 2 days")
end
it "uses provided both :hours_per_day and :days_per_month" do
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 30, hours_per_day: 24)).to eq("1 wk")
expect(described_class.output(5 * 8 * 60 * 60, weeks: true, days_per_month: 20, hours_per_day: 8)).to eq("1 wk")
end
it "uses provided params alongside with :weeks when converting to months" do
expect(described_class.output(30 * 24 * 60 * 60, days_per_month: 30, hours_per_day: 24)).to eq("1 mo")
expect(described_class.output(30 * 24 * 60 * 60, days_per_month: 30, hours_per_day: 24, weeks: true)).to eq("1 mo 2 days")
expect(described_class.output(20 * 8 * 60 * 60, days_per_month: 20, hours_per_day: 8)).to eq("1 mo")
expect(described_class.output(20 * 8 * 60 * 60, days_per_month: 20, hours_per_day: 8, weeks: true)).to eq("1 mo")
end
end
it "returns the specified number of units if provided" do
expect(described_class.output((4 * 3600) + 60 + 1, units: 2)).to eq("4 hrs 1 min")
expect(described_class.output((6 * 30 * 24 * 3600) + (24 * 3600) + 3600 + 60 + 1,
units: 3,
format: :long)).to eq("6 months 1 day 1 hour")
end
context "when the format is not specified" do
it "uses the default format" do
expect(described_class.output((2 * 3600) + (20 * 60))).to eq("2 hrs 20 mins")
end
end
exemplars.each do |seconds, format_spec|
format_spec.each_key do |format|
next if INACCURATE_FORMATS.include?(format)
it "outputs a duration for #{seconds} that parses back to the same thing when using the #{format} format" do
expect(described_class.parse(
described_class.output(seconds, format:, use_complete_matcher: true)
)).to eq(seconds)
end
end
end
it "uses user-specified joiner if provided" do
expect(described_class.output((2 * 3600) + (20 * 60), joiner: ", ")).to eq("2 hrs, 20 mins")
end
end
describe ".filter_by_type" do
it "receives a chrono-formatted time like 3:14 and return a human time like 3 minutes 14 seconds" do
expect(described_class.instance_eval("filter_by_type('3:14')", __FILE__, __LINE__)).to eq("3 minutes 14 seconds")
end
it "receives chrono-formatted time like 12:10:14 and return a human time like 12 hours 10 minutes 14 seconds" do
expect(described_class.instance_eval("filter_by_type('12:10:14')", __FILE__,
__LINE__ - 1)).to eq("12 hours 10 minutes 14 seconds")
end
it "returns the input if it's not a chrono-formatted time" do
expect(described_class.instance_eval("filter_by_type('4 hours')", __FILE__, __LINE__)).to eq("4 hours")
end
end
describe ".cleanup" do
it "cleans up extraneous words" do
expect(described_class.instance_eval("cleanup('4 days and 11 hours')", __FILE__, __LINE__)).to eq("4 days 11 hours")
end
it "cleans up extraneous spaces" do
expect(described_class.instance_eval("cleanup(' 4 days and 11 hours')", __FILE__, __LINE__)).to eq("4 days 11 hours")
end
it "inserts spaces where there aren't any" do
expect(described_class.instance_eval("cleanup('4m11.5s')", __FILE__, __LINE__)).to eq("4 minutes 11.5 seconds")
end
end
describe "work week" do
before do
allow(described_class).to receive_messages(
hours_per_day: 8,
days_per_month: 20
)
end
it "parses knowing the work week" do
week = described_class.parse("5d")
expect(described_class.parse("40h")).to eq(week)
expect(described_class.parse("1w")).to eq(week)
end
end
end