lutaml-model 0.8.16 → 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 (230) 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/.github/workflows/release.yml +0 -3
  9. data/.gitmodules +6 -0
  10. data/.rubocop_todo.yml +76 -12
  11. data/Gemfile +16 -1
  12. data/README.adoc +267 -0
  13. data/Rakefile +166 -4
  14. data/docs/_guides/schema-import.adoc +42 -0
  15. data/docs/_guides/xml/namespace-semantics.adoc +2 -0
  16. data/docs/_guides/xml-mapping.adoc +21 -0
  17. data/docs/_guides/xml-namespace-qualification.adoc +142 -0
  18. data/docs/_pages/validation.adoc +65 -0
  19. data/docs/_tutorials/xml-element-attribute-namespace-guide.adoc +2 -0
  20. data/docs/_tutorials/xml-schema-primer-style-guide.adoc +2 -0
  21. data/docs/namespace-management.adoc +2 -0
  22. data/docs/xml-schema-qualification.md +6 -0
  23. data/lib/compat/opal/generate_boot.rb +123 -0
  24. data/lib/compat/opal/js_bundle_entry.rb +42 -0
  25. data/lib/compat/opal/lutaml_model_boot.rb +497 -0
  26. data/lib/compat/opal/moxml_boot.rb +68 -0
  27. data/lib/compat/opal/yaml_compat.rb +32 -0
  28. data/lib/lutaml/json.rb +1 -1
  29. data/lib/lutaml/jsonld.rb +1 -4
  30. data/lib/lutaml/key_value/transform.rb +3 -8
  31. data/lib/lutaml/key_value/transformation/value_serializer.rb +14 -1
  32. data/lib/lutaml/key_value/transformation.rb +17 -5
  33. data/lib/lutaml/model/adapter_resolver.rb +2 -2
  34. data/lib/lutaml/model/attribute.rb +88 -11
  35. data/lib/lutaml/model/cached_type_resolver.rb +1 -1
  36. data/lib/lutaml/model/choice.rb +34 -0
  37. data/lib/lutaml/model/compiled_rule.rb +7 -0
  38. data/lib/lutaml/model/error/union_schema_unsupported_error.rb +11 -0
  39. data/lib/lutaml/model/global_context.rb +11 -8
  40. data/lib/lutaml/model/instrumentation.rb +2 -2
  41. data/lib/lutaml/model/mapping/mapping_rule.rb +0 -6
  42. data/lib/lutaml/model/register.rb +2 -2
  43. data/lib/lutaml/model/runtime_compatibility.rb +50 -0
  44. data/lib/lutaml/model/schema/class_loader.rb +58 -0
  45. data/lib/lutaml/model/schema/compiled_output.rb +83 -0
  46. data/lib/lutaml/model/schema/definitions/attribute.rb +32 -0
  47. data/lib/lutaml/model/schema/definitions/choice.rb +20 -0
  48. data/lib/lutaml/model/schema/definitions/facet.rb +40 -0
  49. data/lib/lutaml/model/schema/definitions/group_import.rb +22 -0
  50. data/lib/lutaml/model/schema/definitions/member_walk.rb +33 -0
  51. data/lib/lutaml/model/schema/definitions/model.rb +40 -0
  52. data/lib/lutaml/model/schema/definitions/namespace.rb +23 -0
  53. data/lib/lutaml/model/schema/definitions/restricted_type.rb +30 -0
  54. data/lib/lutaml/model/schema/definitions/sequence.rb +19 -0
  55. data/lib/lutaml/model/schema/definitions/simple_content.rb +22 -0
  56. data/lib/lutaml/model/schema/definitions/transform_facet.rb +21 -0
  57. data/lib/lutaml/model/schema/definitions/type_ref.rb +20 -0
  58. data/lib/lutaml/model/schema/definitions/union_type.rb +28 -0
  59. data/lib/lutaml/model/schema/definitions/xml_root.rb +20 -0
  60. data/lib/lutaml/model/schema/definitions.rb +24 -0
  61. data/lib/lutaml/model/schema/file_writer.rb +70 -0
  62. data/lib/lutaml/model/schema/generator/definitions_collection.rb +16 -2
  63. data/lib/lutaml/model/schema/generator/property.rb +21 -4
  64. data/lib/lutaml/model/schema/module_nesting.rb +31 -0
  65. data/lib/lutaml/model/schema/namespace_naming.rb +46 -0
  66. data/lib/lutaml/model/schema/registry_generator.rb +115 -0
  67. data/lib/lutaml/model/schema/renderers/base.rb +40 -0
  68. data/lib/lutaml/model/schema/renderers/mappings.rb +72 -0
  69. data/lib/lutaml/model/schema/renderers/member_decls.rb +115 -0
  70. data/lib/lutaml/model/schema/renderers/model.rb +121 -0
  71. data/lib/lutaml/model/schema/renderers/namespace.rb +30 -0
  72. data/lib/lutaml/model/schema/renderers/registration.rb +64 -0
  73. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +93 -0
  74. data/lib/lutaml/model/schema/renderers/restricted_type.rb +88 -0
  75. data/lib/lutaml/model/schema/renderers/union.rb +83 -0
  76. data/lib/lutaml/model/schema/renderers.rb +19 -0
  77. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +47 -0
  78. data/lib/lutaml/model/schema/rnc_compiler.rb +62 -0
  79. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +91 -0
  80. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +392 -0
  81. data/lib/lutaml/model/schema/rng_compiler/member_collector.rb +30 -0
  82. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +152 -0
  83. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +125 -0
  84. data/lib/lutaml/model/schema/rng_compiler.rb +220 -0
  85. data/lib/lutaml/model/schema/templates.rb +162 -0
  86. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +41 -0
  87. data/lib/lutaml/model/schema/xml_compiler/registry_generator.rb +31 -107
  88. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +191 -0
  89. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +216 -0
  90. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +153 -0
  91. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +167 -0
  92. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +87 -0
  93. data/lib/lutaml/model/schema/xml_compiler.rb +38 -517
  94. data/lib/lutaml/model/schema.rb +19 -0
  95. data/lib/lutaml/model/serialize/attribute_definition.rb +14 -2
  96. data/lib/lutaml/model/serialize/builder.rb +15 -2
  97. data/lib/lutaml/model/serialize/value_mapping.rb +9 -57
  98. data/lib/lutaml/model/serialize.rb +6 -9
  99. data/lib/lutaml/model/services/rule_value_extractor.rb +2 -3
  100. data/lib/lutaml/model/services.rb +0 -2
  101. data/lib/lutaml/model/store.rb +2 -1
  102. data/lib/lutaml/model/toml.rb +1 -1
  103. data/lib/lutaml/model/transform.rb +17 -39
  104. data/lib/lutaml/model/transformation_registry.rb +1 -1
  105. data/lib/lutaml/model/type.rb +1 -0
  106. data/lib/lutaml/model/union.rb +320 -0
  107. data/lib/lutaml/model/utils.rb +6 -0
  108. data/lib/lutaml/model/validation.rb +1 -4
  109. data/lib/lutaml/model/version.rb +1 -1
  110. data/lib/lutaml/model.rb +19 -0
  111. data/lib/lutaml/{jsonld → rdf}/context.rb +1 -1
  112. data/lib/lutaml/{jsonld/transform.rb → rdf/linked_data_transform.rb} +33 -35
  113. data/lib/lutaml/{jsonld → rdf}/term_definition.rb +1 -1
  114. data/lib/lutaml/rdf.rb +3 -0
  115. data/lib/lutaml/toml.rb +1 -1
  116. data/lib/lutaml/xml/adapter.rb +1 -1
  117. data/lib/lutaml/xml/adapter_element.rb +2 -2
  118. data/lib/lutaml/xml/adapter_loader.rb +1 -1
  119. data/lib/lutaml/xml/builder/base.rb +1 -1
  120. data/lib/lutaml/xml/builder.rb +1 -1
  121. data/lib/lutaml/xml/error/schema_validation_error.rb +25 -0
  122. data/lib/lutaml/xml/model_transform.rb +5 -1
  123. data/lib/lutaml/xml/schema/relaxng_schema.rb +5 -0
  124. data/lib/lutaml/xml/schema/xsd_schema.rb +4 -0
  125. data/lib/lutaml/xml/serialization/format_conversion.rb +48 -0
  126. data/lib/lutaml/xml/serialization/instance_methods.rb +13 -0
  127. data/lib/lutaml/xml/transformation/element_builder.rb +59 -31
  128. data/lib/lutaml/xml/transformation/ordered_applier.rb +6 -0
  129. data/lib/lutaml/xml/xsd_validator.rb +66 -0
  130. data/lib/lutaml/xml.rb +53 -12
  131. data/lib/lutaml/yamlld/adapter.rb +25 -0
  132. data/lib/lutaml/yamlld.rb +25 -0
  133. data/lutaml-model.gemspec +4 -2
  134. data/spec/fixtures/xml/schema/rnc/address_book.rnc +15 -0
  135. data/spec/fixtures/xml/schema/rnc/book_features.rnc +18 -0
  136. data/spec/fixtures/xml/schema/rnc/includes/book.rnc +6 -0
  137. data/spec/fixtures/xml/schema/rnc/includes/circular_a.rnc +8 -0
  138. data/spec/fixtures/xml/schema/rnc/includes/circular_b.rnc +8 -0
  139. data/spec/fixtures/xml/schema/rnc/includes/library.rnc +8 -0
  140. data/spec/fixtures/xml/schema/rng/address_book.rng +16 -0
  141. data/spec/fixtures/xml/schema/rng/book.rng +27 -0
  142. data/spec/fixtures/xml/schema/rng/fixed_value.rng +11 -0
  143. data/spec/fixtures/xml/schema/rng/fragment_in_choice.rng +30 -0
  144. data/spec/fixtures/xml/schema/rng/full_name.rng +39 -0
  145. data/spec/fixtures/xml/schema/rng/integer_range.rng +32 -0
  146. data/spec/fixtures/xml/schema/rng/list_type.rng +12 -0
  147. data/spec/fixtures/xml/schema/rng/namespaced.rng +9 -0
  148. data/spec/fixtures/xml/schema/rng/paragraph.rng +13 -0
  149. data/spec/fixtures/xml/schema/rng/person.rng +23 -0
  150. data/spec/fixtures/xml/schema/rng/union.rng +14 -0
  151. data/spec/fixtures/xml/validate_xml_with_address.xsd +13 -0
  152. data/spec/fixtures/xml/validate_xml_with_contact.xsd +11 -0
  153. data/spec/fixtures/xml/validate_xml_with_person.xsd +11 -0
  154. data/spec/fixtures/xml/validate_xml_with_person_strict.xsd +17 -0
  155. data/spec/fixtures/xml/validate_xml_with_product.xsd +37 -0
  156. data/spec/lutaml/integration/multi_format_spec.rb +23 -0
  157. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +134 -0
  158. data/spec/lutaml/model/attribute_spec.rb +177 -13
  159. data/spec/lutaml/model/boot_manifest_spec.rb +30 -0
  160. data/spec/lutaml/model/choice_restrict_spec.rb +225 -0
  161. data/spec/lutaml/model/collection_spec.rb +18 -0
  162. data/spec/lutaml/model/custom_model_spec.rb +4 -4
  163. data/spec/lutaml/model/mixed_content_spec.rb +173 -2
  164. data/spec/lutaml/model/multiple_mapping_spec.rb +4 -4
  165. data/spec/lutaml/model/opal_smoke_spec.rb +67 -2
  166. data/spec/lutaml/model/ordered_content_spec.rb +33 -3
  167. data/spec/lutaml/model/raw_element_spec.rb +125 -0
  168. data/spec/lutaml/model/rule_value_extractor_spec.rb +7 -14
  169. data/spec/lutaml/model/runtime_compatibility_spec.rb +25 -0
  170. data/spec/lutaml/model/schema/renderers/model_spec.rb +149 -0
  171. data/spec/lutaml/model/schema/renderers/namespace_spec.rb +36 -0
  172. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +75 -0
  173. data/spec/lutaml/model/schema/renderers/union_spec.rb +58 -0
  174. data/spec/lutaml/model/transform_apply_value_map_spec.rb +64 -0
  175. data/spec/lutaml/model/uninitialized_class_spec.rb +1 -1
  176. data/spec/lutaml/model/union_attribute_spec.rb +658 -0
  177. data/spec/lutaml/model/union_spec.rb +257 -0
  178. data/spec/lutaml/model/utils_spec.rb +14 -0
  179. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +2 -2
  180. data/spec/lutaml/model/xsd_patterns_spec.rb +4 -4
  181. data/spec/lutaml/{jsonld → rdf}/context_spec.rb +2 -2
  182. data/spec/lutaml/{jsonld/transform_spec.rb → rdf/linked_data_transform_spec.rb} +15 -1
  183. data/spec/lutaml/{jsonld → rdf}/term_definition_spec.rb +2 -2
  184. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  185. data/spec/lutaml/xml/enhanced_mapping_spec.rb +230 -1
  186. data/spec/lutaml/xml/mapping_spec.rb +4 -4
  187. data/spec/lutaml/xml/namespace_placement_spec.rb +11 -8
  188. data/spec/lutaml/xml/namespace_three_phase_spec.rb +1 -1
  189. data/spec/lutaml/xml/opal_xml_spec.rb +22 -20
  190. data/spec/lutaml/xml/prefix_control_spec.rb +4 -4
  191. data/spec/lutaml/xml/schema/compiler_spec.rb +21 -4
  192. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +404 -0
  193. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +893 -0
  194. data/spec/lutaml/xml/serializable_namespace_spec.rb +6 -6
  195. data/spec/lutaml/xml/type_namespace_examples_spec.rb +1 -1
  196. data/spec/lutaml/xml/type_namespace_integration_spec.rb +3 -3
  197. data/spec/lutaml/xml/validate_xml_with_constructs_spec.rb +222 -0
  198. data/spec/lutaml/xml/validate_xml_with_spec.rb +186 -0
  199. data/spec/lutaml/yamlld/adapter_spec.rb +56 -0
  200. data/spec/lutaml/yamlld/registration_spec.rb +33 -0
  201. data/spec/spec_helper.rb +28 -11
  202. data/spec/support/opal.rb +5 -2
  203. metadata +108 -37
  204. data/lib/lutaml/model/schema/xml_compiler/attribute.rb +0 -106
  205. data/lib/lutaml/model/schema/xml_compiler/attribute_group.rb +0 -47
  206. data/lib/lutaml/model/schema/xml_compiler/choice.rb +0 -67
  207. data/lib/lutaml/model/schema/xml_compiler/complex_content.rb +0 -27
  208. data/lib/lutaml/model/schema/xml_compiler/complex_content_restriction.rb +0 -34
  209. data/lib/lutaml/model/schema/xml_compiler/complex_type.rb +0 -173
  210. data/lib/lutaml/model/schema/xml_compiler/element.rb +0 -121
  211. data/lib/lutaml/model/schema/xml_compiler/group.rb +0 -113
  212. data/lib/lutaml/model/schema/xml_compiler/restriction.rb +0 -104
  213. data/lib/lutaml/model/schema/xml_compiler/sequence.rb +0 -52
  214. data/lib/lutaml/model/schema/xml_compiler/simple_content.rb +0 -40
  215. data/lib/lutaml/model/schema/xml_compiler/simple_type.rb +0 -265
  216. data/lib/lutaml/model/schema/xml_compiler/xml_namespace_class.rb +0 -110
  217. data/lib/lutaml/model/services/default_value_resolver.rb +0 -60
  218. data/spec/lutaml/model/services/default_value_resolver_spec.rb +0 -162
  219. data/spec/lutaml/xml/schema/compiler/attribute_group_spec.rb +0 -71
  220. data/spec/lutaml/xml/schema/compiler/attribute_spec.rb +0 -67
  221. data/spec/lutaml/xml/schema/compiler/choice_spec.rb +0 -74
  222. data/spec/lutaml/xml/schema/compiler/complex_content_restriction_spec.rb +0 -60
  223. data/spec/lutaml/xml/schema/compiler/complex_content_spec.rb +0 -41
  224. data/spec/lutaml/xml/schema/compiler/complex_type_spec.rb +0 -202
  225. data/spec/lutaml/xml/schema/compiler/element_spec.rb +0 -68
  226. data/spec/lutaml/xml/schema/compiler/group_spec.rb +0 -90
  227. data/spec/lutaml/xml/schema/compiler/restriction_spec.rb +0 -77
  228. data/spec/lutaml/xml/schema/compiler/sequence_spec.rb +0 -65
  229. data/spec/lutaml/xml/schema/compiler/simple_content_spec.rb +0 -61
  230. data/spec/lutaml/xml/schema/compiler/simple_type_spec.rb +0 -184
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Model::Choice, "#restrict and #remove_attribute" do
6
+ before do
7
+ stub_const("ChoiceRestrictParent", Class.new(Lutaml::Model::Serializable) do
8
+ attribute :name, :string
9
+ attribute :age, :integer
10
+ attribute :email, :string
11
+ end)
12
+ end
13
+
14
+ describe "#restrict" do
15
+ it "restricts a predefined attribute inside a choice block" do
16
+ stub_const("ChoiceRestrictModel", Class.new(Lutaml::Model::Serializable) do
17
+ attribute :foo, :string, collection: 1..10
18
+
19
+ choice min: 1, max: 1 do
20
+ restrict :foo, collection: 2..5
21
+ end
22
+ end)
23
+
24
+ attr = ChoiceRestrictModel.attributes[:foo]
25
+ expect(attr.options[:collection]).to eq(2..5)
26
+ choice = ChoiceRestrictModel.choice_attributes.first
27
+ expect(choice.attributes).to include(attr)
28
+ end
29
+
30
+ it "raises UndefinedAttributeError for non-existent attribute" do
31
+ expect do
32
+ stub_const("ChoiceRestrictBadModel", Class.new(Lutaml::Model::Serializable) do
33
+ choice min: 1, max: 1 do
34
+ restrict :nonexistent, collection: 1..2
35
+ end
36
+ end)
37
+ end.to raise_error(Lutaml::Model::UndefinedAttributeError)
38
+ end
39
+
40
+ it "restricts an imported model attribute inside a choice block" do
41
+ stub_const("ChoiceImportModel", Class.new(Lutaml::Model::Serializable) do
42
+ attribute :name, :string
43
+ attribute :age, :integer
44
+
45
+ choice min: 1, max: 1 do
46
+ import_model_attributes ChoiceRestrictParent
47
+ restrict :age, values: [18, 21, 30]
48
+ end
49
+ end)
50
+
51
+ attr = ChoiceImportModel.attributes[:age]
52
+ expect(attr.options[:values]).to eq([18, 21, 30])
53
+ end
54
+
55
+ it "marks the attribute as belonging to the choice" do
56
+ stub_const("ChoiceRestrictOwnedModel", Class.new(Lutaml::Model::Serializable) do
57
+ attribute :tag, :string
58
+
59
+ choice min: 0, max: 1 do
60
+ restrict :tag, values: %w[a b c]
61
+ end
62
+ end)
63
+
64
+ attr = ChoiceRestrictOwnedModel.attributes[:tag]
65
+ expect(attr.options[:choice]).to be_a(described_class)
66
+ end
67
+
68
+ it "does not duplicate the attribute if restrict is called twice" do
69
+ stub_const("ChoiceRestrictDupModel", Class.new(Lutaml::Model::Serializable) do
70
+ attribute :val, :integer
71
+
72
+ choice min: 0, max: 1 do
73
+ restrict :val, values: [1, 2]
74
+ restrict :val, values: [1, 2, 3]
75
+ end
76
+ end)
77
+
78
+ choice = ChoiceRestrictDupModel.choice_attributes.first
79
+ count = choice.attributes.count do |a|
80
+ !a.is_a?(described_class) && a.name == :val
81
+ end
82
+ expect(count).to eq(1)
83
+ expect(ChoiceRestrictDupModel.attributes[:val].options[:values]).to eq([
84
+ 1, 2, 3
85
+ ])
86
+ end
87
+
88
+ it "restricts an inherited attribute in a child class choice" do
89
+ stub_const("ChoiceRestrictParent", Class.new(Lutaml::Model::Serializable) do
90
+ attribute :tag, :string
91
+ attribute :label, :string
92
+ end)
93
+
94
+ child = Class.new(ChoiceRestrictParent) do
95
+ include Lutaml::Model::Serialize
96
+
97
+ choice min: 1, max: 1 do
98
+ restrict :tag, values: %w[a b c]
99
+ end
100
+ end
101
+
102
+ attr = child.attributes[:tag]
103
+ expect(attr.options[:values]).to eq(%w[a b c])
104
+ choice = child.choice_attributes.first
105
+ expect(choice.flat_attributes.any? { |a| a.name == :tag }).to be(true)
106
+ end
107
+ end
108
+
109
+ describe "#remove_attribute" do
110
+ it "removes an attribute from the choice block" do
111
+ stub_const("ChoiceRemoveModel", Class.new(Lutaml::Model::Serializable) do
112
+ attribute :keep, :string
113
+ attribute :drop, :string
114
+
115
+ choice min: 0, max: 1 do
116
+ attribute :keep, :string
117
+ attribute :drop, :string
118
+ remove_attribute :drop
119
+ end
120
+ end)
121
+
122
+ choice = ChoiceRemoveModel.choice_attributes.first
123
+ attr_names = choice.attributes.filter_map do |a|
124
+ a.is_a?(described_class) ? nil : a.name
125
+ end
126
+ expect(attr_names).to eq([:keep])
127
+ end
128
+
129
+ it "clears the choice option from the removed attribute" do
130
+ stub_const("ChoiceRemoveOptModel", Class.new(Lutaml::Model::Serializable) do
131
+ attribute :item, :string
132
+
133
+ choice min: 0, max: 1 do
134
+ attribute :item, :string
135
+ remove_attribute :item
136
+ end
137
+ end)
138
+
139
+ attr = ChoiceRemoveOptModel.attributes[:item]
140
+ # The top-level :item attribute should still exist without :choice
141
+ # since the one in the choice was a different instance
142
+ expect(attr.options[:choice]).to be_nil
143
+ end
144
+
145
+ it "returns nil if the attribute is not in the choice" do
146
+ result = nil
147
+ stub_const("ChoiceRemoveNotFoundModel", Class.new(Lutaml::Model::Serializable) do
148
+ attribute :x, :string
149
+
150
+ choice min: 0, max: 1 do
151
+ result = remove_attribute(:nonexistent)
152
+ end
153
+ end)
154
+
155
+ expect(result).to be_nil
156
+ end
157
+
158
+ it "invalidates the flat_attributes cache" do
159
+ stub_const("ChoiceRemoveCacheModel", Class.new(Lutaml::Model::Serializable) do
160
+ attribute :a, :string
161
+ attribute :b, :string
162
+
163
+ choice min: 0, max: 1 do
164
+ attribute :a, :string
165
+ attribute :b, :string
166
+ end
167
+ end)
168
+
169
+ choice = ChoiceRemoveCacheModel.choice_attributes.first
170
+ expect(choice.flat_attributes.map(&:name)).to contain_exactly(:a, :b)
171
+ choice.remove_attribute(:b)
172
+ expect(choice.flat_attributes.map(&:name)).to contain_exactly(:a)
173
+ end
174
+
175
+ it "removes an imported attribute from the choice block" do
176
+ source = Class.new(Lutaml::Model::Serializable) do
177
+ attribute :name, :string
178
+ attribute :email, :string
179
+ attribute :phone, :string
180
+ end
181
+ stub_const("ChoiceRemoveImportSource", source)
182
+
183
+ stub_const("ChoiceRemoveImportModel", Class.new(Lutaml::Model::Serializable) do
184
+ choice min: 1, max: 1 do
185
+ import_model_attributes ChoiceRemoveImportSource
186
+ remove_attribute :phone
187
+ end
188
+ end)
189
+
190
+ choice = ChoiceRemoveImportModel.choice_attributes.first
191
+ names = choice.flat_attributes.map(&:name)
192
+ expect(names).to contain_exactly(:name, :email)
193
+ end
194
+
195
+ it "removes a restrict-added attribute from the choice" do
196
+ stub_const("ChoiceRemoveRestrictModel", Class.new(Lutaml::Model::Serializable) do
197
+ attribute :tag, :string
198
+
199
+ choice min: 0, max: 1 do
200
+ restrict :tag, values: %w[a b c]
201
+ remove_attribute :tag
202
+ end
203
+ end)
204
+
205
+ choice = ChoiceRemoveRestrictModel.choice_attributes.first
206
+ expect(choice.flat_attributes).to be_empty
207
+ expect(ChoiceRemoveRestrictModel.attributes[:tag].options[:choice]).to be_nil
208
+ end
209
+
210
+ it "returns the removed attribute" do
211
+ stub_const("ChoiceRemoveReturnModel", Class.new(Lutaml::Model::Serializable) do
212
+ attribute :item, :string
213
+
214
+ choice min: 0, max: 1 do
215
+ attribute :item, :string
216
+ end
217
+ end)
218
+
219
+ choice = ChoiceRemoveReturnModel.choice_attributes.first
220
+ removed = choice.remove_attribute(:item)
221
+ expect(removed).to be_a(Lutaml::Model::Attribute)
222
+ expect(removed.name).to eq(:item)
223
+ end
224
+ end
225
+ 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"])
@@ -153,16 +153,16 @@ module CustomModelSpecs
153
153
  end
154
154
 
155
155
  class MixedWithNestedContent < Lutaml::Model::Serializable
156
- attribute :street, :string, raw: true
157
- attribute :city, :string, raw: true
156
+ attribute :street, :string
157
+ attribute :city, :string
158
158
  attribute :bibdata, Bibdata
159
159
 
160
160
  xml do
161
161
  element "MixedWithNestedContent"
162
162
  mixed_content
163
163
 
164
- map_element "street", to: :street
165
- map_element "city", to: :city
164
+ map_element "street", to: :street, raw: :content
165
+ map_element "city", to: :city, raw: :content
166
166
  map_element "bibdata",
167
167
  to: :bibdata,
168
168
  with: { from: :bibdata_from_xml, to: :bibdata_to_xml }
@@ -107,12 +107,12 @@ module MixedContentSpec
107
107
  end
108
108
 
109
109
  class SpecialCharContentWithRawAndMixedOption < Lutaml::Model::Serializable
110
- attribute :special, :string, raw: true
110
+ attribute :special, :string
111
111
 
112
112
  xml do
113
113
  element "SpecialCharContentWithRawOptionAndMixedOption"
114
114
  mixed_content
115
- map_element :special, to: :special
115
+ map_element :special, to: :special, raw: :content
116
116
  end
117
117
  end
118
118
 
@@ -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
@@ -240,12 +240,12 @@ RSpec.describe MultipleMapping do
240
240
  expect(product1.name).to eq("Coffee Maker")
241
241
  expect(product1.description).to eq("Premium coffee maker")
242
242
  expect(product1.status).to eq("active")
243
- expect(product1.content.to_s).to match(/Some content here/)
243
+ expect(product1.content.to_s).to include("Some content here")
244
244
 
245
245
  expect(product2.name).to eq("Coffee Maker")
246
246
  expect(product2.description).to eq("Premium coffee maker")
247
247
  expect(product2.status).to eq("in-stock")
248
- expect(product2.content.to_s).to match(/Different content/)
248
+ expect(product2.content.to_s).to include("Different content")
249
249
 
250
250
  # Test round-trip: serialize and deserialize should preserve data
251
251
  # (Note: exact XML format differs between adapters due to mixed content whitespace handling)
@@ -253,13 +253,13 @@ RSpec.describe MultipleMapping do
253
253
  expect(round_trip1.name).to eq(product1.name)
254
254
  expect(round_trip1.description).to eq(product1.description)
255
255
  expect(round_trip1.status).to eq(product1.status)
256
- expect(round_trip1.content.to_s).to match(/Some content here/)
256
+ expect(round_trip1.content.to_s).to include("Some content here")
257
257
 
258
258
  round_trip2 = MultipleMapping::Product.from_xml(product2.to_xml)
259
259
  expect(round_trip2.name).to eq(product2.name)
260
260
  expect(round_trip2.description).to eq(product2.description)
261
261
  expect(round_trip2.status).to eq(product2.status)
262
- expect(round_trip2.content.to_s).to match(/Different content/)
262
+ expect(round_trip2.content.to_s).to include("Different content")
263
263
  end
264
264
  end
265
265
 
@@ -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
@@ -102,8 +102,8 @@ RSpec.describe "OrderedContent" do
102
102
  expect(obj.bold).to eq(["bell", "cool"])
103
103
  expect(obj.italic).to eq(["384,400 km"])
104
104
  expect(obj.underline).to eq("craters")
105
- expect(obj.content.first.to_s).to match(/The Earth's Moon rings like a/)
106
- expect(obj.content.join).to match(/Ain't that/)
105
+ expect(obj.content.first.to_s).to include("The Earth's Moon rings like a")
106
+ expect(obj.content.join).to include("Ain't that")
107
107
 
108
108
  # Verify round-trip preserves data
109
109
  # (Note: exact XML format differs between adapters in ordered mode)
@@ -112,7 +112,7 @@ RSpec.describe "OrderedContent" do
112
112
  expect(round_trip.bold).to eq(obj.bold)
113
113
  expect(round_trip.italic).to eq(obj.italic)
114
114
  expect(round_trip.underline).to eq(obj.underline)
115
- expect(round_trip.content.first.to_s).to match(/The Earth's Moon rings like a/)
115
+ expect(round_trip.content.first.to_s).to include("The Earth's Moon rings like a")
116
116
  end
117
117
  end
118
118
 
@@ -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