ZaStoGram_desktop/Telegram/SourceFiles/test/test_widgets.h
John Preston bfb83dd5ec [ai] Deliver synthetic wheels up the parent ladder
Task: 2026/09/11/deliver-a-synthetic-wheel-to-the-scrolling-ancestor
2026-09-24 22:54:00 +04:00

272 lines
12 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include <QtWidgets/QWidget>
namespace Test {
// Telegram's custom widgets do not declare Q_OBJECT, so
// QObject::findChildren<T*>() cannot filter by their type and returns every
// child blindly cast to T* — using such a result crashes. Enumerate QWidget
// descendants (QWidget is a real Q_OBJECT) and dynamic_cast each instead.
template <typename T>
[[nodiscard]] std::vector<T*> FindAll(not_null<QWidget*> root) {
auto result = std::vector<T*>();
for (const auto widget : root->findChildren<QWidget*>()) {
if (const auto typed = dynamic_cast<T*>(widget)) {
result.push_back(typed);
}
}
return result;
}
template <typename T>
[[nodiscard]] T *FindFirst(not_null<QWidget*> root) {
const auto all = FindAll<T>(root);
return all.empty() ? nullptr : all.front();
}
template <typename T>
[[nodiscard]] std::vector<T*> FindVisible(not_null<QWidget*> root) {
auto result = FindAll<T>(root);
result.erase(
ranges::remove_if(result, [](T *widget) {
return !static_cast<QWidget*>(widget)->isVisible();
}),
end(result));
return result;
}
[[nodiscard]] QWidget *FindByObjectName(
not_null<QWidget*> root,
const QString &name);
// In-situ overlay hooks can publish the exact live object created by the
// product instead of rediscovering layer-owned/custom widgets from a window
// tree. Every replacement advances |generation|; dead QObjects resolve as
// unavailable. Publish calls are runtime no-ops unless Test::Active().
struct LiveWidgetSnapshot {
QWidget *widget = nullptr;
int generation = 0;
};
struct LiveActionSnapshot {
bool available = false;
int generation = 0;
int invocationCount = 0;
bool repeatable = false;
};
void PublishLiveWidget(
const QString &key,
not_null<QWidget*> widget);
[[nodiscard]] LiveWidgetSnapshot ReadLiveWidget(const QString &key);
void PublishLiveAction(
const QString &key,
not_null<QObject*> context,
Fn<void()> action,
bool repeatable = false);
[[nodiscard]] LiveActionSnapshot ReadLiveAction(const QString &key);
[[nodiscard]] bool InvokeLiveAction(const QString &key);
// Input delivery and settlement contract.
//
// The input helpers deliver their events synchronously with
// QApplication::sendEvent from the calling (runner-stage) context. During
// each dispatch postponed-call processing is deferred, so a product
// fix-up queued by the event can never run mid-signal-emission, where
// Qt's re-entrant document machinery would swallow its own change
// notifications. After each delivered event every pending
// Ui::PostponeCall (and any calls those queue) runs to empty in a
// top-level context — the state real user input reaches at its own
// event's unwind.
//
// Why the defer and the explicit drain: Core::Sandbox tags each
// Ui::PostponeCall with the loop-nesting level current at queue time and
// runs it only at the unwind of an event whose nesting level matches
// that tag, and only while it sits newest in the queue. Under the
// harness's synthetic nesting a fix-up either starves outright or runs
// at a matching internal unwind INSIDE the sent event's own signal
// emission — both unlike real top-level input.
//
// Guarantee: when a helper returns, postponed text fix-ups (for example
// Ui::CreateTonAmountInput's FixTonAmountInput rewrite) have run AND
// their own change handling has run, so both the widget's document and
// the field's cached text state (InputField::getLastText) show the
// product's rewritten text. The drain covers Ui::PostponeCall only:
// crl::on_main, InvokeQueued, base::Timer and network completions still
// need bounded waits. If a drained call destroys the target widget, the
// helper skips its remaining events and returns.
//
// Pointer model: the harness has no pointer, so Click and Drag end
// with a QEvent::Leave, which leaves the widget they were given
// pointerless. Give them the widget that accepts the press: an ignored
// mouse event walks up the parent chain in QApplication::notify, so a
// click aimed at a non-accepting child sets Over on the ancestor
// button that took the press while the leave reaches only the child,
// and that ancestor stays hovered. The leave is also inert while
// StateFlag::Down is still set, because leaveEventHook returns before
// setOver on that branch. Without the leave
// Ui::AbstractButton::mousePressEvent's
// checkIfOver() latches StateFlag::Over for the rest of the process,
// Ui::RoundButton::paintEvent keeps painting textBgOver, and a later
// Test::DeriveBand under the style's normal fill returns ok=0 with no
// rows — an absence reading for a widget plainly on screen. Qt does
// not re-deliver an unhandled Leave to ancestors the way it
// re-delivers an ignored mouse or key event, so the leave stays on the
// target — but on the target it is an ordinary event. It runs that
// widget's own leaveEventHook, its direct parent's
// enterFromChildEvent, every subscriber of the target's
// RpWidget::events() stream, and every installed event filter. Two
// that matter: Ui::Menu::ItemBase deselects a menu entry on Leave, so
// clicking one no longer leaves it highlighted; and Ui::Tooltip keeps
// an application-wide filter for the life of the process, so any
// synthetic click starts its hide-by-leave timer. A widget that was
// never hovered keeps its Over unchanged, because setOver returns
// early on an unchanged value.
//
// Use Test::Settle for programmatic mutations; SettlePostponedCalls
// remains the bare drain.
// Synthesizes a full mouse press + release on the widget, at its center by
// default, then a QEvent::Leave, so a completed click leaves no hover
// behind. Drives the same event path as a real click.
void Click(not_null<QWidget*> widget, std::optional<QPoint> point = {});
// Synthesizes key press + release pairs carrying one Unicode grapheme at a
// time. Surrogate pairs and joined emoji are never split between events.
void TypeText(not_null<QWidget*> widget, const QString &text);
// Delivers one input-method commit. Prefer this when the behavior under test
// is text insertion itself rather than physical key handling.
void CommitText(not_null<QWidget*> widget, const QString &text);
// Synthesizes a left-button drag in widget-local coordinates. Intermediate
// mouse moves retain Qt::LeftButton in buttons(), matching a real drag.
// Like Click, it ends with a QEvent::Leave, so the drag leaves no hover
// behind.
void Drag(
not_null<QWidget*> widget,
QPoint from,
QPoint to,
int steps = 8);
// Synthesizes a wheel event at |point| in the named widget's coordinates
// (the widget centre by default). |angleDelta| uses Qt's native
// eighths-of-a-degree convention (120 is one wheel step).
//
// A stack-built QWheelEvent sent with QApplication::sendEvent is not
// spontaneous, and Qt 6's QApplication::notify therefore does not climb
// parentWidget() the way a real wheel does. The helper replays that
// ladder itself: a fresh event per widget, accepted reset by the
// constructor, continuing while sendEvent returns false or the event is
// ignored, stopping at isWindow() or Qt::WA_NoMousePropagation.
// QAbstractScrollArea::event returns false for Wheel without ignoring,
// so a delivery to the scroll area itself is recorded in |inert| and is
// never |delivered|. The viewport is the widget that actually consumes
// a wheel aimed at a covering child. The helper neither Notes nor Fails;
// the caller reads the return, as with WindowActivation.
struct WheelDelivery {
bool delivered = false;
QString receiver;
QString inert;
QString refusal;
};
WheelDelivery Wheel(
not_null<QWidget*> widget,
QPoint angleDelta,
std::optional<QPoint> point = {});
[[nodiscard]] QString WheelDeliveryDetails(const WheelDelivery &reading);
void PressKey(
not_null<QWidget*> widget,
int key,
Qt::KeyboardModifiers modifiers = Qt::NoModifier);
// Performs the action with postponed-call processing deferred, then runs
// every pending Ui::PostponeCall to empty. The input helpers use it
// around each delivered event; wrap programmatic text mutations
// (InputField::setText) in it.
void Settle(Fn<void()> action);
// Runs every pending Ui::PostponeCall (see the contract above) — the
// bare drain, with no defer around anything.
void SettlePostponedCalls();
// Injects window activation through the QPA seam the platform plugin itself
// reports through, so a focus-routed affordance can be driven on a console
// where the window is never active in Qt's sense.
//
// QWidget::setFocus() walks the focus_child chain unconditionally, but only
// its if (f->isActiveWindow()) branch promotes the target to
// QApplication::focusWidget(), and isActiveWindow() ends in a fallback to
// QPlatformWindow::isActive() (qwidget.cpp:6723-6725) - so it takes the
// platform window not being active, on a locked or unattended console, for
// setFocus() to "succeed" while hasFocus() and isActiveWindow() keep reading
// false - no error, no event, just absence - and every affordance routed on
// them silently does nothing. Clearing only the QPA focus window does not
// reproduce that where the OS window is still active. Runs 2 and 7 of
// 2026/08/30/replace-wallet-with-new-or-imported paid for that.
// Window::Controller::activate() (window/window_controller.h:118) is not the
// answer: it asks the window manager, which on a locked console does not
// comply, which is why the injection goes through the QPA seam instead.
//
// Run 7 added the second half: the platform de-activates the window again
// asynchronously, between event-loop turns. In that one run an activation
// used in the same synchronous block as its dependent action worked, while
// a one-shot activation read back across polled turns was already gone. So
// a one-shot arrangement expected to survive across polled turns is a
// forbidden technique - assert activation in the same turn as the action,
// and re-assert it on every poll of a bounded wait.
//
// Calling ForceWindowActive from a stage's until is a deliberate, narrow
// exception to the README's stage contract, which says a readiness must be
// pure: it mutates only which window Qt considers focused, never product
// state, it is idempotent and repeatable, and it encodes no expected
// product result. Its note is capped to every kActivationNoteEvery-th call,
// and |attempts| and |notes| are process-wide counts carried in every
// reading, so the flake stays visible without flooding a 50ms-tick log.
inline constexpr auto kActivationNoteEvery = 10;
// For the values ForceWindowActive and ClearWindowActive return, |injected|
// is true exactly when |refusal| is empty, and a refusal is returned rather
// than logged: only the caller knows whether a window the helper could not
// resolve is a failure of the run or an expected reading.
// ReadWindowActivation injects nothing, so it always answers injected=false
// and fills |refusal| only when there is no window to activate through -
// never read its |injected| as "activation is in force".
struct WindowActivation {
bool injected = false;
bool focusWindowSet = false;
bool activeWindow = false;
int attempts = 0;
int notes = 0;
QString identity;
QString refusal;
};
[[nodiscard]] WindowActivation ReadWindowActivation(
QWidget *widget = nullptr);
WindowActivation ForceWindowActive(QWidget *widget = nullptr);
// Deliberately clears the application's focus window through the same seam,
// so a self-test can reach the failing shape instead of describing it. Never
// call this from a scenario, and never leave a stage with it in force.
WindowActivation ClearWindowActive();
// The one formatter both the helper's own note and every self-test Check
// detail print, so a pass and a refusal carry the same fields. It takes the
// reading rather than re-reading, because |attempts| and |notes| move on
// every call and a re-read would print numbers no verdict was made against.
[[nodiscard]] QString WindowActivationDetails(
const WindowActivation &reading);
} // namespace Test