1065 lines
37 KiB
C++
1065 lines
37 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_toast_capture.h"
|
|
|
|
#include "base/unique_qptr.h"
|
|
#include "base/weak_ptr.h"
|
|
#include "core/application.h"
|
|
#include "test/test_capture.h"
|
|
#include "test/test_ink.h"
|
|
#include "test/test_log.h"
|
|
#include "test/test_text_reads.h"
|
|
#include "test/test_widgets.h"
|
|
#include "ui/toast/toast.h"
|
|
#include "ui/toast/toast_widget.h"
|
|
#include "ui/widgets/labels.h"
|
|
#include "ui/rp_widget.h"
|
|
#include "window/window_controller.h"
|
|
|
|
#include <QtCore/QStringList>
|
|
#include <QtGui/QPainter>
|
|
#include <QtWidgets/QApplication>
|
|
|
|
#include "styles/palette.h"
|
|
#include "styles/style_widgets.h"
|
|
|
|
namespace Test {
|
|
namespace {
|
|
|
|
constexpr auto kToastSettledSamples = 32;
|
|
constexpr auto kToastSettledMinPermille = 300;
|
|
constexpr auto kToastSettledToleranceDivisor = 16;
|
|
constexpr auto kToastSettledToleranceMin = 4;
|
|
constexpr auto kSentinelTolerance = 40;
|
|
constexpr auto kSentinelMinSeparation = 2 * kSentinelTolerance;
|
|
|
|
const auto kToastText = u"Harness toast subtree"_q;
|
|
const auto kOtherText = u"Harness toast elsewhere"_q;
|
|
const auto kExpiryText = u"Harness toast expiry"_q;
|
|
const auto kFirstSentinel = QColor(255, 255, 65);
|
|
const auto kSecondSentinel = QColor(192, 0, 192);
|
|
|
|
[[nodiscard]] QColor SettledToastBackground() {
|
|
const auto over = st::toastBg->c;
|
|
const auto base = st::windowBg->c;
|
|
const auto alpha = float64(over.alphaF());
|
|
const auto blend = [&](int source, int destination) {
|
|
return int(std::round(
|
|
(source * alpha) + (destination * (1. - alpha))));
|
|
};
|
|
return QColor(
|
|
blend(over.red(), base.red()),
|
|
blend(over.green(), base.green()),
|
|
blend(over.blue(), base.blue()));
|
|
}
|
|
|
|
[[nodiscard]] int SettledSeparation() {
|
|
return ChannelDelta(SettledToastBackground(), st::windowBg->c);
|
|
}
|
|
|
|
[[nodiscard]] int SettledTolerance() {
|
|
return std::max(
|
|
kToastSettledToleranceMin,
|
|
SettledSeparation() / kToastSettledToleranceDivisor);
|
|
}
|
|
|
|
struct SentinelSeparation {
|
|
QColor blend;
|
|
int windowBg = 0;
|
|
int settled = 0;
|
|
int toastFg = 0;
|
|
int minimum = 0;
|
|
};
|
|
|
|
// Why the sentinel counter cannot be flipped by a palette, and why the
|
|
// number is measured here instead of asserted in a comment. Every colour a
|
|
// toast-rooted frame can hold is a grey or within a few channel units of
|
|
// one: Ui::GrabWidgetToImage fills the frame with st::windowBg, the toast
|
|
// paints st::toastBg's settled blend over that, its label paints
|
|
// st::toastFg ink, and every antialiased mixture lies between them.
|
|
// ChannelDelta is the maximum absolute per-channel difference
|
|
// (test_ink.cpp:96-104), so the nearest grey to a colour sits exactly half
|
|
// that colour's own channel span away: kFirstSentinel spans 190 and
|
|
// kSecondSentinel spans 192, which puts every grey at least 95 from either
|
|
// and every near-grey at least about 92, in a light palette and a dark one
|
|
// alike. An earlier pair was picked against one palette and collided with
|
|
// the other theme's harness base, which is what this gate exists to say out
|
|
// loud rather than fold into a verdict on a correct frame.
|
|
[[nodiscard]] SentinelSeparation MeasureSentinelSeparation() {
|
|
const auto distance = [](QColor color) {
|
|
return std::min(
|
|
ChannelDelta(color, kFirstSentinel),
|
|
ChannelDelta(color, kSecondSentinel));
|
|
};
|
|
auto result = SentinelSeparation{
|
|
.blend = SettledToastBackground(),
|
|
};
|
|
result.windowBg = distance(st::windowBg->c);
|
|
result.settled = distance(result.blend);
|
|
result.toastFg = distance(st::toastFg->c);
|
|
result.minimum = std::min({
|
|
result.windowBg,
|
|
result.settled,
|
|
result.toastFg });
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QString SentinelSeparationText(
|
|
const SentinelSeparation &separation) {
|
|
const auto probe = [](const QString &label, QColor color, int delta) {
|
|
return u"%1 %2 at %3"_q.arg(label, color.name()).arg(delta);
|
|
};
|
|
return u"tones %1 and %2 - minimum separation %3, required at least %4 "
|
|
u"(counter tolerance %5) - %6, %7, %8"_q
|
|
.arg(kFirstSentinel.name(), kSecondSentinel.name())
|
|
.arg(separation.minimum)
|
|
.arg(kSentinelMinSeparation)
|
|
.arg(kSentinelTolerance)
|
|
.arg(
|
|
probe(u"windowBg"_q, st::windowBg->c, separation.windowBg),
|
|
probe(
|
|
u"settled toast blend"_q,
|
|
separation.blend,
|
|
separation.settled),
|
|
probe(u"toastFg"_q, st::toastFg->c, separation.toastFg));
|
|
}
|
|
|
|
struct ToastFrame {
|
|
QImage image;
|
|
int settledPermille = 0;
|
|
int separation = 0;
|
|
bool settled = false;
|
|
};
|
|
|
|
// The one statistic that separates a settled toast from a fading one, and
|
|
// the reason it is a floor on a match count rather than a mean or a
|
|
// minimum: at full show paintEvent calls paintBackground(p) directly
|
|
// (toast_widget.cpp:585-600), so the background pixels are exactly
|
|
// st::toastBg composited over the grab's st::windowBg fill, while at shown
|
|
// level s every one of them is that same colour lerped back towards the
|
|
// fill and none of them matches. The toast's transparent corners and its
|
|
// opaque label ink match at no level at all and only ever lower the count,
|
|
// so a mean or a minimum would measure them instead of the fade.
|
|
[[nodiscard]] ToastFrame ReadToastFrame(const ToastSubtreeReading &reading) {
|
|
auto result = ToastFrame();
|
|
result.separation = SettledSeparation();
|
|
if (!reading.resolved()) {
|
|
return result;
|
|
}
|
|
result.image = GrabRect(reading.toast.data(), reading.frame);
|
|
const auto size = result.image.size();
|
|
const auto columns = std::min(size.width(), kToastSettledSamples);
|
|
const auto rows = std::min(size.height(), kToastSettledSamples);
|
|
if (columns < 1 || rows < 1) {
|
|
return result;
|
|
}
|
|
const auto settled = SettledToastBackground();
|
|
const auto tolerance = SettledTolerance();
|
|
auto matched = 0;
|
|
for (auto y = 0; y != rows; ++y) {
|
|
for (auto x = 0; x != columns; ++x) {
|
|
const auto point = QPoint(
|
|
((2 * x + 1) * size.width()) / (2 * columns),
|
|
((2 * y + 1) * size.height()) / (2 * rows));
|
|
const auto color = result.image.pixelColor(point);
|
|
if (ChannelDelta(color, settled) <= tolerance) {
|
|
++matched;
|
|
}
|
|
}
|
|
}
|
|
result.settledPermille = (1000 * matched) / (columns * rows);
|
|
result.settled = (result.settledPermille >= kToastSettledMinPermille);
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QString ToastSubtreeText(
|
|
const ToastSubtreeReading &reading,
|
|
const ToastFrame &frame) {
|
|
return u"toast-subtree capture: target=%1 frame=%2 settled=%3/1000 "
|
|
u"(threshold %4/1000, tolerance %5, separation %6) - the frame is "
|
|
u"rooted at the toast, so Ui::GrabWidgetToImage fills every pixel "
|
|
u"the toast leaves uncovered with st::windowBg and nothing the "
|
|
u"product painted beneath it can enter%7"_q
|
|
.arg(reading.identity.isEmpty() ? u"<none>"_q : reading.identity)
|
|
.arg(RectText(reading.frame))
|
|
.arg(frame.settledPermille)
|
|
.arg(kToastSettledMinPermille)
|
|
.arg(SettledTolerance())
|
|
.arg(frame.separation)
|
|
.arg(reading.refusal.isEmpty()
|
|
? QString()
|
|
: u" - %1"_q.arg(reading.refusal));
|
|
}
|
|
|
|
struct FrameDiff {
|
|
int differing = 0;
|
|
int maxDelta = 0;
|
|
bool comparable = false;
|
|
};
|
|
|
|
[[nodiscard]] FrameDiff CompareFrames(
|
|
const QImage &first,
|
|
const QImage &second) {
|
|
auto result = FrameDiff();
|
|
if (first.isNull() || second.isNull() || first.size() != second.size()) {
|
|
return result;
|
|
}
|
|
result.comparable = true;
|
|
for (auto y = 0; y != first.height(); ++y) {
|
|
for (auto x = 0; x != first.width(); ++x) {
|
|
const auto delta = ChannelDelta(
|
|
first.pixelColor(x, y),
|
|
second.pixelColor(x, y));
|
|
if (delta > 0) {
|
|
++result.differing;
|
|
result.maxDelta = std::max(result.maxDelta, delta);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] int SentinelPixels(const QImage &image) {
|
|
auto result = 0;
|
|
for (auto y = 0; y != image.height(); ++y) {
|
|
for (auto x = 0; x != image.width(); ++x) {
|
|
const auto color = image.pixelColor(x, y);
|
|
if (ChannelDelta(color, kFirstSentinel) <= kSentinelTolerance
|
|
|| ChannelDelta(color, kSecondSentinel)
|
|
<= kSentinelTolerance) {
|
|
++result;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QString ImageText(const QString &path, const QImage &image) {
|
|
return u"%1 (%2x%3 dpr=%4)"_q
|
|
.arg(path)
|
|
.arg(image.width())
|
|
.arg(image.height())
|
|
.arg(image.devicePixelRatio());
|
|
}
|
|
|
|
struct Fixture {
|
|
base::unique_qptr<Ui::RpWidget> sentinel;
|
|
base::weak_ptr<Ui::Toast::Instance> toast;
|
|
QPointer<QWidget> toastWidget;
|
|
};
|
|
|
|
[[nodiscard]] QString FixtureDetails(const Fixture &fixture) {
|
|
const auto toast = fixture.toastWidget.data();
|
|
if (!toast) {
|
|
return u"the fixture toast no longer exists"_q;
|
|
}
|
|
return u"toast visible=%1 sentinel=%2 - %3"_q
|
|
.arg(toast->isVisible() ? 1 : 0)
|
|
.arg((fixture.sentinel && !fixture.sentinel->isHidden()) ? 1 : 0)
|
|
.arg(ToastSubtreeDetails(toast));
|
|
}
|
|
|
|
// Two tones rather than one, for the reason test_via_window.cpp:65-68
|
|
// gives: with a single tone the window-mapped control's non-blankness
|
|
// would rest on whatever the toast itself managed to paint, which is the
|
|
// thing under measurement. Their lightness is 160 against 96, a spread of
|
|
// 64 against the harness's kBlankSpreadThreshold of 6, so a window-mapped
|
|
// crop of this region clears the blank threshold on the bands alone.
|
|
void PaintSentinelBands(not_null<Ui::RpWidget*> widget) {
|
|
const auto raw = widget.get();
|
|
raw->paintOn([=](QPainter &p) {
|
|
const auto half = raw->height() / 2;
|
|
p.fillRect(QRect(0, 0, raw->width(), half), kFirstSentinel);
|
|
p.fillRect(
|
|
QRect(0, half, raw->width(), raw->height() - half),
|
|
kSecondSentinel);
|
|
});
|
|
}
|
|
|
|
[[nodiscard]] bool BuildFixture(Fixture &fixture) {
|
|
const auto window = Core::App().activePrimaryWindow();
|
|
if (!window) {
|
|
return false;
|
|
}
|
|
const auto top = window->widget().get();
|
|
|
|
// Created before the toast on purpose, and never raised: siblings
|
|
// paint in child order, so the sentinel surface stays underneath it. A
|
|
// raise() would put the fixture over the toast and the window-mapped
|
|
// control would then measure the fixture's own paint instead of what
|
|
// the window holds where the toast is.
|
|
fixture.sentinel = base::make_unique_q<Ui::RpWidget>(top);
|
|
const auto sentinel = fixture.sentinel.get();
|
|
PaintSentinelBands(sentinel);
|
|
|
|
fixture.toast = Ui::Toast::Show(top, {
|
|
.text = { kToastText },
|
|
.st = &st::defaultToast,
|
|
.infinite = true,
|
|
});
|
|
const auto instance = fixture.toast.get();
|
|
if (!instance) {
|
|
return false;
|
|
}
|
|
// The toast Widget is parented to the same |top| (ui/toast/toast.cpp:
|
|
// 36-39), so it and the sentinel are siblings sharing one origin and
|
|
// geometry() maps between them with no conversion at all.
|
|
const auto widget = instance->widget().get();
|
|
fixture.toastWidget = widget;
|
|
sentinel->setGeometry(widget->geometry());
|
|
sentinel->show();
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::vector<QWidget*> FindLiveToasts() {
|
|
auto result = std::vector<QWidget*>();
|
|
const auto append = [&](QWidget *widget) {
|
|
for (const auto existing : result) {
|
|
if (existing == widget) {
|
|
return;
|
|
}
|
|
}
|
|
result.push_back(widget);
|
|
};
|
|
for (const auto top : QApplication::topLevelWidgets()) {
|
|
if (!top->isVisible()) {
|
|
continue;
|
|
}
|
|
if (dynamic_cast<Ui::Toast::internal::Widget*>(top)) {
|
|
append(top);
|
|
}
|
|
for (const auto toast
|
|
: FindVisible<Ui::Toast::internal::Widget>(top)) {
|
|
append(toast);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QWidget *FindLiveToast() {
|
|
const auto all = FindLiveToasts();
|
|
return (all.size() == 1) ? all.front() : nullptr;
|
|
}
|
|
|
|
ToastSubtreeReading ReadToastSubtree(QWidget *widget, QRect requested) {
|
|
if (!widget) {
|
|
return {
|
|
.refusal = u"no widget was handed to the toast-subtree "
|
|
u"capture"_q,
|
|
};
|
|
}
|
|
auto result = ToastSubtreeReading{
|
|
.identity = WidgetDescription(widget),
|
|
};
|
|
if (!dynamic_cast<Ui::Toast::internal::Widget*>(widget)) {
|
|
const auto live = FindLiveToast();
|
|
result.refusal = u"target is not a live "
|
|
u"Ui::Toast::internal::Widget, so a frame rooted at it would "
|
|
u"compose whatever it paints over: target=%1 liveToast=%2 - a "
|
|
u"child inside a toast is refused the same way, because the "
|
|
u"frame is rooted at the toast or nowhere"_q
|
|
.arg(
|
|
result.identity,
|
|
live ? WidgetDescription(live) : u"<none>"_q);
|
|
return result;
|
|
} else if (!widget->isVisible()) {
|
|
result.refusal = u"target is not visible: %1"_q.arg(result.identity);
|
|
return result;
|
|
} else if (widget->size().isEmpty()) {
|
|
result.refusal = u"target has empty geometry: %1"_q.arg(
|
|
result.identity);
|
|
return result;
|
|
}
|
|
const auto bounds = widget->rect();
|
|
if (!requested.isEmpty()) {
|
|
const auto misframed = MisframedDetails(widget, requested);
|
|
if (!misframed.isEmpty()) {
|
|
result.refusal = misframed;
|
|
return result;
|
|
}
|
|
}
|
|
result.frame = requested.isEmpty() ? bounds : requested;
|
|
result.toast = widget;
|
|
return result;
|
|
}
|
|
|
|
QImage GrabToastSubtree(QWidget *widget, QRect requested) {
|
|
const auto reading = ReadToastSubtree(widget, requested);
|
|
return reading.resolved()
|
|
? GrabRect(reading.toast.data(), reading.frame)
|
|
: QImage();
|
|
}
|
|
|
|
bool ToastSubtreeReady(QWidget *widget) {
|
|
const auto reading = ReadToastSubtree(widget);
|
|
return reading.resolved() && ReadToastFrame(reading).settled;
|
|
}
|
|
|
|
QString ToastSubtreeDetails(QWidget *widget) {
|
|
const auto reading = ReadToastSubtree(widget);
|
|
return ToastSubtreeText(reading, ReadToastFrame(reading));
|
|
}
|
|
|
|
bool CaptureToastSubtree(
|
|
not_null<QWidget*> widget,
|
|
const QString &name,
|
|
QRect requested) {
|
|
const auto reading = ReadToastSubtree(widget, requested);
|
|
if (!reading.resolved()) {
|
|
Fail(u"capture %1"_q.arg(name), reading.refusal);
|
|
return false;
|
|
}
|
|
LogGeometry(name, QRect(widget->mapToGlobal(QPoint()), widget->size()));
|
|
const auto frame = ReadToastFrame(reading);
|
|
if (!frame.settled) {
|
|
Note(u"capture %1 declined an unsettled frame: %2"_q
|
|
.arg(name, ToastSubtreeText(reading, frame)));
|
|
return false;
|
|
} else if (LooksBlank(frame.image)) {
|
|
Note(u"capture %1 declined a blank frame: %2"_q
|
|
.arg(name, ToastSubtreeText(reading, frame)));
|
|
return false;
|
|
}
|
|
return !SaveImage(frame.image, name).isEmpty();
|
|
}
|
|
|
|
QString ReadToastText(QWidget *widget) {
|
|
const auto toast = dynamic_cast<Ui::Toast::internal::Widget*>(widget);
|
|
if (!toast) {
|
|
return QString();
|
|
}
|
|
auto names = QStringList();
|
|
for (const auto label : FindAll<Ui::FlatLabel>(toast)) {
|
|
names.push_back(label->accessibilityName());
|
|
}
|
|
return names.join(u" | "_q);
|
|
}
|
|
|
|
void CheckToastReads(
|
|
QWidget *widget,
|
|
const QString &expected,
|
|
const QString &what) {
|
|
CheckTextReads(ReadToastText(widget), expected, what);
|
|
}
|
|
|
|
void AppendToastSubtreeCaptureSelfTest(not_null<Runner*> runner) {
|
|
struct State {
|
|
Fixture fixture;
|
|
QString midfadeDetails;
|
|
QPointer<QWidget> expiringWidget;
|
|
crl::time expiringShownAt = 0;
|
|
crl::time liveInsideElapsed = 0;
|
|
QString liveInsideDetails;
|
|
int liveCount = 0;
|
|
int failuresBefore = 0;
|
|
int failuresAfter = 0;
|
|
int singleAgainCount = 0;
|
|
int liveInsideCount = 0;
|
|
bool built = false;
|
|
bool liveHoldsToast = false;
|
|
bool liveResolvedSingle = false;
|
|
bool midfadeReady = false;
|
|
bool midfadeSaved = false;
|
|
bool singleAgainResolvedFixture = false;
|
|
bool expiringShown = false;
|
|
bool liveInsideReady = false;
|
|
bool liveInsideHoldsFixture = false;
|
|
bool liveInsideHoldsExpiring = 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 details = [=] {
|
|
return FixtureDetails(state->fixture);
|
|
};
|
|
const auto liveWalkDetails = [=] {
|
|
const auto all = FindLiveToasts();
|
|
auto texts = QStringList();
|
|
for (const auto widget : all) {
|
|
const auto text = ReadToastText(widget);
|
|
texts.push_back(text.isEmpty() ? u"<empty>"_q : text);
|
|
}
|
|
const auto resolved = FindLiveToast();
|
|
return u"liveToasts=%1 texts=[%2] resolved=%3"_q
|
|
.arg(int(all.size()))
|
|
.arg(texts.join(u"; "_q))
|
|
.arg(resolved ? WidgetDescription(resolved) : u"<none>"_q);
|
|
};
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: the fade-in frame is a Note and "
|
|
"never a FAIL, and the settled-show term is what says so"_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, or "
|
|
u"Ui::Toast::Show answered no instance"_q);
|
|
const auto separation = MeasureSentinelSeparation();
|
|
Check(
|
|
separation.minimum >= kSentinelMinSeparation,
|
|
u"fixture gate: on this palette both sentinel tones "
|
|
"stay clear of every colour a toast-rooted frame can "
|
|
"hold - the st::windowBg fill, st::toastBg's settled "
|
|
"blend over it and st::toastFg ink are all greys or "
|
|
"beside the grey axis, and the tones are not - so the "
|
|
"sentinel counter measures the frame and never the "
|
|
"theme"_q,
|
|
SentinelSeparationText(separation));
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
if (!state->built || !toast) {
|
|
return;
|
|
}
|
|
// Every reading below is taken here, in the one turn the
|
|
// fade-in window exists: the toast's shown level is still 0,
|
|
// so its paintEvent is on the proxy branch and a toast-rooted
|
|
// grab holds the harness base and nothing else. A tick later
|
|
// the fade has moved and the contrast is gone, which is why
|
|
// |then| asserts from these snapshots instead of re-measuring.
|
|
const auto live = FindLiveToasts();
|
|
state->liveCount = int(live.size());
|
|
for (const auto widget : live) {
|
|
if (widget == toast) {
|
|
state->liveHoldsToast = true;
|
|
}
|
|
}
|
|
state->liveResolvedSingle = (FindLiveToast() == toast);
|
|
state->midfadeDetails = ToastSubtreeDetails(toast);
|
|
state->midfadeReady = ToastSubtreeReady(toast);
|
|
state->failuresBefore = FailureCount();
|
|
state->midfadeSaved = CaptureToastSubtree(
|
|
toast,
|
|
u"toast_subtree_midfade"_q);
|
|
state->failuresAfter = FailureCount();
|
|
},
|
|
.then = [=] {
|
|
if (!state->built || !state->fixture.toastWidget) {
|
|
return;
|
|
}
|
|
Check(
|
|
state->liveHoldsToast && state->liveResolvedSingle,
|
|
u"the live-toast walk resolves the fixture toast across "
|
|
"the top level widgets, which is the only honest "
|
|
"resolution: Ui::Toast::internal::Manager keeps its "
|
|
"_toastByWidget map private and enumerates nothing"_q,
|
|
u"liveToasts=%1 holdsFixture=%2 resolvedSingle=%3"_q
|
|
.arg(state->liveCount)
|
|
.arg(state->liveHoldsToast ? 1 : 0)
|
|
.arg(state->liveResolvedSingle ? 1 : 0));
|
|
Check(
|
|
!state->midfadeReady,
|
|
u"the settled-show term answers false in the turn the "
|
|
"toast is created, so blankness stays diagnosable: the "
|
|
"shown level is private, and the term measures the grab "
|
|
"against st::toastBg's own settled blend over the harness "
|
|
"base instead of reading it"_q,
|
|
state->midfadeDetails);
|
|
Check(
|
|
!state->midfadeSaved
|
|
&& (state->failuresAfter == state->failuresBefore),
|
|
u"the mid-fade capture is declined with a Note and never a "
|
|
"FAIL, which is the contract a toast capture rests on"_q,
|
|
u"saved=%1 failures before=%2 after=%3"_q
|
|
.arg(state->midfadeSaved ? 1 : 0)
|
|
.arg(state->failuresBefore)
|
|
.arg(state->failuresAfter));
|
|
},
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: the frame carries the toast and "
|
|
"not the surface beneath it"_q,
|
|
.until = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
return !state->built || !toast || ToastSubtreeReady(toast);
|
|
},
|
|
.then = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
const auto sentinel = state->fixture.sentinel.get();
|
|
if (!state->built || !toast || !sentinel) {
|
|
return;
|
|
}
|
|
// The drain is load-bearing and not hygiene: a mid-fade
|
|
// paintEvent called disableChildrenPaintOnce(), which sets
|
|
// Qt::WA_UpdatesDisabled on the toast's children and schedules
|
|
// the restore as a Ui::PostponeCall (toast_widget.cpp:
|
|
// 556-583), and every frame below must be taken after that
|
|
// restore has run.
|
|
sentinel->setGeometry(toast->geometry());
|
|
SettlePostponedCalls();
|
|
|
|
const auto subtreeName = u"toast_subtree_settled"_q;
|
|
const auto viaName = u"toast_window_mapped_control"_q;
|
|
const auto subtreeSaved = CaptureToastSubtree(
|
|
toast,
|
|
subtreeName);
|
|
const auto viaSaved = CaptureViaWindow(toast, viaName);
|
|
const auto subtreePath = ScreenshotsDir()
|
|
+ subtreeName
|
|
+ u".png"_q;
|
|
const auto viaPath = ScreenshotsDir() + viaName + u".png"_q;
|
|
const auto subtreeImage = QImage(subtreePath);
|
|
const auto viaImage = QImage(viaPath);
|
|
const auto subtreeSentinel = SentinelPixels(subtreeImage);
|
|
const auto viaSentinel = SentinelPixels(viaImage);
|
|
const auto separation = MeasureSentinelSeparation();
|
|
Check(
|
|
subtreeSaved
|
|
&& viaSaved
|
|
&& !subtreeImage.isNull()
|
|
&& !viaImage.isNull()
|
|
&& !subtreeSentinel
|
|
&& (viaSentinel > 0),
|
|
u"the saved toast-subtree frame holds no pixel within %1 "
|
|
"channel units of either sentinel tone, while the "
|
|
"window-mapped frame of the same toast in the same turn "
|
|
"holds such pixels in the toast's transparent rounded "
|
|
"corners, where the surface beneath shows through "
|
|
"unblended"_q.arg(kSentinelTolerance),
|
|
u"subtree=%1 sentinelPixels=%2 saved=%3; windowMapped=%4 "
|
|
u"sentinelPixels=%5 saved=%6; minSentinelSeparation=%7 "
|
|
u"against tolerance %8, so the count is not vacuous"_q
|
|
.arg(ImageText(subtreePath, subtreeImage))
|
|
.arg(subtreeSentinel)
|
|
.arg(subtreeSaved ? 1 : 0)
|
|
.arg(ImageText(viaPath, viaImage))
|
|
.arg(viaSentinel)
|
|
.arg(viaSaved ? 1 : 0)
|
|
.arg(separation.minimum)
|
|
.arg(kSentinelTolerance));
|
|
|
|
const auto subtreeShown = GrabToastSubtree(toast);
|
|
const auto viaShown = GrabViaWindow(toast);
|
|
sentinel->hide();
|
|
SettlePostponedCalls();
|
|
const auto subtreeHidden = GrabToastSubtree(toast);
|
|
const auto viaHidden = GrabViaWindow(toast);
|
|
sentinel->show();
|
|
SettlePostponedCalls();
|
|
const auto subtreeDiff = CompareFrames(
|
|
subtreeShown,
|
|
subtreeHidden);
|
|
const auto viaDiff = CompareFrames(viaShown, viaHidden);
|
|
Check(
|
|
subtreeDiff.comparable
|
|
&& viaDiff.comparable
|
|
&& !subtreeDiff.differing
|
|
&& (viaDiff.differing > 0),
|
|
u"hiding the surface beneath the toast moves no pixel of "
|
|
"the toast-subtree frame and many pixels of the "
|
|
"window-mapped frame - the corner-independent form of the "
|
|
"same claim, and the stronger one: no pixel of the subtree "
|
|
"frame depends on what is painted under the toast"_q,
|
|
u"subtree differing=%1 maxDelta=%2 comparable=%3; "
|
|
u"windowMapped differing=%4 maxDelta=%5 comparable=%6"_q
|
|
.arg(subtreeDiff.differing)
|
|
.arg(subtreeDiff.maxDelta)
|
|
.arg(subtreeDiff.comparable ? 1 : 0)
|
|
.arg(viaDiff.differing)
|
|
.arg(viaDiff.maxDelta)
|
|
.arg(viaDiff.comparable ? 1 : 0));
|
|
|
|
CheckToastReads(
|
|
toast,
|
|
kToastText,
|
|
u"the toast's own phrase reads back through the joined "
|
|
"accessibilityName of its Ui::FlatLabels, compared by the "
|
|
"shipped Test::CheckTextReads"_q);
|
|
Note(u"toast-subtree self-test: the loud form of the negative "
|
|
u"below is Test::CheckToastReads, which would FAIL on that "
|
|
u"phrase; it is asserted through the shipped comparison's "
|
|
u"own predicate instead, because test_text_reads.h is the "
|
|
u"harness's one self-test that emits deliberate failures"_q);
|
|
const auto read = ReadToastText(toast);
|
|
Check(
|
|
NormalizeSpaces(read) != NormalizeSpaces(kOtherText),
|
|
u"the text term discriminates: the very read that matches "
|
|
"the toast's own phrase does not match a different one"_q,
|
|
u"read=\"%1\" other=\"%2\""_q.arg(read, kOtherText));
|
|
},
|
|
.timeoutDetails = details,
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: a second live toast makes the "
|
|
"resolver refuse, and does not outlive the stage that showed "
|
|
"it"_q,
|
|
.run = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
const auto parent = toast ? toast->parentWidget() : nullptr;
|
|
if (!state->built || !toast || !parent) {
|
|
return;
|
|
}
|
|
// Shown, read and taken down inside this one turn, the way
|
|
// test_via_window.cpp:398-518 builds, reads and destroys its
|
|
// own fixture, so nothing this stage creates reaches the
|
|
// stages after it. It is infinite for the reason the fixture
|
|
// is: an infinite toast leaves _hideAt at 0 (toast.cpp:32-34)
|
|
// and the manager then arms no hide timer for it
|
|
// (toast_manager.cpp:75-83), so nothing takes it down
|
|
// implicitly and the drained loop's starving of hideAnimated()
|
|
// never comes into it. Instance::hide() - _widget->hide();
|
|
// _widget->deleteLater(); (toast.cpp:116-119) - is the seam,
|
|
// and its hide is synchronous while the walk reads visibility,
|
|
// so the count drops in that same statement.
|
|
const auto second = Ui::Toast::Show(parent, {
|
|
.text = { kOtherText },
|
|
.st = &st::defaultToast,
|
|
.infinite = true,
|
|
});
|
|
const auto instance = second.get();
|
|
const auto secondWidget = instance
|
|
? instance->widget().get()
|
|
: nullptr;
|
|
const auto both = FindLiveToasts();
|
|
const auto resolved = FindLiveToast();
|
|
const auto holdsFixture = ranges::contains(both, toast);
|
|
const auto holdsSecond = secondWidget
|
|
&& ranges::contains(both, secondWidget);
|
|
Check(
|
|
(both.size() == 2)
|
|
&& holdsFixture
|
|
&& holdsSecond
|
|
&& (resolved == nullptr),
|
|
u"more than one live toast is an ambiguity the resolver "
|
|
"refuses rather than guesses: with a second toast on "
|
|
"screen the walk answers both of them and FindLiveToast() "
|
|
"answers nullptr, so a resolver handing back the first of "
|
|
"several turns this row red"_q,
|
|
u"liveToasts=%1 holdsFixture=%2 holdsSecond=%3 resolved=%4 "
|
|
u"fixture=%5 second=%6"_q
|
|
.arg(int(both.size()))
|
|
.arg(holdsFixture ? 1 : 0)
|
|
.arg(holdsSecond ? 1 : 0)
|
|
.arg(resolved
|
|
? WidgetDescription(resolved)
|
|
: u"<none>"_q)
|
|
.arg(
|
|
WidgetDescription(toast),
|
|
secondWidget
|
|
? WidgetDescription(secondWidget)
|
|
: u"<none>"_q));
|
|
if (instance) {
|
|
instance->hide();
|
|
}
|
|
state->singleAgainCount = int(FindLiveToasts().size());
|
|
state->singleAgainResolvedFixture = (FindLiveToast() == toast);
|
|
},
|
|
.then = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
if (!state->built || !toast) {
|
|
return;
|
|
}
|
|
const auto live = FindLiveToasts();
|
|
const auto resolved = FindLiveToast();
|
|
Check(
|
|
(state->singleAgainCount == 1)
|
|
&& state->singleAgainResolvedFixture
|
|
&& (live.size() == 1)
|
|
&& (resolved == toast),
|
|
u"the second toast does not outlive the stage that showed "
|
|
"it: Instance::hide() took it out of the walk in the same "
|
|
"statement, and the walk answers the fixture toast alone "
|
|
"again a runner tick later, which is the single-toast "
|
|
"fixture every stage after this one reads"_q,
|
|
u"afterHide liveToasts=%1 resolvedFixture=%2; nextTurn "
|
|
u"liveToasts=%3 resolvedFixture=%4 fixture=%5"_q
|
|
.arg(state->singleAgainCount)
|
|
.arg(state->singleAgainResolvedFixture ? 1 : 0)
|
|
.arg(int(live.size()))
|
|
.arg((resolved == toast) ? 1 : 0)
|
|
.arg(WidgetDescription(toast)));
|
|
},
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: a default-duration toast is "
|
|
"live on ToastSubtreeReady inside its lifetime"_q,
|
|
.run = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
const auto parent = toast ? toast->parentWidget() : nullptr;
|
|
if (!state->built || !toast || !parent) {
|
|
return;
|
|
}
|
|
state->expiringShownAt = crl::now();
|
|
const auto shown = Ui::Toast::Show(parent, {
|
|
.text = { kExpiryText },
|
|
.st = &st::defaultToast,
|
|
.duration = Ui::Toast::kDefaultDuration,
|
|
.infinite = false,
|
|
});
|
|
const auto instance = shown.get();
|
|
state->expiringWidget = instance
|
|
? instance->widget().get()
|
|
: nullptr;
|
|
state->expiringShown = (state->expiringWidget != nullptr);
|
|
},
|
|
.until = [=] {
|
|
if (!state->built) {
|
|
return true;
|
|
}
|
|
const auto widget = state->expiringWidget.data();
|
|
return widget && ToastSubtreeReady(widget);
|
|
},
|
|
.then = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
const auto expiring = state->expiringWidget.data();
|
|
if (!state->built || !toast || !expiring) {
|
|
return;
|
|
}
|
|
const auto all = FindLiveToasts();
|
|
auto texts = QStringList();
|
|
for (const auto widget : all) {
|
|
texts.push_back(ReadToastText(widget));
|
|
}
|
|
state->liveInsideCount = int(all.size());
|
|
state->liveInsideDetails = liveWalkDetails();
|
|
state->liveInsideReady = ToastSubtreeReady(expiring);
|
|
state->liveInsideHoldsFixture = ranges::contains(all, toast);
|
|
state->liveInsideHoldsExpiring = ranges::contains(
|
|
all,
|
|
expiring);
|
|
state->liveInsideElapsed = crl::now() - state->expiringShownAt;
|
|
Check(
|
|
state->expiringShown
|
|
&& state->liveInsideReady
|
|
&& state->liveInsideHoldsFixture
|
|
&& state->liveInsideHoldsExpiring
|
|
&& (state->liveInsideCount == 2)
|
|
&& texts.contains(kToastText)
|
|
&& texts.contains(kExpiryText),
|
|
u"a default-duration toast is live on ToastSubtreeReady "
|
|
"inside its lifetime: the walk answers the fixture and "
|
|
"the expiring toast together, so FindLiveToast() is "
|
|
"nullptr because the count is not one"_q,
|
|
u"%1 elapsed=%2ms ready=%3"_q
|
|
.arg(state->liveInsideDetails)
|
|
.arg(QString::number(state->liveInsideElapsed))
|
|
.arg(state->liveInsideReady ? 1 : 0));
|
|
},
|
|
.timeout = kDefaultStageTimeout,
|
|
.timeoutDetails = [=] {
|
|
const auto widget = state->expiringWidget.data();
|
|
return u"%1 elapsed=%2ms ready=%3 shown=%4"_q
|
|
.arg(liveWalkDetails())
|
|
.arg(QString::number(state->expiringShownAt
|
|
? (crl::now() - state->expiringShownAt)
|
|
: 0))
|
|
.arg((widget && ToastSubtreeReady(widget)) ? 1 : 0)
|
|
.arg(state->expiringShown ? 1 : 0);
|
|
},
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: a default-duration toast is "
|
|
"gone from the walk after its lifetime"_q,
|
|
.until = [=] {
|
|
if (!state->built || !state->expiringShown) {
|
|
return true;
|
|
}
|
|
const auto widget = state->expiringWidget.data();
|
|
const auto all = FindLiveToasts();
|
|
return !widget || !ranges::contains(all, widget);
|
|
},
|
|
.then = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
if (!state->built || !toast) {
|
|
return;
|
|
}
|
|
const auto all = FindLiveToasts();
|
|
const auto resolved = FindLiveToast();
|
|
const auto expiring = state->expiringWidget.data();
|
|
const auto elapsed = crl::now() - state->expiringShownAt;
|
|
auto texts = QStringList();
|
|
for (const auto widget : all) {
|
|
texts.push_back(ReadToastText(widget));
|
|
}
|
|
const auto holdsFixture = ranges::contains(all, toast);
|
|
const auto holdsExpiring = expiring
|
|
&& ranges::contains(all, expiring);
|
|
Check(
|
|
state->expiringShown
|
|
&& state->liveInsideReady
|
|
&& (state->liveInsideCount == 2)
|
|
&& state->liveInsideHoldsFixture
|
|
&& state->liveInsideHoldsExpiring
|
|
&& (int(all.size()) == 1)
|
|
&& holdsFixture
|
|
&& !holdsExpiring
|
|
&& (resolved == toast)
|
|
&& texts.contains(kToastText)
|
|
&& !texts.contains(kExpiryText),
|
|
u"a non-infinite toast shown with kDefaultDuration is "
|
|
"live inside its lifetime and gone from the walk after "
|
|
"it, taken down by the manager hide timer rather than "
|
|
"by this module's Instance::hide() seam"_q,
|
|
u"inside %1 elapsed=%2ms; after %3 elapsed=%4ms "
|
|
u"holdsFixture=%5 holdsExpiring=%6 resolvedFixture=%7"_q
|
|
.arg(state->liveInsideDetails)
|
|
.arg(QString::number(state->liveInsideElapsed))
|
|
.arg(liveWalkDetails())
|
|
.arg(QString::number(elapsed))
|
|
.arg(holdsFixture ? 1 : 0)
|
|
.arg(holdsExpiring ? 1 : 0)
|
|
.arg((resolved == toast) ? 1 : 0));
|
|
},
|
|
.timeout = kDefaultStageTimeout,
|
|
.timeoutDetails = [=] {
|
|
return u"%1 elapsed=%2ms shown=%3"_q
|
|
.arg(liveWalkDetails())
|
|
.arg(QString::number(state->expiringShownAt
|
|
? (crl::now() - state->expiringShownAt)
|
|
: 0))
|
|
.arg(state->expiringShown ? 1 : 0);
|
|
},
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree self-test: a misframed request is refused "
|
|
"by name, quoting both rects"_q,
|
|
.run = [=] {
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
if (!state->built || !toast) {
|
|
return;
|
|
}
|
|
const auto window = Core::App().activePrimaryWindow();
|
|
const auto top = window ? window->widget().get() : nullptr;
|
|
const auto &margin = st::defaultToast.margin;
|
|
const auto wide = toast->rect().marginsAdded(QMargins(
|
|
margin.left(),
|
|
margin.top(),
|
|
margin.right(),
|
|
margin.bottom()));
|
|
const auto before = FailureCount();
|
|
const auto missing = ReadToastSubtree(nullptr);
|
|
const auto missingReady = ToastSubtreeReady(nullptr);
|
|
const auto missingGrab = GrabToastSubtree(nullptr);
|
|
const auto owner = top
|
|
? ReadToastSubtree(top)
|
|
: ToastSubtreeReading();
|
|
const auto ownerReady = top && ToastSubtreeReady(top);
|
|
const auto ownerGrab = top ? GrabToastSubtree(top) : QImage();
|
|
const auto misframed = ReadToastSubtree(toast, wide);
|
|
const auto misframedGrab = GrabToastSubtree(toast, wide);
|
|
const auto after = FailureCount();
|
|
Note(u"toast-subtree self-test: every refusal below is observed "
|
|
u"through ReadToastSubtree, ToastSubtreeReady and "
|
|
u"GrabToastSubtree, which log nothing; "
|
|
u"Test::CaptureToastSubtree turns each of them into a loud "
|
|
u"Fail, which is why this stage never goes through it"_q);
|
|
Check(
|
|
!missing.resolved()
|
|
&& (missing.toast == nullptr)
|
|
&& missing.refusal.contains(u"no widget was handed"_q)
|
|
&& !missingReady
|
|
&& missingGrab.isNull(),
|
|
u"a null target is refused by name, is never ready and is "
|
|
"never grabbed, so a poll around it ends in a named stage "
|
|
"timeout rather than in a grab of something else"_q,
|
|
missing.refusal);
|
|
Check(
|
|
top
|
|
&& !owner.resolved()
|
|
&& owner.refusal.contains(WidgetDescription(top))
|
|
&& owner.refusal.contains(WidgetDescription(toast))
|
|
&& !ownerReady
|
|
&& ownerGrab.isNull(),
|
|
u"an ancestor that paints the product is refused by name, "
|
|
"quoting both the handed widget and the live toast the "
|
|
"caller should grab instead, and answers neither a ready "
|
|
"nor an image"_q,
|
|
owner.refusal);
|
|
Check(
|
|
!misframed.resolved()
|
|
&& misframed.refusal.contains(RectText(wide))
|
|
&& misframed.refusal.contains(RectText(toast->rect()))
|
|
&& misframedGrab.isNull(),
|
|
u"a rect materially larger than the live toast's own "
|
|
"geometry is refused quoting both rects and answers a null "
|
|
"image, never a silently reframed one"_q,
|
|
misframed.refusal);
|
|
Note(u"toast-subtree self-test: ToastSubtreeReady speaks about "
|
|
u"the toast and not about a requested rect, so the "
|
|
u"misframed leg is measured by GrabToastSubtree answering a "
|
|
u"null image; the loud form is still "
|
|
u"Test::CaptureToastSubtree, which FAILs on that rect"_q);
|
|
Check(
|
|
after == before,
|
|
u"none of these refusals logged a failure: a refusal is a "
|
|
"returned value the caller decides about"_q,
|
|
u"failures before=%1 after=%2"_q.arg(before).arg(after));
|
|
},
|
|
});
|
|
|
|
runner->add({
|
|
.name = u"toast-subtree 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 toast was shown infinite, so no expiry
|
|
// timer will ever take it down, and the sentinel is parented
|
|
// into the primary window. Instance::hide() is the product's
|
|
// own immediate path - _widget->hide();
|
|
// _widget->deleteLater(); (ui/toast/toast.cpp:116-119) -
|
|
// rather than hideAnimated(), whose fade-out animation the
|
|
// harness's drained loop starves.
|
|
// The zero reading below is taken around that same hide, and
|
|
// the walk one statement above it is its control: a bare
|
|
// empty answer at an arbitrary point would be an accident of
|
|
// ordering, while the same walk answering the fixture toast
|
|
// alone a statement earlier makes this module's own hide()
|
|
// what took the count to zero.
|
|
const auto toast = state->fixture.toastWidget.data();
|
|
const auto before = FindLiveToasts();
|
|
const auto beforeResolved = FindLiveToast();
|
|
if (const auto instance = state->fixture.toast.get()) {
|
|
instance->hide();
|
|
}
|
|
const auto after = FindLiveToasts();
|
|
const auto afterResolved = FindLiveToast();
|
|
Check(
|
|
(before.size() == 1)
|
|
&& (beforeResolved == toast)
|
|
&& after.empty()
|
|
&& (afterResolved == nullptr),
|
|
u"zero live toasts is the walk's other ambiguity, and the "
|
|
"resolver refuses that one too: the same walk answers the "
|
|
"fixture toast alone, this module's own Instance::hide() "
|
|
"takes it down, and FindLiveToast() then answers nullptr "
|
|
"for the empty list rather than guessing"_q,
|
|
u"before=%1 resolvedFixture=%2 after=%3 resolved=%4 "
|
|
u"fixture=%5"_q
|
|
.arg(int(before.size()))
|
|
.arg((beforeResolved == toast) ? 1 : 0)
|
|
.arg(int(after.size()))
|
|
.arg(afterResolved
|
|
? WidgetDescription(afterResolved)
|
|
: u"<none>"_q)
|
|
.arg(toast ? WidgetDescription(toast) : u"<none>"_q));
|
|
state->fixture.sentinel = nullptr;
|
|
state->fixture.toastWidget = nullptr;
|
|
Note(u"toast-subtree self-test: fixture released, sentinel=%1 "
|
|
"toast=%2"_q
|
|
.arg(state->fixture.sentinel ? 1 : 0)
|
|
.arg(state->fixture.toast ? 1 : 0));
|
|
},
|
|
});
|
|
}
|
|
|
|
} // namespace Test
|
|
|
|
#endif // _DEBUG
|