moxml 0.1.26 → 0.2.0

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +5 -0
  6. data/lib/moxml/adapter/base.rb +53 -49
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +131 -99
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +91 -54
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +61 -50
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/config.rb +29 -3
  35. data/lib/moxml/context.rb +12 -0
  36. data/lib/moxml/element.rb +29 -12
  37. data/lib/moxml/entity/reference.rb +28 -0
  38. data/lib/moxml/entity.rb +125 -0
  39. data/lib/moxml/node.rb +90 -1
  40. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  41. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  42. data/lib/moxml/version.rb +1 -1
  43. data/lib/moxml/xml_emitter.rb +71 -0
  44. data/lib/moxml/xpath/compiler.rb +60 -7
  45. data/lib/moxml/xpath/parser.rb +22 -15
  46. data/lib/moxml.rb +3 -0
  47. data/spec/examples/readme_examples_spec.rb +0 -4
  48. data/spec/examples/xpath_examples_spec.rb +0 -11
  49. data/spec/integration/all_adapters_spec.rb +17 -0
  50. data/spec/integration/sax_parity_spec.rb +58 -0
  51. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  52. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  53. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  54. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  55. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  56. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  57. data/spec/moxml/adapter/ox_spec.rb +20 -6
  58. data/spec/moxml/adapter/platform_spec.rb +15 -2
  59. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  60. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  61. data/spec/moxml/doctype_spec.rb +1 -1
  62. data/spec/moxml/entity_spec.rb +76 -0
  63. data/spec/moxml/node_spec.rb +104 -0
  64. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  65. data/spec/moxml/xml_emitter_spec.rb +64 -0
  66. data/spec/moxml/xpath/axes_spec.rb +72 -0
  67. data/spec/moxml/xpath/parser_spec.rb +6 -0
  68. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  69. data/spec/performance/benchmark_spec.rb +13 -6
  70. data/spec/performance/thread_safety_spec.rb +0 -4
  71. data/spec/spec_helper.rb +5 -1
  72. metadata +21 -2
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SAX event parity across adapters: every bridge must translate its
4
+ # native SAX stream into the same Moxml event sequence — same element
5
+ # names (qualified), same attribute/declaration split (nil key = the
6
+ # default namespace), same characters.
7
+ RSpec.describe "SAX parity across adapters" do
8
+ let(:xml) do
9
+ <<~XML
10
+ <?xml version="1.0" encoding="UTF-8"?>
11
+ <root xmlns="http://d.org" xmlns:p="http://p.org" kind="a"><p:child id="1">text</p:child></root>
12
+ XML
13
+ end
14
+
15
+ def recorded_events(adapter_name)
16
+ events = []
17
+ handler = Class.new(Moxml::SAX::Handler) do
18
+ define_method(:track) { |*event| events << event }
19
+ define_method(:on_start_document) { track(:start_document) }
20
+ define_method(:on_end_document) { track(:end_document) }
21
+ define_method(:on_start_element) { |n, a, ns| track(:start_element, n, a, ns) }
22
+ define_method(:on_end_element) { |n| track(:end_element, n) }
23
+ define_method(:on_characters) { |t| track(:characters, t) }
24
+ end
25
+ Moxml.new(adapter_name).sax_parse(xml, handler.new)
26
+ events
27
+ end
28
+
29
+ def assert_parity(adapter_name)
30
+ # REXML and Oga report document-level whitespace (between the
31
+ # declaration and the root) as characters; parity is over the
32
+ # meaningful event stream.
33
+ events = recorded_events(adapter_name)
34
+ .reject { |event| event[0] == :characters && event[1].to_s.strip.empty? }
35
+
36
+ expect(events.first).to eq([:start_document])
37
+ expect(events[1]).to eq(
38
+ [:start_element, "root",
39
+ { "kind" => "a" },
40
+ { nil => "http://d.org", "p" => "http://p.org" }],
41
+ )
42
+ expect(events[2]).to eq([:start_element, "p:child", { "id" => "1" }, {}])
43
+ expect(events).to include([:characters, "text"])
44
+ expect(events).to include([:end_element, "p:child"])
45
+ expect(events).to include([:end_element, "root"])
46
+ expect(events.last).to eq([:end_document])
47
+ end
48
+
49
+ adapters = %i[nokogiri oga rexml ox headed_ox libxml]
50
+ adapters << :leptris if Object.const_defined?(:Leptris) &&
51
+ Leptris::XML::Document.respond_to?(:create)
52
+
53
+ adapters.each do |adapter_name|
54
+ it "emits the same event stream via #{adapter_name}" do
55
+ assert_parity(adapter_name)
56
+ end
57
+ end
58
+ end
@@ -80,9 +80,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
80
80
 
81
81
  describe "namespace edge cases" do
82
82
  it "handles default namespace changes" do
83
- if context.config.adapter_name == :ox
84
- pending "Ox doesn't have a native XPath"
85
- end
86
83
  if context.config.adapter_name == :headed_ox
87
84
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
88
85
  end
@@ -106,9 +103,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
106
103
  end
107
104
 
108
105
  it "handles recursive namespace definitions" do
109
- if context.config.adapter_name == :ox
110
- pending "Ox doesn't have a native XPath"
111
- end
112
106
  if context.config.adapter_name == :headed_ox
113
107
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
114
108
  end
@@ -132,10 +126,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
132
126
  if context.config.adapter_name == :headed_ox
133
127
  skip "HeadedOx limitation: Namespace-prefixed attribute access needs Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
134
128
  end
135
- if context.config.adapter_name == :ox
136
- skip "Ox doesn't have a native XPath"
137
- end
138
-
139
129
  xml = <<~XML
140
130
  <root xmlns:a="http://a.org" xmlns:b="http://b.org">
141
131
  <element a:id="1" b:id="2"/>
@@ -56,9 +56,6 @@ RSpec.shared_examples "Moxml Integration" do
56
56
  end
57
57
 
58
58
  it "handles xpath queries" do
59
- if context.config.adapter_name == :ox
60
- pending "Ox doesn't support namespace-aware XPath with predicates"
61
- end
62
59
  if context.config.adapter_name == :headed_ox
63
60
  skip "HeadedOx limitation: Namespace-aware XPath with predicates needs investigation. See docs/_pages/headed-ox-limitations.adoc"
64
61
  end
@@ -76,9 +73,6 @@ RSpec.shared_examples "Moxml Integration" do
76
73
 
77
74
  describe "namespace handling" do
78
75
  it "handles complex namespace scenarios" do
79
- if context.config.adapter_name == :ox
80
- pending "Ox doesn't have a native XPath"
81
- end
82
76
  if context.config.adapter_name == :headed_ox
83
77
  skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
84
78
  end
@@ -125,10 +119,6 @@ RSpec.shared_examples "Moxml Integration" do
125
119
  if context.config.adapter_name == :headed_ox
126
120
  skip "HeadedOx limitation: Parent setter not implemented. Requires Ox node reparenting API. See docs/_pages/headed-ox-limitations.adoc"
127
121
  end
128
- if context.config.adapter_name == :ox
129
- skip "Ox doesn't have a native XPath"
130
- end
131
-
132
122
  # Move nodes
133
123
  b_node = doc.at_xpath("//b")
134
124
  a_node = doc.at_xpath("//a")
@@ -65,6 +65,65 @@ RSpec.shared_examples "Moxml::Attribute" do
65
65
  expect(attribute.namespace.prefix).to eq("other")
66
66
  expect(attribute.to_s).to eq('other:attr="value"')
67
67
  end
68
+
69
+ it "replaces by expanded name across prefix spellings" do
70
+ element["ns:attr"] = "first"
71
+ element.add_namespace("alias", "http://example.org")
72
+ element["alias:attr"] = "second"
73
+
74
+ expect(element["ns:attr"]).to eq("second")
75
+ expect(element.attributes.size).to eq(1)
76
+ end
77
+ end
78
+
79
+ describe "expanded-name writes (AttributeResolver)" do
80
+ before do
81
+ element.add_namespace("p", "http://x.org")
82
+ element.add_namespace("q", "http://x.org")
83
+ element["q:type"] = "namespaced"
84
+ element["type"] = "bare"
85
+ end
86
+
87
+ it "does not clobber a namespaced attribute sharing the local name" do
88
+ element["type"] = "changed"
89
+
90
+ expect(element["type"]).to eq("changed")
91
+ expect(element["q:type"]).to eq("namespaced")
92
+ end
93
+
94
+ it "overwrites by expanded name regardless of prefix spelling" do
95
+ element["p:type"] = "overwritten"
96
+
97
+ expect(element["q:type"]).to eq("overwritten")
98
+ expect(element.attributes.size).to eq(2)
99
+ end
100
+
101
+ it "stores writes through undeclared prefixes raw for later resolution" do
102
+ element["z:type"] = "value"
103
+
104
+ # No declaration in scope: the read resolves to nothing, but
105
+ # the attribute is stored (detached builds attach under the
106
+ # declaring ancestor later).
107
+ expect(element["z:type"]).to be_nil
108
+ expect(element.attributes.size).to eq(3)
109
+
110
+ element.add_namespace("z", "http://z.org")
111
+ expect(element["z:type"]).to eq("value")
112
+ end
113
+
114
+ it "removes the bare attribute without touching the namespaced one" do
115
+ element.remove_attribute("type")
116
+
117
+ expect(element["type"]).to be_nil
118
+ expect(element["q:type"]).to eq("namespaced")
119
+ end
120
+
121
+ it "removes by expanded name regardless of prefix spelling" do
122
+ element.remove_attribute("p:type")
123
+
124
+ expect(element["q:type"]).to be_nil
125
+ expect(element["type"]).to eq("bare")
126
+ end
68
127
  end
69
128
 
70
129
  describe "manipulation" do
@@ -162,4 +221,43 @@ RSpec.shared_examples "Moxml::Attribute" do
162
221
  end
163
222
  end
164
223
  end
224
+
225
+ describe "unified XML-correct name resolution" do
226
+ let(:context) { Moxml.new }
227
+ let(:doc) do
228
+ context.parse(
229
+ %(<root xmlns:p="urn:1"><child xmlns:q="urn:1" q:type="v" type="plain" xml:lang="en"/></root>),
230
+ )
231
+ end
232
+ let(:child) { doc.root.children.find(&:element?) }
233
+
234
+ it "matches prefixed lookups by expanded name across redeclared prefixes" do
235
+ # p and q both bind urn:1; the attribute is written q:type
236
+ expect(child["p:type"]).to eq("v")
237
+ expect(child["q:type"]).to eq("v")
238
+ end
239
+
240
+ it "keeps bare and namespaced attributes distinct" do
241
+ expect(child["type"]).to eq("plain")
242
+ expect(child[:type]).to eq("plain")
243
+ end
244
+
245
+ it "resolves the prebound xml prefix without any declaration" do
246
+ expect(child["xml:lang"]).to eq("en")
247
+ end
248
+
249
+ it "returns nil for undeclared prefixes — no prefix-string fallback" do
250
+ expect(child["undeclared:type"]).to be_nil
251
+ end
252
+
253
+ it "never treats namespace declarations as attributes" do
254
+ expect(child["xmlns:q"]).to be_nil
255
+ expect(child["xmlns"]).to be_nil
256
+ end
257
+
258
+ it "attribute(name) agrees with []" do
259
+ expect(child.attribute("p:type").value).to eq("v")
260
+ expect(child.attribute("undeclared:type")).to be_nil
261
+ end
262
+ end
165
263
  end
@@ -170,6 +170,32 @@ RSpec.shared_examples "Moxml::Namespace" do
170
170
  expect(prefixes).to include("xs")
171
171
  end
172
172
 
173
+ it "sees ancestor declarations made after a first read (cache invalidation)" do
174
+ root = doc.create_element("root")
175
+ root.add_namespace("a", "http://a.org")
176
+ child = doc.create_element("child")
177
+ root.add_child(child)
178
+ child.in_scope_namespaces # materialize the scope cache
179
+
180
+ root.add_namespace("b", "http://b.org")
181
+
182
+ expect(child.in_scope_namespaces.map(&:prefix)).to include("b")
183
+ end
184
+
185
+ it "recomputes the scope after the subtree is reparented" do
186
+ source = doc.create_element("source")
187
+ source.add_namespace("s", "http://s.org")
188
+ child = doc.create_element("child")
189
+ source.add_child(child)
190
+ child.in_scope_namespaces # materialize the scope cache
191
+
192
+ target = doc.create_element("target")
193
+ child.remove
194
+ target.add_child(child)
195
+
196
+ expect(child.in_scope_namespaces.map(&:prefix)).not_to include("s")
197
+ end
198
+
173
199
  it "collects namespaces from multiple ancestor levels" do
174
200
  root = doc.create_element("root")
175
201
  root.add_namespace("xs", "http://www.w3.org/2001/XMLSchema")
@@ -96,20 +96,12 @@ RSpec.shared_examples "Moxml::Node" do
96
96
  let(:doc) { context.parse("<root><a><b>1</b></a><a><b>2</b></a></root>") }
97
97
 
98
98
  it "finds nodes by xpath" do
99
- if context.config.adapter_name == :ox
100
- pending "Ox doesn't have a native XPath"
101
- end
102
-
103
99
  nodes = doc.xpath("//b")
104
100
  expect(nodes.size).to eq(2)
105
101
  expect(nodes.map(&:text)).to eq(%w[1 2])
106
102
  end
107
103
 
108
104
  it "finds first node by xpath" do
109
- if context.config.adapter_name == :ox
110
- pending "Ox doesn't have a native XPath"
111
- end
112
-
113
105
  node = doc.at_xpath("//b")
114
106
  expect(node.text).to eq("1")
115
107
  end
@@ -0,0 +1,75 @@
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
+
10
+ # Programmatic document construction relies on the C API added for the
11
+ # Moxml integration (leptris_document_create / leptris_document_set_root,
12
+ # exposed as Leptris::XML::Document.create / #root=). Released leptris
13
+ # 1.1.x does not ship it yet; skip instead of failing until the binding
14
+ # release catches up.
15
+ unless Leptris::XML::Document.respond_to?(:create)
16
+ # String form: the adapter constant is not loaded in this branch —
17
+ # resolving it would raise NameError before the skip applies.
18
+ RSpec.describe "Moxml::Adapter::Leptris" do
19
+ it "waits for a leptris release with programmatic document construction" do
20
+ skip "requires leptris with Leptris::XML::Document.create (unreleased C API)"
21
+ end
22
+ end
23
+
24
+ return
25
+ end
26
+
27
+ require "moxml/adapter/leptris"
28
+
29
+ RSpec.describe Moxml::Adapter::Leptris do
30
+ around do |example|
31
+ Moxml.with_config(:leptris, true, "UTF-8") do
32
+ example.run
33
+ end
34
+ end
35
+
36
+ it_behaves_like "xml adapter"
37
+
38
+ describe "native xpath dispatch" do
39
+ let(:ctx) { Moxml.new(:leptris) }
40
+ let(:doc) do
41
+ ctx.parse(<<~XML)
42
+ <root xmlns:p="http://x.org">
43
+ <item id="1" p:kind="a">alpha</item>
44
+ <item id="2">beta</item>
45
+ </root>
46
+ XML
47
+ end
48
+
49
+ it "evaluates document-context queries on the native engine" do
50
+ expect(doc.xpath("//item[@id='2']").map(&:text)).to eq(["beta"])
51
+ expect(doc.xpath("count(//item)")).to eq(2.0)
52
+ expect(doc.xpath("//item[@p:kind='a']").map { |n| n["id"] }).to eq(["1"])
53
+ end
54
+
55
+ it "falls back to the Ruby engine for attribute-node results" do
56
+ attrs = doc.xpath("//item/@id")
57
+ expect(attrs.map(&:name)).to eq(%w[id id])
58
+ expect(attrs.map(&:value)).to eq(%w[1 2])
59
+ end
60
+
61
+ it "falls back to the Ruby engine for element-context queries" do
62
+ expect(doc.root.xpath(".//item").size).to eq(2)
63
+ end
64
+
65
+ it "falls back to the Ruby engine for the xmlns: reserved prefix" do
66
+ expect(doc.xpath("//xmlns:item").size).to eq(0)
67
+ prefixed = ctx.parse('<r xmlns="http://d.org"><item/></r>')
68
+ expect(prefixed.xpath("//xmlns:item").size).to eq(1)
69
+ end
70
+
71
+ it "raises Moxml::XPathError for invalid expressions" do
72
+ expect { doc.xpath("//item[") }.to raise_error(Moxml::XPathError)
73
+ end
74
+ end
75
+ end
@@ -38,21 +38,35 @@ RSpec.describe Moxml::Adapter::Ox do
38
38
 
39
39
  describe "xpath support" do
40
40
  let(:doc) do
41
- described_class.parse("<root><child id='1'>text</child></root>").native
41
+ described_class.parse("<root><child id='1'>text</child><child id='2'>more</child></root>").native
42
42
  end
43
43
 
44
44
  it "supports basic element matching" do
45
45
  nodes = described_class.xpath(doc, "//child")
46
- expect(nodes.size).to eq(1)
46
+ expect(nodes.size).to eq(2)
47
47
  expect(nodes.first.name).to eq("child")
48
48
  end
49
49
 
50
- it "supports attribute matching" do
51
- pending("Ox does not support attribute value predicates in XPath (documented limitation)")
52
-
50
+ it "supports attribute value predicates" do
53
51
  nodes = described_class.xpath(doc, "//child[@id='1']")
54
52
  expect(nodes.size).to eq(1)
55
- expect(nodes.first.attributes["id"]).to eq("1")
53
+ expect(nodes.first.attributes[:id]).to eq("1")
54
+ end
55
+
56
+ it "supports logical operators" do
57
+ nodes = described_class.xpath(doc, "//child[@id='1' or @id='2']")
58
+ expect(nodes.size).to eq(2)
59
+ end
60
+
61
+ it "supports position predicates" do
62
+ nodes = described_class.xpath(doc, "//child[2]")
63
+ expect(nodes.size).to eq(1)
64
+ expect(nodes.first.attributes[:id]).to eq("2")
65
+ end
66
+
67
+ it "supports XPath functions" do
68
+ count = described_class.xpath(doc, "count(//child)")
69
+ expect(count).to eq(2)
56
70
  end
57
71
  end
58
72
  end
@@ -75,9 +75,22 @@ RSpec.describe Moxml::Adapter do
75
75
  end
76
76
 
77
77
  describe "default adapter / available list invariants" do
78
- it "DEFAULT_ADAPTER is a member of AVAILABLE_ADAPTERS" do
78
+ it "PREFERRED and FALLBACK adapters are members of AVAILABLE_ADAPTERS" do
79
79
  expect(Moxml::Adapter::AVAILABLE_ADAPTERS)
80
- .to include(Moxml::Config::DEFAULT_ADAPTER)
80
+ .to include(Moxml::Config::PREFERRED_ADAPTER, Moxml::Config::FALLBACK_ADAPTER)
81
+ end
82
+
83
+ it "the resolved runtime default adapter is loadable" do
84
+ expect(Moxml::Adapter::AVAILABLE_ADAPTERS)
85
+ .to include(Moxml::Config.default_adapter)
86
+ end
87
+
88
+ it "prefers leptris only when the installed gem supports document construction" do
89
+ if Moxml::Config.leptris_preferred_available?
90
+ expect(Moxml::Config.runtime_default_adapter).to eq(:leptris)
91
+ else
92
+ expect(Moxml::Config.runtime_default_adapter).not_to eq(:leptris)
93
+ end
81
94
  end
82
95
 
83
96
  it "OPAL_DEFAULT_ADAPTER is a member of OPAL_AVAILABLE_ADAPTERS" do
@@ -125,6 +125,186 @@ RSpec.shared_examples "xml adapter" do
125
125
  value = attr.respond_to?(:to_xml) ? attr&.to_xml : attr.to_s
126
126
  expect(value).to match(/&lt; &gt; &amp; (&quot;|") ('|&apos;)/)
127
127
  end
128
+
129
+ it "keeps attributes that share a local name across namespaces (issues #94/#95)" do
130
+ collision = described_class.parse(
131
+ '<probe xmlns:xmi="http://www.example.com/xmi" ' \
132
+ 'xmi:type="uml:Parameter" type="EAnone_void"/>',
133
+ ).native
134
+ probe = described_class.root(collision)
135
+
136
+ attrs = described_class.attributes(probe)
137
+ expect(attrs.size).to eq(2)
138
+ expect(attrs.map(&:value).sort).to eq(["EAnone_void", "uml:Parameter"])
139
+
140
+ reversed = described_class.parse(
141
+ '<probe type="EAnone_void" ' \
142
+ 'xmlns:xmi="http://www.example.com/xmi" xmi:type="uml:Parameter"/>',
143
+ ).native
144
+ expect(described_class.attributes(described_class.root(reversed)).size).to eq(2)
145
+
146
+ # Bare name resolves to the namespace-less attribute; the
147
+ # prefixed name to the namespaced one — on every adapter.
148
+ expect(described_class.get_attribute_value(probe, "type"))
149
+ .to eq("EAnone_void")
150
+ expect(described_class.get_attribute_value(probe, "xmi:type"))
151
+ .to eq("uml:Parameter")
152
+ expect(described_class.get_attribute_value(probe, "bogus:missing"))
153
+ .to be_nil
154
+ end
155
+ end
156
+
157
+ describe "mutation return contract" do
158
+ let(:doc) { described_class.parse(xml).native }
159
+ let(:element) { described_class.children(described_class.root(doc)).first }
160
+
161
+ it "returns the native to track from set_attribute_name" do
162
+ native = described_class.attributes(element).first
163
+ tracked = described_class.set_attribute_name(native, "renamed")
164
+ expect(tracked).to be_a(native.class)
165
+ expect(described_class.attribute_name(tracked)).to eq("renamed")
166
+ end
167
+
168
+ it "returns the native to track from set_namespace on an attribute" do
169
+ native = described_class.attributes(element).first
170
+ ns = described_class.namespace_definitions(
171
+ described_class.root(doc),
172
+ ).last
173
+ tracked = described_class.set_namespace(native, ns)
174
+ expect(tracked).to be_a(native.class)
175
+ end
176
+
177
+ it "returns the native to track from set_attribute_value" do
178
+ native = described_class.attributes(element).first
179
+ tracked = described_class.set_attribute_value(native, "updated")
180
+ expect(tracked).to be_a(native.class)
181
+ expect(tracked.value).to eq("updated")
182
+ end
183
+ end
184
+
185
+ describe "tree mutation" do
186
+ let(:doc) { described_class.parse(xml).native }
187
+ let(:root) { described_class.root(doc) }
188
+
189
+ def names_of(node)
190
+ described_class.children(node)
191
+ .map { |child| described_class.node_name(child) }
192
+ end
193
+
194
+ it "inserts previous and next siblings" do
195
+ target = described_class.children(root)[1]
196
+ described_class.add_previous_sibling(
197
+ target, described_class.create_element("before")
198
+ )
199
+ described_class.add_next_sibling(
200
+ target, described_class.create_element("after")
201
+ )
202
+
203
+ expect(names_of(root)).to eq(%w[child before child after special])
204
+ end
205
+
206
+ it "removes nodes from the tree" do
207
+ described_class.remove(described_class.children(root)[1])
208
+ expect(names_of(root)).to eq(%w[child special])
209
+ end
210
+
211
+ it "replaces a node in place" do
212
+ described_class.replace(
213
+ described_class.children(root)[1],
214
+ described_class.create_element("replacement"),
215
+ )
216
+ expect(names_of(root)).to eq(%w[child replacement special])
217
+ end
218
+
219
+ it "replaces all children" do
220
+ described_class.replace_children(
221
+ root, [described_class.create_element("only")]
222
+ )
223
+ expect(names_of(root)).to eq(%w[only])
224
+ end
225
+ end
226
+
227
+ describe "content accessors" do
228
+ let(:doc) { described_class.parse(xml).native }
229
+ let(:root) { described_class.root(doc) }
230
+
231
+ it "reads inner text from direct text children" do
232
+ child = described_class.children(root)[0]
233
+ expect(described_class.inner_text(child)).to eq("Text")
234
+ end
235
+
236
+ it "writes text content" do
237
+ element = described_class.create_element("scratch")
238
+ described_class.set_text_content(element, "written")
239
+ expect(described_class.inner_text(element)).to eq("written")
240
+ end
241
+
242
+ it "reads and writes comment content" do
243
+ comment = described_class.create_comment("note")
244
+ expect(described_class.comment_content(comment)).to eq("note")
245
+ described_class.set_comment_content(comment, "changed")
246
+ expect(described_class.comment_content(comment)).to eq("changed")
247
+ end
248
+
249
+ it "reads and writes CDATA content" do
250
+ cdata = described_class.create_cdata("<raw>")
251
+ expect(described_class.cdata_content(cdata)).to eq("<raw>")
252
+ described_class.set_cdata_content(cdata, "<new>")
253
+ expect(described_class.cdata_content(cdata)).to eq("<new>")
254
+ end
255
+ end
256
+
257
+ describe "auxiliary node creation" do
258
+ it "creates doctypes with external identifiers" do
259
+ doctype = described_class.create_doctype("cfg", "pub", "sys")
260
+ expect(described_class.node_type(doctype)).to eq(:doctype)
261
+ expect(described_class.doctype_name(doctype)).to eq("cfg")
262
+ expect(described_class.doctype_external_id(doctype)).to eq("pub")
263
+ expect(described_class.doctype_system_id(doctype)).to eq("sys")
264
+ end
265
+
266
+ it "creates declarations with version, encoding and standalone" do
267
+ declaration = described_class.create_declaration("1.0", "UTF-8", "yes")
268
+ expect(described_class.declaration_attribute(declaration, "version"))
269
+ .to eq("1.0")
270
+ expect(described_class.declaration_attribute(declaration, "standalone"))
271
+ .to eq("yes")
272
+ end
273
+
274
+ it "creates entity references by name" do
275
+ reference = described_class.create_entity_reference("copy")
276
+ expect(described_class.node_type(reference)).to eq(:entity_reference)
277
+ expect(described_class.entity_reference_name(reference)).to eq("copy")
278
+ end
279
+
280
+ it "duplicates nodes with their names" do
281
+ element = described_class.create_element("original")
282
+ duplicate = described_class.duplicate_node(element)
283
+ expect(described_class.node_name(duplicate)).to eq("original")
284
+ expect(duplicate).not_to equal(element)
285
+ end
286
+ end
287
+
288
+ describe "in-scope namespaces" do
289
+ let(:doc) { described_class.parse(xml).native }
290
+ let(:special) { described_class.children(described_class.root(doc)).last }
291
+
292
+ it "returns the ancestor-declared prefixes" do
293
+ prefixes = described_class.in_scope_namespaces(special)
294
+ .map { |ns| described_class.namespace_prefix(ns) }
295
+ # The default declaration is reported as a nil prefix everywhere
296
+ # except Ox, which spells it "xmlns"
297
+ expect(prefixes).to include("x")
298
+ expect(prefixes.size).to eq(2)
299
+ end
300
+ end
301
+
302
+ describe "line numbers" do
303
+ it "returns a 1-based position or nil" do
304
+ doc = described_class.parse(xml).native
305
+ line = described_class.line_number(described_class.root(doc))
306
+ expect(line.nil? || (line.is_a?(Integer) && line >= 1)).to be(true)
307
+ end
128
308
  end
129
309
 
130
310
  describe "namespaces" do