lutaml-model 0.8.31 → 0.8.32

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +25 -23
  3. data/docs/_pages/breaking-changes.adoc +58 -0
  4. data/docs/_pages/collections.adoc +25 -24
  5. data/docs/_pages/validation.adoc +48 -1
  6. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  7. data/lib/lutaml/key_value/transform.rb +7 -1
  8. data/lib/lutaml/model/attribute.rb +67 -24
  9. data/lib/lutaml/model/collection.rb +5 -2
  10. data/lib/lutaml/model/collection_handler.rb +45 -2
  11. data/lib/lutaml/model/serialize/enum_handling.rb +5 -1
  12. data/lib/lutaml/model/validation.rb +49 -4
  13. data/lib/lutaml/model/version.rb +1 -1
  14. data/lib/lutaml/xml/format.rb +2 -0
  15. data/lib/lutaml/xml/model_transform.rb +33 -9
  16. data/lib/lutaml/xml/nested_collection_attribute.rb +95 -0
  17. data/lib/lutaml/xml/schema/xsd_schema.rb +396 -128
  18. data/lib/lutaml/xml/serialization/instance_methods.rb +2 -2
  19. data/lib/lutaml/xml/transformation/element_builder.rb +55 -8
  20. data/spec/lutaml/model/attribute_collection_spec.rb +156 -0
  21. data/spec/lutaml/model/attribute_spec.rb +1 -1
  22. data/spec/lutaml/model/cardinality_strict_spec.rb +614 -0
  23. data/spec/lutaml/model/cdata_spec.rb +1 -1
  24. data/spec/lutaml/model/collection_register_spec.rb +119 -0
  25. data/spec/lutaml/model/collection_single_value_coercion_spec.rb +222 -0
  26. data/spec/lutaml/model/root_mappings_spec.rb +10 -7
  27. data/spec/lutaml/model/schema/xsd_schema_spec.rb +785 -25
  28. data/spec/lutaml/model/serializable_validation_spec.rb +293 -0
  29. data/spec/lutaml/model/value_policy_spec.rb +6 -3
  30. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +12 -12
  31. data/spec/lutaml/model/xsd_patterns_spec.rb +29 -29
  32. data/spec/lutaml/model/xsd_type_validation_spec.rb +1 -1
  33. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  34. metadata +5 -1
@@ -0,0 +1,614 @@
1
+ require "spec_helper"
2
+ require_relative "../../../lib/lutaml/model"
3
+
4
+ module CardinalityStrictSpec
5
+ # Singular attribute only, mapped for every format.
6
+ class Simple < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+
9
+ xml do
10
+ root "simple"
11
+ map_element "name", to: :name
12
+ end
13
+
14
+ # key_value covers JSON, YAML and TOML
15
+ key_value do
16
+ map "name", to: :name
17
+ end
18
+ end
19
+
20
+ # Bounded collection, mapped for every format.
21
+ class Ranged < Lutaml::Model::Serializable
22
+ attribute :nick, :string, collection: 0..2
23
+
24
+ xml do
25
+ root "ranged"
26
+ map_element "nick", to: :nick
27
+ end
28
+
29
+ key_value do
30
+ map "nick", to: :nick
31
+ end
32
+ end
33
+
34
+ # Singular attribute deserialized by a custom method. The method receives one
35
+ # node for a single occurrence and the whole array when several arrive — the
36
+ # same shape a custom `to:` method already gets for a collection. It records
37
+ # what it saw so the specs can assert the handover.
38
+ class CustomSingular < Lutaml::Model::Serializable
39
+ attribute :label, :string
40
+
41
+ xml do
42
+ root "custom_singular"
43
+ map_element "label", to: :label,
44
+ with: { from: :label_from, to: :label_to }
45
+ end
46
+
47
+ def self.received
48
+ @received ||= []
49
+ end
50
+
51
+ def label_from(model, node)
52
+ self.class.received << node
53
+ texts = Array(node).map { |n| n.text.upcase }
54
+ model.label = texts.size > 1 ? texts : texts.first
55
+ end
56
+
57
+ def label_to(model, parent, doc); end
58
+ end
59
+
60
+ # A transform on the same rule as a custom deserializer. The transformer runs
61
+ # first and sees whatever arrived, including the over-count array.
62
+ class RecordingTransform < Lutaml::Model::ValueTransformer
63
+ def self.seen
64
+ @seen ||= []
65
+ end
66
+
67
+ def from_xml(value)
68
+ self.class.seen << value
69
+ value
70
+ end
71
+
72
+ def to_xml
73
+ value
74
+ end
75
+ end
76
+
77
+ class CustomWithTransform < Lutaml::Model::Serializable
78
+ attribute :label, :string
79
+
80
+ xml do
81
+ root "custom_with_transform"
82
+ map_element "label", to: :label,
83
+ with: { from: :label_from, to: :label_to },
84
+ transform: RecordingTransform
85
+ end
86
+
87
+ def label_from(model, node)
88
+ model.label = Array(node).map { |n| n.text.upcase }
89
+ end
90
+
91
+ def label_to(model, parent, doc); end
92
+ end
93
+
94
+ # Singular :hash attribute. Shares the same parse branch as custom methods,
95
+ # without a custom method of its own.
96
+ class HashSingular < Lutaml::Model::Serializable
97
+ attribute :meta, :hash
98
+
99
+ xml do
100
+ root "hash_singular"
101
+ map_element "meta", to: :meta
102
+ end
103
+ end
104
+
105
+ # A custom method that collapses the array to one value. The over-count is
106
+ # then not reported: the method decided what the attribute holds.
107
+ class CollapsingCustom < Lutaml::Model::Serializable
108
+ attribute :label, :string
109
+
110
+ xml do
111
+ root "collapsing_custom"
112
+ map_element "label", to: :label,
113
+ with: { from: :label_from, to: :label_to }
114
+ end
115
+
116
+ def label_from(model, node)
117
+ model.label = Array(node).first.text.upcase
118
+ end
119
+
120
+ def label_to(model, parent, doc); end
121
+ end
122
+
123
+ # Singular attribute with an enum. The generated reader used to collapse the
124
+ # stored array via .first, which hid an over-count from #validate entirely.
125
+ class EnumSingular < Lutaml::Model::Serializable
126
+ attribute :kind, :string, values: %w[a b]
127
+
128
+ key_value do
129
+ map "kind", to: :kind
130
+ end
131
+ end
132
+
133
+ # Enum on a bounded collection: the case where both the range and the value
134
+ # set can fail at once.
135
+ class EnumRanged < Lutaml::Model::Serializable
136
+ attribute :kind, :string, values: %w[a b], collection: 0..2
137
+
138
+ xml do
139
+ root "enum_ranged"
140
+ map_element "kind", to: :kind
141
+ end
142
+ end
143
+
144
+ # Enum and pattern on collections, to pin that both skip nil elements.
145
+ class NilElements < Lutaml::Model::Serializable
146
+ attribute :kind, :string, values: %w[a b], collection: true
147
+ attribute :code, :string, pattern: /\A[a-z]+\z/, collection: true
148
+
149
+ key_value do
150
+ map "kind", to: :kind
151
+ map "code", to: :code
152
+ end
153
+ end
154
+
155
+ # A value-level validation that measures length. Stands in for the facets
156
+ # PR #719 adds: it must never run against an over-count array, or a
157
+ # `min_length` check would silently measure the element count instead.
158
+ class LengthChecked < Lutaml::Model::Serializable
159
+ attribute :code, :string, validations: proc {
160
+ validate :code_length
161
+
162
+ def code_length(value)
163
+ errors.add(:code, "measured #{value.length}")
164
+ end
165
+ }
166
+
167
+ key_value do
168
+ map "code", to: :code
169
+ end
170
+ end
171
+
172
+ # A plain Ruby class mapped with `model`. Instances have no #validate, so a
173
+ # cardinality violation has nowhere to be reported later.
174
+ class PlainTarget
175
+ attr_accessor :name
176
+ end
177
+
178
+ class PlainMapper < Lutaml::Model::Serializable
179
+ model PlainTarget
180
+
181
+ attribute :name, :string
182
+
183
+ xml do
184
+ root "plain"
185
+ map_element "name", to: :name
186
+ end
187
+
188
+ key_value do
189
+ map "name", to: :name
190
+ end
191
+ end
192
+ end
193
+
194
+ # #185 asks for cardinality enforcement across XML AND key/value formats.
195
+ # Parsing preserves whatever multiplicity arrived; `.validate` judges it. An
196
+ # over-count on a singular attribute is reported there, not raised at parse.
197
+ # Declared ranges keep their existing parse-time timing.
198
+ RSpec.describe "Issue #185 strict cardinality" do
199
+ describe "non-collection attribute (default 0..1) given more than one value" do
200
+ it "does not raise at parse for XML; reports on validate" do
201
+ obj = nil
202
+ expect do
203
+ obj = CardinalityStrictSpec::Simple.from_xml(
204
+ "<simple><name>A</name><name>B</name></simple>",
205
+ )
206
+ end.not_to raise_error
207
+
208
+ expect(obj.name).to eq(%w[A B])
209
+ expect(obj.validate).to include(
210
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
211
+ )
212
+ expect { obj.validate! }.to raise_error(Lutaml::Model::ValidationError)
213
+ end
214
+
215
+ {
216
+ json: ['{"name":["A","B"]}', '{"name":"A"}'],
217
+ yaml: ["name:\n- A\n- B\n", "name: A\n"],
218
+ toml: ['name = ["A", "B"]', 'name = "A"'],
219
+ }.each do |format, (over_count, single)|
220
+ it "does not raise at parse for #{format.upcase}; reports on validate" do
221
+ obj = nil
222
+ expect do
223
+ obj = CardinalityStrictSpec::Simple.public_send(:"from_#{format}",
224
+ over_count)
225
+ end.not_to raise_error
226
+
227
+ expect(obj.name).to eq(%w[A B])
228
+ expect(obj.validate).to include(
229
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
230
+ )
231
+ end
232
+
233
+ it "accepts a single #{format.upcase} value" do
234
+ obj = CardinalityStrictSpec::Simple.public_send(:"from_#{format}",
235
+ single)
236
+ expect(obj.name).to eq("A")
237
+ expect(obj.validate).to be_empty
238
+ end
239
+ end
240
+
241
+ it "accepts a single XML occurrence as a scalar" do
242
+ obj = CardinalityStrictSpec::Simple.from_xml("<simple><name>A</name></simple>")
243
+ expect(obj.name).to eq("A")
244
+ end
245
+
246
+ it "accepts zero XML occurrences" do
247
+ obj = CardinalityStrictSpec::Simple.from_xml("<simple/>")
248
+ expect(obj.name).to be_nil
249
+ end
250
+
251
+ context "when the singular attribute uses a custom deserializer" do
252
+ before { CardinalityStrictSpec::CustomSingular.received.clear }
253
+
254
+ it "hands the whole array to the custom method for multiple elements" do
255
+ obj = nil
256
+ expect do
257
+ obj = CardinalityStrictSpec::CustomSingular.from_xml(
258
+ "<custom_singular><label>a</label><label>b</label>" \
259
+ "</custom_singular>",
260
+ )
261
+ end.not_to raise_error
262
+
263
+ # Invoked once with the whole array, not once per child.
264
+ received = CardinalityStrictSpec::CustomSingular.received
265
+ expect(received.size).to eq(1)
266
+ expect(received.first).to be_a(Array)
267
+
268
+ expect(obj.label).to eq(%w[A B])
269
+ expect(obj.validate).to include(
270
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
271
+ )
272
+ end
273
+
274
+ it "runs the custom method for a single XML element" do
275
+ obj = CardinalityStrictSpec::CustomSingular.from_xml(
276
+ "<custom_singular><label>hi</label></custom_singular>",
277
+ )
278
+
279
+ received = CardinalityStrictSpec::CustomSingular.received
280
+ expect(received.size).to eq(1)
281
+ expect(received.first).not_to be_a(Array)
282
+
283
+ expect(obj.label).to eq("HI")
284
+ expect(obj.validate).to be_empty
285
+ end
286
+ end
287
+
288
+ context "when a transform shares the rule with a custom deserializer" do
289
+ before { CardinalityStrictSpec::RecordingTransform.seen.clear }
290
+
291
+ it "runs the transform before the custom method, on the whole array" do
292
+ obj = CardinalityStrictSpec::CustomWithTransform.from_xml(
293
+ "<custom_with_transform><label>a</label><label>b</label>" \
294
+ "</custom_with_transform>",
295
+ )
296
+
297
+ seen = CardinalityStrictSpec::RecordingTransform.seen
298
+ expect(seen.size).to eq(1)
299
+ expect(seen.first).to be_a(Array)
300
+
301
+ expect(obj.label).to eq(%w[A B])
302
+ expect(obj.validate).to include(
303
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
304
+ )
305
+ end
306
+ end
307
+
308
+ context "when the custom method collapses the array" do
309
+ it "reports nothing, because the method decided what is stored" do
310
+ obj = CardinalityStrictSpec::CollapsingCustom.from_xml(
311
+ "<collapsing_custom><label>a</label><label>b</label>" \
312
+ "</collapsing_custom>",
313
+ )
314
+
315
+ expect(obj.label).to eq("A")
316
+ expect(obj.validate).to be_empty
317
+ end
318
+ end
319
+
320
+ context "when the model is a plain Ruby class" do
321
+ # A PORO has no #validate, so deferring would discard the violation.
322
+ # These stay eager for that reason, not by oversight.
323
+ it "raises at parse for XML" do
324
+ expect do
325
+ CardinalityStrictSpec::PlainMapper.from_xml(
326
+ "<plain><name>A</name><name>B</name></plain>",
327
+ )
328
+ end.to raise_error(Lutaml::Model::CollectionTrueMissingError, /`name`/)
329
+ end
330
+
331
+ it "raises at parse for key/value" do
332
+ expect do
333
+ CardinalityStrictSpec::PlainMapper.from_json('{"name":["A","B"]}')
334
+ end.to raise_error(Lutaml::Model::CollectionTrueMissingError, /`name`/)
335
+ end
336
+
337
+ it "still accepts a single value" do
338
+ obj = CardinalityStrictSpec::PlainMapper.from_xml(
339
+ "<plain><name>A</name></plain>",
340
+ )
341
+ expect(obj).to be_a(CardinalityStrictSpec::PlainTarget)
342
+ expect(obj.name).to eq("A")
343
+ end
344
+ end
345
+
346
+ context "when the singular attribute is a :hash" do
347
+ it "keeps a single occurrence a Hash" do
348
+ obj = CardinalityStrictSpec::HashSingular.from_xml(
349
+ "<hash_singular><meta><k>v</k></meta></hash_singular>",
350
+ )
351
+ expect(obj.meta).to eq({ "k" => "v" })
352
+ expect(obj.validate).to be_empty
353
+ end
354
+
355
+ it "stores an over-count as an array and reports it on validate" do
356
+ obj = CardinalityStrictSpec::HashSingular.from_xml(
357
+ "<hash_singular><meta><k>v1</k></meta><meta><k>v2</k></meta>" \
358
+ "</hash_singular>",
359
+ )
360
+ expect(obj.meta).to eq([{ "k" => "v1" }, { "k" => "v2" }])
361
+ expect(obj.validate).to include(
362
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
363
+ )
364
+ end
365
+ end
366
+ end
367
+
368
+ describe "declared collection range (collection: 0..2)" do
369
+ it "flags an XML over-max on validate (XML validates ranges lazily)" do
370
+ obj = nil
371
+ expect do
372
+ obj = CardinalityStrictSpec::Ranged.from_xml(
373
+ "<ranged><nick>a</nick><nick>b</nick><nick>c</nick></ranged>",
374
+ )
375
+ end.not_to raise_error
376
+ expect(obj.validate).to include(
377
+ an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
378
+ )
379
+ end
380
+
381
+ it "reports a key/value over-max via validate" do
382
+ obj = nil
383
+ expect do
384
+ obj = CardinalityStrictSpec::Ranged.from_json('{"nick":["a","b","c"]}')
385
+ end.not_to raise_error
386
+ expect(obj.validate).to include(
387
+ an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
388
+ )
389
+ end
390
+
391
+ it "accepts values within range for XML and key/value" do
392
+ xml_obj = CardinalityStrictSpec::Ranged.from_xml(
393
+ "<ranged><nick>a</nick><nick>b</nick></ranged>",
394
+ )
395
+ json_obj = CardinalityStrictSpec::Ranged.from_json('{"nick":["a","b"]}')
396
+ expect(xml_obj.validate).to be_empty
397
+ expect(json_obj.validate).to be_empty
398
+ end
399
+ end
400
+
401
+ describe "absent optional collection (regression: min-side false positive)" do
402
+ # collection: 0..2 with zero occurrences is valid; it must not raise at
403
+ # key/value parse just because an absent value arrives as nil.
404
+ it "does not raise for an absent optional collection in JSON" do
405
+ obj = nil
406
+ expect { obj = CardinalityStrictSpec::Ranged.from_json("{}") }
407
+ .not_to raise_error
408
+ expect(obj.validate).to be_empty
409
+ end
410
+
411
+ it "does not raise for an absent optional collection in YAML" do
412
+ expect { CardinalityStrictSpec::Ranged.from_yaml("--- {}\n") }
413
+ .not_to raise_error
414
+ end
415
+
416
+ it "still raises when the range requires a minimum (1..)" do
417
+ klass = Class.new(Lutaml::Model::Serializable) do
418
+ def self.name = "CardinalityStrictSpec::AtLeastOne"
419
+ attribute :nick, :string, collection: (1..)
420
+ key_value { map "nick", to: :nick }
421
+ end
422
+ expect { klass.from_json("{}") }
423
+ .to raise_error(Lutaml::Model::CollectionCountOutOfRangeError)
424
+ end
425
+ end
426
+
427
+ describe "map_content is left unchanged" do
428
+ let(:mixed) do
429
+ Class.new(Lutaml::Model::Serializable) do
430
+ def self.name = "CardinalityStrictSpec::Mixed"
431
+ attribute :bold, :string, collection: true
432
+ attribute :content, :string
433
+ xml do
434
+ root "r"
435
+ map_element "bold", to: :bold
436
+ map_content to: :content
437
+ end
438
+ end
439
+ end
440
+
441
+ it "does not raise at parse; flags a singular content target on validate" do
442
+ obj = nil
443
+ expect do
444
+ obj = mixed.from_xml(
445
+ "<r>one <bold>b</bold> two <bold>c</bold> three</r>",
446
+ )
447
+ end.not_to raise_error
448
+ expect(obj.validate).to include(
449
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
450
+ )
451
+ end
452
+ end
453
+
454
+ describe "enum on a singular attribute" do
455
+ it "reports an over-count whose elements are outside the value set" do
456
+ obj = CardinalityStrictSpec::EnumSingular.from_json(
457
+ '{"kind":["a","z"]}',
458
+ )
459
+
460
+ expect(obj.kind).to eq(%w[a z])
461
+ expect(obj.validate).to contain_exactly(
462
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
463
+ )
464
+ end
465
+
466
+ it "reports an over-count whose elements are all in the value set" do
467
+ obj = CardinalityStrictSpec::EnumSingular.from_json(
468
+ '{"kind":["a","b"]}',
469
+ )
470
+
471
+ expect(obj.kind).to eq(%w[a b])
472
+ expect(obj.validate).to contain_exactly(
473
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
474
+ )
475
+ end
476
+
477
+ it "leaves a single value reading as a scalar" do
478
+ obj = CardinalityStrictSpec::EnumSingular.from_json('{"kind":"a"}')
479
+
480
+ expect(obj.kind).to eq("a")
481
+ expect(obj.validate).to be_empty
482
+ end
483
+
484
+ it "still rejects a single value outside the set" do
485
+ obj = CardinalityStrictSpec::EnumSingular.from_json('{"kind":"z"}')
486
+
487
+ expect(obj.validate).to contain_exactly(
488
+ an_instance_of(Lutaml::Model::InvalidValueError),
489
+ )
490
+ end
491
+
492
+ # The generated `a?` shorthand reads through the same reader. An attribute
493
+ # holding two values has no honest "yes" to give.
494
+ it "answers the value shorthand false while over-counted" do
495
+ obj = CardinalityStrictSpec::EnumSingular.from_json(
496
+ '{"kind":["a","z"]}',
497
+ )
498
+
499
+ expect(obj.a?).to be(false)
500
+
501
+ obj.kind = "a"
502
+ expect(obj.a?).to be(true)
503
+ end
504
+
505
+ # Known limitation, pinned so it stays visible. An enum setter always
506
+ # stores its value as an array, so a one-element array and a bare scalar
507
+ # are indistinguishable by the time #validate reads the slot. The
508
+ # shape-based rule therefore catches over-counts on an enum but not a
509
+ # one-element array, which a non-enum attribute does flag. Closing this
510
+ # needs the enum setter to preserve the assigned shape.
511
+ it "does not flag a one-element array, unlike a non-enum attribute" do
512
+ enumed = CardinalityStrictSpec::EnumSingular.from_json('{"kind":["a"]}')
513
+ plain = CardinalityStrictSpec::Simple.from_json('{"name":["A"]}')
514
+
515
+ expect(enumed.kind).to eq("a")
516
+ expect(enumed.validate).to be_empty
517
+
518
+ expect(plain.validate).to contain_exactly(
519
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
520
+ )
521
+ end
522
+
523
+ # An invalid document does not round-trip. The array reaches the writer as
524
+ # the attribute's value and gets stringified, which is what any singular
525
+ # attribute holding an over-count already does — this is not specific to
526
+ # enums. Pinned so the shape is a visible decision. Fixing the violation
527
+ # restores clean output.
528
+ it "does not round-trip an over-count" do
529
+ obj = CardinalityStrictSpec::EnumSingular.from_json(
530
+ '{"kind":["a","b"]}',
531
+ )
532
+
533
+ expect(obj.to_json).to eq('{"kind":"[\"a\", \"b\"]"}')
534
+
535
+ obj.kind = "a"
536
+ expect(obj.to_json).to eq('{"kind":"a"}')
537
+ end
538
+ end
539
+
540
+ describe "enum on a bounded collection" do
541
+ # Shape before contents: the count is the more fundamental violation, so a
542
+ # value failing both its range and its value set reports the range.
543
+ it "reports the range when both the range and the value set fail" do
544
+ obj = CardinalityStrictSpec::EnumRanged.from_xml(
545
+ "<enum_ranged><kind>a</kind><kind>z</kind><kind>q</kind></enum_ranged>",
546
+ )
547
+
548
+ expect(obj.validate).to contain_exactly(
549
+ an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
550
+ )
551
+ end
552
+
553
+ it "reports the value set when only the value set fails" do
554
+ obj = CardinalityStrictSpec::EnumRanged.from_xml(
555
+ "<enum_ranged><kind>a</kind><kind>z</kind></enum_ranged>",
556
+ )
557
+
558
+ expect(obj.validate).to contain_exactly(
559
+ an_instance_of(Lutaml::Model::InvalidValueError),
560
+ )
561
+ end
562
+ end
563
+
564
+ describe "nil elements in a collection" do
565
+ it "skips them for both enum and pattern checks" do
566
+ obj = CardinalityStrictSpec::NilElements.from_json(
567
+ '{"kind":["a",null],"code":["ab",null]}',
568
+ )
569
+
570
+ expect(obj.validate).to be_empty
571
+ end
572
+
573
+ it "still reports a non-nil element outside the value set" do
574
+ obj = CardinalityStrictSpec::NilElements.from_json(
575
+ '{"kind":["a",null,"z"]}',
576
+ )
577
+
578
+ expect(obj.validate).to contain_exactly(
579
+ an_instance_of(Lutaml::Model::InvalidValueError),
580
+ )
581
+ end
582
+ end
583
+
584
+ describe "a one-element array on a singular attribute" do
585
+ # The rule is about shape, not count: the attribute is declared to hold a
586
+ # value, and an array of one is still an array.
587
+ it "is a violation even though only one value arrived" do
588
+ obj = CardinalityStrictSpec::Simple.from_json('{"name":["A"]}')
589
+
590
+ expect(obj.name).to eq(["A"])
591
+ expect(obj.validate).to contain_exactly(
592
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
593
+ )
594
+ end
595
+ end
596
+
597
+ describe "value-level validations against an over-count (PR #719 guard)" do
598
+ it "does not run them, so nothing measures the element count" do
599
+ obj = CardinalityStrictSpec::LengthChecked.from_json(
600
+ '{"code":["abcd","efgh"]}',
601
+ )
602
+
603
+ expect(obj.validate).to contain_exactly(
604
+ an_instance_of(Lutaml::Model::CollectionTrueMissingError),
605
+ )
606
+ end
607
+
608
+ it "runs them normally for a single value" do
609
+ obj = CardinalityStrictSpec::LengthChecked.from_json('{"code":"abcd"}')
610
+
611
+ expect(obj.validate.map(&:message).join).to include("measured 4")
612
+ end
613
+ end
614
+ end
@@ -17,7 +17,7 @@ module CdataSpec
17
17
 
18
18
  class Alpha < Lutaml::Model::Serializable
19
19
  attribute :element1, :string
20
- attribute :element2, :string
20
+ attribute :element2, :string, collection: true
21
21
  attribute :element3, :string
22
22
  attribute :beta, Beta
23
23