moxml 0.6.3 → 0.6.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.
data/lib/moxml/xsd.rb CHANGED
@@ -97,9 +97,10 @@ module Moxml
97
97
  end
98
98
 
99
99
  def initialize(handle, context, schema_source = nil,
100
- self_contained: nil)
100
+ self_contained: nil, path: nil)
101
101
  @handle = handle
102
102
  @context = context
103
+ @schema_path = path
103
104
  @rules_index =
104
105
  if schema_source
105
106
  SchemaRules.index(schema_source, context,
@@ -157,8 +158,9 @@ module Moxml
157
158
  end
158
159
  end
159
160
  engine_errors = @context.config.adapter.xsd_validate(
160
- @handle, doc.native
161
+ validation_handle(doc), doc.native
161
162
  )
163
+ engine_errors = dismiss_refuted_not_declared(engine_errors, doc)
162
164
  return engine_errors unless @rules_index
163
165
 
164
166
  # The #452 post-validation rules only ever add findings for
@@ -170,6 +172,438 @@ module Moxml
170
172
  SchemaRules.findings(doc, @rules_index)).uniq
171
173
  end
172
174
 
175
+ # Validations run against the compile-time handle. The
176
+ # composition fix (#522) is dismissal-side: the facade indexes
177
+ # every unit of the composition (the schema + the instance's
178
+ # xsi:schemaLocation hints) and drops engine strict-admission
179
+ # rows the index refutes — engines whose admission consults
180
+ # only the head unit's declarations never see the aux units,
181
+ # and recompiling a mixed-targetNamespace union would swap one
182
+ # false answer for another.
183
+ def validation_handle(_doc)
184
+ @handle
185
+ end
186
+
187
+ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
188
+
189
+ # xsi:schemaLocation (namespace/path pairs) and
190
+ # xsi:noNamespaceSchemaLocation paths from the validated
191
+ # instance's root, resolved against this schema's directory
192
+ # and filtered to files that exist. Empty when this schema was
193
+ # compiled from a string (no base to resolve against).
194
+ def instance_schema_hints(doc)
195
+ return [].freeze unless @schema_path
196
+ return [].freeze unless doc&.root
197
+
198
+ base = File.dirname(@schema_path)
199
+ root = doc.root
200
+ hints = []
201
+ root.attributes.each do |attribute|
202
+ next unless attribute.namespace&.uri == XSI_NS
203
+
204
+ value = attribute.value.to_s
205
+ case attribute.name
206
+ when "noNamespaceSchemaLocation"
207
+ hints << value
208
+ when "schemaLocation"
209
+ hints.concat(value.split(/\s+/).reject(&:empty?)
210
+ .each_slice(2).map(&:last))
211
+ end
212
+ end
213
+ hints.uniq
214
+ .map { |hint| File.expand_path(hint, base) }
215
+ .select do |p|
216
+ File.file?(p) && p != File.expand_path(@schema_path)
217
+ end.freeze
218
+ end
219
+
220
+ # Engine strict-admission rows the composed unit set REFUTES:
221
+ # engines whose wildcard admission consults only the head
222
+ # unit's declaration table report "attribute 'x': not declared
223
+ # (strict)" for attributes declared in another unit of the
224
+ # same compiled composition, and the mixed-tns fresh union
225
+ # compile itself can mis-admit a root element the same way
226
+ # (leptris#1964 family, moxml#522). The facade owns the
227
+ # composition, so it can refute a row when the named item, as
228
+ # it appears in THIS instance, is a global declaration of any
229
+ # unit (namespace + local name). Only exact "not declared
230
+ # (strict)" rows are ever dropped; every other engine finding
231
+ # passes through untouched.
232
+ def dismiss_refuted_not_declared(engine_errors, doc)
233
+ return engine_errors if engine_errors.empty? || !@schema_path
234
+
235
+ hints = instance_schema_hints(doc)
236
+ units = composed_unit_set([@schema_path, *hints])
237
+ declared = composed_declarations(units)
238
+ admissible = element_admissibility(units)
239
+ return engine_errors if declared.empty? && admissible.empty?
240
+
241
+ attr_index = {}
242
+ element_ns = {}
243
+ stack = [doc.root]
244
+ while (element = stack.pop)
245
+ element_ns[(element.namespace&.uri || "")] ||= true
246
+ stack.concat(element.children.grep(Moxml::Element))
247
+ element.attributes.each do |attribute|
248
+ attr_uri = attribute.namespace&.uri || ""
249
+ ((attr_index[[attr_uri, attribute.name]] ||= []) <<
250
+ element.name)
251
+ end
252
+ end
253
+ engine_errors.reject do |finding|
254
+ match = NOT_DECLARED_ROW.match(finding.message)
255
+ next false unless match
256
+
257
+ element_name = match[1]
258
+ attribute_name = match[2]
259
+ if attribute_name
260
+ attr_index.any? do |(uri, attr_name), holders|
261
+ next false unless attr_name == attribute_name &&
262
+ holders.include?(element_name)
263
+
264
+ # A global declaration of some unit, or a group member
265
+ # the element's type admits (moxml#522 attgC class —
266
+ # exact (element, attribute, namespace) match).
267
+ declared.key?([uri, attribute_name]) ||
268
+ admissible.dig(element_name, attribute_name) == uri
269
+ end
270
+ else
271
+ declared.key?(["", element_name]) ||
272
+ (element_ns[element_name] &&
273
+ declared.key?([element_ns[element_name], element_name]))
274
+ end
275
+ end
276
+ end
277
+
278
+ # The composition closure of the unit set: every unit's
279
+ # xs:include / xs:import / xs:redefine schemaLocation targets
280
+ # (existing files), cycle-guarded, resolved relative to each
281
+ # unit. The engine compiles these documents as ONE composition;
282
+ # the facade's index must see the same set even when the
283
+ # instance carries no xsi hints (attgC035's redefined groups
284
+ # live in documents only the schema graph knows).
285
+ def composed_unit_set(units)
286
+ @composed_unit_set ||= {}
287
+ cached = @composed_unit_set[units]
288
+ return cached if cached
289
+
290
+ seen = {}
291
+ queue = units.dup
292
+ until queue.empty?
293
+ unit = queue.pop
294
+ next if seen.key?(unit)
295
+
296
+ seen[unit] = true
297
+ base = File.dirname(unit)
298
+ unit_schema_refs(unit).each do |ref|
299
+ target = File.expand_path(ref, base)
300
+ queue << target if File.file?(target) && !seen.key?(target)
301
+ end
302
+ end
303
+ closure = seen.keys.sort
304
+ @composed_unit_set[units] = closure
305
+ closure
306
+ end
307
+
308
+ # schemaLocation hrefs on the unit's own xs:include, xs:import
309
+ # and xs:redefine (poison-immune REXML read; the engine's
310
+ # component tables are process-global and a later parse can
311
+ # poison compiled handles, moxml#522).
312
+ def unit_schema_refs(unit)
313
+ require "rexml/document"
314
+ root = REXML::Document.new(File.read(unit)).root
315
+ return [].freeze unless root&.name == "schema"
316
+
317
+ refs = %w[include import redefine].map do |tag|
318
+ root.elements.each_with_object([]) do |node, out|
319
+ next unless node.name == tag
320
+
321
+ loc = node.attributes["schemaLocation"]
322
+ out << loc if loc.to_s.match?(/\S/)
323
+ end
324
+ end.flatten
325
+ refs.freeze
326
+ rescue StandardError
327
+ [].freeze
328
+ end
329
+
330
+ # {element name => {attribute name => namespace}}: every
331
+ # attribute the composition admits for each element — the
332
+ # element's type's attributeGroup members resolved transitively
333
+ # across units (import groups; an xs:redefine body's self-ref
334
+ # is the 4.2.2 hook to the ORIGINAL definition, so the
335
+ # effective group is body UNION original). Only exact members
336
+ # of the real composition land here, so a genuinely undeclared
337
+ # attribute never becomes refutable.
338
+ def element_admissibility(units)
339
+ @element_admissibility ||= {}
340
+ cached = @element_admissibility[units]
341
+ return cached if cached
342
+
343
+ facts = {}
344
+ units.each do |unit|
345
+ unit_facts = schema_unit_facts(unit)
346
+ facts[unit] = unit_facts if unit_facts
347
+ end
348
+
349
+ # Effective group definitions: redefining bodies merge the
350
+ # target unit's original when (and only when) the body hooks
351
+ # it with a self-ref. Keys: bare name AND {uri}name (refs
352
+ # resolve with the referencing unit's xmlns, bare-name
353
+ # fallback — same-name groups are composition-unique).
354
+ plain = {}
355
+ bodies = {}
356
+ facts.each_value do |unit|
357
+ unit[:redefines].each do |name, target_path|
358
+ body = unit[:groups][name]
359
+ original = facts[target_path]&.dig(:groups, name)
360
+ bodies[name] = merge_group_def(body, original, name)
361
+ end
362
+ unit[:groups].each do |name, defn|
363
+ plain[name] = defn unless unit[:redefines].key?(name)
364
+ end
365
+ end
366
+ # Redefine bodies are the EFFECTIVE definitions — they win
367
+ # over the plain originals.
368
+ groups = plain.merge(bodies)
369
+
370
+ resolved = {}
371
+ facts.each_value do |unit|
372
+ unit[:elements].each do |element_name, element|
373
+ attrs = {}
374
+ element[:groups].each do |qref|
375
+ resolve_group_ref(qref, unit, groups, attrs, {})
376
+ end
377
+ element[:attrs].each do |name, uri|
378
+ attrs[name] = uri
379
+ end
380
+ resolved[element_name] = attrs unless attrs.empty?
381
+ end
382
+ end
383
+ @element_admissibility[units] = resolved.freeze
384
+ end
385
+
386
+ # body UNION original when the body hooks the original with a
387
+ # self-ref; the body alone otherwise.
388
+ def merge_group_def(body, original, name)
389
+ return body unless body && original.is_a?(Hash) &&
390
+ body[:refs].include?(name)
391
+
392
+ {
393
+ attrs: body[:attrs] + original[:attrs],
394
+ refs: body[:refs] + original[:refs],
395
+ }
396
+ end
397
+
398
+ # Resolve one attributeGroup ref (raw QName string) into attrs
399
+ # {name => uri}, transitively; cycle-guarded by +seen+. Keys
400
+ # are bare names with qualified "{uri}name" fallbacks.
401
+ def resolve_group_ref(qref, from_unit, groups, attrs, seen)
402
+ return if seen.key?(qref)
403
+
404
+ seen[qref] = true
405
+ prefix, _, local = qref.rpartition(":")
406
+ xmlns = from_unit[:xmlns]
407
+ uri = if prefix.empty?
408
+ xmlns["xmlns"].to_s
409
+ else
410
+ xmlns["xmlns:#{prefix}"].to_s
411
+ end
412
+ defn = groups["{#{uri}}#{local}"] || groups[local]
413
+ return unless defn.is_a?(Hash)
414
+
415
+ defn[:attrs].each do |name, attr_uri|
416
+ attrs[name] = attr_uri
417
+ end
418
+ defn[:refs].each do |ref|
419
+ resolve_group_ref(ref, from_unit, groups, attrs, seen)
420
+ end
421
+ end
422
+
423
+ # Per-unit schema facts for the admissibility index:
424
+ # {tns, afd (attributeFormDefault qualified?), xmlns,
425
+ # groups: {name => {attrs, refs}}, redefines: {name => target},
426
+ # elements: {name => {groups, attrs}}} — poison-immune REXML.
427
+ # Attribute member URIs use the DEFINING unit's form rules;
428
+ # group refs stay raw QNames (resolved at chase time).
429
+ def schema_unit_facts(unit)
430
+ require "rexml/document"
431
+ root = REXML::Document.new(File.read(unit)).root
432
+ return nil unless root&.name == "schema"
433
+
434
+ xmlns = {}
435
+ root.attributes.each do |name, value|
436
+ xmlns[name] = value.to_s if name.start_with?("xmlns")
437
+ end
438
+ tns = root.attributes["targetNamespace"].to_s
439
+ afd = root.attributes["attributeFormDefault"] == "qualified"
440
+
441
+ member_uri = lambda do |node|
442
+ qualified = node.attributes["form"] == "qualified" || afd
443
+ qualified ? tns : ""
444
+ end
445
+
446
+ groups = {}
447
+ redefines = {}
448
+ root.elements.each do |node|
449
+ case node.name
450
+ when "attributeGroup"
451
+ name = node.attributes["name"]
452
+ next unless name
453
+
454
+ groups[name] = walk_group_members(node, member_uri)
455
+ when "redefine"
456
+ loc = node.attributes["schemaLocation"]
457
+ node.elements.each do |body|
458
+ next unless body.name == "attributeGroup"
459
+
460
+ name = body.attributes["name"]
461
+ next unless name
462
+
463
+ groups[name] = walk_group_members(body, member_uri)
464
+ redefines[name] = File.expand_path(loc, File.dirname(unit)) if
465
+ loc.to_s.match?(/\S/)
466
+ end
467
+ end
468
+ end
469
+
470
+ elements = {}
471
+ root.elements.each do |node|
472
+ next unless node.name == "element"
473
+ next unless (name = node.attributes["name"])
474
+
475
+ container = node.elements.find { |child| child.name == "complexType" }
476
+ type_name = node.attributes["type"].to_s
477
+ container ||= root.elements.find do |child|
478
+ child.name == "complexType" && child.attributes["name"] == type_name
479
+ end
480
+ next unless container.is_a?(REXML::Element)
481
+
482
+ groups_seen, attrs = walk_type_attributes(container, member_uri)
483
+ elements[name] = { groups: groups_seen, attrs: attrs }
484
+ end
485
+
486
+ {
487
+ tns: tns, afd: afd, xmlns: xmlns, groups: groups,
488
+ redefines: redefines, elements: elements
489
+ }
490
+ rescue StandardError
491
+ nil
492
+ end
493
+
494
+ # Refs and attribute members of one attributeGroup definition.
495
+ # Refs stay raw QNames — the chase resolves prefixes through
496
+ # the referencing unit's xmlns.
497
+ def walk_group_members(node, member_uri)
498
+ refs = []
499
+ attrs = []
500
+ node.elements.each do |child|
501
+ case child.name
502
+ when "attribute"
503
+ name = child.attributes["name"]
504
+ attrs << [name, member_uri.call(child)] if name
505
+ when "attributeGroup"
506
+ ref = child.attributes["ref"]
507
+ refs << ref if ref.to_s.match?(/\S/)
508
+ end
509
+ end
510
+ { attrs: attrs, refs: refs }
511
+ end
512
+
513
+ # attributeGroup refs + own attribute members of a complexType
514
+ # (one level; nested group-through-group chains resolve at
515
+ # chase time). Returns [raw refs, attrs].
516
+ def walk_type_attributes(container, member_uri)
517
+ refs = []
518
+ attrs = []
519
+ container.elements.each do |child|
520
+ case child.name
521
+ when "attributeGroup"
522
+ ref = child.attributes["ref"]
523
+ refs << ref if ref.to_s.match?(/\S/)
524
+ when "attribute"
525
+ name = child.attributes["name"]
526
+ attrs << [name, member_uri.call(child)] if name
527
+ end
528
+ end
529
+ [refs, attrs]
530
+ end
531
+
532
+ # {uri, local} => true for every global xs:attribute and
533
+ # xs:element declared across the unit set: a global
534
+ # declaration always takes its unit's targetNamespace.
535
+ # Parsed with REXML when available: the engine's component
536
+ # tables are process-global and can be poisoned by later
537
+ # parses between compile and validate (moxml#522) — a
538
+ # declaration index must be poison-immune. Falls back to the
539
+ # context parse when REXML is unavailable.
540
+ def composed_declarations(units)
541
+ @composed_declarations ||= {}
542
+ cached = @composed_declarations[units]
543
+ return cached if cached
544
+
545
+ declared = {}
546
+ parser = begin
547
+ require "rexml"
548
+ :rexml
549
+ rescue LoadError
550
+ :context
551
+ end
552
+ units.each do |unit|
553
+ tns, decls = unit_declarations(unit, parser)
554
+ next unless decls
555
+
556
+ decls.each do |name|
557
+ declared[[tns, name]] = true
558
+ end
559
+ end
560
+ @composed_declarations[units] = declared
561
+ end
562
+
563
+ # [targetNamespace, [global attribute/element names]] for one
564
+ # schema unit, per the parser flavor. REXML's read is
565
+ # poison-immune; the context parse is the fallback.
566
+ def unit_declarations(unit, parser)
567
+ text = File.read(unit)
568
+ if parser == :rexml
569
+ require "rexml/document"
570
+ root = REXML::Document.new(text).root
571
+ return nil unless root&.name == "schema"
572
+
573
+ tns = root.attributes["targetNamespace"].to_s
574
+ names = %w[attribute element].freeze
575
+ decls = root.elements.filter_map do |decl|
576
+ next unless names.include?(decl.name)
577
+
578
+ decl.attributes["name"]
579
+ end
580
+ else
581
+ root = @context.parse(text).root
582
+ return nil unless root&.name == "schema"
583
+
584
+ tns = root.attributes.find { |a| a.name == "targetNamespace" }
585
+ &.value.to_s
586
+ names = %w[attribute element].freeze
587
+ decls = root.children
588
+ .grep(Moxml::Element)
589
+ .filter_map do |decl|
590
+ next unless names.include?(decl.name)
591
+
592
+ decl.attributes.find { |a| a.name == "name" }&.value
593
+ end
594
+ end
595
+ [tns, decls]
596
+ rescue StandardError
597
+ nil
598
+ end
599
+
600
+ # Matches both row shapes the strict admission emits — with and
601
+ # without the attribute segment:
602
+ # element 'doc' @2:144: element 'doc': not declared (strict)
603
+ # element 'elem' @3:46: attribute 'foo': not declared (strict)
604
+ NOT_DECLARED_ROW =
605
+ /\Aelement '([^']+)'(?: @\S+)? (?:(?:element|attribute) '([^']+)': )?not declared \(strict\)\z/
606
+
173
607
  def valid?(node_or_xml)
174
608
  check_released!
175
609
  validate(node_or_xml).empty?
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "leptris"
5
+ rescue LoadError
6
+ # Leptris gem not available - skip all specs in this file
7
+ return
8
+ end
9
+ return unless Leptris::XML::Document.respond_to?(:create)
10
+ # The address-native lane rides the binding's shadow faces
11
+ # (children_addrs / walk_addresses, leptris-ruby#587/#589/#591);
12
+ # older bindings keep the columnar per-subtree crossing, so the
13
+ # routing cannot be pinned there.
14
+ return unless defined?(Leptris::XML::Native) &&
15
+ Leptris::XML::Native.respond_to?(:children_addrs)
16
+
17
+ require "moxml/adapter/leptris"
18
+
19
+ RSpec.describe "Moxml::Adapter::Leptris address-native children (shadow lane)" do
20
+ around do |example|
21
+ Moxml.with_config(:leptris, true, "UTF-8") do
22
+ example.run
23
+ end
24
+ end
25
+
26
+ let(:xml) do
27
+ <<~XML
28
+ <?xml version="1.0"?><r><a x="1">t</a><b/><c><d/></c><tail>z</tail></r>
29
+ XML
30
+ end
31
+
32
+ let(:doc) { Moxml.parse(xml) }
33
+ let(:root) { doc.root }
34
+ let(:root_native) { root.native }
35
+
36
+ # Counts real Native.children_addrs C crossings inside the block —
37
+ # no stubs, the actual boundary traffic the address lane pays.
38
+ def with_crossing_count(method_id = :children_addrs)
39
+ calls = 0
40
+ tp = TracePoint.new(:c_call) do |t|
41
+ calls += 1 if t.method_id == method_id
42
+ end
43
+ result = nil
44
+ tp.enable { result = yield }
45
+ [calls, result]
46
+ end
47
+
48
+ describe "the address gate" do
49
+ it "face-probes the shadow faces on the binding" do
50
+ expect(Moxml::Adapter::Leptris::ADDRESS_NATIVE).to be(true)
51
+ expect(Leptris::XML::Native.respond_to?(:children_addrs)).to be(true)
52
+ end
53
+ end
54
+
55
+ describe "plain children reads" do
56
+ it "answer address tokens matching the native children face" do
57
+ got = Moxml::Adapter::Leptris.children(root_native)
58
+ expected = Leptris::XML::NativeNode
59
+ .instance_method(:children).bind_call(root_native).to_a
60
+ expect(got).to all(be_a(Moxml::Adapter::Leptris::AddressNode))
61
+ expect(got.size).to eq(expected.size)
62
+ expect(got.map { |n| Moxml::Adapter::Leptris.node_name(n) })
63
+ .to eq(expected.map(&:name))
64
+ got.each_with_index do |token, i|
65
+ # Address equality: the token stands for the same C node.
66
+ expect(token.address).to eq(expected[i].address)
67
+ expect(Moxml::Adapter::Leptris.node_type(token))
68
+ .to eq(Moxml::Adapter::Leptris.node_type(expected[i]))
69
+ end
70
+ end
71
+
72
+ it "hand back the SAME token per address across traversals" do
73
+ first = Moxml::Adapter::Leptris.children(root_native)
74
+ second = Moxml::Adapter::Leptris.children(root_native)
75
+ expect(second).to eq(first)
76
+ expect(second.first).to equal(first.first)
77
+ end
78
+
79
+ # Parsed-document wrappers mint with Identity::ChildReads above
80
+ # Moxml::Node, so consumer iteration on those wrappers dispatches
81
+ # to the binding's C read layer. The address face pays one
82
+ # children_addrs crossing per element receiver — the token mint
83
+ # replaces the TypedData wrap + identity-cache write the columnar
84
+ # lane pays per node in C.
85
+ it "pay one crossing per element receiver, none on repeat reads" do
86
+ calls, = with_crossing_count do
87
+ walk_face = lambda do |token|
88
+ Moxml::Adapter::Leptris.children(token).each do |child|
89
+ walk_face.call(child)
90
+ end
91
+ end
92
+ walk_face.call(root_native)
93
+ end
94
+ # The subtree has 6 elements (r, a, b, c, d, tail): one crossing
95
+ # each, no walk_columnar at all.
96
+ expect(calls).to eq(6)
97
+ calls2, = with_crossing_count do
98
+ Moxml::Adapter::Leptris.children(root_native)
99
+ end
100
+ expect(calls2).to eq(1)
101
+ end
102
+
103
+ it "serve element reads through the address-keyed faces" do
104
+ target = Moxml::Adapter::Leptris.children(root_native)
105
+ .find { |t| Moxml::Adapter::Leptris.node_name(t) == "a" }
106
+ expect(Moxml::Adapter::Leptris.bare_attr_value(target, "x")).to eq("1")
107
+ expect(Moxml::Adapter::Leptris.text_content(target)).to eq("t")
108
+ expect(Moxml::Adapter::Leptris.attributes(target).map(&:value))
109
+ .to eq(["1"])
110
+ end
111
+
112
+ it "keep the document assembly path on document receivers" do
113
+ calls, doc_children = with_crossing_count do
114
+ Moxml::Adapter::Leptris.children(doc.native)
115
+ end
116
+ # No token crossings: the document's child list rides the
117
+ # assembly path (declaration/doctype kinds are outside the
118
+ # address lane's WS enum).
119
+ expect(calls).to eq(0)
120
+ expect(doc_children.map do |n|
121
+ Moxml::Adapter::Leptris.node_name(n)
122
+ end).to include("r")
123
+ end
124
+ end
125
+
126
+ describe "mutation invalidation" do
127
+ # Read back through the adapter face: the token map must never
128
+ # serve a list a mutation has invalidated.
129
+ def face_children_names
130
+ Moxml::Adapter::Leptris.children(root_native)
131
+ .map { |n| Moxml::Adapter::Leptris.node_name(n) }
132
+ end
133
+
134
+ it "sees children added after the map was primed" do
135
+ face_children_names # prime the map
136
+ root.add_child(doc.create_element("late"))
137
+ expect(face_children_names).to include("late")
138
+ end
139
+
140
+ it "sees children removed after the map was primed" do
141
+ face_children_names # prime the map
142
+ root.children.to_a.first.remove
143
+ expect(face_children_names).not_to include("a")
144
+ end
145
+
146
+ it "sees sibling insertions after the map was primed" do
147
+ face_children_names # prime the map
148
+ anchor = root.children.to_a.first
149
+ anchor.add_previous_sibling(doc.create_element("before2"))
150
+ expect(face_children_names).to include("before2")
151
+ end
152
+
153
+ it "sees replace after the map was primed" do
154
+ face_children_names # prime the map
155
+ victim = root.children.to_a.find { |n| n.name == "b" }
156
+ victim.replace(doc.create_element("swapped"))
157
+ expect(face_children_names).to include("swapped")
158
+ expect(face_children_names).not_to include("b")
159
+ end
160
+
161
+ it "sees content rewrites after the map was primed" do
162
+ face_children_names # prime the map
163
+ target = root.children.to_a.find { |n| n.name == "a" }
164
+ target.content = "rewritten"
165
+ names = face_children_names
166
+ expect(names).to include("a")
167
+ expect(root.children.to_a.find { |n| n.name == "a" }.text)
168
+ .to eq("rewritten")
169
+ end
170
+ end
171
+ end
@@ -53,10 +53,6 @@ RSpec.describe Moxml::Adapter::Leptris do
53
53
  end
54
54
 
55
55
  it "evaluates attribute-node queries with proper wrappers" do
56
- # Native since 1.9.105 (leptris-ruby#153: ResultAttr with
57
- # name/value); earlier bindings returned generic nodes whose
58
- # #name raised, so the Ruby engine owned them.
59
- skip "requires native attr results (leptris 1.9.105+)" unless described_class::ATTR_RESULT_NATIVE
60
56
  attrs = doc.xpath("//item/@id")
61
57
  expect(attrs.map(&:name)).to eq(%w[id id])
62
58
  expect(attrs.map(&:value)).to eq(%w[1 2])
@@ -759,8 +755,6 @@ RSpec.describe Moxml::Adapter::Leptris do
759
755
  let(:ctx) { Moxml.new(:leptris) }
760
756
 
761
757
  it "answers engine source positions for parsed nodes" do
762
- skip "requires leptris 1.9.181" unless described_class::NATIVE_SOURCE_POSITION
763
-
764
758
  doc = ctx.parse("<root>\n <child>text</child>\n</root>")
765
759
  expect(doc.root.source_position).to include(line: 1)
766
760
  expect(doc.root.source_position).to be_a(Hash)