moxml 0.1.26 → 0.3.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 (86) 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 +6 -1
  6. data/lib/moxml/adapter/base.rb +53 -56
  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 +137 -117
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +139 -55
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +64 -51
  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/builder.rb +0 -18
  35. data/lib/moxml/config.rb +29 -5
  36. data/lib/moxml/context.rb +33 -0
  37. data/lib/moxml/document.rb +1 -1
  38. data/lib/moxml/element.rb +29 -12
  39. data/lib/moxml/entity/reference.rb +28 -0
  40. data/lib/moxml/entity/restorer.rb +95 -0
  41. data/lib/moxml/entity.rb +126 -0
  42. data/lib/moxml/entity_registry.rb +1 -1
  43. data/lib/moxml/error.rb +0 -42
  44. data/lib/moxml/node.rb +135 -10
  45. data/lib/moxml/node_set.rb +1 -1
  46. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  47. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  48. data/lib/moxml/version.rb +1 -1
  49. data/lib/moxml/xml_emitter.rb +71 -0
  50. data/lib/moxml/xpath/compiler.rb +143 -8
  51. data/lib/moxml/xpath/parser.rb +22 -15
  52. data/lib/moxml.rb +3 -3
  53. data/spec/examples/readme_examples_spec.rb +0 -4
  54. data/spec/examples/xpath_examples_spec.rb +0 -11
  55. data/spec/integration/all_adapters_spec.rb +18 -1
  56. data/spec/integration/sax_parity_spec.rb +58 -0
  57. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  58. data/spec/integration/shared_examples/high_level/{document_builder_behavior.rb → parse_behavior.rb} +1 -2
  59. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  60. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  61. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  62. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +15 -8
  63. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  64. data/spec/moxml/adapter/libxml_internals_spec.rb +1 -1
  65. data/spec/moxml/adapter/ox_spec.rb +20 -6
  66. data/spec/moxml/adapter/platform_spec.rb +15 -2
  67. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  68. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  69. data/spec/moxml/config_spec.rb +0 -1
  70. data/spec/moxml/context_spec.rb +22 -0
  71. data/spec/moxml/doctype_spec.rb +1 -1
  72. data/spec/moxml/entity_spec.rb +76 -0
  73. data/spec/moxml/error_spec.rb +0 -50
  74. data/spec/moxml/node_spec.rb +104 -0
  75. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  76. data/spec/moxml/xml_emitter_spec.rb +64 -0
  77. data/spec/moxml/xpath/axes_spec.rb +112 -0
  78. data/spec/moxml/xpath/parser_spec.rb +6 -0
  79. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  80. data/spec/performance/benchmark_spec.rb +13 -6
  81. data/spec/performance/thread_safety_spec.rb +0 -4
  82. data/spec/spec_helper.rb +5 -1
  83. metadata +23 -6
  84. data/lib/moxml/adapter/libxml/entity_restorer.rb +0 -94
  85. data/lib/moxml/document_builder.rb +0 -167
  86. data/spec/moxml/document_builder_spec.rb +0 -30
@@ -96,23 +96,30 @@ 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
116
108
  end
109
+
110
+ describe "duplication" do
111
+ it "returns an independent deep copy" do
112
+ doc = context.parse("<root><a id='1'>text</a></root>")
113
+ original = doc.at_xpath("//a")
114
+ copy = original.dup
115
+
116
+ expect(copy).not_to equal(original)
117
+ expect(copy.name).to eq("a")
118
+ expect(copy["id"]).to eq("1")
119
+ expect(copy.text).to eq("text")
120
+ copy["id"] = "changed"
121
+ expect(original["id"]).to eq("1")
122
+ end
123
+ end
117
124
  end
118
125
  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
@@ -154,7 +154,7 @@ RSpec.describe Moxml::Adapter::Libxml do
154
154
  end
155
155
  end
156
156
 
157
- describe Moxml::Adapter::Libxml::EntityRestorer do
157
+ describe Moxml::Entity::Restorer do
158
158
  let(:context) { Moxml.new(:libxml) }
159
159
 
160
160
  it "restores entities through its public entry point" do
@@ -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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Moxml::AttributeResolver do
6
+ let(:context) { Moxml.new }
7
+ let(:doc) do
8
+ context.parse(<<~XML)
9
+ <root xmlns:p="http://x.org" xmlns:q="http://x.org">
10
+ <e q:type="namespaced" type="bare"/>
11
+ </root>
12
+ XML
13
+ end
14
+ let(:element) { doc.root.children.find(&:element?) }
15
+
16
+ describe ".resolve" do
17
+ it "matches bare names against no-namespace attributes only" do
18
+ expect(described_class.resolve(element, "type")&.value).to eq("bare")
19
+ end
20
+
21
+ it "matches prefixed names by expanded name" do
22
+ expect(described_class.resolve(element, "p:type")&.value)
23
+ .to eq("namespaced")
24
+ expect(described_class.resolve(element, "q:type")&.value)
25
+ .to eq("namespaced")
26
+ end
27
+
28
+ it "returns nil for undeclared prefixes and declarations" do
29
+ expect(described_class.resolve(element, "z:type")).to be_nil
30
+ expect(described_class.resolve(element, "xmlns:q")).to be_nil
31
+ end
32
+ end
33
+
34
+ describe ".assign" do
35
+ it "delegates xmlns declarations to the adapter" do
36
+ described_class.assign(element, "xmlns:r", "http://r.org")
37
+ expect(doc.to_xml).to include('xmlns:r="http://r.org"')
38
+ end
39
+
40
+ it "replaces the matching attribute in place" do
41
+ described_class.assign(element, "p:type", "rewritten")
42
+ expect(element["q:type"]).to eq("rewritten")
43
+ expect(element.attributes.size).to eq(2)
44
+ end
45
+
46
+ it "stores undeclared prefixes raw until they come into scope" do
47
+ described_class.assign(element, "z:type", "value")
48
+ expect(element["z:type"]).to be_nil
49
+
50
+ element.add_namespace("z", "http://z.org")
51
+ expect(element["z:type"]).to eq("value")
52
+ end
53
+ end
54
+
55
+ describe ".remove" do
56
+ it "removes by expanded name and returns the attribute" do
57
+ removed = described_class.remove(element, "p:type")
58
+ expect(removed.value).to eq("namespaced")
59
+ expect(element["q:type"]).to be_nil
60
+ expect(element["type"]).to eq("bare")
61
+ end
62
+
63
+ it "returns nil when nothing matches" do
64
+ expect(described_class.remove(element, "z:type")).to be_nil
65
+ end
66
+ end
67
+
68
+ describe ".attribute_test?" do
69
+ let(:attributes) { element.attributes }
70
+
71
+ it "applies Namespaces 1.0 §5.2 to bare names" do
72
+ bare = attributes.find { |a| a.value == "bare" }
73
+ namespaced = attributes.find { |a| a.value == "namespaced" }
74
+ expect(described_class.attribute_test?(element, bare, nil, "type"))
75
+ .to be(true)
76
+ expect(described_class.attribute_test?(element, namespaced, nil, "type"))
77
+ .to be(false)
78
+ end
79
+
80
+ it "matches prefixed tests by URI and accepts any local for nil" do
81
+ namespaced = attributes.find { |a| a.value == "namespaced" }
82
+ expect(described_class.attribute_test?(element, namespaced, "p", "type"))
83
+ .to be(true)
84
+ expect(described_class.attribute_test?(element, namespaced, "p", nil))
85
+ .to be(true)
86
+ expect(described_class.attribute_test?(element, namespaced, "z", "type"))
87
+ .to be(false)
88
+ end
89
+
90
+ it "treats :any as any namespace including none" do
91
+ bare = attributes.find { |a| a.value == "bare" }
92
+ expect(described_class.attribute_test?(element, bare, :any, "type"))
93
+ .to be(true)
94
+ end
95
+ end
96
+
97
+ describe ".attribute_test_uri?" do
98
+ it "matches the compile-time URI regardless of document prefixes" do
99
+ namespaced = element.attributes.find { |a| a.value == "namespaced" }
100
+ expect(described_class.attribute_test_uri?(
101
+ element, namespaced, "http://x.org", "type"
102
+ )).to be(true)
103
+ expect(described_class.attribute_test_uri?(
104
+ element, namespaced, "http://other.org", "type"
105
+ )).to be(false)
106
+ end
107
+ end
108
+ end
@@ -11,7 +11,6 @@ RSpec.describe Moxml::Config do
11
11
  expect(config.default_encoding).to eq("UTF-8")
12
12
  expect(config.default_indent).to eq(2)
13
13
  expect(config.default_line_ending).to eq("\n")
14
- expect(config.entity_encoding).to eq(:basic)
15
14
  end
16
15
 
17
16
  it "sets default entity_load_mode to :required" do
@@ -39,4 +39,26 @@ RSpec.describe Moxml::Context do
39
39
  expect(doc.root.name).to eq("root")
40
40
  end
41
41
  end
42
+
43
+ describe "wrapper identity map" do
44
+ it "hands back the same wrapper for the same native" do
45
+ doc = context.parse("<root><item id='1'/></root>")
46
+ first = doc.xpath("//item").first
47
+ second = doc.xpath("//item").first
48
+ expect(first).to equal(second)
49
+
50
+ again = doc.root.children.last
51
+ expect(again).to equal(first)
52
+ end
53
+
54
+ it "re-keys when refresh_native! swaps the native" do
55
+ doc = context.create_document
56
+ element = doc.create_element("x")
57
+ doc.add_child(element)
58
+
59
+ refreshed = doc.root
60
+ expect(refreshed.name).to eq("x")
61
+ expect(context.wrapper_for(refreshed.native)).to equal(refreshed)
62
+ end
63
+ end
42
64
  end
@@ -48,7 +48,7 @@ RSpec.describe Moxml::Doctype do
48
48
  end
49
49
 
50
50
  describe "parsing" do
51
- %i[nokogiri oga rexml ox].each do |adapter_name|
51
+ %i[nokogiri oga rexml ox libxml].each do |adapter_name|
52
52
  context "with #{adapter_name} adapter" do
53
53
  let(:ctx) { Moxml.new(adapter_name) }
54
54
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Moxml::Entity do
6
+ describe ".preprocess_entities" do
7
+ it "marks non-standard entities and leaves the five standard ones" do
8
+ marked = described_class.preprocess_entities("a &copy; b &amp; c &lt;")
9
+ expect(marked).to include("#{described_class::MARKER}copy;")
10
+ expect(marked).to include("&amp;")
11
+ expect(marked).to include("&lt;")
12
+ end
13
+
14
+ it "returns the input untouched when it contains no ampersand" do
15
+ input = "plain text"
16
+ expect(described_class.preprocess_entities(input)).to equal(input)
17
+ end
18
+
19
+ it "always returns UTF-8" do
20
+ binary = "caf\xC3\xA9 &copy;".b
21
+ result = described_class.preprocess_entities(binary)
22
+ expect(result.encoding).to eq(Encoding::UTF_8)
23
+ end
24
+
25
+ it "returns an empty string for nil" do
26
+ expect(described_class.preprocess_entities(nil)).to eq("")
27
+ end
28
+ end
29
+
30
+ describe ".decode_entities" do
31
+ it "decodes numeric and standard named references in one pass" do
32
+ expect(described_class.decode_entities("&#65;&#x42;&amp;&lt;"))
33
+ .to eq("AB&<")
34
+ end
35
+
36
+ it "preserves invalid numeric codepoints verbatim" do
37
+ expect(described_class.decode_entities("&#0;&#xD800;")).to eq("&#0;&#xD800;")
38
+ end
39
+
40
+ it "skips strings without ampersands" do
41
+ input = "plain"
42
+ expect(described_class.decode_entities(input)).to equal(input)
43
+ end
44
+ end
45
+
46
+ describe ".restore_entities" do
47
+ it "maps markers back to named references" do
48
+ restored = described_class.restore_entities(
49
+ "a#{described_class::MARKER}copy;b",
50
+ )
51
+ expect(restored).to eq("a&copy;b")
52
+ end
53
+
54
+ it "restores markers a native serializer rendered as character references" do
55
+ expect(described_class.restore_entities("a&#xFFFC;&#xFEFF;copy;b"))
56
+ .to eq("a&copy;b")
57
+ end
58
+
59
+ it "returns marker-free strings without a regex pass" do
60
+ input = "no markers here"
61
+ expect(described_class.restore_entities(input)).to equal(input)
62
+ end
63
+ end
64
+
65
+ describe Moxml::Entity::Reference do
66
+ it "round-trips as a serialized entity reference" do
67
+ expect(described_class.new("copy").to_xml).to eq("&copy;")
68
+ end
69
+
70
+ it "compares by name" do
71
+ reference = described_class.new("copy")
72
+ expect(reference).to eq(described_class.new("copy"))
73
+ expect(reference).not_to eq(described_class.new("nbsp"))
74
+ end
75
+ end
76
+ end
@@ -150,31 +150,6 @@ RSpec.describe "Moxml errors" do
150
150
  end
151
151
  end
152
152
 
153
- describe Moxml::SerializationError do
154
- it "includes node, adapter, and format information" do
155
- error = described_class.new(
156
- "Failed to serialize",
157
- adapter: "LibXML",
158
- format: "xml",
159
- )
160
- expect(error.adapter).to eq("LibXML")
161
- expect(error.format).to eq("xml")
162
- end
163
-
164
- it "provides helpful error message" do
165
- error = described_class.new(
166
- "Failed to serialize",
167
- adapter: "Ox",
168
- format: "xml",
169
- )
170
- message = error.to_s
171
- expect(message).to include("Failed to serialize")
172
- expect(message).to include("Adapter: Ox")
173
- expect(message).to include("Format: xml")
174
- expect(message).to include("Hint:")
175
- end
176
- end
177
-
178
153
  describe Moxml::DocumentStructureError do
179
154
  it "includes attempted operation and state" do
180
155
  error = described_class.new(
@@ -200,31 +175,6 @@ RSpec.describe "Moxml errors" do
200
175
  end
201
176
  end
202
177
 
203
- describe Moxml::AttributeError do
204
- it "includes attribute name, element, and value" do
205
- error = described_class.new(
206
- "Invalid attribute",
207
- name: "id",
208
- value: 123,
209
- )
210
- expect(error.attribute_name).to eq("id")
211
- expect(error.value).to eq(123)
212
- end
213
-
214
- it "provides helpful error message" do
215
- error = described_class.new(
216
- "Invalid attribute name",
217
- name: "123invalid",
218
- value: "test",
219
- )
220
- message = error.to_s
221
- expect(message).to include("Invalid attribute name")
222
- expect(message).to include("Attribute: 123invalid")
223
- expect(message).to include("Value: \"test\"")
224
- expect(message).to include("Hint:")
225
- end
226
- end
227
-
228
178
  describe Moxml::NotImplementedError do
229
179
  it "includes feature and adapter information" do
230
180
  error = described_class.new(