mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/telegramdesktop/tdesktop
synced 2026-09-20 08:03:45 +08:00
[ai] Record MTP retries, space-class reads and N/A rows
Task: 2026/09/02/add-code-500-probe-space-class-check-and-na-rows-to-the-harness
This commit is contained in:
@@ -2280,6 +2280,7 @@ def parse_test_log(text):
|
||||
steps = []
|
||||
passed = []
|
||||
failed = []
|
||||
skipped = []
|
||||
screenshots = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("TEST_STEP: "):
|
||||
@@ -2288,12 +2289,15 @@ def parse_test_log(text):
|
||||
passed.append(line[len("TEST_RESULT: PASS: "):])
|
||||
elif line.startswith("TEST_RESULT: FAIL: "):
|
||||
failed.append(line[len("TEST_RESULT: FAIL: "):])
|
||||
elif line.startswith("TEST_RESULT: N/A: "):
|
||||
skipped.append(line[len("TEST_RESULT: N/A: "):])
|
||||
elif line.startswith("SCREENSHOT: "):
|
||||
screenshots.append(line[len("SCREENSHOT: "):])
|
||||
return {
|
||||
"steps": steps,
|
||||
"pass": passed,
|
||||
"fail": failed,
|
||||
"skipped": skipped,
|
||||
"screenshots": screenshots,
|
||||
}
|
||||
|
||||
|
||||
@@ -1894,6 +1894,20 @@ class MechanicsTest(unittest.TestCase):
|
||||
self.assertEqual(result["markers"]["screenshots"], ["/tmp/fake.png"])
|
||||
self.assertFalse(result["crash_report_fresh"])
|
||||
|
||||
def test_parse_test_log_lists_skipped_rows_beside_pass_and_fail(self):
|
||||
markers = workspace.parse_test_log("\n".join([
|
||||
"TEST_STEP: gated stage self-test: arm",
|
||||
"TEST_RESULT: N/A: skipped stage - applies=0",
|
||||
"TEST_RESULT: PASS: applied stage - ran=1",
|
||||
"TEST_RESULT: FAIL: other stage - ran=0",
|
||||
"SCREENSHOT: /tmp/fake.png",
|
||||
]))
|
||||
self.assertEqual(markers["skipped"], ["skipped stage - applies=0"])
|
||||
self.assertEqual(markers["pass"], ["applied stage - ran=1"])
|
||||
self.assertEqual(markers["fail"], ["other stage - ran=0"])
|
||||
self.assertEqual(markers["steps"], ["gated stage self-test: arm"])
|
||||
self.assertEqual(markers["screenshots"], ["/tmp/fake.png"])
|
||||
|
||||
def test_test_run_reports_a_death_after_complete(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
@@ -1965,6 +1965,8 @@ PRIVATE
|
||||
test/test_capture.h
|
||||
test/test_custom_emoji.cpp
|
||||
test/test_custom_emoji.h
|
||||
test/test_gated_stage.cpp
|
||||
test/test_gated_stage.h
|
||||
test/test_history_fixtures.cpp
|
||||
test/test_history_fixtures.h
|
||||
test/test_hover.cpp
|
||||
@@ -1987,11 +1989,15 @@ PRIVATE
|
||||
test/test_panel.h
|
||||
test/test_probe.cpp
|
||||
test/test_probe.h
|
||||
test/test_rpc_retry.cpp
|
||||
test/test_rpc_retry.h
|
||||
test/test_runner.cpp
|
||||
test/test_runner.h
|
||||
test/test_scenario.cpp
|
||||
test/test_style.cpp
|
||||
test/test_style.h
|
||||
test/test_text_reads.cpp
|
||||
test/test_text_reads.h
|
||||
test/test_transfer.cpp
|
||||
test/test_transfer.h
|
||||
test/test_via_window.cpp
|
||||
|
||||
@@ -26,6 +26,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
#include "base/call_delayed.h"
|
||||
#include "base/timer.h"
|
||||
#include "base/network_reachability.h"
|
||||
#include "test/test_rpc_retry.h"
|
||||
|
||||
namespace MTP {
|
||||
namespace {
|
||||
@@ -1501,6 +1502,20 @@ bool Instance::Private::onErrorDefault(
|
||||
auto secs = 1;
|
||||
auto nonPremiumDelay = false;
|
||||
if (code < 0 || code >= 500) {
|
||||
auto body = mtpTypeId(0);
|
||||
{
|
||||
QReadLocker locker(&_requestMapLock);
|
||||
const auto i = _requestMap.find(requestId);
|
||||
if (i != _requestMap.cend()
|
||||
&& i->second
|
||||
&& (i->second->size()
|
||||
> SerializedRequest::kMessageBodyPosition)) {
|
||||
body = mtpTypeId((*i->second)[
|
||||
SerializedRequest::kMessageBodyPosition]);
|
||||
}
|
||||
}
|
||||
Test::RecordRpcRetry(code, type, body);
|
||||
|
||||
const auto it = _requestsDelays.find(requestId);
|
||||
if (it != _requestsDelays.cend()) {
|
||||
secs = (it->second > 60) ? it->second : (it->second *= 2);
|
||||
|
||||
@@ -23,6 +23,7 @@ visible in the run together and rerun the packed scenario.
|
||||
| `until` | A pure, repeatable readiness observation. | Click, mutate state, emit a PASS/FAIL, or encode the expected product result. The one documented exception is the `Test::ForceWindowActive` re-assertion in the activation row under Failure diagnosis. |
|
||||
| `then` | Assertions and the next action after readiness succeeded. | Resolve an unguarded replacement for the object that readiness accepted. |
|
||||
| `timeoutDetails` | Return the latest observed values and identities. | Repeat only “not ready”; omit the values needed to diagnose why. |
|
||||
| `skipReason` | Return why this stage does not apply, or an empty string when it does. A non-empty reason writes `TEST_RESULT: N/A: <stage> - <reason>`, skips `run`, `until` and `then`, and moves on in the same turn. | Decide the product outcome. A gate that reads a measurement instead of a precondition turns a would-be FAIL into a silent N/A. |
|
||||
|
||||
A stage timeout is a harness failure unless the readiness condition itself is
|
||||
the behavior under test. Keep expected geometry, text, pixels, counts, and
|
||||
@@ -254,8 +255,10 @@ clicking.
|
||||
| Module | Facilities |
|
||||
| --- | --- |
|
||||
| `test_agent.h` | Runtime gate, startup scale override, sticky named events, scenario start. |
|
||||
| `test_runner.h` | Stages, bounded waits, exact-widget actions, prepared capture/inspection, watchdog (`TDESKTOP_TEST_WATCHDOG` in seconds) and termination. |
|
||||
| `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. |
|
||||
| `test_runner.h` | Stages, bounded waits, exact-widget actions, prepared capture/inspection, first-class gated skips (`skipReason`), watchdog (`TDESKTOP_TEST_WATCHDOG` in seconds) and termination. |
|
||||
| `test_gated_stage.h` | The first-class gated skip's own self-test: a stage whose `skipReason` returns a reason, writing one `TEST_RESULT: N/A:` row and skipping `run`, `until` and `then` without waiting - its never-ready `until` under a one-second timeout is the falsifier - beside a stage whose gate returns an empty string and runs normally in the tick that begins it. |
|
||||
| `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, N/A rows for stages that did not apply, and their count. |
|
||||
| `test_text_reads.h` | Space-class-normalizing text comparison: `Test::NormalizeSpaces` maps U+00A0 and U+202F to U+0020 and changes nothing else, `Test::CheckTextReads` compares a read-back against an expectation through it and prints both raw strings and their whitespace code points on either verdict; with its own self-test, in which a real `Ui::FlatLabel`'s narrow-no-break-space read-back is accepted beside two deliberate FAIL negative controls - a different minute and a different day - that keep the check from becoming permissive. |
|
||||
| `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. |
|
||||
| `test_widgets.h` | Safe typed discovery, live object/action publication, input, postponed-call settlement, and QPA-injected window activation. |
|
||||
| `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, window-mapped capture for widgets that paint no opaque background of their own. |
|
||||
@@ -274,6 +277,7 @@ clicking.
|
||||
| `test_launch_fuse.h` | Declare and verify operating-system launches while refusing every real launch in test-agent mode. |
|
||||
| `test_open_handoff.h` | Inspect and assert the document-open branch without handing anything to the OS. |
|
||||
| `test_transfer.h` | Observe document save/failure transitions and assert duplicate or failed transfer behavior. |
|
||||
| `test_rpc_retry.h` | The permanent MTP resend seam: `Test::RecordRpcRetry` records one `rpc retry code=<code> type=<type> request=<constructor>` row for every code-500 or negative-code answer the transport auto-resends without calling the request's fail handler, read through the `Test::RpcRetryProbe()` accessor; with its own self-test for the recorded 500, the non-500 that reaches `.fail()` instead, and the answer for a request id this process never sent. |
|
||||
| `test_scenario.cpp` | The only permanent overlay slot; the repository version remains a no-op. |
|
||||
|
||||
`Test::Check`'s third argument is an observation, not a failure excuse. It is
|
||||
@@ -401,6 +405,8 @@ need them.
|
||||
| Assertion/crash in a stage action | `.run` dereferenced an async object or a raw pointer outlived its owner. | Use `actOnWidget`, `QPointer`, or live publication. |
|
||||
| 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. |
|
||||
| Expected mismatch reported as timeout | Product outcome was put in `until`. | Wait only for propagation/generation; assert and log the outcome in `then`/`captureAndInspect`. |
|
||||
| A stage times out at exactly the length of a product deadline it was waiting through - `stage timed out: ... - waited 60000 ms` on a stage whose `until` awaits an outcome the product bounds with its own timer - while the product's own fail row, which that `until` already accepts, would have arrived a moment later | The stage timeout coincides with the product's deadline, so the harness ends the stage before the product decides and the run records an instrument timeout where a decided negative existed. The row above is the neighbouring fault: there the outcome was put in `until`; here it is in `then` already, but the wait was not given room to reach it. | Make a stage that waits through a product timer outlast it by a clear margin - 75 s over a 60 s deadline - accept the product's own fail row as the decided negative, and keep the outcome assertion in `then`. |
|
||||
| A request never answers although the server did: no `.done()`, no `.fail()`, no product row, the stage waits until its timeout, while the application Debug log repeats `RPC Info: error received, code 500, type <T>` for the same request | `Instance::Private::onErrorDefault` auto-resends every code-500 and negative-code `rpc_error` after a doubling delay and never calls a product callback for it, so nothing a scenario can read ever changes. | Read the `rpc retry code=<code> type=<type> request=<constructor>` rows the permanent seam records into `Test::RpcRetryProbe()` (`test_rpc_retry.h`), through a `mark()` taken immediately before the action, and decide from those rows plus the product's own fail row - never from the stage timeout. `<constructor>` is the boxed body constructor id in hex, compared against the `mtpc_*` constant the scenario cares about. The Debug log's lines stay the fallback for a reader without the harness log. |
|
||||
| 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`. |
|
||||
| Old palette/colour sampled | Style had not settled or moved between reference and target. | Use `StyleSettled` and `StyleBaseline`. |
|
||||
| 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. |
|
||||
@@ -424,6 +430,8 @@ need them.
|
||||
| `focused=0` for a field that is plainly focused, often beside `focusWindowSet=1 focusWidget=QTextEdit` | `Ui::InputField` declares its own non-virtual `bool hasFocus() const` returning `_inner->hasFocus()` (`input_field.cpp:4276`), and `QWidget::hasFocus()` is non-virtual too, so a read through the `QWidget*` a generic finder returns answers for the wrapper, not for the inner editor that holds the focus. | `dynamic_cast<Ui::InputField*>` the resolved widget and call its own `hasFocus()`, or test `QApplication::focusWidget()` for descendancy of the field. `Ui::PasswordInput` is a `QLineEdit` subclass with no wrapper, so a direct read there is already right. |
|
||||
| A passcode / cloud-password box accepted Enter and produced no request: no SRP computed, no API call, no error | `PasscodeBox::submit()` (`passcode_box.cpp:350-383`) is five per-field `hasFocus()` branches and nothing else; in a Qt-inactive window every branch reads false, Enter falls through and `save()` never runs. | Submit through the box's own shell button with `Test::ClickBoxButton` — `lng_passcode_submit` when a caller set `customSubmitButton`, otherwise `lng_settings_save` or `lng_passcode_remove_button` — whose handler calls `save()` with no focus dependency; assert activation first, per the activation row above. Two of those branches are irreversible on a live account: `lng_passcode_remove_button` is the turning-off submit that disables the cloud password (`passcode_box.cpp:263-269`), and a `customSubmitButton` is `tr::lng_theme_delete()` on the account self-destruction flow (`self_destruction_box.cpp:58`). Place that click last in the scenario, per step 3 of Building one reliable packed scenario above. `PressKey(Qt::Key_Enter)` is a last resort only, and log which path submitted (`submittedVia=`) so a fallback stays visible. |
|
||||
| Text was typed and nothing was inserted: the field's own text stays empty and `changes()` never fires, even with `focused=1 focusWidget=QTextEdit` | The events were aimed at the `Ui::InputField` wrapper, which does not override `keyPressEvent` (`input_field.h:433-437`), and Qt propagates an ignored key event up the parent chain and never down into a child - the `QEvent::KeyPress` case of `QApplication::notify` re-delivers it to `w->parentWidget()` until it is accepted or a window is reached - so the keys reach the wrapper's ancestors right up to the primary window and never the inner `QTextEdit` that owns the text. | Type into `dynamic_cast<Ui::InputField*>(widget)->rawTextEdit()`, fall back to `Test::CommitText` on that same editor when the text stays empty, and prove insertion by reading the field's own `getLastText()` afterwards. |
|
||||
| A text oracle fails on a date, time or number whose two sides read identically in the details | The formatter's U+202F or U+00A0 against the read-back's U+0020. `Ui::Text::String`'s block parser maps every space-class character except U+00A0 to U+0020 (`text_block_parser.cpp`, `replaceWithSpace`), so a `Ui::FlatLabel` fed `langDateTime()`'s narrow no-break space before AM/PM reads back a plain space through `accessibilityName()`, while a U+00A0 survives the parse. | Compare through `Test::CheckTextReads` / `Test::NormalizeSpaces` (`test_text_reads.h`): they map U+202F and U+00A0 to U+0020 on both sides and print both raw strings' distinct `U+XXXX` whitespace tokens on the passing verdict as well as the failing one. Never loosen digits, month or AM/PM. |
|
||||
| A check is missing from the log with no PASS and no FAIL | The stage's gate read false and it ran as a hand-rolled no-op - `run` and `then` skipped, `until` answered true - so it wrote nothing at all. Distinct from the stages a timed-out stage or the watchdog skips (see “Scenario teardown before quit” above), which leave the timeout's own FAIL behind them. | Give the stage a `skipReason` (`test_runner.h`): a non-empty return emits `TEST_RESULT: N/A: <stage> - <reason>` in the turn that begins it, counts in `SCENARIO_RESULT`'s skipped clause and in the `test-run` report's skipped list, and never waits or times out. Confirm the instrument with the `test_gated_stage.h` self-test. |
|
||||
|
||||
Classify a sound assertion against changed behavior as an implementation bug,
|
||||
not a test flaw. Classify a wrong fixture, target, readiness model, event
|
||||
|
||||
77
Telegram/SourceFiles/test/test_gated_stage.cpp
Normal file
77
Telegram/SourceFiles/test/test_gated_stage.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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_gated_stage.h"
|
||||
|
||||
#include "test/test_log.h"
|
||||
#include "test/test_runner.h"
|
||||
|
||||
namespace Test {
|
||||
|
||||
void AppendGatedStageSelfTest(not_null<Runner*> runner) {
|
||||
struct State {
|
||||
crl::time armedAt = 0;
|
||||
crl::time appliedAt = 0;
|
||||
int skippedRuns = 0;
|
||||
int skippedUntilPolls = 0;
|
||||
int skippedThens = 0;
|
||||
int appliedRuns = 0;
|
||||
};
|
||||
const auto state = std::make_shared<State>();
|
||||
|
||||
runner->add({
|
||||
.name = u"gated stage self-test: arm"_q,
|
||||
.then = [=] { state->armedAt = crl::now(); },
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"gated stage self-test: a stage whose gate reads false"_q,
|
||||
.skipReason = [] {
|
||||
return u"applies=0: the self-test gate is false by "
|
||||
"construction"_q;
|
||||
},
|
||||
.run = [=] { ++state->skippedRuns; },
|
||||
.until = [=] {
|
||||
++state->skippedUntilPolls;
|
||||
return false;
|
||||
},
|
||||
.then = [=] { ++state->skippedThens; },
|
||||
.timeout = crl::time(1000),
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"gated stage self-test: a stage whose gate reads true"_q,
|
||||
.skipReason = [] { return QString(); },
|
||||
.run = [=] {
|
||||
state->appliedAt = crl::now();
|
||||
++state->appliedRuns;
|
||||
},
|
||||
.until = [] { return true; },
|
||||
.then = [=] {
|
||||
Check(
|
||||
(state->appliedRuns == 1)
|
||||
&& !state->skippedRuns
|
||||
&& !state->skippedUntilPolls
|
||||
&& !state->skippedThens,
|
||||
u"gated stage self-test: the false gate skipped run, "
|
||||
"until and then and the true gate ran"_q,
|
||||
u"skippedRun=%1 skippedUntilPolls=%2 skippedThen=%3 "
|
||||
"appliedRun=%4 elapsedSinceArmMs=%5"_q
|
||||
.arg(state->skippedRuns)
|
||||
.arg(state->skippedUntilPolls)
|
||||
.arg(state->skippedThens)
|
||||
.arg(state->appliedRuns)
|
||||
.arg(state->appliedAt - state->armedAt));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Test
|
||||
|
||||
#endif // _DEBUG
|
||||
50
Telegram/SourceFiles/test/test_gated_stage.h
Normal file
50
Telegram/SourceFiles/test/test_gated_stage.h
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 "base/basic_types.h"
|
||||
|
||||
namespace Test {
|
||||
|
||||
class Runner;
|
||||
|
||||
// A scenario stage that does not apply used to be hand-rolled: the gate made
|
||||
// |run| and |then| no-ops and answered |until| true, so the stage wrote no
|
||||
// verdict row at all. The overlay of
|
||||
// 2026/09/01/route-share-fetches-through-a-dedicated-dc-shift carried exactly
|
||||
// that lambda, and its silence is indistinguishable in the log from a check
|
||||
// that was never written - a reader sees neither a PASS nor a FAIL and cannot
|
||||
// tell a deliberate skip from a missing measurement, which is the same class
|
||||
// of unreadable negative the harness refuses everywhere else.
|
||||
// Test::Stage::skipReason removes the silence: a non-empty reason writes one
|
||||
// TEST_RESULT: N/A: <stage> - <reason> row, skips |run|, |until| and |then|,
|
||||
// and moves to the next stage in the turn that began this one, so a false
|
||||
// gate never waits and never times out.
|
||||
//
|
||||
// AppendGatedStageSelfTest is that instrument measuring itself. It registers
|
||||
// one stage whose gate reads false by construction and one whose gate reads
|
||||
// true, and the false-gated stage carries a never-ready |until| under a
|
||||
// one-second timeout: if the skip did not take, that stage fails by timeout
|
||||
// inside a second instead of hanging the run, which is what makes the single
|
||||
// check falsifiable rather than vacuous. The check is in the true-gated
|
||||
// stage's |then| and reads the counters both stages kept - the skipped
|
||||
// stage's |run|, |until| and |then| must each have been entered zero times
|
||||
// and the applied stage's |run| exactly once - together with the
|
||||
// milliseconds elapsed since the opening stage's |then| armed the clock, so
|
||||
// that interval holds the skipped stage alone and not the arm stage's own
|
||||
// tick, which is the reading for "the stage completes in the turn that
|
||||
// begins it".
|
||||
//
|
||||
// It needs no session, no chats list, no network, no wallet, no fixture
|
||||
// secret and no funded value, and it builds no widget, so it asks nothing of
|
||||
// the process and has nothing to tear down. It emits no deliberate failure:
|
||||
// on a healthy harness its run carries exactly one TEST_RESULT: N/A row,
|
||||
// exactly one TEST_RESULT: PASS row and no FAIL.
|
||||
void AppendGatedStageSelfTest(not_null<Runner*> runner);
|
||||
|
||||
} // namespace Test
|
||||
@@ -15,6 +15,7 @@ namespace Test {
|
||||
namespace {
|
||||
|
||||
auto FailuresCount = 0;
|
||||
auto SkippedCountValue = 0;
|
||||
auto CompletedAtValue = crl::time(0);
|
||||
|
||||
[[nodiscard]] QString EnsuredDir(const QString &path) {
|
||||
@@ -66,6 +67,13 @@ void Fail(const QString &text, const QString &details) {
|
||||
: u"TEST_RESULT: FAIL: %1 - %2"_q.arg(text, details));
|
||||
}
|
||||
|
||||
void Skipped(const QString &text, const QString &details) {
|
||||
++SkippedCountValue;
|
||||
LogRaw(details.isEmpty()
|
||||
? u"TEST_RESULT: N/A: %1"_q.arg(text)
|
||||
: u"TEST_RESULT: N/A: %1 - %2"_q.arg(text, details));
|
||||
}
|
||||
|
||||
void Check(bool ok, const QString &what, const QString &details) {
|
||||
if (ok) {
|
||||
Pass(what, details);
|
||||
@@ -107,6 +115,10 @@ int FailureCount() {
|
||||
return FailuresCount;
|
||||
}
|
||||
|
||||
int SkippedCount() {
|
||||
return SkippedCountValue;
|
||||
}
|
||||
|
||||
void Complete() {
|
||||
CompletedAtValue = crl::now();
|
||||
LogRaw(u"TEST_COMPLETE"_q);
|
||||
|
||||
@@ -26,6 +26,14 @@ void Step(const QString &text);
|
||||
void Pass(const QString &text, const QString &details = QString());
|
||||
void Fail(const QString &text, const QString &details = QString());
|
||||
|
||||
// A stage or check that did not apply: its gate read false, so nothing was
|
||||
// measured. Same grammar as Pass and Fail - "TEST_RESULT: N/A: <what>" with
|
||||
// " - <details>" appended when details exist - counted separately from both
|
||||
// and never a failure, because a scenario's verdict stays decided by its
|
||||
// failure count alone. |details| carries the gate's reason, so a reader can
|
||||
// tell a deliberate skip from a missing measurement.
|
||||
void Skipped(const QString &what, const QString &details = QString());
|
||||
|
||||
// |details| is an observation - the reading the verdict was made against -
|
||||
// and it is printed whether the check holds or not, so a green log says what
|
||||
// each check reached and a passing run can be audited without re-running it.
|
||||
@@ -50,6 +58,7 @@ void CheckNear(
|
||||
void LogGeometry(const QString &name, const QRect &rect);
|
||||
|
||||
[[nodiscard]] int FailureCount();
|
||||
[[nodiscard]] int SkippedCount();
|
||||
|
||||
// Writes the TEST_COMPLETE marker the external runner waits for, and
|
||||
// records when it was written. CompletedAt() is crl::now() at that
|
||||
|
||||
306
Telegram/SourceFiles/test/test_rpc_retry.cpp
Normal file
306
Telegram/SourceFiles/test/test_rpc_retry.cpp
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
#include "test/test_rpc_retry.h"
|
||||
|
||||
#ifdef _DEBUG
|
||||
|
||||
#include "core/application.h"
|
||||
#include "main/main_account.h"
|
||||
#include "main/main_domain.h"
|
||||
#include "mtproto/mtp_instance.h"
|
||||
#include "mtproto/sender.h"
|
||||
#include "test/test_agent.h"
|
||||
#include "test/test_log.h"
|
||||
#include "test/test_probe.h"
|
||||
#include "test/test_runner.h"
|
||||
|
||||
#include <QtCore/QCoreApplication>
|
||||
|
||||
namespace Test {
|
||||
namespace {
|
||||
|
||||
// The delayed resend of a first 500 is scheduled one second out, so a
|
||||
// healthy connection answers well inside this cap; a slow or offline host
|
||||
// degrades the observation to a Note instead of failing the run, because
|
||||
// the network is not what this self-test measures. The cap stays well
|
||||
// under kDefaultStageTimeout, so that stage can never be the run's timeout.
|
||||
constexpr auto kResendAnswerCap = crl::time(5000);
|
||||
|
||||
// The distance from a real request id to one this process never allocated.
|
||||
// The derived id is re-checked against hasCallback(), never assumed free.
|
||||
constexpr auto kOrphanIdDistance = 1000000;
|
||||
|
||||
} // namespace
|
||||
|
||||
Probe &RpcRetryProbe() {
|
||||
static auto result = Probe(u"mtp"_q);
|
||||
return result;
|
||||
}
|
||||
|
||||
void RecordRpcRetry(int code, const QString &type, uint32 request) {
|
||||
if (!Active()) {
|
||||
return;
|
||||
}
|
||||
// One multi-argument arg(), never a chain: a '%' inside a server-sent
|
||||
// |type| would otherwise be re-substituted by the next arg() call.
|
||||
RpcRetryProbe().record(
|
||||
u"rpc retry code=%1 type=%2 request=0x%3"_q.arg(
|
||||
QString::number(code),
|
||||
type,
|
||||
QString::number(request, 16).rightJustified(8, QChar('0'))));
|
||||
}
|
||||
|
||||
void AppendRpcRetrySelfTest(not_null<Runner*> runner) {
|
||||
struct State {
|
||||
MTP::Instance *instance = nullptr;
|
||||
std::unique_ptr<MTP::Sender> sender;
|
||||
std::vector<QString> rows500;
|
||||
std::vector<QString> rows400;
|
||||
mtpRequestId id500 = 0;
|
||||
mtpRequestId id400 = 0;
|
||||
mtpRequestId orphanId = 0;
|
||||
crl::time waitStartedAt = 0;
|
||||
int mark500 = 0;
|
||||
int mark400 = 0;
|
||||
int markNone = 0;
|
||||
int failCode500 = 0;
|
||||
int failCode400 = 0;
|
||||
bool pending500Before = false;
|
||||
bool pending500After = false;
|
||||
bool pending400Before = false;
|
||||
bool pending400After = false;
|
||||
bool pendingNoneBefore = false;
|
||||
bool done500 = false;
|
||||
bool mainThread = false;
|
||||
};
|
||||
// Leaked on purpose, the way the harness's other self-tests leak theirs:
|
||||
// the stages outlive this call. The teardown stage releases the Sender,
|
||||
// after which the State holds nothing but QStrings and PODs.
|
||||
const auto state = new State();
|
||||
|
||||
runner->waitForSessionReady();
|
||||
|
||||
runner->add({
|
||||
.name = u"rpc retry self-test: a code-500 answer records one "
|
||||
"retry row"_q,
|
||||
.run = [=] {
|
||||
state->instance = &Core::App().domain().active().mtp();
|
||||
state->sender = std::make_unique<MTP::Sender>(state->instance);
|
||||
state->id500 = state->sender->request(
|
||||
MTPhelp_GetConfig()
|
||||
).done([=] {
|
||||
state->done500 = true;
|
||||
}).fail([=](const MTP::Error &error) {
|
||||
state->failCode500 = error.code();
|
||||
}).send();
|
||||
state->pending500Before
|
||||
= state->instance->hasCallback(state->id500);
|
||||
state->mark500 = RpcRetryProbe().mark();
|
||||
|
||||
auto response = MTP::Response();
|
||||
response.requestId = state->id500;
|
||||
MTPRpcError(MTP_rpc_error(
|
||||
MTP_int(500),
|
||||
MTP_string("SELFTEST_UNAVAILABLE"))
|
||||
).write(response.reply);
|
||||
state->instance->processCallback(response);
|
||||
|
||||
// Read in the same main-thread turn that delivered the answer:
|
||||
// that reading is this call's thread-affinity evidence, not an
|
||||
// assumption about the transport's own path.
|
||||
state->rows500 = RpcRetryProbe().rowsSince(state->mark500);
|
||||
state->pending500After
|
||||
= state->instance->hasCallback(state->id500);
|
||||
state->mainThread = (QThread::currentThread()
|
||||
== QCoreApplication::instance()->thread());
|
||||
},
|
||||
.then = [=] {
|
||||
const auto reading = u"requestId=%1 pendingBefore=%2 "
|
||||
"pendingAfter=%3 rowsInWindow=%4 mainThread=%5"_q.arg(
|
||||
QString::number(state->id500),
|
||||
state->pending500Before ? u"1"_q : u"0"_q,
|
||||
state->pending500After ? u"1"_q : u"0"_q,
|
||||
QString::number(int(state->rows500.size())),
|
||||
state->mainThread ? u"1"_q : u"0"_q);
|
||||
Check(
|
||||
state->pending500Before,
|
||||
u"rpc retry self-test: the synthesized 500 found its "
|
||||
"request pending"_q,
|
||||
reading);
|
||||
if (!state->pending500Before) {
|
||||
// The answer found no parser, so processCallback recorded
|
||||
// nothing and every reading below would pass vacuously.
|
||||
return;
|
||||
}
|
||||
RpcRetryProbe().checkCountSince(
|
||||
state->mark500,
|
||||
u"rpc retry "_q,
|
||||
1,
|
||||
u"rpc retry self-test: exactly one retry row in the "
|
||||
"window"_q);
|
||||
// Rebuilt from mtpc_help_getConfig here instead of shared with
|
||||
// the seam, so a change to the row's format is caught by this
|
||||
// check rather than cancelled out by it.
|
||||
RpcRetryProbe().checkSawSince(
|
||||
state->mark500,
|
||||
u"rpc retry code=500 type=SELFTEST_UNAVAILABLE "
|
||||
"request=0x%1"_q.arg(
|
||||
QString::number(uint32(mtpc_help_getConfig), 16)
|
||||
.rightJustified(8, QChar('0'))),
|
||||
u"rpc retry self-test: the row names code 500 and the "
|
||||
"request's own constructor id"_q);
|
||||
Check(
|
||||
state->pending500After,
|
||||
u"rpc retry self-test: the 500'd request stays registered "
|
||||
"for the delayed resend"_q,
|
||||
reading);
|
||||
},
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"rpc retry self-test: a non-500 answer records no retry "
|
||||
"row and reaches .fail()"_q,
|
||||
.run = [=] {
|
||||
state->id400 = state->sender->request(
|
||||
MTPhelp_GetConfig()
|
||||
).fail([=](const MTP::Error &error) {
|
||||
state->failCode400 = error.code();
|
||||
}).send();
|
||||
state->pending400Before
|
||||
= state->instance->hasCallback(state->id400);
|
||||
state->mark400 = RpcRetryProbe().mark();
|
||||
|
||||
auto response = MTP::Response();
|
||||
response.requestId = state->id400;
|
||||
MTPRpcError(MTP_rpc_error(
|
||||
MTP_int(400),
|
||||
MTP_string("SELFTEST_BAD_REQUEST"))
|
||||
).write(response.reply);
|
||||
state->instance->processCallback(response);
|
||||
|
||||
state->rows400 = RpcRetryProbe().rowsSince(state->mark400);
|
||||
state->pending400After
|
||||
= state->instance->hasCallback(state->id400);
|
||||
},
|
||||
.then = [=] {
|
||||
const auto reading = u"requestId=%1 pendingBefore=%2 "
|
||||
"pendingAfter=%3 rowsInWindow=%4 failCode=%5"_q.arg(
|
||||
QString::number(state->id400),
|
||||
state->pending400Before ? u"1"_q : u"0"_q,
|
||||
state->pending400After ? u"1"_q : u"0"_q,
|
||||
QString::number(int(state->rows400.size())),
|
||||
QString::number(state->failCode400));
|
||||
Check(
|
||||
state->pending400Before,
|
||||
u"rpc retry self-test: the synthesized 400 found its "
|
||||
"request pending"_q,
|
||||
reading);
|
||||
if (!state->pending400Before) {
|
||||
return;
|
||||
}
|
||||
RpcRetryProbe().checkNoneSince(
|
||||
state->mark400,
|
||||
u"rpc retry "_q,
|
||||
u"rpc retry self-test: a non-500 answer records no retry "
|
||||
"row"_q);
|
||||
Check(
|
||||
(state->failCode400 == 400) && !state->pending400After,
|
||||
u"rpc retry self-test: the synthesized 400 reached the "
|
||||
"request's .fail() and unregistered it"_q,
|
||||
reading);
|
||||
},
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"rpc retry self-test: an answer that finds no pending "
|
||||
"request records nothing"_q,
|
||||
.run = [=] {
|
||||
state->orphanId = state->id400 + kOrphanIdDistance;
|
||||
state->markNone = RpcRetryProbe().mark();
|
||||
state->pendingNoneBefore
|
||||
= state->instance->hasCallback(state->orphanId);
|
||||
|
||||
auto response = MTP::Response();
|
||||
response.requestId = state->orphanId;
|
||||
MTPRpcError(MTP_rpc_error(
|
||||
MTP_int(500),
|
||||
MTP_string("SELFTEST_ORPHAN"))
|
||||
).write(response.reply);
|
||||
state->instance->processCallback(response);
|
||||
},
|
||||
.then = [=] {
|
||||
Check(
|
||||
!state->pendingNoneBefore,
|
||||
u"rpc retry self-test: the orphan request id was really "
|
||||
"unknown to the instance"_q,
|
||||
u"orphanId=%1 pendingBefore=%2"_q.arg(
|
||||
QString::number(state->orphanId),
|
||||
state->pendingNoneBefore ? u"1"_q : u"0"_q));
|
||||
RpcRetryProbe().checkNoneSince(
|
||||
state->markNone,
|
||||
u"rpc retry "_q,
|
||||
u"rpc retry self-test: an answer for an unknown request id "
|
||||
"records no retry row, which is why the pendingBefore "
|
||||
"guard on the other legs is not decorative"_q);
|
||||
},
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"rpc retry self-test: the resent request eventually "
|
||||
"answers"_q,
|
||||
.run = [=] {
|
||||
state->waitStartedAt = crl::now();
|
||||
},
|
||||
.until = [=] {
|
||||
return state->done500
|
||||
|| (state->failCode500 != 0)
|
||||
|| (crl::now() - state->waitStartedAt > kResendAnswerCap);
|
||||
},
|
||||
.then = [=] {
|
||||
auto answered = u"none"_q;
|
||||
if (state->done500) {
|
||||
answered = u"done"_q;
|
||||
} else if (state->failCode500) {
|
||||
answered = u"fail"_q;
|
||||
}
|
||||
Note(u"rpc retry self-test: the delayed resend the seam left "
|
||||
"untouched: answered=%1 code=%2 elapsedMs=%3"_q.arg(
|
||||
answered,
|
||||
QString::number(state->failCode500),
|
||||
QString::number(crl::now() - state->waitStartedAt)));
|
||||
},
|
||||
.timeout = kDefaultStageTimeout,
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"rpc retry self-test: teardown"_q,
|
||||
.run = [=] {
|
||||
state->sender = nullptr;
|
||||
state->instance = nullptr;
|
||||
Note(u"rpc retry self-test: a cancelled delayed request leaves "
|
||||
"its _delayedRequests entry behind, and checkDelayedRequests "
|
||||
"discharges it with one benign \"MTP Error: could not find "
|
||||
"request dc for delayed resend\" line in the application "
|
||||
"log - pre-existing MTP behaviour, not a defect this seam "
|
||||
"introduces"_q);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Test
|
||||
|
||||
#else // _DEBUG
|
||||
|
||||
namespace Test {
|
||||
|
||||
void RecordRpcRetry(int, const QString &, uint32) {
|
||||
}
|
||||
|
||||
} // namespace Test
|
||||
|
||||
#endif // _DEBUG
|
||||
63
Telegram/SourceFiles/test/test_rpc_retry.h
Normal file
63
Telegram/SourceFiles/test/test_rpc_retry.h
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
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 "base/basic_types.h"
|
||||
|
||||
namespace Test {
|
||||
|
||||
class Probe;
|
||||
class Runner;
|
||||
|
||||
// Records one "rpc retry code=<code> type=<type> request=<constructor>" row
|
||||
// for an MTP answer the transport auto-resends without ever calling the
|
||||
// request's fail handler. |request| is the boxed body constructor id,
|
||||
// printed as 0x-prefixed lowercase hex; never a request or answer byte,
|
||||
// never a payload, never a secret. No-op unless Active(), so the call site
|
||||
// in application code needs no condition around it, and the Release
|
||||
// branch of this module defines this function alone.
|
||||
void RecordRpcRetry(int code, const QString &type, uint32 request);
|
||||
|
||||
// The record those rows go into. Debug-only: like BlockedLaunches() in
|
||||
// test_launch_fuse.h it is declared here and deliberately left undefined
|
||||
// in a Release build, because no production code reads it.
|
||||
[[nodiscard]] Probe &RpcRetryProbe();
|
||||
|
||||
// The seam's own self-test. MTP::Instance::Private::onErrorDefault resends
|
||||
// every code-500 - and every negative-code - answer after a doubling delay
|
||||
// and returns true, so rpcErrorOccured returns false and the request's own
|
||||
// .fail() is never reached: a scenario waiting on a product row sees no
|
||||
// .done(), no .fail() and no product outcome at all, only its own stage
|
||||
// timeout, while the application Debug log repeats "RPC Info: error
|
||||
// received, code 500, type <T>". RecordRpcRetry is what makes that visible
|
||||
// in the harness log, and RpcRetryProbe() is where a scenario reads it -
|
||||
// through a mark() taken before the action, never over the whole history.
|
||||
//
|
||||
// The self-test issues harmless, idempotent MTPhelp_GetConfig() requests
|
||||
// through an ordinary MTP::Sender - with its default FailSkipPolicy::Simple
|
||||
// and never handleAllErrors() or handleFloodErrors(), because a fail
|
||||
// handler that returns true for a 500 makes rpcErrorOccured return before
|
||||
// onErrorDefault and the seam would then never run - and delivers
|
||||
// synthesized rpc_error answers for them through the already public
|
||||
// MTP::Instance::processCallback. One code 500 must record exactly one row
|
||||
// naming that request's own constructor id and must leave the request
|
||||
// registered for the delayed resend; one code 400 must record no row and
|
||||
// must reach that request's .fail(); and a third leg delivers a 500 for a
|
||||
// request id this process never sent, so the "an answer that finds no
|
||||
// pending request" refusal has positive evidence instead of a guard that
|
||||
// never fires in a healthy run. Every leg reads its rows in the same
|
||||
// main-thread turn that called processCallback(), which is this
|
||||
// self-test's own reading that the call reached the seam synchronously.
|
||||
//
|
||||
// It needs no wallet, no fixture secret, no chats list and no funded value;
|
||||
// the only thing it asks of the process is a ready session to send through.
|
||||
// It appends its own teardown as its last stage, and it emits no deliberate
|
||||
// failure - every stage is expected to PASS on a healthy harness.
|
||||
void AppendRpcRetrySelfTest(not_null<Runner*> runner);
|
||||
|
||||
} // namespace Test
|
||||
@@ -370,6 +370,9 @@ void Runner::start() {
|
||||
});
|
||||
_watchdog.callOnce(WatchdogTimeout());
|
||||
beginStage();
|
||||
if (_finished) {
|
||||
return;
|
||||
}
|
||||
_ticker.setCallback([=] { tick(); });
|
||||
_ticker.callEach(kTickInterval);
|
||||
}
|
||||
@@ -396,11 +399,24 @@ void Runner::tick() {
|
||||
}
|
||||
|
||||
void Runner::beginStage() {
|
||||
const auto &stage = _stages[_index];
|
||||
Step(stage.name);
|
||||
_stageStarted = crl::now();
|
||||
if (stage.run) {
|
||||
stage.run();
|
||||
while (true) {
|
||||
const auto &stage = _stages[_index];
|
||||
Step(stage.name);
|
||||
_stageStarted = crl::now();
|
||||
const auto reason = stage.skipReason
|
||||
? stage.skipReason()
|
||||
: QString();
|
||||
if (reason.isEmpty()) {
|
||||
if (stage.run) {
|
||||
stage.run();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Skipped(stage.name, reason);
|
||||
if (++_index == int(_stages.size())) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,9 +445,13 @@ void Runner::finish() {
|
||||
});
|
||||
base::call_delayed(kFinishDrainDelay, [] {
|
||||
const auto failures = FailureCount();
|
||||
LogRaw(u"SCENARIO_RESULT: %1 (failures: %2)"_q.arg(
|
||||
const auto skipped = SkippedCount();
|
||||
LogRaw(u"SCENARIO_RESULT: %1 (failures: %2%3)"_q.arg(
|
||||
failures ? u"FAIL"_q : u"PASS"_q,
|
||||
QString::number(failures)));
|
||||
QString::number(failures),
|
||||
skipped
|
||||
? u", skipped: %1"_q.arg(skipped)
|
||||
: QString()));
|
||||
Complete();
|
||||
Core::Quit();
|
||||
});
|
||||
|
||||
@@ -22,9 +22,13 @@ inline constexpr auto kStartupStageTimeout = crl::time(30000);
|
||||
// true (immediately ready when null), then runs |then| assertions/actions.
|
||||
// Expected product results belong in |then|, not |until|. A stage past its
|
||||
// |timeout| fails the scenario and finishes early — the scenario always ends
|
||||
// in TEST_COMPLETE and quit, never a hang.
|
||||
// in TEST_COMPLETE and quit, never a hang. A stage whose |skipReason| returns
|
||||
// a non-empty string does not apply: the runner writes TEST_RESULT: N/A with
|
||||
// that reason, skips |run|, |until| and |then|, and moves to the next stage
|
||||
// in the same turn, so a false gate never waits and never times out.
|
||||
struct Stage {
|
||||
QString name;
|
||||
Fn<QString()> skipReason;
|
||||
Fn<void()> run;
|
||||
Fn<bool()> until;
|
||||
Fn<void()> then;
|
||||
|
||||
188
Telegram/SourceFiles/test/test_text_reads.cpp
Normal file
188
Telegram/SourceFiles/test/test_text_reads.cpp
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
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_text_reads.h"
|
||||
|
||||
#include "base/unique_qptr.h"
|
||||
#include "test/test_log.h"
|
||||
#include "test/test_runner.h"
|
||||
#include "ui/widgets/labels.h"
|
||||
|
||||
#include "styles/style_widgets.h"
|
||||
|
||||
namespace Test {
|
||||
namespace {
|
||||
|
||||
// The distinct whitespace code points of |text| as U+XXXX tokens, in order
|
||||
// of appearance. QChar::isSpace() is true for the whole Zs category, so the
|
||||
// tokens cover both classes NormalizeSpaces maps and any other whitespace
|
||||
// the string happens to carry.
|
||||
[[nodiscard]] QString SpaceClasses(const QString &text) {
|
||||
auto seen = base::flat_set<uint>();
|
||||
auto result = QStringList();
|
||||
for (const auto ch : text) {
|
||||
const auto code = uint(ch.unicode());
|
||||
if (ch.isSpace() && seen.emplace(code).second) {
|
||||
result.push_back(u"U+%1"_q.arg(
|
||||
QString::number(code, 16).toUpper().rightJustified(
|
||||
4,
|
||||
QChar('0'))));
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? u"none"_q : result.join(QChar(','));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString NormalizeSpaces(const QString &text) {
|
||||
auto result = text;
|
||||
result.replace(QChar(0x00A0), QChar(0x0020));
|
||||
result.replace(QChar(0x202F), QChar(0x0020));
|
||||
return result;
|
||||
}
|
||||
|
||||
void CheckTextReads(
|
||||
const QString &read,
|
||||
const QString &expected,
|
||||
const QString &what) {
|
||||
// One multi-argument arg(), never a chain: the compared strings are
|
||||
// arbitrary product text and may themselves contain a % sequence, which
|
||||
// a chained arg() would take for the next placeholder.
|
||||
Check(
|
||||
NormalizeSpaces(read) == NormalizeSpaces(expected),
|
||||
what,
|
||||
u"read=\"%1\" readSpaces=%2 expected=\"%3\" expectedSpaces=%4"_q.arg(
|
||||
read,
|
||||
SpaceClasses(read),
|
||||
expected,
|
||||
SpaceClasses(expected)));
|
||||
}
|
||||
|
||||
void AppendTextReadsSelfTest(not_null<Runner*> runner) {
|
||||
struct State {
|
||||
base::unique_qptr<Ui::FlatLabel> narrow;
|
||||
base::unique_qptr<Ui::FlatLabel> nbsp;
|
||||
QString readNarrow;
|
||||
QString readNbsp;
|
||||
};
|
||||
// Leaked on purpose, the way the harness's other self-tests leak theirs:
|
||||
// the stages outlive this call. The teardown stage destroys both labels,
|
||||
// after which the State holds nothing but QStrings.
|
||||
const auto state = new State();
|
||||
const auto narrowText = u"Sep 2 at 9:21"_q + QChar(0x202F) + u"AM"_q;
|
||||
const auto nbspText = u"Sep 2 at 9:21"_q + QChar(0x00A0) + u"AM"_q;
|
||||
const auto plainText = u"Sep 2 at 9:21 AM"_q;
|
||||
|
||||
runner->add({
|
||||
.name = u"text reads self-test: a label's read-back against the "
|
||||
"formatter's string"_q,
|
||||
.run = [=] {
|
||||
// Parentless and never shown: accessibilityName() returns the
|
||||
// parsed text, which the label owns before any layout, paint or
|
||||
// grab happens, so this self-test asks the process for no
|
||||
// primary window and has no fixture gate to report.
|
||||
state->narrow = base::make_unique_q<Ui::FlatLabel>(
|
||||
nullptr,
|
||||
st::defaultFlatLabel);
|
||||
state->nbsp = base::make_unique_q<Ui::FlatLabel>(
|
||||
nullptr,
|
||||
st::defaultFlatLabel);
|
||||
state->narrow->setText(narrowText);
|
||||
state->nbsp->setText(nbspText);
|
||||
state->readNarrow = state->narrow->accessibilityName();
|
||||
state->readNbsp = state->nbsp->accessibilityName();
|
||||
},
|
||||
.then = [=] {
|
||||
Note(u"text reads self-test: byte-exact comparison of the "
|
||||
"U+202F label: equal=%1 read=\"%2\" readSpaces=%3 "
|
||||
"expected=\"%4\" expectedSpaces=%5 - recorded and not "
|
||||
"asserted, so it is neither a PASS nor a FAIL: equal=0 is "
|
||||
"the flaw the check below answers, while equal=1 would mean "
|
||||
"this host's read-back preserved the class and the "
|
||||
"string-only pairs would still prove the helper"_q.arg(
|
||||
QString::number(
|
||||
(state->readNarrow == narrowText) ? 1 : 0),
|
||||
state->readNarrow,
|
||||
SpaceClasses(state->readNarrow),
|
||||
narrowText,
|
||||
SpaceClasses(narrowText)));
|
||||
Note(u"text reads self-test: the U+00A0 label reads back "
|
||||
"\"%1\" with spaces %2 - the block parser excludes "
|
||||
"QChar::Nbsp from the replacement it applies to every "
|
||||
"other space class, and this line is that host reading "
|
||||
"rather than an assumption"_q.arg(
|
||||
state->readNbsp,
|
||||
SpaceClasses(state->readNbsp)));
|
||||
CheckTextReads(
|
||||
state->readNarrow,
|
||||
narrowText,
|
||||
u"text reads self-test: the label's read-back matches the "
|
||||
"formatter-shaped string across the space class"_q);
|
||||
CheckTextReads(
|
||||
state->readNbsp,
|
||||
plainText,
|
||||
u"text reads self-test: a U+00A0 read-back matches a "
|
||||
"U+0020 expectation"_q);
|
||||
const auto normalized = NormalizeSpaces(narrowText);
|
||||
Check(
|
||||
(normalized == plainText)
|
||||
&& (normalized.size() == narrowText.size()),
|
||||
u"text reads self-test: NormalizeSpaces maps the space "
|
||||
"class and changes nothing else - every digit, letter and "
|
||||
"punctuation mark, and the length, are preserved"_q,
|
||||
u"input=\"%1\" inputSpaces=%2 output=\"%3\" "
|
||||
"outputSpaces=%4 inputLength=%5 outputLength=%6"_q.arg(
|
||||
narrowText,
|
||||
SpaceClasses(narrowText),
|
||||
normalized,
|
||||
SpaceClasses(normalized),
|
||||
QString::number(narrowText.size()),
|
||||
QString::number(normalized.size())));
|
||||
},
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"text reads self-test: the negative controls"_q,
|
||||
.then = [=] {
|
||||
Note(u"text reads self-test: the two FAIL rows below are this "
|
||||
"self-test's negative controls - the check is expected to "
|
||||
"refuse both"_q);
|
||||
CheckTextReads(
|
||||
plainText,
|
||||
u"Sep 2 at 9:22"_q + QChar(0x202F) + u"AM"_q,
|
||||
u"text reads self-test negative control: a different "
|
||||
"minute is not accepted"_q);
|
||||
CheckTextReads(
|
||||
plainText,
|
||||
u"Sep 3 at 9:21 AM"_q,
|
||||
u"text reads self-test negative control: a different digit "
|
||||
"sharing the space class is not accepted"_q);
|
||||
},
|
||||
});
|
||||
|
||||
runner->add({
|
||||
.name = u"text reads 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: both labels are parentless top levels this
|
||||
// State alone owns, and releasing the unique_qptrs is what
|
||||
// destroys them.
|
||||
state->narrow = nullptr;
|
||||
state->nbsp = nullptr;
|
||||
Note(u"text reads self-test: labels released, alive=%1"_q.arg(
|
||||
QString::number((state->narrow ? 1 : 0)
|
||||
+ (state->nbsp ? 1 : 0))));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Test
|
||||
|
||||
#endif // _DEBUG
|
||||
81
Telegram/SourceFiles/test/test_text_reads.h
Normal file
81
Telegram/SourceFiles/test/test_text_reads.h
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 "base/basic_types.h"
|
||||
|
||||
#include <QtCore/QString>
|
||||
|
||||
namespace Test {
|
||||
|
||||
class Runner;
|
||||
|
||||
// Maps the no-break space classes U+00A0 NO-BREAK SPACE and U+202F NARROW
|
||||
// NO-BREAK SPACE to U+0020 SPACE, and changes nothing else: digits,
|
||||
// letters, punctuation, every other character and the length of the
|
||||
// string are preserved, because each mapping is one character for one.
|
||||
[[nodiscard]] QString NormalizeSpaces(const QString &text);
|
||||
|
||||
// PASS when |read| and |expected| are equal after NormalizeSpaces on both
|
||||
// sides, FAIL otherwise, reported through Test::Check - there is no second
|
||||
// logging path. Both verdicts print both raw strings and the distinct
|
||||
// whitespace code points of each, as U+XXXX tokens in order of appearance,
|
||||
// so a passing line still names the space classes it reconciled. Nothing
|
||||
// else is loosened: a different minute, digit, month or day period is
|
||||
// still a FAIL.
|
||||
void CheckTextReads(
|
||||
const QString &read,
|
||||
const QString &expected,
|
||||
const QString &what);
|
||||
|
||||
// A text oracle that compares a rendered string against a formatter-shaped
|
||||
// one fails on a date, a time or a number that reads identically in the
|
||||
// details, because the two sides carry different space classes.
|
||||
// langDateTime() (lang/lang_keys.cpp:190-197) embeds
|
||||
// QLocale().toString(time, QLocale::ShortFormat), and Qt's CLDR data for
|
||||
// en_US puts U+202F NARROW NO-BREAK SPACE before the AM/PM day period.
|
||||
// Ui::FlatLabel::setText hands that string to Ui::Text::String::setText,
|
||||
// whose BlockParser replaces every space-class character except
|
||||
// QChar::Nbsp with QChar::Space (text_block_parser.cpp:577-579 and
|
||||
// :638-640), so Ui::FlatLabel::accessibilityName() - which returns that
|
||||
// parsed text - reads back U+0020 where the formatter wrote U+202F, and a
|
||||
// byte-exact comparison fails on two strings a reader cannot tell apart.
|
||||
// The mechanism is the text parser, not the accessibility path and not the
|
||||
// locale: U+00A0 is the one class the parser leaves alone, so a label fed
|
||||
// U+00A0 reads U+00A0 back while one fed U+202F reads U+0020.
|
||||
//
|
||||
// AppendTextReadsSelfTest is those helpers measuring themselves. It builds
|
||||
// two parentless Ui::FlatLabels, one fed U+202F and one fed U+00A0, reads
|
||||
// both back through accessibilityName() - the same accessor the scenarios
|
||||
// use - and reports the host's actual read-backs as Test::Note lines
|
||||
// instead of asserting the mechanism. The reproduced byte-exact inequality
|
||||
// is one of those notes and never a Check, so it is neither a PASS nor a
|
||||
// FAIL; on a host whose read-back preserved U+202F the note says so, and
|
||||
// the string-only pairs still prove the helper. The compared strings are
|
||||
// the self-test's own literals rather than langDateTime()'s output, so no
|
||||
// stage depends on the host locale, and the sources stay ASCII because
|
||||
// every space class is written as QChar(0x202F) / QChar(0x00A0).
|
||||
//
|
||||
// It needs no primary window, no session, no chats list, no network, no
|
||||
// wallet, no fixture secret and no funded value: nothing is shown, painted
|
||||
// or grabbed, and accessibilityName() returns the parsed text independent
|
||||
// of layout, so there is no fixture gate for it to pass. It appends its
|
||||
// own teardown last, which destroys both labels.
|
||||
//
|
||||
// It is the harness's one self-test that emits deliberate failures. Two of
|
||||
// its rows are negative controls - a different minute, and a different
|
||||
// day-of-month digit sharing its counterpart's space class - and both are
|
||||
// expected to FAIL, because a check that accepted either would be
|
||||
// permissive rather than space-class-normalizing. A Test::Note immediately
|
||||
// before them announces that the next two FAIL rows are those controls, so
|
||||
// a reader of a shared log is not misled, and a run carrying this
|
||||
// self-test therefore ends with those two failures counted in its
|
||||
// SCENARIO_RESULT by design.
|
||||
void AppendTextReadsSelfTest(not_null<Runner*> runner);
|
||||
|
||||
} // namespace Test
|
||||
Reference in New Issue
Block a user