lutaml-model 0.8.100 → 0.8.119

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -5
  3. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  4. data/lib/lutaml/json/adapter/yeptris_adapter.rb +28 -0
  5. data/lib/lutaml/key_value/transform.rb +4 -0
  6. data/lib/lutaml/key_value/transformation.rb +10 -0
  7. data/lib/lutaml/model/adapter_resolver.rb +13 -5
  8. data/lib/lutaml/model/attribute.rb +77 -3
  9. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  10. data/lib/lutaml/model/configuration.rb +13 -0
  11. data/lib/lutaml/model/serialize/builder.rb +22 -0
  12. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  13. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  14. data/lib/lutaml/model/type_resolver.rb +7 -5
  15. data/lib/lutaml/model/version.rb +1 -1
  16. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  17. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  18. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  19. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  20. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  21. data/lib/lutaml/xml/leptris/plan_serializer.rb +309 -47
  22. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  23. data/lib/lutaml/xml/leptris.rb +25 -84
  24. data/lib/lutaml/xml/model_transform.rb +5 -0
  25. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  26. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +29 -0
  27. data/lutaml-model.gemspec +3 -3
  28. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  29. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  30. data/spec/lutaml/model/cast_element_form_spec.rb +54 -0
  31. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  32. data/spec/lutaml/model/from_json_scalar_cast_spec.rb +70 -0
  33. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  34. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  35. data/spec/lutaml/model/yeptris_native_detection_spec.rb +83 -0
  36. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  37. data/spec/lutaml/xml/plan_fast_path_spec.rb +34 -6
  38. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  39. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  40. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  41. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  42. metadata +19 -10
@@ -78,12 +78,21 @@ module Lutaml
78
78
 
79
79
  def add_xml_fragment_or_raw_content(parent, fragment_string)
80
80
  require "moxml" unless defined?(Moxml)
81
- fragment_doc = Moxml.new.parse(fragment_string, fragment: true)
82
- add_fragment_children_to_parent(fragment_doc, parent)
83
- rescue Moxml::ParseError
84
- # Engines without a fragment mode (leptris today, lutaml/moxml#188)
85
- # reject multiple top-level nodes; re-parse under a synthetic root
86
- # and lift its children.
81
+ fragment_doc = begin
82
+ Moxml.new.parse(fragment_string, fragment: true)
83
+ rescue Moxml::ParseError
84
+ # Engines that reject multi-root fragments outright
85
+ # (lutaml/moxml#188) — fall through to the synthetic root.
86
+ nil
87
+ end
88
+ # The fallback keys on the RESULT, not the exception: recover-mode
89
+ # engines return an empty document for multi-root fragments instead
90
+ # of raising, which used to leave the synthetic-root path dead and
91
+ # the fragment silently dropped.
92
+ if fragment_doc&.children&.any?(&:element?)
93
+ return add_fragment_children_to_parent(fragment_doc, parent)
94
+ end
95
+
87
96
  wrapped = Moxml.new.parse(
88
97
  "<lutaml-fragment>#{fragment_string}</lutaml-fragment>",
89
98
  )
@@ -40,6 +40,35 @@ module Lutaml
40
40
  end
41
41
  private_class_method :require_engine
42
42
 
43
+ # True where the yeptris engine loads (libyeptris present for
44
+ # this platform) AND answers a probe parse. A require alone
45
+ # does not prove the engine: a half-initialized Yeptris
46
+ # constant (the poison-process state the gem documents)
47
+ # passes const_defined? and require while every parse fails,
48
+ # and the native parse error then surfaces on valid input.
49
+ # Idempotent; a missing or broken install reports false
50
+ # instead of raising, so optional-engine callers (detection)
51
+ # can gate on it and fall back to the standard lane.
52
+ def self.available?
53
+ require_engine
54
+ native_ready?
55
+ rescue LoadError
56
+ false
57
+ end
58
+
59
+ def self.native_ready?
60
+ return @native_ready unless @native_ready.nil?
61
+
62
+ @native_ready = begin
63
+ # rubocop:disable-next Naming/VariableNumber -- upstream schema literal
64
+ ::Yeptris::YAML.load("native_probe: 1", schema: :compat_11)
65
+ true
66
+ rescue ::Yeptris::Error, LoadError, NoMemoryError
67
+ false
68
+ end
69
+ end
70
+ private_class_method :native_ready?
71
+
43
72
  # The parse-error contract is the standard adapter's: invalid
44
73
  # input raises Psych::SyntaxError regardless of the engine, so
45
74
  # consumers' rescue ladders (and this gem's own
data/lutaml-model.gemspec CHANGED
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
34
 
35
35
  # required for liquid
36
- spec.add_dependency "base64"
36
+ spec.add_dependency "base64", ">= 0.1.1"
37
37
  spec.add_dependency "bigdecimal"
38
38
  spec.add_dependency "canon"
39
39
  spec.add_dependency "concurrent-ruby"
@@ -41,10 +41,10 @@ Gem::Specification.new do |spec|
41
41
  # 0.5.84 always passes XML_PARSE_HUGE to libxml2: very large documents
42
42
  # parse completely instead of being silently truncated at the 10 MB
43
43
  # input-buffer cap (lutaml-model#871).
44
- spec.add_dependency "moxml", "~> 0.5.84"
44
+ spec.add_dependency "moxml", "~> 0.5.139"
45
45
  spec.add_dependency "ostruct"
46
46
  spec.add_dependency "rubyzip", "~> 3.7"
47
- spec.add_dependency "thor"
47
+ spec.add_dependency "thor", "~> 1.4"
48
48
 
49
49
  spec.metadata["rubygems_mfa_required"] = "true"
50
50
  end
@@ -1,16 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
- begin
5
- require "lutaml/json/adapter/yeptris_adapter"
6
- rescue LoadError
7
- # yeptris ships no platform gem here (e.g. Windows): every example skips.
8
- end
9
-
10
- YEPTRIS_AVAILABLE = defined?(Yeptris)
3
+ require "lutaml/json/adapter/yeptris_adapter"
11
4
 
12
- RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Json::Adapter::YeptrisAdapter : Object) do
13
- before { skip "yeptris is not available on this platform" unless YEPTRIS_AVAILABLE }
5
+ RSpec.describe Lutaml::Json::Adapter::YeptrisAdapter do
6
+ before { skip "yeptris is not available on this platform" unless described_class.available? }
14
7
 
15
8
  let(:attributes) { { "name" => "John", "age" => 30, "roles" => %w[admin dev] } }
16
9
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "spec_helper"
4
+ require "open3"
4
5
 
5
6
  RSpec.describe Lutaml::Model::AdapterResolver, ".adapter_for with a configured type" do
6
7
  after do
@@ -22,17 +23,40 @@ RSpec.describe Lutaml::Model::AdapterResolver, ".adapter_for with a configured t
22
23
  expect(adapter.name).to include("Json::Standard")
23
24
  end
24
25
 
26
+ # Runs in a subprocess: whether the engine is already loaded is a
27
+ # process-global fact, so any in-process assertion would depend on
28
+ # spec file order (the yeptris adapter faces load it).
25
29
  it "does not load the optional yeptris engine when a standard adapter is configured" do
26
- skip "yeptris already loaded by an earlier spec" if Object.const_defined?(:Yeptris)
30
+ probe = <<~RUBY
31
+ require "lutaml/model"
27
32
 
28
- Lutaml::Model::Config.configure do |config|
29
- config.yaml_adapter_type = :standard
30
- config.json_adapter_type = :standard
31
- end
33
+ Lutaml::Model::Config.configure do |config|
34
+ config.yaml_adapter_type = :standard
35
+ config.json_adapter_type = :standard
36
+ end
37
+
38
+ adapter_yaml = Lutaml::Model::AdapterResolver.adapter_for(:yaml)
39
+ adapter_json = Lutaml::Model::AdapterResolver.adapter_for(:json)
32
40
 
33
- described_class.adapter_for(:yaml)
34
- described_class.adapter_for(:json)
41
+ # Parsing through the configured adapters must likewise stay on
42
+ # the standard engines.
43
+ adapter_yaml.parse("a: 1")
44
+ adapter_json.parse('{"a":1}')
35
45
 
36
- expect(Object.const_defined?(:Yeptris)).to be(false)
46
+ exit(Object.const_defined?(:Yeptris) ? 1 : 0)
47
+ RUBY
48
+
49
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", "lib", "-e", probe)
50
+ expect(status).to be_success, stderr
51
+ end
52
+
53
+ describe "plan lane defaults" do
54
+ it "ships the ordered plan lane on, floored to the memo-capable engine" do
55
+ # leptris#1743 (exponential flatten on diamond-shared plan
56
+ # graphs) shipped its identity-memo flatten_plan in 1.9.366.2;
57
+ # the MIN_LEPTRIS_VERSION floor keeps memo-less engines on the
58
+ # interpretive path, so the default-on lane is safe.
59
+ expect(Lutaml::Model::Config.instance.xml_plan_ordered_path).to be(true)
60
+ end
37
61
  end
38
62
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The scalar-cast diversion (#978) must not capture element-form input:
6
+ # a type declaring its own .cast still receives parsed child elements
7
+ # through the mapping lane, which builds the model from its XML
8
+ # mapping. Diverting those into .cast raised IncorrectModelError
9
+ # downstream (uniword shared-schemas round-trips on 0.8.116/0.8.117).
10
+ RSpec.describe "cast routing for element-form input" do
11
+ let(:styled_class) do
12
+ Class.new(Lutaml::Model::Serializable) do
13
+ attribute :value, :string
14
+
15
+ xml do
16
+ element "style"
17
+ map_attribute "value", to: :value
18
+ end
19
+
20
+ key_value do
21
+ map "value", to: :value
22
+ end
23
+
24
+ def self.cast(value)
25
+ return value if value.is_a?(self)
26
+
27
+ new(value: value.to_s)
28
+ end
29
+ end
30
+ end
31
+
32
+ let(:host_class) do
33
+ Class.new(Lutaml::Model::Serializable) do
34
+ attribute :style, "CastStyled"
35
+
36
+ xml do
37
+ element "host"
38
+ map_element "style", to: :style
39
+ end
40
+ end
41
+ end
42
+
43
+ before do
44
+ stub_const("CastStyled", styled_class)
45
+ stub_const("CastHost", host_class)
46
+ end
47
+
48
+ it "builds element-form input through the mapping lane" do
49
+ model = CastHost.from_xml('<host><style value="normal"/></host>')
50
+
51
+ expect(model.style).to be_a(CastStyled)
52
+ expect(model.style.value).to eq("normal")
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "strict collection assignment" do
6
+ let(:node_class) do
7
+ Class.new(Lutaml::Model::Serializable) do
8
+ attribute :id, :string
9
+ end
10
+ end
11
+
12
+ let(:holder_class) do
13
+ Class.new(Lutaml::Model::Serializable) do
14
+ attribute :items, "Node", collection: true
15
+ end
16
+ end
17
+
18
+ before do
19
+ stub_const("Node", node_class)
20
+ stub_const("Holder", holder_class)
21
+ end
22
+
23
+ it "raises at the constructor boundary" do
24
+ expect do
25
+ Holder.new(items: [[Node.new(id: "a")], Node.new(id: "b")])
26
+ end.to raise_error(Lutaml::Model::TypeError, /item 0 is an Array/)
27
+ end
28
+
29
+ it "raises at the writer boundary" do
30
+ holder = Holder.new
31
+ expect do
32
+ holder.items = [Node.new, [Node.new]]
33
+ end.to raise_error(Lutaml::Model::TypeError, /item 1 is an Array/)
34
+ end
35
+
36
+ it "raises for a custom collection class" do
37
+ custom = Class.new(Lutaml::Model::Collection) do
38
+ instances :node, "Node"
39
+ end
40
+ stub_const("NodeBag", custom)
41
+ wrapper = Class.new(Lutaml::Model::Serializable) do
42
+ attribute :bag, "NodeBag"
43
+ end
44
+ stub_const("BagHolder", wrapper)
45
+
46
+ expect do
47
+ BagHolder.new(bag: [[Node.new]])
48
+ end.to raise_error(Lutaml::Model::TypeError, /nested arrays are not flattened/)
49
+ end
50
+
51
+ it "keeps flat assignment untouched" do
52
+ holder = Holder.new(items: [Node.new(id: "x")])
53
+ expect(holder.items.map(&:id)).to eq(["x"])
54
+ end
55
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #978: a JSON scalar handed to a mapped-model attribute crashed with
6
+ # an internal NoMethodError (key? on String) wrapped as
7
+ # InvalidFormatError. A type that declares its own .cast owns scalar
8
+ # construction from that scalar; a cast-less type falls through to the
9
+ # legacy conversion chain, whose parse error surfaces the offending
10
+ # token — the remedy there is declaring .cast on the type.
11
+ RSpec.describe "from_json scalar into a mapped-model attribute" do
12
+ let(:marked_class) do
13
+ Class.new(Lutaml::Model::Serializable) do
14
+ attribute :content, :string
15
+
16
+ key_value do
17
+ map "content", to: :content
18
+ end
19
+
20
+ def self.cast(value)
21
+ return value if value.is_a?(self)
22
+
23
+ new(content: value.to_s)
24
+ end
25
+ end
26
+ end
27
+
28
+ let(:plain_class) do
29
+ Class.new(Lutaml::Model::Serializable) do
30
+ attribute :content, :string
31
+
32
+ key_value do
33
+ map "content", to: :content
34
+ end
35
+ end
36
+ end
37
+
38
+ let(:holder_class) do
39
+ Class.new(Lutaml::Model::Serializable) do
40
+ attribute :description, "ScalarMarked"
41
+ attribute :plain_desc, "ScalarPlain"
42
+
43
+ key_value do
44
+ map "description", to: :description
45
+ map "plain_desc", to: :plain_desc
46
+ end
47
+ end
48
+ end
49
+
50
+ before do
51
+ stub_const("ScalarMarked", marked_class)
52
+ stub_const("ScalarPlain", plain_class)
53
+ stub_const("ScalarHolder", holder_class)
54
+ end
55
+
56
+ it "constructs the mapped value through the type's .cast" do
57
+ model = ScalarHolder.from_json('{"description": "text"}')
58
+
59
+ expect(model.description).to be_a(ScalarMarked)
60
+ expect(model.description.content).to eq("text")
61
+ end
62
+
63
+ it "falls through to the format parse error for a cast-less mapped type" do
64
+ expect { ScalarHolder.from_json('{"plain_desc": "text"}') }
65
+ .to raise_error(
66
+ Lutaml::Model::InvalidFormatError,
67
+ /unexpected token 'text'/,
68
+ )
69
+ end
70
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #971: a singular attribute may ask ingest to keep the leading value
6
+ # when the mapper collects more than one element (relaton's upstream
7
+ # bibitems carry multiple <formattedref> elements). Default stays
8
+ # strict: the over-count remains a CollectionTrueMissingError.
9
+ RSpec.describe "singular_tolerance attribute option" do
10
+ let(:item_class) do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :content, :string
13
+ end
14
+ end
15
+
16
+ before do
17
+ stub_const("ToleranceItem", item_class)
18
+ end
19
+
20
+ describe "option validation" do
21
+ it "accepts :first" do
22
+ expect do
23
+ Lutaml::Model::Attribute.new(:ref, "ToleranceItem",
24
+ singular_tolerance: :first)
25
+ end.not_to raise_error
26
+ end
27
+
28
+ it "accepts omission (strict default)" do
29
+ expect do
30
+ Lutaml::Model::Attribute.new(:ref, "ToleranceItem")
31
+ end.not_to raise_error
32
+ end
33
+
34
+ it "rejects unsupported tolerances" do
35
+ expect do
36
+ Lutaml::Model::Attribute.new(:ref, "ToleranceItem",
37
+ singular_tolerance: :last)
38
+ end.to raise_error(ArgumentError, /singular_tolerance: :last/)
39
+ end
40
+ end
41
+
42
+ describe "#tolerates_singular_overflow?" do
43
+ let(:tolerant) do
44
+ Lutaml::Model::Attribute.new(:ref, "ToleranceItem",
45
+ singular_tolerance: :first)
46
+ end
47
+
48
+ let(:strict) { Lutaml::Model::Attribute.new(:ref, "ToleranceItem") }
49
+
50
+ let(:collection_attr) do
51
+ Lutaml::Model::Attribute.new(:refs, "ToleranceItem",
52
+ collection: true,
53
+ singular_tolerance: :first)
54
+ end
55
+
56
+ it "is true for an over-ingested collection on a tolerant singular" do
57
+ expect(tolerant.tolerates_singular_overflow?([1, 2])).to be(true)
58
+ end
59
+
60
+ it "is false without the option" do
61
+ expect(strict.tolerates_singular_overflow?([1, 2])).to be(false)
62
+ end
63
+
64
+ it "is false for a scalar on a tolerant singular" do
65
+ expect(tolerant.tolerates_singular_overflow?(1)).to be(false)
66
+ end
67
+
68
+ it "is false for a collection attribute — the option does not apply" do
69
+ expect(collection_attr.tolerates_singular_overflow?([1, 2])).to be(false)
70
+ end
71
+ end
72
+
73
+ describe "strict default" do
74
+ it "still raises CollectionTrueMissingError on over-ingest" do
75
+ attr = Lutaml::Model::Attribute.new(:ref, "ToleranceItem")
76
+
77
+ expect do
78
+ attr.valid_collection!([item_class.new, item_class.new], String)
79
+ end.to raise_error(Lutaml::Model::CollectionTrueMissingError)
80
+ end
81
+ end
82
+
83
+ describe "tolerant ingest" do
84
+ it "lets valid_collection! pass after the transform reduces to first" do
85
+ attr = Lutaml::Model::Attribute.new(:ref, "ToleranceItem",
86
+ singular_tolerance: :first)
87
+ value = [item_class.new, item_class.new]
88
+
89
+ reduced = value.first if attr.tolerates_singular_overflow?(value)
90
+ expect(attr.valid_collection!(reduced, String)).to be(true)
91
+ expect(reduced).to eq(value.first)
92
+ end
93
+ end
94
+
95
+ describe "on the KeyValue lane" do
96
+ let(:marked_class) do
97
+ Class.new(Lutaml::Model::Serializable) do
98
+ attribute :content, :string
99
+
100
+ key_value do
101
+ map "content", to: :content
102
+ end
103
+ end
104
+ end
105
+
106
+ let(:relation_class) do
107
+ Class.new(Lutaml::Model::Serializable) do
108
+ attribute :rel_type, :string
109
+ attribute :description, "ToleranceMarked", singular_tolerance: :first
110
+
111
+ key_value do
112
+ map "type", to: :rel_type
113
+ map "description", to: :description
114
+ end
115
+ end
116
+ end
117
+
118
+ before do
119
+ stub_const("ToleranceMarked", marked_class)
120
+ stub_const("ToleranceRelation", relation_class)
121
+ end
122
+
123
+ it "keeps the leading value on ingest instead of the whole array" do
124
+ model = ToleranceRelation.from_yaml(
125
+ "---\ntype: cites\ndescription:\n - content: First\n - content: Second\n",
126
+ )
127
+
128
+ expect(model.description).to be_a(ToleranceMarked)
129
+ expect(model.description.content).to eq("First")
130
+ end
131
+
132
+ it "round-trips the collapsed singular through to_yaml" do
133
+ model = ToleranceRelation.from_yaml(
134
+ "---\ntype: cites\ndescription:\n - content: First\n - content: Second\n",
135
+ )
136
+
137
+ yaml = model.to_yaml
138
+ expect(yaml).to include("First")
139
+ expect(yaml).not_to include("Second")
140
+ expect(ToleranceRelation.from_yaml(yaml).description.content).to eq("First")
141
+ end
142
+
143
+ it "collapses an over-assigned collection at serialization" do
144
+ model = ToleranceRelation.new(
145
+ rel_type: "cites",
146
+ description: [ToleranceMarked.new(content: "One"),
147
+ ToleranceMarked.new(content: "Two")],
148
+ )
149
+
150
+ yaml = model.to_yaml
151
+ expect(yaml).to include("One")
152
+ expect(yaml).not_to include("Two")
153
+ end
154
+ end
155
+ end
156
+
157
+ # The KV mirror of the XML-lane cases: the YAML lane never consulted
158
+ # the tolerance — ingest stored the whole array and re-serialization
159
+ # raised the mapped-PORO type check.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Register ids and type-context ids are two namespaces that
6
+ # GlobalContext#resolve_type bridges implicitly: an id present only in the
7
+ # Register namespace (a flavor's `lutaml_default_register`) resolves to a
8
+ # nil type context, and every resolution shape must treat that as a
9
+ # legitimate "no namespace" input instead of crashing (metanorma-jis#531).
10
+ RSpec.describe "type resolution through a register id with no registered type context" do
11
+ let(:collection_class) do
12
+ Class.new(Lutaml::Model::Collection) do
13
+ instances :entries, :string
14
+ end
15
+ end
16
+
17
+ it "resolves collection instance types under a nil context" do
18
+ collection = collection_class.new(["a"], lutaml_register: :jis_document)
19
+
20
+ expect(collection.entries).to eq(["a"])
21
+ end
22
+
23
+ it "resolves built-in symbol names under a nil context" do
24
+ resolved = Lutaml::Model::GlobalContext.resolve_type(:string, :jis_document)
25
+
26
+ expect(resolved).to eq(Lutaml::Model::Type::String)
27
+ end
28
+
29
+ it "raises UnknownTypeError for unknown names under a nil context" do
30
+ expect do
31
+ Lutaml::Model::GlobalContext.resolve_type(:no_such_type, :jis_document)
32
+ end.to raise_error(Lutaml::Model::UnknownTypeError)
33
+ end
34
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "open3"
5
+ require "lutaml/json/adapter/yeptris_adapter"
6
+
7
+ # Surface 1 of #978: the yeptris lane was selected from a bare
8
+ # constant check, so a process holding a half-initialized Yeptris
9
+ # module (the poison-process state the gem documents) committed to
10
+ # the native lane and failed pre-mapping on valid JSON — while other
11
+ # processes fell back to the standard lane, flipping the adapter
12
+ # choice across runs. Detection now probes the native engine; a
13
+ # broken native deterministically rides the standard lane.
14
+ RSpec.describe Lutaml::Model::AdapterResolver, ".adapter_for yeptris native detection" do
15
+ let(:parse_probe) do
16
+ <<~RUBY
17
+ require "lutaml/model"
18
+
19
+ klass = Class.new(Lutaml::Model::Serializable) do
20
+ attribute :v, :string
21
+ key_value { map "v", to: :v }
22
+ end
23
+
24
+ model = klass.from_json('{"v": "scalar"}')
25
+ adapter = Lutaml::Model::AdapterResolver.adapter_for(:json)
26
+ puts "ADAPTER=\#{adapter.name.split('::').last} PARSED=\#{model.v}"
27
+ RUBY
28
+ end
29
+
30
+ def subprocess_yeptris_capable?
31
+ probe = <<~RUBY
32
+ begin
33
+ require "yeptris"
34
+ exit(0)
35
+ rescue LoadError
36
+ exit(1)
37
+ end
38
+ RUBY
39
+ _out, _err, status = Open3.capture3(RbConfig.ruby, "-I", "lib", "-e", probe)
40
+ status.success?
41
+ end
42
+
43
+ it "resolves the yeptris lane when the native engine answers a probe" do
44
+ skip "optional yeptris engine not resolvable outside the bundle" unless subprocess_yeptris_capable?
45
+
46
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", "lib", "-e", parse_probe)
47
+ expect(status).to be_success, stderr
48
+ expect(stdout).to include("ADAPTER=YeptrisAdapter")
49
+ expect(stdout).to include("PARSED=scalar")
50
+ end
51
+
52
+ it "falls back to the standard lane when the native engine cannot parse" do
53
+ poison = <<~RUBY
54
+ require "lutaml/model"
55
+ require "yeptris"
56
+ require "lutaml/json/adapter/yeptris_adapter"
57
+
58
+ # The poison state: the module is defined and require succeeds,
59
+ # but every native parse fails. Whatever the cause (a stale
60
+ # libyeptris, a half-initialized load), the lane must not be
61
+ # selected.
62
+ Lutaml::Json::Adapter::YeptrisAdapter
63
+ .define_singleton_method(:native_ready?) { false }
64
+
65
+ #{parse_probe}
66
+ RUBY
67
+
68
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", "lib", "-e", poison)
69
+ expect(status).to be_success, stderr
70
+ expect(stdout).to include("ADAPTER=StandardAdapter")
71
+ expect(stdout).to include("PARSED=scalar")
72
+ end
73
+
74
+ it "reports the adapter unavailable when the native probe fails" do
75
+ allow(Lutaml::Json::Adapter::YeptrisAdapter)
76
+ .to receive(:native_ready?).and_return(false)
77
+
78
+ expect(Lutaml::Json::Adapter::YeptrisAdapter.available?).to be(false)
79
+ ensure
80
+ allow(Lutaml::Json::Adapter::YeptrisAdapter)
81
+ .to receive(:native_ready?).and_call_original
82
+ end
83
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/xml/adapter/leptris_adapter"
5
+ require "lutaml/xml/adapter/nokogiri_adapter"
6
+
7
+ # A failed parse reaches the empty-document guard as a document with no
8
+ # root, indistinguishable from genuinely empty input. The engine's parse
9
+ # diagnostics ride the document (moxml attachments :parse_errors) and
10
+ # must surface in the raised message — otherwise the error names the
11
+ # input's emptiness while the input is neither empty nor the problem
12
+ # (lutaml-model#956).
13
+ #
14
+ # moxml#369 changed the recoverable-malformation contract: the leptris
15
+ # adapter's non-strict parse now RECOVERS (the engine's recover mode,
16
+ # leptris#1660) and returns the best-effort tree instead of failing —
17
+ # a mismatched closer yields a document, not a raise. Only inputs with
18
+ # nothing to recover into (whitespace-only) still reach the guard,
19
+ # carrying the recovery diagnostic.
20
+ RSpec.describe "empty-document errors surface engine parse diagnostics" do
21
+ describe Lutaml::Xml::Adapter::LeptrisAdapter do
22
+ it "returns a recovered tree for recoverable malformations (moxml#369)" do
23
+ version = Gem.loaded_specs.fetch("moxml").version
24
+ recovered = version >= Gem::Version.new("0.5.131")
25
+ unless recovered
26
+ skip "moxml predates the recover-mode wiring (0.5.131)"
27
+ end
28
+
29
+ doc = described_class.parse("<root><a>x</b></root>")
30
+ expect(doc.root.name).to eq("root")
31
+ end
32
+
33
+ it "reports the engine diagnostic for whitespace-only input" do
34
+ expect do
35
+ described_class.parse(" ")
36
+ end.to raise_error(Lutaml::Model::InvalidFormatError, /the XML engine reported:/)
37
+ end
38
+ end
39
+
40
+ describe Lutaml::Xml::Adapter::NokogiriAdapter do
41
+ it "keeps the plain empty-document message when the engine reported nothing" do
42
+ expect do
43
+ described_class.parse("")
44
+ end.to raise_error(Lutaml::Model::InvalidFormatError) do |error|
45
+ expect(error.message).to include("The XML may be empty")
46
+ expect(error.message).not_to include("the XML engine reported:")
47
+ end
48
+ end
49
+ end
50
+ end