lutaml-model 0.8.56 → 0.8.106

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. data/lib/lutaml/xml/plan_walk.rb +0 -30
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "XML plan fast path: (URI, local) dispatch and W3C xml:* attributes" do
6
+ # Same-name element rows in different namespaces must dispatch by
7
+ # the child type's exact URI — the interpretive matcher's
8
+ # typed-element identity. The w14/w15 docId shape (OOXML
9
+ # settings.xml) collapsed to name-only binding: one row captured
10
+ # both elements and the other stayed empty, so round-trips lost a
11
+ # docId and downstream reconcilers regenerated it.
12
+ let(:w14_ns_class) do
13
+ klass = Class.new(Lutaml::Xml::Namespace) do
14
+ uri "http://schemas.microsoft.com/office/word/2010/wordml"
15
+ prefix_default "w14"
16
+ attribute_form_default :qualified
17
+ end
18
+ stub_const("PlanUriLocal::W14Ns", klass)
19
+ klass
20
+ end
21
+
22
+ let(:w15_ns_class) do
23
+ klass = Class.new(Lutaml::Xml::Namespace) do
24
+ uri "http://schemas.microsoft.com/office/word/2012/wordml"
25
+ prefix_default "w15"
26
+ attribute_form_default :qualified
27
+ end
28
+ stub_const("PlanUriLocal::W15Ns", klass)
29
+ klass
30
+ end
31
+
32
+ let(:w_ns_class) do
33
+ klass = Class.new(Lutaml::Xml::Namespace) do
34
+ uri "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
35
+ prefix_default "w"
36
+ end
37
+ stub_const("PlanUriLocal::WNs", klass)
38
+ klass
39
+ end
40
+
41
+ let(:w14_doc_id_class) do
42
+ w14_ns_class
43
+ klass = Class.new(Lutaml::Model::Serializable) do
44
+ attribute :val, :string
45
+
46
+ xml do
47
+ element "docId"
48
+ namespace PlanUriLocal::W14Ns
49
+ map_attribute "val", to: :val
50
+ end
51
+ end
52
+ stub_const("PlanUriLocal::W14DocId", klass)
53
+ klass
54
+ end
55
+
56
+ let(:w15_doc_id_class) do
57
+ w15_ns_class
58
+ klass = Class.new(Lutaml::Model::Serializable) do
59
+ attribute :val, :string
60
+
61
+ xml do
62
+ element "docId"
63
+ namespace PlanUriLocal::W15Ns
64
+ map_attribute "val", to: :val
65
+ end
66
+ end
67
+ stub_const("PlanUriLocal::W15DocId", klass)
68
+ klass
69
+ end
70
+
71
+ def define_settings_class
72
+ w_ns_class
73
+ w14_doc_id_class
74
+ w15_doc_id_class
75
+ Class.new(Lutaml::Model::Serializable) do
76
+ attribute :w14_doc_id, PlanUriLocal::W14DocId
77
+ attribute :w15_doc_id, PlanUriLocal::W15DocId
78
+
79
+ xml do
80
+ element "settings"
81
+ namespace PlanUriLocal::WNs
82
+ namespace_scope [
83
+ { namespace: PlanUriLocal::W14Ns, declare: :always },
84
+ { namespace: PlanUriLocal::W15Ns, declare: :always },
85
+ ]
86
+ map_element "docId", to: :w14_doc_id, render_nil: false
87
+ map_element "docId", to: :w15_doc_id, render_nil: false
88
+ end
89
+ end
90
+ end
91
+
92
+ def settings_xml(body)
93
+ "<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" " \
94
+ "xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\" " \
95
+ "xmlns:w15=\"http://schemas.microsoft.com/office/word/2012/wordml\">#{body}</w:settings>"
96
+ end
97
+
98
+ around do |example|
99
+ old = Lutaml::Model::Config.instance.xml_plan_fast_path
100
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
101
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
102
+ ensure
103
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old
104
+ end
105
+
106
+ it "binds same-name rows by exact URI regardless of document order" do
107
+ settings_class = define_settings_class
108
+
109
+ [%(<w14:docId w14:val="{A}"/><w15:docId w15:val="{B}"/>),
110
+ %(<w15:docId w15:val="{B}"/><w14:docId w14:val="{A}"/>)].each do |body|
111
+ settings = settings_class.from_xml(settings_xml(body))
112
+
113
+ expect(settings.w14_doc_id&.val).to eq("{A}")
114
+ expect(settings.w15_doc_id&.val).to eq("{B}")
115
+ end
116
+ end
117
+
118
+ it "leaves the absent-namespace row unset for single-docId documents" do
119
+ settings_class = define_settings_class
120
+
121
+ only_w14 = settings_class.from_xml(
122
+ settings_xml(%(<w14:docId w14:val="{A}"/>)),
123
+ )
124
+ expect(only_w14.w14_doc_id&.val).to eq("{A}")
125
+ expect(only_w14.w15_doc_id).to be_nil
126
+
127
+ only_w15 = settings_class.from_xml(
128
+ settings_xml(%(<w15:docId w15:val="{B}"/>)),
129
+ )
130
+ expect(only_w15.w14_doc_id).to be_nil
131
+ expect(only_w15.w15_doc_id&.val).to eq("{B}")
132
+ end
133
+
134
+ it "emits each docId under its own namespace on serialize" do
135
+ settings_class = define_settings_class
136
+ settings = settings_class.from_xml(
137
+ settings_xml(%(<w14:docId w14:val="{A}"/><w15:docId w15:val="{B}"/>)),
138
+ )
139
+
140
+ output = settings.to_xml
141
+ expect(output).to include("<w14:docId w14:val=\"{A}\"/>")
142
+ expect(output).to include("<w15:docId w15:val=\"{B}\"/>")
143
+ end
144
+
145
+ # Exact-URI attribute rows capture by (URI, local): a prefixed rule
146
+ # spelling with a namespaced type (xlink:href over XlinkHrefType)
147
+ # binds its local part under the row's URI — the full raw name
148
+ # matched nothing and the attribute was dropped.
149
+ it "binds prefixed attribute rules with namespaced types by (URI, local)" do
150
+ text_class = Class.new(Lutaml::Model::Serializable) do
151
+ attribute :xlink_href, Lutaml::Xml::W3c::XlinkHrefType
152
+
153
+ xml do
154
+ element "link"
155
+ map_attribute "xlink:href", to: :xlink_href
156
+ end
157
+ end
158
+ stub_const("PlanUriLocal::Xlink", text_class)
159
+
160
+ source = '<link xmlns:xlink="http://www.w3.org/1999/xlink" ' \
161
+ 'xlink:href="https://example.com"/>'
162
+ link = text_class.from_xml(source)
163
+
164
+ expect(link.xlink_href).to eq("https://example.com")
165
+ end
166
+
167
+ # The xml prefix is implicit (W3C-reserved): xml:* attribute rules
168
+ # bind by raw name and must not be qualified under the model's
169
+ # namespace by attribute form defaults (w:t's xml:space under a
170
+ # :qualified WML mapping dropped the attribute entirely).
171
+ it "round-trips xml:space through the plan lane" do
172
+ w_ns_class
173
+ text_class = Class.new(Lutaml::Model::Serializable) do
174
+ attribute :content, :string
175
+ attribute :xml_space, :string
176
+
177
+ xml do
178
+ element "t"
179
+ namespace PlanUriLocal::WNs
180
+ map_content to: :content
181
+ map_attribute "xml:space", to: :xml_space
182
+ end
183
+ end
184
+ stub_const("PlanUriLocal::Text", text_class)
185
+
186
+ source = '<w:t xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' \
187
+ 'xml:space="preserve">hi </w:t>'
188
+ text = text_class.from_xml(source)
189
+
190
+ expect(text.xml_space).to eq("preserve")
191
+ expect(text.content).to eq("hi ")
192
+ expect(text.to_xml).to include('xml:space="preserve"')
193
+ end
194
+
195
+ # Writer-added values sit outside the parse-built element_order.
196
+ # The ordered serialize lane reconciles them at the interpretive
197
+ # anchors — but only for instances a writer has touched; pristine
198
+ # parsed instances skip the reconciliation scan entirely.
199
+ it "emits writer-added children after parse and skips the scan when pristine" do
200
+ w_ns_class
201
+ w14_doc_id_class
202
+ zoom_class = Class.new(Lutaml::Model::Serializable) do
203
+ attribute :percent, :string
204
+
205
+ xml do
206
+ element "zoom"
207
+ namespace PlanUriLocal::WNs
208
+ map_attribute "percent", to: :percent
209
+ end
210
+ end
211
+ stub_const("PlanUriLocal::Zoom", zoom_class)
212
+ settings_class = Class.new(Lutaml::Model::Serializable) do
213
+ attribute :zoom, PlanUriLocal::Zoom
214
+ attribute :w14_doc_id, PlanUriLocal::W14DocId
215
+
216
+ xml do
217
+ element "settings"
218
+ namespace PlanUriLocal::WNs
219
+ map_element "zoom", to: :zoom
220
+ map_element "docId", to: :w14_doc_id
221
+ end
222
+ end
223
+ stub_const("PlanUriLocal::Settings", settings_class)
224
+
225
+ source = settings_xml(%(<w:zoom w:percent="100"/>))
226
+ pristine = settings_class.from_xml(source)
227
+ expect(pristine.zoom.percent).to eq("100")
228
+ expect(pristine.to_xml).not_to include("docId")
229
+
230
+ mutated = settings_class.from_xml(source)
231
+ mutated.w14_doc_id = w14_doc_id_class.new(val: "{A}")
232
+ output = mutated.to_xml
233
+ expect(output).to include("<docId")
234
+ expect(output).to include('val="{A}"')
235
+ expect(output.index("zoom")).to be < output.index("docId")
236
+ end
237
+
238
+ # Root declaration-plan parity: plan-hydrated roots carry the same
239
+ # import_declaration_plan state the interpretive parse records, so
240
+ # consumers and the interpretive serialize fallback see in-scope
241
+ # declarations (#872).
242
+ it "records the source declaration plan on plan-hydrated roots" do
243
+ settings_class = define_settings_class
244
+ settings = settings_class.from_xml(
245
+ settings_xml(%(<w14:docId w14:val="{A}"/>)),
246
+ )
247
+
248
+ plan = settings.import_declaration_plan
249
+ expect(plan).to be_a(Lutaml::Xml::DeclarationPlan)
250
+ hoisted = plan.root_node.hoisted_declarations
251
+ expect(hoisted["w14"]).to eq("http://schemas.microsoft.com/office/word/2010/wordml")
252
+
253
+ skipped = settings_class.from_xml(
254
+ settings_xml(%(<w14:docId w14:val="{A}"/>)),
255
+ import_declaration_plan: :skip,
256
+ )
257
+ expect(skipped.import_declaration_plan).to be_nil
258
+ end
259
+
260
+ # Processing-instruction mappings (rfcxml's <?rfc toc?>) have no
261
+ # plan-row spelling: compiling the model silently dropped
262
+ # pi_settings. The compiler refuses such models — the interpretive
263
+ # path owns PIs.
264
+ it "opts models with processing-instruction mappings out of the plan path" do
265
+ rfc_class = Class.new(Lutaml::Model::Serializable) do
266
+ attribute :pi_settings, :hash
267
+ attribute :version, :string
268
+
269
+ xml do
270
+ element "rfc"
271
+ map_processing_instruction "rfc", to: :pi_settings
272
+ map_attribute "version", to: :version
273
+ end
274
+ end
275
+ stub_const("PlanUriLocal::Rfc", rfc_class)
276
+
277
+ plan = Lutaml::Xml::Leptris::PlanCompiler.compile(
278
+ rfc_class, Lutaml::Model::Config.default_register
279
+ )
280
+ expect(plan).to be_nil
281
+
282
+ source = %(<?rfc toc="yes"?>\n<rfc version="3"/>)
283
+ rfc = rfc_class.from_xml(source)
284
+ expect(rfc.version).to eq("3")
285
+ expect(rfc.pi_settings["toc"]).to eq("yes")
286
+ end
287
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "spec_helper"
4
- require "lutaml/xml/plan_walk"
4
+ require "lutaml/xml/leptris/plan_walk"
5
5
 
6
6
  RSpec.describe "XML PlanWalk consumer surface" do
7
7
  around do |example|
@@ -25,7 +25,7 @@ RSpec.describe "XML PlanWalk consumer surface" do
25
25
 
26
26
  it "hydrates equal to the interpretive path on a single-row document" do
27
27
  xml = %(<row id="7"><name>Alice</name><tags>x</tags><tags>y</tags></row>)
28
- walked = Lutaml::Xml::PlanWalk.call(item_class, xml)
28
+ walked = Lutaml::Xml::Leptris::PlanWalk.call(item_class, xml)
29
29
  expected = item_class.from_xml(xml)
30
30
 
31
31
  expect(walked).to be_a(item_class)
@@ -46,7 +46,7 @@ RSpec.describe "XML PlanWalk consumer surface" do
46
46
  end
47
47
  end
48
48
 
49
- walked = Lutaml::Xml::PlanWalk.call(root_class, xml)
49
+ walked = Lutaml::Xml::Leptris::PlanWalk.call(root_class, xml)
50
50
  expected = root_class.from_xml(xml)
51
51
 
52
52
  expect(walked.item).to be_an(Array)
@@ -71,7 +71,7 @@ RSpec.describe "XML PlanWalk consumer surface" do
71
71
  end
72
72
  end
73
73
 
74
- walked = Lutaml::Xml::PlanWalk.call(root_class, xml)
74
+ walked = Lutaml::Xml::Leptris::PlanWalk.call(root_class, xml)
75
75
  interpretive = root_class.from_xml(xml)
76
76
  expect(walked.item.size).to eq(interpretive.item.size)
77
77
  expect(walked.item.map(&:id)).to eq(interpretive.item.map(&:id))
@@ -94,9 +94,9 @@ RSpec.describe "XML PlanWalk consumer surface" do
94
94
  end
95
95
  end
96
96
 
97
- expect(Lutaml::Xml::PlanCompiler.compile(klass,
97
+ expect(Lutaml::Xml::Leptris::PlanCompiler.compile(klass,
98
98
  Lutaml::Model::Config.default_register)).not_to be_nil
99
- expect(Lutaml::Xml::PlanWalk.call(klass, "<doc><note>x</note></doc>").tgt.note)
99
+ expect(Lutaml::Xml::Leptris::PlanWalk.call(klass, "<doc><note>x</note></doc>").tgt.note)
100
100
  .to eq("x")
101
101
  end
102
102
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "XML plan fast path: map_any_element catch-all" do
6
+ # leptris#1552: the wildcard child row rides a two-pass walk where
7
+ # named rows take precedence; the hydrator bridges the unclaimed
8
+ # remainder in document order and casts it through the attribute —
9
+ # the interpretive pass's contract for map_any_element.
10
+
11
+ let(:host_class) do
12
+ klass = Class.new(Lutaml::Model::Serializable) do
13
+ attribute :title, :string
14
+ attribute :others, :string, collection: true
15
+
16
+ xml do
17
+ element "host"
18
+ map_element "title", to: :title
19
+ map_any_element to: :others
20
+ end
21
+ end
22
+ stub_const("Gate4Any::Host", klass)
23
+ klass
24
+ end
25
+
26
+ let(:typed_class) do
27
+ item = Class.new(Lutaml::Model::Serializable) do
28
+ attribute :content, :string
29
+
30
+ xml do
31
+ element "item"
32
+ map_content to: :content
33
+ end
34
+ end
35
+ stub_const("Gate4Any::Item", item)
36
+ model = Class.new(Lutaml::Model::Serializable) do
37
+ attribute :label, :string
38
+ attribute :items, Gate4Any::Item, collection: true
39
+
40
+ xml do
41
+ element "bag"
42
+ map_element "label", to: :label
43
+ map_any_element to: :items
44
+ end
45
+ end
46
+ stub_const("Gate4Any::Bag", model)
47
+ model
48
+ end
49
+
50
+ around do |example|
51
+ old = Lutaml::Model::Config.instance.xml_plan_fast_path
52
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
53
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
54
+ ensure
55
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old
56
+ end
57
+
58
+ # Members flow through the attribute's cast exactly as the
59
+ # interpretive pass does: String attributes stringify the wrapper
60
+ # (both paths identically), Serializable attributes build instances.
61
+ def member_shapes(elements)
62
+ elements.map { |e| e.class.name.split("::").last }
63
+ end
64
+
65
+ it "captures unclaimed children in document order" do
66
+ doc = "<host><title>t</title><extra1>a</extra1>" \
67
+ "<extra2>b</extra2></host>"
68
+
69
+ parsed = host_class.from_xml(doc)
70
+ expect(parsed.title).to eq("t")
71
+ expect(parsed.others.size).to eq(2)
72
+ expect(member_shapes(parsed.others)).to eq(%w[String String])
73
+ end
74
+
75
+ it "captures typed members as model instances" do
76
+ doc = "<bag><label>L</label><item>one</item><item>two</item></bag>"
77
+
78
+ parsed = typed_class.from_xml(doc)
79
+ expect(parsed.label).to eq("L")
80
+ expect(parsed.items.map(&:content)).to eq(%w[one two])
81
+ end
82
+
83
+ it "matches the interpretive path's capture" do
84
+ doc = "<host><title>t</title><extra1>a</extra1><extra2>b</extra2></host>"
85
+
86
+ interpretive = Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
87
+ host_class.from_xml(doc)
88
+ end
89
+
90
+ parsed = host_class.from_xml(doc)
91
+ expect(parsed.others.size).to eq(interpretive.others.size)
92
+ expect(member_shapes(parsed.others)).to eq(
93
+ member_shapes(interpretive.others).map { |n| n.sub("Nokogiri", "Leptris") },
94
+ )
95
+ end
96
+ end
@@ -30,6 +30,30 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Yaml::Adapter::YeptrisAdapter : Objec
30
30
  expect(described_class.parse("d: 2020-01-01\n"))
31
31
  .to eq("d" => Date.new(2020, 1, 1))
32
32
  end
33
+
34
+ it "raises Psych::SyntaxError for invalid YAML, like the standard adapter" do
35
+ expect { described_class.parse("name: test\n invalid: [unclosed\n") }
36
+ .to raise_error(Psych::SyntaxError)
37
+ end
38
+
39
+ it "raises Psych::SyntaxError for a mapping value inside a plain scalar" do
40
+ # The dependent-CI shape: one line, a second "key:" colon after a
41
+ # plain scalar run. Psych rejects it with "mapping values are not
42
+ # allowed in this context"; the engine must surface under the
43
+ # same class so downstream rescue ladders hold.
44
+ expect { described_class.parse('name: test\n invalid: [unclosed') }
45
+ .to raise_error(Psych::SyntaxError)
46
+ end
47
+
48
+ it "carries the engine's position on the syntax error" do
49
+ expect { described_class.parse("name: test\n invalid: [unclosed\n") }
50
+ .to raise_error(Psych::SyntaxError) do |error|
51
+ expect(error.line).to be >= 1
52
+ expect(error.problem).to be_a(String)
53
+ expect(error.problem).not_to be_empty
54
+ expect(error.message).to include("at line #{error.line}")
55
+ end
56
+ end
33
57
  end
34
58
 
35
59
  describe "#to_yaml" do
@@ -59,5 +83,10 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Yaml::Adapter::YeptrisAdapter : Objec
59
83
  expect(model.roles).to eq(%w[admin dev])
60
84
  expect(model_class.from_yaml(model.to_yaml).roles).to eq(%w[admin dev])
61
85
  end
86
+
87
+ it "surfaces invalid YAML as InvalidFormatError" do
88
+ expect { model_class.from_yaml("name: test\n invalid: [unclosed\n") }
89
+ .to raise_error(Lutaml::Model::InvalidFormatError)
90
+ end
62
91
  end
63
92
  end
data/spec/person_spec.rb CHANGED
@@ -115,6 +115,13 @@ RSpec.describe Person do
115
115
  expect(model.to_yaml).to eq(attributes_yaml.to_yaml)
116
116
  end
117
117
 
118
+ # Root cause of the long-pended time-only misparse: psych resolves an
119
+ # unquoted hh:mm:ss scalar as a YAML 1.1 sexagesimal integer (25200
120
+ # for "07:00:00"), and the :time cast fed that integer through
121
+ # Time.parse, which reads "25200" as a date (the fixed 2025-07-19
122
+ # garbage). The cast now reads sexagesimal integers as seconds since
123
+ # midnight — both the psych and yeptris paths answer today-07:00.
124
+ # Evidence chain: moxml#336, yeptris-ruby#265.
118
125
  it "deserializes from YAML" do
119
126
  yaml = attributes_yaml.to_yaml
120
127
  person = described_class.from_yaml(yaml)
@@ -127,6 +134,7 @@ RSpec.describe Person do
127
134
  expect(person.active).to be true
128
135
  end
129
136
 
137
+ # Same sexagesimal time-only scalar as above.
130
138
  it "deserializes from YAML array" do
131
139
  yaml = [attributes_yaml.dup, attributes_yaml.dup].to_yaml
132
140