lutaml-model 0.8.17 → 0.8.18

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 (192) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/downstream-performance.yml +27 -1
  3. data/.github/workflows/js-pr-check.yml +102 -0
  4. data/.github/workflows/js-sync.yml +51 -0
  5. data/.github/workflows/opal.yml +26 -6
  6. data/.github/workflows/performance.yml +32 -7
  7. data/.github/workflows/rake.yml +78 -32
  8. data/.gitmodules +6 -0
  9. data/.rubocop_todo.yml +8 -0
  10. data/Gemfile +16 -1
  11. data/README.adoc +267 -0
  12. data/Rakefile +166 -4
  13. data/docs/_guides/schema-import.adoc +42 -0
  14. data/docs/_guides/xml-mapping.adoc +21 -0
  15. data/docs/_pages/validation.adoc +65 -0
  16. data/lib/compat/opal/generate_boot.rb +123 -0
  17. data/lib/compat/opal/js_bundle_entry.rb +42 -0
  18. data/lib/compat/opal/lutaml_model_boot.rb +497 -0
  19. data/lib/compat/opal/moxml_boot.rb +68 -0
  20. data/lib/compat/opal/yaml_compat.rb +32 -0
  21. data/lib/lutaml/json.rb +1 -1
  22. data/lib/lutaml/key_value/transform.rb +3 -8
  23. data/lib/lutaml/key_value/transformation/value_serializer.rb +14 -1
  24. data/lib/lutaml/key_value/transformation.rb +17 -5
  25. data/lib/lutaml/model/adapter_resolver.rb +2 -2
  26. data/lib/lutaml/model/attribute.rb +88 -11
  27. data/lib/lutaml/model/cached_type_resolver.rb +1 -1
  28. data/lib/lutaml/model/error/union_schema_unsupported_error.rb +11 -0
  29. data/lib/lutaml/model/global_context.rb +11 -8
  30. data/lib/lutaml/model/instrumentation.rb +2 -2
  31. data/lib/lutaml/model/mapping/mapping_rule.rb +0 -6
  32. data/lib/lutaml/model/register.rb +2 -2
  33. data/lib/lutaml/model/runtime_compatibility.rb +50 -0
  34. data/lib/lutaml/model/schema/class_loader.rb +58 -0
  35. data/lib/lutaml/model/schema/compiled_output.rb +83 -0
  36. data/lib/lutaml/model/schema/definitions/attribute.rb +32 -0
  37. data/lib/lutaml/model/schema/definitions/choice.rb +20 -0
  38. data/lib/lutaml/model/schema/definitions/facet.rb +40 -0
  39. data/lib/lutaml/model/schema/definitions/group_import.rb +22 -0
  40. data/lib/lutaml/model/schema/definitions/member_walk.rb +33 -0
  41. data/lib/lutaml/model/schema/definitions/model.rb +40 -0
  42. data/lib/lutaml/model/schema/definitions/namespace.rb +23 -0
  43. data/lib/lutaml/model/schema/definitions/restricted_type.rb +30 -0
  44. data/lib/lutaml/model/schema/definitions/sequence.rb +19 -0
  45. data/lib/lutaml/model/schema/definitions/simple_content.rb +22 -0
  46. data/lib/lutaml/model/schema/definitions/transform_facet.rb +21 -0
  47. data/lib/lutaml/model/schema/definitions/type_ref.rb +20 -0
  48. data/lib/lutaml/model/schema/definitions/union_type.rb +28 -0
  49. data/lib/lutaml/model/schema/definitions/xml_root.rb +20 -0
  50. data/lib/lutaml/model/schema/definitions.rb +24 -0
  51. data/lib/lutaml/model/schema/file_writer.rb +70 -0
  52. data/lib/lutaml/model/schema/generator/definitions_collection.rb +16 -2
  53. data/lib/lutaml/model/schema/generator/property.rb +21 -4
  54. data/lib/lutaml/model/schema/module_nesting.rb +31 -0
  55. data/lib/lutaml/model/schema/namespace_naming.rb +46 -0
  56. data/lib/lutaml/model/schema/registry_generator.rb +115 -0
  57. data/lib/lutaml/model/schema/renderers/base.rb +40 -0
  58. data/lib/lutaml/model/schema/renderers/mappings.rb +72 -0
  59. data/lib/lutaml/model/schema/renderers/member_decls.rb +115 -0
  60. data/lib/lutaml/model/schema/renderers/model.rb +121 -0
  61. data/lib/lutaml/model/schema/renderers/namespace.rb +30 -0
  62. data/lib/lutaml/model/schema/renderers/registration.rb +64 -0
  63. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +93 -0
  64. data/lib/lutaml/model/schema/renderers/restricted_type.rb +88 -0
  65. data/lib/lutaml/model/schema/renderers/union.rb +83 -0
  66. data/lib/lutaml/model/schema/renderers.rb +19 -0
  67. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +47 -0
  68. data/lib/lutaml/model/schema/rnc_compiler.rb +62 -0
  69. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +91 -0
  70. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +392 -0
  71. data/lib/lutaml/model/schema/rng_compiler/member_collector.rb +30 -0
  72. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +152 -0
  73. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +125 -0
  74. data/lib/lutaml/model/schema/rng_compiler.rb +220 -0
  75. data/lib/lutaml/model/schema/templates.rb +162 -0
  76. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +41 -0
  77. data/lib/lutaml/model/schema/xml_compiler/registry_generator.rb +31 -107
  78. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +191 -0
  79. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +216 -0
  80. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +153 -0
  81. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +167 -0
  82. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +87 -0
  83. data/lib/lutaml/model/schema/xml_compiler.rb +38 -517
  84. data/lib/lutaml/model/schema.rb +19 -0
  85. data/lib/lutaml/model/serialize/attribute_definition.rb +14 -2
  86. data/lib/lutaml/model/serialize/builder.rb +15 -2
  87. data/lib/lutaml/model/serialize/value_mapping.rb +9 -57
  88. data/lib/lutaml/model/serialize.rb +6 -9
  89. data/lib/lutaml/model/services/rule_value_extractor.rb +2 -3
  90. data/lib/lutaml/model/services.rb +0 -2
  91. data/lib/lutaml/model/store.rb +2 -1
  92. data/lib/lutaml/model/toml.rb +1 -1
  93. data/lib/lutaml/model/transform.rb +17 -39
  94. data/lib/lutaml/model/transformation_registry.rb +1 -1
  95. data/lib/lutaml/model/type.rb +1 -0
  96. data/lib/lutaml/model/union.rb +320 -0
  97. data/lib/lutaml/model/utils.rb +6 -0
  98. data/lib/lutaml/model/validation.rb +1 -4
  99. data/lib/lutaml/model/version.rb +1 -1
  100. data/lib/lutaml/model.rb +18 -0
  101. data/lib/lutaml/toml.rb +1 -1
  102. data/lib/lutaml/xml/adapter.rb +1 -1
  103. data/lib/lutaml/xml/adapter_loader.rb +1 -1
  104. data/lib/lutaml/xml/builder/base.rb +1 -1
  105. data/lib/lutaml/xml/builder.rb +1 -1
  106. data/lib/lutaml/xml/error/schema_validation_error.rb +25 -0
  107. data/lib/lutaml/xml/model_transform.rb +5 -1
  108. data/lib/lutaml/xml/schema/relaxng_schema.rb +5 -0
  109. data/lib/lutaml/xml/schema/xsd_schema.rb +4 -0
  110. data/lib/lutaml/xml/serialization/format_conversion.rb +48 -0
  111. data/lib/lutaml/xml/serialization/instance_methods.rb +13 -0
  112. data/lib/lutaml/xml/transformation/element_builder.rb +21 -8
  113. data/lib/lutaml/xml/transformation/ordered_applier.rb +6 -0
  114. data/lib/lutaml/xml/xsd_validator.rb +66 -0
  115. data/lib/lutaml/xml.rb +53 -12
  116. data/lutaml-model.gemspec +4 -2
  117. data/spec/fixtures/xml/schema/rnc/address_book.rnc +15 -0
  118. data/spec/fixtures/xml/schema/rnc/book_features.rnc +18 -0
  119. data/spec/fixtures/xml/schema/rnc/includes/book.rnc +6 -0
  120. data/spec/fixtures/xml/schema/rnc/includes/circular_a.rnc +8 -0
  121. data/spec/fixtures/xml/schema/rnc/includes/circular_b.rnc +8 -0
  122. data/spec/fixtures/xml/schema/rnc/includes/library.rnc +8 -0
  123. data/spec/fixtures/xml/schema/rng/address_book.rng +16 -0
  124. data/spec/fixtures/xml/schema/rng/book.rng +27 -0
  125. data/spec/fixtures/xml/schema/rng/fixed_value.rng +11 -0
  126. data/spec/fixtures/xml/schema/rng/fragment_in_choice.rng +30 -0
  127. data/spec/fixtures/xml/schema/rng/full_name.rng +39 -0
  128. data/spec/fixtures/xml/schema/rng/integer_range.rng +32 -0
  129. data/spec/fixtures/xml/schema/rng/list_type.rng +12 -0
  130. data/spec/fixtures/xml/schema/rng/namespaced.rng +9 -0
  131. data/spec/fixtures/xml/schema/rng/paragraph.rng +13 -0
  132. data/spec/fixtures/xml/schema/rng/person.rng +23 -0
  133. data/spec/fixtures/xml/schema/rng/union.rng +14 -0
  134. data/spec/fixtures/xml/validate_xml_with_address.xsd +13 -0
  135. data/spec/fixtures/xml/validate_xml_with_contact.xsd +11 -0
  136. data/spec/fixtures/xml/validate_xml_with_person.xsd +11 -0
  137. data/spec/fixtures/xml/validate_xml_with_person_strict.xsd +17 -0
  138. data/spec/fixtures/xml/validate_xml_with_product.xsd +37 -0
  139. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +134 -0
  140. data/spec/lutaml/model/attribute_spec.rb +169 -12
  141. data/spec/lutaml/model/boot_manifest_spec.rb +30 -0
  142. data/spec/lutaml/model/collection_spec.rb +18 -0
  143. data/spec/lutaml/model/mixed_content_spec.rb +171 -0
  144. data/spec/lutaml/model/opal_smoke_spec.rb +67 -2
  145. data/spec/lutaml/model/ordered_content_spec.rb +30 -0
  146. data/spec/lutaml/model/raw_element_spec.rb +125 -0
  147. data/spec/lutaml/model/rule_value_extractor_spec.rb +7 -14
  148. data/spec/lutaml/model/runtime_compatibility_spec.rb +25 -0
  149. data/spec/lutaml/model/schema/renderers/model_spec.rb +149 -0
  150. data/spec/lutaml/model/schema/renderers/namespace_spec.rb +36 -0
  151. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +75 -0
  152. data/spec/lutaml/model/schema/renderers/union_spec.rb +58 -0
  153. data/spec/lutaml/model/transform_apply_value_map_spec.rb +64 -0
  154. data/spec/lutaml/model/union_attribute_spec.rb +658 -0
  155. data/spec/lutaml/model/union_spec.rb +257 -0
  156. data/spec/lutaml/model/utils_spec.rb +14 -0
  157. data/spec/lutaml/xml/opal_xml_spec.rb +22 -20
  158. data/spec/lutaml/xml/schema/compiler_spec.rb +21 -4
  159. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +404 -0
  160. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +893 -0
  161. data/spec/lutaml/xml/validate_xml_with_constructs_spec.rb +222 -0
  162. data/spec/lutaml/xml/validate_xml_with_spec.rb +186 -0
  163. data/spec/spec_helper.rb +28 -11
  164. data/spec/support/opal.rb +5 -2
  165. metadata +97 -31
  166. data/lib/lutaml/model/schema/xml_compiler/attribute.rb +0 -106
  167. data/lib/lutaml/model/schema/xml_compiler/attribute_group.rb +0 -47
  168. data/lib/lutaml/model/schema/xml_compiler/choice.rb +0 -67
  169. data/lib/lutaml/model/schema/xml_compiler/complex_content.rb +0 -27
  170. data/lib/lutaml/model/schema/xml_compiler/complex_content_restriction.rb +0 -34
  171. data/lib/lutaml/model/schema/xml_compiler/complex_type.rb +0 -173
  172. data/lib/lutaml/model/schema/xml_compiler/element.rb +0 -121
  173. data/lib/lutaml/model/schema/xml_compiler/group.rb +0 -113
  174. data/lib/lutaml/model/schema/xml_compiler/restriction.rb +0 -104
  175. data/lib/lutaml/model/schema/xml_compiler/sequence.rb +0 -52
  176. data/lib/lutaml/model/schema/xml_compiler/simple_content.rb +0 -40
  177. data/lib/lutaml/model/schema/xml_compiler/simple_type.rb +0 -265
  178. data/lib/lutaml/model/schema/xml_compiler/xml_namespace_class.rb +0 -110
  179. data/lib/lutaml/model/services/default_value_resolver.rb +0 -60
  180. data/spec/lutaml/model/services/default_value_resolver_spec.rb +0 -162
  181. data/spec/lutaml/xml/schema/compiler/attribute_group_spec.rb +0 -71
  182. data/spec/lutaml/xml/schema/compiler/attribute_spec.rb +0 -67
  183. data/spec/lutaml/xml/schema/compiler/choice_spec.rb +0 -74
  184. data/spec/lutaml/xml/schema/compiler/complex_content_restriction_spec.rb +0 -60
  185. data/spec/lutaml/xml/schema/compiler/complex_content_spec.rb +0 -41
  186. data/spec/lutaml/xml/schema/compiler/complex_type_spec.rb +0 -202
  187. data/spec/lutaml/xml/schema/compiler/element_spec.rb +0 -68
  188. data/spec/lutaml/xml/schema/compiler/group_spec.rb +0 -90
  189. data/spec/lutaml/xml/schema/compiler/restriction_spec.rb +0 -77
  190. data/spec/lutaml/xml/schema/compiler/sequence_spec.rb +0 -65
  191. data/spec/lutaml/xml/schema/compiler/simple_content_spec.rb +0 -61
  192. data/spec/lutaml/xml/schema/compiler/simple_type_spec.rb +0 -184
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ # Guard: the generated Opal boot manifest must stay in sync with the autoloads
7
+ # in lib/lutaml/**. Opal ignores `autoload`, so lutaml_model_boot.rb explicitly
8
+ # requires every autoload target; a deleted/renamed file leaves a stale require
9
+ # that only fails the Opal CI job (`rake spec:opal`) — MRI's autoload hides it.
10
+ # Regenerating here and comparing catches that drift in the fast MRI suite.
11
+ RSpec.describe "Opal boot manifest" do
12
+ let(:generator) do
13
+ File.expand_path("../../../lib/compat/opal/generate_boot.rb", __dir__)
14
+ end
15
+ let(:manifest) do
16
+ File.expand_path("../../../lib/compat/opal/lutaml_model_boot.rb", __dir__)
17
+ end
18
+
19
+ it "matches the generator output (regenerate: ruby lib/compat/opal/generate_boot.rb)" do
20
+ Dir.mktmpdir do |dir|
21
+ out = File.join(dir, "boot.rb")
22
+ ok = system({ "OPAL_BOOT_OUTPUT" => out }, RbConfig.ruby, generator,
23
+ out: File::NULL, err: File::NULL)
24
+ expect(ok).to be(true), "generate_boot.rb failed to run"
25
+ expect(File.read(manifest)).to eq(File.read(out)),
26
+ "lib/compat/opal/lutaml_model_boot.rb is out of date — run " \
27
+ "`ruby lib/compat/opal/generate_boot.rb` after adding/removing autoloads"
28
+ end
29
+ end
30
+ end
@@ -323,6 +323,24 @@ RSpec.describe Lutaml::Model::Collection do
323
323
  end
324
324
  end
325
325
 
326
+ context "when a range collection allows zero elements" do
327
+ it "validates when the collection is given as empty" do
328
+ kiln = CollectionTests::Kiln.new(attributes.merge(pots: []))
329
+
330
+ expect { kiln.validate! }.not_to raise_error
331
+ end
332
+
333
+ it "validates when the collection is omitted entirely" do
334
+ kiln = CollectionTests::Kiln.new(attributes.except(:pots))
335
+
336
+ expect { kiln.validate! }.not_to raise_error
337
+ end
338
+
339
+ it "does not flag the empty min-zero collection when built with no arguments" do
340
+ expect { CollectionTests::Kiln.new.validate! }.not_to raise_error
341
+ end
342
+ end
343
+
326
344
  context "when collection with unbounded maximum exceeds minimum" do
327
345
  let(:valid_attributes) do
328
346
  attributes.merge(operators: ["John", "Jane", "Jim", "Jessica"])
@@ -275,6 +275,88 @@ module MixedContentSpec
275
275
  map_element "b", to: :bold
276
276
  end
277
277
  end
278
+
279
+ # Regression: nested mixed_content text ownership
280
+ # Bug report claimed child's direct text was captured by the parent.
281
+ # Not reproducible with DOM-based adapters — text nodes are parented
282
+ # by the parser, not by a shared accumulator. These specs lock in
283
+ # correct behavior.
284
+ class NestedTextInner < Lutaml::Model::Serializable
285
+ attribute :text, :string, collection: true
286
+ attribute :child, :string, collection: true
287
+
288
+ xml do
289
+ element "inner"
290
+ mixed_content
291
+ map_content to: :text
292
+ map_element "child", to: :child
293
+ end
294
+ end
295
+
296
+ class NestedTextOuter < Lutaml::Model::Serializable
297
+ attribute :text, :string, collection: true
298
+ attribute :inner, NestedTextInner, collection: true
299
+
300
+ xml do
301
+ element "outer"
302
+ mixed_content
303
+ map_content to: :text
304
+ map_element "inner", to: :inner
305
+ end
306
+ end
307
+
308
+ class NestedTextInnerOrdered < Lutaml::Model::Serializable
309
+ attribute :text, :string, collection: true
310
+ attribute :child, :string, collection: true
311
+
312
+ xml do
313
+ element "inner"
314
+ ordered
315
+ mixed_content
316
+ map_content to: :text
317
+ map_element "child", to: :child
318
+ end
319
+ end
320
+
321
+ class NestedTextOuterOrdered < Lutaml::Model::Serializable
322
+ attribute :text, :string, collection: true
323
+ attribute :inner, NestedTextInnerOrdered, collection: true
324
+
325
+ xml do
326
+ element "outer"
327
+ ordered
328
+ mixed_content
329
+ map_content to: :text
330
+ map_element "inner", to: :inner
331
+ end
332
+ end
333
+
334
+ # Asymmetric variant: middle layer omits mixed_content while outer
335
+ # and inner both have it. Mirrors the original trigger from the
336
+ # metanorma-document BUGS.sts 06 report (FmtStemElement lacked
337
+ # mixed_content at the time, sandwiched between ParagraphBlock and
338
+ # SemxElement which both had it). Symmetric coverage alone misses
339
+ # this case.
340
+ class AsymmetricMiddle < Lutaml::Model::Serializable
341
+ attribute :inner, NestedTextInner, collection: true
342
+
343
+ xml do
344
+ element "middle"
345
+ map_element "inner", to: :inner
346
+ end
347
+ end
348
+
349
+ class AsymmetricOuter < Lutaml::Model::Serializable
350
+ attribute :text, :string, collection: true
351
+ attribute :middle, AsymmetricMiddle, collection: true
352
+
353
+ xml do
354
+ element "outer"
355
+ mixed_content
356
+ map_content to: :text
357
+ map_element "middle", to: :middle
358
+ end
359
+ end
278
360
  end
279
361
 
280
362
  RSpec.describe "MixedContent" do
@@ -1244,6 +1326,95 @@ RSpec.describe "MixedContent" do
1244
1326
  end
1245
1327
  end
1246
1328
  end
1329
+
1330
+ # Regression: nested mixed_content text ownership
1331
+ # Verifies that text directly inside a child element with mixed_content
1332
+ # is attributed to the CHILD, not the parent. Covers standard and
1333
+ # ordered variants, single and multiple children, and outer text.
1334
+ context "nested mixed_content text ownership" do
1335
+ context "with standard mixed_content" do
1336
+ it "attributes child's direct text to the child, not the parent" do
1337
+ outer = MixedContentSpec::NestedTextOuter.from_xml(
1338
+ "<outer><inner>target text<child>x</child></inner></outer>",
1339
+ )
1340
+ inner = outer.inner.first
1341
+
1342
+ expect(inner.text).to eq(["target text"])
1343
+ expect(inner.child).to eq(["x"])
1344
+ expect(outer.text).to eq([])
1345
+ end
1346
+
1347
+ it "keeps outer text segments in the outer model" do
1348
+ outer = MixedContentSpec::NestedTextOuter.from_xml(
1349
+ "<outer>prefix<inner>target text<child>x</child></inner>suffix</outer>",
1350
+ )
1351
+ inner = outer.inner.first
1352
+
1353
+ expect(inner.text).to eq(["target text"])
1354
+ expect(outer.text).to eq(%w[prefix suffix])
1355
+ end
1356
+
1357
+ it "handles multiple nested children with their own text" do
1358
+ outer = MixedContentSpec::NestedTextOuter.from_xml(
1359
+ "<outer>p1<inner>i1text<c>x</c>i2text</inner>p2<inner>i3text</inner>p3</outer>",
1360
+ )
1361
+
1362
+ expect(outer.inner[0].text).to eq(%w[i1text i2text])
1363
+ expect(outer.inner[1].text).to eq(["i3text"])
1364
+ expect(outer.text).to eq(%w[p1 p2 p3])
1365
+ end
1366
+ end
1367
+
1368
+ context "with ordered mixed_content" do
1369
+ it "attributes child's direct text to the child, not the parent" do
1370
+ outer = MixedContentSpec::NestedTextOuterOrdered.from_xml(
1371
+ "<outer><inner>target text<child>x</child></inner></outer>",
1372
+ )
1373
+ inner = outer.inner.first
1374
+
1375
+ expect(inner.text).to eq(["target text"])
1376
+ expect(inner.child).to eq(["x"])
1377
+ expect(outer.text).to eq([])
1378
+ end
1379
+
1380
+ it "keeps outer text segments in the outer model" do
1381
+ outer = MixedContentSpec::NestedTextOuterOrdered.from_xml(
1382
+ "<outer>prefix<inner>target text<child>x</child></inner>suffix</outer>",
1383
+ )
1384
+ inner = outer.inner.first
1385
+
1386
+ expect(inner.text).to eq(["target text"])
1387
+ expect(outer.text).to eq(%w[prefix suffix])
1388
+ end
1389
+ end
1390
+
1391
+ context "with asymmetric nesting (middle layer omits mixed_content)" do
1392
+ # Mirrors the original trigger from metanorma-document BUGS.sts 06:
1393
+ # ParagraphBlock (mixed_content) > FmtStemElement (no mixed_content
1394
+ # at the time) > SemxElement (mixed_content). Symmetric coverage
1395
+ # in the prior contexts does not exercise this code path.
1396
+ it "attributes innermost text to the innermost model" do
1397
+ outer = MixedContentSpec::AsymmetricOuter.from_xml(
1398
+ "<outer>prefix<middle><inner>target text<child>x</child></inner></middle>suffix</outer>",
1399
+ )
1400
+ inner = outer.middle.first.inner.first
1401
+
1402
+ expect(inner.text).to eq(["target text"])
1403
+ expect(inner.child).to eq(["x"])
1404
+ expect(outer.text).to eq(%w[prefix suffix])
1405
+ end
1406
+
1407
+ it "preserves innermost text when outer has no own text" do
1408
+ outer = MixedContentSpec::AsymmetricOuter.from_xml(
1409
+ "<outer><middle><inner>target text<child>x</child></inner></middle></outer>",
1410
+ )
1411
+ inner = outer.middle.first.inner.first
1412
+
1413
+ expect(inner.text).to eq(["target text"])
1414
+ expect(outer.text).to eq([])
1415
+ end
1416
+ end
1417
+ end
1247
1418
  end
1248
1419
 
1249
1420
  describe Lutaml::Xml::Adapter::NokogiriAdapter do
@@ -71,6 +71,47 @@ RSpec.describe "Opal compatibility", if: RUBY_ENGINE == "opal" do
71
71
  expect(instance.flag).to be true
72
72
  end
73
73
 
74
+ it "resolves scalar union attributes" do
75
+ reading = Class.new do
76
+ include Lutaml::Model::Serialize
77
+
78
+ attribute :value, %i[integer string]
79
+ end
80
+
81
+ expect(reading.from_json('{"value":42}').value).to eq(42)
82
+ expect(reading.from_json('{"value":"hot"}').value).to eq("hot")
83
+ end
84
+
85
+ it "resolves a union of a model member and a scalar" do
86
+ temperature = Class.new do
87
+ include Lutaml::Model::Serialize
88
+
89
+ attribute :celsius, :float
90
+ end
91
+ holder = Class.new do
92
+ include Lutaml::Model::Serialize
93
+
94
+ attribute :temp, [temperature, :string]
95
+ end
96
+
97
+ structured = holder.from_json('{"temp":{"celsius":12.5}}')
98
+ expect(structured.temp).to be_a(temperature)
99
+ expect(structured.temp.celsius).to eq(12.5)
100
+ expect(holder.from_json('{"temp":"cold"}').temp).to eq("cold")
101
+ end
102
+
103
+ it "validates a pattern on a union :string member" do
104
+ coded = Class.new do
105
+ include Lutaml::Model::Serialize
106
+
107
+ attribute :code, %i[integer string], pattern: /\A[A-Z]+\z/
108
+ end
109
+
110
+ expect(coded.new(code: "ABC").validate).to be_empty
111
+ expect(coded.new(code: "abc").validate).not_to be_empty
112
+ expect(coded.new(code: 42).validate).to be_empty
113
+ end
114
+
74
115
  it "handles nested models" do
75
116
  address = Class.new do
76
117
  include Lutaml::Model::Serialize
@@ -110,8 +151,32 @@ RSpec.describe "Opal compatibility", if: RUBY_ENGINE == "opal" do
110
151
  expect(Lutaml::Model::RuntimeCompatibility.opal?).to be true
111
152
  end
112
153
 
113
- it "AdapterResolver auto-detects REXML" do
154
+ it "AdapterResolver auto-detects Oga as the default" do
114
155
  adapter = Lutaml::Model::AdapterResolver.detect_xml_adapter
115
- expect(adapter).to eq(:rexml)
156
+ expect(adapter).to eq(:oga)
157
+ end
158
+
159
+ it "exposes both Oga and REXML as available adapters under Opal" do
160
+ # Both are pure Ruby; Nokogiri and Ox are C extensions and excluded.
161
+ options = Lutaml::Model::FormatRegistry.adapter_options_for(:xml)
162
+ expect(options).not_to be_nil
163
+
164
+ available = options[:available]
165
+ expect(available).to include(:oga, :rexml)
166
+ expect(available).not_to include(:nokogiri, :ox)
167
+ end
168
+
169
+ # Opal's Module#prepend raises "Prepending a module multiple times
170
+ # is not supported" on the second call; runtime_compatibility.rb
171
+ # patches it to match MRI's idempotent behavior. Top-level lib files
172
+ # (lib/lutaml/model.rb, lib/lutaml/xml.rb) rely on this when Opal's
173
+ # eager loader re-evaluates them.
174
+ it "Module#prepend is idempotent under Opal" do
175
+ mod = Module.new
176
+ klass = Class.new
177
+ klass.prepend(mod)
178
+
179
+ expect { klass.prepend(mod) }.not_to raise_error
180
+ expect(klass.ancestors.count(mod)).to eq(1)
116
181
  end
117
182
  end
@@ -145,6 +145,36 @@ RSpec.describe "OrderedContent" do
145
145
  expect(serialized).to be_xml_equivalent_to(expected_xml)
146
146
  end
147
147
  end
148
+
149
+ # Regression for issue #735: `ordered` builder must honour setter
150
+ # call order, not declaration order. The @__order_tracking__ flag
151
+ # was gated on mixed_content? instead of ordered?.
152
+ context "ordered builder honours setter call order" do
153
+ it "emits elements in builder-call order, not declaration order" do
154
+ klass = Class.new(Lutaml::Model::Serializable) do
155
+ attribute :a, :string
156
+ attribute :b, :string
157
+
158
+ xml do
159
+ element "item"
160
+ ordered
161
+ map_element "a", to: :a
162
+ map_element "b", to: :b
163
+ end
164
+ end
165
+
166
+ item = klass.new do |i|
167
+ i.b "first"
168
+ i.a "second"
169
+ end
170
+
171
+ xml = item.to_xml
172
+ b_pos = xml.index("<b>first</b>")
173
+ a_pos = xml.index("<a>second</a>")
174
+ expect(b_pos).to be < a_pos
175
+ expect(item.element_order.map(&:name)).to eq(%w[b a])
176
+ end
177
+ end
148
178
  end
149
179
 
150
180
  describe Lutaml::Xml::Adapter::NokogiriAdapter do
@@ -87,6 +87,50 @@ module RawElementSpec
87
87
  end
88
88
  end
89
89
 
90
+ # mixed_content parent with a collection raw: :element mapping.
91
+ # Round-trip dispatches through OrderedApplier#apply_element_rule_single
92
+ # (see issue #727 — previously double-wrapped each fragment).
93
+ class MixedContentRawCollectionContainer < Lutaml::Model::Serializable
94
+ attribute :name, :string
95
+ attribute :fragments, :string, collection: true
96
+
97
+ xml do
98
+ element "container"
99
+ mixed_content
100
+ map_element "name", to: :name
101
+ map_element "fragment", to: :fragments, raw: :element
102
+ end
103
+ end
104
+
105
+ # mixed_content parent with a single (non-collection) raw: :element mapping.
106
+ # Dispatches through OrderedApplier -> :apply_rule -> RuleApplier#apply_rule,
107
+ # which already had the short-circuit. Kept as a regression guard.
108
+ class MixedContentRawSingleContainer < Lutaml::Model::Serializable
109
+ attribute :name, :string
110
+ attribute :embedded, :string
111
+
112
+ xml do
113
+ element "container"
114
+ mixed_content
115
+ map_element "name", to: :name
116
+ map_element "foreign", to: :embedded, raw: :element
117
+ end
118
+ end
119
+
120
+ # ordered (no mixed_content) parent with a collection raw: :element mapping.
121
+ # Same dispatch path as mixed_content; same bug class.
122
+ class OrderedRawCollectionContainer < Lutaml::Model::Serializable
123
+ attribute :name, :string
124
+ attribute :fragments, :string, collection: true
125
+
126
+ xml do
127
+ element "container"
128
+ ordered
129
+ map_element "name", to: :name
130
+ map_element "fragment", to: :fragments, raw: :element
131
+ end
132
+ end
133
+
90
134
  RSpec.describe "map_element raw: :element" do
91
135
  describe "deserialization" do
92
136
  describe "foreign namespace element capture" do
@@ -378,6 +422,87 @@ module RawElementSpec
378
422
  end
379
423
  end
380
424
 
425
+ # Regression for issue #727: OrderedApplier#apply_element_rule_single
426
+ # was missing the raw: :element short-circuit, so each captured fragment
427
+ # string (which already contains its own <fragment> wrapper) got wrapped
428
+ # again, producing <fragment><fragment>...</fragment></fragment>.
429
+ describe "round-trip with mixed_content + raw: :element collection" do
430
+ let(:mixed_collection_xml) do
431
+ <<~XML
432
+ <container>
433
+ before
434
+ <name>multi</name>
435
+ <fragment id="1">first</fragment>
436
+ <fragment id="2"><nested>second</nested></fragment>
437
+ after
438
+ </container>
439
+ XML
440
+ end
441
+
442
+ it "does not double-wrap fragments on round-trip" do
443
+ doc = MixedContentRawCollectionContainer.from_xml(mixed_collection_xml)
444
+ output = doc.to_xml
445
+
446
+ expect(output).not_to include("<fragment><fragment")
447
+ expect(output).to include('id="1"')
448
+ expect(output).to include("first")
449
+ expect(output).to include('id="2"')
450
+ expect(output).to include("<nested>second</nested>")
451
+ end
452
+
453
+ it "is stable across repeated round-trips" do
454
+ doc = MixedContentRawCollectionContainer.from_xml(mixed_collection_xml)
455
+ once = MixedContentRawCollectionContainer.from_xml(doc.to_xml)
456
+ twice = MixedContentRawCollectionContainer.from_xml(once.to_xml)
457
+
458
+ expect(twice.fragments.length).to eq(2)
459
+ expect(twice.fragments[0]).to include("first")
460
+ expect(twice.fragments[1]).to include("<nested>second</nested>")
461
+ end
462
+ end
463
+
464
+ # Regression guard: the non-collection path under mixed_content dispatches
465
+ # through :apply_rule -> RuleApplier#apply_rule, which already had the
466
+ # short-circuit. Lock in the working behavior.
467
+ describe "round-trip with mixed_content + raw: :element single" do
468
+ let(:mixed_single_xml) do
469
+ '<container>before<name>x</name><foreign attr="v"><child>c</child></foreign>after</container>'
470
+ end
471
+
472
+ it "emits the foreign element verbatim without wrapping" do
473
+ doc = MixedContentRawSingleContainer.from_xml(mixed_single_xml)
474
+ output = doc.to_xml
475
+
476
+ expect(output).not_to include("<foreign><foreign")
477
+ expect(output).to include('attr="v"')
478
+ expect(output).to include("<child>c</child>")
479
+ end
480
+ end
481
+
482
+ # Same dispatch path (OrderedApplier) as mixed_content; same bug class.
483
+ describe "round-trip with ordered + raw: :element collection" do
484
+ let(:ordered_collection_xml) do
485
+ <<~XML
486
+ <container>
487
+ <name>multi</name>
488
+ <fragment id="1">first</fragment>
489
+ <fragment id="2">second</fragment>
490
+ </container>
491
+ XML
492
+ end
493
+
494
+ it "does not double-wrap fragments on round-trip" do
495
+ doc = OrderedRawCollectionContainer.from_xml(ordered_collection_xml)
496
+ output = doc.to_xml
497
+
498
+ expect(output).not_to include("<fragment><fragment")
499
+ expect(output).to include('id="1"')
500
+ expect(output).to include("first")
501
+ expect(output).to include('id="2"')
502
+ expect(output).to include("second")
503
+ end
504
+ end
505
+
381
506
  describe "nil and empty values" do
382
507
  it "omits raw element when value is nil" do
383
508
  model = SvgContainer.new(svg_data: nil)
@@ -9,19 +9,10 @@ RSpec.describe Lutaml::Model::RuleValueExtractor do
9
9
  let(:rule) { instance_double(Lutaml::Model::Jsonl::MappingRule) }
10
10
  let(:doc) { { "name" => "Test", "value" => 123 } }
11
11
  let(:format) { :json }
12
- let(:attr) { instance_double(Lutaml::Model::Attribute) }
13
- let(:register) { instance_double(Lutaml::Model::Register) }
12
+ let(:attr) { Lutaml::Model::Attribute.new("name", :string) }
13
+ let(:register) { Lutaml::Model::Config.default_register }
14
14
  let(:options) { {} }
15
15
 
16
- def mock_resolver(default_set_value, default_value_data = nil)
17
- resolver_double = instance_double(Lutaml::Model::Services::DefaultValueResolver,
18
- default_set?: default_set_value,
19
- default_value: default_value_data)
20
- allow(Lutaml::Model::Services::DefaultValueResolver).to receive(:new)
21
- .with(attr, register, instance)
22
- .and_return(resolver_double)
23
- end
24
-
25
16
  describe "#call" do
26
17
  context "when rule has single mapping" do
27
18
  before do
@@ -82,7 +73,7 @@ RSpec.describe Lutaml::Model::RuleValueExtractor do
82
73
  end
83
74
  end
84
75
 
85
- context "when value is not found" do
76
+ context "when value is not found and attribute has no default" do
86
77
  before do
87
78
  allow(rule).to receive_messages(
88
79
  multiple_mappings?: false,
@@ -90,7 +81,6 @@ RSpec.describe Lutaml::Model::RuleValueExtractor do
90
81
  root_mapping?: false,
91
82
  raw_mapping?: false,
92
83
  )
93
- mock_resolver(false)
94
84
  end
95
85
 
96
86
  it "returns uninitialized value" do
@@ -99,6 +89,10 @@ RSpec.describe Lutaml::Model::RuleValueExtractor do
99
89
  end
100
90
 
101
91
  context "when attribute has default value" do
92
+ let(:attr) do
93
+ Lutaml::Model::Attribute.new("name", :string, default: "default_value")
94
+ end
95
+
102
96
  before do
103
97
  allow(rule).to receive_messages(
104
98
  multiple_mappings?: false,
@@ -106,7 +100,6 @@ RSpec.describe Lutaml::Model::RuleValueExtractor do
106
100
  root_mapping?: false,
107
101
  raw_mapping?: false,
108
102
  )
109
- mock_resolver(true, "default_value")
110
103
  end
111
104
 
112
105
  it "returns default value" do
@@ -33,4 +33,29 @@ RSpec.describe Lutaml::Model::RuntimeCompatibility do
33
33
  .to eq(error_class)
34
34
  end
35
35
  end
36
+
37
+ # lib/lutaml/model.rb and lib/lutaml/xml.rb re-evaluate their top-level
38
+ # prepend calls when Opal's eager loader processes them more than once.
39
+ # Opal's Module#prepend raises "Prepending a module multiple times is
40
+ # not supported" in that case; runtime_compatibility.rb aligns Opal
41
+ # with MRI's idempotent behavior. MRI has been idempotent since 2.0,
42
+ # so the spec applies to both runtimes.
43
+ describe "Module#prepend idempotency" do
44
+ it "is a no-op when the module is already in the ancestor chain" do
45
+ mod = Module.new
46
+ klass = Class.new
47
+ klass.prepend(mod)
48
+
49
+ expect { klass.prepend(mod) }.not_to raise_error
50
+ expect(klass.ancestors.count(mod)).to eq(1)
51
+ end
52
+
53
+ it "still prepends when the module is not yet present" do
54
+ mod = Module.new
55
+ klass = Class.new
56
+
57
+ expect { klass.prepend(mod) }.not_to raise_error
58
+ expect(klass.ancestors).to include(mod)
59
+ end
60
+ end
36
61
  end