1
0
forked from BRT/arc

Add support for mock server state persistence, error tracking, and screenshot handling. Extend bootstrapping logic and monitoring endpoints for enhanced debugging and task management.

This commit is contained in:
slava 2026-04-14 18:46:34 +07:00
parent 628323184f
commit 899f4ff6d6
20 changed files with 933 additions and 492 deletions

4
.gitignore vendored
View File

@ -6,6 +6,10 @@
.DS_Store .DS_Store
.idea/ .idea/
# Mock server state
tests/mock_server/.mock_state.json
tests/mock_server/.mock_screenshots/
cmake_install.cmake cmake_install.cmake
CMakeCache.txt CMakeCache.txt
Makefile Makefile

View File

@ -4,6 +4,7 @@
#include <QString> #include <QString>
#include <QCoreApplication> #include <QCoreApplication>
#include <QFileInfo> #include <QFileInfo>
#include <QRegularExpression>
#include <QThread> #include <QThread>
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) { AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
@ -197,54 +198,69 @@ bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &pack
// adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml' // adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml'
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) { QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
const QString adb = adbPath(); const QString adb = adbPath();
QString output;
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android) for (int retry = 0; retry < 3; ++retry) {
bool dumpOk = false; // 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
for (int attempt = 0; attempt < 3; ++attempt) { bool dumpOk = false;
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) { for (int attempt = 0; attempt < 3; ++attempt) {
dumpOk = true; if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
break; dumpOk = true;
break;
}
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3";
QThread::msleep(1000);
} }
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3"; if (!dumpOk) {
QThread::msleep(1000); qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
}
if (!dumpOk) {
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
return {};
}
// 2. Optionally pull and save the XML file
if (screenshot) {
if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
QString("step_%1.xml").arg(idx)})) {
qWarning() << "Failed to pull UIDump XML.";
return {}; return {};
} }
} // 2. Optionally pull and save the XML file
// 3. Read the XML content if (screenshot) {
QString xmlOut; if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) { QString("step_%1.xml").arg(idx)})) {
qWarning() << "Failed to read UIDump XML."; qWarning() << "Failed to pull UIDump XML.";
return {}; return {};
}
}
// 3. Read the XML content
QString xmlOut;
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
qWarning() << "Failed to read UIDump XML.";
return {};
}
// 4. Remove temp XML file
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
// 5. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы
static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")");
const bool hasContent = reNonEmpty.match(xmlOut).hasMatch();
if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml")
|| xmlOut.size() < 500 || !hasContent) {
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
<< "), retry" << (retry + 1) << "of 3";
QThread::msleep(1500);
continue;
}
// 6. Screenshot (if needed)
if (screenshot) {
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
QString("screen_step_%1.png").arg(idx)});
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
}
// 7. Обрезка к началу XML
QByteArray xmlBytes = xmlOut.toUtf8();
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start);
}
return QString::fromUtf8(xmlBytes);
} }
// 4. Remove temp XML file qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // неважно, успешна ли команда return {};
// 5. Screenshot (if needed)
if (screenshot) {
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
QString("screen_step_%1.png").arg(idx)});
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
}
// 6. Обрезка к началу XML
QByteArray xmlBytes = xmlOut.toUtf8();
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start);
}
return QString::fromUtf8(xmlBytes);
} }
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) { bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {

View File

@ -331,9 +331,9 @@ void GetLastTransactionsScript::doStart() {
// Pull-to-refresh: свайп сверху вниз для обновления списка // Pull-to-refresh: свайп сверху вниз для обновления списка
{ {
const int x = device.screenWidth / 2; const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 4; const int fromY = device.screenHeight / 3;
const int toY = device.screenHeight * 3 / 4; const int toY = device.screenHeight * 2 / 3;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY); AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(3000); QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка"; qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
} }
@ -369,7 +369,7 @@ void GetLastTransactionsScript::doStart() {
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства // Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe"); const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 4; const int maxScrolls = 10;
auto interrupted = []() { auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested(); return QThread::currentThread()->isInterruptionRequested();
@ -379,6 +379,7 @@ void GetLastTransactionsScript::doStart() {
double amount = 0.0; double amount = 0.0;
QString time; // "21:31:21" QString time; // "21:31:21"
QString date; // "05.04.2026" QString date; // "05.04.2026"
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
Node node; Node node;
}; };
@ -462,7 +463,7 @@ void GetLastTransactionsScript::doStart() {
it.time = last; it.time = last;
it.node = n; it.node = n;
// В P2P формате дата встроена в первую строку // В P2P формате дата встроена в первую строку — надёжная
if (isP2P) { if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy"); it.date = todayLocal.toString("dd.MM.yyyy");
@ -470,6 +471,7 @@ void GetLastTransactionsScript::doStart() {
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy"); it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else else
it.date = firstLine; it.date = firstLine;
it.dateReliable = true;
} }
items.append(it); items.append(it);
} }
@ -537,8 +539,15 @@ void GetLastTransactionsScript::doStart() {
prevY2 = it.node.y2(); prevY2 = it.node.y2();
} }
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size() qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
<< "groups:" << groupDates; << "items:" << items.size() << "groups:" << groupDates
<< "hasEmbedded:" << hasEmbedded;
// Логируем все items для отладки
for (int i = 0; i < items.size(); ++i) {
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
<< "date=" << items[i].date << "time=" << items[i].time
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(m_searchAmount); const double searchAbs = qAbs(m_searchAmount);
for (const auto &tx : items) { for (const auto &tx : items) {
@ -552,10 +561,10 @@ void GetLastTransactionsScript::doStart() {
<< (matchedX100 ? "(scaled x100)" : "(direct)") << (matchedX100 ? "(scaled x100)" : "(direct)")
<< "date:" << tx.date << "time:" << tx.time; << "date:" << tx.date << "time:" << tx.time;
// Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день), // Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк // DCWallet транзакции получают дату из groupDates ImageView, которая
// быстрее, чем 10-секундный цикл tap→parse→goBack. // неправильно присваивается при скролле — не фильтруем по ней.
if (m_searchTime.isValid() && !tx.date.isEmpty()) { if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy"); const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date(); const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) { if (listDate.isValid() && searchLocalDate.isValid()) {
@ -720,13 +729,21 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
// Скроллим вниз // Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) { if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2; const int x = device.screenWidth / 2;
const int fromY = device.screenHeight * 3 / 4; // Определяем область контента из реальных Y позиций items
const int toY = device.screenHeight / 4; int contentBottom = device.screenHeight * 2 / 3;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY); int contentTop = device.screenHeight / 3;
if (!items.isEmpty()) {
contentBottom = items.last().node.y();
contentTop = items.first().node.y();
}
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
const int toY = qMax(contentTop, device.screenHeight / 4);
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000); QThread::msleep(2000);
} }
} }

View File

@ -88,6 +88,15 @@ bool CommonScript::runAppAndGoToHomeScreen(
qDebug() << "[Ozon] Fast path: Home reached after PIN"; qDebug() << "[Ozon] Fast path: Home reached after PIN";
return true; return true;
} }
// Ошибка загрузки — тапаем "Обновить"
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[Ozon] Fast path: page error after PIN, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
}
}
QThread::msleep(1000); QThread::msleep(1000);
} }
} }
@ -128,6 +137,17 @@ bool CommonScript::runAppAndGoToHomeScreen(
continue; continue;
} }
// Проверяем на ошибку загрузки WebView ("Обновить")
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[runAppAndGoToHomeScreen] Page load error, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
continue;
}
}
// Проверяем на банеры перед пин-кодом // Проверяем на банеры перед пин-кодом
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner(); Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
if (!closeBannerNode.isEmpty()) { if (!closeBannerNode.isEmpty()) {

View File

@ -132,7 +132,21 @@ void GetLastTransactionsScript::doStart() {
} }
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) { bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
auto tryRefreshIfError = [&]() {
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
<< refreshBtn.x() << refreshBtn.y();
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
};
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
// Закрываем рекламный bottom sheet // Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) { if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
@ -147,6 +161,21 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если уже на экране операций — скроллим наверх и готово
if (xmlScreenParser.isTransactionListScreen()) {
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
for (int i = 0; i < 5; ++i) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(500);
}
QThread::msleep(1000);
return true;
}
// Если не на домашнем экране — перезапускаем // Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
@ -160,6 +189,7 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
return false; return false;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
} }
// Закрываем баннеры на главной // Закрываем баннеры на главной
@ -212,17 +242,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8()); QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow"); if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 30; const int maxScrolls = 10;
const int navBarTop = device.screenHeight - (device.screenHeight / 10); const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке // Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz); const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date(); const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: amount=" << m_searchAmount qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate) << "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName << "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy"); << "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
// ── Шаг 1: скроллим до нужной даты ────────────────────────────── // ── Шаг 1: скроллим до нужной даты ──────────────────────────────
static const QMap<QString, int> monthMap = { static const QMap<QString, int> monthMap = {
@ -290,7 +322,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
break; break;
} }
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight - 300, device.screenWidth / 2, 300, 300); device.screenHeight * 2 / 3, device.screenWidth / 2,
device.screenHeight / 3, 600);
QThread::msleep(1500); QThread::msleep(1500);
} }
@ -305,26 +338,45 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню. // Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// принадлежат нашему дню.
auto findDateYRange = [&]() -> QPair<int, int> { auto findDateYRange = [&]() -> QPair<int, int> {
int dateY = -1; int dateY = -1;
int nextDateY = device.screenHeight; int nextDateY = device.screenHeight;
bool foundOlderDate = false;
for (const Node &node : xmlScreenParser.getNodes()) { for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue; if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed()); const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue; if (!d.isValid()) continue;
if (d == searchDate) { if (d == searchDate) {
dateY = node.y(); dateY = node.y();
} else if (dateY >= 0 && node.y() > dateY) { } else if (d < searchDate) {
// Первый заголовок другой даты после нашей // Заголовок более старой даты — граница снизу
nextDateY = node.y(); if (dateY >= 0 && node.y() > dateY) {
nextDateY = node.y();
} else if (dateY < 0) {
// Заголовок нужной даты ушёл вверх, но видна более старая дата
nextDateY = node.y();
dateY = 0; // всё выше nextDateY принадлежит нашему дню
}
foundOlderDate = true;
break; break;
} }
} }
// Заголовок ушёл вверх и нет более старых дат на экране —
// весь экран всё ещё внутри нашей даты
if (dateY < 0 && !foundOlderDate && dateFound) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
return {dateY, nextDateY}; return {dateY, nextDateY};
}; };
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ─── // ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails; QSet<QString> checkedDetails;
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
@ -333,21 +385,38 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Логируем все заголовки дат на экране
{
QStringList datesOnScreen;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid()) {
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
}
}
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
}
// Находим Y-диапазон заголовка нужной даты // Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange(); const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) { if (dateHeaderY < 0) {
qDebug() << "[FetchTransactions] Date header not on screen, scrolling"; qDebug() << "[FetchTransactions] Date completely off screen, scrolling up";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 3 / 4, device.screenHeight / 3,
device.screenWidth / 2, device.screenWidth / 2,
device.screenHeight / 4, 300); device.screenHeight * 2 / 3, 600);
QThread::msleep(1000); QThread::msleep(1500);
continue; continue;
} }
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20); QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
<< dateHeaderY << "-" << nextDateHeaderY;
bool tapped = false; bool tapped = false;
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
for (int bi = 0; bi < txButtons.size(); ++bi) { for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted"; m_error = "Interrupted";
@ -358,8 +427,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
const int btnY = txButtons[bi].y(); const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue; if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue; if (btnY <= 0 || btnY >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi]; const Node &visibleBtn = txButtons[bi];
@ -412,6 +485,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
} }
checkedDetails.insert(dtStr); checkedDetails.insert(dtStr);
tapped = true; tapped = true;
hasNewButton = true;
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
if (txTime.isValid()) {
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
if (txLocalDate != searchDate) {
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
<< "expected date" << searchDate.toString("dd.MM.yyyy")
<< "- button belongs to different date, skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Проверяем время (±10 минут) // Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) { if (txTime.isValid() && m_searchTime.isValid()) {
@ -427,7 +514,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
} }
// Нашли! // Нашли!
qDebug() << "[FetchTransactions] FOUND transaction:" qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
<< "amount=" << parsed.amount << "time=" << dtStr << "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name; << "name=" << detail.name;
@ -443,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QThread::msleep(2000); QThread::msleep(2000);
bool docLoaded = false; bool docLoaded = false;
for (int da = 0; da < 15; ++da) { for (int da = 0; da < 5; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) break; if (QThread::currentThread()->isInterruptionRequested()) break;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) { if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
@ -535,17 +622,50 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return; // успех return; // успех
} }
// Скроллим вниз если ни одна кнопка не была нажата или все проверенные не подошли // Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
if (!tapped) { // суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, // нужная кнопка может быть ниже.
device.screenHeight * 3 / 4, if (!hasNewButton && tapped) {
device.screenWidth / 2, ++noProgressCount;
device.screenHeight / 4, 300); if (noProgressCount >= 5) {
QThread::msleep(1000); qDebug() << "[FetchTransactions] No progress after" << noProgressCount
<< "attempts with matching amount — giving up";
break;
}
} else if (hasNewButton) {
noProgressCount = 0;
}
// Скроллим вниз если не нашли нужную транзакцию
if (!tapped || !hasNewButton) {
qDebug() << "[FetchTransactions] Scrolling down (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount << ")";
if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
// Заголовок в верхней половине — маленький скролл чтобы показать
// больше кнопок под этой датой
const int fromY = device.screenHeight * 2 / 3;
const int toY = device.screenHeight / 3;
qDebug() << "[FetchTransactions] Small scroll: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else {
// Заголовок в нижней половине или за экраном — полный скролл
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 400);
}
QThread::msleep(1500);
} }
} }
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2) m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy"); + " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error; qWarning() << "[FetchTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска // Отправляем скриншот списка транзакций, сделанный до начала поиска

View File

@ -316,7 +316,8 @@ int main(int argc, char *argv[]) {
QApplication::setPalette(lightPalette); QApplication::setPalette(lightPalette);
logFile.setFileName("application.log"); logFile.setFileName("application.log");
if (!logFile.open(QIODevice::Append | QIODevice::Text)) { // Очищаем лог-файл при каждом запуске
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
qWarning() << "Не удалось открыть лог-файл для записи"; qWarning() << "Не удалось открыть лог-файл для записи";
} }
// Устанавливаем свой обработчик // Устанавливаем свой обработчик
@ -334,7 +335,7 @@ int main(int argc, char *argv[]) {
// Миграции БД // Миграции БД
DatabaseManager::instance().initSchema(); DatabaseManager::instance().initSchema();
DatabaseManager::instance().cleanupOldData(5); DatabaseManager::instance().cleanupOldData(0);
dbReady.store(true); dbReady.store(true);
// 1. Если есть api_key — логинимся и проверяем/создаём desktop // 1. Если есть api_key — логинимся и проверяем/создаём desktop

View File

@ -32,8 +32,8 @@
#include "dushanbe/PayByPhoneScript.h" #include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1); static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}); static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1);
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}); static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {} EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -176,6 +176,8 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
obj["material_id"] = materialId; // UUID string obj["material_id"] = materialId; // UUID string
obj["bank_name"] = bankName; obj["bank_name"] = bankName;
obj["data"] = data; obj["data"] = data;
if (eventId > 0)
obj["internal_task_id"] = eventId;
auto *thread = new QThread; auto *thread = new QThread;
auto *networkService = new NetworkService; auto *networkService = new NetworkService;
@ -200,24 +202,26 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
} }
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
const QString &description, const QByteArray &screenshot) { const QString &description, const QByteArray &screenshot, int eventId) {
QJsonObject errorData; QJsonObject errorData;
errorData["status"] = QStringLiteral("error"); errorData["status"] = QStringLiteral("error");
errorData["description"] = description; errorData["description"] = description;
sendTaskResult(type, materialId, bankName, errorData); sendTaskResult(type, materialId, bankName, errorData, eventId);
if (!screenshot.isEmpty()) { if (!screenshot.isEmpty()) {
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4") const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
.arg(type, bankName, materialId, description); .arg(type, bankName, materialId, description);
sendMonitoringLog("error", "task_error", message, screenshot); sendMonitoringLog("error", "task_error", message, screenshot, eventId);
} }
} }
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
const QByteArray &image) { const QByteArray &image, int eventId) {
auto *thread = new QThread; auto *thread = new QThread;
auto *networkService = new NetworkService; auto *networkService = new NetworkService;
networkService->moveToThread(thread); networkService->moveToThread(thread);
if (eventId > 0)
networkService->addExtra("internal_task_id", eventId);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() { QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
networkService->postMonitoringLog(type, event, message, image); networkService->postMonitoringLog(type, event, message, image);
emit networkService->finished(); emit networkService->finished();
@ -273,7 +277,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result); .arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot); GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id);
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
} }
@ -375,21 +379,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
} }
f.remove(); f.remove();
} }
sendMonitoringLog("info", "task_success", msg, screenshot); sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
} }
if (event.type == EventType::FetchTransactions) { if (event.type == EventType::FetchTransactions) {
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2") const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
.arg(event.bankName, event.externalId); .arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key); const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr); sendMonitoringLog("info", "task_success", msg, scr, event.id);
} }
if (event.type == EventType::FetchProfile) { if (event.type == EventType::FetchProfile) {
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2") const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
.arg(event.bankName, event.externalId); .arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key); const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr); sendMonitoringLog("info", "task_success", msg, scr, event.id);
} }
} }
} }

View File

@ -1318,6 +1318,11 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
addFormField(multiPart, "event", event); addFormField(multiPart, "event", event);
addFormField(multiPart, "message", message); addFormField(multiPart, "message", message);
const int internalTaskId = m_extraData.value("internal_task_id").toInt();
if (internalTaskId > 0) {
addFormField(multiPart, "internal_task_id", QString::number(internalTaskId));
}
if (!image.isEmpty()) { if (!image.isEmpty()) {
QHttpPart imagePart; QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");

View File

@ -13,7 +13,7 @@ ARC запускается как обычно (реальные Android-уст
| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» | | Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» |
| Flask | `pip install flask` | `pip install flask` | | Flask | `pip install flask` | `pip install flask` |
| ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` | | ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` |
| ARC | `cmake --build build` | `cmake --build build --config Release` | | ARC | `cmake --build build_test` | `cmake --build build --config Release` |
| Устройства | `adb devices``device` | `tools\adb\windows\adb.exe devices` | | Устройства | `adb devices``device` | `tools\adb\windows\adb.exe devices` |
--- ---
@ -35,24 +35,26 @@ python tests\mock_server\gen_mock_data.py
Скрипт: Скрипт:
- Находит `config.ini` в текущей директории (или `build/Release/config.ini`) - Находит `config.ini` в текущей директории (или `build/Release/config.ini`)
- Открывает БД, читает активные `devices → bank_profiles → materials` - Открывает БД, читает `bank_profiles` с проверенным PIN-кодом (`pin_code_checked = 'yes'`) и активные `materials`
- Генерирует: - Генерирует:
- `tests/mock_server/default_profile.json` — профиль для мока - `tests/mock_server/default_profile.json` — профиль для мока
- `tests/mock_server/scenarios/*_fetch_profile.json` — по одному на материал - `tests/mock_server/scenarios/*_<card>_fetch_profile.json` — по одному на материал (с номером карты в имени)
- `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов - Очищает таблицу `events` в БД
Пример вывода: Пример вывода:
``` ```
config: config.ini config: config.ini
database: database/app_data.db database: database/app_data.db
deleted 5 old events
wrote default_profile.json (3 bank_profiles) wrote default_profile.json (3 bank_profiles)
wrote scenarios/pixel_7_ozon_fetch_profile.json wrote scenarios/pixel_7_ozon_7919_fetch_profile.json
wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json wrote scenarios/pixel_7_ozon_9865_fetch_profile.json
wrote scenarios/tecno_km4_ozon_fetch_profile.json wrote scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json
wrote scenarios/tecno_km4_ozon_0982_fetch_profile.json
Devices (2): Devices (2):
pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…) pixel_7: ozon/7919 (50393cc1…), ozon/9865 (06a1ae15…), dushanbe_city_bank/3258 (36002ac2…)
tecno_km4: ozon (49001bb5…) tecno_km4: ozon/0982 (7e3a7e4e…)
``` ```
Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории. Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории.
@ -70,19 +72,12 @@ python tests\mock_server\gen_fetch_transactions.py
Скрипт: Скрипт:
- Читает БД → находит активные материалы - Читает БД → находит активные материалы
- Находит подключённые ADB-устройства → матчит по `adb_serial` - Находит подключённые ADB-устройства → матчит по `adb_serial`
- Показывает меню: - Показывает меню → выбор материала или `all`
``` - Проходит по транзакциям на экране:
Available materials (3): - **Ozon**: парсит заголовки дат (Сегодня/Вчера/dd месяца), определяет дату каждой транзакции по Y-позиции, тапает для получения времени из деталей, скроллит вниз для следующих
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…) - **Dushanbe**: парсит Выписку, тап в детали, время Dushanbe (UTC+5)
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…) - Дедупликация по `(amount, created_at)` — одинаковые суммы в один день с разным временем сохраняются как отдельные задачи
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…) - Сохраняет `scenarios/pool/*_<card>_ft_*.json` с реальными `amount` (в копейках) и `created_at` (UTC)
Pick [1-3] or 'all':
```
- После выбора просит открыть список транзакций на устройстве → Enter
- Проходит по каждой транзакции: тап → дамп деталей → назад
(перед каждым тапом проверяет, что мы на списке)
- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках)
и `created_at` (UTC, из таймзоны устройства)
- Если мок запущен — сразу инжектит задачи - Если мок запущен — сразу инжектит задачи
Флаги: Флаги:
@ -113,57 +108,48 @@ apiBase=http://127.0.0.1:8888
### Терминал 1 — mock-сервер ### Терминал 1 — mock-сервер
```bash ```bash
# macOS # macOS — полный цикл (генерация данных + запуск)
cd tests/mock_server && ./run.sh cd tests/mock_server && ./run.sh
# macOS — только определённые банки
cd tests/mock_server && ./run.sh --banks ozon
# macOS — только FETCH_TRANSACTIONS (без FETCH_PROFILE)
cd tests/mock_server && ./run.sh --types FETCH_TRANSACTIONS --banks ozon
# macOS — debug режим (auto-reload при изменении кода)
cd tests/mock_server && ./run.sh --banks ozon --debug
# Windows # Windows
cd tests\mock_server cd tests\mock_server
python mock_server.py --port 8888 python mock_server.py --port 8888
``` ```
Логи в консоли: Флаги `mock_server.py`:
``` - `--banks ozon dushanbe_city_bank` — только указанные банки
[mock] state cleared — starting fresh - `--types FETCH_TRANSACTIONS` — только указанные типы задач
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
* Running on http://127.0.0.1:8888
```
Флаги:
- `--banks ozon` — генерировать задачи только для указанных банков
- `--banks ozon dushanbe_city_bank` — несколько банков через пробел
- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3) - `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3)
- `--seed 42` — фиксированный RNG для воспроизводимости - `--seed 42` — фиксированный RNG для воспроизводимости
- `--debug` — Flask debug режим (auto-reload, состояние сохраняется между перезагрузками)
Без `--banks` задачи генерируются для всех активных банков. При `--debug`:
- Изменения в `.py` файлах применяются автоматически без перезапуска
Примеры: - Состояние задач сохраняется в `.mock_state.json` и восстанавливается при reload
```bash - Скриншоты сохраняются в `.mock_screenshots/` (переживают reload)
# Только Ozon - При **первом** запуске — чистый старт (events в БД очищаются, state сбрасывается)
./run.sh --banks ozon
# Только Dushanbe
./run.sh --banks dushanbe_city_bank
# Ozon + Dushanbe, по 2 FT на материал
./run.sh --banks ozon dushanbe_city_bank --ft-per-material 2
# Все банки (по умолчанию)
./run.sh
```
### Терминал 2 — ARC ### Терминал 2 — ARC
```bash ```bash
# macOS # macOS (из корня проекта!)
./build/ARC ./build_test/ARC
# Windows # Windows
build\Release\ARC.exe build\Release\ARC.exe
``` ```
При логине мок автоматически ставит задачи: При логине мок автоматически ставит задачи:
- 1 × FETCH_PROFILE на каждый материал - 1 × FETCH_PROFILE на каждый материал (если `--types` не ограничивает)
- N × FETCH_TRANSACTIONS (случайные из pool) - N × FETCH_TRANSACTIONS (случайные из pool)
Задачи раздаются **по 1 на material за поллинг** — следующая только после Задачи раздаются **по 1 на material за поллинг** — следующая только после
@ -172,15 +158,9 @@ build\Release\ARC.exe
### Терминал 3 — отчёт ### Терминал 3 — отчёт
```bash ```bash
# Браузер с авто-обновлением (задачи группируются по устройствам) # Браузер с авто-обновлением
open http://127.0.0.1:8888/_admin/report/html # macOS open http://127.0.0.1:8888/_admin/report/html # macOS
start http://127.0.0.1:8888/_admin/report/html # Windows start http://127.0.0.1:8888/_admin/report/html # Windows
# Счётчики в реальном времени
watch -n 2 'curl -s http://127.0.0.1:8888/_admin/state' # macOS
# Windows (PowerShell)
while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
``` ```
--- ---
@ -188,86 +168,89 @@ while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
## 5. HTML-отчёт ## 5. HTML-отчёт
`http://127.0.0.1:8888/_admin/report/html` — веб-страница с: `http://127.0.0.1:8888/_admin/report/html` — веб-страница с:
- Сводкой: всего / выполнено / в очереди - Сводкой: всего / выполнено / ошибки / в очереди
- Группировкой по устройствам (Pixel 7, TECNO KM4, …) - Группировкой по **устройствам****банкам** (Pixel 7 → ozon, dushanbe_city_bank)
- Для каждой задачи: Task JSON слева ↔ Result JSON справа - Для каждой задачи:
- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error - Task JSON и Result JSON (для успешных — свёрнуты, кнопка "Показать JSON")
- Авто-обновлением каждые 2 секунды (без сброса скролла) - Ошибки — развёрнуты сразу с красным бейджем
- Скриншот (если есть) — третья колонка, клик открывает полноэкранный просмотр
Также доступен JSON: - Кнопка "Копировать" на каждом JSON блоке
```bash - Цветовой индикацией: зелёный = completed, красный = error, жёлтый = pending
curl -s http://127.0.0.1:8888/_admin/report | jq # полный - Авто-обновлением каждые 2 секунды (состояние свёрнутых/развёрнутых блоков сохраняется)
curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending
```
--- ---
## 6. Ручной инжект задач ## 6. Скриншоты
Приложение отправляет скриншоты через `POST /monitoring/log` с полем `internal_task_id`.
Мок-сервер:
- Сохраняет PNG на диск в `.mock_screenshots/`
- Привязывает к задаче по `internal_task_id` (точное совпадение с `task_result`)
- Отображает в отчёте как третью колонку рядом с Task/Result JSON
- Клик по скриншоту — полноэкранный просмотр (Escape для закрытия)
---
## 7. Ручной инжект задач
```bash ```bash
# Одна задача # Одна задача
./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS curl -X POST http://127.0.0.1:8888/_admin/inject \
curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows -H "Content-Type: application/json" \
-H "Content-Type: application/json" ^ --data-binary @scenarios/pixel_7_ozon_7919_fetch_profile.json
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json
# N случайных из пула
python inject_random.py --count 5
# Повторный бутстрап (без перезапуска мока) # Повторный бутстрап (без перезапуска мока)
curl -X POST http://127.0.0.1:8888/_admin/bootstrap curl -X POST http://127.0.0.1:8888/_admin/bootstrap
# Сброс (очистка очереди + результатов + in-flight) # Сброс (очистка очереди + результатов + скриншотов)
curl -X POST http://127.0.0.1:8888/_admin/reset curl -X POST http://127.0.0.1:8888/_admin/reset
``` ```
--- ---
## 7. Админ-эндпойнты мока ## 8. Админ-эндпойнты мока
| Метод | Путь | Назначение | | Метод | Путь | Назначение |
|---|---|---| |---|---|---|
| POST | `/_admin/inject` | Положить задачу (или массив) в очередь | | POST | `/_admin/inject` | Положить задачу (или массив) в очередь |
| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) | | POST | `/_admin/bootstrap` | Повторный бутстрап |
| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` | | POST | `/_admin/seed_profile` | Подменить ответ `current_profile` |
| POST | `/_admin/seed_devices` | Подменить список устройств | | POST | `/_admin/seed_devices` | Подменить список устройств |
| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты | | GET | `/_admin/report` | JSON: полный отчёт |
| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением | | GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением |
| GET | `/_admin/report/pending` | Только незавершённые задачи | | GET | `/_admin/report/pending` | Только незавершённые задачи |
| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления | | GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления |
| GET | `/_admin/screenshot/<id>` | PNG скриншот по ID |
| GET | `/_admin/state` | Счётчики | | GET | `/_admin/state` | Счётчики |
| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап | | POST | `/_admin/reset` | Очистить всё, сбросить бутстрап |
--- ---
## 8. Полный цикл с нуля (новая машина / новые устройства) ## 9. Полный цикл с нуля
```bash ```bash
# 1. Установка # 1. Установка
pip install flask pip install flask
# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства, # 2. Собрать ARC, запустить с боевым сервером → залогиниться
# банковские профили и материалы появятся в БД # Устройства, профили и материалы появятся в БД
# 3. Переключить config.ini на мок # 3. Переключить config.ini на мок
# [net] # [net]
# apiBase=http://127.0.0.1:8888 # apiBase=http://127.0.0.1:8888
# 4. Сгенерировать стартовые данные из БД # 4. Сгенерировать стартовые данные из БД
python tests/mock_server/gen_mock_data.py python3 tests/mock_server/gen_mock_data.py
# 5. Открыть на устройстве банковское приложение → список транзакций # 5. Открыть на устройстве банковское приложение → список транзакций
# Сгенерировать пул задач # Сгенерировать пул задач
python tests/mock_server/gen_fetch_transactions.py python3 tests/mock_server/gen_fetch_transactions.py
# 6. Запустить мок (все банки) # 6. Запустить мок
cd tests/mock_server && python mock_server.py --port 8888 cd tests/mock_server && ./run.sh --banks ozon --debug
# или только определённые:
# cd tests/mock_server && python mock_server.py --port 8888 --banks ozon
# 7. Запустить ARC (в отдельном терминале) # 7. Запустить ARC (из корня проекта, в отдельном терминале)
./build/ARC # macOS ./build_test/ARC
build\Release\ARC.exe # Windows
# 8. Открыть отчёт # 8. Открыть отчёт
open http://127.0.0.1:8888/_admin/report/html open http://127.0.0.1:8888/_admin/report/html
@ -275,63 +258,65 @@ open http://127.0.0.1:8888/_admin/report/html
# 9. Дождаться completed = всего в отчёте # 9. Дождаться completed = всего в отчёте
# 10. Вернуть config.ini на прод # 10. Вернуть config.ini на прод
# [net]
# apiBase=http://93.183.75.134:8005
``` ```
--- ---
## 9. Структура файлов ## 10. Структура файлов
``` ```
tests/mock_server/ tests/mock_server/
├── mock_server.py # Flask mock-сервер ├── mock_server.py # Flask mock-сервер
├── run.sh # запуск (macOS/Linux) ├── run.sh # запуск (macOS/Linux): gen_mock_data + mock_server
├── gen_mock_data.py # генерация default_profile + scenarios из БД ├── gen_mock_data.py # генерация default_profile + scenarios из БД
├── gen_fetch_transactions.py # генерация pool из живого устройства ├── gen_fetch_transactions.py # генерация pool из живого устройства
├── inject.sh # curl-обёртка для инжекта ├── inject.sh # curl-обёртка для инжекта
├── inject_random.py # рандомный инжект из pool
├── default_profile.json # авто-генерированный профиль для мока ├── default_profile.json # авто-генерированный профиль для мока
├── seed_profile.example.json # пример ручного засева
├── README.md # эта инструкция ├── README.md # эта инструкция
├── WINDOWS.md # Windows-специфичные нюансы ├── WINDOWS.md # Windows-специфичные нюансы
├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body) ├── .mock_state.json # состояние задач (debug режим, .gitignore)
│ ├── pixel_7_ozon_fetch_profile.json ├── .mock_screenshots/ # скриншоты с устройств (.gitignore)
│ ├── pixel_7_ozon_fetch_transactions.json ├── scenarios/ # FETCH_PROFILE сценарии
│ ├── tecno_km4_ozon_fetch_profile.json │ ├── pixel_7_ozon_7919_fetch_profile.json
│ ├── pixel_7_ozon_9865_fetch_profile.json
│ └── ... │ └── ...
├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at └── scenarios/pool/ # пул FETCH_TRANSACTIONS
│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json ├── pixel_7_ozon_7919_01_minus_108_ft_01.json
│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json ├── pixel_7_ozon_9865_01_minus_108_ft_01.json
│ └── ... ├── pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
└── fixtures/ # XML/PNG дампы экранов (справочно) └── ...
├── tecno_ozon/
├── pixel7_ozon/
└── pixel7_dushanbe/
``` ```
--- ---
## 10. Типовые проблемы ## 11. Типовые проблемы
| Симптом | Причина | Решение | | Симптом | Причина | Решение |
|---|---|---| |---|---|---|
| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` | | `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` |
| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться | | `No active materials` | Нет профилей с проверенным PIN | Запустить ARC с боевым сервером, добавить PIN |
| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB | | `No ADB devices match` | Устройства не подключены | `adb devices`, проверить USB |
| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC | | `App restarted` ошибки в отчёте | Events остались от прошлого запуска | Мок-сервер чистит events при старте; используйте `run.sh` или запускайте сервер **до** ARC |
| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный | | Задача `pending` навсегда | material в `_in_flight` | `/_admin/reset` и перезапустить ARC |
| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` | | `Transaction not found` | Баг поиска — скролл, видимость кнопок | Анализировать логи (device= в ошибке) |
| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг | | `Page load error` | Ozon WebView не загрузился | Проблема интернета/устройства, таймаут 5 мин |
| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) | | Скриншот не привязан | Нет `internal_task_id` | Обновить ARC (фикс в EventHandler.cpp) |
| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском | | Pool material_id не совпадает | БД обновилась, pool устарел | Перегенерировать pool или обновить material_id в файлах |
| Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` | | Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` |
--- ---
## 11. Что мок НЕ эмулирует ## 12. Что мок эмулирует и что нет
- Валидацию тел запросов (всё `{success: true}`) **Эмулирует:**
- Хранение скриншотов - Авторизацию (login/refresh)
- Синхронизацию профилей (current_profile, bank_profile CRUD)
- Выдачу задач (get_tasks) с in-flight контролем
- Приём результатов (task_result) с `internal_task_id`
- Приём скриншотов (monitoring/log) с привязкой к задачам
- Управление устройствами и материалами (toggle)
**Не эмулирует:**
- Валидацию тел запросов
- Webhook-уведомления банков - Webhook-уведомления банков
- Сетевые задержки/таймауты - Сетевые задержки/таймауты

View File

@ -28,14 +28,11 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
4. Находит `config.ini` и проверяет **базу данных** 4. Находит `config.ini` и проверяет **базу данных**
5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает) 5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает)
6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД 6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД
7. Если порт занят — убивает старый процесс 7. Очищает таблицу `events` в БД
8. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`) 8. Если порт занят — убивает старый процесс
9. Показывает сводку: устройства, профили, материалы, пул транзакций 9. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
10. Запускает mock-сервер и открывает HTML-отчёт в браузере 10. Показывает сводку: устройства, профили, материалы, пул транзакций
11. Запускает mock-сервер и открывает HTML-отчёт в браузере
При первом логине ARC мок автоматически ставит **FETCH_PROFILE** задачи
на каждый материал. **FETCH_TRANSACTIONS** задачи нужно генерировать отдельно
(см. [раздел 3](#3-генерация-fetch_transactions-задач)).
### Параметры ### Параметры
@ -44,33 +41,23 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
| `-Port` | 8888 | Порт mock-сервера | | `-Port` | 8888 | Порт mock-сервера |
| `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) | | `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) |
| `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` | | `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` |
| `-Types` | все | Типы задач: `FETCH_PROFILE`, `FETCH_TRANSACTIONS` |
| `-SkipInstall` | — | Пропустить установку Python/Flask | | `-SkipInstall` | — | Пропустить установку Python/Flask |
| `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер | | `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер |
| `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) | | `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) |
| `-Debug` | — | Flask debug режим (auto-reload при изменении кода) |
### Примеры ### Примеры
```powershell ```powershell
# Всё автоматически (установка + генерация + запуск) # Всё автоматически
.\setup.ps1 .\setup.ps1
# На другом порту с 5 транзакциями на материал # Только Ozon, только FETCH_TRANSACTIONS
.\setup.ps1 -Port 9999 -FtPerMaterial 5 .\setup.ps1 -Banks ozon -Types FETCH_TRANSACTIONS
# Только Ozon # Debug режим с 5 транзакциями на материал
.\setup.ps1 -Banks ozon .\setup.ps1 -Banks ozon -FtPerMaterial 5 -Debug
# Только Dushanbe, по 2 транзакции
.\setup.ps1 -Banks dushanbe_city_bank -FtPerMaterial 2
# Ozon + Dushanbe
.\setup.ps1 -Banks ozon,dushanbe_city_bank
# Пропустить установку (Python/Flask уже есть)
.\setup.ps1 -SkipInstall
# Только подготовить данные, сервер не запускать
.\setup.ps1 -GenerateOnly
# Полная очистка (включая пул транзакций) # Полная очистка (включая пул транзакций)
.\setup.ps1 -Clean .\setup.ps1 -Clean
@ -81,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
## 2. Вспомогательные скрипты ## 2. Вспомогательные скрипты
После первого запуска `setup.ps1` в папке `tests\mock_server\` появляются После первого запуска `setup.ps1` в папке `tests\mock_server\` появляются
скрипты с зашитым путём до Python — искать `python.exe` вручную не нужно: скрипты с зашитым путём до Python:
| Скрипт | Назначение | Запуск | | Скрипт | Назначение | Запуск |
|---|---|---| |---|---|---|
@ -89,14 +76,14 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
| `run_server.ps1` | Запуск mock-сервера | `powershell -ExecutionPolicy Bypass -File run_server.ps1` | | `run_server.ps1` | Запуск mock-сервера | `powershell -ExecutionPolicy Bypass -File run_server.ps1` |
| `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` | | `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` |
Скрипты принимают дополнительные аргументы, которые передаются в Python-скрипт: Скрипты принимают дополнительные аргументы:
```powershell ```powershell
# Запуск сервера только для Ozon, только FETCH_TRANSACTIONS
powershell -ExecutionPolicy Bypass -File run_server.ps1 --banks ozon --types FETCH_TRANSACTIONS
# Генерация с явным серийником # Генерация с явным серийником
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712 powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
# Запуск сервера на другом порту
powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
``` ```
--- ---
@ -104,8 +91,6 @@ powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
## 3. Генерация FETCH_TRANSACTIONS задач ## 3. Генерация FETCH_TRANSACTIONS задач
FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства. FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства.
Скрипт подключается по ADB, проходит по списку транзакций на экране
(тап -> снятие суммы/даты -> назад) и сохраняет JSON-файлы в `scenarios/pool/`.
### Подготовка ### Подготовка
@ -113,158 +98,74 @@ FETCH_TRANSACTIONS задачи генерируются **только вруч
2. Убедитесь что ADB видит устройство: 2. Убедитесь что ADB видит устройство:
```powershell ```powershell
tools\adb\windows\adb.exe devices tools\adb\windows\adb.exe devices
# Должно показать серийник со статусом "device"
``` ```
3. Откройте на устройстве банковское приложение на экране **списка транзакций** 3. Откройте на устройстве банковское приложение на экране **списка транзакций**
- Ozon: раздел "Операции" -> список переводов/пополнений
- Dushanbe City Bank: раздел "Операции" -> список P2P операций
### Запуск ### Запуск
```powershell ```powershell
# Через вспомогательный скрипт (рекомендуется)
cd tests\mock_server cd tests\mock_server
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
# Или напрямую (если Python в PATH)
python tests\mock_server\gen_fetch_transactions.py
``` ```
Скрипт: Скрипт:
1. Покажет меню с активными материалами: 1. Покажет меню с активными материалами
2. Выберите нужный или `all`
3. Подтвердите что список транзакций открыт → **Enter**
4. Автоматически пройдёт по транзакциям (**не трогайте телефон!**)
5. Для **Ozon**: парсит заголовки дат, определяет дату каждой транзакции по позиции на экране, из деталей берёт только время
6. Для **Dushanbe**: время конвертируется из Asia/Dushanbe (UTC+5)
7. Сохраняет в `scenarios/pool/` с номером карты в имени файла:
``` ```
Available materials (3): pixel_7_ozon_7919_01_minus_108_ft_01.json
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c... serial=28291FDH30xxxx) pixel_7_ozon_9865_01_minus_108_ft_01.json
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1...) pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5...)
Pick [1-3] or 'all':
``` ```
2. Выберите нужный материал (номер) или `all` для всех
3. Скрипт попросит подтвердить что список транзакций открыт -> нажмите **Enter**
4. Автоматически пройдёт по каждой транзакции на экране (**не трогайте телефон!**)
5. Сохранит задачи в `scenarios/pool/`
6. Если mock-сервер запущен — сразу инжектнёт задачи в него
### Флаги ### Важно
| Флаг | Описание | - Если у банка **два материала** (две карты), генерируются **отдельные файлы** для каждого
|---|---| - При смене устройств/аккаунтов нужно перегенерировать pool — `material_id` в файлах должны совпадать с `default_profile.json`
| `--serial 28291FDH30xxxx` | Явный ADB-серийник (если несколько устройств) |
| `--kopecks 100` | Множитель суммы (по умолчанию 100: рубли -> копейки) |
| `--config path` | Путь к config.ini |
### Примеры
```powershell
# Интерактивный выбор материала
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
# Для конкретного устройства
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
# С другим config
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --config cmake-build-debug\config.ini
```
### Результат
После генерации в `scenarios/pool/` появятся файлы вида:
```
pixel_7_ozon_ft_01_plus_15000_143022.json
pixel_7_ozon_ft_02_minus_500_150133.json
tecno_km4_ozon_ft_01_plus_3000_091500.json
```
Каждый файл содержит задачу с реальной суммой (в копейках) и временем (UTC):
```json
{
"bank_name": "ozon",
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"type": "FETCH_TRANSACTIONS",
"body": {
"amount": 1500000,
"created_at": "2026-04-10T11:30:22Z"
}
}
```
При следующем запуске `setup.ps1` пул сохраняется (если не указать `-Clean`),
и мок будет раздавать задачи из него при бутстрапе.
--- ---
## 4. Ручной запуск (пошагово) ## 4. Ручной запуск (пошагово)
Если не хотите использовать `setup.ps1`:
### 4.1. Установка зависимостей ### 4.1. Установка зависимостей
```powershell ```powershell
# Python 3.9+ (если нет — скачать с python.org, включить «Add to PATH») python --version # Python 3.9+
python --version
# Flask
pip install flask pip install flask
``` ```
### 4.2. Подготовка config.ini ### 4.2. Подготовка config.ini
В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`:
```ini ```ini
[net] [net]
apiBase=http://127.0.0.1:8888 apiBase=http://127.0.0.1:8888
``` ```
> Совет: держите копию `config.ini.mock`, а перед тестом ### 4.3. Генерация данных + запуск
> `copy config.ini.mock config.ini`.
### 4.3. Генерация профиля из БД
```powershell ```powershell
# Из корня проекта # Генерация профиля
python tests\mock_server\gen_mock_data.py python tests\mock_server\gen_mock_data.py
```
Создаёт `default_profile.json` и FETCH_PROFILE scenarios. # Запуск сервера
### 4.4. Генерация пула транзакций (опционально)
См. [раздел 3](#3-генерация-fetch_transactions-задач).
### 4.5. Запуск mock-сервера
```powershell
cd tests\mock_server cd tests\mock_server
python mock_server.py --port 8888 python mock_server.py --port 8888 --banks ozon --types FETCH_TRANSACTIONS
# Debug режим (auto-reload)
python mock_server.py --port 8888 --banks ozon --debug
``` ```
Должны увидеть: ### 4.4. Запуск ARC
```
[mock] state cleared — starting fresh
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
* Running on http://127.0.0.1:8888
```
### 4.6. Запуск ARC
В отдельном терминале:
```powershell ```powershell
cd build\Release cd C:\ARC\Ver.18 # или build\Release
ARC.exe ARC.exe
``` ```
Или через CLion: Run -> ARC.
При логине мок автоматически ставит задачи:
- 1 x FETCH_PROFILE на каждый материал (всегда)
- N x FETCH_TRANSACTIONS из пула (если пул не пуст)
Задачи раздаются по 1 на material за поллинг — следующая только
после результата предыдущей.
--- ---
## 5. Мониторинг и отчёт ## 5. Мониторинг и отчёт
@ -272,46 +173,37 @@ ARC.exe
```powershell ```powershell
# Открыть отчёт в браузере (авто-обновление каждые 2 сек) # Открыть отчёт в браузере (авто-обновление каждые 2 сек)
start http://127.0.0.1:8888/_admin/report/html start http://127.0.0.1:8888/_admin/report/html
# Счётчики
curl -s http://127.0.0.1:8888/_admin/state
# Полный JSON-отчёт
curl -s http://127.0.0.1:8888/_admin/report
``` ```
Если `curl` нет, используйте PowerShell: Отчёт показывает:
- **Сводку**: всего / выполнено / ошибки / в очереди
- **Группировку**: устройство → банк → задачи
- **Для каждой задачи**:
- Статус (completed / error / pending)
- Task JSON + Result JSON (успешные свёрнуты, ошибки развёрнуты)
- Скриншот (если есть) — клик для полноэкранного просмотра
- Кнопка "Копировать" на каждом JSON блоке
- Авто-обновление без сброса состояния развёрнутых блоков
```powershell ```powershell
# Счётчики # Счётчики
(Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json (Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json
# Полный отчёт в файл
Invoke-WebRequest http://127.0.0.1:8888/_admin/report -OutFile report.json
``` ```
На главной странице отчёта (`/_admin/report/html`) отображаются:
- Счётчики задач (всего / выполнено / в очереди)
- **Загруженные данные** — устройства, профили, материалы с количеством задач в пуле (группировка по устройствам)
- Задачи с результатами, сгруппированные по устройствам
--- ---
## 6. Ручной инжект задач ## 6. Ручной инжект задач
```powershell ```powershell
# Одна задача из файла # Одна задача
curl -X POST http://127.0.0.1:8888/_admin/inject ^ curl -X POST http://127.0.0.1:8888/_admin/inject ^
-H "Content-Type: application/json" ^ -H "Content-Type: application/json" ^
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json --data-binary @scenarios\pixel_7_ozon_7919_fetch_profile.json
# N случайных из пула # Повторный бутстрап
python inject_random.py --count 5
# Повторный бутстрап (1 FP + N FT на material)
curl -X POST http://127.0.0.1:8888/_admin/bootstrap curl -X POST http://127.0.0.1:8888/_admin/bootstrap
# Сброс (очистка очереди + результатов) # Сброс (очистка всего + скриншоты)
curl -X POST http://127.0.0.1:8888/_admin/reset curl -X POST http://127.0.0.1:8888/_admin/reset
``` ```
@ -319,19 +211,16 @@ curl -X POST http://127.0.0.1:8888/_admin/reset
## 7. ADB на Windows ## 7. ADB на Windows
ARC использует ADB из `tools\adb\windows\`. Проверка: ARC использует ADB из `tools\adb\windows\`:
```powershell ```powershell
tools\adb\windows\adb.exe devices tools\adb\windows\adb.exe devices
``` ```
Должно показать устройства в статусе `device`. Если ADB занят:
Если ADB занят другим процессом (Android Studio, scrcpy):
```powershell ```powershell
tools\adb\windows\adb.exe kill-server tools\adb\windows\adb.exe kill-server
tools\adb\windows\adb.exe start-server tools\adb\windows\adb.exe start-server
tools\adb\windows\adb.exe devices
``` ```
--- ---
@ -340,26 +229,25 @@ tools\adb\windows\adb.exe devices
| Проблема | Решение | | Проблема | Решение |
|---|---| |---|---|
| `python` не найден | Запустите `setup.ps1` — установит автоматически. Или используйте скрипты `gen_transactions.ps1` / `run_server.ps1` — в них путь уже зашит | | `python` не найден | Запустите `setup.ps1` — установит автоматически |
| Порт 8888 занят | `setup.ps1` убивает старый процесс автоматически. Вручную: `netstat -ano \| findstr :8888` -> `taskkill /PID <pid> /F` | | Порт 8888 занят | `setup.ps1` убивает автоматически. Вручную: `netstat -ano \| findstr :8888``taskkill /PID <pid> /F` |
| `curl` нет | `Invoke-WebRequest` (PowerShell) или `winget install curl` | | `App restarted` ошибки | Мок-сервер чистит events в БД при старте. Запускайте сервер **до** ARC |
| Firewall блокирует | Разрешить `python.exe` в Windows Firewall. Мок слушает только `127.0.0.1` | | Pool material_id не совпадает | `gen_mock_data.py` обновил material_id. Перегенерируйте pool |
| ARC не видит устройства | Проверить USB-драйверы, включить USB debugging, подтвердить RSA-ключ | | `Transaction not found` | Баг поиска — анализировать логи (device= в ошибке) |
| Кракозябры в консоли | `chcp 65001` перед запуском (UTF-8). Скрипты `.ps1` делают это автоматически | | `Page load error` | Ozon WebView не загрузился — проблема интернета/устройства |
| BOM в config.ini | `setup.ps1` пишет без BOM. Если правили вручную — пересохраните в UTF-8 без BOM (VS Code) | | Скриншот не привязался | Нет `internal_task_id` — обновить ARC |
| No active materials | БД пустая. Запустите ARC с боевым сервером, залогиньтесь, добавьте устройства | | Кракозябры в консоли | `chcp 65001` (UTF-8). Скрипты `.ps1` делают автоматически |
| Пул транзакций пуст | Сгенерируйте: [раздел 3](#3-генерация-fetch_transactions-задач) | | No active materials | Нет профилей с проверенным PIN. Запустить ARC с боевым сервером |
| `No ADB devices match` | Устройства не подключены или serial не совпадает с БД. Проверьте `adb devices` | | Firewall | Разрешить `python.exe`. Мок слушает только `127.0.0.1` |
| SSL ошибки при сборке | OpenSSL DLL копируются автоматически через CMake post-build |
--- ---
## 9. Быстрый чеклист ## 9. Быстрый чеклист
``` ```
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 [ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -Banks ozon
[ ] Устройства в adb devices -> device [ ] Устройства в adb devices device
[ ] (опционально) powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 [ ] (опционально) gen_transactions.ps1 для пула транзакций
[ ] ARC.exe запущен (отдельный терминал) [ ] ARC.exe запущен (отдельный терминал)
[ ] Браузер: http://127.0.0.1:8888/_admin/report/html [ ] Браузер: http://127.0.0.1:8888/_admin/report/html
[ ] Дождаться completed = всего в отчёте [ ] Дождаться completed = всего в отчёте

View File

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,27 @@
{ {
"bank_profiles": [ "bank_profiles": [
{ {
"id": "85f577bc-20be-40fd-ab47-315af3aadfb9", "id": "b8763ad9-50bd-4d8c-b6f1-cc122119e2df",
"bank_name": "dushanbe_city_bank",
"state": "enabled",
"phone_number": "+992185555519",
"first_name": "Мехрубон",
"middle_name": null,
"last_name": "Имомкулзода",
"currency": 972,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "36002ac2-6f4d-49da-8892-18db723a31c7",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "ec6b5712-067c-4354-8641-a3f19a9fdef1",
"bank_name": "ozon", "bank_name": "ozon",
"state": "enabled", "state": "enabled",
"phone_number": "+79538038318", "phone_number": "+79538038318",
@ -9,22 +29,42 @@
"middle_name": null, "middle_name": null,
"last_name": "Терехина", "last_name": "Терехина",
"currency": 643, "currency": 643,
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078", "device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7", "device_label": "Pixel 7",
"materials": [ "materials": [
{ {
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9", "id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
"state": "enabled", "state": "enabled",
"card_number": "2204321035207919", "card_number": "2204321035207919",
"currency": 643 "currency": 643
}, },
{ {
"id": "39592a22-3e79-4438-89d7-778aea72a5e9", "id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
"state": "enabled", "state": "enabled",
"card_number": "2204321086109865", "card_number": "2204321086109865",
"currency": 643 "currency": 643
} }
] ]
},
{
"id": "a3fa08d3-58d7-4744-886c-c81afa707dcd",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79855532896",
"first_name": "Владислав",
"middle_name": null,
"last_name": "Касаткин",
"currency": 643,
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
"device_label": "TECNO KM4",
"materials": [
{
"id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
"state": "enabled",
"card_number": "2204320439130982",
"currency": 643
}
]
} }
] ]
} }

View File

@ -479,7 +479,9 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
amount = int(float(amt_s)) if amt_s else (row["amount"] or 0) amount = int(float(amt_s)) if amt_s else (row["amount"] or 0)
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M" fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz) # Душанбе показывает время в Asia/Dushanbe (UTC+5), не в таймзоне устройства
dushanbe_tz = timezone(timedelta(hours=5))
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=dushanbe_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
dedup_key = (amount, cat) dedup_key = (amount, cat)
if dedup_key in processed_keys: if dedup_key in processed_keys:

View File

@ -89,10 +89,11 @@ def query_active_materials(db_path: Path):
FROM devices d FROM devices d
JOIN bank_profiles bp ON bp.device_id = d.id JOIN bank_profiles bp ON bp.device_id = d.id
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
WHERE bp.status = 'active' WHERE bp.pin_code IS NOT NULL AND bp.pin_code != ''
AND m.status = 'active' AND bp.pin_code_checked = 'yes'
AND m.material_id != '' AND m.status = 'active'
AND bp.bank_profile_id != '' AND m.material_id IS NOT NULL AND m.material_id != ''
AND bp.bank_profile_id IS NOT NULL AND bp.bank_profile_id != ''
ORDER BY d.name, bp.code ORDER BY d.name, bp.code
""").fetchall() """).fetchall()
conn.close() conn.close()

View File

@ -40,7 +40,10 @@ Usage:
import argparse import argparse
import copy import copy
import json import json
import os
import random import random
import re
import shutil
import threading import threading
import time import time
import uuid import uuid
@ -75,6 +78,51 @@ _in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted ta
_bootstrap_done = False _bootstrap_done = False
_bootstrap_ft_count = 3 _bootstrap_ft_count = 3
_bootstrap_banks: list | None = None # None = all banks _bootstrap_banks: list | None = None # None = all banks
_bootstrap_types: list | None = None # None = all types (FETCH_PROFILE + FETCH_TRANSACTIONS)
_STATE_FILE = HERE / ".mock_state.json"
def _save_state():
"""Persist task state to disk so debug reloader doesn't lose it."""
try:
state = {
"injected": _injected,
"task_queue": list(_task_queue),
"in_flight": list(_in_flight),
"bootstrap_done": _bootstrap_done,
"pending_by_key": {f"{k[0]}|{k[1]}": v for k, v in _pending_by_key.items()},
}
_STATE_FILE.write_text(json.dumps(state, default=str), encoding="utf-8")
except Exception as e:
print(f"[mock] failed to save state: {e}")
def _load_state() -> bool:
"""Restore state from disk. Returns True if loaded."""
global _bootstrap_done
if not _STATE_FILE.exists():
return False
try:
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
_injected.clear()
_injected.extend(state.get("injected", []))
_task_queue.clear()
_task_queue.extend(state.get("task_queue", []))
_in_flight.clear()
_in_flight.update(state.get("in_flight", []))
_bootstrap_done = state.get("bootstrap_done", False)
_pending_by_key.clear()
for k, v in state.get("pending_by_key", {}).items():
parts = k.split("|", 1)
if len(parts) == 2:
_pending_by_key[(parts[0], parts[1])] = v
print(f"[mock] restored state: {len(_injected)} injected, "
f"{len(_task_queue)} queued, bootstrap={'done' if _bootstrap_done else 'pending'}")
return True
except Exception as e:
print(f"[mock] failed to load state: {e}")
return False
def ok(data=None): def ok(data=None):
@ -173,25 +221,28 @@ def bootstrap_tasks():
if not mid: if not mid:
continue continue
# 1 × FETCH_PROFILE # 1 × FETCH_PROFILE
enqueue_task({ if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types:
"bank_name": bank, enqueue_task({
"material_id": mid, "bank_name": bank,
"type": "FETCH_PROFILE", "material_id": mid,
"body": {}, "type": "FETCH_PROFILE",
"_source": "bootstrap", "body": {},
}) "_source": "bootstrap",
queued += 1 })
queued += 1
# N × random FETCH_TRANSACTIONS from pool # N × random FETCH_TRANSACTIONS from pool
pool = _pool_by_material.get(mid, []) if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types:
if not pool: pool = _pool_by_material.get(mid, [])
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})") if not pool:
continue print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool))) continue
for t in pick: pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
enqueue_task(t) for t in pick:
queued += 1 enqueue_task(t)
queued += 1
_bootstrap_done = True _bootstrap_done = True
_save_state()
print(f"[mock] bootstrap queued {queued} tasks") print(f"[mock] bootstrap queued {queued} tasks")
@ -359,6 +410,7 @@ def get_tasks():
_in_flight.add(mid) _in_flight.add(mid)
_task_queue.extend(kept) _task_queue.extend(kept)
if delivered: if delivered:
_save_state()
log("GET /task/get_tasks -> delivered", log("GET /task/get_tasks -> delivered",
[{"type": t.get("type"), "material_id": t.get("material_id")} [{"type": t.get("type"), "material_id": t.get("material_id")}
for t in delivered]) for t in delivered])
@ -393,15 +445,72 @@ def task_result():
"result_at": time.time(), "result_at": time.time(),
"status": "unsolicited", "status": "unsolicited",
}) })
_save_state()
return ok({}) return ok({})
# ---------------------------------------------------------------- monitoring # ---------------------------------------------------------------- monitoring
_monitoring_logs: list = [] # {type, event, message, has_image, image_id}
_SCREENSHOTS_DIR = HERE / ".mock_screenshots"
@app.post("/monitoring/log") @app.post("/monitoring/log")
def monitoring_log(): def monitoring_log():
mtype = request.form.get("type", "")
mevent = request.form.get("event", "")
message = request.form.get("message", "")
image_file = request.files.get("image")
image_id = None
if image_file:
image_id = str(uuid.uuid4())
_SCREENSHOTS_DIR.mkdir(exist_ok=True)
(_SCREENSHOTS_DIR / f"{image_id}.png").write_bytes(image_file.read())
entry = {
"type": mtype,
"event": mevent,
"message": message,
"has_image": image_id is not None,
"image_id": image_id,
"timestamp": time.time(),
}
with _lock:
_monitoring_logs.append(entry)
# Привязываем скриншот к задаче по internal_task_id
internal_tid = request.form.get("internal_task_id", "")
if image_id:
if not internal_tid:
print(f"[mock] WARNING: monitoring_log without internal_task_id, screenshot not linked")
else:
matched = False
with _lock:
for e in reversed(_injected):
r = e.get("result")
if r and str(r.get("internal_task_id", "")) == internal_tid:
e["screenshot_id"] = image_id
matched = True
break
if matched:
_save_state()
else:
print(f"[mock] WARNING: no task found for internal_task_id={internal_tid}")
log("POST /monitoring/log", {"type": mtype, "event": mevent,
"has_image": image_id is not None,
"message": message[:100]})
return ok({}) return ok({})
@app.get("/_admin/screenshot/<image_id>")
def get_screenshot(image_id):
path = _SCREENSHOTS_DIR / f"{image_id}.png"
if not path.exists():
return "not found", 404
return Response(path.read_bytes(), mimetype="image/png")
# ---------------------------------------------------------------- admin # ---------------------------------------------------------------- admin
@app.post("/_admin/inject") @app.post("/_admin/inject")
def admin_inject(): def admin_inject():
@ -445,10 +554,12 @@ def admin_bootstrap():
def admin_report(): def admin_report():
"""Full task ↔ result pairing report.""" """Full task ↔ result pairing report."""
with _lock: with _lock:
errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e))
summary = { summary = {
"total": len(_injected), "total": len(_injected),
"pending": sum(1 for e in _injected if e["status"] == "pending"), "pending": sum(1 for e in _injected if e["status"] == "pending"),
"completed": sum(1 for e in _injected if e["status"] == "completed"), "completed": sum(1 for e in _injected if e["status"] == "completed") - errors,
"errors": errors,
"unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"), "unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"),
} }
return jsonify({ return jsonify({
@ -463,12 +574,25 @@ def admin_report_pending():
return jsonify([e for e in _injected if e["status"] == "pending"]) return jsonify([e for e in _injected if e["status"] == "pending"])
def _is_error_entry(e) -> bool:
"""Check if a completed entry has an error in result data."""
result = e.get("result")
if not result:
return False
data = result.get("data")
if isinstance(data, dict) and data.get("status") == "error":
return True
return False
def _snapshot_entries_and_summary(): def _snapshot_entries_and_summary():
entries = list(_injected) entries = list(_injected)
errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e))
summary = { summary = {
"total": len(entries), "total": len(entries),
"pending": sum(1 for e in entries if e["status"] == "pending"), "pending": sum(1 for e in entries if e["status"] == "pending"),
"completed": sum(1 for e in entries if e["status"] == "completed"), "completed": sum(1 for e in entries if e["status"] == "completed") - errors,
"errors": errors,
"unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"), "unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"),
} }
return entries, summary return entries, summary
@ -487,33 +611,7 @@ def admin_report_fragment():
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts.""" """JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
with _lock: with _lock:
entries, summary = _snapshot_entries_and_summary() entries, summary = _snapshot_entries_and_summary()
# Render just the grouped body (without the full page shell) body = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст</div>'
from collections import defaultdict as _dd
import html as _html
groups = _dd(list)
for e in entries:
mid = (e.get("task") or {}).get("material_id")
label = _device_label_by_material.get(mid) or (
"(без устройства)" if mid is None else f"{mid[:8]}"
)
groups[label].append(e)
blocks = []
for label in sorted(groups.keys()):
gentries = groups[label]
g_done = sum(1 for e in gentries if e["status"] == "completed")
g_pend = sum(1 for e in gentries if e["status"] == "pending")
rows_html = "\n".join(render_entry_html(e) for e in gentries)
blocks.append(
f'<section class="group">'
f'<h2><span class="dev">{_html.escape(label)}</span>'
f'<span class="g-stats">'
f'<span class="g-done">{g_done}</span>'
f' / <span class="g-total">{len(gentries)}</span>'
f' <span class="muted">(ожидают: {g_pend})</span>'
f'</span></h2>{rows_html}</section>'
)
body = "\n".join(blocks) or '<div class="empty">Отчёт пуст</div>'
return jsonify({"html": body, "summary": summary}) return jsonify({"html": body, "summary": summary})
@ -531,6 +629,8 @@ def _esc(s: str) -> str:
def render_entry_html(e) -> str: def render_entry_html(e) -> str:
import html as _html import html as _html
status = e["status"] status = e["status"]
is_error = _is_error_entry(e)
display_status = "error" if is_error else status
task = e.get("task") or {} task = e.get("task") or {}
result = e.get("result") result = e.get("result")
ttype = _html.escape(task.get("type", "?")) ttype = _html.escape(task.get("type", "?"))
@ -540,41 +640,70 @@ def render_entry_html(e) -> str:
injected_at = _fmt_ts(e.get("injected_at")) injected_at = _fmt_ts(e.get("injected_at"))
result_at = _fmt_ts(e.get("result_at")) result_at = _fmt_ts(e.get("result_at"))
tid = _html.escape(e.get("id", ""))
def json_block(obj): def json_block(obj):
if obj is None: if obj is None:
return '<span class="muted">—</span>' return '<span class="muted">—</span>'
text = json.dumps(obj, ensure_ascii=False, indent=2) text = json.dumps(obj, ensure_ascii=False, indent=2)
return f'<pre>{_html.escape(text)}</pre>' escaped = _html.escape(text)
# data-json с escaped JSON для копирования
raw = _html.escape(text, quote=True)
return (f'<div class="json-wrap">'
f'<button class="copy-btn" onclick="copyJson(this)" data-json="{raw}">Копировать</button>'
f'<pre>{escaped}</pre></div>')
result_summary = "" result_summary = ""
if result and isinstance(result.get("data"), dict): if is_error:
d = result["data"] d = result.get("data", {}) if result else {}
if d.get("status") == "error": result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
elif result and isinstance(result.get("data"), list): elif result and isinstance(result.get("data"), list):
result_summary = f'<div class="muted">{len(result["data"])} items</div>' result_summary = f'<div class="muted">{len(result["data"])} items</div>'
scr_id = e.get("screenshot_id")
scr_pane = ""
if scr_id:
scr_pane = f"""
<div class="pane screenshot-pane">
<div class="label">Screenshot</div>
<img src="/_admin/screenshot/{scr_id}" class="screenshot" loading="lazy">
</div>"""
has_screenshot = bool(scr_id)
panes_cls = "panes panes-3" if has_screenshot else "panes"
# Успешные — вся задача свёрнута, ошибки — развёрнуты
if display_status == "completed":
panes_html = f"""
<div class="toggle-wrap" data-tid="{tid}">
<button class="toggle-btn" onclick="this.parentElement.classList.toggle('open')">Показать JSON</button>
<div class="toggle-content">
<div class="{panes_cls}">
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
{scr_pane}
</div>
</div>
</div>"""
else:
panes_html = f"""
<div class="{panes_cls}">
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
{scr_pane}
</div>"""
return f""" return f"""
<div class="entry {status}"> <div class="entry {display_status}">
<div class="head"> <div class="head">
<span class="badge {status}">{status}</span> <span class="badge {display_status}">{display_status}</span>
<span class="type">{ttype}</span> <span class="type">{ttype}</span>
<span class="bank">{bank}</span> <span class="bank">{bank}</span>
<span class="mid">{mid}</span> <span class="mid">{mid}</span>
<span class="src">{src}</span> <span class="src">{src}</span>
<span class="times">inj {injected_at} res {result_at}</span> <span class="times">inj {injected_at} res {result_at}</span>
</div> </div>
<div class="panes"> {panes_html}
<div class="pane task">
<div class="label">Task JSON</div>
{json_block(task)}
</div>
<div class="pane result">
<div class="label">Result JSON</div>
{result_summary}
{json_block(result)}
</div>
</div>
</div> </div>
""" """
@ -670,25 +799,52 @@ def render_profile_section() -> str:
) )
def render_report_html(entries, summary) -> str: def _render_grouped_blocks(entries) -> str:
import html as _html import html as _html
# Group by device label # Group by device label → bank_name
groups: dict = defaultdict(list) devices: dict = defaultdict(lambda: defaultdict(list))
for e in entries: for e in entries:
mid = (e.get("task") or {}).get("material_id") task = e.get("task") or {}
mid = task.get("material_id")
label = _device_label_by_material.get(mid) or ( label = _device_label_by_material.get(mid) or (
"(без устройства)" if mid is None else f"{mid[:8]}" "(без устройства)" if mid is None else f"{mid[:8]}"
) )
groups[label].append(e) bank = task.get("bank_name", "unknown")
devices[label][bank].append(e)
group_blocks = [] group_blocks = []
for label in sorted(groups.keys()): for label in sorted(devices.keys()):
gentries = groups[label] banks = devices[label]
g_total = len(gentries) # Device-level stats
g_done = sum(1 for e in gentries if e["status"] == "completed") all_entries = [e for bl in banks.values() for e in bl]
g_pend = sum(1 for e in gentries if e["status"] == "pending") g_total = len(all_entries)
rows_html = "\n".join(render_entry_html(e) for e in gentries) g_err = sum(1 for e in all_entries if _is_error_entry(e))
g_done = sum(1 for e in all_entries if e["status"] == "completed") - g_err
g_pend = sum(1 for e in all_entries if e["status"] == "pending")
err_badge = f' <span style="color:var(--unsolicited)">ошибки: {g_err}</span>' if g_err else ''
bank_blocks = []
for bank in sorted(banks.keys()):
bentries = banks[bank]
b_total = len(bentries)
b_err = sum(1 for e in bentries if _is_error_entry(e))
b_done = sum(1 for e in bentries if e["status"] == "completed") - b_err
b_pend = sum(1 for e in bentries if e["status"] == "pending")
b_err_badge = f' <span style="color:var(--unsolicited)">{b_err}</span>' if b_err else ''
rows_html = "\n".join(render_entry_html(e) for e in bentries)
bank_blocks.append(
f'<div class="bank-group">'
f'<h3><span class="bank">{_html.escape(bank)}</span>'
f'<span class="g-stats">'
f'<span class="g-done">{b_done}</span>'
f' / <span class="g-total">{b_total}</span>'
f'{b_err_badge}'
f' <span class="muted">(ожидают: {b_pend})</span>'
f'</span></h3>'
f'{rows_html}</div>'
)
group_blocks.append(f""" group_blocks.append(f"""
<section class="group"> <section class="group">
<h2> <h2>
@ -696,14 +852,19 @@ def render_report_html(entries, summary) -> str:
<span class="g-stats"> <span class="g-stats">
<span class="g-done">{g_done}</span> <span class="g-done">{g_done}</span>
/ <span class="g-total">{g_total}</span> / <span class="g-total">{g_total}</span>
{err_badge}
<span class="muted">(ожидают: {g_pend})</span> <span class="muted">(ожидают: {g_pend})</span>
</span> </span>
</h2> </h2>
{rows_html} {"".join(bank_blocks)}
</section> </section>
""") """)
body_rows = "\n".join(group_blocks) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>' return "\n".join(group_blocks)
def render_report_html(entries, summary) -> str:
body_rows = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
return f"""<!doctype html> return f"""<!doctype html>
<html lang="ru"> <html lang="ru">
@ -751,6 +912,7 @@ def render_report_html(entries, summary) -> str:
overflow: hidden; overflow: hidden;
}} }}
.entry.completed {{ border-left: 4px solid var(--completed); }} .entry.completed {{ border-left: 4px solid var(--completed); }}
.entry.error {{ border-left: 4px solid var(--unsolicited); }}
.entry.pending {{ border-left: 4px solid var(--pending); }} .entry.pending {{ border-left: 4px solid var(--pending); }}
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }} .entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
.head {{ .head {{
@ -764,6 +926,7 @@ def render_report_html(entries, summary) -> str:
}} }}
.badge.pending {{ background: var(--pending); color: #1a1600; }} .badge.pending {{ background: var(--pending); color: #1a1600; }}
.badge.completed {{ background: var(--completed); color: #0a1a0a; }} .badge.completed {{ background: var(--completed); color: #0a1a0a; }}
.badge.error {{ background: var(--unsolicited); color: #200; }}
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }} .badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
.type {{ font-weight: 600; color: #9cdcfe; }} .type {{ font-weight: 600; color: #9cdcfe; }}
.bank {{ color: #ffb86c; }} .bank {{ color: #ffb86c; }}
@ -771,6 +934,7 @@ def render_report_html(entries, summary) -> str:
.src {{ color: var(--muted); font-style: italic; }} .src {{ color: var(--muted); font-style: italic; }}
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }} .times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }} .panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
.panes-3 {{ grid-template-columns: 1fr 1fr auto; }}
.pane {{ padding: 10px 12px; }} .pane {{ padding: 10px 12px; }}
.pane.task {{ background: var(--task); }} .pane.task {{ background: var(--task); }}
.pane.result {{ background: var(--result); }} .pane.result {{ background: var(--result); }}
@ -785,6 +949,37 @@ def render_report_html(entries, summary) -> str:
}} }}
.muted {{ color: var(--muted); font-style: italic; }} .muted {{ color: var(--muted); font-style: italic; }}
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }} .err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
.toggle-wrap .toggle-content {{ display: none; }}
.toggle-wrap.open .toggle-content {{ display: block; }}
.toggle-btn {{
background: var(--panel); color: var(--muted);
border: 1px solid var(--border); border-radius: 4px;
padding: 4px 12px; margin: 8px 12px;
cursor: pointer; font-size: 11px;
transition: background 0.15s, color 0.15s;
}}
.toggle-btn:hover {{ background: var(--border); color: var(--text); }}
.json-wrap {{ position: relative; }}
.copy-btn {{
position: absolute; top: 6px; right: 6px;
background: var(--border); color: var(--muted);
border: none; border-radius: 4px;
padding: 5px 12px; font-size: 12px;
cursor: pointer; opacity: 0; transition: opacity 0.15s;
}}
.json-wrap:hover .copy-btn {{ opacity: 1; }}
.copy-btn:hover {{ color: var(--text); background: #3a3f4a; }}
.copy-btn.copied {{ color: var(--completed); }}
.screenshot-pane {{ background: var(--panel); max-width: 280px; }}
.screenshot {{ max-height: 200px; width: auto; border-radius: 4px; margin-top: 4px; cursor: pointer; transition: opacity 0.15s; }}
.screenshot:hover {{ opacity: 0.8; }}
.lightbox {{
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
cursor: zoom-out;
}}
.lightbox.open {{ display: flex; }}
.lightbox img {{ max-width: 90vw; max-height: 90vh; border-radius: 6px; }}
.empty {{ color: var(--muted); text-align: center; padding: 40px; }} .empty {{ color: var(--muted); text-align: center; padding: 40px; }}
a {{ color: #9cdcfe; }} a {{ color: #9cdcfe; }}
.group {{ .group {{
@ -807,6 +1002,22 @@ def render_report_html(entries, summary) -> str:
.group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }} .group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }} .group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
.group h2 .g-total {{ color: var(--muted); }} .group h2 .g-total {{ color: var(--muted); }}
.bank-group {{
margin: 8px 0 16px;
padding-left: 12px;
border-left: 2px solid var(--border);
}}
.bank-group h3 {{
margin: 0 0 8px;
font-size: 13px;
display: flex;
align-items: baseline;
gap: 10px;
}}
.bank-group h3 .bank {{ color: #9cdcfe; font-weight: 600; }}
.bank-group h3 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 12px; }}
.bank-group h3 .g-done {{ color: var(--completed); font-weight: 700; }}
.bank-group h3 .g-total {{ color: var(--muted); }}
.updating {{ opacity: 0.6; }} .updating {{ opacity: 0.6; }}
/* Profile section */ /* Profile section */
.profile-section {{ .profile-section {{
@ -891,8 +1102,8 @@ def render_report_html(entries, summary) -> str:
<div class="summary"> <div class="summary">
<div class="cell"><div class="n">{summary['total']}</div>всего</div> <div class="cell"><div class="n">{summary['total']}</div>всего</div>
<div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div> <div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div>
<div class="cell unsolicited"><div class="n">{summary['errors']}</div>ошибки</div>
<div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div> <div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div>
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</div>незапрошенные</div>
</div> </div>
{render_profile_section()} {render_profile_section()}
<div id="report-body"> <div id="report-body">
@ -906,20 +1117,48 @@ def render_report_html(entries, summary) -> str:
const summaryEls = {{ const summaryEls = {{
total: document.querySelector('.summary .cell:nth-child(1) .n'), total: document.querySelector('.summary .cell:nth-child(1) .n'),
completed: document.querySelector('.summary .cell.completed .n'), completed: document.querySelector('.summary .cell.completed .n'),
errors: document.querySelector('.summary .cell.unsolicited .n'),
pending: document.querySelector('.summary .cell.pending .n'), pending: document.querySelector('.summary .cell.pending .n'),
unsolicited: document.querySelector('.summary .cell.unsolicited .n'),
}}; }};
function saveToggleState() {{
const state = new Set();
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
if (el.classList.contains('open')) state.add(el.dataset.tid);
}});
return state;
}}
function restoreToggleState(state) {{
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
if (state.has(el.dataset.tid)) el.classList.add('open');
}});
updateToggleLabels();
}}
function updateToggleLabels() {{
bodyEl.querySelectorAll('.toggle-wrap').forEach(el => {{
const btn = el.querySelector('.toggle-btn');
if (btn) btn.textContent = el.classList.contains('open') ? 'Свернуть JSON' : 'Показать JSON';
}});
}}
document.addEventListener('click', e => {{
if (e.target.classList.contains('toggle-btn')) updateToggleLabels();
}});
async function tick() {{ async function tick() {{
try {{ try {{
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}}); const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
if (!resp.ok) throw new Error(resp.status); if (!resp.ok) throw new Error(resp.status);
const data = await resp.json(); const data = await resp.json();
const saved = saveToggleState();
bodyEl.innerHTML = data.html; bodyEl.innerHTML = data.html;
restoreToggleState(saved);
summaryEls.total.textContent = data.summary.total; summaryEls.total.textContent = data.summary.total;
summaryEls.completed.textContent = data.summary.completed; summaryEls.completed.textContent = data.summary.completed;
summaryEls.errors.textContent = data.summary.errors;
summaryEls.pending.textContent = data.summary.pending; summaryEls.pending.textContent = data.summary.pending;
summaryEls.unsolicited.textContent = data.summary.unsolicited;
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')'; statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
}} catch (e) {{ }} catch (e) {{
statusEl.textContent = '(ошибка обновления: ' + e + ')'; statusEl.textContent = '(ошибка обновления: ' + e + ')';
@ -929,6 +1168,31 @@ def render_report_html(entries, summary) -> str:
setInterval(tick, REFRESH_MS); setInterval(tick, REFRESH_MS);
}})(); }})();
</script> </script>
<script>
function copyJson(btn) {{
const text = btn.dataset.json;
navigator.clipboard.writeText(text).then(() => {{
btn.textContent = 'Скопировано';
btn.classList.add('copied');
setTimeout(() => {{ btn.textContent = 'Копировать'; btn.classList.remove('copied'); }}, 1500);
}});
}}
</script>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')">
<img id="lightbox-img" src="" alt="">
</div>
<script>
document.addEventListener('click', function(e) {{
if (e.target.classList.contains('screenshot')) {{
const lb = document.getElementById('lightbox');
document.getElementById('lightbox-img').src = e.target.src;
lb.classList.add('open');
}}
}});
document.addEventListener('keydown', function(e) {{
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('open');
}});
</script>
</body> </body>
</html> </html>
""" """
@ -942,7 +1206,12 @@ def admin_reset():
_injected.clear() _injected.clear()
_pending_by_key.clear() _pending_by_key.clear()
_in_flight.clear() _in_flight.clear()
_monitoring_logs.clear()
_bootstrap_done = False _bootstrap_done = False
if _SCREENSHOTS_DIR.exists():
shutil.rmtree(_SCREENSHOTS_DIR)
if _STATE_FILE.exists():
_STATE_FILE.unlink()
return ok({}) return ok({})
@ -970,24 +1239,68 @@ if __name__ == "__main__":
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)") help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
parser.add_argument("--seed", type=int, default=None, parser.add_argument("--seed", type=int, default=None,
help="RNG seed for reproducible bootstrap picks") help="RNG seed for reproducible bootstrap picks")
parser.add_argument("--types", nargs="+", default=None,
help="Only bootstrap these task types (e.g. --types FETCH_TRANSACTIONS)")
parser.add_argument("--debug", action="store_true",
help="Run Flask in debug mode (auto-reload on code changes)")
args = parser.parse_args() args = parser.parse_args()
_bootstrap_ft_count = args.ft_per_material _bootstrap_ft_count = args.ft_per_material
_bootstrap_banks = args.banks _bootstrap_banks = args.banks
_bootstrap_types = args.types
if _bootstrap_banks: if _bootstrap_banks:
print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}") print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}")
if _bootstrap_types:
print(f"[mock] bootstrap limited to types: {_bootstrap_types}")
if args.seed is not None: if args.seed is not None:
random.seed(args.seed) random.seed(args.seed)
# Fresh start: drop any leftover state from a previous run in this process.
_task_queue.clear()
_injected.clear()
_pending_by_key.clear()
_in_flight.clear()
_bootstrap_done = False
print("[mock] state cleared — starting fresh")
load_default_profile() load_default_profile()
load_pool() load_pool()
app.run(host=args.host, port=args.port, threaded=True) is_reload = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
if args.debug and is_reload and _load_state():
# Auto-reload в debug — восстанавливаем состояние
print("[mock] auto-reload: state restored from disk")
else:
# Первый запуск (или без --debug) — чистим всё
_task_queue.clear()
_injected.clear()
_pending_by_key.clear()
_in_flight.clear()
_monitoring_logs.clear()
_bootstrap_done = False
if _STATE_FILE.exists():
_STATE_FILE.unlink()
import shutil
if _SCREENSHOTS_DIR.exists():
shutil.rmtree(_SCREENSHOTS_DIR)
# Чистим events в БД приложения чтобы не было "App restarted" при старте
try:
import configparser
import sqlite3
project_root = (HERE / ".." / "..").resolve()
for cfg in [project_root / "config.ini", project_root / "build_test" / "config.ini"]:
if cfg.exists():
cp = configparser.ConfigParser()
cp.read(str(cfg), encoding="utf-8")
raw = cp.get("db", "dbPath", fallback="")
if raw:
db = Path(os.path.expanduser(raw))
if not db.is_absolute():
db = cfg.parent / db
if db.exists():
conn = sqlite3.connect(str(db))
n = conn.execute("SELECT count(*) FROM events").fetchone()[0]
if n > 0:
conn.execute("DELETE FROM events")
conn.commit()
print(f"[mock] cleaned {n} events from {db.name}")
conn.close()
break
except Exception as e:
print(f"[mock] warning: could not clean events DB: {e}")
print("[mock] starting fresh (clean slate)")
app.run(host=args.host, port=args.port, threaded=True, debug=args.debug)

View File

@ -0,0 +1,6 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "36002ac2-6f4d-49da-8892-18db723a31c7",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
"type": "FETCH_PROFILE",
"body": {}
}