lutaml-model 0.8.95 → 0.8.96

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.
@@ -38,16 +38,84 @@ module Lutaml
38
38
  return nil if plan[:ordered] && instance.element_order.nil?
39
39
 
40
40
  doc = ::Leptris::XML::Document.create
41
- root = doc.create_element(plan[:tree][:name])
41
+ ns_ctx = Namespaces.new(plan)
42
+ return nil if ns_ctx.unspellable?
43
+
44
+ root = doc.create_element(
45
+ ns_ctx.spell(plan[:tree][:name], plan[:tree][:ns]),
46
+ )
42
47
  doc.root = root
48
+ ns_ctx.declare_on(root)
43
49
  if plan[:ordered]
44
50
  build_ordered(root, instance, plan, doc, register)
45
51
  else
46
- build(root, instance, plan, doc, register)
52
+ build(root, instance, plan, doc, register, ns_ctx)
47
53
  end
48
54
  doc.to_xml(indent: 2, no_decl: true)
49
55
  end
50
56
 
57
+ # Namespace spelling for namespaced plans (#847): every ns
58
+ # form resolves to a wire spelling (prefix:local, or local
59
+ # under a default xmlns), and every distinct (prefix, uri)
60
+ # pair is declared exactly once on the output root in
61
+ # first-encounter order — the #1558 contract on the DOM
62
+ # path. A nested plan's own tree ns overrides the inherited
63
+ # one; rows without an ns form inherit.
64
+ class Namespaces
65
+ attr_reader :unspellable
66
+
67
+ def initialize(plan)
68
+ @forms = {}
69
+ @declared = {}
70
+ @unspellable = false
71
+ collect(plan[:tree])
72
+ end
73
+
74
+ def unspellable?
75
+ @unspellable
76
+ end
77
+
78
+ def collect(tree)
79
+ register(tree[:ns])
80
+ (tree[:attributes] || []).each { |attr| register(attr[:ns]) }
81
+ (tree[:children] || []).each do |row|
82
+ register(row[:ns]) if row[:ns].is_a?(Hash)
83
+ collect(row[:plan]) if row[:kind] == :nested && row[:plan]
84
+ end
85
+ end
86
+
87
+ def register(ns_form)
88
+ return if ns_form.nil? || ns_form == :none || ns_form == :any
89
+
90
+ form = { prefix: ns_form[:prefix], uri: ns_form[:exact] }
91
+ @forms[ns_form[:exact]] ||= form
92
+ @unspellable = true if ns_form[:exact] && !ns_form.key?(:prefix)
93
+ end
94
+
95
+ # The wire spelling for a local name under this form.
96
+ def spell(local, ns_form = nil)
97
+ form = ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
98
+ prefix = form && form[:prefix]
99
+ prefix ? "#{prefix}:#{local}" : local
100
+ end
101
+
102
+ def form_for(ns_form)
103
+ return nil if ns_form.nil? || ns_form == :none
104
+
105
+ ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
106
+ end
107
+
108
+ def declare_on(root)
109
+ @forms.each_value do |form|
110
+ key = form[:uri]
111
+ next if key.nil? || @declared[key]
112
+
113
+ @declared[key] = true
114
+ root.add_namespace_definition(form[:prefix], form[:uri])
115
+ end
116
+ end
117
+ end
118
+
51
119
  # Whether the compiled plan is serialize-shaped. Ordered/mixed
52
120
  # models serialize through build_ordered from element_order;
53
121
  # plain models through plan rows.
@@ -63,9 +131,10 @@ module Lutaml
63
131
  Lutaml::Model::Config.default_register
64
132
  end
65
133
 
66
- def build(element, instance, plan, doc, reg = nil)
134
+ def build(element, instance, plan, doc, reg = nil, ns_ctx = nil,
135
+ inherited_ns = nil)
67
136
  reg ||= register
68
- write_attributes(element, instance, plan, reg)
137
+ write_attributes(element, instance, plan, reg, ns_ctx)
69
138
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
70
139
  value = value_of(instance, attr, delegate)
71
140
  next unless rule.render?(value, instance)
@@ -76,33 +145,46 @@ module Lutaml
76
145
  # several occurrences serializes one element per item,
77
146
  # as the interpretive writer does. A plain scalar skips
78
147
  # the Array() wrap — the common case.
148
+ row_ns = plan[:tree][:children]&.find do |r|
149
+ r[:name] == rule.name.to_s
150
+ end&.[](:ns) || inherited_ns
79
151
  if value.is_a?(::Array)
80
152
  value.each do |item|
81
- add_leaf(element, row_name(rule, spelling),
153
+ add_leaf(element,
154
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
82
155
  attr.serialize(item, :xml, reg), doc,
83
156
  attrs: rule.when_attribute)
84
157
  end
85
158
  else
86
- add_leaf(element, row_name(rule, spelling),
159
+ add_leaf(element,
160
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
87
161
  attr.serialize(value, :xml, reg), doc,
88
162
  attrs: rule.when_attribute)
89
163
  end
90
164
  when :collection_native, :collection_cb
165
+ row_ns = plan[:tree][:children]&.find do |r|
166
+ r[:name] == rule.name.to_s
167
+ end&.[](:ns) || inherited_ns
91
168
  Array(value).each do |item|
92
- add_leaf(element, rule.name.to_s,
169
+ add_leaf(element,
170
+ ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
93
171
  attr.serialize(item, :xml, reg), doc,
94
172
  attrs: rule.when_attribute)
95
173
  end
96
174
  when :nested, :ordered_deferred
97
175
  Array(value).each do |item|
98
176
  child_plan = PlanCompiler.compile(item.class, reg)
99
- child = element.create_child(child_plan[:tree][:name])
177
+ child_ns = child_plan[:tree][:ns] || inherited_ns
178
+ child = element.create_child(
179
+ ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
180
+ )
181
+ ns_ctx&.collect(child_plan[:tree])
100
182
  if child_plan[:ordered]
101
- return nil unless build_ordered(child, item, child_plan,
102
- doc, reg)
183
+ next unless build_ordered(child, item, child_plan,
184
+ doc, reg, ns_ctx, child_ns)
103
185
 
104
186
  else
105
- build(child, item, child_plan, doc, reg)
187
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
106
188
  end
107
189
  end
108
190
  when :raw
@@ -121,9 +203,11 @@ module Lutaml
121
203
  # the model are reflected; element_order text is the fallback.
122
204
  # PIs drop (interpretive parity). Returns nil when a nested
123
205
  # ordered child lacks element_order (caller falls back).
124
- def build_ordered(element, instance, plan, doc, reg = nil)
206
+ def build_ordered(element, instance, plan, doc, reg = nil,
207
+ ns_ctx = nil, _inherited_ns = nil)
125
208
  reg ||= register
126
- write_attributes(element, instance, plan, reg)
209
+ ns_ctx&.collect(plan[:tree])
210
+ write_attributes(element, instance, plan, reg, ns_ctx)
127
211
  rows_by_name = {}
128
212
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
129
213
  next unless rule.name
@@ -213,8 +297,16 @@ module Lutaml
213
297
 
214
298
  # Attributes in document order when the instance recorded it
215
299
  # (parsed models), else plan row order.
216
- def write_attributes(element, instance, plan, _reg = nil)
300
+ def write_attributes(element, instance, plan, reg = nil,
301
+ ns_ctx = nil)
217
302
  recorded = instance.attribute_order
303
+ attr_prefixes = {}
304
+ if ns_ctx
305
+ (plan[:tree][:attributes] || []).each do |a|
306
+ form = a[:ns].is_a?(Hash) ? a[:ns] : nil
307
+ attr_prefixes[a[:name]] = form && form[:prefix]
308
+ end
309
+ end
218
310
  if recorded && !recorded.empty?
219
311
  by_name = {}
220
312
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
@@ -223,24 +315,28 @@ module Lutaml
223
315
  names = recorded |
224
316
  plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
225
317
  names.each do |name|
226
- write_attribute(element, instance, name, by_name[name])
318
+ write_attribute(element, instance, name, by_name[name],
319
+ reg, attr_prefixes[name])
227
320
  end
228
321
  else
229
322
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
230
323
  write_attribute(element, instance, rule.name.to_s,
231
- [rule, attr, delegate])
324
+ [rule, attr, delegate], reg,
325
+ attr_prefixes[rule.name.to_s])
232
326
  end
233
327
  end
234
328
  end
235
329
 
236
- def write_attribute(element, instance, name, entry, reg = nil)
330
+ def write_attribute(element, instance, name, entry, reg = nil,
331
+ prefix = nil)
237
332
  return unless entry
238
333
 
239
334
  _rule, attr, delegate = entry
240
335
  value = value_of(instance, attr, delegate)
241
336
  return if value.nil?
242
337
 
243
- element.set_attribute(name,
338
+ spelled = prefix ? "#{prefix}:#{name}" : name
339
+ element.set_attribute(spelled,
244
340
  attr.serialize(value, :xml, reg || register).to_s)
245
341
  end
246
342
 
@@ -21,6 +21,18 @@ module Lutaml
21
21
  # type-namespaced attributes on the interpretive path.
22
22
  MIN_LEPTRIS_ATTR_NS_VERSION = "1.9.289.0"
23
23
 
24
+ # Nested child plans capture their declared attribute rows
25
+ # correctly again as of 1.9.313.0 (leptris#1563: the walk dropped
26
+ # children's attribute capture when the parent plan carried an
27
+ # exact-URI attribute row). Older gems keep those models
28
+ # interpretive.
29
+ MIN_LEPTRIS_NESTED_ATTR_VERSION = "1.9.313.0"
30
+
31
+ # Wildcard child rows (leptris#1552: named-rows-take-precedence
32
+ # two-pass walk, ns-form aware, type_tag echo) — the plan path's
33
+ # map_any_element catch-all. Ships with the 1.9.313 binding.
34
+ MIN_LEPTRIS_WILDCARD_VERSION = "1.9.313.0"
35
+
24
36
  def self.plan_path_compatible?
25
37
  return false unless defined?(Gem)
26
38
 
@@ -35,6 +47,20 @@ module Lutaml
35
47
  spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_ATTR_NS_VERSION)
36
48
  end
37
49
 
50
+ def self.nested_attr_capture_compatible?
51
+ return false unless defined?(Gem)
52
+
53
+ spec = Gem.loaded_specs["leptris"]
54
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NESTED_ATTR_VERSION)
55
+ end
56
+
57
+ def self.wildcard_rows_compatible?
58
+ return false unless defined?(Gem)
59
+
60
+ spec = Gem.loaded_specs["leptris"]
61
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
62
+ end
63
+
38
64
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
39
65
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
40
66
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
@@ -147,6 +147,23 @@ RSpec.describe "Opal compatibility", if: RUBY_ENGINE == "opal" do
147
147
  expect(instance.port).to eq(8080)
148
148
  end
149
149
 
150
+ # Opal has no TracePoint; a symbol import is resolved on first use.
151
+ it "resolves a deferred symbol import on first use" do
152
+ register = Lutaml::Model::GlobalRegister
153
+ .lookup(Lutaml::Model::Config.default_register)
154
+ importer = Class.new(Lutaml::Model::Serializable) do
155
+ import_model :opal_deferred_import
156
+ end
157
+ imported = Class.new(Lutaml::Model::Serializable) do
158
+ attribute :title, :string
159
+ end
160
+ register.register_model(imported, id: :opal_deferred_import)
161
+
162
+ instance = importer.from_json('{"title":"Deferred"}')
163
+ expect(instance.title).to eq("Deferred")
164
+ expect(instance.to_hash).to eq({ "title" => "Deferred" })
165
+ end
166
+
150
167
  it "RuntimeCompatibility detects Opal" do
151
168
  expect(Lutaml::Model::RuntimeCompatibility.opal?).to be true
152
169
  end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../../lib/lutaml/model"
5
+ require "lutaml/xml/adapter/nokogiri_adapter"
6
+
7
+ # Runtimes without a source compiler (Opal) define the attribute
8
+ # accessors and the state-defaults seed from blocks instead of a String
9
+ # class_eval. These specs force that block form on MRI and check it
10
+ # behaves exactly like the compiled form.
11
+ module AttributeDefinitionBlockFormSpec
12
+ module_function
13
+
14
+ def define_model(klass)
15
+ klass.class_eval do
16
+ attribute :name, :string
17
+ attribute :count, :integer
18
+ attribute :tags, :string, collection: true
19
+ # "char" is an enum shorthand, so the second :char definition
20
+ # redefines the setter but keeps the first Attribute's accessor.
21
+ attribute :align, :string, values: %w[char left]
22
+ attribute :char, :integer
23
+ attribute :char, :string
24
+ # String names, mutated once defined: the accessors must keep
25
+ # reading the ivar named at definition time.
26
+ label = +"label"
27
+ notes = +"notes"
28
+ attribute label, :integer
29
+ attribute notes, :string, collection: true
30
+ label << "x"
31
+ notes << "x"
32
+
33
+ # A model may define its own #send; the setters must not dispatch
34
+ # through it.
35
+ define_method(:send) { |*| "domain-method" }
36
+
37
+ xml do
38
+ element "item"
39
+ ordered
40
+ map_attribute "name", to: :name
41
+ map_element "count", to: :count
42
+ map_element "tag", to: :tags
43
+ map_element "char", to: :char
44
+ end
45
+
46
+ key_value do
47
+ map "name", to: :name
48
+ map "count", to: :count
49
+ map "tags", to: :tags
50
+ map "char", to: :char
51
+ end
52
+ end
53
+ klass
54
+ end
55
+
56
+ # A model overriding the core methods the block form might dispatch
57
+ # through: the accessors must behave as the compiled ones do.
58
+ def override_model(klass)
59
+ klass.class_eval do
60
+ attribute :count, :integer
61
+ attribute :tags, :string, collection: true
62
+ %i[__send__ instance_variable_get instance_variable_set].each do |m|
63
+ define_method(m) { |*| :overridden }
64
+ end
65
+ end
66
+ m = klass.new
67
+ m.count = "5"
68
+ m.tags = %w[a]
69
+ m.tags("b")
70
+ # The collection getter itself reads through the overridden method,
71
+ # so check what was stored.
72
+ stored = ::Kernel.instance_method(:instance_variable_get)
73
+ [stored.bind_call(m, :@count), stored.bind_call(m, :@tags)]
74
+ end
75
+
76
+ # A model whose scope defines its own Array: appends must resolve the
77
+ # constant where the compiled getter does.
78
+ # Checked on the defining class and on a subclass that shadows Array,
79
+ # with the defining class overriding const_get.
80
+ def array_scope_model(klass)
81
+ klass.const_set(:Array, Class.new(::Array))
82
+ klass.class_eval { attribute :tags, :string, collection: true }
83
+ klass.define_singleton_method(:const_get) { |*| raise "const_get called" }
84
+ sub = Class.new(klass)
85
+ sub.const_set(:Array, ::Array)
86
+ [klass, sub].map do |k|
87
+ m = k.new
88
+ m.tags = %w[a]
89
+ m.tags("b")
90
+ ::Kernel.instance_method(:instance_variable_get).bind_call(m, :@tags)
91
+ end
92
+ end
93
+
94
+ # Raw-state readers after the state defaults compiled for a smaller
95
+ # attribute set: an attribute imported later, and one declared later,
96
+ # must both get a reader (#933).
97
+ def late_raw_readers(klass)
98
+ klass.class_eval { attribute :name, :string }
99
+ klass.new
100
+ imported = Class.new(Lutaml::Model::Serializable) do
101
+ attribute :br, :string
102
+ end
103
+ klass.import_model_attributes(imported)
104
+ klass.class_eval { attribute :late, :string, collection: true }
105
+ m = klass.new
106
+ m.br = "b"
107
+ readers = %i[name br late].map do |a|
108
+ raw = m.public_send(:"__lutaml_raw_#{a}")
109
+ [a, Lutaml::Model::Utils.uninitialized?(raw), raw.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) || raw]
110
+ end
111
+ readers << register_choice_raw_reader
112
+ end
113
+
114
+ # A choice import into a non-default register merges attribute records
115
+ # without define_attribute_methods: only the state-defaults
116
+ # compilation gives the imported name its raw reader.
117
+ def register_choice_raw_reader
118
+ register = Lutaml::Model::Register.new(:raw_reader_block_form_spec)
119
+ Lutaml::Model::GlobalRegister.register(register)
120
+ imported = Class.new(Lutaml::Model::Serializable) do
121
+ attribute :br, :string, collection: true
122
+ end
123
+ klass = Class.new(Lutaml::Model::Serializable) do
124
+ attribute :name, :string
125
+ choice { import_model_attributes(imported, :raw_reader_block_form_spec) }
126
+ end
127
+ klass.allocate_for_deserialization(:raw_reader_block_form_spec).__lutaml_raw_br
128
+ .equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
129
+ ensure
130
+ Lutaml::Model::GlobalRegister.unregister(:raw_reader_block_form_spec)
131
+ end
132
+
133
+ def build_model
134
+ define_model(Class.new(Lutaml::Model::Serializable))
135
+ end
136
+
137
+ def element_order(model)
138
+ model.element_order.map { |e| [e.type, e.name, e.text_content] }
139
+ end
140
+
141
+ def arity_error(model, name)
142
+ model.public_send(name, "1", "2")
143
+ nil
144
+ rescue ArgumentError => e
145
+ e.message
146
+ end
147
+
148
+ def observe(klass)
149
+ fresh = klass.new
150
+ set = klass.new
151
+ set.name = "a"
152
+ set.count = "5"
153
+ set.tags("x")
154
+ set.tags("y")
155
+ set.char = "7"
156
+ # A parsed instance with no tags still holds the lazy sentinel.
157
+ emptied = klass.from_xml('<item name="z"/>')
158
+ sentinel_before_nil = emptied.instance_variable_get(:@tags)
159
+ .equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
160
+ emptied.tags = nil
161
+ xml = set.to_xml
162
+ parsed = klass.from_xml(xml)
163
+
164
+ seed = klass.__send__(:compiled_state_defaults_name!, :default)
165
+ seeded = klass.new.__send__(seed)
166
+
167
+ {
168
+ seed_return: [seeded.class, seeded.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)],
169
+ fresh_scalar_uninitialized: Lutaml::Model::Utils.uninitialized?(
170
+ fresh.instance_variable_get(:@name),
171
+ ),
172
+ fresh_collection_sentinel: fresh.instance_variable_get(:@tags)
173
+ .equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION),
174
+ fresh_readers: [fresh.name, fresh.count, fresh.tags],
175
+ set_readers: [set.name, set.count, set.tags, set.char],
176
+ set_using_default: %i[name count tags].map { |a| set.using_default?(a) },
177
+ sentinel_before_nil: sentinel_before_nil,
178
+ nil_keeps_sentinel: emptied.instance_variable_get(:@tags)
179
+ .equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION),
180
+ getter_with_arg: klass.new.tap { |m| m.count("3") }.count,
181
+ assign_parsed_skips_cast: klass.new.tap { |m| m.__assign_parsed_count = "3" }.count,
182
+ built_order: element_order(klass.new do |m|
183
+ m.count = "1"
184
+ m.tags = %w[p q]
185
+ m.tags("r")
186
+ end),
187
+ string_names: klass.new.tap do |m|
188
+ m.label = "4"
189
+ m.notes("n")
190
+ end.then { |m| [m.label, m.notes, m.instance_variables.grep(/label|notes/).sort] },
191
+ two_args: %i[count tags label notes].map { |a| arity_error(klass.new, a) },
192
+ xml: xml,
193
+ json: set.to_json,
194
+ parsed: [parsed.name, parsed.count, parsed.tags, parsed.char],
195
+ parsed_equal: parsed == set,
196
+ }
197
+ end
198
+ end
199
+
200
+ RSpec.describe Lutaml::Model::Serialize::AttributeDefinition do
201
+ let(:compiled) { AttributeDefinitionBlockFormSpec.observe(AttributeDefinitionBlockFormSpec.build_model) }
202
+
203
+ it "compiles accessors from source by default on MRI" do
204
+ expect(described_class.source_compilation?).to be(true)
205
+ end
206
+
207
+ it "defines the block form without String class_eval and matches the compiled form" do
208
+ expected = compiled
209
+ allow(described_class).to receive(:source_compilation?).and_return(false)
210
+ klass = Class.new(Lutaml::Model::Serializable)
211
+ allow(klass).to receive(:class_eval).and_wrap_original do |original, *args, &block|
212
+ raise "String class_eval used: #{args.first}" if args.first.is_a?(String)
213
+
214
+ original.call(*args, &block)
215
+ end
216
+ AttributeDefinitionBlockFormSpec.define_model(klass)
217
+
218
+ expect(AttributeDefinitionBlockFormSpec.observe(klass)).to eq(expected)
219
+ end
220
+
221
+ it "does not dispatch through a model's own core methods" do
222
+ expected = AttributeDefinitionBlockFormSpec.override_model(Class.new(Lutaml::Model::Serializable))
223
+ allow(described_class).to receive(:source_compilation?).and_return(false)
224
+ actual = AttributeDefinitionBlockFormSpec.override_model(Class.new(Lutaml::Model::Serializable))
225
+
226
+ expect(actual).to eq(expected)
227
+ end
228
+
229
+ it "resolves Array in the model's scope when appending" do
230
+ expected = AttributeDefinitionBlockFormSpec.array_scope_model(Class.new(Lutaml::Model::Serializable))
231
+ allow(described_class).to receive(:source_compilation?).and_return(false)
232
+ actual = AttributeDefinitionBlockFormSpec.array_scope_model(Class.new(Lutaml::Model::Serializable))
233
+
234
+ expect(actual).to eq(expected)
235
+ end
236
+
237
+ it "defines raw-state readers for imported and late attributes as the compiled form does" do
238
+ expected = AttributeDefinitionBlockFormSpec.late_raw_readers(Class.new(Lutaml::Model::Serializable))
239
+ allow(described_class).to receive(:source_compilation?).and_return(false)
240
+ actual = AttributeDefinitionBlockFormSpec.late_raw_readers(Class.new(Lutaml::Model::Serializable))
241
+
242
+ expect(actual).to eq(expected)
243
+ end
244
+ end
@@ -144,4 +144,51 @@ RSpec.describe "XML adapters under Opal", if: RUBY_ENGINE == "opal" do
144
144
  expect(Lutaml::Model::Config.xml_adapter_type).to eq(:rexml)
145
145
  end
146
146
  end
147
+
148
+ # Opal does not carry modules prepended into Serialize over to
149
+ # Serializable, which included it at boot; lib/lutaml/model.rb and
150
+ # lib/lutaml/xml/format.rb prepend onto Serializable directly.
151
+ describe "Serializable subclasses" do
152
+ before { Lutaml::Model::Config.xml_adapter_type = :oga }
153
+
154
+ it "builds an ordered model from a builder block" do
155
+ klass = Class.new(Lutaml::Model::Serializable) do
156
+ attribute :first, :string
157
+ attribute :second, :string
158
+ xml do
159
+ element "pair"
160
+ ordered
161
+ map_element "first", to: :first
162
+ map_element "second", to: :second
163
+ end
164
+ end
165
+
166
+ instance = klass.new do
167
+ second "b"
168
+ first "a"
169
+ end
170
+
171
+ expect([instance.first, instance.second]).to eq(%w[a b])
172
+ expect(instance.element_order.map(&:name)).to eq(%w[second first])
173
+ expect(instance.to_xml)
174
+ .to include("<pair><second>b</second><first>a</first></pair>")
175
+ end
176
+
177
+ it "installs the XML accessors on a plain model class" do
178
+ plain = Class.new { attr_accessor :name }
179
+ mapper = Class.new(Lutaml::Model::Serializable) do
180
+ model plain
181
+ attribute :name, :string
182
+ xml do
183
+ element "person"
184
+ map_element "name", to: :name
185
+ end
186
+ end
187
+
188
+ expect(plain.method_defined?(:encoding=)).to be true
189
+ parsed = mapper.from_xml("<person><name>Alice</name></person>")
190
+ expect(parsed).to be_a(plain)
191
+ expect(parsed.name).to eq("Alice")
192
+ end
193
+ end
147
194
  end