Skip to content

Commit b31fd80

Browse files
committed
[ai] Resolve box paint roots and capture popup menus
Task: 2026/08/28/add-box-root-and-popup-menu-helpers
1 parent bdbcf2d commit b31fd80

8 files changed

Lines changed: 1112 additions & 13 deletions

File tree

‎Telegram/CMakeLists.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,8 +1969,12 @@ PRIVATE
19691969
test/test_ink.h
19701970
test/test_launch_fuse.cpp
19711971
test/test_launch_fuse.h
1972+
test/test_layer_root.cpp
1973+
test/test_layer_root.h
19721974
test/test_log.cpp
19731975
test/test_log.h
1976+
test/test_menu.cpp
1977+
test/test_menu.h
19741978
test/test_messages.cpp
19751979
test/test_messages.h
19761980
test/test_open_handoff.cpp

‎Telegram/SourceFiles/test/README.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ Choose the narrowest helper that captures the real paint owner:
179179
| Static visible widget or local rect | `CaptureWidget` / `CaptureRect` |
180180
| Rect expressed in a child or offscreen content widget's coordinates | `CaptureMappedRect` |
181181
| Full box, layer owner, animation, or asynchronously populated surface | `Runner::captureWidget` |
182+
| Full box inside a layer | `PaintingLayerRoot` + `Runner::captureWidget`, or `CaptureInLayerRoot` |
183+
| Open `Ui::PopupMenu` | `CapturePopupMenu` |
182184
| Exact accepted frame plus numeric/raster assertions | `Runner::captureAndInspect` |
183185
| Small target comparison | `Crop`, `Zoom`, `ContactSheet` |
184186
| Foreground colour, contrast, or painted-band measurement | `MeasurePaintedInk` and the helpers in `test_ink.h` |
@@ -246,11 +248,13 @@ clicking.
246248
| `test_log.h` | Absolute flushed logs, steps, notes, checks whose `details` are printed on the passing verdict as well as the failing one, tolerances, geometry, completion markers. |
247249
| `test_probe.h` | Append-only observation records read only through a declared window, each carrying the time it was recorded; keyed issue/answer rows correlated into one round trip by key rather than by list position, refusing every reading it cannot positively pair; and scans that must match a control before a zero counts as absence. |
248250
| `test_widgets.h` | Safe typed discovery, live object/action publication, input, and postponed-call settlement. |
249-
| `test_capture.h` | In-process grabs, paint-root validation, mapped rects, blank detection, crops, zoom, contact sheets. |
251+
| `test_capture.h` | In-process grabs, paint-root validation, mapped rects, blank detection, painting-layer-root resolution for boxes inside a layer, crops, zoom, contact sheets. |
252+
| `test_layer_root.h` | The painting-layer-root resolver's own self-test: a plain `Ui::GenericBox` accepted as its own render root beside one that cleared `Qt::WA_OpaquePaintEvent`, refused and then captured through its `Ui::BoxLayerWidget`. |
250253
| `test_hover.h` | The input helpers' own self-test: a clicked-then-dragged and a never-touched `Ui::RoundButton` measured against the two fills their style names, proving a completed synthetic click and drag leave no hover. |
251254
| `test_ink.h` | Derived paint bands, colour separation, ink scans, counts, and contrast reports. |
252255
| `test_style.h` | Wait for palette/style samples to stabilize and assert a recorded baseline still holds. |
253256
| `test_panel.h` | Distinguish a live `Ui::SeparatePanel` from its faded/squeezed show-animation cache. |
257+
| `test_menu.h` | Deterministic `Ui::PopupMenu` capture whose readiness is content identity only, its `showingContent` reading, and its own self-test. |
254258
| `test_messages.h` | Lifetime-owned watcher for a matching newly sent server message. |
255259
| `test_history_fixtures.h` | Inject a caller-supplied service action into a real history as a regular or (negative-control) local item, with a caller-owned lifetime that removes it, and log the menu-gating predicates. |
256260
| `test_custom_emoji.h` | Supply an always-ready `Ui::Text::CustomEmoji` that fills the large-emoji box with worst-case ink, handed out only for document ids the scenario itself registered. |
@@ -384,7 +388,7 @@ need them.
384388
| Assertion/crash in a stage action | `.run` dereferenced an async object or a raw pointer outlived its owner. | Use `actOnWidget`, `QPointer`, or live publication. |
385389
| Wrong custom widget/button found | Unsafe Qt typed search or ambiguous descendant order. | Use the RTTI finders; for repeated/layer-owned controls publish the exact object/action. |
386390
| Expected mismatch reported as timeout | Product outcome was put in `until`. | Wait only for propagation/generation; assert and log the outcome in `then`/`captureAndInspect`. |
387-
| Blank or partial screenshot | Wrong paint owner, animation cache, or viewport clipping. | Use prepared capture, `PanelShowSettled`, the owning ancestor, or `CaptureMappedRect`. |
391+
| Blank or partial screenshot | Wrong paint owner, animation cache, or viewport clipping. | Use prepared capture, `PanelShowSettled`, the owning ancestor, or `CaptureMappedRect`; for a box inside a layer, `Test::PaintingLayerRoot`; for a `Ui::PopupMenu`, `Test::CapturePopupMenu`. |
388392
| Old palette/colour sampled | Style had not settled or moved between reference and target. | Use `StyleSettled` and `StyleBaseline`. |
389393
| A clicked button's measured fill matches no style constant, or `DeriveBand` returns `ok=0` with no rows for a widget plainly on screen | The reading was taken while the widget was still hovered by an earlier synthetic click, so it painted `textBgOver` where the check named `textBg`; before the input helpers delivered a leave this latched for the whole process. | Take the reading through helpers that leave the target pointerless (`Click`/`Drag` deliver a `QEvent::Leave`), and when a hovered reading is what is wanted, set the hover deliberately and name the fill the state actually implies. Confirm the instrument with the `test_hover.h` self-test. |
390394
| Emoji split or custom entity absent | UTF-16 code units or the wrong editor event route were synthesized. | Use grapheme-safe `TypeText` or one `CommitText` on the raw editor. |
@@ -398,6 +402,9 @@ need them.
398402
| A sweep reports a confident `found=0` that no repair ever changes | The enumeration structurally cannot reach the subject, so the zero was guaranteed before the run started and measures nothing. | Count through `Test::DiscriminatingScan` and feed it a known-present control; `report()` refuses to certify a zero the walk cannot tell from absence. |
399403
| A green log that does not say what its checks were made against, so a passing run cannot be audited after the fact | The reading was handed to `Test::Check` as `details` back when `details` was written only on the failing branch, or worked around by folding it into `what` or by emitting a `Note` beside the check that a reader then has to re-correlate by position. | Pass the reading as `Check`'s third argument: it is printed on the passing verdict too, as `TEST_RESULT: PASS: <what> - <details>`. Keep only failure-only text behind `ok ? QString() : ...`, which still prints the bare passing line. |
400404
| A round trip reported as a negative or otherwise impossible number, or a pair count that does not match the issue count | Two lists were related by position - an issue list against an answer list, or a row list against a parallel `crl::time` vector indexed at `mark + i` - so one extra or missing element on either side paired a row with another row's time, and the reading was emitted as a `Note` that failed nothing. | Record both sides into one `Test::Probe` with `recordIssue`/`recordAnswer` and read them through `checkRoundTripSince(mark, key)`: it pairs by key, discards and names every answer not strictly later than its issue, and refuses as a FAIL carrying the tallies rather than reporting an interval it did not positively pair. Read a bare time through `timedRowsSince`, which carries each plain row's own time. |
405+
| A capture or `captureWidget` stage times out and its details carry `render root paints no background of its own: ... - grab N...BoxLayerWidget... instead (unpainted 0/1000)` | The render root was the box, and that box had cleared `Qt::WA_OpaquePaintEvent`. `Ui::BoxContent`'s constructor sets that attribute, so a plain box paints its own background and is accepted - but `setNoContentMargin(true)`, which 53 product call sites use, clears it again, and then the box paints no background of its own and `PreparedWidgetCapture::prepare()` refuses every frame it is offered; the poll can only end in a timeout. | Resolve the root with `Test::PaintingLayerRoot(box)` and capture the `Ui::BoxLayerWidget` it answers, or use `Test::CaptureInLayerRoot(box, name)` for a frame cropped to the box (it composes `CaptureMappedRect`, so a box that maps outside its layer is still a named FAIL). The refusal is naming the right widget - do not widen it. |
406+
| A menu capture times out with `showingContent=0` in its last state, or the popup vanishes mid-stage | The readiness waited on the inner `Ui::Menu`'s visibility. `Ui::PopupMenu::startShowAnimation()` calls `hideChildren()`, and the children come back only from the final `paintEvent`'s `Ui::PostponeCall` - a side effect no `-testagent` run is guaranteed to reach - so the wait can last until the popup dies of a focus-out. A one-shot grab in the turn that opened the menu is the opposite failure: it lands on a show-animation frame with nothing drawn. | Capture with `Test::CapturePopupMenu`: its readiness carries content identity only - the menu exists, is visible, has non-empty geometry and carries actions - and the prepared capture's own blank-frame refusal decides when the frame is good. `showingContent` belongs in the details, never in the gate. Do not use `Test::PaintingLayerRoot` on a popup: it sets `Qt::WA_NoSystemBackground`, so the blank-root refusal never applies, and it is its own window. |
407+
| A stage times out although its own details show every precondition met and the action already fired - for example `box=1 toggle=1 popups=0` after a click on a control in a freshly shown layer | One synthesized click on a control in a layer that is still settling intermittently does not land, and nothing observes that until the stage's 10-second timeout ends the whole scenario. The input helpers are not at fault: `Test::Click` and `Test::Drag` deliver press, release and a `QEvent::Leave` synchronously and drain every `Ui::PostponeCall` before returning. | Make the click self-correcting in the scenario, not in the helper: read the effect, click the same `QPointer`-guarded widget once more if it did not appear, and `Note` which attempt worked so the flake rate stays visible. Establish first, from the production callback, that a repeat is provably a no-op - the details-menu toggle's first statement is `if (*menu) { return; }`. |
401408

402409
Classify a sound assertion against changed behavior as an implementation bug,
403410
not a test flaw. Classify a wrong fixture, target, readiness model, event
@@ -409,5 +416,6 @@ When a flaw's cause is the instrument idiom rather than this task's fixture,
409416
repair it here as well as in the overlay: add the missing helper, tighten an
410417
existing contract, or add its row to the table above, in the same run that
411418
diagnosed it. A diagnosis that stays in one task's notes is rediscovered by
412-
the next task, which is how the two rows above cost four runs each before
413-
they were written down.
419+
the next task, which is how the `Test::Probe` window row and the
420+
`Test::DiscriminatingScan` row above cost four runs each before they were
421+
written down.

‎Telegram/SourceFiles/test/test_capture.cpp‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ For license and copyright information please follow this link:
1010
#include "test/test_capture.h"
1111

1212
#include "test/test_log.h"
13+
#include "ui/layers/box_layer_widget.h"
1314
#include "ui/ui_utility.h"
1415

1516
#include "styles/palette.h"
@@ -24,6 +25,7 @@ constexpr auto kContactSheetGap = 8;
2425
constexpr auto kCoverageSamples = 32;
2526
constexpr auto kUnpaintedMinPermille = 100;
2627
constexpr auto kBackgroundOwnerHops = 6;
28+
constexpr auto kWalkedChainHead = 3;
2729

2830
[[nodiscard]] QString WithPngExtension(const QString &name) {
2931
return name.endsWith(u".png"_q, Qt::CaseInsensitive)
@@ -126,13 +128,6 @@ constexpr auto kBackgroundOwnerHops = 6;
126128
return (1000 * unpainted) / int(points.size());
127129
}
128130

129-
[[nodiscard]] QString WidgetDescription(not_null<QWidget*> widget) {
130-
const auto &instance = *widget;
131-
return u"%1 %2"_q.arg(
132-
QString::fromUtf8(typeid(instance).name()),
133-
RectText(widget->geometry()));
134-
}
135-
136131
[[nodiscard]] QString BackgroundOwnerDetails(
137132
not_null<QWidget*> widget,
138133
const QRect &logicalRect) {
@@ -145,9 +140,12 @@ constexpr auto kBackgroundOwnerHops = 6;
145140
if (!mapped.isEmpty()) {
146141
const auto unpainted = UnpaintedPermille(ancestor, mapped);
147142
if (unpainted < kUnpaintedMinPermille) {
148-
return u"grab %1 instead (unpainted %2/1000)"_q
143+
return u"grab %1 instead (unpainted %2/1000)%3"_q
149144
.arg(WidgetDescription(ancestor))
150-
.arg(unpainted);
145+
.arg(unpainted)
146+
.arg(dynamic_cast<Ui::BoxLayerWidget*>(ancestor)
147+
? u" - Test::PaintingLayerRoot() resolves it"_q
148+
: QString());
151149
}
152150
}
153151
ancestor = (ancestor == top) ? nullptr : ancestor->parentWidget();
@@ -195,8 +193,31 @@ constexpr auto kBackgroundOwnerHops = 6;
195193
.arg(BackgroundOwnerDetails(widget, logicalRect));
196194
}
197195

196+
// The walk a refusal prints always names the window it stopped at, and never
197+
// more than kWalkedChainHead entries before it, so a target buried deep in a
198+
// widget tree cannot turn one refusal into a multi-kilobyte log line.
199+
[[nodiscard]] QString ElidedChain(const QStringList &walked) {
200+
if (walked.size() <= kWalkedChainHead + 1) {
201+
return walked.join(u" < "_q);
202+
}
203+
auto shown = QStringList();
204+
for (auto i = 0; i != kWalkedChainHead; ++i) {
205+
shown.push_back(walked[i]);
206+
}
207+
shown.push_back(u"\u2026"_q);
208+
shown.push_back(walked.back());
209+
return shown.join(u" < "_q);
210+
}
211+
198212
} // namespace
199213

214+
QString WidgetDescription(not_null<QWidget*> widget) {
215+
const auto &instance = *widget;
216+
return u"%1 %2"_q.arg(
217+
QString::fromUtf8(typeid(instance).name()),
218+
RectText(widget->geometry()));
219+
}
220+
200221
QImage GrabWidget(not_null<QWidget*> widget) {
201222
return Ui::GrabWidgetToImage(widget, QRect(), st::windowBg->c);
202223
}
@@ -371,6 +392,40 @@ bool CaptureMappedRect(
371392
name);
372393
}
373394

395+
PaintingLayerRootResult PaintingLayerRoot(QWidget *box) {
396+
if (!box) {
397+
return {
398+
.refusal = u"no widget was handed to the painting layer root "
399+
u"resolver"_q,
400+
};
401+
}
402+
auto walked = QStringList();
403+
for (auto widget = box; widget; widget = widget->parentWidget()) {
404+
if (const auto layer = dynamic_cast<Ui::BoxLayerWidget*>(widget)) {
405+
return { .widget = layer };
406+
}
407+
walked.push_back(WidgetDescription(widget));
408+
if (widget == widget->window()) {
409+
break;
410+
}
411+
}
412+
return {
413+
.refusal = u"no Ui::BoxLayerWidget between the target and its own "
414+
u"window, so it is not a box inside a layer and has no painting "
415+
u"layer root; walked %1 widget(s) and stopped at the window: "
416+
u"[%2]"_q.arg(walked.size()).arg(ElidedChain(walked)),
417+
};
418+
}
419+
420+
bool CaptureInLayerRoot(not_null<QWidget*> box, const QString &name) {
421+
const auto root = PaintingLayerRoot(box);
422+
if (!root.resolved()) {
423+
Fail(u"capture %1"_q.arg(name), root.refusal);
424+
return false;
425+
}
426+
return CaptureMappedRect(root.widget, box, box->rect(), name);
427+
}
428+
374429
QImage Crop(const QImage &image, const QRect &pixelRect) {
375430
const auto bounded = pixelRect.intersected(image.rect());
376431
return bounded.isEmpty() ? QImage() : image.copy(bounded);

‎Telegram/SourceFiles/test/test_capture.h‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ namespace Test {
2626
not_null<QWidget*> widget,
2727
const QRect &logicalRect);
2828

29+
// The harness's one widget-identity formatter: the typeid name of the live
30+
// instance plus its "x,y WxH" geometry. Every refusal here prints identities
31+
// through it, so two logs name the same widget the same way and stay
32+
// comparable line by line.
33+
[[nodiscard]] QString WidgetDescription(not_null<QWidget*> widget);
34+
2935
// Near-uniform images are capture failures, never evidence.
3036
[[nodiscard]] bool LooksBlank(const QImage &image);
3137

@@ -76,6 +82,55 @@ bool CaptureMappedRect(
7682
const QRect &logicalRect,
7783
const QString &name);
7884

85+
// The one render root a capture of a box inside a layer may use.
86+
//
87+
// Ui::BoxContent's constructor sets Qt::WA_OpaquePaintEvent
88+
// (ui/layers/box_content.h:117-119) and BoxContent::paintEvent fills with the
89+
// delegate's style().bg only while that attribute is set
90+
// (ui/layers/box_content.cpp:450-459), so a plain Ui::GenericBox paints its
91+
// own background and the blank-root refusal short-circuits on it: a plain box
92+
// is NOT refused. setNoContentMargin(true) clears the attribute again
93+
// (box_content.h:224-230), which is what 53 call sites under
94+
// Telegram/SourceFiles/ do, and that is the only shape the refusal fires for.
95+
// Such a box paints no background of its own, and the Ui::BoxLayerWidget the
96+
// layer stack wrapped it in is what paints instead
97+
// (ui/layers/box_layer_widget.cpp:120-141), with the box as its direct child
98+
// (:46) - so the walk is normally one hop, but it is written as a walk
99+
// because a scenario resolves from a descendant just as often.
100+
//
101+
// Run 1 of 2026/08/28/complete-server-history-details-hash-and-paging handed
102+
// the bare box to Runner::captureAndInspect: PreparedWidgetCapture::prepare()
103+
// refused every frame it was offered and the stage could only end in a
104+
// timeout, although the refusal it printed already named the widget to grab -
105+
// "grab N2Ui14BoxLayerWidgetE 14,72 364x413 instead (unpainted 0/1000)".
106+
// This answers that once instead of leaving every call site to hand-roll it.
107+
//
108+
// The walk stops at widget->window() and refuses there, so it never leaves
109+
// the target's own window looking for a layer. That is also why it must never
110+
// be used on a Ui::PopupMenu: a popup is its own window, so the walk refuses
111+
// on its first hop, and Ui::PopupMenu::init() sets Qt::WA_NoSystemBackground
112+
// (ui/widgets/popup_menu.cpp:126), so the blank-root refusal never applies to
113+
// it and it needs no layer root at all.
114+
//
115+
// |widget| is non-null exactly when |refusal| is empty: a caller cannot take
116+
// the pointer without being handed the reason there is none.
117+
struct PaintingLayerRootResult {
118+
QWidget *widget = nullptr;
119+
QString refusal;
120+
121+
[[nodiscard]] bool resolved() const {
122+
return widget != nullptr;
123+
}
124+
};
125+
126+
[[nodiscard]] PaintingLayerRootResult PaintingLayerRoot(QWidget *box);
127+
128+
// Saves the box's own rect, grabbed out of the Ui::BoxLayerWidget that paints
129+
// it. An unresolved root is a logged FAIL carrying the refusal above, never a
130+
// null the caller has to re-check before CaptureMappedRect, which this
131+
// composes and which takes not_null<QWidget*>.
132+
bool CaptureInLayerRoot(not_null<QWidget*> box, const QString &name);
133+
79134
[[nodiscard]] QImage Crop(const QImage &image, const QRect &pixelRect);
80135

81136
// Nearest-neighbor upscale for readable small-target evidence.

0 commit comments

Comments
 (0)