diff --git a/lib/openai/internal/type/array_of.rb b/lib/openai/internal/type/array_of.rb index 1a3f9aec..2b02d8d6 100644 --- a/lib/openai/internal/type/array_of.rb +++ b/lib/openai/internal/type/array_of.rb @@ -38,19 +38,7 @@ def self.[](...) = new(...) # @param other [Object] # # @return [Boolean] - def ===(other) - type = item_type - other.is_a?(Array) && other.all? do |item| - case item - in ^type - true - in nil - nilable? - else - false - end - end - end + def ===(other) = other.is_a?(Array) && other.all?(item_type) # @api public # @@ -94,21 +82,16 @@ def coerce(value, state:) target = item_type exactness[:yes] += 1 - error = state.fetch(:error) - converted = value.map do |item| - case [nilable?, item] - in [true, nil] - exactness[:yes] += 1 - nil - else - coerced, item_error = - OpenAI::Internal::Type::Converter.coerce_with_error(target, item, state: state) - error ||= item_error - coerced + value + .map do |item| + case [nilable?, item] + in [true, nil] + exactness[:yes] += 1 + nil + else + OpenAI::Internal::Type::Converter.coerce(target, item, state: state) + end end - end - state[:error] = error - converted end # @api private diff --git a/lib/openai/internal/type/base_model.rb b/lib/openai/internal/type/base_model.rb index c68d0f97..0d4db937 100644 --- a/lib/openai/internal/type/base_model.rb +++ b/lib/openai/internal/type/base_model.rb @@ -82,22 +82,12 @@ def fields define_method(setter) do |value| target = type_fn.call state = OpenAI::Internal::Type::Converter.new_coerce_state(translate_names: false) - coerced = - if value.nil? && (nilable || !required) - nil - else - OpenAI::Internal::Type::Converter.coerce(target, value, state: state) - end - error = state.fetch(:error) - @coerced.store(name_sym, error || true) + coerced = OpenAI::Internal::Type::Converter.coerce(target, value, state: state) + error = @coerced.store(name_sym, state.fetch(:error) || true) stored = case [target, error] in [OpenAI::Internal::Type::Converter | Symbol, nil] - if value in ^target - value - else - coerced - end + coerced else value end @@ -283,7 +273,7 @@ class << self def coerce(value, state:) exactness = state.fetch(:exactness) - if value.is_a?(self) + if value.is_a?(self.class) exactness[:yes] += 1 return value end @@ -301,7 +291,6 @@ def coerce(value, state:) viability = instance.instance_variable_get(:@coerced) # rubocop:disable Metrics/BlockLength - error = state.fetch(:error) fields.each do |name, field| mode, required, target = field.fetch_values(:mode, :required, :type) api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const) @@ -319,14 +308,13 @@ def coerce(value, state:) item = val.fetch(src_name) keys.delete(src_name) - field_error = nil + state[:error] = nil converted = if item.nil? && (nilable || !required) exactness[nilable ? :yes : :maybe] += 1 nil else - coerced, field_error = - OpenAI::Internal::Type::Converter.coerce_with_error(target, item, state: state) + coerced = OpenAI::Internal::Type::Converter.coerce(target, item, state: state) case target in OpenAI::Internal::Type::Converter | Symbol coerced @@ -335,13 +323,11 @@ def coerce(value, state:) end end - error ||= field_error - viability.store(name, field_error || true) + viability.store(name, state.fetch(:error) || true) data.store(name, converted) end # rubocop:enable Metrics/BlockLength - state[:error] = error keys.each { data.store(_1, val.fetch(_1)) } instance end diff --git a/lib/openai/internal/type/converter.rb b/lib/openai/internal/type/converter.rb index 8221c134..0b31dfcf 100644 --- a/lib/openai/internal/type/converter.rb +++ b/lib/openai/internal/type/converter.rb @@ -268,26 +268,6 @@ def coerce(target, value, state: OpenAI::Internal::Type::Converter.new_coerce_st # rubocop:enable Metrics/BlockNesting end - # @api private - # - # Coerces a value while isolating its error from sibling coercions. - # - # @param target [OpenAI::Internal::Type::Converter, Class] - # - # @param value [Object] - # - # @param state [Hash{Symbol=>Object}] - # - # @return [Array(Object, StandardError, nil)] - def coerce_with_error(target, value, state:) - previous_error = state.fetch(:error) - state[:error] = nil - coerced = coerce(target, value, state: state) - [coerced, state.fetch(:error)] - ensure - state[:error] = previous_error - end - # @api private # # @param target [OpenAI::Internal::Type::Converter, Class] diff --git a/lib/openai/internal/type/hash_of.rb b/lib/openai/internal/type/hash_of.rb index da8caa16..df6a7c1d 100644 --- a/lib/openai/internal/type/hash_of.rb +++ b/lib/openai/internal/type/hash_of.rb @@ -46,8 +46,6 @@ def ===(other) case [key, val] in [Symbol | String, ^type] true - in [Symbol | String, nil] - nilable? else false end @@ -99,26 +97,21 @@ def coerce(value, state:) target = item_type exactness[:yes] += 1 - error = state.fetch(:error) - converted = value.to_h do |key, val| - k = key.is_a?(String) ? key.to_sym : key - v = - case [nilable?, val] - in [true, nil] - exactness[:yes] += 1 - nil - else - coerced, value_error = - OpenAI::Internal::Type::Converter.coerce_with_error(target, val, state: state) - error ||= value_error - coerced - end - - exactness[:no] += 1 unless k.is_a?(Symbol) - [k, v] - end - state[:error] = error - converted + value + .to_h do |key, val| + k = key.is_a?(String) ? key.to_sym : key + v = + case [nilable?, val] + in [true, nil] + exactness[:yes] += 1 + nil + else + OpenAI::Internal::Type::Converter.coerce(target, val, state: state) + end + + exactness[:no] += 1 unless k.is_a?(Symbol) + [k, v] + end end # @api private diff --git a/lib/openai/internal/type/union.rb b/lib/openai/internal/type/union.rb index 81243bbb..0199d301 100644 --- a/lib/openai/internal/type/union.rb +++ b/lib/openai/internal/type/union.rb @@ -159,11 +159,11 @@ def hash = variants.hash # # @return [Object] def coerce(value, state:) - strictness = state.fetch(:strictness) if (target = resolve_variant(value)) return OpenAI::Internal::Type::Converter.coerce(target, value, state: state) end + strictness = state.fetch(:strictness) exactness = state.fetch(:exactness) alternatives = [] @@ -172,16 +172,14 @@ def coerce(value, state:) exact = state[:exactness] = {yes: 0, no: 0, maybe: 0} state[:branched] += 1 - coerced, error = - OpenAI::Internal::Type::Converter.coerce_with_error(target, value, state: state) + coerced = OpenAI::Internal::Type::Converter.coerce(target, value, state: state) yes, no, maybe = exact.values if (no + maybe).zero? || (!strictness && yes.positive?) exact.each { exactness[_1] += _2 } state[:exactness] = exactness - state[:error] = error return coerced elsif maybe.positive? - alternatives << [[-yes, -maybe, no], exact, coerced, error] + alternatives << [[-yes, -maybe, no], exact, coerced] end end @@ -190,9 +188,8 @@ def coerce(value, state:) exactness[:no] += 1 state[:error] = ArgumentError.new("no matching variant for #{value.inspect}") value - in [[_, exact, coerced, error], *] + in [[_, exact, coerced], *] exact.each { exactness[_1] += _2 } - state[:error] = error coerced end .tap { state[:exactness] = exactness } diff --git a/test/openai/internal/type/base_model_raw_value_contract_test.rb b/test/openai/internal/type/base_model_raw_value_contract_test.rb new file mode 100644 index 00000000..75379353 --- /dev/null +++ b/test/openai/internal/type/base_model_raw_value_contract_test.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +# These tests pin the v0.78 BaseModel compatibility boundary. Request models retain +# caller-owned values, while the response coercion path may materialize parsed values. +class OpenAI::Test::BaseModelRawValueContractTest < Minitest::Test + class Item < OpenAI::Internal::Type::BaseModel + required :count, Integer + required :type, const: :item + end + + module ItemOrInteger + extend OpenAI::Internal::Type::Union + + variant Item + variant Integer + end + + class Container < OpenAI::Internal::Type::BaseModel + optional :item, Item + optional :items, OpenAI::Internal::Type::ArrayOf[Item] + optional :items_by_name, OpenAI::Internal::Type::HashOf[Item] + optional :choice, ItemOrInteger + end + + class Envelope < OpenAI::Internal::Type::BaseModel + required :message, OpenAI::Responses::ResponseOutputMessage + end + + def test_constructor_preserves_raw_values_for_every_nested_shape + item = {count: "1", type: "item"} + items = [{count: "2", type: "item"}] + items_by_name = {"third" => {count: "3", type: "item"}} + choice = {count: "4", type: "item"} + model = Container.new(item: item, items: items, items_by_name: items_by_name, choice: choice) + + assert_same(item, model.item) + assert_same(items, model.items) + assert_same(items_by_name, model.items_by_name) + assert_same(choice, model[:choice]) + assert_same(item, model[:item]) + assert_same(items, model.to_h.fetch(:items)) + assert_equal("item", model.to_h.fetch(:item).fetch(:type)) + end + + def test_assignment_replaces_the_shared_raw_value + model = Container.new(item: {count: "1", type: "item"}) + replacement = {count: "2", type: "item"} + + model.item = replacement + + assert_same(replacement, model.item) + assert_same(replacement, model[:item]) + assert_same(replacement, model.to_h.fetch(:item)) + end + + def test_deep_to_h_recurses_without_materializing_request_values + raw = { + item: {count: "1", type: "item"}, + items: [{count: "2", type: "item"}], + items_by_name: {"third" => {count: "3", type: "item"}} + } + model = Container.new(**raw) + + assert_equal(raw, model.deep_to_h) + assert_instance_of(Hash, model.deep_to_h.fetch(:item)) + assert_instance_of(Hash, model.deep_to_h.fetch(:items).fetch(0)) + assert_instance_of(Hash, model.deep_to_h.dig(:items_by_name, "third")) + end + + def test_generated_params_support_nested_fetch_chains_on_raw_discriminators + input = [{content: "hello", role: "user", type: "message"}] + params = OpenAI::Responses::ResponseCreateParams.new(input: input, model: "gpt-4o") + + assert_equal("message", params.to_h.fetch(:input).fetch(0).fetch(:type)) + assert_same(input, params.input) + assert_same(input, params[:input]) + assert_same(input, params.to_h.fetch(:input)) + end + + def test_beta_params_have_the_same_raw_value_contract + input = [{content: "hello", role: "user", type: "message"}] + params = OpenAI::Beta::ResponseCreateParams.new(input: input, model: "gpt-4o") + + assert_equal("message", params.to_h.fetch(:input).fetch(0).fetch(:type)) + assert_same(input, params.input) + assert_same(input, params.to_h.fetch(:input)) + end + + def test_request_dump_observes_mutations_to_shared_nested_values + input = [{content: "before", role: "user", type: "message"}] + params = OpenAI::Responses::ResponseCreateParams.new(input: input, model: "gpt-4o") + + input.fetch(0)[:content] = "after" + input << {content: "second", role: "user", type: "message"} + dumped, = OpenAI::Responses::ResponseCreateParams.dump_request(params) + + assert_same(input, params.to_h.fetch(:input)) + assert_equal(%w[after second], dumped.fetch(:input).map { _1.fetch(:content) }) + assert_equal(2, dumped.fetch(:input).size) + end + + def test_response_coercion_preserves_symbolized_enum_and_const_values + message = coerce_response_output_message + + assert_equal(:assistant, message.role) + assert_equal(:assistant, message[:role]) + assert_equal(:message, message.type) + assert_equal(:message, message.to_h.fetch(:type)) + assert_equal(:completed, message.status) + end + + def test_parsed_response_equality_and_hash_use_parsed_values + parsed = coerce_response_output_message + symbolic = OpenAI::Responses::ResponseOutputMessage.new( + id: "msg_123", + content: [], + role: :assistant, + status: :completed, + type: :message + ) + stringly = OpenAI::Responses::ResponseOutputMessage.new( + id: "msg_123", + content: [], + role: "assistant", + status: "completed", + type: "message" + ) + + assert_equal(symbolic, parsed) + assert_equal(symbolic.hash, parsed.hash) + refute_equal(stringly, parsed) + refute_equal(stringly.hash, parsed.hash) + end + + def test_response_coercion_materializes_nested_models + state = OpenAI::Internal::Type::Converter.new_coerce_state + envelope = OpenAI::Internal::Type::Converter.coerce( + Envelope, + {message: response_output_message_payload}, + state: state + ) + + assert_instance_of(OpenAI::Responses::ResponseOutputMessage, envelope.message) + assert_same(envelope.message, envelope.to_h.fetch(:message)) + assert_equal(:message, envelope.message[:type]) + assert_nil(state.fetch(:error)) + end + + private + + def coerce_response_output_message + state = OpenAI::Internal::Type::Converter.new_coerce_state + message = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Responses::ResponseOutputMessage, + response_output_message_payload, + state: state + ) + + assert_nil(state.fetch(:error)) + message + end + + def response_output_message_payload + { + id: "msg_123", + content: [], + role: "assistant", + status: "completed", + type: "message" + } + end +end diff --git a/test/openai/internal/type/base_model_test.rb b/test/openai/internal/type/base_model_test.rb index 1f4848db..f8aae4d2 100644 --- a/test/openai/internal/type/base_model_test.rb +++ b/test/openai/internal/type/base_model_test.rb @@ -125,24 +125,6 @@ def test_coerce_errors end end - def test_coerce_with_error_isolates_each_attempt - previous_error = RuntimeError.new("previous") - state = OpenAI::Internal::Type::Converter.new_coerce_state - state[:error] = previous_error - - value, error = OpenAI::Internal::Type::Converter.coerce_with_error(Integer, "one", state: state) - - assert_equal("one", value) - assert_instance_of(ArgumentError, error) - assert_same(previous_error, state.fetch(:error)) - - value, error = OpenAI::Internal::Type::Converter.coerce_with_error(Integer, "1", state: state) - - assert_equal(1, value) - assert_nil(error) - assert_same(previous_error, state.fetch(:error)) - end - def test_dump_retry types = [ OpenAI::Internal::Type::Unknown, @@ -283,9 +265,6 @@ class OpenAI::Test::CollectionModelTest < Minitest::Test A3 = OpenAI::Internal::Type::ArrayOf[Integer, nil?: true] H3 = OpenAI::Internal::Type::HashOf[Integer, nil?: true] - A4 = OpenAI::Internal::Type::ArrayOf[OpenAI::Internal::Type::Unknown] - H4 = OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown] - def test_coerce cases = { [A1, []] => [{yes: 1}, []], @@ -321,21 +300,6 @@ def test_coerce end end end - - def test_collection_matchers_respect_nullable_items_and_item_types - cases = { - [A1, [nil]] => false, - [A3, [nil]] => true, - [A4, [nil]] => true, - [H1, {item: nil}] => false, - [H3, {item: nil}] => true, - [H4, {item: nil}] => true - } - - cases.each do |(target, input), expected| - assert_equal(expected, target.public_send(:===, input)) - end - end end class OpenAI::Test::BaseModelTest < Minitest::Test @@ -593,14 +557,6 @@ def test_accessors end end - def test_discriminated_coercion_preserves_strictness - state = OpenAI::Internal::Type::Converter.new_coerce_state - - OpenAI::Internal::Type::Converter.coerce(U2, {type: :a}, state: state) - - assert_equal(true, state.fetch(:strictness)) - end - def test_coerce cases = { [U0, :""] => [{no: 1}, 0, :""], diff --git a/test/openai/internal/type/nested_model_coercion_test.rb b/test/openai/internal/type/nested_model_coercion_test.rb deleted file mode 100644 index aeac9037..00000000 --- a/test/openai/internal/type/nested_model_coercion_test.rb +++ /dev/null @@ -1,253 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../test_helper" - -class OpenAI::Test::NestedModelCoercionTest < Minitest::Test - class Item < OpenAI::Internal::Type::BaseModel - required :a, Integer - required :b, Integer - end - - module ItemOrInteger - extend OpenAI::Internal::Type::Union - - variant Integer - variant Item - end - - module ReversedItemOrInteger - extend OpenAI::Internal::Type::Union - - variant Item - variant Integer - end - - class Container < OpenAI::Internal::Type::BaseModel - optional :item, Item - optional :items, OpenAI::Internal::Type::ArrayOf[Item] - optional :map, OpenAI::Internal::Type::HashOf[Item] - optional :nullable_items, OpenAI::Internal::Type::ArrayOf[Item, nil?: true] - optional :nullable_map, OpenAI::Internal::Type::HashOf[Item, nil?: true] - optional :choice, ItemOrInteger - optional :reversed_choice, ReversedItemOrInteger - end - - class NullableContainer < OpenAI::Internal::Type::BaseModel - optional :optional_item, Item - required :nullable_item, Item, nil?: true - end - - def test_constructor_and_assignment_store_coerced_nested_models - model = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) - - assert_instance_of(Item, model.item) - assert_instance_of(Item, model.items.fetch(0)) - assert_instance_of(Item, model.to_h.fetch(:item)) - assert_instance_of(Item, model.to_h.fetch(:items).fetch(0)) - assert_equal([1, 2], [model.item.a, model.item.b]) - assert_equal([3, 4], [model.items.fetch(0).a, model.items.fetch(0).b]) - - model.item = {a: "5", b: "6"} - model.items = [{a: "7", b: "8"}] - - assert_instance_of(Item, model.item) - assert_instance_of(Item, model.items.fetch(0)) - assert_instance_of(Item, model.to_h.fetch(:item)) - assert_instance_of(Item, model.to_h.fetch(:items).fetch(0)) - assert_equal([5, 6], [model.item.a, model.item.b]) - assert_equal([7, 8], [model.items.fetch(0).a, model.items.fetch(0).b]) - end - - def test_constructor_and_assignment_coerce_nested_maps_and_unions - map = {"first" => {a: "1", b: "2"}} - choice = {a: "3", b: "4"} - model = Container.new(map: map, choice: choice, reversed_choice: choice) - - assert_instance_of(Item, model.map.fetch(:first)) - assert_instance_of(Item, model.choice) - assert_instance_of(Item, model.reversed_choice) - assert_equal([1, 2], [model.map.fetch(:first).a, model.map.fetch(:first).b]) - assert_equal([3, 4], [model.choice.a, model.choice.b]) - - model.map = {"second" => {a: "5", b: "6"}} - model.choice = {a: "7", b: "8"} - model.reversed_choice = {a: "9", b: "10"} - - assert_instance_of(Item, model.map.fetch(:second)) - assert_instance_of(Item, model.choice) - assert_instance_of(Item, model.reversed_choice) - assert_equal( - { - map: {second: {a: "5", b: "6"}}, - choice: {a: "7", b: "8"}, - reversed_choice: {a: "9", b: "10"} - }, - model.deep_to_h - ) - assert_equal(model.deep_to_h, JSON.parse(model.to_json, symbolize_names: true)) - end - - def test_setter_preserves_already_coerced_nested_model_identity - item = Item.new(a: 1, b: 2) - items = [item] - map = {item: item} - model = Container.new(item: item, items: items, map: map) - - assert_same(item, model.item) - assert_same(items, model.items) - assert_same(item, model.items.fetch(0)) - assert_same(map, model.map) - assert_same(item, model.map.fetch(:item)) - end - - def test_setter_preserves_nullable_collection_identity - item = Item.new(a: 1, b: 2) - items = [item, nil] - map = {item: item, empty: nil} - model = Container.new(nullable_items: items, nullable_map: map) - - assert_same(items, model.nullable_items) - assert_same(map, model.nullable_map) - end - - def test_constructor_and_assignment_accept_nil_for_optional_and_nilable_models - model = NullableContainer.new(optional_item: nil, nullable_item: nil) - - assert_nil(model.optional_item) - assert_nil(model.nullable_item) - assert_equal({optional_item: nil, nullable_item: nil}, model.to_h) - - model.optional_item = Item.new(a: 1, b: 2) - model.nullable_item = Item.new(a: 3, b: 4) - model.optional_item = nil - model.nullable_item = nil - - assert_nil(model.optional_item) - assert_nil(model.nullable_item) - assert_equal({optional_item: nil, nullable_item: nil}, model.to_h) - end - - def test_coerce_preserves_already_coerced_model_identity - model = Item.new(a: 1, b: 2) - state = OpenAI::Internal::Type::Converter.new_coerce_state - - assert_same(model, OpenAI::Internal::Type::Converter.coerce(Item, model, state: state)) - end - - def test_successful_nested_coercion_isolated_from_raw_input_mutation - item = {a: "1", b: "2"} - items = [{a: "3", b: "4"}] - map = {item: {a: "5", b: "6"}} - model = Container.new(item: item, items: items, map: map) - - item[:a] = "changed" - items.fetch(0)[:a] = "changed" - items << {a: "changed", b: "changed"} - map.fetch(:item)[:a] = "changed" - map[:later] = {a: "changed", b: "changed"} - - assert_equal(1, model.item.a) - assert_equal(3, model.items.fetch(0).a) - assert_equal(1, model.items.size) - assert_equal(5, model.map.fetch(:item).a) - refute(model.map.key?(:later)) - end - - def test_nested_model_equality_and_hash_use_stored_coerced_values - left = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) - right = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) - - assert_equal(left, right) - assert_equal(left.hash, right.hash) - end - - def test_failed_nested_model_coercion_preserves_input_and_error - item = {a: "one", b: "2"} - items = [{a: "1", b: "two"}] - model = Container.new(item: item, items: items) - - assert_same(item, model.to_h.fetch(:item)) - assert_same(items, model.to_h.fetch(:items)) - assert_raises(OpenAI::Errors::ConversionError) { model.item } - assert_raises(OpenAI::Errors::ConversionError) { model.items } - end - - def test_assignment_replaces_previous_success_and_error_status - valid = {a: "1", b: "2"} - invalid = {a: "one", b: "2"} - model = Container.new - - model.item = valid - assert_instance_of(Item, model.item) - model.item = invalid - assert_same(invalid, model.to_h.fetch(:item)) - assert_raises(OpenAI::Errors::ConversionError) { model.item } - model.item = valid - assert_instance_of(Item, model.item) - - model.items = [valid] - assert_instance_of(Item, model.items.fetch(0)) - invalid_items = [invalid] - model.items = invalid_items - assert_same(invalid_items, model.to_h.fetch(:items)) - assert_raises(OpenAI::Errors::ConversionError) { model.items } - model.items = [valid] - assert_instance_of(Item, model.items.fetch(0)) - - model.map = {valid: valid} - assert_instance_of(Item, model.map.fetch(:valid)) - invalid_map = {invalid: invalid} - model.map = invalid_map - assert_same(invalid_map, model.to_h.fetch(:map)) - assert_raises(OpenAI::Errors::ConversionError) { model.map } - model.map = {valid: valid} - assert_instance_of(Item, model.map.fetch(:valid)) - - model.choice = valid - assert_instance_of(Item, model.choice) - model.choice = invalid - assert_same(invalid, model.to_h.fetch(:choice)) - assert_raises(OpenAI::Errors::ConversionError) { model.choice } - model.choice = valid - assert_instance_of(Item, model.choice) - end - - def test_composite_coercion_errors_do_not_depend_on_field_or_item_order - valid = {a: "1", b: "2"} - invalid_values = [{a: "one", b: "2"}, {a: "1", b: "two"}] - - invalid_values.each do |invalid| - [[invalid, valid], [valid, invalid]].each do |items| - map = items.each_with_index.to_h { |value, index| [index, value] } - model = Container.new(item: invalid, items: items, map: map) - - assert_same(invalid, model.to_h.fetch(:item)) - assert_same(items, model.to_h.fetch(:items)) - assert_same(map, model.to_h.fetch(:map)) - assert_raises(OpenAI::Errors::ConversionError) { model.item } - assert_raises(OpenAI::Errors::ConversionError) { model.items } - assert_raises(OpenAI::Errors::ConversionError) { model.map } - end - end - end - - def test_rejected_union_variant_does_not_override_selected_coercion - model = Container.new(choice: "1", reversed_choice: "2") - - assert_equal(1, model.choice) - assert_equal(1, model.to_h.fetch(:choice)) - assert_equal(2, model.reversed_choice) - assert_equal(2, model.to_h.fetch(:reversed_choice)) - end - - def test_selected_union_variant_preserves_its_conversion_error - choice = {a: "one", b: "2"} - reversed_choice = {a: "1", b: "two"} - model = Container.new(choice: choice, reversed_choice: reversed_choice) - - assert_same(choice, model.to_h.fetch(:choice)) - assert_same(reversed_choice, model.to_h.fetch(:reversed_choice)) - assert_raises(OpenAI::Errors::ConversionError) { model.choice } - assert_raises(OpenAI::Errors::ConversionError) { model.reversed_choice } - end -end