moxml 0.1.13 → 0.1.15

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +117 -66
  3. data/Gemfile +1 -0
  4. data/README.adoc +11 -9
  5. data/Rakefile +3 -1
  6. data/docs/_pages/configuration.adoc +22 -19
  7. data/docs/_tutorials/namespace-handling.adoc +5 -5
  8. data/lib/moxml/adapter/base.rb +8 -3
  9. data/lib/moxml/adapter/customized_libxml/entity_reference.rb +23 -0
  10. data/lib/moxml/adapter/customized_libxml.rb +18 -0
  11. data/lib/moxml/adapter/customized_oga/xml_generator.rb +2 -2
  12. data/lib/moxml/adapter/customized_oga.rb +10 -0
  13. data/lib/moxml/adapter/customized_ox/entity_reference.rb +25 -0
  14. data/lib/moxml/adapter/customized_ox.rb +12 -0
  15. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +19 -0
  16. data/lib/moxml/adapter/customized_rexml/formatter.rb +2 -0
  17. data/lib/moxml/adapter/customized_rexml.rb +11 -0
  18. data/lib/moxml/adapter/headed_ox.rb +9 -3
  19. data/lib/moxml/adapter/libxml.rb +76 -62
  20. data/lib/moxml/adapter/nokogiri.rb +4 -5
  21. data/lib/moxml/adapter/oga.rb +50 -26
  22. data/lib/moxml/adapter/ox.rb +189 -41
  23. data/lib/moxml/adapter/rexml.rb +27 -8
  24. data/lib/moxml/attribute.rb +3 -0
  25. data/lib/moxml/builder.rb +1 -0
  26. data/lib/moxml/config.rb +7 -7
  27. data/lib/moxml/document.rb +5 -1
  28. data/lib/moxml/document_builder.rb +37 -31
  29. data/lib/moxml/element.rb +13 -5
  30. data/lib/moxml/entity_registry.rb +36 -0
  31. data/lib/moxml/node.rb +23 -2
  32. data/lib/moxml/node_set.rb +43 -15
  33. data/lib/moxml/version.rb +1 -1
  34. data/lib/moxml/xml_utils.rb +1 -1
  35. data/spec/integration/shared_examples/edge_cases.rb +3 -0
  36. data/spec/moxml/adapter/oga_spec.rb +62 -0
  37. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +1 -12
  38. data/spec/moxml/allocation_benchmark_spec.rb +96 -0
  39. data/spec/moxml/allocation_guard_spec.rb +282 -0
  40. data/spec/moxml/builder_spec.rb +22 -0
  41. data/spec/moxml/config_spec.rb +11 -11
  42. data/spec/moxml/doctype_spec.rb +41 -0
  43. data/spec/moxml/lazy_parse_spec.rb +115 -0
  44. data/spec/moxml/namespace_uri_validation_spec.rb +11 -3
  45. data/spec/moxml/node_cache_spec.rb +110 -0
  46. data/spec/moxml/node_set_cache_spec.rb +90 -0
  47. data/spec/moxml/xml_utils_spec.rb +32 -0
  48. data/spec/support/allocation_helper.rb +165 -0
  49. data/spec/support/w3c_namespace_helpers.rb +2 -1
  50. metadata +15 -2
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # Allocation guard specs — these run in CI by default (no :performance tag).
7
+ #
8
+ # These specs enforce allocation budgets per adapter per operation.
9
+ # If an adapter exceeds its threshold, the spec fails with a diagnostic
10
+ # message showing the actual vs expected allocation count.
11
+ #
12
+ # Thresholds are in AllocationHelper::THRESHOLDS and should be tightened
13
+ # as optimizations are confirmed.
14
+ RSpec.describe "Allocation guards", order: :defined do
15
+ def threshold_for(adapter_name, operation)
16
+ AllocationHelper.threshold(adapter_name, operation)
17
+ end
18
+
19
+ def guard_allocations(adapter_name, operation, &block)
20
+ allocs = AllocationHelper.count_allocations(&block)
21
+ limit = threshold_for(adapter_name, operation)
22
+
23
+ if allocs > limit
24
+ # Generate StackProf diagnostic on failure
25
+ profile = AllocationHelper.profile_allocations(&block)
26
+ raise(<<~MSG)
27
+ #{adapter_name}/#{operation}: #{allocs} allocations exceeds limit of #{limit}
28
+
29
+ #{profile}
30
+ MSG
31
+ end
32
+ allocs
33
+ end
34
+
35
+ shared_examples "allocation guard" do |adapter_name|
36
+ let(:ctx) { Moxml::Context.new(adapter_name) }
37
+
38
+ # ----------------------------------------------------------------
39
+ # Parse allocation guard
40
+ # ----------------------------------------------------------------
41
+ describe "parse allocations" do
42
+ it "parses 100-element document within allocation budget" do
43
+ xml = generate_xml(100)
44
+ allocs = guard_allocations(adapter_name, :parse_100) { ctx.parse(xml) }
45
+ expect(allocs).to be <= threshold_for(adapter_name, :parse_100)
46
+ end
47
+
48
+ it "parses 50-element document within allocation budget" do
49
+ xml = generate_xml(50)
50
+ allocs = guard_allocations(adapter_name, :parse_50) { ctx.parse(xml) }
51
+ expect(allocs).to be <= threshold_for(adapter_name, :parse_50)
52
+ end
53
+
54
+ it "parse + root.name is allocation-efficient" do
55
+ xml = generate_xml(100)
56
+ allocs = guard_allocations(adapter_name, :parse_and_root) do
57
+ doc = ctx.parse(xml)
58
+ doc.root.name
59
+ end
60
+ expect(allocs).to be <= threshold_for(adapter_name, :parse_and_root)
61
+ end
62
+ end
63
+
64
+ # ----------------------------------------------------------------
65
+ # Cache hit guards — second access should allocate near-zero objects
66
+ # ----------------------------------------------------------------
67
+ describe "cache hit guards" do
68
+ let(:simple_xml) { "<root><a/><b/><c/></root>" }
69
+ let(:attr_xml) { '<root x="1" y="2" z="3"><child k="4"/></root>' }
70
+
71
+ it "children access is cached after first call" do
72
+ doc = ctx.parse(simple_xml)
73
+ root = doc.root
74
+ # Warm the cache
75
+ root.children.to_a
76
+
77
+ allocs = AllocationHelper.count_allocations { root.children.to_a }
78
+ expect(allocs).to be <= threshold_for(adapter_name, :cached_children_access),
79
+ "Second children access (#{allocs}) should allocate <= #{threshold_for(
80
+ adapter_name, :cached_children_access
81
+ )}"
82
+ end
83
+
84
+ it "attributes access is cached after first call" do
85
+ doc = ctx.parse(attr_xml)
86
+ root = doc.root
87
+ # Warm the cache
88
+ root.attributes
89
+
90
+ allocs = AllocationHelper.count_allocations { root.attributes }
91
+ expect(allocs).to be <= threshold_for(adapter_name, :cached_attributes_access),
92
+ "Second attributes access (#{allocs}) should allocate <= #{threshold_for(
93
+ adapter_name, :cached_attributes_access
94
+ )}"
95
+ end
96
+
97
+ it "NodeSet iteration is cached on second pass" do
98
+ xml = generate_xml(20)
99
+ doc = ctx.parse(xml)
100
+ root = doc.root
101
+ # Warm the cache
102
+ root.children.each { |_| nil }
103
+
104
+ allocs = AllocationHelper.count_allocations do
105
+ root.children.each do |_|
106
+ nil
107
+ end
108
+ end
109
+ expect(allocs).to be <= threshold_for(adapter_name, :cached_iteration),
110
+ "Second NodeSet iteration (#{allocs}) should allocate <= #{threshold_for(
111
+ adapter_name, :cached_iteration
112
+ )}"
113
+ end
114
+ end
115
+
116
+ # ----------------------------------------------------------------
117
+ # Round-trip allocation guard
118
+ # ----------------------------------------------------------------
119
+ describe "round-trip allocations" do
120
+ it "parse → serialize → parse stays within budget" do
121
+ xml = generate_xml(50)
122
+ allocs = guard_allocations(adapter_name, :round_trip) do
123
+ doc = ctx.parse(xml)
124
+ serialized = doc.to_xml
125
+ ctx.parse(serialized)
126
+ end
127
+ expect(allocs).to be <= threshold_for(adapter_name, :round_trip)
128
+ end
129
+ end
130
+
131
+ # ----------------------------------------------------------------
132
+ # Scalability guard — allocations grow linearly, not quadratically
133
+ # ----------------------------------------------------------------
134
+ describe "scalability" do
135
+ it "allocation growth is linear with document size" do
136
+ xml_100 = generate_xml(100)
137
+ xml_200 = generate_xml(200)
138
+
139
+ allocs_100 = AllocationHelper.count_allocations { ctx.parse(xml_100) }
140
+ allocs_200 = AllocationHelper.count_allocations { ctx.parse(xml_200) }
141
+
142
+ ratio = allocs_200.to_f / allocs_100
143
+ max_ratio = threshold_for(adapter_name, :scalability_ratio)
144
+
145
+ expect(ratio).to be <= max_ratio,
146
+ "200-element parse (#{allocs_200}) vs 100-element (#{allocs_100}) = #{ratio.round(2)}x, " \
147
+ "expected <= #{max_ratio}x (linear growth)"
148
+ end
149
+ end
150
+
151
+ # ----------------------------------------------------------------
152
+ # Cache invalidation guards — mutation breaks cache
153
+ # ----------------------------------------------------------------
154
+ describe "cache invalidation" do
155
+ it "adding a child invalidates children cache" do
156
+ xml = "<root><a/></root>"
157
+ doc = ctx.parse(xml)
158
+ root = doc.root
159
+ children_before = root.children
160
+
161
+ new_elem = ctx.parse("<b/>").root
162
+ root.add_child(new_elem)
163
+
164
+ children_after = root.children
165
+ expect(children_before).not_to equal(children_after),
166
+ "Children cache should be invalidated after add_child"
167
+ expect(children_after.size).to eq(2)
168
+ end
169
+
170
+ it "setting text invalidates children cache" do
171
+ xml = "<root><a/></root>"
172
+ doc = ctx.parse(xml)
173
+ root = doc.root
174
+ children_before = root.children
175
+
176
+ root.text = "replaced"
177
+
178
+ children_after = root.children
179
+ expect(children_before).not_to equal(children_after),
180
+ "Children cache should be invalidated after text="
181
+ end
182
+
183
+ it "setting attribute invalidates attributes cache" do
184
+ xml = '<root a="1"/>'
185
+ doc = ctx.parse(xml)
186
+ root = doc.root
187
+ attrs_before = root.attributes
188
+
189
+ root["b"] = "2"
190
+
191
+ attrs_after = root.attributes
192
+ expect(attrs_before).not_to equal(attrs_after),
193
+ "Attributes cache should be invalidated after []="
194
+ expect(attrs_after.size).to eq(2)
195
+ end
196
+
197
+ it "removing attribute invalidates attributes cache" do
198
+ xml = '<root a="1" b="2"/>'
199
+ doc = ctx.parse(xml)
200
+ root = doc.root
201
+ attrs_before = root.attributes
202
+
203
+ root.remove_attribute("a")
204
+
205
+ attrs_after = root.attributes
206
+ expect(attrs_before).not_to equal(attrs_after),
207
+ "Attributes cache should be invalidated after remove_attribute"
208
+ expect(attrs_after.size).to eq(1)
209
+ end
210
+
211
+ it "removing a child invalidates parent's children cache" do
212
+ xml = "<root><a/><b/></root>"
213
+ doc = ctx.parse(xml)
214
+ root = doc.root
215
+ children_before = root.children
216
+ child_a = root.children.first
217
+
218
+ child_a.remove
219
+
220
+ children_after = root.children
221
+ expect(children_before).not_to equal(children_after),
222
+ "Parent's children cache should be invalidated after child.remove"
223
+ expect(children_after.size).to eq(1)
224
+ end
225
+ end
226
+
227
+ # ----------------------------------------------------------------
228
+ # NodeSet wrap cache guards
229
+ # ----------------------------------------------------------------
230
+ describe "NodeSet wrap cache" do
231
+ it "returns the same wrapper for same index" do
232
+ doc = ctx.parse("<root><a/><b/><c/></root>")
233
+ children = doc.root.children
234
+
235
+ first_access = children[0]
236
+ second_access = children[0]
237
+ expect(first_access).to equal(second_access),
238
+ "Same index should return identical wrapper object"
239
+ end
240
+
241
+ it "returns the same wrapper from each as from []" do
242
+ doc = ctx.parse("<root><a/><b/><c/></root>")
243
+ children = doc.root.children
244
+
245
+ from_each = nil
246
+ children.each { |c| from_each = c if c.name == "b" }
247
+ from_index = children[1]
248
+
249
+ expect(from_each).to equal(from_index),
250
+ "Node from #each should be identical to same index from #[]"
251
+ end
252
+
253
+ it "preserves cache across multiple iterations" do
254
+ doc = ctx.parse("<root><a/><b/><c/></root>")
255
+ children = doc.root.children
256
+
257
+ pass1 = children.map(&:name)
258
+ pass2 = children.map(&:name)
259
+
260
+ expect(pass1).to eq(pass2)
261
+ # Also verify object identity between passes
262
+ pass1_nodes = children.to_a
263
+ pass2_nodes = children.to_a
264
+ pass1_nodes.each_with_index do |node, i|
265
+ expect(node).to equal(pass2_nodes[i]),
266
+ "Node #{i} should be identical across iterations"
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ # Run guards for each adapter
273
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
274
+ describe "#{adapter_name} adapter" do
275
+ before(:all) do
276
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
277
+ end
278
+
279
+ it_behaves_like "allocation guard", adapter_name
280
+ end
281
+ end
282
+ end
@@ -5,6 +5,28 @@ require "spec_helper"
5
5
  RSpec.describe Moxml::Builder do
6
6
  let(:context) { Moxml.new }
7
7
 
8
+ describe "#document" do
9
+ it "exposes the underlying document via document accessor" do
10
+ builder = described_class.new(context)
11
+ expect(builder.document).to be_a(Moxml::Document)
12
+ end
13
+
14
+ it "exposes the underlying document via doc alias" do
15
+ builder = described_class.new(context)
16
+ expect(builder.doc).to be_a(Moxml::Document)
17
+ expect(builder.doc).to equal(builder.document)
18
+ end
19
+
20
+ it "returns the same document before and after build" do
21
+ builder = described_class.new(context)
22
+ doc_before = builder.document
23
+ builder.build do
24
+ element "root"
25
+ end
26
+ expect(builder.document).to equal(doc_before)
27
+ end
28
+ end
29
+
8
30
  describe "#build" do
9
31
  it "builds a document with DSL" do
10
32
  doc = described_class.new(context).build do
@@ -17,8 +17,8 @@ RSpec.describe Moxml::Config do
17
17
  expect(config.entity_load_mode).to eq(:required)
18
18
  end
19
19
 
20
- it "sets default namespace_uri_mode to :strict" do
21
- expect(config.namespace_uri_mode).to eq(:strict)
20
+ it "sets default namespace_validation_mode to :strict" do
21
+ expect(config.namespace_validation_mode).to eq(:strict)
22
22
  end
23
23
  end
24
24
 
@@ -66,26 +66,26 @@ RSpec.describe Moxml::Config do
66
66
  end
67
67
  end
68
68
 
69
- describe "#namespace_uri_mode=" do
69
+ describe "#namespace_validation_mode=" do
70
70
  it "accepts :strict" do
71
- config.namespace_uri_mode = :strict
72
- expect(config.namespace_uri_mode).to eq(:strict)
71
+ config.namespace_validation_mode = :strict
72
+ expect(config.namespace_validation_mode).to eq(:strict)
73
73
  end
74
74
 
75
75
  it "accepts :lenient" do
76
- config.namespace_uri_mode = :lenient
77
- expect(config.namespace_uri_mode).to eq(:lenient)
76
+ config.namespace_validation_mode = :lenient
77
+ expect(config.namespace_validation_mode).to eq(:lenient)
78
78
  end
79
79
 
80
80
  it "accepts string values" do
81
- config.namespace_uri_mode = "lenient"
82
- expect(config.namespace_uri_mode).to eq(:lenient)
81
+ config.namespace_validation_mode = "lenient"
82
+ expect(config.namespace_validation_mode).to eq(:lenient)
83
83
  end
84
84
 
85
85
  it "raises error for invalid mode" do
86
86
  expect do
87
- config.namespace_uri_mode = :invalid
88
- end.to raise_error(ArgumentError, /Invalid namespace_uri_mode/)
87
+ config.namespace_validation_mode = :invalid
88
+ end.to raise_error(ArgumentError, /Invalid namespace_validation_mode/)
89
89
  end
90
90
  end
91
91
 
@@ -46,4 +46,45 @@ RSpec.describe Moxml::Doctype do
46
46
  expect(doctype.name).to eq("html")
47
47
  end
48
48
  end
49
+
50
+ describe "parsing" do
51
+ %i[nokogiri oga rexml ox].each do |adapter_name|
52
+ context "with #{adapter_name} adapter" do
53
+ let(:ctx) { Moxml.new(adapter_name) }
54
+
55
+ it "parses PUBLIC doctype with external and system identifiers" do
56
+ xml = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html/>'
57
+ doc = ctx.parse(xml)
58
+ doctype = doc.children.find { |c| c.is_a?(described_class) }
59
+
60
+ expect(doctype).not_to be_nil
61
+ expect(doctype.name).to eq("html")
62
+ expect(doctype.external_id).to eq("-//W3C//DTD XHTML 1.0 Strict//EN")
63
+ expect(doctype.system_id).to eq("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd")
64
+ end
65
+
66
+ it "parses SYSTEM doctype with system identifier only" do
67
+ xml = '<!DOCTYPE config SYSTEM "config.dtd"><config/>'
68
+ doc = ctx.parse(xml)
69
+ doctype = doc.children.find { |c| c.is_a?(described_class) }
70
+
71
+ expect(doctype).not_to be_nil
72
+ expect(doctype.name).to eq("config")
73
+ expect(doctype.external_id).to be_nil
74
+ expect(doctype.system_id).to eq("config.dtd")
75
+ end
76
+
77
+ it "parses simple doctype without identifiers" do
78
+ xml = "<!DOCTYPE html><html/>"
79
+ doc = ctx.parse(xml)
80
+ doctype = doc.children.find { |c| c.is_a?(described_class) }
81
+
82
+ expect(doctype).not_to be_nil
83
+ expect(doctype.name).to eq("html")
84
+ expect(doctype.external_id).to be_nil
85
+ expect(doctype.system_id).to be_nil
86
+ end
87
+ end
88
+ end
89
+ end
49
90
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # Lazy parse correctness tests — these run in CI by default.
7
+ # Verifies that lazy parse produces correct document structure
8
+ # across all adapters without eager wrapper construction.
9
+ RSpec.describe "Moxml lazy parse" do
10
+ let(:xml) do
11
+ "<root><child><nested>text</nested></child><sibling>more</sibling></root>"
12
+ end
13
+
14
+ shared_examples "lazy parse behavior" do |adapter_name|
15
+ let(:ctx) { Moxml::Context.new(adapter_name) }
16
+
17
+ describe "#parse" do
18
+ it "returns a Document without eagerly building wrapper tree" do
19
+ doc = ctx.parse(xml)
20
+ expect(doc).to be_a(Moxml::Document)
21
+ expect(doc.root).to be_a(Moxml::Element)
22
+ expect(doc.root.name).to eq("root")
23
+ end
24
+
25
+ it "provides correct children via lazy access" do
26
+ doc = ctx.parse(xml)
27
+ root = doc.root
28
+ children = root.children.to_a
29
+ expect(children.size).to eq(2)
30
+ expect(children[0]).to be_a(Moxml::Element)
31
+ expect(children[0].name).to eq("child")
32
+ expect(children[1].name).to eq("sibling")
33
+ end
34
+
35
+ it "provides correct nested children" do
36
+ doc = ctx.parse(xml)
37
+ nested = doc.root.children[0].children[0]
38
+ expect(nested.name).to eq("nested")
39
+ expect(nested.text).to eq("text")
40
+ end
41
+
42
+ it "preserves attributes" do
43
+ xml_with_attrs = '<root a="1" b="2"><child c="3"/></root>'
44
+ doc = ctx.parse(xml_with_attrs)
45
+ root = doc.root
46
+ attrs = root.attributes
47
+ expect(attrs.size).to eq(2)
48
+ expect(root["a"]).to eq("1")
49
+ expect(root["b"]).to eq("2")
50
+ expect(root.children[0]["c"]).to eq("3")
51
+ end
52
+
53
+ it "preserves text content" do
54
+ doc = ctx.parse("<root>hello world</root>")
55
+ expect(doc.root.text).to eq("hello world")
56
+ end
57
+
58
+ it "preserves mixed content" do
59
+ mixed_xml = "<root>before<child/>after</root>"
60
+ doc = ctx.parse(mixed_xml)
61
+ expect(doc.root.text).to eq("beforeafter")
62
+ end
63
+
64
+ it "handles comments" do
65
+ comment_xml = "<root><!-- a comment --><child/></root>"
66
+ doc = ctx.parse(comment_xml)
67
+ children = doc.root.children.to_a
68
+ comment = children.find(&:comment?)
69
+ expect(comment).not_to be_nil
70
+ expect(comment.content).to include("a comment")
71
+ end
72
+
73
+ it "handles processing instructions" do
74
+ pi_xml = "<?pi-target pi-content?><root/>"
75
+ doc = ctx.parse(pi_xml)
76
+ expect(doc.root.name).to eq("root")
77
+ end
78
+
79
+ it "handles namespace declarations" do
80
+ ns_xml = '<root xmlns:ns="http://example.com"><ns:child/></root>'
81
+ doc = ctx.parse(ns_xml)
82
+ root = doc.root
83
+ nss = root.namespaces
84
+ expect(nss.size).to be >= 1
85
+ end
86
+
87
+ it "round-trips through serialize" do
88
+ doc = ctx.parse(xml)
89
+ serialized = doc.to_xml
90
+ doc2 = ctx.parse(serialized)
91
+ expect(doc2.root.name).to eq("root")
92
+ expect(doc2.root.children.to_a.size).to eq(2)
93
+ end
94
+ end
95
+ end
96
+
97
+ # Run for all guarded adapters
98
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
99
+ describe "#{adapter_name} adapter" do
100
+ before(:all) do
101
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
102
+ end
103
+
104
+ it_behaves_like "lazy parse behavior", adapter_name
105
+
106
+ # CDATA behavior differs between adapters
107
+ it "handles CDATA sections" do
108
+ ctx = Moxml::Context.new(adapter_name)
109
+ cdata_xml = "<root><![CDATA[<not xml>]]></root>"
110
+ doc = ctx.parse(cdata_xml)
111
+ expect(doc.root).not_to be_nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -92,10 +92,10 @@ RSpec.describe "Namespace URI validation" do
92
92
  end
93
93
  end
94
94
 
95
- describe "lenient namespace_uri_mode" do
95
+ describe "lenient namespace_validation_mode" do
96
96
  let(:context) do
97
97
  Moxml.new do |config|
98
- config.namespace_uri_mode = :lenient
98
+ config.namespace_validation_mode = :lenient
99
99
  end
100
100
  end
101
101
 
@@ -134,7 +134,15 @@ RSpec.describe "Namespace URI validation" do
134
134
  end
135
135
 
136
136
  it "still accepts valid URIs" do
137
- expect { element.add_namespace("ns", "http://example.com") }.not_to raise_error
137
+ expect do
138
+ element.add_namespace("ns", "http://example.com")
139
+ end.not_to raise_error
140
+ end
141
+
142
+ it "accepts prefixes with dots in lenient mode" do
143
+ expect do
144
+ element.add_namespace("xmlns_1.0", "http://example.com")
145
+ end.not_to raise_error
138
146
  end
139
147
  end
140
148
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/allocation_helper"
5
+
6
+ # Node caching correctness tests — these run in CI by default.
7
+ # Verifies that cache semantics (identity, invalidation) work correctly
8
+ # across all adapters.
9
+ RSpec.describe "Moxml node caching" do
10
+ shared_examples "cached children" do |adapter_name|
11
+ let(:ctx) { Moxml::Context.new(adapter_name) }
12
+ let(:xml) { "<root><a/><b/><c/></root>" }
13
+ let(:doc) { ctx.parse(xml) }
14
+ let(:root) { doc.root }
15
+
16
+ describe "Node#children caching" do
17
+ it "returns the same NodeSet object on repeated calls" do
18
+ children1 = root.children
19
+ children2 = root.children
20
+ expect(children1).to equal(children2)
21
+ end
22
+
23
+ it "returns consistent child elements across calls" do
24
+ children1 = root.children.to_a
25
+ children2 = root.children.to_a
26
+ expect(children1.map(&:name)).to eq(children2.map(&:name))
27
+ end
28
+
29
+ it "invalidates cache when a child is added" do
30
+ children_before = root.children
31
+ root.add_child(ctx.parse("<d/>").root)
32
+ children_after = root.children
33
+ expect(children_before).not_to equal(children_after)
34
+ expect(children_after.to_a.size).to eq(4)
35
+ end
36
+
37
+ it "invalidates cache when text is set" do
38
+ children_before = root.children
39
+ root.text = "new text"
40
+ children_after = root.children
41
+ expect(children_before).not_to equal(children_after)
42
+ end
43
+ end
44
+
45
+ describe "Element#attributes caching" do
46
+ let(:attr_xml) { '<root a="1" b="2"><child c="3"/></root>' }
47
+ let(:attr_doc) { ctx.parse(attr_xml) }
48
+ let(:attr_root) { attr_doc.root }
49
+
50
+ it "returns the same array on repeated calls" do
51
+ attrs1 = attr_root.attributes
52
+ attrs2 = attr_root.attributes
53
+ expect(attrs1).to equal(attrs2)
54
+ end
55
+
56
+ it "returns consistent attribute values" do
57
+ attrs = attr_root.attributes
58
+ expect(attrs.to_h do |a|
59
+ [a.name, a.value]
60
+ end).to eq({ "a" => "1", "b" => "2" })
61
+ end
62
+
63
+ it "invalidates cache when an attribute is set" do
64
+ attrs_before = attr_root.attributes
65
+ attr_root["c"] = "3"
66
+ attrs_after = attr_root.attributes
67
+ expect(attrs_before).not_to equal(attrs_after)
68
+ expect(attrs_after.size).to eq(3)
69
+ end
70
+
71
+ it "invalidates cache when an attribute is removed" do
72
+ attrs_before = attr_root.attributes
73
+ attr_root.remove_attribute("a")
74
+ attrs_after = attr_root.attributes
75
+ expect(attrs_before).not_to equal(attrs_after)
76
+ expect(attrs_after.size).to eq(1)
77
+ end
78
+ end
79
+
80
+ describe "Element#namespaces caching" do
81
+ let(:ns_xml) { '<root xmlns:a="http://a.com" xmlns:b="http://b.com"><a:child/></root>' }
82
+ let(:ns_doc) { ctx.parse(ns_xml) }
83
+ let(:ns_root) { ns_doc.root }
84
+
85
+ it "returns the same array on repeated calls" do
86
+ nss1 = ns_root.namespaces
87
+ nss2 = ns_root.namespaces
88
+ expect(nss1).to equal(nss2)
89
+ end
90
+
91
+ it "invalidates cache when a namespace is added" do
92
+ nss_before = ns_root.namespaces
93
+ ns_root.add_namespace("c", "http://c.com")
94
+ nss_after = ns_root.namespaces
95
+ expect(nss_before).not_to equal(nss_after)
96
+ expect(nss_after.size).to eq(3)
97
+ end
98
+ end
99
+ end
100
+
101
+ AllocationHelper::GUARDED_ADAPTERS.each do |adapter_name|
102
+ describe "#{adapter_name} adapter" do
103
+ before(:all) do
104
+ skip("#{adapter_name} adapter not available") unless AllocationHelper.adapter_available?(adapter_name)
105
+ end
106
+
107
+ it_behaves_like "cached children", adapter_name
108
+ end
109
+ end
110
+ end