lutaml-model 0.8.2 → 0.8.4

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +23 -23
  3. data/README.adoc +213 -1
  4. data/docs/_guides/document-validation.adoc +303 -0
  5. data/docs/_guides/index.adoc +1 -0
  6. data/docs/_guides/xml-mapping.adoc +9 -1
  7. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  8. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  9. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  10. data/lib/lutaml/model/attribute.rb +19 -1
  11. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  12. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  13. data/lib/lutaml/model/global_context.rb +1 -0
  14. data/lib/lutaml/model/liquefiable.rb +12 -15
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  16. data/lib/lutaml/model/mapping_hash.rb +1 -1
  17. data/lib/lutaml/model/services/transformer.rb +67 -32
  18. data/lib/lutaml/model/transform.rb +41 -4
  19. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  20. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  21. data/lib/lutaml/model/validation/context.rb +36 -0
  22. data/lib/lutaml/model/validation/issue.rb +62 -0
  23. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  24. data/lib/lutaml/model/validation/profile.rb +66 -0
  25. data/lib/lutaml/model/validation/registry.rb +60 -0
  26. data/lib/lutaml/model/validation/remediation.rb +33 -0
  27. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  28. data/lib/lutaml/model/validation/report.rb +39 -0
  29. data/lib/lutaml/model/validation/rule.rb +59 -0
  30. data/lib/lutaml/model/validation.rb +2 -1
  31. data/lib/lutaml/model/validation_framework.rb +77 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model.rb +4 -0
  34. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  35. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  36. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  37. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  38. data/lib/lutaml/xml/adapter_element.rb +26 -2
  39. data/lib/lutaml/xml/data_model.rb +14 -0
  40. data/lib/lutaml/xml/document.rb +3 -0
  41. data/lib/lutaml/xml/element.rb +8 -2
  42. data/lib/lutaml/xml/mapping.rb +9 -0
  43. data/lib/lutaml/xml/model_transform.rb +42 -0
  44. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  45. data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
  46. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  47. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  48. data/lib/lutaml/xml/transformation.rb +40 -1
  49. data/lib/lutaml/xml/xml_element.rb +8 -7
  50. data/lutaml-model.gemspec +1 -2
  51. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  52. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  53. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  54. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  55. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  56. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  57. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  58. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  59. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  60. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  61. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  62. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  63. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  64. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  65. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  66. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  67. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  68. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  69. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  70. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  71. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  72. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  73. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  74. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  75. data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
  76. metadata +46 -21
  77. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
data/lutaml-model.gemspec CHANGED
@@ -35,9 +35,8 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency "bigdecimal"
36
36
  spec.add_dependency "canon"
37
37
  spec.add_dependency "concurrent-ruby"
38
- spec.add_dependency "liquid", "~> 5.0"
38
+ spec.add_dependency "liquid", ">= 4.0", "< 6.0"
39
39
  spec.add_dependency "moxml", ">= 0.1.16"
40
- spec.add_dependency "openssl", "~> 3.0"
41
40
  spec.add_dependency "ostruct"
42
41
  spec.add_dependency "rubyzip", "~> 2.3"
43
42
  spec.add_dependency "thor"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Attribute default caching" do
6
+ describe "immutable defaults" do
7
+ let(:klass) do
8
+ Class.new(Lutaml::Model::Serializable) do
9
+ attribute :name, :string, default: -> { "default_name" }
10
+ attribute :count, :integer, default: -> { 42 }
11
+ end
12
+ end
13
+
14
+ it "returns same default object for immutable types" do
15
+ attr = klass.attributes[:name]
16
+ default1 = attr.default(:default)
17
+ default2 = attr.default(:default)
18
+ expect(default1).to equal(default2)
19
+ end
20
+ end
21
+
22
+ describe "mutable defaults" do
23
+ let(:klass) do
24
+ Class.new(Lutaml::Model::Serializable) do
25
+ attribute :prefs, :hash, default: -> { { theme: "dark" } }
26
+ end
27
+ end
28
+
29
+ it "does not share mutable default between calls" do
30
+ attr = klass.attributes[:prefs]
31
+ default1 = attr.default(:default)
32
+ default2 = attr.default(:default)
33
+
34
+ # If cached, they'd be the same object. Mutating one would affect the other.
35
+ # With the immutable_value? guard, Hashes are NOT cached, so each call
36
+ # re-evaluates the default proc, producing independent objects.
37
+ if default1.equal?(default2)
38
+ # If they happen to be the same (cached), mutation should NOT propagate
39
+ pending "mutable default caching needs fixing"
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "instance-aware defaults" do
45
+ let(:klass) do
46
+ Class.new(Lutaml::Model::Serializable) do
47
+ attribute :label, :string, default: -> { "default" }
48
+ end
49
+ end
50
+
51
+ it "passes instance_object to default_value when provided" do
52
+ attr = klass.attributes[:label]
53
+ instance = klass.new
54
+ result = attr.default(:default, instance)
55
+ expect(result).to eq("default")
56
+ end
57
+ end
58
+ end
@@ -81,8 +81,10 @@ RSpec.describe Lutaml::Model::Liquefiable do
81
81
  it "raises an error" do
82
82
  expect do
83
83
  dummy.class.register_liquid_drop_class
84
- end.to raise_error(RuntimeError,
85
- "Drop Already exists!")
84
+ end.to raise_error(
85
+ Lutaml::Model::LiquidDropAlreadyRegisteredError,
86
+ "Liquid drop class 'Drop' is already registered.",
87
+ )
86
88
  end
87
89
  end
88
90
 
@@ -94,8 +96,10 @@ RSpec.describe Lutaml::Model::Liquefiable do
94
96
  it "raises an error" do
95
97
  expect do
96
98
  EmptyModel.register_liquid_drop_class
97
- end.to raise_error(RuntimeError,
98
- "Drop Already exists!")
99
+ end.to raise_error(
100
+ Lutaml::Model::LiquidDropAlreadyRegisteredError,
101
+ "Liquid drop class 'Drop' is already registered.",
102
+ )
99
103
  end
100
104
  end
101
105
  end
@@ -254,14 +258,26 @@ RSpec.describe Lutaml::Model::Liquefiable do
254
258
  end
255
259
 
256
260
  it "renders" do
261
+ # Probe which partial tag the installed Liquid supports (render ≥5, include ≤4)
262
+ partial_tag = begin
263
+ Liquid::Template.parse("{% render 'probe' %}")
264
+ "render"
265
+ rescue Liquid::SyntaxError
266
+ "include"
267
+ end
268
+
257
269
  template = Liquid::Template.new
258
270
  file_system = Liquid::LocalFileSystem.new(liquid_template_dir)
259
271
  template.registers[:file_system] = file_system
260
- template.parse(file_system.read_template_file("ceramics"))
272
+ parent_template = <<~LIQUID
273
+ {% for ceramic in ceramic_collection.ceramics %}
274
+ {% #{partial_tag} 'ceramic' ceramic: ceramic %}
275
+ {%- endfor %}
276
+ LIQUID
277
+ template.parse(parent_template)
261
278
 
262
279
  ceramic_collection = LiquefiableSpec::CeramicCollection.from_yaml(yaml)
263
280
  output = template.render("ceramic_collection" => ceramic_collection)
264
- # puts output
265
281
 
266
282
  expected_output = <<~OUTPUT
267
283
  * Name: "Celadon Bowl"
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "liquid"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+
8
+ # These specs verify the Liquid API surface that Lutaml::Model::Liquefiable
9
+ # depends on. They are behavior-based, not version-gated — if these pass,
10
+ # the installed Liquid version provides everything we need.
11
+ #
12
+ # Liquid features exercised here:
13
+ # - Liquid::Drop (base class for generated drop objects)
14
+ # - Liquid::Template.parse / #render (template compilation and execution)
15
+ # - Liquid::LocalFileSystem (partial resolution for {% include %})
16
+ # - to_liquid protocol (automatic drop conversion in render contexts)
17
+ # - Template tags: {{ var }}, {% for %}, {% if %}, {% assign %}
18
+ RSpec.describe "Liquid compatibility for Lutaml::Model::Liquefiable" do
19
+ # ── Core API availability ──────────────────────────────────────────────
20
+
21
+ describe "required Liquid classes and methods" do
22
+ it "provides Liquid::Drop as a base class" do
23
+ expect(defined?(Liquid::Drop)).to eq("constant")
24
+ drop = Class.new(Liquid::Drop).new
25
+ expect(drop).to be_a(Liquid::Drop)
26
+ end
27
+
28
+ it "provides Liquid::Template.parse" do
29
+ expect(Liquid::Template).to respond_to(:parse)
30
+ end
31
+
32
+ it "provides template#render with a hash of variables" do
33
+ template = Liquid::Template.parse("{{ msg }}")
34
+ expect(template.render("msg" => "hello")).to eq("hello")
35
+ end
36
+
37
+ it "provides Liquid::LocalFileSystem" do
38
+ expect(defined?(Liquid::LocalFileSystem)).to eq("constant")
39
+ end
40
+
41
+ it "supports the to_liquid protocol on strings and integers" do
42
+ expect("hello".to_liquid).to eq("hello")
43
+ expect(42.to_liquid).to eq(42)
44
+ end
45
+ end
46
+
47
+ # ── Drop generation from Serializable models ───────────────────────────
48
+
49
+ describe "drop generation for Serializable models" do
50
+ let(:model_class) do
51
+ Class.new(Lutaml::Model::Serializable) do
52
+ def self.name
53
+ "Widget"
54
+ end
55
+
56
+ attribute :label, :string
57
+ attribute :count, :integer
58
+ end
59
+ end
60
+
61
+ let(:instance) { model_class.new(label: "Sprocket", count: 3) }
62
+
63
+ it "generates a Drop subclass inheriting from Liquid::Drop" do
64
+ drop = instance.to_liquid
65
+ expect(drop).to be_a(Liquid::Drop)
66
+ end
67
+
68
+ it "exposes attributes as drop methods" do
69
+ drop = instance.to_liquid
70
+ expect(drop.label).to eq("Sprocket")
71
+ expect(drop.count).to eq(3)
72
+ end
73
+
74
+ it "returns the same Drop class for all instances" do
75
+ drop_class = instance.to_liquid.class
76
+ other = model_class.new(label: "Gear", count: 1)
77
+ expect(other.to_liquid.class).to eq(drop_class)
78
+ end
79
+
80
+ it "does not re-register methods on subsequent calls" do
81
+ instance.to_liquid
82
+ expect do
83
+ instance.to_liquid
84
+ end.not_to(change do
85
+ instance.to_liquid.class.instance_methods(false).sort
86
+ end)
87
+ end
88
+ end
89
+
90
+ # ── Nested model drops ─────────────────────────────────────────────────
91
+
92
+ describe "nested model composition" do
93
+ let(:inner_class) do
94
+ Class.new(Lutaml::Model::Serializable) do
95
+ def self.name
96
+ "Detail"
97
+ end
98
+
99
+ attribute :color, :string
100
+ end
101
+ end
102
+
103
+ let(:outer_class) do
104
+ inner = inner_class
105
+
106
+ Class.new(Lutaml::Model::Serializable) do
107
+ define_method(:inner_class) { inner }
108
+
109
+ def self.name
110
+ "Container"
111
+ end
112
+
113
+ attribute :title, :string
114
+ attribute :detail, inner
115
+ end
116
+ end
117
+
118
+ let(:instance) do
119
+ outer_class.new(title: "Box", detail: inner_class.new(color: "blue"))
120
+ end
121
+
122
+ it "converts nested models to drops via to_liquid" do
123
+ drop = instance.to_liquid
124
+ expect(drop.detail).to be_a(Liquid::Drop)
125
+ expect(drop.detail.color).to eq("blue")
126
+ end
127
+
128
+ it "resolves nested access in templates" do
129
+ template = Liquid::Template.parse("{{ instance.detail.color }}")
130
+ result = template.render("instance" => instance)
131
+ expect(result).to eq("blue")
132
+ end
133
+ end
134
+
135
+ # ── Collections ────────────────────────────────────────────────────────
136
+
137
+ describe "collection handling" do
138
+ let(:item_class) do
139
+ Class.new(Lutaml::Model::Serializable) do
140
+ def self.name
141
+ "Item"
142
+ end
143
+
144
+ attribute :name, :string
145
+ end
146
+ end
147
+
148
+ let(:container_class) do
149
+ item_klass = item_class
150
+
151
+ Class.new(Lutaml::Model::Serializable) do
152
+ define_method(:item_class) { item_klass }
153
+
154
+ def self.name
155
+ "ItemContainer"
156
+ end
157
+
158
+ attribute :items, item_klass, collection: true
159
+ end
160
+ end
161
+
162
+ let(:instance) do
163
+ container_class.new(
164
+ items: [
165
+ item_class.new(name: "Alpha"),
166
+ item_class.new(name: "Beta"),
167
+ ],
168
+ )
169
+ end
170
+
171
+ it "converts array of models to array of drops" do
172
+ drops = instance.to_liquid.items
173
+ expect(drops).to all(be_a(Liquid::Drop))
174
+ expect(drops.map(&:name)).to eq(["Alpha", "Beta"])
175
+ end
176
+
177
+ it "iterates collections in {% for %} loops" do
178
+ template = Liquid::Template.parse(<<~LIQUID)
179
+ {% for item in container.items %}{{ item.name }},{% endfor %}
180
+ LIQUID
181
+ result = template.render("container" => instance)
182
+ expect(result.strip).to eq("Alpha,Beta,")
183
+ end
184
+ end
185
+
186
+ # ── Custom liquid mappings ─────────────────────────────────────────────
187
+
188
+ describe "custom liquid mappings via liquid block" do
189
+ let(:model_class) do
190
+ Class.new(Lutaml::Model::Serializable) do
191
+ def self.name
192
+ "MappedWidget"
193
+ end
194
+
195
+ attribute :path, :string
196
+ attribute :source, :string
197
+
198
+ liquid do
199
+ map "full_path", to: :computed_path
200
+ map "summary", to: :formatted_summary
201
+ end
202
+
203
+ def computed_path
204
+ "/app/#{path}"
205
+ end
206
+
207
+ def formatted_summary
208
+ "#{source} (#{path})"
209
+ end
210
+ end
211
+ end
212
+
213
+ let(:instance) { model_class.new(path: "index.xml", source: "Hello") }
214
+
215
+ it "maps custom keys to instance methods" do
216
+ drop = instance.to_liquid
217
+ expect(drop.full_path).to eq("/app/index.xml")
218
+ expect(drop.summary).to eq("Hello (index.xml)")
219
+ end
220
+
221
+ it "renders custom mappings in templates" do
222
+ template = Liquid::Template.parse("{{ w.full_path }} | {{ w.summary }}")
223
+ result = template.render("w" => instance)
224
+ expect(result).to eq("/app/index.xml | Hello (index.xml)")
225
+ end
226
+
227
+ it "still exposes original attributes alongside custom mappings" do
228
+ drop = instance.to_liquid
229
+ expect(drop.path).to eq("index.xml")
230
+ expect(drop.source).to eq("Hello")
231
+ end
232
+ end
233
+
234
+ # ── Conditional and control-flow templates ─────────────────────────────
235
+
236
+ describe "template control flow with drops" do
237
+ let(:model_class) do
238
+ Class.new(Lutaml::Model::Serializable) do
239
+ def self.name
240
+ "ConditionalModel"
241
+ end
242
+
243
+ attribute :title, :string
244
+ attribute :score, :integer
245
+ end
246
+ end
247
+
248
+ it "handles {% if %} with drop attributes" do
249
+ instance = model_class.new(title: "present", score: 10)
250
+ template = Liquid::Template.parse(<<~LIQUID)
251
+ {% if m.title %}HAS_TITLE{% else %}NO_TITLE{% endif %}
252
+ LIQUID
253
+ expect(template.render("m" => instance).strip).to eq("HAS_TITLE")
254
+
255
+ empty = model_class.new(title: nil, score: 0)
256
+ expect(template.render("m" => empty).strip).to eq("NO_TITLE")
257
+ end
258
+
259
+ it "handles {% assign %} and expressions with drop values" do
260
+ instance = model_class.new(title: "demo", score: 42)
261
+ template = Liquid::Template.parse(<<~LIQUID)
262
+ {% assign threshold = 10 %}{% if m.score > threshold %}HIGH{% else %}LOW{% endif %}
263
+ LIQUID
264
+ expect(template.render("m" => instance).strip).to eq("HIGH")
265
+ end
266
+ end
267
+
268
+ # ── Inheritance ────────────────────────────────────────────────────────
269
+
270
+ describe "drop inheritance across class hierarchy" do
271
+ let(:parent_class) do
272
+ Class.new(Lutaml::Model::Serializable) do
273
+ def self.name
274
+ "Parent"
275
+ end
276
+
277
+ attribute :name, :string
278
+ end
279
+ end
280
+
281
+ let(:child_class) do
282
+ Class.new(parent_class) do
283
+ def self.name
284
+ "Child"
285
+ end
286
+
287
+ attribute :age, :integer
288
+ end
289
+ end
290
+
291
+ it "inherits attribute drops from the parent" do
292
+ child = child_class.new(name: "Alice", age: 5)
293
+ drop = child.to_liquid
294
+ expect(drop.name).to eq("Alice")
295
+ expect(drop.age).to eq(5)
296
+ end
297
+
298
+ it "parent and child have distinct drop classes" do
299
+ parent = parent_class.new(name: "Bob")
300
+ child = child_class.new(name: "Alice", age: 5)
301
+ expect(parent.to_liquid.class).not_to eq(child.to_liquid.class)
302
+ end
303
+ end
304
+
305
+ # ── Non-Serializable Liquefiable ───────────────────────────────────────
306
+
307
+ describe "Liquefiable without Serializable" do
308
+ let(:plain_class) do
309
+ Class.new do
310
+ include Lutaml::Model::Liquefiable
311
+
312
+ def self.name
313
+ "PlainObject"
314
+ end
315
+
316
+ def initialize(label)
317
+ @label = label
318
+ end
319
+
320
+ def label
321
+ @label
322
+ end
323
+
324
+ liquid do
325
+ map "label", to: :label
326
+ end
327
+ end
328
+ end
329
+
330
+ it "creates a drop class inheriting from Liquid::Drop" do
331
+ instance = plain_class.new("test")
332
+ expect(instance.to_liquid).to be_a(Liquid::Drop)
333
+ end
334
+
335
+ it "exposes mapped methods on the drop" do
336
+ instance = plain_class.new("hello")
337
+ expect(instance.to_liquid.label).to eq("hello")
338
+ end
339
+
340
+ it "renders in templates" do
341
+ instance = plain_class.new("world")
342
+ template = Liquid::Template.parse("{{ obj.label }}")
343
+ expect(template.render("obj" => instance)).to eq("world")
344
+ end
345
+ end
346
+
347
+ # ── Error handling ─────────────────────────────────────────────────────
348
+
349
+ describe "error handling" do
350
+ it "raises LiquidNotEnabledError when Liquid is not loaded" do
351
+ allow(Object).to receive(:const_defined?).with(:Liquid).and_return(false)
352
+ klass = Class.new do
353
+ include Lutaml::Model::Liquefiable
354
+ end
355
+ instance = klass.new
356
+ expect { instance.to_liquid }.to raise_error(
357
+ Lutaml::Model::LiquidNotEnabledError,
358
+ )
359
+ end
360
+
361
+ it "raises LiquidDropAlreadyRegisteredError on duplicate registration" do
362
+ allow(Object).to receive(:const_defined?).with(:Liquid).and_call_original
363
+ klass = Class.new(Lutaml::Model::Serializable) do
364
+ def self.name
365
+ "DupTestModel"
366
+ end
367
+
368
+ attribute :x, :string
369
+ end
370
+ # Drop already registered during class definition
371
+ expect do
372
+ klass.register_liquid_drop_class
373
+ end.to raise_error(Lutaml::Model::LiquidDropAlreadyRegisteredError)
374
+ end
375
+
376
+ it "raises LiquidClassNotFoundError for missing custom drop class" do
377
+ klass = Class.new(Lutaml::Model::Serializable) do
378
+ def self.name
379
+ "MissingDropModel"
380
+ end
381
+
382
+ attribute :x, :string
383
+
384
+ liquid_class "NonexistentDrop"
385
+ end
386
+ instance = klass.new(x: "test")
387
+ expect { instance.to_liquid }.to raise_error(
388
+ Lutaml::Model::LiquidClassNotFoundError,
389
+ /NonexistentDrop/,
390
+ )
391
+ end
392
+
393
+ it "raises NoAttributesDefinedLiquidError for attribute-less Serializable" do
394
+ klass = Class.new(Lutaml::Model::Serializable) do
395
+ def self.name
396
+ "NoAttrsModel"
397
+ end
398
+ end
399
+ instance = klass.new
400
+ expect { instance.to_liquid }.to raise_error(
401
+ Lutaml::Model::NoAttributesDefinedLiquidError,
402
+ /NoAttrsModel/,
403
+ )
404
+ end
405
+ end
406
+
407
+ # ── LocalFileSystem (partials via {% include %}) ───────────────────────
408
+
409
+ describe "partial rendering with Liquid::LocalFileSystem" do
410
+ let(:template_dir) do
411
+ Dir.mktmpdir
412
+ end
413
+
414
+ after do
415
+ FileUtils.remove_entry(template_dir)
416
+ end
417
+
418
+ it "resolves {% include %} partials via LocalFileSystem" do
419
+ File.write(File.join(template_dir, "_item.liquid"), <<~LIQUID)
420
+ [{{ item.name }}]
421
+ LIQUID
422
+
423
+ item_class = Class.new(Lutaml::Model::Serializable) do
424
+ def self.name
425
+ "FsItem"
426
+ end
427
+
428
+ attribute :name, :string
429
+ end
430
+
431
+ item = item_class.new(name: "Alpha")
432
+ template = Liquid::Template.new
433
+ template.registers[:file_system] = Liquid::LocalFileSystem.new(template_dir)
434
+ template.parse(<<~LIQUID)
435
+ {% include 'item' item: item %}
436
+ LIQUID
437
+
438
+ result = template.render("item" => item)
439
+ expect(result.strip).to eq("[Alpha]")
440
+ end
441
+ end
442
+ end
@@ -15,11 +15,11 @@ module OrderedContentSpec
15
15
  attribute :bold, :string, collection: true
16
16
  attribute :italic, :string, collection: true
17
17
  attribute :underline, :string
18
- attribute :content, :string
18
+ attribute :content, :string, collection: true
19
19
 
20
20
  xml do
21
21
  element "RootOrderedContent"
22
- ordered
22
+ mixed_content
23
23
 
24
24
  map_attribute :id, to: :id
25
25
  map_element :bold, to: :bold
@@ -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.to_s).to match(/The Earth's Moon rings like a/)
106
- expect(obj.content.to_s).to match(/Ain't that/)
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/)
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.to_s).to match(/The Earth's Moon rings like a/)
115
+ expect(round_trip.content.first.to_s).to match(/The Earth's Moon rings like a/)
116
116
  end
117
117
  end
118
118
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Model::Transformer do
6
+ describe "open/closed principle" do
7
+ it "defaults to import direction" do
8
+ transformer = described_class.new(nil, nil)
9
+ expect(transformer.export_direction?).to be false
10
+ end
11
+
12
+ it "ImportTransformer returns false for export_direction?" do
13
+ transformer = Lutaml::Model::ImportTransformer.new(nil, nil)
14
+ expect(transformer.export_direction?).to be false
15
+ end
16
+
17
+ it "ExportTransformer returns true for export_direction?" do
18
+ transformer = Lutaml::Model::ExportTransformer.new(nil, nil)
19
+ expect(transformer.export_direction?).to be true
20
+ end
21
+
22
+ it "allows custom subclass to define direction" do
23
+ custom = Class.new(described_class) do
24
+ def export_direction?
25
+ true
26
+ end
27
+ end
28
+ expect(custom.new(nil, nil).export_direction?).to be true
29
+ end
30
+ end
31
+
32
+ describe "class-level .call" do
33
+ it "ImportTransformer.call returns value unchanged without transforms" do
34
+ result = Lutaml::Model::ImportTransformer.call("hello", nil, nil)
35
+ expect(result).to eq("hello")
36
+ end
37
+
38
+ it "ExportTransformer.call returns value unchanged without transforms" do
39
+ result = Lutaml::Model::ExportTransformer.call("hello", nil, nil)
40
+ expect(result).to eq("hello")
41
+ end
42
+ end
43
+ end