decoding 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +6 -0
- data/README.md +57 -0
- data/lib/decoding/decoders/time.rb +68 -16
- data/lib/decoding/matcher_helpers.rb +50 -0
- data/lib/decoding/rspec/decode_value.rb +87 -0
- data/lib/decoding/rspec/fail_with.rb +48 -0
- data/lib/decoding/rspec/succeed_with.rb +31 -0
- data/lib/decoding/rspec.rb +44 -0
- data/lib/decoding/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27eee73e0aa7f430b7fb6aac6aea19c872dd5d825633d1f0e54362668ae355e5
|
|
4
|
+
data.tar.gz: b6ceb7aa0bd5cda5812949c165a4f8614dd0da97f5e05a8cbbf77cefa3e10e4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8119c1c00072ccbe6393ba6265d8eb5bd18e4f3a6b0c821a66eac6f1a9d52481b4cdf6b7e70656545c08cecbbb365f7736a505b3df25a9bd03c1b2e9ec486e9e
|
|
7
|
+
data.tar.gz: 5a76a8b7c125c47e2afd5f8a37bd0290ca5e2fb6de03a42cc2dabeeba4121f4601a27bd804e62a088aad8e165e2f85aca7ac8a07f6fc537be3019d9a610baae7
|
data/.rubocop.yml
CHANGED
|
@@ -29,6 +29,8 @@ Metrics/AbcSize:
|
|
|
29
29
|
Metrics/BlockLength:
|
|
30
30
|
Exclude:
|
|
31
31
|
- spec/**/*_spec.rb
|
|
32
|
+
# The RSpec matcher DSL puts a matcher's whole definition inside one block.
|
|
33
|
+
- lib/decoding/rspec/*.rb
|
|
32
34
|
|
|
33
35
|
Metrics/MethodLength:
|
|
34
36
|
Max: 17
|
|
@@ -53,3 +55,13 @@ RSpec/IdenticalEqualityAssertion:
|
|
|
53
55
|
|
|
54
56
|
RSpec/ExampleLength:
|
|
55
57
|
Enabled: false
|
|
58
|
+
|
|
59
|
+
# The matcher specs deliberately pass literal values to `expect` to assert on
|
|
60
|
+
# the messages the matchers themselves produce.
|
|
61
|
+
RSpec/DescribeClass:
|
|
62
|
+
Exclude:
|
|
63
|
+
- spec/matchers/*_spec.rb
|
|
64
|
+
|
|
65
|
+
RSpec/ExpectActual:
|
|
66
|
+
Exclude:
|
|
67
|
+
- spec/matchers/*_spec.rb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0]
|
|
4
|
+
|
|
5
|
+
* Add a `zone:` argument to the `time` and `unix_time` decoders, for resolving times somewhere other than the system's time zone.
|
|
6
|
+
* Add `decoding/rspec` module with matchers to help with testing custom decoders
|
|
7
|
+
|
|
3
8
|
## [0.4.0]
|
|
4
9
|
|
|
5
10
|
* Add `uri` decoder for URI objects and strings that can be parsed as one, available after `require "decoding/decoders/uri"`
|
data/CLAUDE.md
CHANGED
|
@@ -19,6 +19,12 @@ Ruby gem for decoding dynamic/external data into known structures. Functional-st
|
|
|
19
19
|
- Test file: `spec/<module>/<class>_spec.rb`
|
|
20
20
|
- Run with `COVERAGE=true bundle exec rspec`
|
|
21
21
|
|
|
22
|
+
**Custom matchers** (defined in `spec/support/matchers/`, tested in `spec/matchers/`):
|
|
23
|
+
- `expect(decoder).to decode_value(input).to(value)` - assert what a decoder decodes an input to
|
|
24
|
+
- `expect(decoder).to decode_value(input).failing_with(msg).at(*path)` - assert how it fails, path outermost first
|
|
25
|
+
- `expect(result).to succeed_with(value)` / `expect(result).to fail_with(msg).at(*path)` - assert on a `Result`
|
|
26
|
+
- Expected values compare strictly (`eql?`) unless you pass a matcher, so `1` does not match `1.0`
|
|
27
|
+
|
|
22
28
|
## Architecture
|
|
23
29
|
|
|
24
30
|
**Core concepts:**
|
data/README.md
CHANGED
|
@@ -268,6 +268,24 @@ Decoding.decode(D.unix_time(:milliseconds), 1_595_674_680_123) # => Decoding::Ok
|
|
|
268
268
|
|
|
269
269
|
A timestamp read in the wrong unit is not an error but a wildly different point in time, so a source reporting milliseconds has to say so. There is deliberately no default: `:parse` is lenient and fills in whatever the input leaves out from the current time, so it has to be asked for by name.
|
|
270
270
|
|
|
271
|
+
Both `time` and `unix_time` resolve times through `Time` itself, and so in the system's time zone. That is rarely the zone a Rails application means: its containers usually run UTC while the application runs somewhere else, and taking `to_date` of the two answers differs by a day for a late-evening timestamp. Pass anything that answers the format you name as `zone:` to decide it yourself:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
Decoding.decode(D.time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
|
|
275
|
+
# => Decoding::Ok(2020-01-01 10:00:00 +0100)
|
|
276
|
+
Decoding.decode(D.map(D.unix_time(zone: Time.zone), &:to_date), 1_710_113_400)
|
|
277
|
+
# => Decoding::Ok(#<Date: 2024-03-11>)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An `ActiveSupport::TimeZone` answers only `iso8601`, `rfc3339` and `parse` of the format names above, plus `strptime` patterns, so a zone that cannot parse the format you asked for is refused when the decoder is built:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
D.time(:httpdate, zone: Time.zone)
|
|
284
|
+
# raises ArgumentError: cannot decode a time in httpdate format: the given zone does not respond to httpdate
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note `ActiveSupport::TimeZone#parse` answers a value it cannot parse with `nil` rather than by raising, unlike every method of `Time`. Both are read as a failure to decode.
|
|
288
|
+
|
|
271
289
|
* `big_decimal` -- decode a `BigDecimal` object, or a number or string describing one. Only finite numbers are accepted:
|
|
272
290
|
|
|
273
291
|
```ruby
|
|
@@ -316,6 +334,45 @@ Decoding.env("SENTRY_DSN", D.optional(D.string)) # => nil when not set
|
|
|
316
334
|
|
|
317
335
|
Pass `from:` to read from somewhere other than `ENV`, which is useful in tests.
|
|
318
336
|
|
|
337
|
+
## Testing your own decoders
|
|
338
|
+
|
|
339
|
+
The matchers this gem tests itself with are available to applications that write decoders of their own. They need `rspec-expectations`, which is not a dependency of this gem, so require them from your spec helper:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# spec/spec_helper.rb
|
|
343
|
+
require "decoding/rspec"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`decode_value` describes a decoder, running it for you, so an example says what a decoder does rather than how to call it:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
|
|
350
|
+
expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
|
|
351
|
+
expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
|
|
352
|
+
expect(my_decoder).not_to decode_value(nil) # fails to decode
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
An error nested in a structure is asserted with its location, outermost segment first, rather than by matching the rendered `Error at .` prefix:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
expect(D.field("a", D.field("b", D.string)))
|
|
359
|
+
.to decode_value({ "a" => { "b" => 1 } })
|
|
360
|
+
.failing_with("expected String, got Integer").at("a", "b")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`succeed_with` and `fail_with` describe a result you already have, for when making the call is part of what the example tests:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
|
|
367
|
+
expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Expected values are compared strictly, so `1` does not match `1.0` and a decoder answering with the wrong type cannot pass. Pass a matcher where that is too strict:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
expect(D.unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
|
|
374
|
+
```
|
|
375
|
+
|
|
319
376
|
## Development
|
|
320
377
|
|
|
321
378
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -22,35 +22,50 @@ module Decoding
|
|
|
22
22
|
# `strptime` pattern. Note that the `:parse` format is lenient: it fills in
|
|
23
23
|
# any components the input value leaves out from the current time.
|
|
24
24
|
#
|
|
25
|
+
# Times are resolved by `Time` itself, and so in the system's time zone,
|
|
26
|
+
# unless another zone is given. Anything answering the format you name will
|
|
27
|
+
# do: an `ActiveSupport::TimeZone` resolves in the application's zone, which
|
|
28
|
+
# is rarely the system's, but note it answers only `iso8601`, `rfc3339` and
|
|
29
|
+
# `parse` of the names above.
|
|
30
|
+
#
|
|
25
31
|
# @example
|
|
26
32
|
# decode(time(:iso8601), "2020-01-01T10:00:00Z")
|
|
27
33
|
# # => Decoding::Ok(2020-01-01 10:00:00 UTC)
|
|
28
34
|
# decode(time("%Y|%m"), "nope")
|
|
29
35
|
# # => Decoding::Err("expected a time matching \"%Y|%m\", got \"nope\"")
|
|
36
|
+
# decode(time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
|
|
37
|
+
# # => Decoding::Ok(2020-01-01 10:00:00 +0100)
|
|
30
38
|
# @param format [Symbol, String]
|
|
31
|
-
# @
|
|
32
|
-
#
|
|
39
|
+
# @param zone [Object] anything answering the format you name, such as
|
|
40
|
+
# `Time` or an `ActiveSupport::TimeZone`.
|
|
41
|
+
# @raise [ArgumentError] when the format is not a known name or a pattern,
|
|
42
|
+
# or the zone cannot parse it. This is raised when the decoder is built,
|
|
43
|
+
# not when it is used.
|
|
33
44
|
# @return [Decoding::Decoder<Time>]
|
|
34
45
|
# @see Decoding::Decoders::MapErr
|
|
35
|
-
def time(format)
|
|
36
|
-
parse, description = time_format(format)
|
|
46
|
+
def time(format, zone: ::Time)
|
|
47
|
+
parse, description = time_format(format, zone)
|
|
37
48
|
Decoders.map_err(
|
|
38
49
|
Decoders.any(
|
|
39
50
|
Decoders.match(::Time),
|
|
40
|
-
|
|
51
|
+
parsed_by(parse)
|
|
41
52
|
)
|
|
42
53
|
) { |_message, value| "expected #{description}, got #{value.inspect}" }
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
# @private
|
|
46
|
-
def time_format(format)
|
|
57
|
+
def time_format(format, zone)
|
|
47
58
|
case format
|
|
48
59
|
when ::String
|
|
49
|
-
|
|
60
|
+
description = "a time matching #{format.inspect}"
|
|
61
|
+
require_parser(zone, :strptime, description)
|
|
62
|
+
[->(str) { zone.strptime(str, format) }, description]
|
|
50
63
|
when ::Symbol
|
|
51
64
|
raise ArgumentError, "unknown time format: #{format.inspect}" unless TIME_FORMATS.include?(format)
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
description = format == :parse ? "a time" : "a time in #{format} format"
|
|
67
|
+
require_parser(zone, format, description)
|
|
68
|
+
[->(str) { zone.public_send(format, str) }, description]
|
|
54
69
|
else
|
|
55
70
|
raise ArgumentError, "expected a time format name or a strptime pattern, got #{format.inspect}"
|
|
56
71
|
end
|
|
@@ -63,29 +78,66 @@ module Decoding
|
|
|
63
78
|
# error but a wildly different point in time, so a source that reports
|
|
64
79
|
# milliseconds has to say so.
|
|
65
80
|
#
|
|
81
|
+
# A timestamp names a point in time rather than a local one, so the zone
|
|
82
|
+
# only decides how the value describes itself afterwards -- which matters as
|
|
83
|
+
# soon as anything derives a date from it.
|
|
84
|
+
#
|
|
66
85
|
# @example
|
|
67
86
|
# decode(unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
|
|
68
87
|
# decode(unix_time(:milliseconds), 1_595_674_680_123)
|
|
69
88
|
# # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
|
|
70
89
|
# @param unit [Symbol] one of the keys of {UNIX_TIME_UNITS}.
|
|
71
|
-
# @
|
|
72
|
-
#
|
|
90
|
+
# @param zone [Object] anything answering `at`, such as `Time` or an
|
|
91
|
+
# `ActiveSupport::TimeZone`.
|
|
92
|
+
# @raise [ArgumentError] when the unit is not a known one, or the zone
|
|
93
|
+
# cannot read a timestamp. This is raised when the decoder is built, not
|
|
94
|
+
# when it is used.
|
|
73
95
|
# @return [Decoding::Decoder<Time>]
|
|
74
96
|
# @see Decoding::Decoders::MapErr
|
|
75
|
-
def unix_time(unit = :seconds)
|
|
97
|
+
def unix_time(unit = :seconds, zone: ::Time)
|
|
76
98
|
raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
|
|
77
99
|
|
|
78
|
-
|
|
100
|
+
require_parser(zone, :at, "a unix timestamp")
|
|
79
101
|
Decoders.map_err(
|
|
80
102
|
Decoders.any(
|
|
81
103
|
Decoders.match(::Time),
|
|
82
|
-
|
|
83
|
-
Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
|
|
84
|
-
) { ::Time.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
|
|
104
|
+
timestamp_in(zone, UNIX_TIME_UNITS.fetch(unit))
|
|
85
105
|
)
|
|
86
106
|
) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
|
|
87
107
|
end
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
# Decode a number, or a string describing one, as a point in time.
|
|
110
|
+
#
|
|
111
|
+
# @private
|
|
112
|
+
def timestamp_in(zone, per_second)
|
|
113
|
+
Decoders.map(
|
|
114
|
+
Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
|
|
115
|
+
) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Decode a string with the given parser, treating a nil answer as a failure.
|
|
119
|
+
#
|
|
120
|
+
# Every method of `Time` raises for a value it cannot parse, but
|
|
121
|
+
# `ActiveSupport::TimeZone#parse` answers with nil instead, which would
|
|
122
|
+
# otherwise decode as a successful nil.
|
|
123
|
+
#
|
|
124
|
+
# @private
|
|
125
|
+
def parsed_by(parse)
|
|
126
|
+
Decoders.and_then(Decoders.map(Decoders.string) { parse.call(_1) }) do |value|
|
|
127
|
+
value.nil? ? Decoders.fail("parsed to nothing") : Decoders.succeed(value)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Refuse a zone that cannot answer the parsing this decoder will ask of it,
|
|
132
|
+
# while the decoder is being built rather than once it is decoding.
|
|
133
|
+
#
|
|
134
|
+
# @private
|
|
135
|
+
def require_parser(zone, method, description)
|
|
136
|
+
return if zone.respond_to?(method)
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, "cannot decode #{description}: the given zone does not respond to #{method}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private_class_method :time_format, :parsed_by, :timestamp_in, :require_parser
|
|
90
142
|
end
|
|
91
143
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decoding
|
|
4
|
+
# Shared logic for the matchers that describe decoding, loaded along with
|
|
5
|
+
# them by `require "decoding/rspec"`.
|
|
6
|
+
#
|
|
7
|
+
# Decoders report failures as {Decoding::Failure} values, but {Decoding.decode}
|
|
8
|
+
# renders those into strings, so matchers have to understand both.
|
|
9
|
+
module MatcherHelpers
|
|
10
|
+
# Split the error value of a result into its message and the path it
|
|
11
|
+
# occurred at, with the outermost path segment first.
|
|
12
|
+
#
|
|
13
|
+
# @param result [Decoding::Result]
|
|
14
|
+
# @return [Array(String, Array)]
|
|
15
|
+
def describe_failure(result)
|
|
16
|
+
error = result.unwrap_err(nil)
|
|
17
|
+
return [error.msg, error.path.reverse] if error.is_a?(Decoding::Failure)
|
|
18
|
+
|
|
19
|
+
match = error.to_s.match(/\AError at \.(?<path>.+?): (?<msg>.*)\z/m)
|
|
20
|
+
match ? [match[:msg], match[:path].split(".")] : [error.to_s, []]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param path [Array]
|
|
24
|
+
# @return [String]
|
|
25
|
+
def render_path(path) = path.empty? ? "" : " at .#{path.join(".")}"
|
|
26
|
+
|
|
27
|
+
# The path set by the `at` chain, if any.
|
|
28
|
+
#
|
|
29
|
+
# @return [Array]
|
|
30
|
+
def expected_path = @expected_path ||= []
|
|
31
|
+
|
|
32
|
+
# @param path [Array]
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def path_matches?(path) = path.map(&:to_s) == expected_path.map(&:to_s)
|
|
35
|
+
|
|
36
|
+
# Compare a decoded value to what the example expects. Values are
|
|
37
|
+
# compared strictly, so that a decoder returning a value of the wrong
|
|
38
|
+
# type is not mistaken for a success, unless the example expects a
|
|
39
|
+
# matcher rather than a value.
|
|
40
|
+
#
|
|
41
|
+
# @param expected [Object]
|
|
42
|
+
# @param actual [Object]
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def matches_value?(expected, actual)
|
|
45
|
+
return values_match?(expected, actual) if RSpec::Support.is_a_matcher?(expected)
|
|
46
|
+
|
|
47
|
+
actual.eql?(expected)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given decoder decodes the given input. Chain `to` to also
|
|
6
|
+
# assert the decoded value, or `failing_with` (optionally with `at`) to assert
|
|
7
|
+
# that decoding fails with a particular message. Both expected values may
|
|
8
|
+
# themselves be matchers.
|
|
9
|
+
#
|
|
10
|
+
# expect(decoder).to decode_value(["foo"]).to(["foo"])
|
|
11
|
+
# expect(decoder).to decode_value([1]).failing_with("expected String, got Integer").at(0)
|
|
12
|
+
# expect(decoder).not_to decode_value(true)
|
|
13
|
+
RSpec::Matchers.define :decode_value do |input|
|
|
14
|
+
include Decoding::MatcherHelpers
|
|
15
|
+
|
|
16
|
+
match do |actual|
|
|
17
|
+
next false unless actual.respond_to?(:call)
|
|
18
|
+
|
|
19
|
+
result = Decoding.decode(actual, input)
|
|
20
|
+
next matches_failure?(result) if message_expected?
|
|
21
|
+
next result.ok? && matches_value?(@expected_value, result.unwrap!) if value_expected?
|
|
22
|
+
|
|
23
|
+
result.ok?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
match_when_negated do |actual|
|
|
27
|
+
raise ArgumentError, "use `not_to decode_value(input)` without `to` or `failing_with`" if message_expected? || value_expected?
|
|
28
|
+
|
|
29
|
+
actual.respond_to?(:call) && Decoding.decode(actual, input).err?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
chain :to do |value|
|
|
33
|
+
@expected_value = value
|
|
34
|
+
@value_expected = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
chain :failing_with do |message|
|
|
38
|
+
@expected_message = message
|
|
39
|
+
@message_expected = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
chain :at do |*segments|
|
|
43
|
+
@expected_path = segments
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
failure_message do |actual|
|
|
47
|
+
next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
|
|
48
|
+
|
|
49
|
+
"expected the decoder to #{description}, but #{describe_outcome(Decoding.decode(actual, input))}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
failure_message_when_negated do |actual|
|
|
53
|
+
next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
|
|
54
|
+
|
|
55
|
+
"expected the decoder not to decode #{description_of(input)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
description do
|
|
59
|
+
if message_expected?
|
|
60
|
+
next "fail decoding #{description_of(input)} with #{description_of(@expected_message)}#{render_path(expected_path)}"
|
|
61
|
+
end
|
|
62
|
+
next "decode #{description_of(input)} to #{description_of(@expected_value)}" if value_expected?
|
|
63
|
+
|
|
64
|
+
"decode #{description_of(input)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def message_expected? = @message_expected
|
|
68
|
+
def value_expected? = @value_expected
|
|
69
|
+
|
|
70
|
+
def matches_failure?(result)
|
|
71
|
+
return false unless result.err?
|
|
72
|
+
|
|
73
|
+
msg, path = describe_failure(result)
|
|
74
|
+
matches_value?(@expected_message, msg) && path_matches?(path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def describe_outcome(result)
|
|
78
|
+
unless result.ok?
|
|
79
|
+
msg, path = describe_failure(result)
|
|
80
|
+
return "it failed with #{description_of(msg)}#{render_path(path)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return "it succeeded with #{description_of(result.unwrap!)}" if message_expected?
|
|
84
|
+
|
|
85
|
+
"it decoded to #{description_of(result.unwrap!)}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given {Decoding::Result} is an `Err` that failed with the
|
|
6
|
+
# expected message. Chain `at` to also assert where the failure occurred,
|
|
7
|
+
# naming the path segments outermost first. The expected message may itself be
|
|
8
|
+
# a matcher.
|
|
9
|
+
#
|
|
10
|
+
# expect(Decoding.decode(decoder, [1])).to fail_with("expected String, got Integer").at(0)
|
|
11
|
+
#
|
|
12
|
+
# Failures are recognised both as {Decoding::Failure} values and as the strings
|
|
13
|
+
# they turn into once {Decoding.decode} has rendered them.
|
|
14
|
+
RSpec::Matchers.define :fail_with do |expected|
|
|
15
|
+
include Decoding::MatcherHelpers
|
|
16
|
+
|
|
17
|
+
match do |actual|
|
|
18
|
+
next false unless actual.is_a?(Decoding::Result) && actual.err?
|
|
19
|
+
|
|
20
|
+
msg, path = describe_failure(actual)
|
|
21
|
+
matches_value?(expected, msg) && path_matches?(path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
chain :at do |*segments|
|
|
25
|
+
@expected_path = segments
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
failure_message do |actual|
|
|
29
|
+
next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
|
|
30
|
+
|
|
31
|
+
outcome =
|
|
32
|
+
if actual.ok?
|
|
33
|
+
"it succeeded with #{description_of(actual.unwrap!)}"
|
|
34
|
+
else
|
|
35
|
+
msg, path = describe_failure(actual)
|
|
36
|
+
"it failed with #{description_of(msg)}#{render_path(path)}"
|
|
37
|
+
end
|
|
38
|
+
"expected the result to #{description}, but #{outcome}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
failure_message_when_negated do
|
|
42
|
+
"expected the result not to #{description}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
description do
|
|
46
|
+
"fail with #{description_of(expected)}#{render_path(expected_path)}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given {Decoding::Result} is an `Ok` holding the expected
|
|
6
|
+
# value. The expected value may itself be a matcher.
|
|
7
|
+
#
|
|
8
|
+
# expect(Decoding.decode(decoder, "foo")).to succeed_with("foo")
|
|
9
|
+
RSpec::Matchers.define :succeed_with do |expected|
|
|
10
|
+
include Decoding::MatcherHelpers
|
|
11
|
+
|
|
12
|
+
match do |actual|
|
|
13
|
+
actual.is_a?(Decoding::Result) && actual.ok? && matches_value?(expected, actual.unwrap!)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
failure_message do |actual|
|
|
17
|
+
next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
|
|
18
|
+
|
|
19
|
+
outcome =
|
|
20
|
+
if actual.ok?
|
|
21
|
+
"it succeeded with #{description_of(actual.unwrap!)}"
|
|
22
|
+
else
|
|
23
|
+
"it failed with #{description_of(actual.unwrap_err(nil).to_s)}"
|
|
24
|
+
end
|
|
25
|
+
"expected the result to succeed with #{description_of(expected)}, but #{outcome}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
failure_message_when_negated do
|
|
29
|
+
"expected the result not to succeed with #{description_of(expected)}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Matchers for asserting on decoders and their results, for applications that
|
|
4
|
+
# write decoders of their own.
|
|
5
|
+
#
|
|
6
|
+
# These are the matchers this gem tests itself with. Require them from your
|
|
7
|
+
# spec helper; they need `rspec-expectations`, which is not a dependency of
|
|
8
|
+
# this gem, so nothing is loaded unless you ask for it.
|
|
9
|
+
#
|
|
10
|
+
# # spec/spec_helper.rb
|
|
11
|
+
# require "decoding/rspec"
|
|
12
|
+
#
|
|
13
|
+
# `decode_value` describes a decoder, and is what you want most of the time: it
|
|
14
|
+
# runs the decoder for you, so an example says what a decoder does rather than
|
|
15
|
+
# how to call it.
|
|
16
|
+
#
|
|
17
|
+
# expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
|
|
18
|
+
# expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
|
|
19
|
+
# expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
|
|
20
|
+
# expect(my_decoder).not_to decode_value(nil) # fails to decode
|
|
21
|
+
#
|
|
22
|
+
# An error nested in a structure is asserted with its location, outermost
|
|
23
|
+
# segment first, rather than by matching the rendered "Error at ." prefix:
|
|
24
|
+
#
|
|
25
|
+
# expect(field("a", field("b", string)))
|
|
26
|
+
# .to decode_value({ "a" => { "b" => 1 } })
|
|
27
|
+
# .failing_with("expected String, got Integer").at("a", "b")
|
|
28
|
+
#
|
|
29
|
+
# `succeed_with` and `fail_with` describe a result you already have, for when
|
|
30
|
+
# making the call is part of what the example is testing:
|
|
31
|
+
#
|
|
32
|
+
# expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
|
|
33
|
+
# expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
|
|
34
|
+
#
|
|
35
|
+
# Expected values are compared strictly, so `1` does not match `1.0` and a
|
|
36
|
+
# decoder answering with the wrong type cannot pass. Pass a matcher where that
|
|
37
|
+
# is too strict:
|
|
38
|
+
#
|
|
39
|
+
# expect(unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
|
|
40
|
+
require_relative "../decoding"
|
|
41
|
+
require_relative "matcher_helpers"
|
|
42
|
+
require_relative "rspec/decode_value"
|
|
43
|
+
require_relative "rspec/succeed_with"
|
|
44
|
+
require_relative "rspec/fail_with"
|
data/lib/decoding/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: decoding
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arjan van der Gaag
|
|
@@ -55,7 +55,12 @@ files:
|
|
|
55
55
|
- lib/decoding/env.rb
|
|
56
56
|
- lib/decoding/error.rb
|
|
57
57
|
- lib/decoding/failure.rb
|
|
58
|
+
- lib/decoding/matcher_helpers.rb
|
|
58
59
|
- lib/decoding/result.rb
|
|
60
|
+
- lib/decoding/rspec.rb
|
|
61
|
+
- lib/decoding/rspec/decode_value.rb
|
|
62
|
+
- lib/decoding/rspec/fail_with.rb
|
|
63
|
+
- lib/decoding/rspec/succeed_with.rb
|
|
59
64
|
- lib/decoding/version.rb
|
|
60
65
|
- sig/decoding.rbs
|
|
61
66
|
homepage: https://github.com/avdgaag/decoding
|