1
\$\begingroup\$

Basically I wanted to make a generic class for parsing time codes and dealing with small units of time.

I have tried to keep it very simple (small API) and 0 dependency (apart from RSpec).

Some points I am wondering about:

  • Should I define HOURS_MINUTES_SECONDS_REGEXP as a class variable for performance?
  • Should I raise errors from the initializer?
  • Am I correct to only the value as seconds internally and discard any intermediates?
  • Is there a good way to check if the val is a Hash or MatchData that are usable in .valid?

Code is also available on Github

module Countdown
  # Used to parse time code strings
  class Counter

    # @param val [Interger|String|Hash] representing a length of time in HH:MM:SS or HH:SS
    # Sets the internal time store in seconds from input
    def initialize val
      self.seconds = val
    end

    # @return [Integer] the total number of seconds
    attr_reader :seconds

    # @param val [Integer|String|Hash]
    #   - seconds
    #   - a string representing a length of time in HH:MM:SS or HH:SS
    #   - a Hash or Hash like object with the keys :seconds and :minutes
    # @raise TypeError
    #   if val is not an acceptable parameter
    def seconds= val
      if val.is_a?(Integer)
        @seconds = val
      elsif val.is_a?(String)
        @seconds = from_string val
      elsif self.class.quacks_like_a_hash?(val) # quacks like a hash
        @seconds = from_hash(val)
      else
        raise TypeError, "input must be a String, Integer or Hash like object"
      end
    end

    # Checks the format of a string or hash like object
    # @param input [String|Hash]
    # @return [Boolean]
    def self.valid? input
      hash = input.is_a?(String) ?  HOURS_MINUTES_SECONDS_REGEXP.match(input) : input
      if hash && hash[:minutes] && hash[:seconds]
        if hash[:hours].to_i < 24 && hash[:minutes].to_i < 60 && hash[:seconds].to_i < 60
          return true
        end
      end
      false
    end

    # @param short_format [Boolean] prefer mm:ss output
    # @return [String]
    #   formated as a hh:mm:ss countdown
    def to_s(short_format: false)
      mm, ss = @seconds.divmod(60)
      hh, mm = mm.divmod(60)
      parts = short_format && hh.zero? ? [ mm, ss] : [hh, mm, ss]
      parts.map { |d| d.to_s.rjust(2, '0') }.join(':')
    end

    alias_method :to_i, :seconds

    # @return [Float] the total amount of time in minutes decimal
    def minutes
      to_i / 60.0
    end

    # @return [Float] the total amount of time in hours decimal
    def hours
      to_i / 3600.0
    end

    # @return [Hash]
    def to_h
      mm, ss = @seconds.divmod(60)
      hh, mm = mm.divmod(60)
      {
          hours: hh,
          minutes: mm,
          seconds: ss
      }
    end

    def self.quacks_like_a_hash? val
      val.respond_to?(:[]) && val[:seconds] || val[:minutes] || val[:hours]
    end


    private

    HOURS_MINUTES_SECONDS_REGEXP = /^((?<hours>([0-5]?[0-9]|60))?:)?(?<minutes>([0-5]?[0-9]|60)):(?<seconds>([0-5]?[0-9]|60))$/.freeze

    def from_string tcs
      matches = HOURS_MINUTES_SECONDS_REGEXP.match(tcs)
      unless Counter.valid?(matches)
        raise TypeError, "#{tcs} is not a valid time code string."
      end
      from_hash(matches)
    end

    def from_hash hash
      unless Counter.valid?(matches)
        raise TypeError, "#{hash} is not a valid time input"
      end
      (hash[:hours].to_i * 3600) + (hash[:minutes].to_i * 60) + hash[:seconds].to_i
    end
  end
end

Spec:

require 'spec_helper'
require 'countdown/counter'

RSpec.describe Countdown::Counter do

  describe '.valid?' do
    it "allows mm:ss" do
      expect(Countdown::Counter.valid?('12:56')).to eq(true)
    end

    it "allows hh:mm:ss" do
      expect(Countdown::Counter.valid?('13:12:56')).to eq(true)
    end

    it "ensures there are not more than 24 hours" do
      expect(Countdown::Counter.valid?('55:12:56')).to eq(false)
    end

    it "ensures there are not more than 60 minutes" do
      expect(Countdown::Counter.valid?('12:99:56')).to eq(false)
    end

    it "ensures there are not more than 60 seconds" do
      expect(Countdown::Counter.valid?('12:23:67')).to eq(false)
    end
  end

  describe 'initialization' do
    it 'will raise ArgumentError if input is invalid' do
      expect {
        expect( Countdown::Counter.new('asdasd') )
      }.to raise_error "asdasd is not a valid time code string."
    end

    it "allows creation from an integer" do
      expect( Countdown::Counter.new(10).seconds ).to eq 10
    end

    it "allows creation from an hash" do
      expect( Countdown::Counter.new(minutes: 1, seconds: 2).seconds ).to eq 62
      expect( Countdown::Counter.new(hours: 1, seconds: 2).seconds ).to eq 3602
    end
  end

  describe '#to_s' do
    it "formats a countdown" do
      expect( Countdown::Counter.new('12:56').to_s ).to eq('00:12:56')
    end

    it "can output a short MM:SS format" do
      expect( Countdown::Counter.new('12:56').to_s(short_format: true) ).to eq('12:56')
    end
  end
  describe "#to_i" do
    it "outputs the total seconds" do
      expect( Countdown::Counter.new('1:20').to_i ).to eq(80)
      expect( Countdown::Counter.new('01:01:20').to_i ).to eq(3680)
    end
  end
  describe "#seconds" do
    it "outputs the total seconds" do
      expect( Countdown::Counter.new('1:22').seconds ).to eq(82)
    end
  end
  describe "#minutes" do
    it "outputs the time expressed in minutes decimal" do
      expect( Countdown::Counter.new('1:30').minutes ).to eq(1.5)
    end
  end
  describe "#hours" do
    it "outputs the time expressed in hours decimal" do
      expect( Countdown::Counter.new('01:30:00').hours ).to eq(1.5)
    end
  end
  describe "#to_h" do
    it "outputs the time as a hash" do
      input = {
        seconds: 1,
        minutes: 2,
        hours: 1
      }
      expect( Countdown::Counter.new(input).to_h ).to eq input
    end
  end
end
\$\endgroup\$
  • \$\begingroup\$ When you said "small units of time", I was thinking milliseconds. \$\endgroup\$ – 200_success Nov 16 '14 at 14:05
  • \$\begingroup\$ What's the intended use for this code? I'm thinking that it seems a little complicated due to all the hash/string/integer-juggling, so my first thought is "do you actually need all that?" \$\endgroup\$ – Flambino Nov 17 '14 at 9:43
  • \$\begingroup\$ An example use case would be aggregating videos from for example several sites (Youtube, Vimeo etc) and storing the result in a standarized format. So you might get a timecode string 02:00. And then store a Video object in the db with a INT length. Later in a view you might want to display a timecode again with Counter.new(int).to_s(short_format: true). \$\endgroup\$ – papirtiger Nov 17 '14 at 11:32
  • \$\begingroup\$ Thats the motivation for the different input formats. The hash input format might be a bit unnecessary - I guess I just though why not since I had all the parts there anyways for processing the MatchData object from strings. \$\endgroup\$ – papirtiger Nov 17 '14 at 11:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.