From 6b9ce751aa36d770c1b4933dbb683900fcafc7d2 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 7 Jun 2024 10:07:44 +0200 Subject: [PATCH] Add chronic_duration in our code to customize it --- Gemfile | 2 - Gemfile.lock | 4 - lib/chronic_duration.rb | 343 +++++++++++++++++++++++++++++ spec/lib/chronic_duration_spec.rb | 346 ++++++++++++++++++++++++++++++ 4 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 lib/chronic_duration.rb create mode 100644 spec/lib/chronic_duration_spec.rb diff --git a/Gemfile b/Gemfile index 11fc578e912..111a2716133 100644 --- a/Gemfile +++ b/Gemfile @@ -230,8 +230,6 @@ gem "turbo-rails", "~> 2.0.0" gem "httpx" -gem "gitlab_chronic_duration" - group :test do gem "launchy", "~> 3.0.0" gem "rack-test", "~> 2.1.0" diff --git a/Gemfile.lock b/Gemfile.lock index 3263c39e6a8..e8f6e78cdbd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -556,8 +556,6 @@ GEM fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - gitlab_chronic_duration (0.12.0) - numerizer (~> 0.2) glob (0.4.0) globalid (1.2.1) activesupport (>= 6.1) @@ -749,7 +747,6 @@ GEM nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) - numerizer (0.2.0) oj (3.16.3) bigdecimal (>= 3.0) okcomputer (1.18.5) @@ -1214,7 +1211,6 @@ DEPENDENCIES fog-aws friendly_id (~> 5.5.0) fuubar (~> 2.5.0) - gitlab_chronic_duration gon (~> 6.4.0) good_job (= 3.26.2) google-apis-gmail_v1 diff --git a/lib/chronic_duration.rb b/lib/chronic_duration.rb new file mode 100644 index 00000000000..3347620a144 --- /dev/null +++ b/lib/chronic_duration.rb @@ -0,0 +1,343 @@ +# 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. + +# rubocop:disable Metrics/AbcSize +# rubocop:disable Metrics/PerceivedComplexity +module ChronicDuration + extend self + + class DurationParseError < StandardError + end + + # On average, there's a little over 4 weeks in month. + FULL_WEEKS_PER_MONTH = 4 + + @@raise_exceptions = false + @@hours_per_day = 24 + @@days_per_month = 30 + + def self.raise_exceptions + !!@@raise_exceptions + end + + def self.raise_exceptions=(value) + @@raise_exceptions = !!value + end + + def self.hours_per_day + @@hours_per_day + end + + def self.hours_per_day=(value) + @@hours_per_day = value + end + + def self.days_per_month + @@days_per_month + end + + def self.days_per_month=(value) + @@days_per_month = value + end + + # Given a string representation of elapsed time, + # return an integer (or float, if fractions of a + # second are input) + def parse(string, opts = {}) + result = calculate_from_words(cleanup(string), opts) + !opts[:keep_zero] && result == 0 ? nil : result + end + + # Given an integer and an optional format, + # returns a formatted string representing elapsed time + # rubocop:disable Lint/UselessAssignment + def output(seconds, opts = {}) + int = seconds.to_i + seconds = int if seconds - int == 0 # if seconds end with .0 + + opts[:format] ||= :default + opts[:keep_zero] ||= false + + hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day + days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month + days_per_week = days_per_month / FULL_WEEKS_PER_MONTH + + years = months = weeks = days = hours = minutes = 0 + + decimal_places = seconds.to_s.split(".").last.length if seconds.is_a?(Float) + + minute = 60 + hour = 60 * minute + day = hours_per_day * hour + month = days_per_month * day + year = 31_557_600 + + if seconds >= 31_557_600 && seconds % year < seconds % month + years = seconds / year + months = seconds % year / month + days = seconds % year % month / day + hours = seconds % year % month % day / hour + minutes = seconds % year % month % day % hour / minute + seconds = seconds % year % month % day % hour % minute + elsif seconds >= 60 + minutes = (seconds / 60).to_i + seconds %= 60 + if minutes >= 60 + hours = (minutes / 60).to_i + minutes = (minutes % 60).to_i + if !opts[:limit_to_hours] && (hours >= hours_per_day) + days = (hours / hours_per_day).to_i + hours = (hours % hours_per_day).to_i + if opts[:weeks] + if days >= days_per_week + weeks = (days / days_per_week).to_i + days = (days % days_per_week).to_i + if weeks >= FULL_WEEKS_PER_MONTH + months = (weeks / FULL_WEEKS_PER_MONTH).to_i + weeks = (weeks % FULL_WEEKS_PER_MONTH).to_i + end + end + elsif days >= days_per_month + months = (days / days_per_month).to_i + days = (days % days_per_month).to_i + end + end + end + end + + joiner = opts.fetch(:joiner) { " " } + process = nil + + case opts[:format] + when :micro + dividers = { + years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" + } + joiner = "" + when :short + dividers = { + years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" + } + when :default + dividers = { + years: " yr", months: " mo", weeks: " wk", days: " day", hours: " hr", minutes: " min", seconds: " sec", + pluralize: true + } + when :long + dividers = { + years: " year", months: " month", weeks: " week", days: " day", hours: " hour", minutes: " minute", seconds: " second", + pluralize: true + } + when :chrono + dividers = { + years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true + } + process = lambda do |str| + # Pad zeros + # Get rid of lead off times if they are zero + # Get rid of lead off zero + # Get rid of trailing : + divider = ":" + str.split(divider).map do |n| + # add zeros only if n is an integer + n.include?(".") ? ("%04.#{decimal_places}f" % n) : ("%02d" % n) + end.join(divider).gsub(/^(00:)+/, "").gsub(/^0/, "").gsub(/:$/, "") + end + joiner = "" + end + + result = %i[years months weeks days hours minutes seconds].map do |t| + next if t == :weeks && !opts[:weeks] + + num = eval(t.to_s) # rubocop:disable Security/Eval + num = ("%.#{decimal_places}f" % num) if num.is_a?(Float) && t == :seconds + keep_zero = dividers[:keep_zero] + keep_zero ||= opts[:keep_zero] if t == :seconds + humanize_time_unit(num, dividers[t], dividers[:pluralize], keep_zero) + end.compact! + + result = result[0...opts[:units]] if opts[:units] + + result = result.join(joiner) + + result = process.call(result) if process + + result.empty? ? nil : result + end + # rubocop:enable Lint/UselessAssignment + + private + + def humanize_time_unit(number, unit, pluralize, keep_zero) + return nil if number == 0 && !keep_zero + + res = "#{number}#{unit}" + # A poor man's pluralizer + res << "s" if (number != 1) && pluralize + res + end + + def calculate_from_words(string, opts) + val = 0 + words = string.split + words.each_with_index do |v, k| + next unless v&.match?(float_matcher) + + val += (convert_to_number(v) * duration_units_seconds_multiplier( + words[k + 1] || (opts[:default_unit] || "seconds"), opts + )) + end + val + end + + def cleanup(string) + res = string.downcase + res = filter_by_type(res) + res = res.gsub(float_matcher) { |n| " #{n} " }.squeeze(" ").strip + filter_through_white_list(res) + end + + def convert_to_number(string) + string.to_f % 1 > 0 ? string.to_f : string.to_i + end + + def duration_units_list + %w[seconds minutes hours days weeks months years] + end + + def duration_units_seconds_multiplier(unit, opts) + return 0 unless duration_units_list.include?(unit) + + hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day + days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month + days_per_week = days_per_month / FULL_WEEKS_PER_MONTH + + case unit + when "years" then 31_557_600 + when "months" then 3600 * hours_per_day * days_per_month + when "weeks" then 3600 * hours_per_day * days_per_week + when "days" then 3600 * hours_per_day + when "hours" then 3600 + when "minutes" then 60 + when "seconds" then 1 + end + end + + # Parse 3:41:59 and return 3 hours 41 minutes 59 seconds + def filter_by_type(string) + chrono_units_list = duration_units_list.reject { |v| v == "weeks" } + + if string.delete(" ")&.match?(time_matcher) + res = [] + string.delete(" ").split(":").reverse.each_with_index do |v, k| + return unless chrono_units_list[k] # rubocop:disable Lint/NonLocalExitFromIterator + + res << "#{v} #{chrono_units_list[k]}" + end + res = res.reverse.join(" ") + else + res = string + end + res + end + + def time_matcher + /^[0-9]+:[0-9]+(:[0-9]+){0,4}(\.[0-9]*)?$/ + end + + def float_matcher + /[0-9]*\.?[0-9]+/ + end + + # Get rid of unknown words and map found + # words to defined time units + def filter_through_white_list(string) + res = [] + string.split.each do |word| + if word&.match?(float_matcher) + res << word.strip + next + end + stripped_word = word.strip.gsub(/^,/, "").gsub(/,$/, "") + if mappings.has_key?(stripped_word) + res << mappings[stripped_word] + elsif !join_words.include?(stripped_word) and ChronicDuration.raise_exceptions # rubocop:disable Rails/NegateInclude + raise DurationParseError, "An invalid word #{word.inspect} was used in the string to be parsed." + end + end + # add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec' + res.unshift(1) if !res.empty? && mappings[res[0]] + res.join(" ") + end + + def mappings + { + "seconds" => "seconds", + "second" => "seconds", + "secs" => "seconds", + "sec" => "seconds", + "s" => "seconds", + "minutes" => "minutes", + "minute" => "minutes", + "mins" => "minutes", + "min" => "minutes", + "m" => "minutes", + "hours" => "hours", + "hour" => "hours", + "hrs" => "hours", + "hr" => "hours", + "h" => "hours", + "days" => "days", + "day" => "days", + "dy" => "days", + "d" => "days", + "weeks" => "weeks", + "week" => "weeks", + "wks" => "weeks", + "wk" => "weeks", + "w" => "weeks", + "months" => "months", + "mo" => "months", + "mos" => "months", + "month" => "months", + "years" => "years", + "year" => "years", + "yrs" => "years", + "yr" => "years", + "y" => "years" + } + end + + def join_words + %w[and with plus] + end +end +# rubocop:enable Metrics/AbcSize +# rubocop:enable Metrics/PerceivedComplexity diff --git a/spec/lib/chronic_duration_spec.rb b/spec/lib/chronic_duration_spec.rb new file mode 100644 index 00000000000..d5497825895 --- /dev/null +++ b/spec/lib/chronic_duration_spec.rb @@ -0,0 +1,346 @@ +# 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 "spec_helper" + +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 + 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 + it "raises with ChronicDuration::DurationParseError" do + described_class.raise_exceptions = true + expect { described_class.parse("23 gobblygoos") }.to raise_error(ChronicDuration::DurationParseError) + described_class.raise_exceptions = false + 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 + + 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", + 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", + 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", + 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", + chrono: "4:01:01" + }, + ((2 * 3600) + (20 * 60)) => + { + micro: "2h20m", + short: "2h 20m", + default: "2 hrs 20 mins", + long: "2 hours 20 minutes", + chrono: "2:20" + }, + ((2 * 3600) + (20 * 60)) => + { + micro: "2h20m", + short: "2h 20m", + default: "2 hrs 20 mins", + long: "2 hours 20 minutes", + chrono: "2:20:00" + }, + ((6 * 30 * 24 * 3600) + (24 * 3600)) => + { + micro: "6mo1d", + short: "6mo 1d", + default: "6 mos 1 day", + long: "6 months 1 day", + 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", + 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", + chrono: "3:00:01:00:00:00" + }, + (3600 * 24 * 30 * 18) => + { + micro: "18mo", + short: "18mo", + default: "18 mos", + long: "18 months", + 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", + chrono: "0" + }, + false => + { + micro: nil, + short: nil, + default: nil, + long: nil, + 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| + 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