Repository: nvaccess/nvda

Issue:
New NVDA+Shift+b message feels too verbose
### Is your feature request related to a problem? Please describe.
The current battery status message when pressing NVDA+shift+b feels too verbose, because it reports the AC status before the battery level, which is useful when plugging in a charger, but feels unnecessarily long when pressing NVDA+shift+b, because when pressing that keyboard shortcut a user tends to be looking for the battery status first in that case.
### Describe the solution you'd like
Revert back to the older style of battery announcements, charging battery: x% when plugging in a charger, and x% X hours and x minutes remaining, or x% charging when pressing NVDA+shift+b.
### Describe alternatives you've considered
Leave the current message as is.
### Additional context
See the discussion in #14213.


Discussion:

Resolving patch:
diff --git a/source/winAPI/_powerTracking.py b/source/winAPI/_powerTracking.py
--- a/source/winAPI/_powerTracking.py
+++ b/source/winAPI/_powerTracking.py
@@ -13,8 +13,11 @@
 
 import ctypes
 from enum import (
+	Enum,
 	IntEnum,
 	IntFlag,
+	auto,
+	unique,
 )
 from typing import (
 	List,
@@ -124,14 +127,36 @@ def initialize():
 	return
 
 
-def reportCurrentBatteryStatus(onlyReportIfStatusChanged: bool = False) -> None:
+@unique
+class _ReportContext(Enum):
 	"""
-	@param onlyReportIfStatusChanged: sometimes multiple events may fire for a power status change.
-	Set this to True to only report if the power status changes.
+	Used to determine the order of information, based on relevance to the user,
+	when announcing power status information
+	"""
+
+	AC_STATUS_CHANGE = auto()
+	"""e.g. a charger is connected/disconnected"""
+	FETCH_STATUS = auto()
+	"""e.g. when a user presses nvda+shift+b to fetch the current battery status"""
+
+
+def reportACStateChange() -> None:
+	_reportPowerStatus(_ReportContext.AC_STATUS_CHANGE)
+
+
+def reportCurrentBatteryStatus() -> None:
+	_reportPowerStatus(_ReportContext.FETCH_STATUS)
+
+
+def _reportPowerStatus(context: _ReportContext) -> None:
+	"""
+	@param context: the context is used to order the announcement.
+	When the context is AC_STATUS_CHANGE, this reports the current AC status first.
+	When the context is FETCH_STATUS, this reports the remaining battery life first.
 	"""
 	global _powerState
 	systemPowerStatus = _getPowerStatus()
-	speechSequence = _getSpeechForBatteryStatus(systemPowerStatus, onlyReportIfStatusChanged, _powerState)
+	speechSequence = _getSpeechForBatteryStatus(systemPowerStatus, context, _powerState)
 	if speechSequence:
 		ui.message(" ".join(speechSequence))
 	if systemPowerStatus is not None:
@@ -149,7 +174,7 @@ def _getPowerStatus() -> Optional[SystemPowerStatus]:
 
 def _getSpeechForBatteryStatus(
 		systemPowerStatus: Optional[SystemPowerStatus],
-		onlyReportIfStatusChanged: bool,
+		context: _ReportContext,
 		oldPowerState: PowerState,
 ) -> List[str]:
 	if not systemPowerStatus or systemPowerStatus.BatteryFlag == BatteryFlag.UNKNOWN:
@@ -161,20 +186,44 @@ def _getSpeechForBatteryStatus(
 		# and laptops with battery pack removed.
 		return [_("No system battery")]
 
-	if onlyReportIfStatusChanged and systemPowerStatus.ACLineStatus == oldPowerState:
+	if (
+		context == _ReportContext.AC_STATUS_CHANGE
+		and systemPowerStatus.ACLineStatus == oldPowerState
+	):
 		# Sometimes, the power change event double fires.
 		# The power change event also fires when the battery level decreases by 3%.
 		return []
 
 	text: List[str] = []
+
+	if context == _ReportContext.AC_STATUS_CHANGE:
+		# When the AC status changes, users want to be alerted to the new AC status first.
+		text.append(_getACStatusText(systemPowerStatus))
+		text.extend(_getBatteryInformation(systemPowerStatus))
+	elif context == _ReportContext.FETCH_STATUS:
+		# When fetching the current battery status,
+		# users want to know the current battery status first,
+		# rather than the AC status which should be unchanged.
+		text.extend(_getBatteryInformation(systemPowerStatus))
+		text.append(_getACStatusText(systemPowerStatus))
+	else:
+		raise NotImplementedError(f"Unexpected _ReportContext: {context}")
+
+	return text
+
+
+def _getACStatusText(systemPowerStatus: SystemPowerStatus) -> str:
 	# Translators: This is presented to inform the user of the current battery percentage.
 	if systemPowerStatus.ACLineStatus & PowerState.AC_ONLINE:
 		# Translators: Reported when the battery is plugged in, and now is charging.
-		text.append(_("Charging battery"))
+		return _("Charging battery")
 	else:
 		# Translators: Reported when the battery is no longer plugged in, and now is not charging.
-		text.append(_("AC disconnected"))
+		return _("AC disconnected")
 
+
+def _getBatteryInformation(systemPowerStatus: SystemPowerStatus) -> List[str]:
+	text: List[str] = []
 	# Translators: This is presented to inform the user of the current battery percentage.
 	text.append(_("%d percent") % systemPowerStatus.BatteryLifePercent)
 	SECONDS_PER_HOUR = 3600
diff --git a/source/winAPI/messageWindow.py b/source/winAPI/messageWindow.py
--- a/source/winAPI/messageWindow.py
+++ b/source/winAPI/messageWindow.py
@@ -148,7 +148,7 @@ def handleWindowMessage(self, msg: int, wParam: int, lParam: int) -> None:
 		"""
 		if msg == WindowMessage.POWER_BROADCAST:
 			if wParam == _powerTracking.PowerBroadcast.APM_POWER_STATUS_CHANGE:
-				_powerTracking.reportCurrentBatteryStatus(onlyReportIfStatusChanged=True)
+				_powerTracking.reportACStateChange()
 		elif msg == WindowMessage.DISPLAY_CHANGE:
 			_displayTracking.reportScreenOrientationChange(lParam)
 		elif msg == WindowMessage.WTS_SESSION_CHANGE:


Test patch:
diff --git a/tests/unit/test_winAPI/test_powerTracking.py b/tests/unit/test_winAPI/test_powerTracking.py
--- a/tests/unit/test_winAPI/test_powerTracking.py
+++ b/tests/unit/test_winAPI/test_powerTracking.py
@@ -11,6 +11,7 @@
 	BATTERY_LIFE_TIME_UNKNOWN,
 	BatteryFlag,
 	PowerState,
+	_ReportContext,
 	SystemPowerStatus,
 	_getSpeechForBatteryStatus,
 )
@@ -23,10 +24,10 @@ def setUp(self) -> None:
 			MagicMock(SystemPowerStatus())
 		)
 
-	def test_unknownPowerStatus_failedFetch(self):
+	def test_fetch_status_fetchFailed(self):
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=None,
-			onlyReportIfStatusChanged=False,
+			context=_ReportContext.FETCH_STATUS,
 			oldPowerState=PowerState.UNKNOWN,
 		)
 		self.assertEqual(
@@ -34,11 +35,11 @@ def test_unknownPowerStatus_failedFetch(self):
 			actualSpeech,
 		)
 
-	def test_unknownPowerStatus_fetchSuccessful(self):
+	def test_fetch_status_fetchSuccessful_unknownPowerStatus(self):
 		self.testPowerStatus.BatteryFlag = BatteryFlag.UNKNOWN
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=False,
+			context=_ReportContext.FETCH_STATUS,
 			oldPowerState=PowerState.UNKNOWN,
 		)
 		self.assertEqual(
@@ -46,11 +47,11 @@ def test_unknownPowerStatus_fetchSuccessful(self):
 			actualSpeech,
 		)
 
-	def test_noSystemBattery(self):
+	def test_fetch_status_fetchSuccessful_noSystemBattery(self):
 		self.testPowerStatus.BatteryFlag = 0 ^ BatteryFlag.NO_SYSTEM_BATTERY
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=False,
+			context=_ReportContext.FETCH_STATUS,
 			oldPowerState=PowerState.UNKNOWN,
 		)
 		self.assertEqual(
@@ -58,40 +59,40 @@ def test_noSystemBattery(self):
 			actualSpeech,
 		)
 
-	def test_statusUnchanged_ignore(self):
+	def test_fetch_status_full_report(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_OFFLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
+		self.testPowerStatus.BatteryLifeTime = 3660
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=True,
+			context=_ReportContext.FETCH_STATUS,
 			oldPowerState=PowerState.AC_OFFLINE,
 		)
 		self.assertEqual(
-			[],
+			['1 percent', '1 hours and 1 minutes remaining', "AC disconnected"],
 			actualSpeech,
 		)
 
-	def test_statusUnchanged_report(self):
+	def test_ac_status_change_statusUnchanged_ignore(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_OFFLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
-		self.testPowerStatus.BatteryLifeTime = 3660
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=False,
+			context=_ReportContext.AC_STATUS_CHANGE,
 			oldPowerState=PowerState.AC_OFFLINE,
 		)
 		self.assertEqual(
-			["AC disconnected", '1 percent', '1 hours and 1 minutes remaining'],
+			[],
 			actualSpeech,
 		)
 
-	def test_statusChanged_connected(self):
+	def test_ac_status_change_statusChanged_connected(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_ONLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
 		self.testPowerStatus.BatteryLifeTime = 3660
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=True,
+			context=_ReportContext.AC_STATUS_CHANGE,
 			oldPowerState=PowerState.AC_OFFLINE,
 		)
 		self.assertEqual(
@@ -99,13 +100,13 @@ def test_statusChanged_connected(self):
 			actualSpeech,
 		)
 
-	def test_statusChanged_disconnected(self):
+	def test_ac_status_change_statusChanged_disconnected(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_OFFLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
 		self.testPowerStatus.BatteryLifeTime = 3660
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=True,
+			context=_ReportContext.AC_STATUS_CHANGE,
 			oldPowerState=PowerState.AC_ONLINE,
 		)
 		self.assertEqual(
@@ -113,13 +114,13 @@ def test_statusChanged_disconnected(self):
 			actualSpeech,
 		)
 
-	def test_batteryLifetimeUnknown(self):
+	def test_ac_status_change_batteryLifetimeUnknown(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_OFFLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
 		self.testPowerStatus.BatteryLifeTime = BATTERY_LIFE_TIME_UNKNOWN
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=True,
+			context=_ReportContext.AC_STATUS_CHANGE,
 			oldPowerState=PowerState.AC_ONLINE,
 		)
 		self.assertEqual(
@@ -127,14 +128,14 @@ def test_batteryLifetimeUnknown(self):
 			actualSpeech,
 		)
 
-	def test_batteryLifePercent(self):
+	def test_ac_status_change_batteryLifePercent(self):
 		self.testPowerStatus.ACLineStatus = PowerState.AC_OFFLINE
 		self.testPowerStatus.BatteryFlag = BatteryFlag.HIGH
 		self.testPowerStatus.BatteryLifePercent = 7
 		self.testPowerStatus.BatteryLifeTime = BATTERY_LIFE_TIME_UNKNOWN
 		actualSpeech = _getSpeechForBatteryStatus(
 			systemPowerStatus=self.testPowerStatus,
-			onlyReportIfStatusChanged=True,
+			context=_ReportContext.AC_STATUS_CHANGE,
 			oldPowerState=PowerState.AC_ONLINE,
 		)
 		self.assertEqual(