lutaml-model 0.8.3 → 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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +23 -23
- data/README.adoc +213 -1
- data/docs/_guides/document-validation.adoc +303 -0
- data/docs/_guides/index.adoc +1 -0
- data/docs/_guides/xml-mapping.adoc +9 -1
- data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
- data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
- data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
- data/lib/lutaml/model/attribute.rb +19 -1
- data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
- data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
- data/lib/lutaml/model/global_context.rb +1 -0
- data/lib/lutaml/model/liquefiable.rb +12 -15
- data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
- data/lib/lutaml/model/mapping_hash.rb +1 -1
- data/lib/lutaml/model/services/transformer.rb +67 -32
- data/lib/lutaml/model/transform.rb +41 -4
- data/lib/lutaml/model/uninitialized_class.rb +11 -5
- data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
- data/lib/lutaml/model/validation/context.rb +36 -0
- data/lib/lutaml/model/validation/issue.rb +62 -0
- data/lib/lutaml/model/validation/layer_result.rb +34 -0
- data/lib/lutaml/model/validation/profile.rb +66 -0
- data/lib/lutaml/model/validation/registry.rb +60 -0
- data/lib/lutaml/model/validation/remediation.rb +33 -0
- data/lib/lutaml/model/validation/remediation_result.rb +20 -0
- data/lib/lutaml/model/validation/report.rb +39 -0
- data/lib/lutaml/model/validation/rule.rb +59 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/validation_framework.rb +77 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +4 -0
- data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
- data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
- data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
- data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
- data/lib/lutaml/xml/adapter_element.rb +26 -2
- data/lib/lutaml/xml/data_model.rb +14 -0
- data/lib/lutaml/xml/document.rb +3 -0
- data/lib/lutaml/xml/element.rb +8 -2
- data/lib/lutaml/xml/mapping.rb +9 -0
- data/lib/lutaml/xml/model_transform.rb +42 -0
- data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
- data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
- data/lib/lutaml/xml/transformation.rb +40 -1
- data/lib/lutaml/xml/xml_element.rb +8 -7
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
- data/spec/lutaml/model/liquefiable_spec.rb +22 -6
- data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
- data/spec/lutaml/model/ordered_content_spec.rb +5 -5
- data/spec/lutaml/model/services/transformer_spec.rb +43 -0
- data/spec/lutaml/model/transform_cache_spec.rb +62 -0
- data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
- data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
- data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
- data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
- data/spec/lutaml/model/validation/context_spec.rb +60 -0
- data/spec/lutaml/model/validation/issue_spec.rb +77 -0
- data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
- data/spec/lutaml/model/validation/profile_spec.rb +134 -0
- data/spec/lutaml/model/validation/registry_spec.rb +94 -0
- data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
- data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
- data/spec/lutaml/model/validation/report_spec.rb +58 -0
- data/spec/lutaml/model/validation/rule_spec.rb +134 -0
- data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
- data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
- data/spec/lutaml/xml/mapping_spec.rb +12 -7
- metadata +46 -7
- data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
|
@@ -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(
|
|
85
|
-
|
|
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(
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|