Files
tdesktop/Telegram/SourceFiles/test/test_hover.cpp
John Preston 6f3934633a [ai] Clear the hover a synthesized click left behind
Task: 2026/08/28/clear-hover-state-after-a-synthesized-click
2026-08-28 13:11:19 +04:00

530 lines
17 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
*/
#ifdef _DEBUG
#include "test/test_hover.h"
#include "base/unique_qptr.h"
#include "core/application.h"
#include "test/test_capture.h"
#include "test/test_ink.h"
#include "test/test_log.h"
#include "test/test_runner.h"
#include "test/test_style.h"
#include "test/test_widgets.h"
#include "ui/widgets/buttons.h"
#include "ui/qt_object_factory.h"
#include "ui/rp_widget.h"
#include "window/window_controller.h"
#include <QtGui/QPainter>
#include "styles/palette.h"
#include "styles/style_widgets.h"
namespace Test {
namespace {
constexpr auto kStyleStableFor = crl::time(200);
constexpr auto kStyleDeadline = crl::time(5000);
// Test::PillBand insets the derived fill region by box.height() / 2 on the
// left and on the right, and keeps the inset band only while it stays at
// least 8 device pixels wide. A button narrower than its own height plus 8
// would therefore be measured through its un-inset box, rounded caps and
// the surface behind them included. Four times the style height clears that
// at every interface scale and leaves a band three heights wide, which
// comfortably contains the centred label the ink scan reads.
constexpr auto kFixtureWidthFactor = 4;
struct Fixture {
base::unique_qptr<Ui::RpWidget> container;
Ui::RoundButton *subject = nullptr;
Ui::RoundButton *control = nullptr;
};
struct FillReading {
bool over = false;
bool down = false;
bool normalOk = false;
bool overOk = false;
int normalRows = 0;
int overRows = 0;
int normalFillRows = 0;
int overFillRows = 0;
int inkPixels = 0;
QString resolved;
QString normalReason;
QString overReason;
QString inkReport;
};
// The literal DeriveBand answers with when no row of the box it was given
// reads as the fill it was given. Kept here so the self-test asserts the
// refusal text a scenario actually sees, and so loosening it in test_ink.cpp
// fails this module instead of passing quietly.
[[nodiscard]] QString NoFillRowsReason() {
return u"no row of the recovered box has the pill fill "
u"as its own background"_q;
}
[[nodiscard]] QString ResolvedFill(bool normalOk, bool overOk) {
if (normalOk && overOk) {
return u"ambiguous"_q;
} else if (normalOk) {
return u"textBg"_q;
} else if (overOk) {
return u"textBgOver"_q;
}
return u"none"_q;
}
[[nodiscard]] FillReading ReadBands(
const DerivedBand &normal,
const DerivedBand &over) {
auto result = FillReading();
result.normalOk = normal.ok;
result.overOk = over.ok;
result.normalRows = int(normal.rows.size());
result.overRows = int(over.rows.size());
result.normalFillRows = normal.fillRows;
result.overFillRows = over.fillRows;
result.normalReason = normal.reason;
result.overReason = over.reason;
result.resolved = ResolvedFill(normal.ok, over.ok);
return result;
}
[[nodiscard]] FillReading ReadFill(
not_null<Ui::RoundButton*> button,
const QImage &image) {
// A grab holds logical-size x devicePixelRatio() device pixels, so the
// button's logical geometry is multiplied by that ratio exactly once,
// here at the sampling boundary, and every band below indexes device
// space. Doing it anywhere else, or twice, samples the wrong rectangle
// at every interface scale other than 100%.
const auto ratio = image.devicePixelRatio();
const auto logical = button->geometry();
const auto box = QRectF(
logical.x() * ratio,
logical.y() * ratio,
logical.width() * ratio,
logical.height() * ratio).toRect();
// One ink candidate, textFg, and never the two fills: kSameTolerance
// governs ink separation while kBackgroundSame governs the fill, and a
// real button style keeps its two fills far closer than kSameTolerance
// on purpose. With a lone candidate Separable() is trivially true and
// CollisionDump() answers "none", so the ink side carries no collision
// the fill side would then have to explain away.
const auto &st = button->st();
const auto measured = MeasurePaintedInk(
image,
box,
st.textBg->c,
{ { u"textFg"_q, st.textFg->c } });
auto result = ReadBands(
measured.derived,
DeriveBand(image, box, st.textBgOver->c));
result.over = button->isOver();
result.down = button->isDown();
result.inkPixels = measured.scan.inkPixels;
result.inkReport = measured.report;
return result;
}
[[nodiscard]] QString DescribeBand(
const QString &label,
bool ok,
int rows,
int fillRows,
const QString &reason) {
return u"%1[ok=%2 rows=%3 fillRows=%4 reason=%5]"_q
.arg(label)
.arg(ok ? 1 : 0)
.arg(rows)
.arg(fillRows)
.arg(reason);
}
[[nodiscard]] QString Describe(
const QString &label,
const FillReading &reading) {
return u"%1: resolved=%2 isOver=%3 isDown=%4 %5 %6 inkPx=%7 ink=%8"_q
.arg(label, reading.resolved)
.arg(reading.over ? 1 : 0)
.arg(reading.down ? 1 : 0)
.arg(
DescribeBand(
u"normal"_q,
reading.normalOk,
reading.normalRows,
reading.normalFillRows,
reading.normalReason),
DescribeBand(
u"over"_q,
reading.overOk,
reading.overRows,
reading.overFillRows,
reading.overReason))
.arg(reading.inkPixels)
.arg(reading.inkReport.isEmpty() ? u"none"_q : reading.inkReport);
}
[[nodiscard]] QString DescribeImage(const QImage &image) {
return u"size=%1x%2 devicePixelRatio=%3"_q
.arg(image.width())
.arg(image.height())
.arg(image.devicePixelRatio());
}
void CheckNormalFill(
const FillReading &reading,
const QString &what,
const QString &details) {
Check(
(reading.resolved == u"textBg"_q)
&& reading.normalOk
&& (reading.normalRows > 0)
&& !reading.overOk
&& (reading.inkPixels >= kMinInkPixels),
what,
details);
}
[[nodiscard]] bool BuildFixture(Fixture &fixture) {
const auto window = Core::App().activePrimaryWindow();
if (!window) {
return false;
}
const auto height = st::defaultActiveButton.height;
const auto width = height * kFixtureWidthFactor;
const auto skip = height / 2;
fixture.container = base::make_unique_q<Ui::RpWidget>(
window->widget().get());
const auto container = fixture.container.get();
container->setGeometry(0, 0, width + skip * 2, height * 2 + skip * 3);
container->paintOn([=](QPainter &p) {
p.fillRect(container->rect(), st::windowBgOver);
});
// The container is deliberately never shown. Ui::GrabWidgetToImage
// renders a not-visible source explicitly and marks its opaque children
// dirty afterwards, so a hidden fixture still paints into the grab -
// while a hidden fixture can never be entered by the real cursor, which
// would otherwise set Over on the never-touched control and destroy the
// only thing that control is there to prove. Its opaque paint handler is
// load-bearing for the same reason: the grab's uncovered pixels have to
// read as the fixture's own background rather than as the harness base.
// The two buttons are shown, because Qt skips an explicitly hidden child
// when it renders a widget's children.
const auto add = [&](int top) {
const auto button = Ui::CreateChild<Ui::RoundButton>(
container,
rpl::single(u"Measure"_q),
st::defaultActiveButton);
button->setFullWidth(width);
button->setGeometry(skip, top, width, height);
button->show();
return button;
};
fixture.subject = add(skip);
fixture.control = add(skip * 2 + height);
return true;
}
} // namespace
void AppendClickHoverSelfTest(not_null<Runner*> runner) {
struct State {
Fixture fixture;
QString control;
QString afterClick;
bool built = false;
};
// Leaked on purpose, the way the harness's other self-tests leak theirs:
// the stages outlive this call. The teardown stage releases the fixture,
// after which the State holds nothing but QStrings and PODs - no rpl
// subscription to anything the session owns.
const auto state = new State();
const auto probe = [] {
const auto &st = st::defaultActiveButton;
return std::vector<StyleSample>{
{ .label = u"textBg"_q, .color = st.textBg->c },
{ .label = u"textBgOver"_q, .color = st.textBgOver->c },
{ .label = u"textFg"_q, .color = st.textFg->c },
};
};
runner->add({
.name = u"click hover self-test: positive"_q,
.run = [=] {
state->built = BuildFixture(state->fixture);
Check(
state->built,
u"fixture gate: the self-test fixture was built"_q,
state->built
? QString()
: u"Core::App().activePrimaryWindow() is null"_q);
const auto &st = st::defaultActiveButton;
const auto delta = ChannelDelta(st.textBg->c, st.textBgOver->c);
Check(
delta > kBackgroundSame,
u"fixture gate: the live style's normal and over fills "
"are separable"_q,
u"textBg=%1 textBgOver=%2 delta=%3 kBackgroundSame=%4"_q
.arg(st.textBg->c.name(), st.textBgOver->c.name())
.arg(delta)
.arg(kBackgroundSame));
},
.until = [=] {
return StyleSettled(
u"click-hover-self-test"_q,
probe,
kStyleStableFor,
kStyleDeadline);
},
.then = [=] {
if (!state->built) {
return;
}
// One grab, measured and saved: the evidence PNG is the frame
// the readings came out of, never a second one taken later.
const auto image = GrabWidget(state->fixture.container.get());
Check(
!LooksBlank(image),
u"fixture gate: the fixture grab is not blank"_q,
DescribeImage(image));
SaveImage(image, u"click_hover_before"_q);
const auto subject = ReadFill(state->fixture.subject, image);
const auto control = ReadFill(state->fixture.control, image);
const auto subjectText = Describe(u"subject"_q, subject);
state->control = Describe(u"control"_q, control);
Note(subjectText);
Note(state->control);
Check(
!subject.over && !subject.down,
u"before any input the subject button is neither over "
"nor down"_q,
subjectText);
CheckNormalFill(
subject,
u"before any input the subject button's painted fill "
"resolves as the style's normal textBg"_q,
subjectText);
Check(
!control.over && !control.down,
u"before any input the control button is neither over "
"nor down"_q,
state->control);
CheckNormalFill(
control,
u"before any input the never-touched control's painted "
"fill resolves as the style's normal textBg"_q,
state->control);
},
});
runner->add({
.name = u"click hover self-test: negative"_q,
.run = [=] {
// The public Ui::AbstractButton::setSynteticOver, not a
// Test::Click: this stage provokes the bad reading directly, so
// that it is reproduced on both legs and the recovery stage's
// success is shown to be discriminating rather than universal.
// It starts no ripple - RippleButton::onStateChanged returns
// before the ripple branch while down == wasDown.
if (const auto subject = state->fixture.subject) {
subject->setSynteticOver(true);
}
},
.then = [=] {
if (!state->built) {
return;
}
const auto subject = state->fixture.subject;
const auto image = GrabWidget(state->fixture.container.get());
const auto reading = ReadFill(subject, image);
const auto text = Describe(u"subject hovered"_q, reading);
Note(text);
Check(
(reading.resolved == u"textBgOver"_q)
&& reading.over
&& !reading.normalOk
&& (reading.normalRows == 0)
&& (reading.normalFillRows == 0),
u"a hovered button paints the style's textBgOver, and "
"DeriveBand under textBg refuses it with no rows"_q,
text);
Check(
reading.normalReason == NoFillRowsReason(),
u"the refused band says why it could not be derived "
"instead of reporting an absence"_q,
u"reason=%1"_q.arg(reading.normalReason));
subject->setSynteticOver(false);
Check(
!subject->isOver() && !subject->isDown(),
u"the deliberate hover is cleared again before the repair "
"itself is exercised"_q,
u"isOver=%1 isDown=%2"_q
.arg(subject->isOver() ? 1 : 0)
.arg(subject->isDown() ? 1 : 0));
},
});
runner->add({
.name = u"click hover self-test: recovery"_q,
.run = [=] {
if (!state->built) {
return;
}
const auto subject = state->fixture.subject;
const auto container = state->fixture.container.get();
// finishAnimating() is why the reading is deterministic, not
// why it passes. Every press starts a RippleButton ripple:
// RoundButton::prepareRippleStartPosition derives its origin
// from the real QCursor::pos() with no containment guard, the
// style's ripple colour is opaque, and the animation runs for
// showDuration plus hideDuration - so a grab taken inside that
// window feeds ripple pixels to the rows DeriveBand takes the
// modal of, on a schedule that differs per run and per machine.
// RippleButton::finishAnimating() only resets the ripple; it
// touches no state flag, so it can neither clear Over nor hide
// the defect this stage exists to catch, and it is applied
// identically on both legs.
Click(subject);
subject->finishAnimating();
const auto clicked = ReadFill(subject, GrabWidget(container));
state->afterClick = Describe(u"subject"_q, clicked);
Note(state->afterClick);
Check(
!clicked.over && !clicked.down,
u"a completed synthetic click leaves its target neither "
"over nor down"_q,
state->afterClick);
CheckNormalFill(
clicked,
u"the clicked button's painted fill resolves as the "
"style's normal textBg, not as an absence"_q,
state->afterClick);
const auto inner = subject->rect();
Drag(
subject,
QPoint(inner.width() / 4, inner.height() / 2),
QPoint((inner.width() * 3) / 4, inner.height() / 2));
subject->finishAnimating();
},
.then = [=] {
if (!state->built) {
return;
}
const auto image = GrabWidget(state->fixture.container.get());
Check(
!LooksBlank(image),
u"fixture gate: the post-input grab is not blank"_q,
DescribeImage(image));
SaveImage(image, u"click_hover_after"_q);
const auto dragged = ReadFill(state->fixture.subject, image);
const auto control = ReadFill(state->fixture.control, image);
const auto draggedText = Describe(u"subject"_q, dragged);
const auto controlText = Describe(u"control"_q, control);
Note(draggedText);
Note(controlText);
Check(
!dragged.over && !dragged.down,
u"a completed synthetic drag whose endpoints are both "
"inside the widget leaves it neither over nor down"_q,
draggedText);
CheckNormalFill(
dragged,
u"the dragged button's painted fill resolves as the "
"style's normal textBg, not as an absence"_q,
draggedText);
Check(
draggedText == state->afterClick,
u"Test::Drag leaves exactly what Test::Click leaves: the "
"two readings are identical"_q,
u"afterClick=[%1] afterDrag=[%2]"_q
.arg(state->afterClick, draggedText));
Check(
controlText == state->control,
u"the never-touched control reads exactly as it did "
"before any input, so the change is about the clicked "
"widget and not about the measurement"_q,
u"before=[%1] after=[%2]"_q
.arg(state->control, controlText));
},
});
runner->add({
.name = u"click hover self-test: refusal text"_q,
.run = [] {
// The negative of this stage is an implementation that made the
// recovery stage pass by loosening kBackgroundSame or by having
// DeriveBand certify a band it could not derive. On an image
// that is nothing but the over fill, the refusal and the
// certification must both still read exactly as they did.
const auto &st = st::defaultActiveButton;
const auto box = QRect(
0,
0,
st.height * kFixtureWidthFactor,
st.height);
auto image = QImage(
box.size(),
QImage::Format_ARGB32_Premultiplied);
image.fill(st.textBgOver->c);
const auto refused = DeriveBand(image, box, st.textBg->c);
const auto certified = DeriveBand(image, box, st.textBgOver->c);
const auto reading = ReadBands(refused, certified);
const auto text = Describe(u"refusal text"_q, reading);
Note(text);
Check(
!refused.ok
&& refused.rows.empty()
&& (refused.reason == NoFillRowsReason()),
u"DeriveBand still refuses a band it cannot derive, with "
"no rows and the reason that names why"_q,
text);
Check(
certified.ok && !certified.rows.empty(),
u"DeriveBand still certifies the band it can derive, so "
"the refusal discriminates instead of refusing every "
"reading"_q,
text);
Check(
text.contains(u"normal[ok="_q)
&& text.contains(u"over[ok="_q)
&& text.contains(u"reason="_q),
u"the reading names both bands it judged and the reason "
"each one carries"_q,
text);
},
});
runner->add({
.name = u"click hover self-test: teardown"_q,
.run = [=] {
// Last on purpose. A timed-out stage or the watchdog skips
// every stage after it, so anything still held here would
// outlive the run: the fixture is parented to the primary
// window and would keep two Ui::RoundButtons alive inside it
// for the rest of the process. Releasing the unique_qptr
// destroys the container and with it both buttons, which is
// why the raw back-pointers are dropped in the same breath.
state->fixture.container = nullptr;
state->fixture.subject = nullptr;
state->fixture.control = nullptr;
Note(u"click hover self-test: fixture released, alive=%1"_q
.arg(state->fixture.container ? 1 : 0));
},
});
}
} // namespace Test
#endif // _DEBUG