From d864a962644f78f5237098b97a811f043229bcd4 Mon Sep 17 00:00:00 2001 From: cary-rowen Date: Sun, 12 Jul 2026 05:35:56 +0800 Subject: [PATCH 1/2] Report descriptions for WinForms ToolStrip menu items --- source/NVDAObjects/UIA/__init__.py | 17 +++++++----- tests/unit/test_NVDAObjects_UIA.py | 43 ++++++++++++++++++++++++++++++ user_docs/en/changes.md | 1 + 3 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_NVDAObjects_UIA.py diff --git a/source/NVDAObjects/UIA/__init__.py b/source/NVDAObjects/UIA/__init__.py index 5f9ff822901..cda0b835c3f 100644 --- a/source/NVDAObjects/UIA/__init__.py +++ b/source/NVDAObjects/UIA/__init__.py @@ -2565,13 +2565,18 @@ def _get_positionInfo(self): class MenuItem(UIA): - def _get_description(self): + def _get_description(self) -> str | None: name = self.name - description = super(MenuItem, self)._get_description() - if description != name: - return description - else: - return None + description = super()._get_description() + if not description or description == name: + legacyDescription = self._getUIACacheablePropertyValue_handlesCOMErrors( + UIAHandler.UIA_LegacyIAccessibleDescriptionPropertyId, + ignoreDefault=True, + onError=None, + ) + if isinstance(legacyDescription, str): + description = legacyDescription + return description if description != name else None class UIColumnHeader(UIA): diff --git a/tests/unit/test_NVDAObjects_UIA.py b/tests/unit/test_NVDAObjects_UIA.py new file mode 100644 index 00000000000..a838cfa72be --- /dev/null +++ b/tests/unit/test_NVDAObjects_UIA.py @@ -0,0 +1,43 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Cary-rowen +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for NVDAObjects.UIA.""" + +import unittest +from unittest.mock import patch + +from NVDAObjects.UIA import MenuItem, UIA + + +class TestMenuItemDescription(unittest.TestCase): + def test_legacyDescriptionFallback(self) -> None: + menuItem = object.__new__(MenuItem) + menuItem.name = "Name" + notSupportedValue = object() + testCases = ( + ("UIA description", "Legacy description", "UIA description", False), + ("", "Legacy description", "Legacy description", True), + ("Name", "Legacy description", "Legacy description", True), + ("Name", "Name", None, True), + ("Name", notSupportedValue, None, True), + ) + for uiaDescription, legacyDescription, expectedDescription, shouldReadLegacyDescription in testCases: + with ( + self.subTest( + uiaDescription=uiaDescription, + legacyDescription=legacyDescription, + ), + patch.object(UIA, "_get_description", return_value=uiaDescription), + patch.object( + MenuItem, + "_getUIACacheablePropertyValue_handlesCOMErrors", + return_value=legacyDescription, + ) as getLegacyDescription, + ): + self.assertEqual(expectedDescription, menuItem._get_description()) + if shouldReadLegacyDescription: + getLegacyDescription.assert_called_once() + else: + getLegacyDescription.assert_not_called() diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 6befac7d53e..ac1108f6714 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -52,6 +52,7 @@ Previously these keys had no function when pressed on their own. (#20366, @fla-rion) * The HID keyboard input simulation setting for ALVA braille displays is now remembered across reconnects and restarts. (#20455, @Cary-rowen) * Braille now follows the spoken text during say all in browse mode when braille is tethered to focus. (#3287, @LeonarddeR) +* Object descriptions are now reported for .NET Framework Windows Forms ToolStrip menu items exposed through UI Automation. (#20486, @Cary-rowen) ### Changes for Developers From b30fcf33eea6d96fef572c59b7776112466ef682 Mon Sep 17 00:00:00 2001 From: cary-rowen Date: Sun, 12 Jul 2026 08:10:06 +0800 Subject: [PATCH 2/2] Limit WinForms menu item description fallback --- source/NVDAObjects/UIA/__init__.py | 20 ++++++++++----- tests/unit/test_NVDAObjects_UIA.py | 41 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/source/NVDAObjects/UIA/__init__.py b/source/NVDAObjects/UIA/__init__.py index cda0b835c3f..4fe0aedb7c6 100644 --- a/source/NVDAObjects/UIA/__init__.py +++ b/source/NVDAObjects/UIA/__init__.py @@ -2569,13 +2569,19 @@ def _get_description(self) -> str | None: name = self.name description = super()._get_description() if not description or description == name: - legacyDescription = self._getUIACacheablePropertyValue_handlesCOMErrors( - UIAHandler.UIA_LegacyIAccessibleDescriptionPropertyId, - ignoreDefault=True, - onError=None, - ) - if isinstance(legacyDescription, str): - description = legacyDescription + providerDescription = self.UIAElement.cachedProviderDescription or "" + if ( + self.UIAElement.cachedFrameworkID == "WinForm" + and "System.Windows.Forms, Version=4.0.0.0" in providerDescription + and "ToolStripMenuItem" in providerDescription + ): + legacyDescription = self._getUIACacheablePropertyValue_handlesCOMErrors( + UIAHandler.UIA_LegacyIAccessibleDescriptionPropertyId, + ignoreDefault=True, + onError=None, + ) + if isinstance(legacyDescription, str): + description = legacyDescription return description if description != name else None diff --git a/tests/unit/test_NVDAObjects_UIA.py b/tests/unit/test_NVDAObjects_UIA.py index a838cfa72be..8c099111dc8 100644 --- a/tests/unit/test_NVDAObjects_UIA.py +++ b/tests/unit/test_NVDAObjects_UIA.py @@ -6,7 +6,7 @@ """Unit tests for NVDAObjects.UIA.""" import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from NVDAObjects.UIA import MenuItem, UIA @@ -15,6 +15,13 @@ class TestMenuItemDescription(unittest.TestCase): def test_legacyDescriptionFallback(self) -> None: menuItem = object.__new__(MenuItem) menuItem.name = "Name" + menuItem.UIAElement = Mock( + cachedFrameworkID="WinForm", + cachedProviderDescription=( + "managed:System.Windows.Forms.ToolStripMenuItem+ToolStripMenuItemAccessibleObject, " + "System.Windows.Forms, Version=4.0.0.0" + ), + ) notSupportedValue = object() testCases = ( ("UIA description", "Legacy description", "UIA description", False), @@ -41,3 +48,35 @@ def test_legacyDescriptionFallback(self) -> None: getLegacyDescription.assert_called_once() else: getLegacyDescription.assert_not_called() + + def test_legacyDescriptionFallbackIsLimitedToNetFrameworkWinFormsToolStripMenuItems(self) -> None: + menuItem = object.__new__(MenuItem) + menuItem.name = "Name" + for frameworkID, providerDescription in ( + ( + "WPF", + "managed:System.Windows.Forms.ToolStripMenuItem+ToolStripMenuItemAccessibleObject, " + "System.Windows.Forms, Version=4.0.0.0", + ), + ( + "WinForm", + "managed:System.Windows.Forms.ToolStripMenuItem+ToolStripMenuItemAccessibleObject, " + "System.Windows.Forms, Version=8.0.0.0", + ), + ("WinForm", "System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0"), + ): + with ( + self.subTest(frameworkID=frameworkID, providerDescription=providerDescription), + patch.object(UIA, "_get_description", return_value=""), + patch.object( + MenuItem, + "_getUIACacheablePropertyValue_handlesCOMErrors", + return_value="Legacy description", + ) as getLegacyDescription, + ): + menuItem.UIAElement = Mock( + cachedFrameworkID=frameworkID, + cachedProviderDescription=providerDescription, + ) + self.assertEqual("", menuItem._get_description()) + getLegacyDescription.assert_not_called()