moxml 0.5.145 → 0.5.147

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +35 -0
  3. data/README.adoc +19 -1
  4. data/docs/_pages/adapter-compatibility.adoc +7 -0
  5. data/docs/_pages/divergences.adoc +90 -0
  6. data/docs/_references/index.adoc +9 -0
  7. data/docs/_references/processing-model.adoc +211 -0
  8. data/docs/index.adoc +9 -0
  9. data/lib/moxml/adapter/leptris.rb +129 -2
  10. data/lib/moxml/adapter/libxml.rb +5 -0
  11. data/lib/moxml/adapter/oga.rb +10 -0
  12. data/lib/moxml/adapter/rexml.rb +5 -0
  13. data/lib/moxml/document.rb +9 -0
  14. data/lib/moxml/version.rb +1 -1
  15. data/lib/moxml/xpath/compiler.rb +14 -1
  16. data/spec/consistency/divergence_register_spec.rb +109 -0
  17. data/spec/consistency/document_text_spec.rb +30 -0
  18. data/spec/consistency/eol_normalization_spec.rb +8 -8
  19. data/spec/consistency/loud_degradation_spec.rb +44 -0
  20. data/spec/consistency/model_coverage_audit_spec.rb +113 -0
  21. data/spec/consistency/spec_constant_uniqueness_spec.rb +29 -0
  22. data/spec/consistency/xpath_idioms_spec.rb +32 -1
  23. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +133 -0
  24. data/spec/moxml/adapter/leptris_detached_text_spec.rb +34 -0
  25. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +35 -0
  26. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +3 -3
  27. data/spec/moxml/c14n/adapter_delegation_spec.rb +3 -3
  28. data/spec/moxml/c14n/comments_pis_spec.rb +2 -2
  29. data/spec/moxml/c14n/subset_spec.rb +4 -4
  30. data/spec/moxml/c14n/w3c_examples_spec.rb +3 -1
  31. data/spec/moxml/declaration_preservation_spec.rb +2 -2
  32. data/spec/moxml/element_add_element_spec.rb +3 -3
  33. data/spec/moxml/element_attribute_pairs_spec.rb +2 -2
  34. data/spec/moxml/node_spec.rb +1 -1
  35. data/spec/moxml/plan_conformance_spec.rb +3 -3
  36. data/spec/moxml/sax_entity_parity_spec.rb +3 -3
  37. data/spec/moxml/sax_spec.rb +7 -7
  38. data/spec/moxml/struct_plan_spec.rb +2 -2
  39. data/spec/moxml/xpath_capabilities_spec.rb +13 -13
  40. metadata +11 -1
@@ -73,6 +73,15 @@ module Moxml
73
73
  root_element ? Moxml::Node.wrap(root_element, context) : nil
74
74
  end
75
75
 
76
+ # Documents are container nodes: #text concatenates descendant
77
+ # text, Nokogiri's Document#text contract (issue #400) — the base
78
+ # Node#text answered "" and silently emptied consumers'
79
+ # content-preservation checks.
80
+ def text
81
+ val = adapter.text_content(@native)
82
+ entity_bearing? ? adapter.restore_entities(val) : val
83
+ end
84
+
76
85
  # Materialize the root subtree — see Moxml::Materializer.
77
86
  def materialize(&block)
78
87
  return to_enum(:materialize) unless block
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.145"
4
+ VERSION = "0.5.147"
5
5
  end
@@ -40,6 +40,7 @@ module Moxml
40
40
  # @param namespaces [Hash, nil] Optional namespace prefix mappings
41
41
  # @return [Proc] Compiled Proc that accepts a document
42
42
  def self.compile_with_cache(ast, namespaces: nil)
43
+ namespaces = normalize_namespaces(namespaces)
43
44
  cache_key = namespaces ? [ast, namespaces] : ast
44
45
  CACHE.get_or_set(cache_key) { new(namespaces: namespaces).compile(ast) }
45
46
  end
@@ -48,12 +49,24 @@ module Moxml
48
49
  #
49
50
  # @param namespaces [Hash, nil] Optional namespace prefix mappings
50
51
  def initialize(namespaces: nil)
51
- @namespaces = namespaces
52
+ @namespaces = self.class.normalize_namespaces(namespaces)
52
53
  @literal_id = 0
53
54
  @predicate_nodesets = []
54
55
  @predicate_indexes = []
55
56
  end
56
57
 
58
+ # Prefix lookups are string-keyed (the parser yields prefix
59
+ # strings); symbol-keyed bindings — the common Nokogiri spelling
60
+ # (`xpath("//cml:molecule", cml: URI)`) — must resolve too, or
61
+ # the miss falls through to prefix-spelling matching and
62
+ # namespaced elements never match (issue #395).
63
+ def self.normalize_namespaces(namespaces)
64
+ return nil if namespaces.nil?
65
+ return namespaces unless namespaces.is_a?(Hash)
66
+
67
+ namespaces.transform_keys(&:to_s)
68
+ end
69
+
57
70
  # Compiles an XPath AST into a Ruby Proc
58
71
  #
59
72
  # @param ast [AST::Node] XPath AST to compile
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Divergence register linkage (TODO.model/02): the register
6
+ # (docs/_pages/divergences.adoc) and the consistency suite must stay
7
+ # mechanically consistent — every capability-related skip cites a
8
+ # register ID, every register entry is cited by at least one spec, and
9
+ # no spec cites an unregistered ID.
10
+ RSpec.describe "divergence register linkage" do
11
+ REGISTER_PATH = "docs/_pages/divergences.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
12
+ SCAN_GLOBS = %w[spec/consistency/**/*.rb spec/moxml/**/*.rb].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
13
+ NEXT_GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
+ /same engine as/, # engine dedup, not a divergence
15
+ /source_adapter == target_adapter/, # self round-trip
16
+ /MAX_SIZE/, # size guard
17
+ /AVAILABLE_ADAPTERS\.include/, # availability filter
18
+ /skip_shapes/, # shape routing (IDs cited above)
19
+ /leptris_fixed\?/, # version gate
20
+ /source_elements/, # data-presence guard
21
+ /next unless match/, # scanner-internal guard
22
+ ].freeze
23
+
24
+ # Version/availability gates are not divergences: a binding too old
25
+ # or an adapter not installed is a deployment fact, not an
26
+ # engine-vs-model difference at equal versions.
27
+ GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
28
+ /unavailable/i, /not available/, /predates/, /not installed/i,
29
+ /not present/i, /requires/i, /required\z/, /has no \S+ face/,
30
+ /lacks the /, /carries /, /degraded below/, /lockstep/i,
31
+ /unreleased/i, /stood down/, /recovered without/,
32
+ /Only \S+ available in CI/i, /adapter-specific/i, /noblanks/,
33
+ /SKIP_ parse flags/, /engine < \d/
34
+ ].freeze
35
+
36
+ def register_ids
37
+ File.readlines(REGISTER_PATH)
38
+ .filter_map { |line| line[/\A\|\s*(D-\d+)\s*$/, 1] }
39
+ .uniq
40
+ end
41
+
42
+ def spec_files
43
+ SCAN_GLOBS.flat_map { |g| Dir[g] }.uniq
44
+ end
45
+
46
+ def cited_ids
47
+ spec_files.flat_map { |path| File.readlines(path) }
48
+ .flat_map { |line| line.scan(/D-\d+/) }
49
+ .uniq
50
+ end
51
+
52
+ it "has a register with entries" do
53
+ expect(register_ids).not_to be_empty
54
+ end
55
+
56
+ it "requires capability-related skips to cite a register ID" do
57
+ offenders = []
58
+ spec_files.each do |path|
59
+ File.readlines(path).each_with_index do |line, i|
60
+ next unless (msg = line[/skip\s+[&(]?\s*["'](.*?)["']/, 1])
61
+
62
+ next if GATE.any? { |re| re.match?(msg) }
63
+ next if msg.match?(/D-\d+/)
64
+
65
+ offenders << "#{path}:#{i + 1} #{msg.strip[0, 60]}"
66
+ end
67
+ end
68
+ expect(offenders).to be_empty,
69
+ "capability skips must cite a divergence-register " \
70
+ "ID (D-NN): #{offenders.join('; ')}"
71
+ end
72
+
73
+ it "requires adapter-exempting next-guards to cite a nearby register ID" do
74
+ offenders = []
75
+ spec_files.each do |path|
76
+ next if path.end_with?("divergence_register_spec.rb")
77
+
78
+ lines = File.readlines(path)
79
+ lines.each_with_index do |line, i|
80
+ next unless line.match?(/^\s*next\s+(if|unless)\s/)
81
+ next if NEXT_GATE.any? { |re| re.match?(line) }
82
+ next if line.match?(/D-\d+/)
83
+
84
+ context = lines[[i - 4, 0].max...i] + lines[(i + 1)..(i + 4)].to_a
85
+ next if context.any? { |l| l.match?(/D-\d+/) }
86
+
87
+ offenders << "#{path}:#{i + 1} #{line.strip[0, 60]}"
88
+ end
89
+ end
90
+ expect(offenders).to be_empty,
91
+ "adapter-exempting next-guards must cite a " \
92
+ "divergence-register ID (D-NN) on or above the " \
93
+ "line: #{offenders.join('; ')}"
94
+ end
95
+
96
+ it "cites every register entry from at least one spec" do
97
+ missing = register_ids - cited_ids
98
+ expect(missing).to be_empty,
99
+ "register entries never cited by a spec: " \
100
+ "#{missing.join(', ')}"
101
+ end
102
+
103
+ it "never cites an unregistered divergence ID" do
104
+ orphans = cited_ids - register_ids
105
+ expect(orphans).to be_empty,
106
+ "specs cite IDs missing from the register: " \
107
+ "#{orphans.join(', ')}"
108
+ end
109
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #400: documents are container nodes — Document#text
6
+ # concatenates descendant text (Nokogiri's Document#text contract).
7
+ # The base Node#text answered "" and silently emptied consumers'
8
+ # content-preservation checks (canon's formatter asserted through
9
+ # doc.text).
10
+ RSpec.describe "Document#text concatenates descendant text" do
11
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
12
+ context "with #{adapter_name}" do
13
+ let(:ctx) { Moxml.new(adapter_name) }
14
+
15
+ it "answers the concatenated descendant text" do
16
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
17
+ expect(doc.text).to eq("xy")
18
+ end
19
+
20
+ it "matches root.text (both containers recurse)" do
21
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
22
+ expect(doc.text).to eq(doc.root.text)
23
+ end
24
+
25
+ it "answers an empty string for element-free documents" do
26
+ expect(ctx.parse("<r/>").text).to eq("")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -20,7 +20,7 @@ require "spec_helper"
20
20
  # binding release vending that engine rides
21
21
  # (gate: binding 1.9.240).
22
22
  RSpec.describe "XML line-ending normalization" do
23
- ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
23
+ EOL_NORMALIZATION_ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
24
24
 
25
25
  def with_adapter(name)
26
26
  ctx = Moxml.new(name)
@@ -37,10 +37,10 @@ RSpec.describe "XML line-ending normalization" do
37
37
  end
38
38
 
39
39
  it "normalizes CRLF and lone CR to LF in text content" do
40
- ADAPTERS.each do |name|
40
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
41
41
  next unless leptris_fixed?(name)
42
42
 
43
- skip "oga preserves raw CR (upstream)" if name == :oga
43
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
44
44
 
45
45
  with_adapter(name) do |ctx|
46
46
  text = ctx.parse("<r>a\r\nb\rc</r>").root.children.first
@@ -50,10 +50,10 @@ RSpec.describe "XML line-ending normalization" do
50
50
  end
51
51
 
52
52
  it "normalizes EOL inside CDATA content" do
53
- ADAPTERS.each do |name|
53
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
54
54
  next unless leptris_fixed?(name)
55
55
 
56
- skip "oga preserves raw CR (upstream)" if name == :oga
56
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
57
57
 
58
58
  with_adapter(name) do |ctx|
59
59
  cdata = ctx.parse("<r><![CDATA[a\r\nb]]></r>").root.children.first
@@ -63,11 +63,11 @@ RSpec.describe "XML line-ending normalization" do
63
63
  end
64
64
 
65
65
  it "preserves CR from character references" do
66
- ADAPTERS.each do |name|
66
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
67
67
  next unless leptris_fixed?(name)
68
68
 
69
- skip "ox collapses &#13; to LF (upstream)" if name == :ox
70
- skip "oga preserves raw CR (upstream)" if name == :oga
69
+ skip "D-03: ox collapses &#13; to LF (upstream)" if name == :ox
70
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
71
71
 
72
72
  with_adapter(name) do |ctx|
73
73
  text = ctx.parse("<r>a&#13;b</r>").root.children.first
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Loud degradation (processing model, "Errors and loud degradation"):
6
+ # a capability an engine cannot deliver must answer the contract or
7
+ # raise the named error — never a divergent answer. Probed across all
8
+ # available adapters; the capability lists mirror the adapters'
9
+ # actual engine backing.
10
+ RSpec.describe "loud capability contracts" do
11
+ available = Moxml::Adapter::AVAILABLE_ADAPTERS
12
+ xslt_capable = %i[nokogiri leptris] & available
13
+ html_capable = %i[nokogiri leptris] & available
14
+
15
+ let(:sheet) do
16
+ %(<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">) \
17
+ "<xsl:template match='/'><o/></xsl:template></xsl:stylesheet>"
18
+ end
19
+
20
+ (available - xslt_capable).each do |name|
21
+ it "raises NotImplementedError for XSLT on #{name}" do
22
+ ctx = Moxml.new(name)
23
+ expect { ctx.xslt(sheet) }.to raise_error(Moxml::NotImplementedError)
24
+ end
25
+ end
26
+
27
+ (available - html_capable).each do |name|
28
+ it "raises NotImplementedError for to_xhtml on #{name}" do
29
+ doc = Moxml.new(name).parse("<r><a/></r>")
30
+ expect { doc.to_xhtml }.to raise_error(Moxml::NotImplementedError)
31
+ end
32
+
33
+ it "raises AdapterError for to_html on #{name}" do
34
+ doc = Moxml.new(name).parse("<r><a/></r>")
35
+ expect { doc.to_html }.to raise_error(Moxml::AdapterError)
36
+ end
37
+ end
38
+
39
+ xslt_capable.each do |name|
40
+ it "compiles a stylesheet on #{name}" do
41
+ expect(Moxml.new(name).xslt(sheet)).to be_a(Moxml::XSLT::Stylesheet)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Model coverage matrix (TODO.model/03): every face family of the
6
+ # processing model is governed for every adapter — covered by a pin
7
+ # (the consistency/contract spec that asserts equivalence), exempt via
8
+ # a registered divergence (adapter => D-ID), or loud (adapter =>
9
+ # the named error class). An unpinned family is an ungoverned cell:
10
+ # this audit fails until it has one of the three.
11
+ # See docs/_references/processing-model.adoc for the model itself.
12
+ RSpec.describe "model coverage matrix" do
13
+ MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
+ "document order and children listing" => {
15
+ pin: "spec/consistency/adapter_parity_spec.rb",
16
+ },
17
+ "name resolution" => {
18
+ pin: "spec/consistency/xpath_idioms_spec.rb",
19
+ },
20
+ "namespace resolution and bindings normalization" => {
21
+ pin: "spec/consistency/xpath_idioms_spec.rb",
22
+ },
23
+ "attribute access" => {
24
+ pin: "spec/moxml/attribute_resolver_spec.rb",
25
+ },
26
+ "text/content/raw_content" => {
27
+ pin: "spec/moxml/text_spec.rb",
28
+ },
29
+ "parsing modes and diagnostics" => {
30
+ pin: "spec/moxml/document_parse_diagnostics_spec.rb",
31
+ },
32
+ "EOL normalization" => {
33
+ pin: "spec/consistency/eol_normalization_spec.rb",
34
+ exempt: { oga: "D-02", ox: "D-03" },
35
+ },
36
+ "entity modes" => {
37
+ pin: "spec/moxml/entity_spec.rb",
38
+ },
39
+ "XPath evaluation and declared idioms" => {
40
+ pin: "spec/consistency/xpath_idioms_spec.rb",
41
+ exempt: { oga: "D-01" },
42
+ },
43
+ "serialization shapes (xml/html/xhtml)" => {
44
+ pin: "spec/moxml/xhtml_output_spec.rb",
45
+ },
46
+ "indentation knobs" => {
47
+ pin: "spec/moxml/to_html_spec.rb",
48
+ exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
49
+ libxml: "D-09", leptris: "D-09" },
50
+ },
51
+ "canonicalization" => {
52
+ pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
53
+ exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
54
+ },
55
+ "XSLT" => {
56
+ pin: "spec/consistency/xslt_spec.rb",
57
+ loud: { oga: "Moxml::NotImplementedError",
58
+ ox: "Moxml::NotImplementedError",
59
+ headed_ox: "Moxml::NotImplementedError",
60
+ rexml: "Moxml::NotImplementedError",
61
+ libxml: "Moxml::NotImplementedError" },
62
+ },
63
+ "SAX" => {
64
+ pin: "spec/moxml/sax_spec.rb",
65
+ },
66
+ "materialization" => {
67
+ pin: "spec/moxml/plan_conformance_spec.rb",
68
+ },
69
+ "loud capability contracts" => {
70
+ pin: "spec/consistency/loud_degradation_spec.rb",
71
+ },
72
+ }.freeze
73
+
74
+ def register_ids
75
+ File.readlines("docs/_pages/divergences.adoc")
76
+ .filter_map { |line| line[/\A\|\s*(D-\d+)\s*$/, 1] }
77
+ .uniq
78
+ end
79
+
80
+ it "governs every face family with a pin" do
81
+ unpinned = MODEL_MATRIX.reject { |_, cell| cell[:pin] }.keys
82
+ expect(unpinned).to be_empty,
83
+ "ungoverned face families (no pin): " \
84
+ "#{unpinned.join(', ')}"
85
+ end
86
+
87
+ it "points every pin at an existing spec" do
88
+ missing = MODEL_MATRIX.filter_map do |family, cell|
89
+ family unless cell[:pin] && File.exist?(cell[:pin])
90
+ end
91
+ expect(missing).to be_empty, "pins missing on disk: #{missing.join(', ')}"
92
+ end
93
+
94
+ it "exempts adapters only via registered divergences" do
95
+ unknown = MODEL_MATRIX.flat_map do |family, cell|
96
+ (cell[:exempt] || {}).filter_map do |adapter, id|
97
+ "#{family}: #{adapter} -> #{id}" unless register_ids.include?(id)
98
+ end
99
+ end
100
+ expect(unknown).to be_empty,
101
+ "exemptions cite unregistered IDs: #{unknown.join('; ')}"
102
+ end
103
+
104
+ it "declares loud cells only with named Moxml errors" do
105
+ bad = MODEL_MATRIX.flat_map do |family, cell|
106
+ (cell[:loud] || {}).filter_map do |adapter, error|
107
+ "#{family}: #{adapter} -> #{error}" unless error.start_with?("Moxml::")
108
+ end
109
+ end
110
+ expect(bad).to be_empty,
111
+ "loud cells must name Moxml errors: #{bad.join('; ')}"
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Checker integrity (TODO.model/06): top-level constant names in spec
6
+ # files all resolve through Object inside example blocks, so the same
7
+ # name defined by two files makes the last-loaded file win — the 0.5.143
8
+ # CORPUS collision made the battery fail ~50% of runs with a foreign
9
+ # corpus. This guard kills the class.
10
+ RSpec.describe "spec-file top-level constant uniqueness" do
11
+ it "defines each constant name in at most one spec/support file" do
12
+ seen = {}
13
+
14
+ Dir["spec/**/*.rb"].each do |path|
15
+ File.readlines(path).each_with_index do |line, i|
16
+ match = line.match(/\A\s*([A-Z][A-Z0-9_]*)\s*=[^=]/)
17
+ next unless match
18
+
19
+ name = match[1]
20
+ expect(seen)
21
+ .not_to have_key(name),
22
+ "#{name} defined at #{seen[name]} and #{path}:#{i + 1} " \
23
+ "— constants inside example blocks collide through " \
24
+ "Object; give one file a unique name"
25
+ seen[name] = "#{path}:#{i + 1}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -11,11 +11,22 @@ require "spec_helper"
11
11
  # - Attribute-terminal steps work in every path position
12
12
  # (leptris-ruby#458's table: //a/@id, //@id, r/a/@id,
13
13
  # //*[f()]/@id — fixed by the engine's VM path work).
14
+ CML_NS = "http://www.xml-cml.org/schema"
15
+ ACI_NS = "http://www.xml-cml.org/schema/asciichem"
16
+
14
17
  RSpec.describe "XPath idioms across adapters" do
15
18
  let(:default_ns_xml) do
16
19
  '<svgmap xmlns="http://www.example.org/doc"><target href="a"/></svgmap>'
17
20
  end
18
21
  let(:plain_xml) { '<r><a id="x"><b/></a></r>' }
22
+ let(:cml_xml) do
23
+ <<~XML
24
+ <molecule xmlns="#{CML_NS}" xmlns:aci="#{ACI_NS}" id="m1">
25
+ <atomArray><atom id="a1"/></atomArray>
26
+ <aci:group multiplicity="2" bracket="paren" atomRefs="a1"/>
27
+ </molecule>
28
+ XML
29
+ end
19
30
 
20
31
  Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
21
32
  next if adapter_name == :headed_ox # same engine as :ox
@@ -31,7 +42,7 @@ RSpec.describe "XPath idioms across adapters" do
31
42
  if adapter_name == :oga
32
43
  # oga's own Ruby XPath engine has no xmlns: default-ns
33
44
  # support (third-party; explicit bindings work).
34
- skip "oga's XPath engine does not implement the xmlns: idiom"
45
+ skip "D-01: oga's XPath engine does not implement the xmlns: idiom"
35
46
  end
36
47
  end
37
48
 
@@ -56,6 +67,26 @@ RSpec.describe "XPath idioms across adapters" do
56
67
  expect(doc.xpath("//*[local-name()='a']/@id").first&.value).to eq("x")
57
68
  end
58
69
 
70
+ # Issue #395: bindings hashes arrive in both key spellings
71
+ # (symbols are the common Nokogiri idiom); the engines must
72
+ # resolve both — a symbol-key miss falls through to
73
+ # prefix-spelling matching and namespaced elements never match.
74
+ # asciichem's CML group extraction rides exactly this shape.
75
+ it "resolves symbol-keyed namespace bindings" do
76
+ doc = ctx.parse(cml_xml)
77
+ mols = doc.xpath("//cml:molecule", cml: CML_NS)
78
+ expect(mols.to_a.size).to eq(1)
79
+
80
+ groups = mols.first.xpath("./aci:group", aci: ACI_NS)
81
+ expect(groups.to_a.size).to eq(1)
82
+ expect(groups.first["multiplicity"]).to eq("2")
83
+ end
84
+
85
+ it "resolves string-keyed namespace bindings identically" do
86
+ doc = ctx.parse(cml_xml)
87
+ expect(doc.xpath("//cml:molecule", "cml" => CML_NS).to_a.size).to eq(1)
88
+ end
89
+
59
90
  # moxml#393: a relative name-test path on the DOCUMENT resolves
60
91
  # laxly, descendant-anchored at the root — Nokogiri's own
61
92
  # Document#at/#search semantics, now pinned for every adapter.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # leptris-ruby#460 — the Identity contract reads ride the C faces
6
+ # by plain dispatch (NativeNode subclasses inherit the C-defined
7
+ # name/content since binding 1.9.359). The wrapper memo stays; the
8
+ # ADAPTER CROSSING (bind_call through node_name/text_content) goes.
9
+ RSpec.describe "leptris Identity contract reads" do
10
+ let(:ctx) { Moxml.new(:leptris) }
11
+
12
+ before do
13
+ unless Moxml::Adapter::Leptris::NATIVE_READ_LAYER &&
14
+ Leptris::XML::NativeNode.method_defined?(:name) &&
15
+ Leptris::XML::NativeNode.method_defined?(:content)
16
+ skip "binding #{defined?(Leptris) && Leptris::VERSION} lacks the C contract faces"
17
+ end
18
+ end
19
+
20
+ def bust(node, *ivars)
21
+ ivars.each do |iv|
22
+ node.remove_instance_variable(iv) if node.instance_variable_defined?(iv)
23
+ end
24
+ end
25
+
26
+ def forbid_crossing!(method)
27
+ singleton = Moxml::Adapter::Leptris.singleton_class
28
+ singleton.send(:alias_method, :"#{method}_without_460", method)
29
+ singleton.send(:define_method, method) { |*| raise "adapter #{method} crossing" }
30
+ method
31
+ end
32
+
33
+ def restore_crossing!(method)
34
+ singleton = Moxml::Adapter::Leptris.singleton_class
35
+ singleton.send(:alias_method, method, :"#{method}_without_460")
36
+ singleton.send(:remove_method, :"#{method}_without_460")
37
+ end
38
+
39
+ describe "element#name" do
40
+ it "answers without the adapter node_name crossing" do
41
+ unless Leptris::XML::NativeNode.method_defined?(:name_read)
42
+ skip "binding #{Leptris::VERSION} has no name_read face (adapter path is correct there)"
43
+ end
44
+ doc = ctx.parse('<root><item id="1">v</item></root>')
45
+ el = doc.root.children.first
46
+ expect(el.native).to be_a(Leptris::XML::NativeNode)
47
+
48
+ bust(el, :@name)
49
+ forbid_crossing!(:node_name)
50
+ begin
51
+ expect(el.name).to eq("item")
52
+ ensure
53
+ restore_crossing!(:node_name)
54
+ end
55
+ end
56
+
57
+ it "answers the local name for prefixed elements (cross-adapter contract)" do
58
+ doc = ctx.parse('<root xmlns:ns="urn:u"><ns:p/></root>')
59
+ el = doc.root.children.first
60
+ bust(el, :@name)
61
+ expect(el.name).to eq("p")
62
+ end
63
+
64
+ it "memoizes: warm reads return the same String" do
65
+ doc = ctx.parse("<root><item/></root>")
66
+ el = doc.root.children.first
67
+ first = el.name
68
+ expect(el.name).to equal(first)
69
+ end
70
+
71
+ it "re-reads after name=" do
72
+ doc = ctx.parse("<root><item/></root>")
73
+ el = doc.root.children.first
74
+ el.name = "renamed"
75
+ expect(el.name).to eq("renamed")
76
+ end
77
+ end
78
+
79
+ describe "text#content" do
80
+ it "answers without the adapter text_content crossing" do
81
+ unless Leptris::XML::NativeNode.method_defined?(:text_read)
82
+ skip "binding #{Leptris::VERSION} has no text_read face (adapter path is correct there)"
83
+ end
84
+ doc = ctx.parse("<root><item>v</item></root>")
85
+ tx = doc.root.children.first.children.first
86
+ expect(tx.native).to be_a(Leptris::XML::NativeNode)
87
+
88
+ bust(tx, :@content)
89
+ forbid_crossing!(:text_content)
90
+ begin
91
+ expect(tx.content).to eq("v")
92
+ ensure
93
+ restore_crossing!(:text_content)
94
+ end
95
+ end
96
+
97
+ it "memoizes: warm reads return the same String" do
98
+ doc = ctx.parse("<root><item>v</item></root>")
99
+ tx = doc.root.children.first.children.first
100
+ first = tx.content
101
+ expect(tx.content).to equal(first)
102
+ end
103
+
104
+ it "re-reads after content=" do
105
+ doc = ctx.parse("<root><item>v</item></root>")
106
+ tx = doc.root.children.first.children.first
107
+ tx.content = "changed"
108
+ expect(tx.content).to eq("changed")
109
+ end
110
+
111
+ it "restores entities on entity-bearing documents" do
112
+ ctx2 = Moxml.new(:leptris) { |c| c.restore_entities = true }
113
+ doc = ctx2.parse("<root><item>a &amp; b</item></root>")
114
+ tx = doc.root.children.first.children.first
115
+ bust(tx, :@content)
116
+ expect(tx.content).to eq("a & b")
117
+ end
118
+ end
119
+
120
+ describe "the untouched faces" do
121
+ it "keeps text wrappers named text" do
122
+ doc = ctx.parse("<root><item>v</item></root>")
123
+ tx = doc.root.children.first.children.first
124
+ expect(tx.name).to eq("text")
125
+ end
126
+
127
+ it "keeps element#text on the existing path" do
128
+ doc = ctx.parse("<root><item>v</item></root>")
129
+ el = doc.root.children.first
130
+ expect(el.text).to eq("v")
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #401: Text#content= on a detached (parentless) node answered
6
+ # the engine's bare "Invalid argument" with no indication of the cause.
7
+ # The leptris adapter now re-raises it as a named Moxml error carrying
8
+ # the detachment. Created-unattached nodes work (probe), so only the
9
+ # failing detachment is re-raised.
10
+ RSpec.describe "detached text content= names the cause" do
11
+ let(:ctx) { Moxml.new(:leptris) }
12
+
13
+ it "raises a named Moxml error for a removed text node" do
14
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
15
+ text = doc.root.children[0].children[0]
16
+ text.remove
17
+
18
+ expect { text.content = "z" }
19
+ .to raise_error(Moxml::Error, /detached \(parentless\).*issue #401/)
20
+ end
21
+
22
+ it "keeps created-unattached nodes mutable" do
23
+ text = ctx.create_document.create_text("x")
24
+ text.content = "y"
25
+ expect(text.content).to eq("y")
26
+ end
27
+
28
+ it "keeps attached nodes mutable" do
29
+ doc = ctx.parse("<r><a>x</a></r>")
30
+ text = doc.root.children[0].children[0]
31
+ text.content = "z"
32
+ expect(text.content).to eq("z")
33
+ end
34
+ end