lutaml-model 0.8.18 → 0.9.1

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.

Potentially problematic release.


This version of lutaml-model might be problematic. Click here for more details.

Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +1 -1
  3. data/.github/workflows/rake.yml +1 -0
  4. data/.rubocop_todo.yml +133 -72
  5. data/bench/bench_common.rb +6 -3
  6. data/bench/bench_niso.rb +1 -2
  7. data/docs/_guides/builder-dsl.adoc +127 -0
  8. data/lib/compat/opal/generate_boot.rb +3 -1
  9. data/lib/lutaml/key_value/transform.rb +4 -1
  10. data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
  11. data/lib/lutaml/model/attribute.rb +23 -2
  12. data/lib/lutaml/model/global_context.rb +4 -2
  13. data/lib/lutaml/model/schema/class_loader.rb +2 -1
  14. data/lib/lutaml/model/schema/definitions/namespace.rb +2 -1
  15. data/lib/lutaml/model/schema/file_writer.rb +5 -2
  16. data/lib/lutaml/model/schema/renderers/base.rb +2 -1
  17. data/lib/lutaml/model/schema/renderers/mappings.rb +9 -3
  18. data/lib/lutaml/model/schema/renderers/member_decls.rb +9 -3
  19. data/lib/lutaml/model/schema/renderers/model.rb +16 -4
  20. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +2 -1
  21. data/lib/lutaml/model/schema/renderers.rb +2 -1
  22. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +6 -2
  23. data/lib/lutaml/model/schema/rnc_compiler.rb +3 -1
  24. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +35 -15
  25. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +4 -2
  26. data/lib/lutaml/model/schema/rng_compiler.rb +10 -5
  27. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +2 -1
  28. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +4 -2
  29. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +39 -13
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +29 -10
  31. data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +10 -9
  33. data/lib/lutaml/model/serialize/builder.rb +55 -0
  34. data/lib/lutaml/model/serialize/initialization.rb +12 -3
  35. data/lib/lutaml/model/serialize/model_import.rb +1 -0
  36. data/lib/lutaml/model/serialize.rb +5 -4
  37. data/lib/lutaml/model/union.rb +5 -2
  38. data/lib/lutaml/model/version.rb +1 -1
  39. data/lib/lutaml/xml/mapping_rule.rb +55 -1
  40. data/lib/lutaml/xml/model_transform.rb +53 -5
  41. data/lib/lutaml/xml/schema/xsd.rb +0 -2
  42. data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
  43. data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
  44. data/lib/lutaml/xml/transformation/ordered_applier.rb +99 -4
  45. data/lutaml-model.gemspec +4 -1
  46. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +26 -13
  47. data/spec/lutaml/model/attribute_spec.rb +8 -3
  48. data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
  49. data/spec/lutaml/model/defaults_spec.rb +45 -0
  50. data/spec/lutaml/model/json_spec.rb +1 -2
  51. data/spec/lutaml/model/ordered_content_spec.rb +18 -7
  52. data/spec/lutaml/model/serialize/builder_spec.rb +361 -0
  53. data/spec/lutaml/model/transform_apply_value_map_spec.rb +6 -3
  54. data/spec/lutaml/model/union_attribute_spec.rb +4 -2
  55. data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
  56. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +17 -6
  57. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +15 -6
  58. data/spec/lutaml/xml/w3c_types_spec.rb +3 -6
  59. metadata +7 -3
@@ -18,7 +18,8 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
18
18
 
19
19
  it "maps nil through value_map[:nil] => :empty to [] (collection)" do
20
20
  expect(
21
- attribute(:string, collection: true).apply_value_map(nil, { nil: :empty }),
21
+ attribute(:string, collection: true).apply_value_map(nil,
22
+ { nil: :empty }),
22
23
  ).to eq([])
23
24
  end
24
25
 
@@ -34,13 +35,15 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
34
35
  custom_collection_class = Class.new(Lutaml::Model::Collection) do
35
36
  instances :items, member_class
36
37
  end
37
- attr = described_class.new("items", member_class, collection: custom_collection_class)
38
+ attr = described_class.new("items", member_class,
39
+ collection: custom_collection_class)
38
40
  result = attr.apply_value_map(nil, { nil: :empty })
39
41
  expect(result).to be_a(custom_collection_class)
40
42
  end
41
43
 
42
44
  it "maps nil through value_map[:nil] => :omitted to UninitializedClass" do
43
- expect(attribute(:string).apply_value_map(nil, { nil: :omitted })).to eq(uninit)
45
+ expect(attribute(:string).apply_value_map(nil,
46
+ { nil: :omitted })).to eq(uninit)
44
47
  end
45
48
  end
46
49
 
@@ -50,12 +53,14 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
50
53
  end
51
54
 
52
55
  it "maps '' through value_map[:empty] => :empty preserving '' for scalar" do
53
- expect(attribute(:string).apply_value_map("", { empty: :empty })).to eq("")
56
+ expect(attribute(:string).apply_value_map("",
57
+ { empty: :empty })).to eq("")
54
58
  end
55
59
 
56
60
  it "maps [] through value_map[:empty] => :empty preserving [] for collection" do
57
61
  expect(
58
- attribute(:string, collection: true).apply_value_map([], { empty: :empty }),
62
+ attribute(:string, collection: true).apply_value_map([],
63
+ { empty: :empty }),
59
64
  ).to eq([])
60
65
  end
61
66
 
@@ -69,11 +74,13 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
69
74
 
70
75
  describe "omitted handling" do
71
76
  it "maps UninitializedClass through value_map[:omitted] => :nil" do
72
- expect(attribute(:string).apply_value_map(uninit, { omitted: :nil })).to be_nil
77
+ expect(attribute(:string).apply_value_map(uninit,
78
+ { omitted: :nil })).to be_nil
73
79
  end
74
80
 
75
81
  it "maps UninitializedClass through value_map[:omitted] => :empty" do
76
- expect(attribute(:string).apply_value_map(uninit, { omitted: :empty })).to eq("")
82
+ expect(attribute(:string).apply_value_map(uninit,
83
+ { omitted: :empty })).to eq("")
77
84
  end
78
85
 
79
86
  it "does NOT route UninitializedClass through the :empty branch" do
@@ -81,18 +88,21 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
81
88
  # utils.rb:132-137. Ensure dispatch order matches the originals: uninit
82
89
  # values go to :omitted, not :empty.
83
90
  expect(
84
- attribute(:string).apply_value_map(uninit, { empty: :nil, omitted: :empty }),
91
+ attribute(:string).apply_value_map(uninit,
92
+ { empty: :nil, omitted: :empty }),
85
93
  ).to eq("")
86
94
  end
87
95
  end
88
96
 
89
97
  describe "Boolean bare-form (Boolean attribute type required)" do
90
98
  it "returns false directly for Boolean attribute, value_map[:empty] = false" do
91
- expect(attribute(:boolean).apply_value_map("", { empty: false })).to be(false)
99
+ expect(attribute(:boolean).apply_value_map("",
100
+ { empty: false })).to be(false)
92
101
  end
93
102
 
94
103
  it "returns true directly for Boolean attribute, value_map[:omitted] = true" do
95
- expect(attribute(:boolean).apply_value_map(uninit, { omitted: true })).to be(true)
104
+ expect(attribute(:boolean).apply_value_map(uninit,
105
+ { omitted: true })).to be(true)
96
106
  end
97
107
 
98
108
  it "does NOT apply bare-boolean form to a non-Boolean attribute (documented tightening)" do
@@ -106,15 +116,18 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
106
116
 
107
117
  describe "Boolean nested-form (value_map[:from][:empty] / [:omitted])" do
108
118
  it "returns false from value_map[:from][:empty] unconditionally for Boolean attribute" do
109
- expect(attribute(:boolean).apply_value_map("", { from: { empty: false } })).to be(false)
119
+ expect(attribute(:boolean).apply_value_map("",
120
+ { from: { empty: false } })).to be(false)
110
121
  end
111
122
 
112
123
  it "returns true from value_map[:from][:omitted] unconditionally for Boolean attribute" do
113
- expect(attribute(:boolean).apply_value_map(uninit, { from: { omitted: true } })).to be(true)
124
+ expect(attribute(:boolean).apply_value_map(uninit,
125
+ { from: { omitted: true } })).to be(true)
114
126
  end
115
127
 
116
128
  it "returns false from nested form even for non-Boolean attribute (no type gate)" do
117
- expect(attribute(:string).apply_value_map("", { from: { empty: false } })).to be(false)
129
+ expect(attribute(:string).apply_value_map("",
130
+ { from: { empty: false } })).to be(false)
118
131
  end
119
132
  end
120
133
 
@@ -358,7 +358,8 @@ RSpec.describe Lutaml::Model::Attribute do
358
358
  it "executes the proc in the instance context (reads instance attribute)" do
359
359
  instance = model_class.new(name: "Alice")
360
360
  attribute = model_class.attributes[:greeting]
361
- expect(attribute.default_value(register, instance)).to eq("Hello, Alice")
361
+ expect(attribute.default_value(register,
362
+ instance)).to eq("Hello, Alice")
362
363
  end
363
364
  end
364
365
  end
@@ -413,7 +414,9 @@ RSpec.describe Lutaml::Model::Attribute do
413
414
  end
414
415
  instance = model_class.new(prefix: "Dr")
415
416
  attr = model_class.attributes[:name]
416
- expect { attr.validate_value!(nil, register, instance_object: instance) }
417
+ expect do
418
+ attr.validate_value!(nil, register, instance_object: instance)
419
+ end
417
420
  .to raise_error(Lutaml::Model::InvalidValueError) do |error|
418
421
  # The error carries the value that was used for validation.
419
422
  # If instance_exec ran, value is "Dr_default"; if not, error
@@ -427,7 +430,9 @@ RSpec.describe Lutaml::Model::Attribute do
427
430
  it "short-circuits without raising" do
428
431
  uninit = Lutaml::Model::UninitializedClass.instance
429
432
  attr = described_class.new("name", :string)
430
- expect { attr.validate_value!(uninit, register, instance_object: model) }
433
+ expect do
434
+ attr.validate_value!(uninit, register, instance_object: model)
435
+ end
431
436
  .not_to raise_error
432
437
  end
433
438
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#746: an empty value ("", [], {}) is a PRESENT value per
6
+ # docs/_guides/missing-values-handling.adoc and must reach custom `from`
7
+ # methods. Only nil (non-existent) and an omitted key (undefined) skip them.
8
+ module CustomFromEmptyValuesSpec
9
+ class Tagged < Lutaml::Model::Serializable
10
+ attribute :received, :string
11
+
12
+ key_value do
13
+ map "tags", to: :received, with: { to: :tags_to, from: :tags_from }
14
+ end
15
+
16
+ def tags_to(model, doc)
17
+ doc["tags"] = model.received
18
+ end
19
+
20
+ def tags_from(model, value)
21
+ model.received = value.inspect
22
+ end
23
+ end
24
+
25
+ class Mapped < Lutaml::Model::Serializable
26
+ attribute :received, :string
27
+
28
+ key_value do
29
+ map "fields", to: :received, with: { to: :fields_to, from: :fields_from }
30
+ end
31
+
32
+ def fields_to(model, doc)
33
+ doc["fields"] = model.received
34
+ end
35
+
36
+ def fields_from(model, value)
37
+ model.received = value.inspect
38
+ end
39
+ end
40
+ end
41
+
42
+ RSpec.describe "custom from methods and empty values" do
43
+ it "receives an empty collection" do
44
+ expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("tags: []").received)
45
+ .to eq("[]")
46
+ end
47
+
48
+ it "receives an empty string" do
49
+ expect(CustomFromEmptyValuesSpec::Tagged.from_yaml('tags: ""').received)
50
+ .to eq("\"\"")
51
+ end
52
+
53
+ it "receives an empty mapping" do
54
+ expect(CustomFromEmptyValuesSpec::Mapped.from_yaml("fields: {}").received)
55
+ .to eq("{}")
56
+ end
57
+
58
+ it "receives empty values from JSON too" do
59
+ expect(CustomFromEmptyValuesSpec::Tagged.from_json(%({"tags": []})).received)
60
+ .to eq("[]")
61
+ expect(CustomFromEmptyValuesSpec::Tagged.from_json(%({"tags": ""})).received)
62
+ .to eq("\"\"")
63
+ end
64
+
65
+ it "still skips the method for a nil value" do
66
+ expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("tags:").received)
67
+ .to be_nil
68
+ end
69
+
70
+ it "still skips the method for an omitted key" do
71
+ expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("{}").received)
72
+ .to be_nil
73
+ end
74
+ end
@@ -146,6 +146,33 @@ module DefaultsSpec
146
146
 
147
147
  class InstanceAwareGrandChild < InstanceAwareChild
148
148
  end
149
+
150
+ # Class-method defaults must keep resolving as they did before 0.8.0
151
+ # (lutaml-model#745): default lambdas are evaluated against the instance,
152
+ # with the model class as fallback for methods the instance does not have.
153
+ class ClassMethodDefault < Lutaml::Model::Serializable
154
+ attribute :os, :string, default: -> { detect_os }
155
+
156
+ def self.detect_os
157
+ "from-class-method"
158
+ end
159
+ end
160
+
161
+ class ClassMethodDefaultWithArgs < Lutaml::Model::Serializable
162
+ attribute :label, :string, default: -> { build_label("x") }
163
+
164
+ def self.build_label(prefix)
165
+ "#{prefix}-label"
166
+ end
167
+ end
168
+
169
+ class InstanceMethodDefault < Lutaml::Model::Serializable
170
+ attribute :value, :string, default: -> { instance_helper }
171
+
172
+ def instance_helper
173
+ "from-instance-method"
174
+ end
175
+ end
149
176
  end
150
177
 
151
178
  RSpec.describe DefaultsSpec::Glaze do
@@ -346,3 +373,21 @@ RSpec.describe DefaultsSpec::Glaze do
346
373
  end
347
374
  end
348
375
  end
376
+
377
+ RSpec.describe DefaultsSpec::ClassMethodDefault do
378
+ it "resolves class methods from default lambdas" do
379
+ expect(described_class.new.os).to eq("from-class-method")
380
+ end
381
+ end
382
+
383
+ RSpec.describe DefaultsSpec::ClassMethodDefaultWithArgs do
384
+ it "passes arguments through to class methods" do
385
+ expect(described_class.new.label).to eq("x-label")
386
+ end
387
+ end
388
+
389
+ RSpec.describe DefaultsSpec::InstanceMethodDefault do
390
+ it "still resolves instance methods in default lambdas" do
391
+ expect(described_class.new.value).to eq("from-instance-method")
392
+ end
393
+ end
@@ -8,9 +8,8 @@ RSpec.describe Lutaml::Model::Json do
8
8
  let(:lib_path) { File.expand_path("../../../lib", __dir__) }
9
9
 
10
10
  it "loads require 'lutaml/json' without preloading lutaml/model" do
11
- # rubocop:disable Style/CommandLiteral
11
+ # rubocop:disable-next Style/CommandLiteral
12
12
  result = `#{RbConfig.ruby} -I#{lib_path} -e 'require "lutaml/json"; puts :ok' 2>&1`
13
- # rubocop:enable Style/CommandLiteral
14
13
 
15
14
  expect($?.success?).to be(true), result
16
15
  expect(result).to include("ok")
@@ -148,7 +148,9 @@ RSpec.describe "OrderedContent" do
148
148
 
149
149
  # Regression for issue #735: `ordered` builder must honour setter
150
150
  # call order, not declaration order. The @__order_tracking__ flag
151
- # was gated on mixed_content? instead of ordered?.
151
+ # was gated on mixed_content? instead of ordered?. Comprehensive
152
+ # coverage of the setter/appender equivalence lives in
153
+ # spec/lutaml/model/serialize/builder_spec.rb.
152
154
  context "ordered builder honours setter call order" do
153
155
  it "emits elements in builder-call order, not declaration order" do
154
156
  klass = Class.new(Lutaml::Model::Serializable) do
@@ -163,16 +165,25 @@ RSpec.describe "OrderedContent" do
163
165
  end
164
166
  end
165
167
 
166
- item = klass.new do |i|
168
+ # Both the appender syntax and the direct setter syntax MUST
169
+ # honour call order and MUST NOT silently drop the other.
170
+ item_appender = klass.new do |i|
167
171
  i.b "first"
168
172
  i.a "second"
169
173
  end
170
174
 
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])
175
+ item_setter = klass.new do |i|
176
+ i.b = "first"
177
+ i.a = "second"
178
+ end
179
+
180
+ [item_appender, item_setter].each do |item|
181
+ xml = item.to_xml
182
+ b_pos = xml.index("<b>first</b>")
183
+ a_pos = xml.index("<a>second</a>")
184
+ expect(b_pos).to be < a_pos
185
+ expect(item.element_order.map(&:name)).to eq(%w[b a])
186
+ end
176
187
  end
177
188
  end
178
189
  end
@@ -0,0 +1,361 @@
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
+ # Specs for Lutaml::Model::Serialize::Builder — the module that gives
8
+ # Serializable the `Klass.new do |x| ... end` block syntax.
9
+ #
10
+ # These specs exist because of a regression where direct setters
11
+ # (`x.attr = v`) inside the builder block silently dropped attributes
12
+ # from serialized output while appender calls (`x.attr(v)`) worked.
13
+ # The root cause was that only getter-with-arg paths called track_order;
14
+ # generated setters did not. The fix centralises mutation recording via
15
+ # Builder#record_mutation / record_mutation_collection, and the
16
+ # serializer's apply_remaining_rules acts as a safety net so element
17
+ # values are never silently dropped.
18
+ #
19
+ # These specs cover the invariant ("no silent data loss"), call-order
20
+ # preservation, round-trip parity, equivalence between .tap and block,
21
+ # the no-op guarantee for non-ordered models, and the wholesale
22
+ # collection assignment case.
23
+ RSpec.describe "Lutaml::Model::Serialize::Builder" do
24
+ let(:ordered_klass) do
25
+ Class.new(Lutaml::Model::Serializable) do
26
+ attribute :singular, :string
27
+ attribute :items, :string, collection: true
28
+
29
+ xml do
30
+ element "container"
31
+ ordered
32
+ map_element "singular", to: :singular
33
+ map_element "items", to: :items
34
+ end
35
+ end
36
+ end
37
+
38
+ let(:mixed_klass) do
39
+ Class.new(Lutaml::Model::Serializable) do
40
+ attribute :singular, :string
41
+ attribute :items, :string, collection: true
42
+
43
+ xml do
44
+ element "container"
45
+ mixed_content
46
+ map_element "singular", to: :singular
47
+ map_element "items", to: :items
48
+ end
49
+ end
50
+ end
51
+
52
+ let(:plain_klass) do
53
+ Class.new(Lutaml::Model::Serializable) do
54
+ attribute :singular, :string
55
+ attribute :items, :string, collection: true
56
+
57
+ xml do
58
+ element "container"
59
+ map_element "singular", to: :singular
60
+ map_element "items", to: :items
61
+ end
62
+ end
63
+ end
64
+
65
+ shared_examples "no silent data loss across mutation styles" do
66
+ it "appender-only: emits every call in order" do
67
+ obj = ordered_klass.new do |x|
68
+ x.singular "first"
69
+ x.items "a"
70
+ x.items "b"
71
+ end
72
+
73
+ xml = obj.to_xml
74
+ expect(xml).to be_xml_equivalent_to(<<~XML)
75
+ <container>
76
+ <singular>first</singular>
77
+ <items>a</items>
78
+ <items>b</items>
79
+ </container>
80
+ XML
81
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items items])
82
+ end
83
+
84
+ it "direct setter for singular: still emits the element" do
85
+ obj = ordered_klass.new do |x|
86
+ x.singular = "first"
87
+ x.items "a"
88
+ end
89
+
90
+ xml = obj.to_xml
91
+ expect(xml).to be_xml_equivalent_to(<<~XML)
92
+ <container>
93
+ <singular>first</singular>
94
+ <items>a</items>
95
+ </container>
96
+ XML
97
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items])
98
+ end
99
+
100
+ it "wholesale collection assignment: emits one element per item" do
101
+ obj = ordered_klass.new do |x|
102
+ x.singular "first"
103
+ x.items = %w[a b c]
104
+ end
105
+
106
+ xml = obj.to_xml
107
+ expect(xml).to be_xml_equivalent_to(<<~XML)
108
+ <container>
109
+ <singular>first</singular>
110
+ <items>a</items>
111
+ <items>b</items>
112
+ <items>c</items>
113
+ </container>
114
+ XML
115
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items items
116
+ items])
117
+ end
118
+
119
+ it "all direct setters: emits in call order" do
120
+ obj = ordered_klass.new do |x|
121
+ x.singular = "first"
122
+ x.items = %w[a b]
123
+ end
124
+
125
+ xml = obj.to_xml
126
+ expect(xml).to be_xml_equivalent_to(<<~XML)
127
+ <container>
128
+ <singular>first</singular>
129
+ <items>a</items>
130
+ <items>b</items>
131
+ </container>
132
+ XML
133
+ end
134
+
135
+ it "mixed style preserves call order across setters and appenders" do
136
+ obj = ordered_klass.new do |x|
137
+ x.items "a"
138
+ x.singular = "middle"
139
+ x.items "b"
140
+ end
141
+
142
+ xml = obj.to_xml
143
+ expect(xml).to be_xml_equivalent_to(<<~XML)
144
+ <container>
145
+ <items>a</items>
146
+ <singular>middle</singular>
147
+ <items>b</items>
148
+ </container>
149
+ XML
150
+ end
151
+ end
152
+
153
+ describe "no silent data loss" do
154
+ it_behaves_like "no silent data loss across mutation styles"
155
+
156
+ it "mixed_content model emits setter-only attributes too" do
157
+ obj = mixed_klass.new do |x|
158
+ x.singular = "first"
159
+ x.items "a"
160
+ end
161
+
162
+ xml = obj.to_xml
163
+ expect(xml).to include("<singular>first</singular>")
164
+ expect(xml).to include("<items>a</items>")
165
+ expect(obj.element_order.map(&:name)).to eq(%w[singular items])
166
+ end
167
+ end
168
+
169
+ describe "call order preservation" do
170
+ it "reverse declaration order is honoured via direct setters" do
171
+ obj = ordered_klass.new do |x|
172
+ x.items = %w[a b]
173
+ x.singular = "last"
174
+ end
175
+
176
+ xml = obj.to_xml
177
+ items_pos = xml.index("<items>")
178
+ singular_pos = xml.index("<singular>")
179
+ expect(items_pos).to be < singular_pos
180
+ expect(obj.element_order.map(&:name)).to eq(%w[items items singular])
181
+ end
182
+
183
+ it "reverse declaration order is honoured via appenders" do
184
+ obj = ordered_klass.new do |x|
185
+ x.items "a"
186
+ x.singular "last"
187
+ end
188
+
189
+ xml = obj.to_xml
190
+ items_pos = xml.index("<items>")
191
+ singular_pos = xml.index("<singular>")
192
+ expect(items_pos).to be < singular_pos
193
+ expect(obj.element_order.map(&:name)).to eq(%w[items singular])
194
+ end
195
+ end
196
+
197
+ describe "round-trip parity" do
198
+ it "ordered: parses and re-emits equivalent XML" do
199
+ original = <<~XML
200
+ <container>
201
+ <items>a</items>
202
+ <singular>middle</singular>
203
+ <items>b</items>
204
+ </container>
205
+ XML
206
+
207
+ parsed = ordered_klass.from_xml(original)
208
+ expect(parsed.to_xml).to be_xml_equivalent_to(original)
209
+ end
210
+
211
+ it "mixed_content: parses and re-emits equivalent XML" do
212
+ original = <<~XML
213
+ <container>before <items>a</items> mid <singular>x</singular> end</container>
214
+ XML
215
+
216
+ parsed = mixed_klass.from_xml(original)
217
+ roundtrip = parsed.to_xml
218
+ expect(roundtrip).to include("<items>a</items>")
219
+ expect(roundtrip).to include("<singular>x</singular>")
220
+ end
221
+ end
222
+
223
+ describe ".tap vs builder block equivalence" do
224
+ it "produces identical element_order presence and XML shape" do
225
+ via_tap = ordered_klass.new.tap do |x|
226
+ x.singular = "first"
227
+ x.items = %w[a b]
228
+ end
229
+
230
+ via_block = ordered_klass.new do |x|
231
+ x.singular = "first"
232
+ x.items = %w[a b]
233
+ end
234
+
235
+ # .tap path does not enable tracking; both must still emit the same XML.
236
+ expect(via_tap.element_order).to be_nil
237
+ expect(via_block.element_order).not_to be_nil
238
+ expect(via_tap.to_xml).to be_xml_equivalent_to(via_block.to_xml)
239
+ end
240
+ end
241
+
242
+ describe "setter return value contract" do
243
+ # Ruby's setter contract: `obj.foo = v` evaluates to `v`. Callers
244
+ # like `obj.foo || (obj.foo = [])` depend on this. Generated
245
+ # setters must continue to honour this contract regardless of
246
+ # whether order tracking is enabled.
247
+ it "singular setter returns the assigned value" do
248
+ obj = plain_klass.new
249
+ expect((obj.singular = "v")).to eq("v")
250
+ end
251
+
252
+ it "collection setter returns the assigned value" do
253
+ obj = plain_klass.new
254
+ expect((obj.items = %w[a b])).to eq(%w[a b])
255
+ end
256
+
257
+ it "supports the `obj.foo || (obj.foo = [])` pattern from downstream consumers" do
258
+ obj = plain_klass.new
259
+ # Mirror Docbook::Elements::Para#try_add_inline, which relies on
260
+ # the setter returning the array (not nil) when the getter is nil.
261
+ # The bug: when the setter returned nil (because record_mutation
262
+ # returned nil with tracking disabled), `collection` was nil and
263
+ # `collection << element` failed with NoMethodError.
264
+ collection = obj.items || (obj.items = [])
265
+ expect(collection).to eq([])
266
+ expect { collection << "x" }.not_to raise_error
267
+ end
268
+ end
269
+
270
+ describe "no-op guarantee when not tracking" do
271
+ it "plain (non-ordered) models do not allocate element_order entries" do
272
+ obj = plain_klass.new do |x|
273
+ x.singular = "first"
274
+ x.items "a"
275
+ end
276
+
277
+ expect(obj.element_order).to be_nil
278
+ expect { obj.to_xml }.not_to raise_error
279
+ end
280
+
281
+ it "ordered models constructed without a block do not track" do
282
+ obj = ordered_klass.new
283
+ obj.singular = "first"
284
+ obj.items = %w[a b]
285
+
286
+ expect(obj.element_order).to be_nil
287
+ expect(obj.to_xml).to include("<singular>first</singular>")
288
+ end
289
+ end
290
+
291
+ describe "wholesale collection reassignment" do
292
+ it "tracks one entry per item across multiple assignments" do
293
+ obj = ordered_klass.new do |x|
294
+ x.items = %w[a b]
295
+ x.items = %w[c d e]
296
+ end
297
+
298
+ # element_order is a log of mutations, not a reflection of final state,
299
+ # so both assignments are recorded. The serializer emits the current
300
+ # value of the attribute via element_order in recorded order; the
301
+ # safety net guarantees no data loss.
302
+ expect(obj.element_order.map(&:name)).to eq(%w[items items items items
303
+ items])
304
+ xml = obj.to_xml
305
+ # The serialized output must contain all current items
306
+ expect(xml).to include("<items>c</items>")
307
+ expect(xml).to include("<items>d</items>")
308
+ expect(xml).to include("<items>e</items>")
309
+ end
310
+ end
311
+
312
+ describe "no-silent-drop invariant (cross-cutting)" do
313
+ # The bug class: any attribute with a non-default value MUST appear in
314
+ # the serialized output, regardless of how it was set. This spec is the
315
+ # regression guard for the whole class of bug, not just one instance.
316
+ shared_examples "no silent drop" do |description, mutation_block|
317
+ it "does not silently drop any attribute set via: #{description}" do
318
+ obj = ordered_klass.new(&mutation_block)
319
+ xml = obj.to_xml
320
+
321
+ if !obj.singular.nil? && !obj.singular.to_s.empty?
322
+ expect(xml).to include("<singular>"),
323
+ "singular was dropped from output"
324
+ end
325
+ return if obj.items.nil? || obj.items.empty?
326
+
327
+ obj.items.each do |item|
328
+ expect(xml).to include("<items>#{item}</items>"),
329
+ "items element for #{item.inspect} was dropped from output"
330
+ end
331
+ end
332
+ end
333
+
334
+ it_behaves_like "no silent drop",
335
+ "singular setter + wholesale items setter",
336
+ lambda { |x|
337
+ x.singular = "v"
338
+ x.items = %w[a b]
339
+ }
340
+ it_behaves_like "no silent drop",
341
+ "all appenders",
342
+ lambda { |x|
343
+ x.singular "v"
344
+ x.items "a"
345
+ x.items "b"
346
+ }
347
+ it_behaves_like "no silent drop",
348
+ "singular setter + items appender (the original bug)",
349
+ lambda { |x|
350
+ x.singular = "v"
351
+ x.items "a"
352
+ x.items "b"
353
+ }
354
+ it_behaves_like "no silent drop",
355
+ "singular appender + wholesale items setter",
356
+ lambda { |x|
357
+ x.singular "v"
358
+ x.items = %w[a b]
359
+ }
360
+ end
361
+ end