Repository: nvaccess/nvda

Issue:
nvda does not announce the accentuation of the letter if the check box is Say capitalization before capitalization

### Steps to reproduce:
Open a text editor
In the nvda speech settings select the checkbox
Say capitalization before capitalization
return to the text editor
turn on capslook
type letters with accents
examples:
Á
À
Ã
É
Ê
Í
Ó
Ô
Ú
read the accented letters typed with the left and right arrows

### Actual behavior:
nvda informs
the character name is spoken
character accent is not spoken
is spoken capitalized

### Expected behavior:
nvda informs:
speak the name of the character
speaks the accentuation of the character
speaks  capital letter
### System configuration
#### NVDA installed/portable/running from source:
instaled
#### NVDA version:
nvda.exe, NVDA alpha-21524,7b92e442

#### Windows version:
windows 10 19042.662
#### Name and version of other software in use when reproducing the issue:
any text editor
#### Other information about your system:
if the checkbox
Say capitalization before capitalization
is unchecked and the user chooses another way to receive the capitalization status of the letters, everything works fine.
In this case I want the nvda to announce the capitalization of the letters, and the accentuation of the letters as well.
tested with several synthesizers and the problem remains

### Other questions
#### Does the issue still occur after restarting your computer?
yes
#### Have you tried any other versions of NVDA? If so, please report their behaviors.
yes
#### If addons are disabled, is your problem still occuring?
yes
#### Did you try to run the COM registry fixing tool in NVDA menu / tools?
no


Discussion:

Resolving patch:
diff --git a/source/speech/__init__.py b/source/speech/__init__.py
--- a/source/speech/__init__.py
+++ b/source/speech/__init__.py
@@ -211,14 +211,70 @@ def speakSpelling(
 	speak(seq, priority=priority)
 
 
-# C901 'getSpellingSpeech' is too complex
-# Note: when working on getSpellingSpeech, look for opportunities to simplify
-# and move logic out into smaller helper functions.
-def getSpellingSpeech(  # noqa: C901
+def _getSpellingSpeechAddCharMode(
+		seq: Generator[SequenceItemT, None, None],
+) -> Generator[SequenceItemT, None, None]:
+	"""Inserts CharacterMode commands in a speech sequence generator to ensure any single character
+	is spelt by the synthesizer.
+	@param seq: The speech sequence to be spelt.
+	"""
+	charMode = False
+	for item in seq:
+		if isinstance(item, str):
+			if len(item) == 1:
+				if not charMode:
+					yield CharacterModeCommand(True)
+					charMode = True
+			elif charMode:
+				yield CharacterModeCommand(False)
+				charMode = False
+		yield item
+
+
+def _getSpellingCharAddCapNotification(
+		speakCharAs: str,
+		sayCapForCapitals: bool,
+		capPitchChange: int,
+		beepForCapitals: bool,
+) -> Generator[SequenceItemT, None, None]:
+	"""This function produces a speech sequence containing a character to be spelt as well as commands
+	to indicate that this character is uppercase if applicable.
+	@param speakCharAs: The character as it will be spoken by the synthesizer.
+	@param sayCapForCapitals: indicates if 'cap' should be reported along with the currently spelt character.
+	@param capPitchChange: pitch offset to apply while spelling the currently spelt character.
+	@param beepForCapitals: indicates if a cap notification beep should be produced while spelling the currently
+	spellt character.
+	"""
+	if sayCapForCapitals:
+		# Translators: cap will be spoken before the given letter when it is capitalized.
+		capMsg = _("cap %s")
+		(capMsgBefore, capMsgAfter) = capMsg.split('%s')
+	else:
+		capMsgBefore = ''
+		capMsgAfter = ''
+	
+	if capPitchChange:
+		yield PitchCommand(offset=capPitchChange)
+	if beepForCapitals:
+		yield BeepCommand(2000, 50)
+	if capMsgBefore:
+		yield capMsgBefore
+	yield speakCharAs
+	if capMsgAfter:
+		yield capMsgAfter
+	if capPitchChange:
+		yield PitchCommand()
+
+
+def _getSpellingSpeechWithoutCharMode(
 		text: str,
-		locale: Optional[str] = None,
-		useCharacterDescriptions: bool = False
+		locale: str,
+		useCharacterDescriptions: bool,
+		sayCapForCapitals: bool,
+		capPitchChange: int,
+		beepForCapitals: bool,
 ) -> Generator[SequenceItemT, None, None]:
+	
 	defaultLanguage=getCurrentLanguage()
 	if not locale or (not config.conf['speech']['autoDialectSwitching'] and locale.split('_')[0]==defaultLanguage.split('_')[0]):
 		locale=defaultLanguage
@@ -230,9 +286,6 @@ def getSpellingSpeech(  # noqa: C901
 	if not text.isspace():
 		text=text.rstrip()
 
-	synth = getSynth()
-	synthConfig=config.conf["speech"][synth.name]
-	charMode = False
 	textLength=len(text)
 	count = 0
 	localeHasConjuncts = True if locale.split('_',1)[0] in LANGS_WITH_CONJUNCT_CHARS else False
@@ -253,27 +306,43 @@ def getSpellingSpeech(  # noqa: C901
 			speakCharAs=charDesc[0] if textLength>1 else IDEOGRAPHIC_COMMA.join(charDesc)
 		else:
 			speakCharAs=characterProcessing.processSpeechSymbol(locale,speakCharAs)
-		if uppercase and synthConfig["sayCapForCapitals"]:
-			# Translators: cap will be spoken before the given letter when it is capitalized.
-			speakCharAs=_("cap %s")%speakCharAs
-		if uppercase and synth.isSupported("pitch") and synthConfig["capPitchChange"]:
-			yield PitchCommand(offset=synthConfig["capPitchChange"])
 		if config.conf['speech']['autoLanguageSwitching']:
 			yield LangChangeCommand(locale)
-		if len(speakCharAs) == 1 and synthConfig["useSpellingFunctionality"]:
-			if not charMode:
-				yield CharacterModeCommand(True)
-				charMode = True
-		elif charMode:
-			yield CharacterModeCommand(False)
-			charMode = False
-		if uppercase and  synthConfig["beepForCapitals"]:
-			yield BeepCommand(2000, 50)
-		yield speakCharAs
-		if uppercase and synth.isSupported("pitch") and synthConfig["capPitchChange"]:
-			yield PitchCommand()
+		yield from _getSpellingCharAddCapNotification(
+			speakCharAs,
+			uppercase and sayCapForCapitals,
+			capPitchChange if uppercase else 0,
+			uppercase and beepForCapitals,
+		)
 		yield EndUtteranceCommand()
 
+
+def getSpellingSpeech(
+		text: str,
+		locale: Optional[str] = None,
+		useCharacterDescriptions: bool = False
+) -> Generator[SequenceItemT, None, None]:
+	
+	synth = getSynth()
+	synthConfig = config.conf["speech"][synth.name]
+	
+	if synth.isSupported("pitch"):
+		capPitchChange = synthConfig["capPitchChange"]
+	else:
+		capPitchChange = 0
+	seq = _getSpellingSpeechWithoutCharMode(
+		text,
+		locale,
+		useCharacterDescriptions,
+		sayCapForCapitals=synthConfig["sayCapForCapitals"],
+		capPitchChange=capPitchChange,
+		beepForCapitals=synthConfig["beepForCapitals"],
+	)
+	if synthConfig["useSpellingFunctionality"]:
+		seq = _getSpellingSpeechAddCharMode(seq)
+	yield from seq
+
+
 def getCharDescListFromText(text,locale):
 	"""This method prepares a list, which contains character and its description for all characters the text is made up of, by checking the presence of character descriptions in characterDescriptions.dic of that locale for all possible combination of consecutive characters in the text.
 	This is done to take care of conjunct characters present in several languages such as Hindi, Urdu, etc.


Test patch:
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -21,7 +21,8 @@
 import gettext
 #Localization settings
 locale.setlocale(locale.LC_ALL,'')
-gettext.install('nvda')
+translations = gettext.NullTranslations()
+translations.install()
 
 # The path to the unit tests.
 UNIT_DIR = os.path.dirname(os.path.abspath(__file__))
diff --git a/tests/unit/test_speech.py b/tests/unit/test_speech.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_speech.py
@@ -0,0 +1,346 @@
+# A part of NonVisual Desktop Access (NVDA)
+# This file is covered by the GNU General Public License.
+# See the file COPYING for more details.
+# Copyright (C) 2021 NV Access Limited, Cyrille Bougot
+
+"""Unit tests for the speech module.
+"""
+import unittest
+import gettext
+import typing
+import config
+from speech import (
+	_getSpellingSpeechAddCharMode,
+	_getSpellingCharAddCapNotification,
+	_getSpellingSpeechWithoutCharMode,
+)
+from speech.commands import (
+	EndUtteranceCommand,
+	CharacterModeCommand,
+	PitchCommand,
+	BeepCommand,
+	LangChangeCommand
+)
+
+
+class Test_getSpellingSpeechAddCharMode(unittest.TestCase):
+	def test_symbolNamesAtStartAndEnd(self):
+		# Spelling ¡hola!
+		seq = (c for c in [
+			'inverted exclamation point',
+			EndUtteranceCommand(),
+			'h',
+			EndUtteranceCommand(),
+			'o',
+			EndUtteranceCommand(),
+			'l',
+			EndUtteranceCommand(),
+			'a',
+			EndUtteranceCommand(),
+			'bang',
+			EndUtteranceCommand()
+		])
+		expected = repr([
+			'inverted exclamation point',
+			EndUtteranceCommand(),
+			CharacterModeCommand(True),
+			'h',
+			EndUtteranceCommand(),
+			'o',
+			EndUtteranceCommand(),
+			'l',
+			EndUtteranceCommand(),
+			'a',
+			EndUtteranceCommand(),
+			CharacterModeCommand(False),
+			'bang',
+			EndUtteranceCommand()
+		])
+		output = _getSpellingSpeechAddCharMode(seq)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_manySymbolNamesInARow(self):
+		# Spelling a...b
+		seq = (c for c in [
+			'a',
+			EndUtteranceCommand(),
+			'dot',
+			EndUtteranceCommand(),
+			'dot',
+			EndUtteranceCommand(),
+			'dot',
+			EndUtteranceCommand(),
+			'b',
+			EndUtteranceCommand()
+		])
+		expected = repr([
+			CharacterModeCommand(True),
+			'a',
+			EndUtteranceCommand(),
+			CharacterModeCommand(False),
+			'dot',
+			EndUtteranceCommand(),
+			'dot',
+			EndUtteranceCommand(),
+			'dot',
+			EndUtteranceCommand(),
+			CharacterModeCommand(True),
+			'b',
+			EndUtteranceCommand()
+		])
+		output = _getSpellingSpeechAddCharMode(seq)
+		self.assertEqual(repr(list(output)), expected)
+
+
+class Translation_Fake(gettext.NullTranslations):
+	originalTranslationFunction: gettext.NullTranslations
+	translationResults: typing.Dict[str, str]
+
+	def __init__(self, originalTranslationFunction: gettext.NullTranslations):
+		self.originalTranslationFunction = originalTranslationFunction
+		self.translationResults = {}
+		super().__init__()
+
+	def gettext(self, msg: str) -> str:
+		if msg in self.translationResults:
+			return self.translationResults[msg]
+		return self.originalTranslationFunction.gettext(msg)
+
+
+class Test_getSpellingCharAddCapNotification(unittest.TestCase):
+	translationsFake: Translation_Fake
+
+	@classmethod
+	def setUpClass(cls):
+		from . import translations as originalTranslationClass
+		cls.translationsFake = Translation_Fake(originalTranslationClass)
+		cls.translationsFake.install()
+
+	@classmethod
+	def tearDownClass(cls):
+		cls.translationsFake.originalTranslationFunction.install()
+
+	def tearDown(self) -> None:
+		self.translationsFake.translationResults.clear()
+	
+	def test_noNotifications(self):
+		expected = repr([
+			'A',
+		])
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_pitchNotifications(self):
+		expected = repr([
+			PitchCommand(offset=30),
+			'A',
+			PitchCommand()
+		])
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=False,
+			capPitchChange=30,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_beepNotifications(self):
+		expected = repr([
+			BeepCommand(2000, 50, left=50, right=50),
+			'A',
+		])
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=True,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_capNotifications(self):
+		expected = repr([
+			'cap ',
+			'A',
+		])
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=True,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_capNotificationsWithPlaceHolderBefore(self):
+		self.translationsFake.translationResults["cap %s"] = "%s cap"
+		expected = repr(['A', ' cap', ])  # for English this would be "cap A"
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=True,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_allNotifications(self):
+		expected = repr([
+			PitchCommand(offset=30),
+			BeepCommand(2000, 50, left=50, right=50),
+			'cap ',
+			'A',
+			PitchCommand()
+		])
+		output = _getSpellingCharAddCapNotification(
+			speakCharAs='A',
+			sayCapForCapitals=True,
+			capPitchChange=30,
+			beepForCapitals=True,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+
+class Test_getSpellingSpeechWithoutCharMode(unittest.TestCase):
+
+	def setUp(self):
+		config.conf['speech']['autoLanguageSwitching'] = False
+
+	def tearDown(self):
+		# Restore default value
+		config.conf['speech']['autoLanguageSwitching'] = config.conf.getConfigValidation(
+			['speech', 'autoLanguageSwitching']
+		).default
+	
+	def test_simpleSpelling(self):
+		expected = repr([
+			'a',
+			EndUtteranceCommand(),
+			'b',
+			EndUtteranceCommand(),
+			'c',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='abc',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_cap(self):
+		expected = repr([
+			PitchCommand(offset=30),
+			BeepCommand(2000, 50, left=50, right=50),
+			'cap ',
+			'A',
+			PitchCommand(),
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='A',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=True,
+			capPitchChange=30,
+			beepForCapitals=True,
+		)
+		self.assertEqual(repr(list(output)), expected)
+	
+	def test_characterMode(self):
+		expected = repr([
+			'Alfa',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='a',
+			locale='en',
+			useCharacterDescriptions=True,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_blank(self):
+		expected = repr([
+			'blank',
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_onlySpaces(self):
+		expected = repr([
+			'space',
+			EndUtteranceCommand(),
+			'tab',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text=' \t',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_trimRightSpace(self):
+		expected = repr([
+			'a',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='a   ',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_symbol(self):
+		expected = repr([
+			'bang',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='!',
+			locale=None,
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)
+
+	def test_languageDetection(self):
+		config.conf['speech']['autoLanguageSwitching'] = True
+		expected = repr([
+			LangChangeCommand('fr_FR'),
+			'a',
+			EndUtteranceCommand(),
+		])
+		output = _getSpellingSpeechWithoutCharMode(
+			text='a',
+			locale='fr_FR',
+			useCharacterDescriptions=False,
+			sayCapForCapitals=False,
+			capPitchChange=0,
+			beepForCapitals=False,
+		)
+		self.assertEqual(repr(list(output)), expected)