lutaml-model 0.8.32 → 0.8.34

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/Gemfile +4 -0
  4. data/README.adoc +39 -0
  5. data/docs/conversion_caching.adoc +143 -0
  6. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  7. data/lib/lutaml/model/attribute.rb +89 -0
  8. data/lib/lutaml/model/config.rb +13 -0
  9. data/lib/lutaml/model/configuration.rb +23 -0
  10. data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
  11. data/lib/lutaml/model/error/length_error.rb +19 -0
  12. data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
  13. data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
  14. data/lib/lutaml/model/error/max_length_error.rb +20 -0
  15. data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
  16. data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
  17. data/lib/lutaml/model/error/min_length_error.rb +20 -0
  18. data/lib/lutaml/model/error/restriction_error.rb +8 -0
  19. data/lib/lutaml/model/error/total_digits_error.rb +20 -0
  20. data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
  21. data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
  22. data/lib/lutaml/model/error/type.rb +2 -0
  23. data/lib/lutaml/model/restriction_validation.rb +437 -0
  24. data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
  25. data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
  26. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
  27. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
  28. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
  29. data/lib/lutaml/model/schema/templates.rb +2 -0
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
  31. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
  33. data/lib/lutaml/model/serialize/builder.rb +4 -5
  34. data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
  35. data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
  36. data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
  37. data/lib/lutaml/model/serialize/initialization.rb +4 -2
  38. data/lib/lutaml/model/serialize.rb +61 -0
  39. data/lib/lutaml/model/services/type/validator.rb +23 -0
  40. data/lib/lutaml/model/type/string.rb +28 -3
  41. data/lib/lutaml/model/type/value.rb +231 -0
  42. data/lib/lutaml/model/type.rb +2 -0
  43. data/lib/lutaml/model/validation.rb +2 -1
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/model.rb +16 -0
  46. data/lib/lutaml/xml/model_transform.rb +47 -6
  47. data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
  48. data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
  49. data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
  50. data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
  51. data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
  52. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  53. data/lib/lutaml/xml/transformation.rb +2 -0
  54. data/lib/lutaml/xml/transformation_support.rb +1 -0
  55. data/spec/fixtures/xml/restriction_facets.xsd +46 -0
  56. data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
  57. data/spec/lutaml/model/attribute_spec.rb +7 -2
  58. data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
  59. data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
  60. data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
  61. data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
  62. data/spec/lutaml/model/phantom_value_spec.rb +799 -0
  63. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
  64. data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
  65. data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
  66. data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
  67. data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
  68. data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
  69. data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
  70. data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
  71. metadata +27 -1
@@ -0,0 +1,799 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # An attribute whose type defines its own `self.cast` used to gain a value
6
+ # nobody wrote.
7
+ #
8
+ # The pipeline hands an attribute "no data arrived" — nil, or the uninitialized
9
+ # sentinel — and casts it anyway. Every built-in type returns that input
10
+ # unchanged, so nothing showed. A custom `self.cast` returns a real instance,
11
+ # and that instance became a value the source never carried: a phantom element
12
+ # in the document, or a one-item collection nobody created.
13
+ #
14
+ # There are more ways to reach a cast than the four reader shapes
15
+ # define_attribute_methods dispatches on, so each shape gets its own example
16
+ # here, plus a built-in-typed control that must stay clean either way.
17
+ module PhantomValueSpec
18
+ # The differentiator: a type with its own cast that manufactures an instance
19
+ # for anything, including nil and the sentinel.
20
+ class Eager < Lutaml::Model::Type::Value
21
+ # Value#initialize casts what it is given, so a cast that constructs would
22
+ # recurse forever. Store the value straight, the way a Serializable-typed
23
+ # element (the shape this bug was found on) does.
24
+ def initialize(value) # rubocop:disable Lint/MissingSuper -- see above
25
+ @value = value
26
+ end
27
+
28
+ def self.cast(value)
29
+ return value if value.is_a?(Eager)
30
+
31
+ new("E(#{value.inspect})")
32
+ end
33
+
34
+ def self.serialize(value)
35
+ value.is_a?(Eager) ? value.value.to_s : value.to_s
36
+ end
37
+
38
+ def to_s
39
+ value.to_s
40
+ end
41
+ end
42
+
43
+ # S1 / S1c — enum scalar.
44
+ class EnumScalar < Lutaml::Model::Serializable
45
+ attribute :lead, :string
46
+ attribute :role, Eager, values: %w[a b]
47
+
48
+ xml do
49
+ root "r"
50
+ map_element "lead", to: :lead
51
+ map_element "role", to: :role
52
+ end
53
+
54
+ key_value do
55
+ map "lead", to: :lead
56
+ map "role", to: :role
57
+ end
58
+ end
59
+
60
+ class EnumScalarBuiltIn < Lutaml::Model::Serializable
61
+ attribute :lead, :string
62
+ attribute :role, :string, values: %w[a b]
63
+
64
+ xml do
65
+ root "r"
66
+ map_element "lead", to: :lead
67
+ map_element "role", to: :role
68
+ end
69
+
70
+ key_value do
71
+ map "lead", to: :lead
72
+ map "role", to: :role
73
+ end
74
+ end
75
+
76
+ # S2 / S2c — enum collection.
77
+ class EnumCollection < Lutaml::Model::Serializable
78
+ attribute :lead, :string
79
+ attribute :roles, Eager, values: %w[a b], collection: true
80
+
81
+ xml do
82
+ root "r"
83
+ map_element "lead", to: :lead
84
+ map_element "roles", to: :roles
85
+ end
86
+
87
+ key_value do
88
+ map "lead", to: :lead
89
+ map "roles", to: :roles
90
+ end
91
+ end
92
+
93
+ class EnumCollectionBuiltIn < Lutaml::Model::Serializable
94
+ attribute :lead, :string
95
+ attribute :roles, :string, values: %w[a b c], collection: true
96
+
97
+ xml do
98
+ root "r"
99
+ map_element "lead", to: :lead
100
+ map_element "roles", to: :roles
101
+ end
102
+ end
103
+
104
+ # S3 — enum whose reader the model already defines.
105
+ class EnumPredefinedReader < Lutaml::Model::Serializable
106
+ def role
107
+ "mine"
108
+ end
109
+
110
+ attribute :lead, :string
111
+ attribute :role, Eager, values: %w[a b]
112
+
113
+ xml do
114
+ root "r"
115
+ map_element "lead", to: :lead
116
+ map_element "role", to: :role
117
+ end
118
+ end
119
+
120
+ # S4 — enum with non-String values, so no shorthand methods are generated.
121
+ class EnumNonString < Lutaml::Model::Serializable
122
+ attribute :lead, :string
123
+ attribute :level, :integer, values: [1, 2]
124
+
125
+ xml do
126
+ root "r"
127
+ map_element "lead", to: :lead
128
+ map_element "level", to: :level
129
+ end
130
+
131
+ key_value do
132
+ map "lead", to: :lead
133
+ map "level", to: :level
134
+ end
135
+ end
136
+
137
+ # S5 — derived scalar, name != method_name. The reader casts on every call.
138
+ class DerivedScalar < Lutaml::Model::Serializable
139
+ attribute :lead, :string
140
+ attribute :thing, Eager, method: :source_thing
141
+
142
+ xml do
143
+ root "r"
144
+ map_element "lead", to: :lead
145
+ map_element "thing", to: :thing
146
+ end
147
+
148
+ key_value do
149
+ map "lead", to: :lead
150
+ map "thing", to: :thing
151
+ end
152
+
153
+ def source_thing
154
+ nil
155
+ end
156
+ end
157
+
158
+ class DerivedScalarWithValue < DerivedScalar
159
+ def source_thing
160
+ "real"
161
+ end
162
+ end
163
+
164
+ # S6 — derived collection.
165
+ class DerivedCollection < Lutaml::Model::Serializable
166
+ attribute :lead, :string
167
+ attribute :things, Eager, method: :source_things, collection: true
168
+
169
+ xml do
170
+ root "r"
171
+ map_element "lead", to: :lead
172
+ map_element "things", to: :things
173
+ end
174
+
175
+ key_value do
176
+ map "lead", to: :lead
177
+ map "things", to: :things
178
+ end
179
+
180
+ def source_things
181
+ nil
182
+ end
183
+ end
184
+
185
+ class DerivedCollectionWithValues < DerivedCollection
186
+ def source_things
187
+ %w[x y]
188
+ end
189
+ end
190
+
191
+ # S6b — a derived collection whose source method returns one bare value
192
+ # rather than a list. This is the only path that reaches the collection
193
+ # wrap in cast_derived; an Array source goes through cast_value instead.
194
+ class DerivedCollectionScalarSource < DerivedCollection
195
+ def source_things
196
+ "x"
197
+ end
198
+ end
199
+
200
+ # S6c — a derived collection that declares its own collection class. An
201
+ # ordinary Array source is a list of elements here too, but
202
+ # collection_instance? only recognises the declared class.
203
+ class EagerCollection < Lutaml::Model::Collection
204
+ instances :items, Eager
205
+ end
206
+
207
+ class DerivedCustomCollection < Lutaml::Model::Serializable
208
+ attribute :lead, :string
209
+ attribute :things, Eager, collection: EagerCollection,
210
+ method: :source_things
211
+
212
+ def source_things
213
+ %w[x y]
214
+ end
215
+ end
216
+
217
+ # S7 — derived where name == method_name, so the branch predicate is false
218
+ # and the regular generator runs instead.
219
+ class DerivedSameName < Lutaml::Model::Serializable
220
+ attribute :lead, :string
221
+ attribute :thing, Eager, method: :thing
222
+
223
+ xml do
224
+ root "r"
225
+ map_element "lead", to: :lead
226
+ map_element "thing", to: :thing
227
+ end
228
+ end
229
+
230
+ # S8 — derived whose reader is already defined, so nothing is generated.
231
+ class DerivedPredefinedReader < Lutaml::Model::Serializable
232
+ def thing
233
+ "mine"
234
+ end
235
+
236
+ attribute :lead, :string
237
+ attribute :thing, Eager, method: :source_thing
238
+
239
+ xml do
240
+ root "r"
241
+ map_element "lead", to: :lead
242
+ map_element "thing", to: :thing
243
+ end
244
+
245
+ def source_thing
246
+ nil
247
+ end
248
+ end
249
+
250
+ # S9 / S10 — Reference attributes. cast_element short-circuits into
251
+ # cast_with_metadata before the type check, so these fabricate with a
252
+ # built-in type too.
253
+ class ReferenceScalar < Lutaml::Model::Serializable
254
+ attribute :lead, :string
255
+ attribute :thing, Lutaml::Model::Type::Reference,
256
+ ref_model_class: "PhantomValueSpec::Target",
257
+ ref_key_attribute: :id
258
+
259
+ xml do
260
+ root "r"
261
+ map_element "lead", to: :lead
262
+ end
263
+ end
264
+
265
+ class ReferenceCollection < Lutaml::Model::Serializable
266
+ attribute :lead, :string
267
+ attribute :things, Lutaml::Model::Type::Reference,
268
+ ref_model_class: "PhantomValueSpec::Target",
269
+ ref_key_attribute: :id, collection: true
270
+
271
+ xml do
272
+ root "r"
273
+ map_element "lead", to: :lead
274
+ end
275
+ end
276
+
277
+ # S11 — regular scalar.
278
+ class RegularScalar < Lutaml::Model::Serializable
279
+ attribute :lead, :string
280
+ attribute :thing, Eager
281
+
282
+ xml do
283
+ root "r"
284
+ map_element "lead", to: :lead
285
+ map_element "thing", to: :thing
286
+ end
287
+
288
+ key_value do
289
+ map "lead", to: :lead
290
+ map "thing", to: :thing
291
+ end
292
+ end
293
+
294
+ # S12 — regular collection, custom element type. The shape the downstream
295
+ # phantom <w:t/> came from.
296
+ class RegularCollection < Lutaml::Model::Serializable
297
+ attribute :lead, :string
298
+ attribute :things, Eager, collection: true
299
+
300
+ xml do
301
+ root "r"
302
+ ordered
303
+ map_element "lead", to: :lead
304
+ map_element "things", to: :things
305
+ end
306
+
307
+ key_value do
308
+ map "lead", to: :lead
309
+ map "things", to: :things
310
+ end
311
+ end
312
+
313
+ # Same shape without `ordered`, because rounds 1 and 2 both mistook this for
314
+ # an ordering problem.
315
+ class RegularCollectionUnordered < Lutaml::Model::Serializable
316
+ attribute :lead, :string
317
+ attribute :things, Eager, collection: true
318
+
319
+ xml do
320
+ root "r"
321
+ map_element "lead", to: :lead
322
+ map_element "things", to: :things
323
+ end
324
+ end
325
+
326
+ # S12b — the control. Identical but for the element type.
327
+ class RegularCollectionBuiltIn < Lutaml::Model::Serializable
328
+ attribute :lead, :string
329
+ attribute :things, :string, collection: true
330
+
331
+ xml do
332
+ root "r"
333
+ ordered
334
+ map_element "lead", to: :lead
335
+ map_element "things", to: :things
336
+ end
337
+
338
+ key_value do
339
+ map "lead", to: :lead
340
+ map "things", to: :things
341
+ end
342
+ end
343
+
344
+ # S13 — regular attribute whose writer the model already defines. The value
345
+ # is cast before it reaches that writer, so a phantom can be laundered
346
+ # through third-party code.
347
+ class RegularPredefinedWriter < Lutaml::Model::Serializable
348
+ def thing=(value)
349
+ @thing = "seen(#{value.inspect})"
350
+ end
351
+
352
+ attribute :lead, :string
353
+ attribute :thing, Eager
354
+
355
+ xml do
356
+ root "r"
357
+ map_element "lead", to: :lead
358
+ map_element "thing", to: :thing
359
+ end
360
+ end
361
+
362
+ # S14 — regular attribute whose name collides with an enum value string, so
363
+ # the writer is regenerated even though a setter already exists.
364
+ class RegularEnumShorthandCollision < Lutaml::Model::Serializable
365
+ attribute :lead, :string
366
+ attribute :align, :string, values: %w[char left]
367
+ attribute :char, Eager
368
+
369
+ xml do
370
+ root "r"
371
+ map_element "lead", to: :lead
372
+ map_element "align", to: :align
373
+ map_element "char", to: :char
374
+ end
375
+ end
376
+
377
+ # S17 — regular collection with a custom Collection class.
378
+ class ThingCollection < Lutaml::Model::Collection
379
+ instances :items, Eager
380
+ end
381
+
382
+ class RegularCustomCollection < Lutaml::Model::Serializable
383
+ attribute :lead, :string
384
+ attribute :things, Eager, collection: ThingCollection
385
+
386
+ xml do
387
+ root "r"
388
+ map_element "lead", to: :lead
389
+ map_element "things", to: :things
390
+ end
391
+ end
392
+
393
+ # S18 — a collection with a real default. The fix must not eat these.
394
+ class SeededCollection < Lutaml::Model::Serializable
395
+ attribute :lead, :string
396
+ attribute :things, Eager, collection: true, default: -> { ["seed"] }
397
+
398
+ xml do
399
+ root "r"
400
+ map_element "lead", to: :lead
401
+ map_element "things", to: :things
402
+ end
403
+ end
404
+
405
+ # S20 — a scalar default the mapping asks for explicitly.
406
+ class RenderedDefault < Lutaml::Model::Serializable
407
+ attribute :lead, :string
408
+ attribute :thing, Eager, default: -> { "dflt" }
409
+
410
+ xml do
411
+ root "r"
412
+ map_element "lead", to: :lead
413
+ map_element "thing", to: :thing, render_default: true
414
+ end
415
+ end
416
+ end
417
+
418
+ RSpec.describe "a value nobody wrote" do
419
+ def compact(xml)
420
+ xml.to_s.gsub(/<\?xml[^>]*\?>/, "").gsub(/\s*\n\s*/, "").strip
421
+ end
422
+
423
+ let(:register) { Lutaml::Model::Config.default_register }
424
+ let(:uninitialized) { Lutaml::Model::UninitializedClass.instance }
425
+ let(:bare_xml) { "<r><lead>L</lead></r>" }
426
+ let(:bare_json) { '{"lead":"L"}' }
427
+
428
+ # Each format is asserted against its own output. A single combined
429
+ # assertion would hide the shapes where the formats disagree.
430
+ def emits_nothing_extra(klass, attr_name)
431
+ aggregate_failures do
432
+ expect(compact(klass.new(lead: "L").to_xml)).to eq("<r><lead>L</lead></r>")
433
+ expect(klass.new(lead: "L").to_json).to eq('{"lead":"L"}')
434
+ expect(klass.new(lead: "L").to_yaml).to eq("---\nlead: L\n")
435
+ expect(compact(klass.from_xml(bare_xml).to_xml))
436
+ .to eq("<r><lead>L</lead></r>")
437
+ expect(klass.from_json(bare_json).to_json).to eq('{"lead":"L"}')
438
+ expect(klass.from_yaml("lead: L\n").to_yaml).to eq("---\nlead: L\n")
439
+ expect(klass.attributes[attr_name].cast_element(uninitialized, register))
440
+ .to be(uninitialized)
441
+ end
442
+ end
443
+
444
+ describe "the root cause" do
445
+ # The module claims to protect self.cast for every subclass. It prepends
446
+ # instance methods, so it never sees a class-level cast, which is why the
447
+ # guard has to live in the attribute layer instead.
448
+ it "is not covered by UninitializedClassGuard" do
449
+ expect(Lutaml::Model::Type::Value.singleton_class.ancestors)
450
+ .not_to include(Lutaml::Model::Type::UninitializedClassGuard)
451
+ expect(PhantomValueSpec::Eager.cast(uninitialized))
452
+ .to be_a(PhantomValueSpec::Eager)
453
+ end
454
+
455
+ it "leaves the sentinel alone at the one place a type is handed a value" do
456
+ attr = PhantomValueSpec::RegularCollection.attributes[:things]
457
+
458
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
459
+ expect(attr.cast_value(uninitialized, register)).to be(uninitialized)
460
+ expect(attr.default(register)).to be(uninitialized)
461
+ end
462
+
463
+ it "leaves nil alone there too" do
464
+ attr = PhantomValueSpec::RegularCollection.attributes[:things]
465
+
466
+ expect(attr.cast_element(nil, register)).to be_nil
467
+ expect(attr.cast(nil, :json, register)).to be_nil
468
+ end
469
+
470
+ it "still casts real data" do
471
+ attr = PhantomValueSpec::RegularCollection.attributes[:things]
472
+
473
+ expect(attr.cast_element("x", register)).to be_a(PhantomValueSpec::Eager)
474
+ expect(attr.cast_value(%w[x y], register).size).to eq(2)
475
+ end
476
+ end
477
+
478
+ describe "S1c enum scalar, custom element type" do
479
+ it "emits no element and no key for a source that carried neither" do
480
+ emits_nothing_extra(PhantomValueSpec::EnumScalar, :role)
481
+ end
482
+
483
+ it "does not fail its own validator over a value nobody set" do
484
+ expect { PhantomValueSpec::EnumScalar.new(lead: "L").validate! }
485
+ .not_to raise_error
486
+ end
487
+ end
488
+
489
+ describe "S1 enum scalar, built-in type (control)" do
490
+ it "stays clean" do
491
+ emits_nothing_extra(PhantomValueSpec::EnumScalarBuiltIn, :role)
492
+ end
493
+ end
494
+
495
+ describe "S2c enum collection, custom element type" do
496
+ it "emits no element and no key for a source that carried neither" do
497
+ emits_nothing_extra(PhantomValueSpec::EnumCollection, :roles)
498
+ end
499
+ end
500
+
501
+ describe "S2 enum collection, built-in type" do
502
+ it "keeps an item pushed through the reader" do
503
+ model = PhantomValueSpec::EnumCollectionBuiltIn.new(lead: "L",
504
+ roles: ["a"])
505
+ model.roles << "b"
506
+
507
+ expect(model.roles).to eq(%w[a b])
508
+ end
509
+
510
+ it "carries that item into the document" do
511
+ model = PhantomValueSpec::EnumCollectionBuiltIn.new(lead: "L",
512
+ roles: ["a"])
513
+ model.roles << "b"
514
+
515
+ expect(compact(model.to_xml))
516
+ .to eq("<r><lead>L</lead><roles>a</roles><roles>b</roles></r>")
517
+ end
518
+
519
+ it "keeps an item pushed onto a collection the document omitted" do
520
+ model = PhantomValueSpec::EnumCollectionBuiltIn.from_xml(bare_xml)
521
+ model.roles << "a"
522
+
523
+ expect(model.roles).to eq(["a"])
524
+ expect(compact(model.to_xml))
525
+ .to eq("<r><lead>L</lead><roles>a</roles></r>")
526
+ end
527
+
528
+ it "still keeps duplicates out" do
529
+ model = PhantomValueSpec::EnumCollectionBuiltIn.new(lead: "L")
530
+ model.roles = ["a"]
531
+ model.roles = %w[a b]
532
+
533
+ expect(model.roles).to eq(%w[a b])
534
+ end
535
+
536
+ it "still keeps duplicates out through the shorthand writer" do
537
+ model = PhantomValueSpec::EnumCollectionBuiltIn.new(lead: "L")
538
+ model.a!
539
+ model.a!
540
+ model.b!
541
+
542
+ expect(model.roles).to eq(%w[a b])
543
+ end
544
+
545
+ it "still removes a value through the shorthand writer" do
546
+ model = PhantomValueSpec::EnumCollectionBuiltIn.new(lead: "L",
547
+ roles: %w[a b])
548
+ model.a = false
549
+
550
+ expect(model.roles).to eq(["b"])
551
+ end
552
+ end
553
+
554
+ describe "S3 enum whose reader the model already defines" do
555
+ it "leaves the model's own reader in charge" do
556
+ expect(PhantomValueSpec::EnumPredefinedReader.new(lead: "L").role)
557
+ .to eq("mine")
558
+ expect(compact(PhantomValueSpec::EnumPredefinedReader.new(lead: "L").to_xml))
559
+ .to eq("<r><lead>L</lead><role>mine</role></r>")
560
+ end
561
+
562
+ it "does not fabricate at the attribute layer behind it" do
563
+ attr = PhantomValueSpec::EnumPredefinedReader.attributes[:role]
564
+
565
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
566
+ end
567
+ end
568
+
569
+ describe "S4 enum with non-String values (control)" do
570
+ it "stays clean" do
571
+ emits_nothing_extra(PhantomValueSpec::EnumNonString, :level)
572
+ end
573
+
574
+ it "generates no shorthand methods" do
575
+ expect(PhantomValueSpec::EnumNonString.new(lead: "L")).not_to respond_to(:"1?")
576
+ end
577
+ end
578
+
579
+ describe "S5 derived scalar" do
580
+ # The reader calls into the cast on every call, with no writer in the
581
+ # path, so a writer-side guard cannot reach this shape at all.
582
+ it "emits no element and no key when the source method returns nothing" do
583
+ emits_nothing_extra(PhantomValueSpec::DerivedScalar, :thing)
584
+ end
585
+
586
+ it "reads as nil rather than as a manufactured instance" do
587
+ expect(PhantomValueSpec::DerivedScalar.new(lead: "L").thing).to be_nil
588
+ end
589
+
590
+ it "still casts what the source method does return" do
591
+ model = PhantomValueSpec::DerivedScalarWithValue.new(lead: "L")
592
+
593
+ expect(model.thing).to be_a(PhantomValueSpec::Eager)
594
+ expect(compact(model.to_xml))
595
+ .to eq(%(<r><lead>L</lead><thing>E("real")</thing></r>))
596
+ end
597
+ end
598
+
599
+ describe "S6 derived collection" do
600
+ it "emits no element and no key when the source method returns nothing" do
601
+ emits_nothing_extra(PhantomValueSpec::DerivedCollection, :things)
602
+ end
603
+
604
+ it "reads as a collection, not as a bare instance" do
605
+ expect(PhantomValueSpec::DerivedCollection.new(lead: "L").things).to eq([])
606
+ end
607
+
608
+ it "still casts every item the source method returns" do
609
+ model = PhantomValueSpec::DerivedCollectionWithValues.new(lead: "L")
610
+
611
+ expect(model.things.size).to eq(2)
612
+ expect(model.things).to all(be_a(PhantomValueSpec::Eager))
613
+ expect(compact(model.to_xml))
614
+ .to eq(%(<r><lead>L</lead><things>E("x")</things><things>E("y")</things></r>))
615
+ end
616
+
617
+ it "reads an array source as many elements even with its own collection class" do
618
+ model = PhantomValueSpec::DerivedCustomCollection.new(lead: "L")
619
+
620
+ expect(model.things.size).to eq(2)
621
+ expect(model.things.map(&:to_s)).to eq(['E("x")', 'E("y")'])
622
+ end
623
+
624
+ it "reads a single source value as a one-item collection, not a bare instance" do
625
+ model = PhantomValueSpec::DerivedCollectionScalarSource.new(lead: "L")
626
+
627
+ expect(model.things).to be_a(Array)
628
+ expect(model.things.size).to eq(1)
629
+ expect(model.things.first).to be_a(PhantomValueSpec::Eager)
630
+ expect(compact(model.to_xml))
631
+ .to eq(%(<r><lead>L</lead><things>E("x")</things></r>))
632
+ end
633
+ end
634
+
635
+ describe "S7 derived where the name equals the method name" do
636
+ it "falls through to the regular generator and stays clean" do
637
+ attr = PhantomValueSpec::DerivedSameName.attributes[:thing]
638
+
639
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
640
+ expect(compact(PhantomValueSpec::DerivedSameName.new(lead: "L").to_xml))
641
+ .to eq("<r><lead>L</lead></r>")
642
+ end
643
+ end
644
+
645
+ describe "S8 derived whose reader the model already defines" do
646
+ it "leaves the model's own reader in charge" do
647
+ expect(compact(PhantomValueSpec::DerivedPredefinedReader.new(lead: "L").to_xml))
648
+ .to eq("<r><lead>L</lead><thing>mine</thing></r>")
649
+ end
650
+
651
+ it "does not fabricate at the attribute layer behind it" do
652
+ attr = PhantomValueSpec::DerivedPredefinedReader.attributes[:thing]
653
+
654
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
655
+ end
656
+ end
657
+
658
+ describe "S9 Reference scalar" do
659
+ # cast_element returns before validate_attr_type!, so this shape
660
+ # fabricated a live Reference even with a built-in type.
661
+ it "does not build a Reference out of the sentinel" do
662
+ attr = PhantomValueSpec::ReferenceScalar.attributes[:thing]
663
+
664
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
665
+ expect(attr.cast_value(uninitialized, register)).to be(uninitialized)
666
+ expect(attr.default(register)).to be(uninitialized)
667
+ end
668
+
669
+ it "still builds one out of a real key" do
670
+ attr = PhantomValueSpec::ReferenceScalar.attributes[:thing]
671
+
672
+ expect(attr.cast_element("k1", register))
673
+ .to be_a(Lutaml::Model::Type::Reference)
674
+ end
675
+ end
676
+
677
+ describe "S10 Reference collection" do
678
+ it "does not build a Reference out of the sentinel" do
679
+ attr = PhantomValueSpec::ReferenceCollection.attributes[:things]
680
+
681
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
682
+ expect(attr.default(register)).to be(uninitialized)
683
+ end
684
+ end
685
+
686
+ describe "the cast guard itself" do
687
+ it "still reports an undeclared type when the value carries nothing" do
688
+ klass = Class.new(Lutaml::Model::Serializable) do
689
+ attribute :thing, :no_such_type_is_registered
690
+ end
691
+
692
+ expect { klass.new(thing: nil) }
693
+ .to raise_error(Lutaml::Model::UnknownTypeError)
694
+ end
695
+ end
696
+
697
+ describe "S11 regular scalar" do
698
+ it "emits no element and no key for a source that carried neither" do
699
+ emits_nothing_extra(PhantomValueSpec::RegularScalar, :thing)
700
+ end
701
+
702
+ it "reads as nil on a model built with no value for it" do
703
+ expect(PhantomValueSpec::RegularScalar.new(lead: "L").thing).to be_nil
704
+ end
705
+ end
706
+
707
+ describe "S12 regular collection, custom element type" do
708
+ it "emits no element and no key for a source that carried neither" do
709
+ emits_nothing_extra(PhantomValueSpec::RegularCollection, :things)
710
+ end
711
+
712
+ it "emits nothing on a plain mapping either" do
713
+ expect(compact(PhantomValueSpec::RegularCollectionUnordered.from_xml(bare_xml).to_xml))
714
+ .to eq("<r><lead>L</lead></r>")
715
+ end
716
+
717
+ it "leaves the collection empty rather than holding a made-up item" do
718
+ expect(PhantomValueSpec::RegularCollection.from_xml(bare_xml).things)
719
+ .to eq([])
720
+ end
721
+
722
+ it "still emits what the document did contain" do
723
+ model = PhantomValueSpec::RegularCollection
724
+ .from_xml("<r><lead>L</lead><things>P</things></r>")
725
+
726
+ expect(compact(model.to_xml))
727
+ .to eq(%(<r><lead>L</lead><things>E("P")</things></r>))
728
+ end
729
+
730
+ it "appends through the builder argument instead of replacing" do
731
+ model = PhantomValueSpec::RegularCollection.new(lead: "L")
732
+ model.things("x")
733
+ model.things("y")
734
+
735
+ expect(model.things.size).to eq(2)
736
+ end
737
+ end
738
+
739
+ describe "S12b regular collection, built-in element type (control)" do
740
+ it "was clean before and stays clean" do
741
+ emits_nothing_extra(PhantomValueSpec::RegularCollectionBuiltIn, :things)
742
+ end
743
+ end
744
+
745
+ describe "S13 regular attribute whose writer the model already defines" do
746
+ # The value is cast before it reaches the model's own writer, so a
747
+ # manufactured one gets laundered through code the library does not own.
748
+ it "hands that writer nothing rather than a made-up value" do
749
+ model = PhantomValueSpec::RegularPredefinedWriter.from_json(bare_json)
750
+
751
+ expect(model.thing).to eq("seen(nil)")
752
+ end
753
+
754
+ it "does not fabricate at the attribute layer behind it" do
755
+ attr = PhantomValueSpec::RegularPredefinedWriter.attributes[:thing]
756
+
757
+ expect(attr.cast_element(uninitialized, register)).to be(uninitialized)
758
+ end
759
+ end
760
+
761
+ describe "S14 regular attribute colliding with an enum value name" do
762
+ it "emits no element for a source that carried none" do
763
+ expect(compact(PhantomValueSpec::RegularEnumShorthandCollision.new(lead: "L").to_xml))
764
+ .to eq("<r><lead>L</lead></r>")
765
+ expect(compact(PhantomValueSpec::RegularEnumShorthandCollision.from_xml(bare_xml).to_xml))
766
+ .to eq("<r><lead>L</lead></r>")
767
+ end
768
+ end
769
+
770
+ describe "S17 regular collection with a custom Collection class" do
771
+ it "emits no element for a source that carried none" do
772
+ expect(compact(PhantomValueSpec::RegularCustomCollection.from_xml(bare_xml).to_xml))
773
+ .to eq("<r><lead>L</lead></r>")
774
+ end
775
+
776
+ it "does not fail its own validator over a collection nobody filled" do
777
+ expect { PhantomValueSpec::RegularCustomCollection.new(lead: "L").validate! }
778
+ .not_to raise_error
779
+ end
780
+ end
781
+
782
+ describe "S18 collection with a real default" do
783
+ it "still emits the default" do
784
+ expect(compact(PhantomValueSpec::SeededCollection.new(lead: "L").to_xml))
785
+ .to eq(%(<r><lead>L</lead><things>E("seed")</things></r>))
786
+ expect(compact(PhantomValueSpec::SeededCollection.from_xml(bare_xml).to_xml))
787
+ .to eq(%(<r><lead>L</lead><things>E("seed")</things></r>))
788
+ end
789
+ end
790
+
791
+ describe "S20 scalar with render_default on the mapping" do
792
+ it "still emits the default" do
793
+ expect(compact(PhantomValueSpec::RenderedDefault.new(lead: "L").to_xml))
794
+ .to include("dflt")
795
+ expect(compact(PhantomValueSpec::RenderedDefault.from_xml(bare_xml).to_xml))
796
+ .to include("dflt")
797
+ end
798
+ end
799
+ end