lutaml-model 0.8.45 → 0.8.47

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.
@@ -106,6 +106,77 @@ module Lutaml
106
106
 
107
107
  attr_accessor :importable_mappings
108
108
 
109
+ # lutaml-model#88: attribute-value discriminator. Keys are wire
110
+ # names (an XML attribute, or an object key in key-value formats),
111
+ # values the expected string value. Shared by the XML and KV DSLs.
112
+ def validate_when_attribute!(when_attribute)
113
+ when_attribute.each do |k, v|
114
+ next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
115
+ (v.is_a?(::String) || v.is_a?(::Symbol))
116
+
117
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
118
+ "when_attribute expects string/symbol attribute names " \
119
+ "mapped to string/symbol values, got " \
120
+ "#{k.inspect} => #{v.inspect}"
121
+ end
122
+ end
123
+
124
+ # lutaml-model#88: what a parse does with an occurrence that no
125
+ # rule on the wire name claims — :drop it (default) or :raise
126
+ # UnknownDiscriminatorError. Only meaningful on discriminator rules.
127
+ def validate_unmatched!(unmatched, when_attribute)
128
+ unless %i[drop raise].include?(unmatched)
129
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
130
+ "unmatched expects :drop or :raise, got #{unmatched.inspect}"
131
+ end
132
+
133
+ return unless when_attribute.empty? && unmatched != :drop
134
+
135
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
136
+ "unmatched only applies to rules declared with when_attribute"
137
+ end
138
+
139
+ # lutaml-model#88: grouped discriminator form.
140
+ # `when_attribute: "type"` names the discriminator key; `to:` maps
141
+ # each wire value to its target attribute. Validates the shape and
142
+ # returns the group pairs — the DSL then expands one rule per
143
+ # value, so the compiled rules are identical to the per-rule form.
144
+ def when_attribute_group(when_attribute, to)
145
+ if when_attribute.to_s.empty?
146
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
147
+ "when_attribute group key cannot be empty"
148
+ end
149
+ unless to.is_a?(::Hash) && !to.empty?
150
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
151
+ "when_attribute: #{when_attribute.inspect} is the grouped " \
152
+ "form and requires to: { value => attribute }, got " \
153
+ "to: #{to.inspect}"
154
+ end
155
+
156
+ to.each do |value, target|
157
+ next if (value.is_a?(::String) || value.is_a?(::Symbol)) &&
158
+ (target.is_a?(::String) || target.is_a?(::Symbol))
159
+
160
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
161
+ "when_attribute group expects string/symbol values mapped " \
162
+ "to string/symbol attribute names, got " \
163
+ "#{value.inspect} => #{target.inspect}"
164
+ end
165
+ to
166
+ end
167
+
168
+ # The per-rule `{ name => value }` form maps ONE attribute; a Hash
169
+ # `to:` only makes sense with the grouped form.
170
+ def reject_ambiguous_when_attribute!(when_attribute, to)
171
+ return unless when_attribute.is_a?(::Hash) && !when_attribute.empty? &&
172
+ to.is_a?(::Hash)
173
+
174
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
175
+ "to: must name a single attribute when when_attribute is the " \
176
+ "per-rule { name => value } form; the grouped form is " \
177
+ 'when_attribute: "key", to: { value => attribute }'
178
+ end
179
+
109
180
  def register(register_id = nil)
110
181
  register_id ||= Lutaml::Model::Config.default_register
111
182
  Lutaml::Model::GlobalRegister.lookup(register_id)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.45"
5
+ VERSION = "0.8.47"
6
6
  end
7
7
  end
@@ -539,6 +539,48 @@ module Lutaml
539
539
  name, to, with, render_nil, render_empty, type: TYPES[:element]
540
540
  )
541
541
 
542
+ # lutaml-model#88: grouped form — `when_attribute: "type"` plus
543
+ # `to: { value => attribute }` expands to one rule per value at
544
+ # DSL time, so the compiled rules are identical to the per-rule
545
+ # form and nothing downstream changes.
546
+ if when_attribute.is_a?(::String) || when_attribute.is_a?(::Symbol)
547
+ # These locals are `false` when their kwarg default ran and
548
+ # nil when the caller passed the kwarg explicitly (planned
549
+ # locals read as nil, so defined? is useless here).
550
+ if namespace_set.nil? || prefix_provided.nil? ||
551
+ xsd_type_provided.nil?
552
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
553
+ "namespace/prefix/xsd_type are not supported in the " \
554
+ "grouped when_attribute form; declare rules individually"
555
+ end
556
+
557
+ when_attribute_group(when_attribute, to).each do |value, target|
558
+ map_element(
559
+ name,
560
+ to: target,
561
+ render_nil: render_nil,
562
+ render_default: render_default,
563
+ render_empty: render_empty,
564
+ treat_nil: treat_nil,
565
+ treat_empty: treat_empty,
566
+ treat_omitted: treat_omitted,
567
+ with: with,
568
+ delegate: delegate,
569
+ cdata: cdata,
570
+ polymorphic: polymorphic,
571
+ transform: transform,
572
+ value_map: value_map,
573
+ when_attribute: { when_attribute.to_s => value.to_s },
574
+ unmatched: unmatched,
575
+ form: form,
576
+ documentation: documentation,
577
+ raw: raw,
578
+ )
579
+ end
580
+ return
581
+ end
582
+ reject_ambiguous_when_attribute!(when_attribute, to)
583
+
542
584
  validate_when_attribute!(when_attribute) unless when_attribute.empty?
543
585
  validate_unmatched!(unmatched, when_attribute)
544
586
 
@@ -614,35 +656,6 @@ module Lutaml
614
656
  end
615
657
  end
616
658
 
617
- # lutaml-model#88: attribute-value discriminator. Keys are wire
618
- # attribute names, values the expected string value.
619
- def validate_when_attribute!(when_attribute)
620
- when_attribute.each do |k, v|
621
- next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
622
- (v.is_a?(::String) || v.is_a?(::Symbol))
623
-
624
- raise Lutaml::Model::IncorrectMappingArgumentsError,
625
- "when_attribute expects string/symbol attribute names " \
626
- "mapped to string/symbol values, got " \
627
- "#{k.inspect} => #{v.inspect}"
628
- end
629
- end
630
-
631
- # lutaml-model#88: what a parse does with an occurrence that no
632
- # rule on the wire name claims — :drop it (default) or :raise
633
- # UnknownDiscriminatorError. Only meaningful on discriminator rules.
634
- def validate_unmatched!(unmatched, when_attribute)
635
- unless %i[drop raise].include?(unmatched)
636
- raise Lutaml::Model::IncorrectMappingArgumentsError,
637
- "unmatched expects :drop or :raise, got #{unmatched.inspect}"
638
- end
639
-
640
- return unless when_attribute.empty? && unmatched != :drop
641
-
642
- raise Lutaml::Model::IncorrectMappingArgumentsError,
643
- "unmatched only applies to rules declared with when_attribute"
644
- end
645
-
646
659
  def map_attribute(
647
660
  name,
648
661
  to: nil,
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: `when_attribute` partitioning for key-value formats.
6
+ # The discriminator is an object key: items of the array at a shared wire
7
+ # key are claimed by the rule whose pairs their key values satisfy.
8
+ # Serialization merges every rule's items back under the one key, each
9
+ # item stamped with its rule's discriminator pairs.
10
+ class KvWhenComponent < Lutaml::Model::Serializable
11
+ attribute :text, :string
12
+
13
+ json do
14
+ map "text", to: :text
15
+ end
16
+
17
+ yaml do
18
+ map "text", to: :text
19
+ end
20
+
21
+ toml do
22
+ map "text", to: :text
23
+ end
24
+
25
+ hsh do
26
+ map "text", to: :text
27
+ end
28
+ end
29
+
30
+ class KvWhenRequirement < Lutaml::Model::Serializable
31
+ attribute :guidance, KvWhenComponent, collection: true
32
+ attribute :purpose, KvWhenComponent, collection: true
33
+
34
+ json do
35
+ map "component", to: :guidance,
36
+ when_attribute: { "type" => "guidance" }
37
+ map "component", to: :purpose,
38
+ when_attribute: { "type" => "purpose" }
39
+ end
40
+
41
+ yaml do
42
+ map "component", to: :guidance,
43
+ when_attribute: { "type" => "guidance" }
44
+ map "component", to: :purpose,
45
+ when_attribute: { "type" => "purpose" }
46
+ end
47
+
48
+ hsh do
49
+ map "component", to: :guidance,
50
+ when_attribute: { "type" => "guidance" }
51
+ map "component", to: :purpose,
52
+ when_attribute: { "type" => "purpose" }
53
+ end
54
+ end
55
+
56
+ RSpec.describe "when_attribute for key-value formats" do
57
+ before do
58
+ stub_const("KvWhen::Component", KvWhenComponent)
59
+ stub_const("KvWhen::Requirement", KvWhenRequirement)
60
+ end
61
+
62
+ let(:payload) do
63
+ {
64
+ "component" => [
65
+ { "type" => "guidance", "text" => "g1" },
66
+ { "type" => "purpose", "text" => "p1" },
67
+ { "type" => "guidance", "text" => "g2" },
68
+ ],
69
+ }
70
+ end
71
+
72
+ it "partitions json by the discriminator key" do
73
+ req = KvWhenRequirement.from_json(payload.to_json)
74
+
75
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
76
+ expect(req.purpose.map(&:text)).to eq(["p1"])
77
+ end
78
+
79
+ it "partitions yaml arrays of mappings" do
80
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
81
+
82
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
83
+ expect(req.purpose.map(&:text)).to eq(["p1"])
84
+ end
85
+
86
+ it "partitions hashes passed directly with symbol keys" do
87
+ req = KvWhenRequirement.from_hash(
88
+ component: [
89
+ { type: "guidance", text: "g1" },
90
+ { type: "purpose", text: "p1" },
91
+ ],
92
+ )
93
+
94
+ expect(req.guidance.map(&:text)).to eq(["g1"])
95
+ expect(req.purpose.map(&:text)).to eq(["p1"])
96
+ end
97
+
98
+ it "keeps a single object occurrence as one item" do
99
+ req = KvWhenRequirement.from_hash(
100
+ "component" => { "type" => "purpose", "text" => "p1" },
101
+ )
102
+
103
+ expect(req.purpose.map(&:text)).to eq(["p1"])
104
+ expect(req.guidance).to be_empty
105
+ end
106
+
107
+ it "serializes one merged array with the discriminators stamped" do
108
+ req = KvWhenRequirement.from_json(payload.to_json)
109
+ out = req.to_json
110
+
111
+ items = JSON.parse(out)["component"]
112
+ expect(items).to eq([
113
+ { "type" => "guidance", "text" => "g1" },
114
+ { "type" => "guidance", "text" => "g2" },
115
+ { "type" => "purpose", "text" => "p1" },
116
+ ])
117
+ end
118
+
119
+ it "round-trips through yaml" do
120
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
121
+ again = KvWhenRequirement.from_yaml(req.to_yaml)
122
+
123
+ expect(again.guidance.map(&:text)).to eq(%w[g1 g2])
124
+ expect(again.purpose.map(&:text)).to eq(["p1"])
125
+ end
126
+
127
+ it "does not stamp over a key the item's own mapping writes" do
128
+ holder = Class.new(Lutaml::Model::Serializable) do
129
+ attribute :items, KvWhenComponent, collection: true
130
+
131
+ json do
132
+ map "component", to: :items,
133
+ when_attribute: { "type" => "guidance" }
134
+ end
135
+
136
+ hsh do
137
+ map "component", to: :items,
138
+ when_attribute: { "type" => "guidance" }
139
+ end
140
+ end
141
+ stub_const("KvWhen::Holder", holder)
142
+
143
+ out = holder.from_hash(
144
+ "component" => [{ "type" => "guidance", "text" => "g" }],
145
+ ).to_json
146
+ expect(JSON.parse(out)["component"]).to eq(
147
+ [{ "type" => "guidance", "text" => "g" }],
148
+ )
149
+ end
150
+
151
+ describe "plain rule complement" do
152
+ let(:holder) do
153
+ Class.new(Lutaml::Model::Serializable) do
154
+ attribute :special, KvWhenComponent, collection: true
155
+ attribute :plain, KvWhenComponent, collection: true
156
+
157
+ json do
158
+ map "item", to: :special, when_attribute: { "kind" => "special" }
159
+ map "item", to: :plain
160
+ end
161
+
162
+ hsh do
163
+ map "item", to: :special, when_attribute: { "kind" => "special" }
164
+ map "item", to: :plain
165
+ end
166
+ end
167
+ end
168
+
169
+ it "captures occurrences no discriminator claimed exactly once" do
170
+ stub_const("KvWhen::Both", holder)
171
+ doc = holder.from_hash(
172
+ "item" => [
173
+ { "kind" => "special", "text" => "b" },
174
+ { "text" => "a" },
175
+ { "kind" => "other", "text" => "x" },
176
+ ],
177
+ )
178
+
179
+ expect(doc.special.map(&:text)).to eq(["b"])
180
+ expect(doc.plain.map(&:text)).to eq(%w[a x])
181
+ end
182
+
183
+ it "merges plain and stamped items into the one key" do
184
+ stub_const("KvWhen::Both", holder)
185
+ doc = holder.from_hash(
186
+ "item" => [
187
+ { "kind" => "special", "text" => "b" },
188
+ { "text" => "a" },
189
+ ],
190
+ )
191
+ items = JSON.parse(doc.to_json)["item"]
192
+
193
+ expect(items).to eq([
194
+ { "kind" => "special", "text" => "b" },
195
+ { "text" => "a" },
196
+ ])
197
+ end
198
+ end
199
+
200
+ describe "unmatched policy" do
201
+ let(:strict) do
202
+ Class.new(Lutaml::Model::Serializable) do
203
+ attribute :guidance, KvWhenComponent, collection: true
204
+
205
+ json do
206
+ map "component", to: :guidance,
207
+ when_attribute: { "type" => "guidance" },
208
+ unmatched: :raise
209
+ end
210
+ end
211
+ end
212
+
213
+ it "raises on an unclaimed discriminator value" do
214
+ stub_const("KvWhen::Strict", strict)
215
+
216
+ expect do
217
+ strict.from_json({ "component" => [
218
+ { "type" => "guidance", "text" => "g" },
219
+ { "type" => "nope", "text" => "x" },
220
+ ] }.to_json)
221
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="nope"/)
222
+ end
223
+
224
+ it "raises on an item without the discriminator key" do
225
+ stub_const("KvWhen::Strict", strict)
226
+
227
+ expect do
228
+ strict.from_json({ "component" => [{ "text" => "x" }] }.to_json)
229
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /<component>/)
230
+ end
231
+
232
+ it "drops unclaimed occurrences by default" do
233
+ lenient = Class.new(Lutaml::Model::Serializable) do
234
+ attribute :guidance, KvWhenComponent, collection: true
235
+
236
+ json do
237
+ map "component", to: :guidance,
238
+ when_attribute: { "type" => "guidance" }
239
+ end
240
+ end
241
+ stub_const("KvWhen::Lenient", lenient)
242
+
243
+ doc = lenient.from_json({ "component" => [
244
+ { "type" => "guidance", "text" => "g" },
245
+ { "type" => "other", "text" => "x" },
246
+ ] }.to_json)
247
+ expect(doc.guidance.map(&:text)).to eq(["g"])
248
+ end
249
+ end
250
+
251
+ describe "toml arrays of tables" do
252
+ let(:toml_holder) do
253
+ Class.new(Lutaml::Model::Serializable) do
254
+ attribute :guidance, KvWhenComponent, collection: true
255
+ attribute :purpose, KvWhenComponent, collection: true
256
+
257
+ toml do
258
+ map "component", to: :guidance,
259
+ when_attribute: { "type" => "guidance" }
260
+ map "component", to: :purpose,
261
+ when_attribute: { "type" => "purpose" }
262
+ end
263
+ end
264
+ end
265
+
266
+ it "partitions and round-trips" do
267
+ stub_const("KvWhen::TomlHolder", toml_holder)
268
+ src = <<~TOML
269
+ [[component]]
270
+ type = "guidance"
271
+ text = "g1"
272
+
273
+ [[component]]
274
+ type = "purpose"
275
+ text = "p1"
276
+ TOML
277
+
278
+ doc = toml_holder.from_toml(src)
279
+ expect(doc.guidance.map(&:text)).to eq(["g1"])
280
+ expect(doc.purpose.map(&:text)).to eq(["p1"])
281
+
282
+ again = toml_holder.from_toml(doc.to_toml)
283
+ expect(again.guidance.map(&:text)).to eq(["g1"])
284
+ expect(again.purpose.map(&:text)).to eq(["p1"])
285
+ end
286
+ end
287
+
288
+ describe "grouped form" do
289
+ let(:grouped) do
290
+ Class.new(Lutaml::Model::Serializable) do
291
+ attribute :guidance, KvWhenComponent, collection: true
292
+ attribute :purpose, KvWhenComponent, collection: true
293
+
294
+ json do
295
+ map "component", when_attribute: "type",
296
+ to: { guidance: :guidance, purpose: :purpose },
297
+ unmatched: :raise
298
+ end
299
+
300
+ hsh do
301
+ map "component", when_attribute: "type",
302
+ to: { guidance: :guidance, purpose: :purpose }
303
+ end
304
+ end
305
+ end
306
+
307
+ it "expands to one rule per value with identical behavior" do
308
+ stub_const("KvWhen::Grouped", grouped)
309
+ req = grouped.from_json(payload.to_json)
310
+
311
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
312
+ expect(req.purpose.map(&:text)).to eq(["p1"])
313
+ expect(JSON.parse(req.to_json)["component"]).to eq([
314
+ { "type" => "guidance", "text" => "g1" },
315
+ { "type" => "guidance", "text" => "g2" },
316
+ { "type" => "purpose", "text" => "p1" },
317
+ ])
318
+
319
+ rules = grouped.mappings_for(:json).mappings.select { |r| r.name == "component" }
320
+ expect(rules.map(&:when_attribute)).to eq(
321
+ [{ "type" => "guidance" }, { "type" => "purpose" }],
322
+ )
323
+ end
324
+
325
+ it "works through the hash format" do
326
+ stub_const("KvWhen::Grouped", grouped)
327
+ req = grouped.from_hash(
328
+ "component" => [{ type: "purpose", text: "p1" }],
329
+ )
330
+
331
+ expect(req.purpose.map(&:text)).to eq(["p1"])
332
+ expect(req.guidance).to be_empty
333
+ end
334
+
335
+ it "forwards the unmatched policy to every rule" do
336
+ stub_const("KvWhen::Grouped", grouped)
337
+
338
+ expect do
339
+ grouped.from_json({ "component" => [{ "type" => "nope" }] }.to_json)
340
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="nope"/)
341
+ end
342
+
343
+ it "rejects the grouped form without a to: map" do
344
+ expect do
345
+ Class.new(Lutaml::Model::Serializable) do
346
+ attribute :x, :string
347
+
348
+ json do
349
+ map "x", to: :x, when_attribute: "type"
350
+ end
351
+ end
352
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /to:/)
353
+ end
354
+
355
+ it "rejects a Hash to: with the per-rule form" do
356
+ expect do
357
+ Class.new(Lutaml::Model::Serializable) do
358
+ attribute :x, :string
359
+
360
+ json do
361
+ map "x", when_attribute: { "type" => "a" }, to: { "a" => :x }
362
+ end
363
+ end
364
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /single attribute/)
365
+ end
366
+ end
367
+
368
+ describe "DSL validation" do
369
+ it "rejects non-string/symbol pairs" do
370
+ expect do
371
+ Class.new(Lutaml::Model::Serializable) do
372
+ attribute :x, :string
373
+
374
+ json do
375
+ map "x", to: :x, when_attribute: { "type" => 42 }
376
+ end
377
+ end
378
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
379
+ end
380
+
381
+ it "rejects unmatched without when_attribute" do
382
+ expect do
383
+ Class.new(Lutaml::Model::Serializable) do
384
+ attribute :x, :string
385
+
386
+ json do
387
+ map "x", to: :x, unmatched: :raise
388
+ end
389
+ end
390
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
391
+ end
392
+
393
+ it "rejects unknown policy values" do
394
+ expect do
395
+ Class.new(Lutaml::Model::Serializable) do
396
+ attribute :x, :string
397
+
398
+ json do
399
+ map "x", to: :x, when_attribute: { "type" => "a" },
400
+ unmatched: :explode
401
+ end
402
+ end
403
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /unmatched/)
404
+ end
405
+
406
+ it "rejects when_attribute combined with custom methods" do
407
+ expect do
408
+ Class.new(Lutaml::Model::Serializable) do
409
+ attribute :x, :string
410
+
411
+ json do
412
+ map "x", to: :x, when_attribute: { "type" => "a" },
413
+ with: { to: :x_to, from: :x_from }
414
+ end
415
+ end
416
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
417
+ end
418
+
419
+ it "survives deep_dup of mappings" do
420
+ rule = KvWhenRequirement.mappings_for(:json).mappings.find do |r|
421
+ r.to == :guidance
422
+ end
423
+
424
+ expect(rule.when_attribute).to eq("type" => "guidance")
425
+ expect(rule.deep_dup.when_attribute).to eq("type" => "guidance")
426
+ end
427
+ end
428
+ end
@@ -131,6 +131,99 @@ RSpec.describe "when_attribute discriminator mappings" do
131
131
  end.to raise_error(ArgumentError)
132
132
  end
133
133
 
134
+ describe "grouped form" do
135
+ let(:grouped) do
136
+ Class.new(Lutaml::Model::Serializable) do
137
+ attribute :guidance, WhenAttrComponent, collection: true
138
+ attribute :purpose, WhenAttrComponent, collection: true
139
+
140
+ xml do
141
+ element "requirement"
142
+ map_element "component", when_attribute: "type",
143
+ to: { "guidance" => :guidance,
144
+ "purpose" => :purpose },
145
+ unmatched: :raise
146
+ end
147
+ end
148
+ end
149
+
150
+ let(:grouped_xml) do
151
+ <<~XML
152
+ <requirement>
153
+ <component type="guidance"><text>g1</text></component>
154
+ <component type="purpose"><text>p1</text></component>
155
+ <component type="guidance"><text>g2</text></component>
156
+ </requirement>
157
+ XML
158
+ end
159
+
160
+ it "expands to one rule per value with identical behavior" do
161
+ stub_const("WhenAttr::Grouped", grouped)
162
+ req = grouped.from_xml(grouped_xml)
163
+
164
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
165
+ expect(req.purpose.map(&:text)).to eq(["p1"])
166
+ expect(req.to_xml).to include('type="guidance"')
167
+
168
+ rules = grouped.mappings_for(:xml).mappings.select { |r| r.name == "component" }
169
+ expect(rules.map(&:when_attribute)).to eq(
170
+ [{ "type" => "guidance" }, { "type" => "purpose" }],
171
+ )
172
+ end
173
+
174
+ it "forwards the unmatched policy to every rule" do
175
+ stub_const("WhenAttr::Grouped", grouped)
176
+
177
+ expect do
178
+ grouped.from_xml(
179
+ '<requirement><component type="other"><text>x</text></component></requirement>',
180
+ )
181
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="other"/)
182
+ end
183
+
184
+ it "rejects the grouped form without a to: map" do
185
+ expect do
186
+ Class.new(Lutaml::Model::Serializable) do
187
+ attribute :x, :string
188
+
189
+ xml do
190
+ element "x"
191
+ map_element "x", to: :x, when_attribute: "type"
192
+ end
193
+ end
194
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /to:/)
195
+ end
196
+
197
+ it "rejects a Hash to: with the per-rule form" do
198
+ expect do
199
+ Class.new(Lutaml::Model::Serializable) do
200
+ attribute :x, :string
201
+
202
+ xml do
203
+ element "x"
204
+ map_element "x", when_attribute: { "type" => "a" },
205
+ to: { "a" => :x }
206
+ end
207
+ end
208
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /single attribute/)
209
+ end
210
+
211
+ it "rejects namespace in the grouped form" do
212
+ expect do
213
+ Class.new(Lutaml::Model::Serializable) do
214
+ attribute :x, :string
215
+
216
+ xml do
217
+ element "x"
218
+ map_element "x", when_attribute: "type",
219
+ to: { "a" => :x },
220
+ namespace: "https://example.com"
221
+ end
222
+ end
223
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /grouped/)
224
+ end
225
+ end
226
+
134
227
  # Orthogonality guard: `when_attribute` partitions occurrences across
135
228
  # attributes; `polymorphic` dispatches the class of each hydrated item.
136
229
  # Different axes over the same discriminator concept — they compose on