|
| 1 | +# A part of NonVisual Desktop Access (NVDA) |
| 2 | +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter |
| 3 | +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. |
| 4 | +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt |
| 5 | + |
| 6 | +"""Unit tests for ICU sentence segmentation. |
| 7 | +
|
| 8 | +Covers the low-level ``textUtils.icu.calculateSentenceOffsets`` primitive and the |
| 9 | +``OffsetsTextInfo._getSentenceOffsets`` integration, including the iteration/tiling |
| 10 | +invariant that ``move``/``expand`` rely on. Tests that require ICU are skipped when |
| 11 | +the ICU library is not present on the system. |
| 12 | +""" |
| 13 | + |
| 14 | +import unittest |
| 15 | +from unittest.mock import patch |
| 16 | + |
| 17 | +import textInfos |
| 18 | +from textInfos import offsets as offsetsModule |
| 19 | +from textInfos.offsets import Offsets |
| 20 | +from textUtils import icu |
| 21 | + |
| 22 | +from ..textProvider import BasicTextInfo, BasicTextProvider |
| 23 | +from . import skipIfNoICU |
| 24 | + |
| 25 | + |
| 26 | +class _BlockParagraphTextInfo(BasicTextInfo): |
| 27 | + """A TextInfo whose paragraphs are blocks separated by a blank line ("\\n\\n"). |
| 28 | +
|
| 29 | + Mimics the block-level bounds VirtualBufferTextInfo._getParagraphOffsets returns, |
| 30 | + rather than the line splitting BasicTextInfo does. Test text is ASCII, so str |
| 31 | + offsets equal UTF-16 offsets. |
| 32 | + """ |
| 33 | + |
| 34 | + def _getParagraphOffsets(self, offset): |
| 35 | + text = self._getStoryText() |
| 36 | + start = text.rfind("\n\n", 0, offset) |
| 37 | + start = 0 if start < 0 else start + 2 |
| 38 | + end = text.find("\n\n", offset) |
| 39 | + end = len(text) if end < 0 else end + 2 |
| 40 | + return (start, end) |
| 41 | + |
| 42 | + |
| 43 | +class _BlockParagraphProvider(BasicTextProvider): |
| 44 | + TextInfo = _BlockParagraphTextInfo |
| 45 | + |
| 46 | + |
| 47 | +@skipIfNoICU |
| 48 | +class TestCalculateSentenceOffsets(unittest.TestCase): |
| 49 | + """Low-level UAX#29 sentence boundary tests (UTF-16 code-unit offsets, root locale).""" |
| 50 | + |
| 51 | + def test_english_two_sentences(self): |
| 52 | + """Trailing space after the terminator is attached to the preceding sentence (UAX#29).""" |
| 53 | + text = "Hello world. Goodbye now." |
| 54 | + self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 13)) |
| 55 | + # Mid-sentence offsets resolve to the same sentence. |
| 56 | + self.assertEqual(icu.calculateSentenceOffsets(text, 5), (0, 13)) |
| 57 | + self.assertEqual(icu.calculateSentenceOffsets(text, 12), (0, 13)) |
| 58 | + self.assertEqual(icu.calculateSentenceOffsets(text, 13), (13, 25)) |
| 59 | + |
| 60 | + def test_japanese_ideographic_full_stop(self): |
| 61 | + """U+3002 (。) is Sentence_Break=STerm, so Japanese segments without a locale.""" |
| 62 | + text = "これは日本語です。次の文です。" |
| 63 | + self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 9)) |
| 64 | + self.assertEqual(icu.calculateSentenceOffsets(text, 9), (9, 15)) |
| 65 | + |
| 66 | + def test_abbreviation_splits_under_root_locale(self): |
| 67 | + """Under the root locale, which has no abbreviation tailoring, "Dr." ends a sentence.""" |
| 68 | + text = "Dr. Smith went home." |
| 69 | + self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 4)) |
| 70 | + self.assertEqual(icu.calculateSentenceOffsets(text, 4), (4, 20)) |
| 71 | + |
| 72 | + def test_surrogate_pair_offsets(self): |
| 73 | + """Offsets are UTF-16 code-unit indexed; a surrogate pair counts as two units.""" |
| 74 | + # H i sp [🤦 = 2 units] sp t h e r e . sp B y e sp n o w . |
| 75 | + text = "Hi \U0001f926 there. Bye now." |
| 76 | + # First sentence spans the surrogate pair and ends after the space at UTF-16 offset 13. |
| 77 | + self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 13)) |
| 78 | + # An offset inside the surrogate pair still resolves to the containing sentence. |
| 79 | + self.assertEqual(icu.calculateSentenceOffsets(text, 4), (0, 13)) |
| 80 | + self.assertEqual(icu.calculateSentenceOffsets(text, 13), (13, 21)) |
| 81 | + |
| 82 | + def test_offset_past_end_fast_path(self): |
| 83 | + """An offset at/past the end returns a single-unit span (matches word behaviour).""" |
| 84 | + self.assertEqual(icu.calculateSentenceOffsets("abc", 5), (5, 6)) |
| 85 | + |
| 86 | + def test_offset_containment(self): |
| 87 | + """For every in-range offset, start <= offset < end (the tiling precondition).""" |
| 88 | + text = "One. Two! Three? Four." |
| 89 | + length = len(text.encode("utf-16-le")) // 2 |
| 90 | + for offset in range(length): |
| 91 | + start, end = icu.calculateSentenceOffsets(text, offset) |
| 92 | + self.assertTrue( |
| 93 | + start <= offset < end, |
| 94 | + f"offset {offset} not contained in ({start}, {end}) for {text!r}", |
| 95 | + ) |
| 96 | + |
| 97 | + |
| 98 | +@skipIfNoICU |
| 99 | +class TestSentenceIterationTiling(unittest.TestCase): |
| 100 | + """The iteration invariant: move(UNIT_SENTENCE) walks sentences gap-free without stalling.""" |
| 101 | + |
| 102 | + def _collectSentences(self, obj, length: int, direction: int) -> list[tuple[int, int]]: |
| 103 | + """Enumerate sentence spans by expanding then moving in ``direction`` until the walk stops. |
| 104 | +
|
| 105 | + :param obj: A text provider to navigate. |
| 106 | + :param length: UTF-16 length of the story text (bounds the loop and seeds the reverse walk). |
| 107 | + :param direction: 1 to walk forward from the start, -1 to walk backward from the end. |
| 108 | + :return: Sentence spans in document order (the backward walk is reversed before returning). |
| 109 | + """ |
| 110 | + startPos = 0 if direction > 0 else max(0, length - 1) |
| 111 | + info = obj.makeTextInfo(Offsets(startPos, startPos)) |
| 112 | + info.expand(textInfos.UNIT_SENTENCE) |
| 113 | + spans = [] |
| 114 | + # The loop is bounded so that a stalled walk fails rather than hangs. |
| 115 | + for _ in range(length + 2): |
| 116 | + spans.append((info._startOffset, info._endOffset)) |
| 117 | + if info.move(textInfos.UNIT_SENTENCE, direction) == 0: |
| 118 | + break |
| 119 | + info.expand(textInfos.UNIT_SENTENCE) |
| 120 | + if direction < 0: |
| 121 | + spans.reverse() |
| 122 | + return spans |
| 123 | + |
| 124 | + def _assertTiles(self, spans: list[tuple[int, int]], length: int): |
| 125 | + """Assert the spans tile [0, length) gap-free, in order, with no overlaps.""" |
| 126 | + self.assertEqual(spans[0][0], 0, f"first sentence does not start at 0: {spans}") |
| 127 | + self.assertEqual(spans[-1][1], length, f"last sentence does not reach {length}: {spans}") |
| 128 | + for (_, prevEnd), (nextStart, _) in zip(spans, spans[1:]): |
| 129 | + self.assertEqual(prevEnd, nextStart, f"gap/overlap between sentences: {spans}") |
| 130 | + |
| 131 | + def test_single_paragraph_tiles_both_directions(self): |
| 132 | + text = "Hello world. Goodbye now. The third one." |
| 133 | + length = len(text) # all-ASCII, so str length == UTF-16 length |
| 134 | + obj = BasicTextProvider(text=text) |
| 135 | + forward = self._collectSentences(obj, length, 1) |
| 136 | + self._assertTiles(forward, length) |
| 137 | + # Three terminators => three sentences. |
| 138 | + self.assertEqual(len(forward), 3) |
| 139 | + # Backward navigation (alt+upArrow) visits the same sentences in the same order. |
| 140 | + backward = self._collectSentences(obj, length, -1) |
| 141 | + self.assertEqual(backward, forward) |
| 142 | + |
| 143 | + def test_crosses_block_paragraph_boundary_both_directions(self): |
| 144 | + """With block-paragraph semantics, the walk crosses the blank-line boundary.""" |
| 145 | + text = "One. Two.\n\nThree. Four." |
| 146 | + length = len(text) |
| 147 | + blockStart = text.index("\n\n") + 2 # start of the second block paragraph |
| 148 | + obj = _BlockParagraphProvider(text=text) |
| 149 | + forward = self._collectSentences(obj, length, 1) |
| 150 | + self._assertTiles(forward, length) |
| 151 | + # A sentence boundary lands exactly on the second paragraph's start (the walk crossed). |
| 152 | + self.assertIn(blockStart, [start for start, _ in forward]) |
| 153 | + backward = self._collectSentences(obj, length, -1) |
| 154 | + self.assertEqual(backward, forward) |
| 155 | + self.assertIn(blockStart, [start for start, _ in backward]) |
| 156 | + |
| 157 | + |
| 158 | +class TestSentenceOffsetsWithoutIcu(unittest.TestCase): |
| 159 | + """When ICU is unavailable, _getSentenceOffsets degrades to NotImplementedError.""" |
| 160 | + |
| 161 | + def test_not_implemented_when_icu_unavailable(self): |
| 162 | + obj = BasicTextProvider(text="Hello world. Goodbye now.") |
| 163 | + info = obj.makeTextInfo(Offsets(0, 0)) |
| 164 | + with patch.object(offsetsModule, "ICU_AVAILABLE", False): |
| 165 | + with self.assertRaises(NotImplementedError): |
| 166 | + info._getSentenceOffsets(0) |
0 commit comments