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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0af816a4ee858fb01b7affa44921ddb05b38199a2fbdee33f9439342d8c5dee7
4
- data.tar.gz: 18511340412ca51970844209ed9d878a2f46b32ffec15f1c9862c97a540c8921
3
+ metadata.gz: 27eee73e0aa7f430b7fb6aac6aea19c872dd5d825633d1f0e54362668ae355e5
4
+ data.tar.gz: b6ceb7aa0bd5cda5812949c165a4f8614dd0da97f5e05a8cbbf77cefa3e10e4d
5
5
  SHA512:
6
- metadata.gz: 044227d25ff8af0830443a267b86455de69ba14c72fa37bed1b5c84080d5f1156aeb533cceb46d5e8cce7b57a6d2a55a85729fb652b36bb747dc6b9f704ef6ef
7
- data.tar.gz: f957565332b96ca5fa607fb12ecebfc0bc3379beb6aa154760b70f14f92f59b4c8fbb39e235621e020473f49d68e20fe7909c8a0f6bcef1e091cd7d6f02b6a99
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
- # @raise [ArgumentError] when the format is not a known name or a pattern.
32
- # This is raised when the decoder is built, not when it is used.
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
- Decoders.map(Decoders.string) { parse.call(_1) }
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
- [->(str) { ::Time.strptime(str, format) }, "a time matching #{format.inspect}"]
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
- [->(str) { ::Time.public_send(format, str) }, format == :parse ? "a time" : "a time in #{format} format"]
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
- # @raise [ArgumentError] when the unit is not a known one. This is raised
72
- # when the decoder is built, not when it is used.
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
- per_second = UNIX_TIME_UNITS.fetch(unit)
100
+ require_parser(zone, :at, "a unix timestamp")
79
101
  Decoders.map_err(
80
102
  Decoders.any(
81
103
  Decoders.match(::Time),
82
- Decoders.map(
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
- private_class_method :time_format
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Decoding
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.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