include/test/a11y/accessibletestbase.hxx | 60 ++++++++++++- include/test/a11y/eventposter.hxx | 132 +++++++++++++++++++++++++++++ sw/CppunitTest_sw_a11y.mk | 1 sw/qa/extras/accessibility/dialogs.cxx | 140 +++++++++++++++++++++++++++++++ test/Library_subsequenttest.mk | 1 test/source/a11y/accessibletestbase.cxx | 84 ++++++++++++++++++ test/source/a11y/eventposter.cxx | 46 ++++++++++ 7 files changed, 458 insertions(+), 6 deletions(-)
New commits: commit be86c74bb5bf04347846261243c8eb21dc8d7200 Author: Colomban Wendling <cwendl...@hypra.fr> AuthorDate: Thu Nov 3 15:08:25 2022 +0100 Commit: Michael Weghorn <m.wegh...@posteo.de> CommitDate: Fri Mar 3 09:58:46 2023 +0000 test: Add a few basic dialog tests and helpers This adds basic tests for a few dialogues, showcasing and exercising the dialog handling code. Those tests are extremely basic but show that it is trivial enough to interact with a dialog. This adds a few helpers to navigate the UI using keyboard events as well, because it's one of the best methods to verify the actual interaction works for a user of assistive technologies. Change-Id: Idc1f279f35ff01769138c3addb10ef851ca0dbb8 Reviewed-on: https://gerrit.libreoffice.org/c/core/+/142259 Tested-by: Jenkins Reviewed-by: Michael Weghorn <m.wegh...@posteo.de> diff --git a/include/test/a11y/accessibletestbase.hxx b/include/test/a11y/accessibletestbase.hxx index e174c0cb4b8a..745f9fae2458 100644 --- a/include/test/a11y/accessibletestbase.hxx +++ b/include/test/a11y/accessibletestbase.hxx @@ -28,6 +28,7 @@ #include <rtl/ustring.hxx> #include <test/bootstrapfixture.hxx> +#include <test/a11y/eventposter.hxx> #include "AccessibilityTools.hxx" @@ -130,13 +131,57 @@ protected: return activateMenuItem(menuBar, names...); } + /** + * @brief Gets the focused accessible object at @p xAcc level or below + * @param xAcc An accessible object + * @returns The accessible context of the focused object, or @c nullptr + * + * Finds the accessible object context at or under @p xAcc that has the focused state (and is + * showing). Normally only one such object should exist in a given hierarchy, but in all cases + * this function will return the first one found. + * + * @see AccessibilityTools::getAccessibleObjectForPredicate() + */ + static css::uno::Reference<css::accessibility::XAccessibleContext> + getFocusedObject(const css::uno::Reference<css::accessibility::XAccessibleContext>& xCtx); + + static inline css::uno::Reference<css::accessibility::XAccessibleContext> + getFocusedObject(const css::uno::Reference<css::accessibility::XAccessible>& xAcc) + { + return getFocusedObject(xAcc->getAccessibleContext()); + } + + /** + * @brief Navigates through focusable elements using the Tab keyboard shortcut. + * @param xRoot The root element to look for focused elements in. + * @param role The accessible role of the element to tab to. + * @param name The accessible name of the element to tab to. + * @param pEventPosterHelper Pointer to a @c EventPosterHelper instance, or @c nullptr to obtain + * it from @p xRoot. + * @returns The element tabbed to, or @c nullptr if not found. + * + * Navigates through focusable elements in the top level containing @p xRoot using the Tab + * keyboard key until the focused elements matches @p role and @p name. + * + * Note that usually @p xRoot should be the toplevel accessible, or at least contain all + * focusable elements within that window. It is however *not* a requirement, but only elements + * actually inside it will be candidate for a match, and thus if focus goes outside it, it might + * lead to not finding the target element. + * + * If @p pEventPosterHelper is @c nullptr, this function will try to construct one from + * @p xRoot. @see EventPosterHelper. + */ + static css::uno::Reference<css::accessibility::XAccessibleContext> + tabTo(const css::uno::Reference<css::accessibility::XAccessible>& xRoot, const sal_Int16 role, + const std::u16string_view name, + const EventPosterHelperBase* pEventPosterHelper = nullptr); + /* Dialog handling */ - class Dialog + class Dialog : public test::EventPosterHelper { friend class AccessibleTestBase; private: - VclPtr<vcl::Window> mxWindow; bool mbAutoClose; Dialog(vcl::Window* pWindow, bool bAutoClose = true); @@ -144,9 +189,6 @@ protected: public: virtual ~Dialog(); - explicit operator bool() const { return mxWindow && !mxWindow->isDisposed(); } - bool operator!() const { return !bool(*this); } - void setAutoClose(bool bAutoClose) { mbAutoClose = bAutoClose; } css::uno::Reference<css::accessibility::XAccessible> getAccessible() const @@ -155,6 +197,12 @@ protected: } bool close(sal_Int32 result = VclResponseType::RET_CANCEL); + + css::uno::Reference<css::accessibility::XAccessibleContext> + tabTo(const sal_Int16 role, const std::u16string_view name) + { + return AccessibleTestBase::tabTo(getAccessible(), role, name, this); + } }; class DialogWaiter @@ -189,7 +237,9 @@ protected: * auto waiter = awaitDialog(u"Special Characters", [this](Dialog &dialog) { * // for example, something like this: * // something(); + * // CPPUNIT_ASSERT(dialog.tabTo(...)); * // CPPUNIT_ASSERT(somethingElse); + * // dialog.postKeyEventAsync(0, awt::Key::RETURN); * }); * CPPUNIT_ASSERT(activateMenuItem(u"Some menu", u"Some Item Triggering a Dialog...")); * CPPUNIT_ASSERT(waiter->waitEndDialog()); diff --git a/include/test/a11y/eventposter.hxx b/include/test/a11y/eventposter.hxx new file mode 100644 index 000000000000..c0e607a70100 --- /dev/null +++ b/include/test/a11y/eventposter.hxx @@ -0,0 +1,132 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#pragma once + +#include <test/testdllapi.hxx> + +#include <com/sun/star/accessibility/XAccessible.hpp> +#include <com/sun/star/accessibility/XAccessibleContext.hpp> +#include <com/sun/star/uno/Reference.hxx> + +#include <LibreOfficeKit/LibreOfficeKitEnums.h> +#include <rtl/ustring.hxx> +#include <vcl/window.hxx> + +namespace test +{ +/** + * @brief Base helper class to send events to a window. + * + * Implementations of this helper will usually just wrap an implementation of post*Event*() calls. + * This class is mostly useful to encapsulate the calls when getting the target window is not + * trivial or is only relevant to sending events, and to have a generic event poster interface. + * + * Additionally, this class provides simplified helpers to send event pairs, like key down/up, or + * text+commit, to make it easier on the common case for callers. + */ +class OOO_DLLPUBLIC_TEST EventPosterHelperBase +{ +public: + virtual ~EventPosterHelperBase(){}; + + /** @see SfxLokHelper::postKeyEventAsync */ + virtual void postKeyEventAsync(int nType, int nCharCode, int nKeyCode) const = 0; + + /** Posts a full key down/up cycle */ + void postKeyEventAsync(int nCharCode, int nKeyCode) const + { + postKeyEventAsync(LOK_KEYEVENT_KEYINPUT, nCharCode, nKeyCode); + postKeyEventAsync(LOK_KEYEVENT_KEYUP, nCharCode, nKeyCode); + } + + /** @see SfxLokHelper::postExtTextEventAsync */ + virtual void postExtTextEventAsync(int nType, const OUString& rText) const = 0; + + /** Posts a full text input + commit sequence */ + void postExtTextEventAsync(const OUString& rText) const + { + postExtTextEventAsync(LOK_EXT_TEXTINPUT, rText); + postExtTextEventAsync(LOK_EXT_TEXTINPUT_END, rText); + } +}; + +/** + * @brief Helper to send events to a window. + * + * This helper basically just wraps SfxLokHelper::post*EventAsync() calls to hold the target window + * reference in the class. + */ +class OOO_DLLPUBLIC_TEST EventPosterHelper : public EventPosterHelperBase +{ +protected: + VclPtr<vcl::Window> mxWindow; + +public: + EventPosterHelper(void) + : mxWindow(nullptr) + { + } + EventPosterHelper(VclPtr<vcl::Window> xWindow) + : mxWindow(xWindow) + { + } + EventPosterHelper(vcl::Window* pWindow) + : mxWindow(pWindow) + { + } + + vcl::Window* getWindow() const { return mxWindow; } + + void setWindow(VclPtr<vcl::Window> xWindow) { mxWindow = xWindow; } + void setWindow(vcl::Window* pWindow) { mxWindow = pWindow; } + + explicit operator bool() const { return mxWindow && !mxWindow->isDisposed(); } + bool operator!() const { return !bool(*this); } + + using EventPosterHelperBase::postKeyEventAsync; + using EventPosterHelperBase::postExtTextEventAsync; + + /** @see SfxLokHelper::postKeyEventAsync */ + virtual void postKeyEventAsync(int nType, int nCharCode, int nKeyCode) const override; + /** @see SfxLokHelper::postExtTextEventAsync */ + virtual void postExtTextEventAsync(int nType, const OUString& rText) const override; +}; + +/** + * @brief Accessibility-specialized helper to send events to a window. + * + * This augments @c test::EventPosterHelper to simplify usage in accessibility tests. + */ +class OOO_DLLPUBLIC_TEST AccessibleEventPosterHelper : public EventPosterHelper +{ +public: + AccessibleEventPosterHelper(void) + : EventPosterHelper() + { + } + AccessibleEventPosterHelper(const css::uno::Reference<css::accessibility::XAccessible> xAcc) + { + setWindow(xAcc); + } + + /** + * @brief Sets the window on which post events based on an accessible object inside it. + * @param xAcc An accessible object inside a toplevel. + * + * This method tries and find the top level window containing @p xAcc to use it to post events. + * + * This currently relies on a toplevel accessible being a @c VCLXWindow, and requires that + * window's output device to be set (@see VCLXWindow::GetWindow()). + */ + void setWindow(css::uno::Reference<css::accessibility::XAccessible> xAcc); +}; +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s cinkeys+=0=break: */ diff --git a/sw/CppunitTest_sw_a11y.mk b/sw/CppunitTest_sw_a11y.mk index 67c527d1179c..282f580423aa 100644 --- a/sw/CppunitTest_sw_a11y.mk +++ b/sw/CppunitTest_sw_a11y.mk @@ -11,6 +11,7 @@ $(eval $(call gb_CppunitTest_CppunitTest,sw_a11y)) $(eval $(call gb_CppunitTest_add_exception_objects,sw_a11y, \ sw/qa/extras/accessibility/basics \ + sw/qa/extras/accessibility/dialogs \ )) $(eval $(call gb_CppunitTest_use_libraries,sw_a11y, \ diff --git a/sw/qa/extras/accessibility/dialogs.cxx b/sw/qa/extras/accessibility/dialogs.cxx new file mode 100644 index 000000000000..ab03af5e22db --- /dev/null +++ b/sw/qa/extras/accessibility/dialogs.cxx @@ -0,0 +1,140 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#include <com/sun/star/awt/Key.hpp> +#include <com/sun/star/accessibility/AccessibleRole.hpp> + +#include <vcl/scheduler.hxx> + +#include <test/a11y/swaccessibletestbase.hxx> +#include <test/a11y/AccessibilityTools.hxx> + +using namespace css; + +// FIXME: dialog doesn't pop up on macos and doesn't close on win32... +#if !defined(_WIN32) && !defined(MACOSX) +CPPUNIT_TEST_FIXTURE(test::SwAccessibleTestBase, BasicTestHyperlinkDialog) +{ + load(u"private:factory/swriter"); + + auto dialogWaiter = awaitDialog(u"Hyperlink", [this](Dialog& dialog) { + dumpA11YTree(dialog.getAccessible()->getAccessibleContext()); + + // Focus the URL box (should be default, but make sure we're on it) + CPPUNIT_ASSERT(dialog.tabTo(accessibility::AccessibleRole::COMBO_BOX, u"URL:")); + // Fill in an address + dialog.postExtTextEventAsync(u"https://libreoffice.org/"); + // Validate the whole dialog + dialog.postKeyEventAsync(0, awt::Key::RETURN); + Scheduler::ProcessEventsToIdle(); + }); + + // Activate the Insert->Hyperlink... menu item to open the Hyperlink dialog + CPPUNIT_ASSERT(activateMenuItem(u"Insert", u"Hyperlink...")); + CPPUNIT_ASSERT(dialogWaiter->waitEndDialog()); + + CPPUNIT_ASSERT_EQUAL(rtl::OUString("<PARAGRAPH>https://libreoffice.org/</PARAGRAPH>"), + collectText()); +} +#endif + +// FIXME: dialog doesn't pop up on macos and doesn't close on win32... +#if !defined(_WIN32) && !defined(MACOSX) +CPPUNIT_TEST_FIXTURE(test::SwAccessibleTestBase, BasicTestBookmarkDialog) +{ + load(u"private:factory/swriter"); + + auto dialogWaiter = awaitDialog(u"Bookmark", [this](Dialog& dialog) { + dumpA11YTree(dialog.getAccessible()->getAccessibleContext()); + + CPPUNIT_ASSERT(dialog.tabTo(accessibility::AccessibleRole::TEXT, u"Name:")); + dialog.postKeyEventAsync(0, awt::Key::SELECT_ALL); + dialog.postKeyEventAsync(0, awt::Key::DELETE); + dialog.postExtTextEventAsync(u"Test Bookmark 1"); + // Validate the whole dialog + dialog.postKeyEventAsync(0, awt::Key::RETURN); + Scheduler::ProcessEventsToIdle(); + }); + + // Activate the Insert->Bookmark... menu item to open the Bookmark dialog + CPPUNIT_ASSERT(activateMenuItem(u"Insert", u"Bookmark...")); + CPPUNIT_ASSERT(dialogWaiter->waitEndDialog()); + + CPPUNIT_ASSERT_EQUAL(rtl::OUString("<PARAGRAPH>#Test Bookmark 1 Bookmark </PARAGRAPH>"), + collectText()); +} +#endif + +// FIXME: dialog doesn't pop up on macos and doesn't close on win32... +#if !defined(_WIN32) && !defined(MACOSX) +CPPUNIT_TEST_FIXTURE(test::SwAccessibleTestBase, BasicTestSectionDialog) +{ + load(u"private:factory/swriter"); + + auto dialogWaiter = awaitDialog(u"Insert Section", [this](Dialog& dialog) { + dumpA11YTree(dialog.getAccessible()->getAccessibleContext()); + + // Validate the whole dialog + dialog.postKeyEventAsync(0, awt::Key::RETURN); + Scheduler::ProcessEventsToIdle(); + }); + + CPPUNIT_ASSERT(activateMenuItem(u"Insert", u"Section...")); + CPPUNIT_ASSERT(dialogWaiter->waitEndDialog()); + + CPPUNIT_ASSERT_EQUAL(rtl::OUString("<PARAGRAPH/><PARAGRAPH/>"), collectText()); +} +#endif + +// FIXME: dialog doesn't pop up on macos and doesn't close on win32... +#if !defined(_WIN32) && !defined(MACOSX) +CPPUNIT_TEST_FIXTURE(test::SwAccessibleTestBase, BasicTestFontworkDialog) +{ + load(u"private:factory/swriter"); + + auto dialogWaiter = awaitDialog(u"Fontwork Gallery", [this](Dialog& dialog) { + dumpA11YTree(dialog.getAccessible()->getAccessibleContext()); + + // Validate the whole dialog + dialog.postKeyEventAsync(0, awt::Key::RETURN); + Scheduler::ProcessEventsToIdle(); + }); + + CPPUNIT_ASSERT(activateMenuItem(u"Insert", u"Fontwork...")); + CPPUNIT_ASSERT(dialogWaiter->waitEndDialog()); + + CPPUNIT_ASSERT_EQUAL( + rtl::OUString("<PARAGRAPH/><SHAPE name=\"Simple\" description=\" \"><PARAGRAPH " + "description=\"Paragraph: 0 Simple\">Simple</PARAGRAPH></SHAPE>"), + collectText()); +} +#endif + +// FIXME: dialog doesn't pop up on macos and doesn't close on win32... +#if !defined(_WIN32) && !defined(MACOSX) +CPPUNIT_TEST_FIXTURE(test::SwAccessibleTestBase, BasicTestFrameDialog) +{ + load(u"private:factory/swriter"); + + auto dialogWaiter = awaitDialog(u"Frame", [](Dialog& dialog) { + // Validate the whole dialog + dialog.postKeyEventAsync(0, awt::Key::RETURN); + Scheduler::ProcessEventsToIdle(); + }); + + CPPUNIT_ASSERT(activateMenuItem(u"Insert", u"Frame", u"Frame...")); + CPPUNIT_ASSERT(dialogWaiter->waitEndDialog()); + + CPPUNIT_ASSERT_EQUAL( + rtl::OUString("<PARAGRAPH/><TEXT_FRAME name=\"Frame1\"><PARAGRAPH/></TEXT_FRAME>"), + collectText()); +} +#endif + +/* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s cinkeys+=0=break: */ diff --git a/test/Library_subsequenttest.mk b/test/Library_subsequenttest.mk index 1e94db958852..7323f7312d93 100644 --- a/test/Library_subsequenttest.mk +++ b/test/Library_subsequenttest.mk @@ -47,6 +47,7 @@ $(eval $(call gb_Library_add_exception_objects,subsequenttest,\ test/source/unoapixml_test \ test/source/a11y/AccessibilityTools \ test/source/a11y/accessibletestbase \ + test/source/a11y/eventposter \ test/source/a11y/swaccessibletestbase \ test/source/beans/xpropertyset \ test/source/chart/xchartdata \ diff --git a/test/source/a11y/accessibletestbase.cxx b/test/source/a11y/accessibletestbase.cxx index 857df519f136..d9cfaa731f95 100644 --- a/test/source/a11y/accessibletestbase.cxx +++ b/test/source/a11y/accessibletestbase.cxx @@ -235,10 +235,92 @@ bool test::AccessibleTestBase::activateMenuItem( return false; } +uno::Reference<accessibility::XAccessibleContext> test::AccessibleTestBase::getFocusedObject( + const uno::Reference<accessibility::XAccessibleContext>& xCtx) +{ + return AccessibilityTools::getAccessibleObjectForPredicate( + xCtx, [](const uno::Reference<accessibility::XAccessibleContext>& xCandidateCtx) { + const auto states = (accessibility::AccessibleStateType::FOCUSED + | accessibility::AccessibleStateType::SHOWING); + return (xCandidateCtx->getAccessibleStateSet() & states) == states; + }); +} + +uno::Reference<accessibility::XAccessibleContext> +test::AccessibleTestBase::tabTo(const uno::Reference<accessibility::XAccessible>& xRoot, + const sal_Int16 role, const std::u16string_view name, + const EventPosterHelperBase* pEventPosterHelper) +{ + AccessibleEventPosterHelper eventHelper; + if (!pEventPosterHelper) + { + eventHelper.setWindow(xRoot); + pEventPosterHelper = &eventHelper; + } + + auto xOriginalFocus = getFocusedObject(xRoot); + auto xFocus = xOriginalFocus; + int nSteps = 0; + + std::cout << "Tabbing to '" << OUString(name) << "'..." << std::endl; + while (xFocus && (nSteps == 0 || xFocus != xOriginalFocus)) + { + std::cout << " focused object is: " << AccessibilityTools::debugString(xFocus) + << std::endl; + if (xFocus->getAccessibleRole() == role && AccessibilityTools::nameEquals(xFocus, name)) + { + std::cout << " -> OK, focus matches" << std::endl; + return xFocus; + } + if (++nSteps > 100) + { + std::cerr << "Object not found after tabbing 100 times! bailing out" << std::endl; + break; + } + + std::cout << " -> no match, sending <TAB>" << std::endl; + pEventPosterHelper->postKeyEventAsync(0, awt::Key::TAB); + Scheduler::ProcessEventsToIdle(); + + const auto xPrevFocus = xFocus; + xFocus = getFocusedObject(xRoot); + if (!xFocus) + std::cerr << "Focus lost after sending <TAB>!" << std::endl; + else if (xPrevFocus == xFocus) + { + std::cerr << "Focus didn't move after sending <TAB>! bailing out" << std::endl; + std::cerr << "Focused object(s):" << std::endl; + int iFocusedCount = 0; + // count and print out objects with focused state + AccessibilityTools::getAccessibleObjectForPredicate( + xRoot, + [&iFocusedCount](const uno::Reference<accessibility::XAccessibleContext>& xCtx) { + const auto states = (accessibility::AccessibleStateType::FOCUSED + | accessibility::AccessibleStateType::SHOWING); + if ((xCtx->getAccessibleStateSet() & states) == states) + { + std::cerr << " * " << AccessibilityTools::debugString(xCtx) << std::endl; + iFocusedCount++; + } + return false; // keep going + }); + std::cerr << "Total focused element(s): " << iFocusedCount << std::endl; + if (iFocusedCount > 1) + std::cerr << "WARNING: there are more than one focused object! This usually means " + "there is a BUG in the focus handling of that accessibility tree." + << std::endl; + break; + } + } + + std::cerr << "NOT FOUND" << std::endl; + return nullptr; +} + /* Dialog handling */ test::AccessibleTestBase::Dialog::Dialog(vcl::Window* pWindow, bool bAutoClose) - : mxWindow(pWindow) + : test::EventPosterHelper(pWindow) , mbAutoClose(bAutoClose) { CPPUNIT_ASSERT(pWindow); diff --git a/test/source/a11y/eventposter.cxx b/test/source/a11y/eventposter.cxx new file mode 100644 index 000000000000..39e178e22756 --- /dev/null +++ b/test/source/a11y/eventposter.cxx @@ -0,0 +1,46 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#include <test/a11y/eventposter.hxx> + +#include <com/sun/star/accessibility/XAccessible.hpp> +#include <com/sun/star/accessibility/XAccessibleContext.hpp> +#include <com/sun/star/uno/Reference.hxx> + +#include <sfx2/lokhelper.hxx> +#include <test/a11y/AccessibilityTools.hxx> +#include <toolkit/awt/vclxwindow.hxx> + +void test::EventPosterHelper::postKeyEventAsync(int nType, int nCharCode, int nKeyCode) const +{ + SfxLokHelper::postKeyEventAsync(mxWindow, nType, nCharCode, nKeyCode); +} + +void test::EventPosterHelper::postExtTextEventAsync(int nType, const OUString& rText) const +{ + SfxLokHelper::postExtTextEventAsync(mxWindow, nType, rText); +} + +void test::AccessibleEventPosterHelper::setWindow( + css::uno::Reference<css::accessibility::XAccessible> xAcc) +{ + while (auto xParent = xAcc->getAccessibleContext()->getAccessibleParent()) + xAcc = xParent; + auto vclXWindow = dynamic_cast<VCLXWindow*>(xAcc.get()); + if (!vclXWindow) + { + std::cerr << "WARNING: AccessibleEventPosterHelper::setWindow() called on " + "unsupported object " + << AccessibilityTools::debugString(xAcc) << ". Event delivery will not work." + << std::endl; + } + mxWindow = vclXWindow ? vclXWindow->GetWindow() : nullptr; +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s cinkeys+=0=break: */