Dedupe & refactor range comparison code
diff --git a/packages/dom/test/text-position/match.test.ts b/packages/dom/test/text-position/match.test.ts
index f59f490..cbfbfc7 100644
--- a/packages/dom/test/text-position/match.test.ts
+++ b/packages/dom/test/text-position/match.test.ts
@@ -21,7 +21,7 @@
 import { assert } from 'chai';
 import type { TextPositionSelector } from '@apache-annotator/selector';
 import { createTextPositionSelectorMatcher } from '../../src/text-position/match';
-import { evaluateXPath } from '../utils';
+import { evaluateXPath, assertRangeEquals } from '../utils';
 import type { RangeInfo } from '../utils';
 import { testCases } from './match-cases';
 
@@ -33,7 +33,7 @@
   )) {
     it(`works for case: '${name}'`, async () => {
       const doc = domParser.parseFromString(html, 'text/html');
-      await testMatcher(doc, doc, selector, expected);
+      await testMatcher(doc, selector, expected);
     });
   }
 
@@ -46,7 +46,7 @@
     // console.log([...textNode.parentNode.childNodes].map(node => node.textContent))
     // → [ 'l😃rem ipsum dol', 'or amet yada yada' ]
 
-    await testMatcher(doc, doc, selector, [
+    await testMatcher(doc, selector, [
       {
         startContainerXPath: '//b/text()[1]',
         startOffset: 13,
@@ -72,7 +72,7 @@
     // console.log([...textNode.parentNode.childNodes].map(node => node.textContent))
     // → [ '', 'l😃rem ipsum ', '', 'dolor', '', ' am', '', 'et yada yada', '' ]
 
-    await testMatcher(doc, doc, selector, [
+    await testMatcher(doc, selector, [
       {
         startContainerXPath: '//b/text()[4]', // "dolor"
         startOffset: 0,
@@ -89,7 +89,7 @@
     const scope = doc.createRange();
     scope.selectNodeContents(evaluateXPath(doc, '//b/text()'));
 
-    await testMatcher(doc, scope, selector, expected);
+    await testMatcher(scope, selector, expected);
   });
 
   it('works when scope starts with an empty text node, matching its first characters', async () => {
@@ -102,7 +102,7 @@
     const scope = doc.createRange();
     scope.selectNodeContents(evaluateXPath(doc, '//b'));
 
-    await testMatcher(doc, scope, selector, [
+    await testMatcher(scope, selector, [
       {
         startContainerXPath: '//b/text()[2]',
         startOffset: 0,
@@ -128,7 +128,7 @@
       end: 14,
     };
 
-    await testMatcher(doc, scope, selector, expected);
+    await testMatcher(scope, selector, expected);
   });
 
   it('works when scope has both ends inside text nodes', async () => {
@@ -146,7 +146,7 @@
       end: 12,
     };
 
-    await testMatcher(doc, scope, selector, expected);
+    await testMatcher(scope, selector, expected);
   });
 
   it('works when scope has both ends inside an element', async () => {
@@ -161,55 +161,19 @@
       start: 6,
       end: 14,
     };
-    await testMatcher(doc, scope, selector, expected);
+    await testMatcher(scope, selector, expected);
   });
 });
 
 async function testMatcher(
-  doc: Document,
   scope: Node | Range,
   selector: TextPositionSelector,
   expected: RangeInfo[],
 ) {
   const matcher = createTextPositionSelectorMatcher(selector);
-  const matches = [];
-  for await (const value of matcher(scope)) matches.push(value);
-  assert.equal(matches.length, expected.length);
-  matches.forEach((match, i) => {
-    const expectedRange = expected[i];
-    const expectedStartContainer = evaluateXPath(
-      doc,
-      expectedRange.startContainerXPath,
-    );
-    const expectedEndContainer = evaluateXPath(
-      doc,
-      expectedRange.endContainerXPath,
-    );
-    assert(
-      match.startContainer === expectedStartContainer,
-      `unexpected start container: ${prettyNodeName(match.startContainer)}; ` +
-        `expected ${prettyNodeName(expectedStartContainer)}`,
-    );
-    assert.equal(match.startOffset, expectedRange.startOffset);
-    assert(
-      match.endContainer ===
-        evaluateXPath(doc, expectedRange.endContainerXPath),
-      `unexpected end container: ${prettyNodeName(match.endContainer)}; ` +
-        `expected ${prettyNodeName(expectedEndContainer)}`,
-    );
-    assert.equal(match.endOffset, expectedRange.endOffset);
-  });
-}
-
-function prettyNodeName(node: Node) {
-  switch (node.nodeType) {
-    case Node.TEXT_NODE: {
-      const text = (node as Text).nodeValue || '';
-      return `#text "${text.length > 50 ? text.substring(0, 50) + '…' : text}"`;
-    }
-    case Node.ELEMENT_NODE:
-      return `<${(node as Element).tagName.toLowerCase()}>`;
-    default:
-      return node.nodeName.toLowerCase();
+  let count = 0;
+  for await (const match of matcher(scope)) {
+    assertRangeEquals(match, expected[count++]);
   }
+  assert.equal(count, expected.length, 'Wrong number of matches.');
 }
diff --git a/packages/dom/test/text-quote/match.test.ts b/packages/dom/test/text-quote/match.test.ts
index bd54963..c9429a4 100644
--- a/packages/dom/test/text-quote/match.test.ts
+++ b/packages/dom/test/text-quote/match.test.ts
@@ -21,7 +21,7 @@
 import { assert } from 'chai';
 import type { TextQuoteSelector } from '@apache-annotator/selector';
 import { createTextQuoteSelectorMatcher } from '../../src/text-quote/match';
-import { evaluateXPath } from '../utils';
+import { evaluateXPath, assertRangeEquals } from '../utils';
 import type { RangeInfo } from '../utils';
 import { testCases } from './match-cases';
 
@@ -194,7 +194,7 @@
   const matcher = createTextQuoteSelectorMatcher(selector);
   let count = 0;
   for await (const match of matcher(scope)) {
-    assertMatchIsCorrect(doc, match, expected[count++]);
+    assertRangeEquals(match, expected[count++]);
     if (mutateDom) {
       const wrapperNode = doc.createElement('mark');
       match.surroundContents(wrapperNode);
@@ -202,70 +202,3 @@
   }
   assert.equal(count, expected.length, 'Wrong number of matches.');
 }
-
-function assertMatchIsCorrect(
-  doc: Document,
-  match: Range,
-  expected: RangeInfo,
-) {
-  if (expected === undefined) {
-    assert.fail(`Unexpected match: ${prettyRange(match)}`);
-  }
-  const expectedStartContainer = evaluateXPath(
-    doc,
-    expected.startContainerXPath,
-  );
-  const expectedEndContainer = evaluateXPath(
-    doc,
-    expected.endContainerXPath,
-  );
-  assert(
-    match.startContainer === expectedStartContainer,
-    `unexpected start container: ${prettyNodeName(match.startContainer)}; ` +
-      `expected ${prettyNodeName(expectedStartContainer)}`,
-  );
-  assert.equal(match.startOffset, expected.startOffset);
-  assert(
-    match.endContainer ===
-      evaluateXPath(doc, expected.endContainerXPath),
-    `unexpected end container: ${prettyNodeName(match.endContainer)}; ` +
-      `expected ${prettyNodeName(expectedEndContainer)}`,
-  );
-  assert.equal(match.endOffset, expected.endOffset);
-}
-
-function prettyNodeName(node: Node) {
-  switch (node.nodeType) {
-    case Node.TEXT_NODE: {
-      const text = (node as Text).nodeValue || '';
-      return `#text "${text.length > 50 ? text.substring(0, 50) + '…' : text}"`;
-    }
-    case Node.ELEMENT_NODE:
-      return `<${(node as Element).tagName.toLowerCase()}>`;
-    default:
-      return node.nodeName.toLowerCase();
-  }
-}
-
-function prettyRange(range: Range): string {
-  let s = 'Range('
-  if (
-    range.startContainer.nodeType === Node.TEXT_NODE
-    && range.startContainer.parentNode
-  ) s += prettyNodeName(range.startContainer.parentNode) + ' → ';
-  s += prettyNodeName(range.startContainer) + ' : ' + range.startOffset;
-  if (range.endContainer !== range.startContainer) {
-    s += ' … '
-    if (
-      range.endContainer.nodeType === Node.TEXT_NODE
-      && range.endContainer.parentNode
-      && range.endContainer.parentNode !== range.startContainer.parentNode
-    ) s += prettyNodeName(range.endContainer.parentNode) + ' → ';
-    s += prettyNodeName(range.endContainer) + ' : ';
-  } else {
-    s += '…';
-  }
-  s += range.endOffset;
-  s += ')';
-  return s;
-}
diff --git a/packages/dom/test/utils.ts b/packages/dom/test/utils.ts
index 9a484a9..5a3f183 100644
--- a/packages/dom/test/utils.ts
+++ b/packages/dom/test/utils.ts
@@ -19,6 +19,7 @@
  */
 
 import { assert } from 'chai';
+import { ownerDocument } from '../src/owner-document';
 
 // RangeInfo serialises a Range’s start and end containers as XPaths.
 export type RangeInfo = {
@@ -58,3 +59,70 @@
   );
   return range;
 }
+
+export function assertRangeEquals(
+  match: Range,
+  expected: RangeInfo,
+) {
+  const doc = ownerDocument(match);
+  if (expected === undefined) {
+    assert.fail(`Unexpected match: ${prettyRange(match)}`);
+  }
+  const expectedStartContainer = evaluateXPath(
+    doc,
+    expected.startContainerXPath,
+  );
+  const expectedEndContainer = evaluateXPath(
+    doc,
+    expected.endContainerXPath,
+  );
+  assert(
+    match.startContainer === expectedStartContainer,
+    `unexpected start container: ${prettyNodeName(match.startContainer)}; ` +
+      `expected ${prettyNodeName(expectedStartContainer)}`,
+  );
+  assert.equal(match.startOffset, expected.startOffset);
+  assert(
+    match.endContainer ===
+      evaluateXPath(doc, expected.endContainerXPath),
+    `unexpected end container: ${prettyNodeName(match.endContainer)}; ` +
+      `expected ${prettyNodeName(expectedEndContainer)}`,
+  );
+  assert.equal(match.endOffset, expected.endOffset);
+}
+
+function prettyNodeName(node: Node) {
+  switch (node.nodeType) {
+    case Node.TEXT_NODE: {
+      const text = (node as Text).nodeValue || '';
+      return `#text "${text.length > 50 ? text.substring(0, 50) + '…' : text}"`;
+    }
+    case Node.ELEMENT_NODE:
+      return `<${(node as Element).tagName.toLowerCase()}>`;
+    default:
+      return node.nodeName.toLowerCase();
+  }
+}
+
+function prettyRange(range: Range): string {
+  let s = 'Range('
+  if (
+    range.startContainer.nodeType === Node.TEXT_NODE
+    && range.startContainer.parentNode
+  ) s += prettyNodeName(range.startContainer.parentNode) + ' → ';
+  s += prettyNodeName(range.startContainer) + ' : ' + range.startOffset;
+  if (range.endContainer !== range.startContainer) {
+    s += ' … '
+    if (
+      range.endContainer.nodeType === Node.TEXT_NODE
+      && range.endContainer.parentNode
+      && range.endContainer.parentNode !== range.startContainer.parentNode
+    ) s += prettyNodeName(range.endContainer.parentNode) + ' → ';
+    s += prettyNodeName(range.endContainer) + ' : ';
+  } else {
+    s += '…';
+  }
+  s += range.endOffset;
+  s += ')';
+  return s;
+}