Skip to content

Commit

Permalink
v1.1.0
Browse files Browse the repository at this point in the history
- Enabled matching of VTs to VTs
- Updated documentation
  • Loading branch information
docelic committed Dec 18, 2023
1 parent 1215698 commit 0fe24a9
Show file tree
Hide file tree
Showing 3 changed files with 393 additions and 81 deletions.
162 changes: 144 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ That `VirtualTime` instance will match any `Time` that is:
- Between hours noon and 4PM (hour = 12..16)
- And any minute (since example block always returns true)

It is also possible to match VirtualTimes themselves:


```cr
vt = VirtualTime.new
vt.month = 3
vt.day = 15
vt2 = VirtualTime.new
vt2.month = 2..4
vt.matches?(vt2) # ==> True, because March is in the Feb..April range
```

# Installation

Add the following to your application's "shard.yml":
Expand Down Expand Up @@ -83,30 +97,33 @@ All properties (that are specified, i.e. not nil) must match for the match to su
This `VirtualTime` object can then be used for matching arbitrary `Time`s against it, to check if
they match.

The described syntax allows for specifying simple but functionally intricate
rules, of which just some of them are:
# Matching Times

Once `VirtualTime` is created, it can be used for matching `Time` objects.

An example showing use of different values was given in the introduction:

```cr
vt = VirtualTime.new
vt.year = 2020..2030
vt.day = -8..-1
vt.day_of_week = [6,7]
vt.hour = 12..16
vt.minute = ->( val : Int32) { true }
time = Time.local
vt.matches? time
```

The syntax allows for specifying simple but functionally intricate rules, of which just some of them are:

```txt
day=-1 -- matches last day in month
day=-1 -- matches last day of month (28th, 29th, 30th, or 31st of particular month)
day_of_week=6, day=24..31 -- matches last Saturday in month
day_of_week=1..5, day=-1 -- matches last day of month if it is a workday
```

Negative values count from the end of the range. Typical end values are 7, 12, 30/31, 365/366,
23, 59, and 999, and virtualtime implicitly knows which one to apply in every case. For example,
a day of `-1` would always match the last day of the month, be that 28th, 29th, 30th, or 31st in a
particular case.

An interesting case is week number, which is calculated as number of Mondays in the year.
The first Monday in a year starts week number 1, but not every year starts on Monday so up to
the first 3 days of new year can still technically belong to the last week of the previous year.
That means it
is possible for this field to have values between 0 and 53. Value 53 indicates a week that has
started in one year (53rd Monday seen in a year), but up to 3 of its days will overflow into
the new year. Similarly, a value 0 matches up to the first 3 days (which inevitably must be
Friday, Saturday, and/or Sunday) of the new year that belong to the week started in the
previous year.

Another example:

```cr
Expand All @@ -120,6 +137,81 @@ vt.second = true # Unconditional match
vt.millisecond = ->( val : Int32) { true } # Will match any value as block returns true
```

# Matching other VirtualTimes

In addition to matching `Time` structs, VirtualTimes can match other VirtualTimes.

For example, if you had a `VirtualTime` that matches every March 15:

```cr
vt = VirtualTime.new month: 3, day: 15
```

And you wanted to check whether this was scheduled on any day in the first 6 months of
the year, you could do:

```cr
vt = VirtualTime.new month: 3, day: 15
vt2 = VirtualTime.new month: 1..6
vt.matches?(vt2) # ==> true
```

When matching `VirtualTime`s to `VirtualTime`s, comparisons between fields' values
which are both `Proc`s is not supported and will throw `ArgumentError` in runtime.

# Field Values

As can be seen above, fields can have some interesting values, such as negative numbers.

Here is a list of all non-obvious values that are supported:

1. Negative integer values

Negative integer values count from the end of the range, if the max / wrap-around value is
specified. Typical end values are 7, 12, 30/31, 365/366, 23, 59, and 999, and virtualtime
implicitly knows which one to apply in every case.
For example, a day of `-1` would always match the last day of the month, be that 28th, 29th,
30th, or 31st in a particular case.

If the wrap-around value is not specified, negative values are not converted to positive
and they enter matching as-is.

2. Week numbers

Another interesting case is week number, which is calculated as number of Mondays in the year.
The first Monday in a year starts week number 1, but not every year starts on Monday so up to
the first 3 days of new year can still technically belong to the last week of the previous year.

That means it is possible for this field to have values between 0 and 53.
Value 53 indicates a week that has started in one year (53rd Monday seen in a year),
but up to 3 of its days will overflow into the new year.

Similarly, a value 0 matches up to the first 3 days (which inevitably must be Friday, Saturday,
and/or Sunday) of the new year that belong to the week started in the previous year.

3. Range values

Crystal allows one to define `Range`s that have `end` value smaller than `begin`.
Such objects will simply not contain any elements.

Because creating such ranges *is* allowed, VirtualTime detects such cases and creates
copies of objects with values converted to positive and in the correct order.

In other words, if you specify a range of say, `day: (10..-7).step(2)`, this will properly
match every other day from 10th to a day 7 days before the end of the month.

4. Days in month and year

When matching `VirtualTime`s to other `VirtualTime`s, helper functions `days_in_month` and
`days_in_year` return `nil`. As a consequence, matching is performed without converting
negative values to positive ones.

This choice was made because it is only possible to know the exact values if/when `year`
and `month` happen to be integers. If they are a value of any other type (e.g. a range,
`2023..2030`), it is ambiguous or indeterminable what the value should be.

# Materialization

VirtualTimes sometimes need to be "materialized" for
Expand Down Expand Up @@ -167,6 +259,40 @@ vt.location = Time::Location.load("America/New_York")
vt.matches?(t) # ==> true, because time instant 0 hours converted to NY time (-6) is 18 hours
```

When comparing `VirtualTime`s to `VirtualTime`s, comparisons between objects with different
`location` values are not supported and will throw `ArgumentError` in runtime.

# Considerations

Alias `Virtual` is defined as:

```cr
alias Virtual = Nil | Bool | Int32 |
Array(Int32) | Range(Int32, Int32) | Steppable::StepIterator(Int32, Int32, Int32) |
VirtualProc
```

`Array`, `Range`, and `Steppable::StepIterator` are mentioned explicitly instead of just
being replaced with `Enumerable(Int32)` due to a bug in Crystal
(https://github.com/crystal-lang/crystal/issues/14047).

Another, related consideration is related to matching fields that contain these enumerable
types.

Some enumerables change internal state when they are used, so in the matching function accepting
`Enumerable` data types they are `#dup`-ed before use, to make sure the original objects
remain intact.

An alternative approach, to avoid duplicating objects in every case, would be to define more
specific function overloads for matching `Array`s, `Range`s, and `StepIterator`s, and only have
the `Enumerable` function overload as a fallback, unless a more specific match is found.

Currently the first option for doing all matching via `Enumerable`s is used because it
results is a smaller amount of active code to maintain. But the code for other types exists;
it is just disabled.

Please open an issue on the project to discuss if you would advise differently.

# Tests

Run `crystal spec` or just `crystal s`.
Expand Down
56 changes: 40 additions & 16 deletions spec/virtualtime_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ require "../src/virtualtime"

describe VirtualTime do
it "can be initialized" do
a = VirtualTime.new
a.year.should eq nil
a.month.should eq nil
a.day.should eq nil
a.day_of_week.should eq nil
a.location.should eq nil
vt = VirtualTime.new
vt.year.should eq nil
vt.month.should eq nil
vt.day.should eq nil
vt.day_of_week.should eq nil
vt.location.should eq nil
end

it "supports all 7 documented types of values" do
a = VirtualTime.new
a.year = nil # Remains unspecified, matches everything it is compared with
a.month = 3
a.day = [1, 2]
a.hour = (10..20)
a.minute = (10..20).step(2)
a.second = true
a.millisecond = ->(_val : Int32) { true }
it "supports all documented types of values" do
vt = VirtualTime.new
vt.year = nil # Remains unspecified, matches everything it is compared with
vt.month = 3
vt.week = true
vt.day = [1, 2]
vt.hour = (10..20)
vt.minute = (10..20).step(2)
vt.millisecond = ->(_val : Int32) { true }
end

it "can materialize" do
Expand Down Expand Up @@ -67,6 +67,30 @@ describe VirtualTime do
vt.matches?(Time.parse("2018-03-11", "%F", Time::Location::UTC)).should be_nil
end

it "can match other VirtualTimes" do
vt = VirtualTime.new
vt.year = 2017
vt.month = 1..3
vt.hour = [10, 11, 12]
vt.minute = (10..30).step(3)
vt.second = ->(_val : Int32) { true }
vt.millisecond = 1

vt2 = VirtualTime.new
vt2.year = nil
vt2.month = [2, 3]
vt2.day = ->(_val : Int32) { true }
vt2.hour = 11..12
vt2.minute = 20..25
vt2.second = 10
vt2.millisecond = (10..30).step(3)

vt.matches?(vt2).should be_nil

vt.millisecond = 16
vt.matches?(vt2).should be_true
end

it "can match Crystal's Times in different locations" do
vt = VirtualTime.new
vt.hour = 16..20
Expand All @@ -82,7 +106,6 @@ describe VirtualTime do
date = VirtualTime.new
date.year = 2017
date.month = 4..6
date.day = true
date.hour = (2..8).step 3

y = date.to_yaml
Expand Down Expand Up @@ -112,6 +135,7 @@ describe VirtualTime do
vt.month.should eq 3
vt.day.should eq [1, 2]
vt.hour.should eq 10..20
vt.second.should eq true
vt.location.should eq Time::Location.load("Europe/Berlin")
end
end
Loading

0 comments on commit 0fe24a9

Please sign in to comment.