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,658 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Behavioral contract for issue #190 union-typed attributes:
6
+ # - DSL: bare Array in type position -> attribute :x, [A, B, :string]
7
+ # - Semantics: xsd:union -> first-conforming member in declared order wins;
8
+ # valid if it conforms to at least one member (NO "exactly one" rule).
9
+ # - No-match: a real value matching no member becomes nil (like every failed
10
+ # cast); `required: true` then raises the standard RequiredAttributeMissingError.
11
+ # - Stateless serialization: the value's own class drives output, so a model
12
+ # built in plain Ruby serializes identically to a deserialized one.
13
+ module UnionAttributeSpec
14
+ # Type-only member (no root declared) — structured, keys {number, unit}.
15
+ class TemperatureWithUnit < Lutaml::Model::Serializable
16
+ attribute :number, :float
17
+ attribute :unit, :string
18
+
19
+ xml do
20
+ map_element "number", to: :number
21
+ map_element "unit", to: :unit
22
+ end
23
+
24
+ key_value do
25
+ map "number", to: :number
26
+ map "unit", to: :unit
27
+ end
28
+ end
29
+
30
+ # Type-only member — structured, disjoint key {celsius}.
31
+ class Temperature < Lutaml::Model::Serializable
32
+ attribute :celsius, :float
33
+
34
+ xml do
35
+ map_element "celsius", to: :celsius
36
+ end
37
+
38
+ key_value do
39
+ map "celsius", to: :celsius
40
+ end
41
+ end
42
+
43
+ # Mixed scalar + model union under a single shared element name.
44
+ class Ceramic < Lutaml::Model::Serializable
45
+ attribute :firing_temperature,
46
+ [TemperatureWithUnit, Temperature, :string]
47
+
48
+ xml do
49
+ element "ceramic"
50
+ map_element "FiringTemperature", to: :firing_temperature
51
+ end
52
+
53
+ key_value do
54
+ map "firing_temperature", to: :firing_temperature
55
+ end
56
+ end
57
+
58
+ # Scalar union with a catch-all — exercises first-conforming order.
59
+ class Reading < Lutaml::Model::Serializable
60
+ attribute :value, %i[integer string]
61
+
62
+ key_value do
63
+ map "value", to: :value
64
+ end
65
+ end
66
+
67
+ # Model member whose mapping aliases one attribute to two keys.
68
+ class AliasedMember < Lutaml::Model::Serializable
69
+ attribute :name, :string
70
+
71
+ key_value do
72
+ map %w[name product_name], to: :name
73
+ end
74
+ end
75
+
76
+ # Union holding the aliased member (plus a scalar catch-all).
77
+ class AliasedHolder < Lutaml::Model::Serializable
78
+ attribute :value, [AliasedMember, :string]
79
+
80
+ key_value do
81
+ map "value", to: :value
82
+ end
83
+ end
84
+
85
+ # Scalar union carrying enum + default constraints, for JSON Schema.
86
+ class ConstrainedReading < Lutaml::Model::Serializable
87
+ attribute :value, %i[integer string], values: [1, "one"], default: "one"
88
+
89
+ key_value do
90
+ map "value", to: :value
91
+ end
92
+ end
93
+
94
+ # Union with a :string member carrying a pattern — the pattern binds the
95
+ # string branch; a model member value is exempt.
96
+ class PatternedHolder < Lutaml::Model::Serializable
97
+ attribute :code, [Temperature, :string], pattern: /\A[A-Z]+\z/
98
+
99
+ key_value do
100
+ map "code", to: :code
101
+ end
102
+ end
103
+
104
+ # Union collection carrying a pattern — each string element is checked;
105
+ # non-string (integer) elements are exempt.
106
+ class PatternedList < Lutaml::Model::Serializable
107
+ attribute :codes, %i[integer string], collection: true,
108
+ pattern: /\A[A-Z]+\z/
109
+
110
+ key_value do
111
+ map "codes", to: :codes
112
+ end
113
+ end
114
+
115
+ # Scalar union, no catch-all — no-match yields nil (library default).
116
+ class OptionalReading < Lutaml::Model::Serializable
117
+ attribute :value, %i[integer float]
118
+
119
+ key_value do
120
+ map "value", to: :value
121
+ end
122
+ end
123
+
124
+ # Same, but required — no-match -> nil -> standard RequiredAttributeMissingError.
125
+ class RequiredReading < Lutaml::Model::Serializable
126
+ attribute :value, %i[integer float], required: true
127
+
128
+ key_value do
129
+ map "value", to: :value
130
+ end
131
+ end
132
+
133
+ # Collection of union values (heterogeneous).
134
+ class ReadingSet < Lutaml::Model::Serializable
135
+ attribute :readings,
136
+ [TemperatureWithUnit, :string], collection: true
137
+
138
+ key_value do
139
+ map "readings", to: :readings
140
+ end
141
+ end
142
+
143
+ # Scalar union collection — for JSON Schema array-wrapping.
144
+ class IntStringList < Lutaml::Model::Serializable
145
+ attribute :vals, %i[integer string], collection: true
146
+
147
+ key_value do
148
+ map "vals", to: :vals
149
+ end
150
+ end
151
+
152
+ # :decimal member — native BigDecimal + decimal string.
153
+ class DecimalReading < Lutaml::Model::Serializable
154
+ attribute :value, %i[decimal string]
155
+
156
+ key_value do
157
+ map "value", to: :value
158
+ end
159
+ end
160
+
161
+ # Simple-content model (text content + attribute) union member.
162
+ class Amount < Lutaml::Model::Serializable
163
+ attribute :value, :string
164
+ attribute :unit, :string
165
+
166
+ xml do
167
+ map_content to: :value
168
+ map_attribute "unit", to: :unit
169
+ end
170
+
171
+ key_value do
172
+ map "value", to: :value
173
+ map "unit", to: :unit
174
+ end
175
+ end
176
+
177
+ class Measurement < Lutaml::Model::Serializable
178
+ attribute :amount, [Amount, :string]
179
+
180
+ xml do
181
+ element "measurement"
182
+ map_element "amount", to: :amount
183
+ end
184
+ end
185
+
186
+ # Class-based transformer that builds the union's model member itself —
187
+ # key_value transforms apply `transform:` BEFORE the cast, so the cast
188
+ # receives an already-constructed member instance.
189
+ class TemperatureTransformer < Lutaml::Model::ValueTransformer
190
+ def from_yaml(value)
191
+ return value unless value.is_a?(::Hash)
192
+
193
+ TemperatureWithUnit.new(value.transform_keys(&:to_sym))
194
+ end
195
+ end
196
+
197
+ class TransformedReading < Lutaml::Model::Serializable
198
+ attribute :temp, [TemperatureWithUnit, :string]
199
+
200
+ key_value do
201
+ map "temp", to: :temp, transform: TemperatureTransformer
202
+ end
203
+ end
204
+
205
+ # XML collection mixing structured members and castable scalar text.
206
+ class TemperatureLog < Lutaml::Model::Serializable
207
+ attribute :entries, [TemperatureWithUnit, :integer],
208
+ collection: true
209
+
210
+ xml do
211
+ element "log"
212
+ map_element "entry", to: :entries
213
+ end
214
+ end
215
+ end
216
+
217
+ RSpec.describe "Union-typed attributes (issue #190)" do
218
+ describe "deserialization — member resolution by shape" do
219
+ it "resolves a scalar string to the :string member" do
220
+ ceramic = UnionAttributeSpec::Ceramic.from_yaml(
221
+ "firing_temperature: Very Hot",
222
+ )
223
+ expect(ceramic.firing_temperature).to eq("Very Hot")
224
+ end
225
+
226
+ it "resolves a structured value to the covering model member (number+unit)" do
227
+ ceramic = UnionAttributeSpec::Ceramic.from_yaml(<<~YAML)
228
+ firing_temperature:
229
+ number: 1300.0
230
+ unit: C
231
+ YAML
232
+ expect(ceramic.firing_temperature)
233
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
234
+ expect(ceramic.firing_temperature.number).to eq(1300.0)
235
+ expect(ceramic.firing_temperature.unit).to eq("C")
236
+ end
237
+
238
+ it "resolves a text-only XML element with an unrelated attribute to the string member" do
239
+ ceramic = UnionAttributeSpec::Ceramic.from_xml(
240
+ '<ceramic><FiringTemperature lang="en">Very Hot</FiringTemperature></ceramic>',
241
+ )
242
+ expect(ceramic.firing_temperature).to eq("Very Hot")
243
+ end
244
+
245
+ it "resolves a simple-content (map_content + map_attribute) model member in XML" do
246
+ m = UnionAttributeSpec::Measurement.from_xml(
247
+ '<measurement><amount unit="kg">5</amount></measurement>',
248
+ )
249
+ expect(m.amount).to be_a(UnionAttributeSpec::Amount)
250
+ expect(m.amount.value).to eq("5")
251
+ expect(m.amount.unit).to eq("kg")
252
+ end
253
+
254
+ it "resolves a structured value with a disjoint key to the other model member" do
255
+ ceramic = UnionAttributeSpec::Ceramic.from_yaml(<<~YAML)
256
+ firing_temperature:
257
+ celsius: 1200.0
258
+ YAML
259
+ expect(ceramic.firing_temperature)
260
+ .to be_a(UnionAttributeSpec::Temperature)
261
+ expect(ceramic.firing_temperature.celsius).to eq(1200.0)
262
+ end
263
+ end
264
+
265
+ describe "round-trip fidelity (deserialized)" do
266
+ %i[yaml json].each do |format|
267
+ it "round-trips the scalar case through #{format}" do
268
+ ceramic = UnionAttributeSpec::Ceramic.from_yaml(
269
+ "firing_temperature: Very Hot",
270
+ )
271
+ reparsed = UnionAttributeSpec::Ceramic.public_send(
272
+ :"from_#{format}", ceramic.public_send(:"to_#{format}")
273
+ )
274
+ expect(reparsed.firing_temperature).to eq("Very Hot")
275
+ end
276
+
277
+ it "round-trips the structured case through #{format}" do
278
+ ceramic = UnionAttributeSpec::Ceramic.from_yaml(<<~YAML)
279
+ firing_temperature:
280
+ number: 1300.0
281
+ unit: C
282
+ YAML
283
+ reparsed = UnionAttributeSpec::Ceramic.public_send(
284
+ :"from_#{format}", ceramic.public_send(:"to_#{format}")
285
+ )
286
+ expect(reparsed.firing_temperature)
287
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
288
+ expect(reparsed.firing_temperature.number).to eq(1300.0)
289
+ end
290
+ end
291
+
292
+ it "round-trips both XML cases (text-only vs child elements)" do
293
+ scalar = UnionAttributeSpec::Ceramic.from_xml(
294
+ "<ceramic><FiringTemperature>Very Hot</FiringTemperature></ceramic>",
295
+ )
296
+ expect(scalar.firing_temperature).to eq("Very Hot")
297
+ expect(UnionAttributeSpec::Ceramic.from_xml(scalar.to_xml)
298
+ .firing_temperature).to eq("Very Hot")
299
+
300
+ structured = UnionAttributeSpec::Ceramic.from_xml(<<~XML)
301
+ <ceramic><FiringTemperature><number>1300.0</number><unit>C</unit></FiringTemperature></ceramic>
302
+ XML
303
+ expect(structured.firing_temperature)
304
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
305
+ reparsed = UnionAttributeSpec::Ceramic.from_xml(structured.to_xml)
306
+ expect(reparsed.firing_temperature.unit).to eq("C")
307
+ end
308
+ end
309
+
310
+ describe "plain-Ruby construction (stateless serialization — the v1 bug class)" do
311
+ it "serializes a model member built in plain Ruby identically to a deserialized one" do
312
+ built = UnionAttributeSpec::Ceramic.new(
313
+ firing_temperature:
314
+ UnionAttributeSpec::TemperatureWithUnit.new(number: 1300.0, unit: "C"),
315
+ )
316
+ round = UnionAttributeSpec::Ceramic.from_yaml(built.to_yaml)
317
+ expect(round.firing_temperature)
318
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
319
+ expect(round.firing_temperature.number).to eq(1300.0)
320
+ end
321
+
322
+ it "does NOT double-encode a plain-Ruby scalar member" do
323
+ built = UnionAttributeSpec::Ceramic.new(firing_temperature: "Very Hot")
324
+ expect(JSON.parse(built.to_json)["firing_temperature"]).to eq("Very Hot")
325
+ end
326
+
327
+ it "does not raise serializing a plain-Ruby scalar to TOML" do
328
+ built = UnionAttributeSpec::Ceramic.new(firing_temperature: "Very Hot")
329
+ expect { built.to_toml }.not_to raise_error
330
+ end
331
+
332
+ it "builds a model member from a plain-Ruby hash (attribute-name keys)" do
333
+ built = UnionAttributeSpec::Ceramic.new(
334
+ firing_temperature: { number: 1300.0, unit: "C" },
335
+ )
336
+ expect(built.firing_temperature)
337
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
338
+ expect(built.firing_temperature.number).to eq(1300.0)
339
+ end
340
+ end
341
+
342
+ describe "first-conforming-in-declared-order (xsd:union)" do
343
+ it "prefers the earlier member when several could match" do
344
+ # "42" conforms to Integer's lexical space, so Integer wins over :string.
345
+ expect(UnionAttributeSpec::Reading.from_yaml("value: '42'").value).to eq(42)
346
+ end
347
+
348
+ it "falls through to the catch-all when earlier members reject the value" do
349
+ expect(UnionAttributeSpec::Reading.from_yaml("value: hello").value)
350
+ .to eq("hello")
351
+ end
352
+ end
353
+
354
+ describe "no-match handling (lenient by default, like every cast)" do
355
+ it "yields nil when no member conforms" do
356
+ reading = UnionAttributeSpec::OptionalReading.new(value: "not a number")
357
+ expect(reading.value).to be_nil
358
+ end
359
+
360
+ it "raises the standard RequiredAttributeMissingError when required and nothing conforms" do
361
+ reading = UnionAttributeSpec::RequiredReading.new(value: "not a number")
362
+ # `validate!` aggregates per-attribute errors into a ValidationError (the
363
+ # library's universal contract for every required attribute, union or not);
364
+ # the underlying error is the standard RequiredAttributeMissingError.
365
+ expect { reading.validate! }.to raise_error(
366
+ Lutaml::Model::ValidationError,
367
+ ) { |error| expect(error).to include(Lutaml::Model::RequiredAttributeMissingError) }
368
+ end
369
+
370
+ it "never trips on nil" do
371
+ expect { UnionAttributeSpec::OptionalReading.new(value: nil) }
372
+ .not_to raise_error
373
+ end
374
+ end
375
+
376
+ describe "definition-time member validation" do
377
+ it "raises on an empty member list (attribute :x, [])" do
378
+ expect do
379
+ Class.new(Lutaml::Model::Serializable) do
380
+ attribute :empty_union, []
381
+ end
382
+ end.to raise_error(ArgumentError)
383
+ end
384
+
385
+ it "raises when the array contains no valid member type" do
386
+ expect do
387
+ Class.new(Lutaml::Model::Serializable) do
388
+ attribute :bad_union, [Object]
389
+ end
390
+ end.to raise_error(ArgumentError)
391
+ end
392
+
393
+ it "raises when ANY member is invalid (not only when all are)" do
394
+ expect do
395
+ Class.new(Lutaml::Model::Serializable) do
396
+ attribute :mixed_union, [UnionAttributeSpec::Temperature, Object]
397
+ end
398
+ end.to raise_error(ArgumentError)
399
+ end
400
+
401
+ it "raises when a catch-all :string is not the last union member" do
402
+ expect do
403
+ Class.new(Lutaml::Model::Serializable) do
404
+ attribute :bad_order, %i[string integer]
405
+ end
406
+ end.to raise_error(ArgumentError)
407
+ end
408
+
409
+ it "raises when a catch-all :string precedes a model member" do
410
+ expect do
411
+ Class.new(Lutaml::Model::Serializable) do
412
+ attribute :bad_order,
413
+ [:string, UnionAttributeSpec::Temperature]
414
+ end
415
+ end.to raise_error(ArgumentError, /last union member/)
416
+ end
417
+
418
+ it "raises clearly when a member is an option hash (e.g. { ref: ... })" do
419
+ expect do
420
+ Class.new(Lutaml::Model::Serializable) do
421
+ attribute :ref_union, [{ ref: %w[Target id] }, :string]
422
+ end
423
+ end.to raise_error(ArgumentError, /not supported as union members/)
424
+ end
425
+
426
+ it "rejects unsupported scalar member types at definition time" do
427
+ %i[hash symbol time time_without_date date date_time].each do |unsupported|
428
+ expect do
429
+ Class.new(Lutaml::Model::Serializable) do
430
+ attribute :bad, [unsupported, :string]
431
+ end
432
+ end.to raise_error(ArgumentError, /unsupported union member/),
433
+ "expected #{unsupported.inspect} to be rejected"
434
+ end
435
+ end
436
+ end
437
+
438
+ describe "decimal and numeric scalar members" do
439
+ it "accepts a native BigDecimal for a :decimal member" do
440
+ require "bigdecimal"
441
+ reading = UnionAttributeSpec::DecimalReading.new(value: BigDecimal("1.5"))
442
+ expect(reading.value).to eq(BigDecimal("1.5"))
443
+ end
444
+
445
+ it "resolves a decimal string to the :decimal member" do
446
+ reading = UnionAttributeSpec::DecimalReading.from_yaml("value: '1.5'")
447
+ expect(reading.value).to eq(BigDecimal("1.5"))
448
+ end
449
+
450
+ it "serializes a scalar decimal through its type, not as a raw Ruby object" do
451
+ yaml = UnionAttributeSpec::DecimalReading.new(value: BigDecimal("1.5")).to_yaml
452
+ expect(yaml).not_to include("ruby/object")
453
+ expect(UnionAttributeSpec::DecimalReading.from_yaml(yaml).value)
454
+ .to eq(BigDecimal("1.5"))
455
+ end
456
+
457
+ it "resolves XSD-style boolean literals (1/yes) to :boolean, else :string" do
458
+ klass = Class.new(Lutaml::Model::Serializable) do
459
+ attribute :value, %i[boolean string]
460
+ key_value { map "value", to: :value }
461
+ end
462
+ expect(klass.from_yaml("value: '1'").value).to be(true)
463
+ expect(klass.from_yaml("value: 'maybe'").value).to eq("maybe")
464
+ end
465
+
466
+ it "accepts a native Integer for a :float member (lossless widening)" do
467
+ klass = Class.new(Lutaml::Model::Serializable) do
468
+ attribute :value, %i[float string]
469
+ key_value { map "value", to: :value }
470
+ end
471
+ value = klass.new(value: 42).value
472
+ expect(value).to be_a(Float)
473
+ expect(value).to eq(42.0)
474
+ end
475
+
476
+ it "accepts a native Integer for a :decimal member" do
477
+ require "bigdecimal"
478
+ klass = Class.new(Lutaml::Model::Serializable) do
479
+ attribute :value, %i[decimal string]
480
+ key_value { map "value", to: :value }
481
+ end
482
+ expect(klass.new(value: 42).value).to eq(BigDecimal(42))
483
+ end
484
+
485
+ it "rejects a lossy native numeric for :integer (3.7 falls through to :float)" do
486
+ klass = Class.new(Lutaml::Model::Serializable) do
487
+ attribute :value, %i[integer float]
488
+ key_value { map "value", to: :value }
489
+ end
490
+ value = klass.new(value: 3.7).value
491
+ expect(value).to be_a(Float)
492
+ expect(value).to eq(3.7)
493
+ end
494
+
495
+ it "keeps a whole-valued native Float a Float for :integer (3.0 stays 3.0)" do
496
+ klass = Class.new(Lutaml::Model::Serializable) do
497
+ attribute :value, %i[integer float]
498
+ key_value { map "value", to: :value }
499
+ end
500
+ obj = klass.new(value: 3.0)
501
+ expect(obj.value).to be_a(Float)
502
+ expect(obj.value).to eq(3.0)
503
+ expect(obj.to_json).to eq('{"value":3.0}')
504
+ end
505
+ end
506
+
507
+ describe "JSON Schema export" do
508
+ it "wraps a collection union as an array of anyOf" do
509
+ attr = UnionAttributeSpec::IntStringList.attributes[:vals]
510
+ schema = Lutaml::Model::Schema::Generator::Property.new(
511
+ :vals, attr, register: Lutaml::Model::Config.default_register
512
+ ).to_schema
513
+ expect(schema["vals"]["type"]).to eq("array")
514
+ expect(schema["vals"]["items"]).to have_key("anyOf")
515
+ end
516
+
517
+ it "exports an optional scalar union as anyOf including a null branch" do
518
+ attr = UnionAttributeSpec::Reading.attributes[:value]
519
+ schema = Lutaml::Model::Schema::Generator::Property.new(
520
+ :value, attr, register: Lutaml::Model::Config.default_register
521
+ ).to_schema
522
+ expect(schema["value"]).to have_key("anyOf")
523
+ expect(schema["value"]["anyOf"]).to include("type" => "null")
524
+ end
525
+
526
+ it "includes $defs entries for a union's model members (no dangling $ref)" do
527
+ schema = JSON.parse(
528
+ Lutaml::Model::Schema::JsonSchema.generate(UnionAttributeSpec::Ceramic),
529
+ )
530
+ defs = schema["$defs"] || schema["definitions"] || {}
531
+ refs = JSON.generate(schema)
532
+ .scan(%r{#/(?:\$defs|definitions)/([^"]+)}).flatten
533
+ expect(refs).not_to be_empty
534
+ expect(refs - defs.keys).to be_empty
535
+ end
536
+
537
+ it "carries enum and default constraints onto a union schema" do
538
+ attr = UnionAttributeSpec::ConstrainedReading.attributes[:value]
539
+ schema = Lutaml::Model::Schema::Generator::Property.new(
540
+ :value, attr, register: Lutaml::Model::Config.default_register
541
+ ).to_schema
542
+ expect(schema["value"]).to have_key("anyOf")
543
+ expect(schema["value"]["enum"]).to eq([1, "one"])
544
+ expect(schema["value"]["default"]).to eq("one")
545
+ end
546
+
547
+ # A :decimal (BigDecimal) serializes to JSON as a string (to preserve
548
+ # arbitrary precision), so its union schema branch is "string" — matching
549
+ # the actual output rather than the README's number classification.
550
+ it "maps a decimal union member to a string branch matching its output" do
551
+ attr = UnionAttributeSpec::DecimalReading.attributes[:value]
552
+ schema = Lutaml::Model::Schema::Generator::Property.new(
553
+ :value, attr, register: Lutaml::Model::Config.default_register
554
+ ).to_schema
555
+ types = schema["value"]["anyOf"].map { |member| member["type"] }
556
+ expect(types).to include("string")
557
+ json = UnionAttributeSpec::DecimalReading.new(value: BigDecimal("1.5"))
558
+ .to_json
559
+ expect(JSON.parse(json)["value"]).to be_a(String)
560
+ end
561
+ end
562
+
563
+ describe "pattern on a union with a :string member" do
564
+ it "accepts a string value matching the pattern" do
565
+ expect(UnionAttributeSpec::PatternedHolder.new(code: "ABC")
566
+ .validate).to be_empty
567
+ end
568
+
569
+ it "rejects a string value violating the pattern" do
570
+ expect(UnionAttributeSpec::PatternedHolder.new(code: "ab1")
571
+ .validate).not_to be_empty
572
+ end
573
+
574
+ it "exempts a non-string member value from the pattern" do
575
+ temp = UnionAttributeSpec::Temperature.new(celsius: 12.0)
576
+ expect(UnionAttributeSpec::PatternedHolder.new(code: temp)
577
+ .validate).to be_empty
578
+ end
579
+
580
+ it "checks each string element of a union collection" do
581
+ expect(UnionAttributeSpec::PatternedList.new(codes: %w[ABC DEF])
582
+ .validate).to be_empty
583
+ expect(UnionAttributeSpec::PatternedList.new(codes: ["ABC", "bad"])
584
+ .validate).not_to be_empty
585
+ end
586
+
587
+ it "exempts non-string elements of a union collection" do
588
+ expect(UnionAttributeSpec::PatternedList.new(codes: [1, "ABC"])
589
+ .validate).to be_empty
590
+ expect(UnionAttributeSpec::PatternedList.new(codes: [1, "bad"])
591
+ .validate).not_to be_empty
592
+ end
593
+ end
594
+
595
+ describe "model member with aliased serialization keys" do
596
+ it "resolves the member through its aliased key" do
597
+ holder = UnionAttributeSpec::AliasedHolder.from_yaml(<<~YAML)
598
+ value:
599
+ product_name: Vase
600
+ YAML
601
+ expect(holder.value).to be_a(UnionAttributeSpec::AliasedMember)
602
+ expect(holder.value.name).to eq("Vase")
603
+ end
604
+
605
+ it "resolves the member through its primary key" do
606
+ holder = UnionAttributeSpec::AliasedHolder.from_yaml(<<~YAML)
607
+ value:
608
+ name: Vase
609
+ YAML
610
+ expect(holder.value).to be_a(UnionAttributeSpec::AliasedMember)
611
+ expect(holder.value.name).to eq("Vase")
612
+ end
613
+ end
614
+
615
+ describe "collections of union values" do
616
+ it "resolves each element independently (mixed model + scalar)" do
617
+ set = UnionAttributeSpec::ReadingSet.from_yaml(<<~YAML)
618
+ readings:
619
+ - number: 1300.0
620
+ unit: C
621
+ - Very Hot
622
+ YAML
623
+ expect(set.readings.first)
624
+ .to be_a(UnionAttributeSpec::TemperatureWithUnit)
625
+ expect(set.readings.last).to eq("Very Hot")
626
+ end
627
+
628
+ it "casts scalar elements in an XML collection mixing model members" do
629
+ log = UnionAttributeSpec::TemperatureLog.from_xml(<<~XML)
630
+ <log>
631
+ <entry>42</entry>
632
+ <entry><number>1300.0</number><unit>C</unit></entry>
633
+ <entry>7</entry>
634
+ </log>
635
+ XML
636
+ expect(log.entries.map(&:class))
637
+ .to eq([Integer, UnionAttributeSpec::TemperatureWithUnit, Integer])
638
+ expect(log.entries.first).to eq(42)
639
+
640
+ reparsed = UnionAttributeSpec::TemperatureLog.from_xml(log.to_xml)
641
+ expect(reparsed.entries.map(&:class)).to eq(log.entries.map(&:class))
642
+ expect(reparsed.entries.first).to eq(42)
643
+ expect(reparsed.entries[1].number).to eq(1300.0)
644
+ end
645
+ end
646
+
647
+ describe "value transformers (cast idempotence)" do
648
+ it "keeps a model member built by transform: before the cast runs" do
649
+ reading = UnionAttributeSpec::TransformedReading.from_yaml(<<~YAML)
650
+ temp:
651
+ number: 7.0
652
+ unit: C
653
+ YAML
654
+ expect(reading.temp).to be_a(UnionAttributeSpec::TemperatureWithUnit)
655
+ expect(reading.temp.number).to eq(7.0)
656
+ end
657
+ end
658
+ end