lutaml-model 0.8.3 → 0.8.5

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +3 -1
  3. data/.rubocop.yml +18 -0
  4. data/.rubocop_todo.yml +16 -22
  5. data/Gemfile +2 -0
  6. data/README.adoc +327 -3
  7. data/docs/_guides/document-validation.adoc +303 -0
  8. data/docs/_guides/index.adoc +19 -0
  9. data/docs/_guides/jsonld-serialization.adoc +217 -0
  10. data/docs/_guides/rdf-serialization.adoc +344 -0
  11. data/docs/_guides/turtle-serialization.adoc +224 -0
  12. data/docs/_guides/xml-mapping.adoc +9 -1
  13. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  14. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  15. data/docs/_pages/serialization_adapters.adoc +31 -0
  16. data/docs/_references/index.adoc +1 -0
  17. data/docs/_references/rdf-namespaces.adoc +243 -0
  18. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  19. data/docs/index.adoc +3 -2
  20. data/lib/lutaml/jsonld/adapter.rb +23 -0
  21. data/lib/lutaml/jsonld/context.rb +69 -0
  22. data/lib/lutaml/jsonld/term_definition.rb +39 -0
  23. data/lib/lutaml/jsonld/transform.rb +174 -0
  24. data/lib/lutaml/jsonld.rb +23 -0
  25. data/lib/lutaml/model/attribute.rb +19 -1
  26. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  27. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  28. data/lib/lutaml/model/format_registry.rb +10 -1
  29. data/lib/lutaml/model/global_context.rb +1 -0
  30. data/lib/lutaml/model/liquefiable.rb +12 -15
  31. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  32. data/lib/lutaml/model/mapping_hash.rb +1 -1
  33. data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
  34. data/lib/lutaml/model/services/transformer.rb +67 -32
  35. data/lib/lutaml/model/transform.rb +41 -4
  36. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  37. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  38. data/lib/lutaml/model/validation/context.rb +36 -0
  39. data/lib/lutaml/model/validation/issue.rb +62 -0
  40. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  41. data/lib/lutaml/model/validation/profile.rb +66 -0
  42. data/lib/lutaml/model/validation/registry.rb +60 -0
  43. data/lib/lutaml/model/validation/remediation.rb +33 -0
  44. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  45. data/lib/lutaml/model/validation/report.rb +39 -0
  46. data/lib/lutaml/model/validation/rule.rb +59 -0
  47. data/lib/lutaml/model/validation.rb +2 -1
  48. data/lib/lutaml/model/validation_framework.rb +77 -0
  49. data/lib/lutaml/model/version.rb +1 -1
  50. data/lib/lutaml/model.rb +10 -0
  51. data/lib/lutaml/rdf/error.rb +7 -0
  52. data/lib/lutaml/rdf/iri.rb +44 -0
  53. data/lib/lutaml/rdf/language_tagged.rb +11 -0
  54. data/lib/lutaml/rdf/literal.rb +62 -0
  55. data/lib/lutaml/rdf/mapping.rb +71 -0
  56. data/lib/lutaml/rdf/mapping_rule.rb +35 -0
  57. data/lib/lutaml/rdf/member_rule.rb +13 -0
  58. data/lib/lutaml/rdf/namespace.rb +58 -0
  59. data/lib/lutaml/rdf/namespace_set.rb +69 -0
  60. data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
  61. data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
  62. data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
  63. data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
  64. data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
  65. data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
  66. data/lib/lutaml/rdf/namespaces.rb +14 -0
  67. data/lib/lutaml/rdf/transform.rb +36 -0
  68. data/lib/lutaml/rdf.rb +19 -0
  69. data/lib/lutaml/turtle/adapter.rb +35 -0
  70. data/lib/lutaml/turtle/mapping.rb +7 -0
  71. data/lib/lutaml/turtle/transform.rb +158 -0
  72. data/lib/lutaml/turtle.rb +22 -0
  73. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  74. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  75. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  76. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  77. data/lib/lutaml/xml/adapter_element.rb +26 -2
  78. data/lib/lutaml/xml/data_model.rb +14 -0
  79. data/lib/lutaml/xml/document.rb +3 -0
  80. data/lib/lutaml/xml/element.rb +8 -2
  81. data/lib/lutaml/xml/mapping.rb +9 -0
  82. data/lib/lutaml/xml/model_transform.rb +42 -0
  83. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  84. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  85. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  86. data/lib/lutaml/xml/transformation.rb +40 -1
  87. data/lib/lutaml/xml/xml_element.rb +8 -7
  88. data/lutaml-model.gemspec +1 -1
  89. data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
  90. data/spec/lutaml/integration/multi_format_spec.rb +106 -0
  91. data/spec/lutaml/integration/round_trip_spec.rb +170 -0
  92. data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
  93. data/spec/lutaml/jsonld/context_spec.rb +114 -0
  94. data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
  95. data/spec/lutaml/jsonld/transform_spec.rb +211 -0
  96. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  97. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  98. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  99. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  100. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  101. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  102. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  103. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  104. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  105. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  106. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  107. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  108. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  109. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  110. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  111. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  112. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  113. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  114. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  115. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  116. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  117. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  118. data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
  119. data/spec/lutaml/rdf/iri_spec.rb +73 -0
  120. data/spec/lutaml/rdf/literal_spec.rb +98 -0
  121. data/spec/lutaml/rdf/mapping_spec.rb +164 -0
  122. data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
  123. data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
  124. data/spec/lutaml/rdf/namespace_spec.rb +241 -0
  125. data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
  126. data/spec/lutaml/turtle/adapter_spec.rb +47 -0
  127. data/spec/lutaml/turtle/mapping_spec.rb +123 -0
  128. data/spec/lutaml/turtle/transform_spec.rb +273 -0
  129. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  130. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  131. metadata +95 -7
  132. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
@@ -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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Transform caching" do
6
+ after { Lutaml::Model::Transform.clear_cache! }
7
+
8
+ describe ".cached_transform" do
9
+ it "returns same instance for same context and register" do
10
+ klass = Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+ end
13
+
14
+ t1 = Lutaml::Model::Transform.cached_transform(klass, :default)
15
+ t2 = Lutaml::Model::Transform.cached_transform(klass, :default)
16
+ expect(t1).to equal(t2)
17
+ end
18
+
19
+ it "returns different instances for different contexts" do
20
+ klass_a = Class.new(Lutaml::Model::Serializable) do
21
+ attribute :name, :string
22
+ end
23
+ klass_b = Class.new(Lutaml::Model::Serializable) do
24
+ attribute :title, :string
25
+ end
26
+
27
+ t1 = Lutaml::Model::Transform.cached_transform(klass_a, :default)
28
+ t2 = Lutaml::Model::Transform.cached_transform(klass_b, :default)
29
+ expect(t1).not_to equal(t2)
30
+ end
31
+ end
32
+
33
+ describe ".clear_cache!" do
34
+ it "clears the cache" do
35
+ klass = Class.new(Lutaml::Model::Serializable) do
36
+ attribute :name, :string
37
+ end
38
+
39
+ Lutaml::Model::Transform.cached_transform(klass, :default)
40
+ expect(Lutaml::Model::Transform.cache_size).to be > 0
41
+
42
+ Lutaml::Model::Transform.clear_cache!
43
+ expect(Lutaml::Model::Transform.cache_size).to eq(0)
44
+ end
45
+ end
46
+
47
+ describe "cache eviction" do
48
+ it "evicts entries when exceeding MAX_CACHE_SIZE" do
49
+ stub_const("Lutaml::Model::Transform::MAX_CACHE_SIZE", 4)
50
+
51
+ classes = Array.new(6) do |i|
52
+ Class.new(Lutaml::Model::Serializable) do
53
+ attribute :"attr_#{i}", :string
54
+ end
55
+ end
56
+
57
+ classes.each { |k| Lutaml::Model::Transform.cached_transform(k, :default) }
58
+
59
+ expect(Lutaml::Model::Transform.cache_size).to be <= 4
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Transform with dynamically added attributes" do
6
+ before do
7
+ Lutaml::Model::GlobalContext.clear_caches
8
+ end
9
+
10
+ it "picks up attributes added after initial class definition" do
11
+ base_class = Class.new(Lutaml::Model::Serializable) do
12
+ attribute :name, :string
13
+
14
+ xml do
15
+ root "test"
16
+ map_element "name", to: :name
17
+ end
18
+
19
+ def self.name
20
+ "DynamicAttributeTestClass"
21
+ end
22
+ end
23
+
24
+ # Parse once to populate Transform cache
25
+ base_class.from_xml("<test><name>initial</name></test>")
26
+
27
+ # Dynamically add a new attribute and mapping (like xmi EaRoot.load_extension)
28
+ base_class.class_eval do
29
+ attribute :extra, :string
30
+
31
+ xml do
32
+ map_element "extra", to: :extra
33
+ end
34
+ end
35
+
36
+ # The Transform must see the newly added attribute
37
+ result = base_class.from_xml("<test><name>hello</name><extra>world</extra></test>")
38
+ expect(result.name).to eq("hello")
39
+ expect(result.extra).to eq("world")
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "UninitializedClass deep_dup compatibility" do
6
+ let(:uninitialized) { Lutaml::Model::UninitializedClass.instance }
7
+
8
+ # Replicates the rng gem's ExternalRefResolver#deep_dup pattern
9
+ def deep_dup(obj)
10
+ case obj
11
+ when Array
12
+ obj.map { |o| deep_dup(o) }
13
+ when Hash
14
+ obj.each_with_object({}) { |(k, v), h| h[deep_dup(k)] = deep_dup(v) }
15
+ when NilClass, Symbol, Numeric, TrueClass, FalseClass
16
+ obj
17
+ else
18
+ obj.dup
19
+ end
20
+ end
21
+
22
+ it "does not raise TypeError when deep_dup encounters UninitializedClass in a hash value" do
23
+ data = { "key" => "value", "missing" => uninitialized }
24
+ result = deep_dup(data)
25
+ expect(result["missing"]).to equal(uninitialized)
26
+ end
27
+
28
+ it "does not raise TypeError when deep_dup encounters UninitializedClass in an array" do
29
+ data = ["hello", uninitialized, "world"]
30
+ result = deep_dup(data)
31
+ expect(result[1]).to equal(uninitialized)
32
+ end
33
+
34
+ it "does not raise TypeError when deep_dup encounters UninitializedClass as hash key" do
35
+ data = { uninitialized => "value" }
36
+ result = deep_dup(data)
37
+ expect(result.keys.first).to equal(uninitialized)
38
+ end
39
+ end
@@ -77,8 +77,8 @@ RSpec.describe Lutaml::Model::UninitializedClass do
77
77
  end
78
78
 
79
79
  context "when method doesn't end with '?'" do
80
- it "raises NoMethodError" do
81
- expect { uninitialized.unknown_method }.to raise_error(NoMethodError)
80
+ it "returns nil" do
81
+ expect(uninitialized.unknown_method).to be_nil
82
82
  end
83
83
  end
84
84
  end
@@ -93,4 +93,16 @@ RSpec.describe Lutaml::Model::UninitializedClass do
93
93
  expect(uninitialized.respond_to?(:unknown_method)).to be false
94
94
  end
95
95
  end
96
+
97
+ describe "#dup" do
98
+ it "returns self" do
99
+ expect(uninitialized.dup).to equal(uninitialized)
100
+ end
101
+ end
102
+
103
+ describe "#clone" do
104
+ it "returns self" do
105
+ expect(uninitialized.clone).to equal(uninitialized)
106
+ end
107
+ end
96
108
  end