From 899f4ff6d688646828e1f65b5045107d03091663 Mon Sep 17 00:00:00 2001 From: slava Date: Tue, 14 Apr 2026 18:46:34 +0700 Subject: [PATCH] Add support for mock server state persistence, error tracking, and screenshot handling. Extend bootstrapping logic and monitoring endpoints for enhanced debugging and task management. --- .gitignore | 4 + android/adb/AdbUtils.cpp | 100 ++-- .../dushanbe/GetLastTransactionsScript.cpp | 47 +- android/ozon/CommonScript.cpp | 20 + android/ozon/GetLastTransactionsScript.cpp | 162 +++++- main.cpp | 5 +- services/EventHandler.cpp | 24 +- services/net/NetworkService.cpp | 5 + tests/mock_server/README.md | 235 ++++----- tests/mock_server/WINDOWS.md | 248 +++------ tests/mock_server/application.log | 0 tests/mock_server/debug_detail.xml | 1 + tests/mock_server/default_profile.json | 48 +- tests/mock_server/gen_fetch_transactions.py | 4 +- tests/mock_server/gen_mock_data.py | 9 +- tests/mock_server/mock_server.py | 489 ++++++++++++++---- ...dushanbe_city_bank_3258_fetch_profile.json | 6 + .../pixel_7_ozon_7919_fetch_profile.json | 6 + .../pixel_7_ozon_9865_fetch_profile.json | 6 + .../tecno_km4_ozon_0982_fetch_profile.json | 6 + 20 files changed, 933 insertions(+), 492 deletions(-) create mode 100644 tests/mock_server/application.log create mode 100644 tests/mock_server/debug_detail.xml create mode 100644 tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json create mode 100644 tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json create mode 100644 tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json create mode 100644 tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json diff --git a/.gitignore b/.gitignore index 40ede52..61b816a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ .DS_Store .idea/ +# Mock server state +tests/mock_server/.mock_state.json +tests/mock_server/.mock_screenshots/ + cmake_install.cmake CMakeCache.txt Makefile diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index a579877..66127d3 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include 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' QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) { const QString adb = adbPath(); - QString output; - // 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android) - bool dumpOk = false; - for (int attempt = 0; attempt < 3; ++attempt) { - if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) { - dumpOk = true; - break; + for (int retry = 0; retry < 3; ++retry) { + // 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android) + bool dumpOk = false; + for (int attempt = 0; attempt < 3; ++attempt) { + if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) { + 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"; - QThread::msleep(1000); - } - 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."; + if (!dumpOk) { + qWarning() << "Failed to dump UI hierarchy after 3 attempts."; 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 {}; + // 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 {}; + } + } + // 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("isInterruptionRequested(); @@ -379,6 +379,7 @@ void GetLastTransactionsScript::doStart() { double amount = 0.0; QString time; // "21:31:21" QString date; // "05.04.2026" + bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates Node node; }; @@ -462,7 +463,7 @@ void GetLastTransactionsScript::doStart() { it.time = last; it.node = n; - // В P2P формате дата встроена в первую строку + // В P2P формате дата встроена в первую строку — надёжная if (isP2P) { if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) it.date = todayLocal.toString("dd.MM.yyyy"); @@ -470,6 +471,7 @@ void GetLastTransactionsScript::doStart() { it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy"); else it.date = firstLine; + it.dateReliable = true; } items.append(it); } @@ -537,8 +539,15 @@ void GetLastTransactionsScript::doStart() { prevY2 = it.node.y2(); } - qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size() - << "groups:" << groupDates; + qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll + << "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); for (const auto &tx : items) { @@ -552,10 +561,10 @@ void GetLastTransactionsScript::doStart() { << (matchedX100 ? "(scaled x100)" : "(direct)") << "date:" << tx.date << "time:" << tx.time; - // Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день), - // пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк - // быстрее, чем 10-секундный цикл tap→parse→goBack. - if (m_searchTime.isValid() && !tx.date.isEmpty()) { + // Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата). + // DCWallet транзакции получают дату из groupDates ImageView, которая + // неправильно присваивается при скролле — не фильтруем по ней. + if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) { const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy"); const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date(); if (listDate.isValid() && searchLocalDate.isValid()) { @@ -720,13 +729,21 @@ void GetLastTransactionsScript::doStart() { return; } - // Скроллим вниз + // Скроллим вниз — свайп внутри области транзакций if (scroll < maxScrolls) { if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } const int x = device.screenWidth / 2; - const int fromY = device.screenHeight * 3 / 4; - const int toY = device.screenHeight / 4; - AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY); + // Определяем область контента из реальных Y позиций items + int contentBottom = device.screenHeight * 2 / 3; + 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); } } diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index 2bed3fa..89eb98e 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -88,6 +88,15 @@ bool CommonScript::runAppAndGoToHomeScreen( qDebug() << "[Ozon] Fast path: Home reached after PIN"; 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); } } @@ -128,6 +137,17 @@ bool CommonScript::runAppAndGoToHomeScreen( 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(); if (!closeBannerNode.isEmpty()) { diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 94f6511..b6eb7d5 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -132,7 +132,21 @@ void GetLastTransactionsScript::doStart() { } 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)); + tryRefreshIfError(); // Закрываем рекламный bottom sheet 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)); + // Если уже на экране операций — скроллим наверх и готово + 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 (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, @@ -160,6 +189,7 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi return false; } 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); QTimeZone deviceTz(tzName.toUtf8()); if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow"); - const int maxScrolls = 30; + const int maxScrolls = 10; const int navBarTop = device.screenHeight - (device.screenHeight / 10); // Целевая дата в таймзоне устройства для поиска заголовка дня в списке const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz); 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) << "tz=" << tzName - << "date(local)=" << searchDate.toString("dd.MM.yyyy"); + << "date(local)=" << searchDate.toString("dd.MM.yyyy") + << "screen=" << device.screenWidth << "x" << device.screenHeight; // ── Шаг 1: скроллим до нужной даты ────────────────────────────── static const QMap monthMap = { @@ -290,7 +322,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int break; } 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); } @@ -305,26 +338,45 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. // Кнопки транзакций между этими Y принадлежат нужному дню. + // Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё + // видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY + // принадлежат нашему дню. auto findDateYRange = [&]() -> QPair { int dateY = -1; int nextDateY = device.screenHeight; + bool foundOlderDate = false; for (const Node &node : xmlScreenParser.getNodes()) { if (node.className != "android.widget.TextView") continue; const QDate d = parseDateHeader(node.text.trimmed()); if (!d.isValid()) continue; if (d == searchDate) { dateY = node.y(); - } else if (dateY >= 0 && node.y() > dateY) { - // Первый заголовок другой даты после нашей - nextDateY = node.y(); + } else if (d < searchDate) { + // Заголовок более старой даты — граница снизу + if (dateY >= 0 && node.y() > dateY) { + nextDateY = node.y(); + } else if (dateY < 0) { + // Заголовок нужной даты ушёл вверх, но видна более старая дата + nextDateY = node.y(); + dateY = 0; // всё выше nextDateY принадлежит нашему дню + } + foundOlderDate = true; break; } } + // Заголовок ушёл вверх и нет более старых дат на экране — + // весь экран всё ещё внутри нашей даты + if (dateY < 0 && !foundOlderDate && dateFound) { + dateY = 0; + } + qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY + << "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate; return {dateY, nextDateY}; }; // ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ─── QSet checkedDetails; + int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок) for (int attempt = 0; attempt < 15; ++attempt) { if (QThread::currentThread()->isInterruptionRequested()) { @@ -333,21 +385,38 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int } 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-диапазон заголовка нужной даты const auto [dateHeaderY, nextDateHeaderY] = findDateYRange(); 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, - device.screenHeight * 3 / 4, + device.screenHeight / 3, device.screenWidth / 2, - device.screenHeight / 4, 300); - QThread::msleep(1000); + device.screenHeight * 2 / 3, 600); + QThread::msleep(1500); continue; } QList txButtons = xmlScreenParser.findTransactionButtons(20); + qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:" + << dateHeaderY << "-" << nextDateHeaderY; bool tapped = false; + bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой + int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты for (int bi = 0; bi < txButtons.size(); ++bi) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; @@ -358,8 +427,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int const int btnY = txButtons[bi].y(); if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue; if (btnY <= 0 || btnY >= navBarTop) continue; + ++buttonsInRange; 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; const Node &visibleBtn = txButtons[bi]; @@ -412,6 +485,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int } checkedDetails.insert(dtStr); 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 минут) 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 << "name=" << detail.name; @@ -443,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int QThread::msleep(2000); bool docLoaded = false; - for (int da = 0; da < 15; ++da) { + for (int da = 0; da < 5; ++da) { if (QThread::currentThread()->isInterruptionRequested()) break; xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) { @@ -535,17 +622,50 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int return; // успех } - // Скроллим вниз если ни одна кнопка не была нажата или все проверенные не подошли - if (!tapped) { - AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, - device.screenHeight * 3 / 4, - device.screenWidth / 2, - device.screenHeight / 4, 300); - QThread::msleep(1000); + // Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ + // суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше, + // нужная кнопка может быть ниже. + if (!hasNewButton && tapped) { + ++noProgressCount; + if (noProgressCount >= 5) { + 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"); qWarning() << "[FetchTransactions]" << m_error; // Отправляем скриншот списка транзакций, сделанный до начала поиска diff --git a/main.cpp b/main.cpp index c445940..43875b0 100644 --- a/main.cpp +++ b/main.cpp @@ -316,7 +316,8 @@ int main(int argc, char *argv[]) { QApplication::setPalette(lightPalette); logFile.setFileName("application.log"); - if (!logFile.open(QIODevice::Append | QIODevice::Text)) { + // Очищаем лог-файл при каждом запуске + if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { qWarning() << "Не удалось открыть лог-файл для записи"; } // Устанавливаем свой обработчик @@ -334,7 +335,7 @@ int main(int argc, char *argv[]) { // Миграции БД DatabaseManager::instance().initSchema(); - DatabaseManager::instance().cleanupOldData(5); + DatabaseManager::instance().cleanupOldData(0); dbReady.store(true); // 1. Если есть api_key — логинимся и проверяем/создаём desktop diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index c504907..89782fc 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -32,8 +32,8 @@ #include "dushanbe/PayByPhoneScript.h" 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 sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}); +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 = {}, int eventId = -1); 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["bank_name"] = bankName; obj["data"] = data; + if (eventId > 0) + obj["internal_task_id"] = eventId; auto *thread = new QThread; 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, - const QString &description, const QByteArray &screenshot) { + const QString &description, const QByteArray &screenshot, int eventId) { QJsonObject errorData; errorData["status"] = QStringLiteral("error"); errorData["description"] = description; - sendTaskResult(type, materialId, bankName, errorData); + sendTaskResult(type, materialId, bankName, errorData, eventId); if (!screenshot.isEmpty()) { const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4") .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, - const QByteArray &image) { + const QByteArray &image, int eventId) { auto *thread = new QThread; auto *networkService = new NetworkService; networkService->moveToThread(thread); + if (eventId > 0) + networkService->addExtra("internal_task_id", eventId); QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() { networkService->postMonitoringLog(type, event, message, image); emit networkService->finished(); @@ -273,7 +277,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, .arg(eventTypeToString(event.type), deviceLabel, event.bankName, result); 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); } @@ -375,21 +379,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } f.remove(); } - sendMonitoringLog("info", "task_success", msg, screenshot); + sendMonitoringLog("info", "task_success", msg, screenshot, event.id); } if (event.type == EventType::FetchTransactions) { const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2") .arg(event.bankName, event.externalId); 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) { const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2") .arg(event.bankName, event.externalId); const QByteArray scr = m_screenshots.take(key); - sendMonitoringLog("info", "task_success", msg, scr); + sendMonitoringLog("info", "task_success", msg, scr, event.id); } } } diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 8b918dd..766aa58 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1318,6 +1318,11 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event addFormField(multiPart, "event", event); 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()) { QHttpPart imagePart; imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); diff --git a/tests/mock_server/README.md b/tests/mock_server/README.md index 43a389d..5b0d488 100644 --- a/tests/mock_server/README.md +++ b/tests/mock_server/README.md @@ -13,7 +13,7 @@ ARC запускается как обычно (реальные Android-уст | Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» | | Flask | `pip install flask` | `pip install flask` | | 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` | --- @@ -35,24 +35,26 @@ python tests\mock_server\gen_mock_data.py Скрипт: - Находит `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/scenarios/*_fetch_profile.json` — по одному на материал - - `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов + - `tests/mock_server/scenarios/*__fetch_profile.json` — по одному на материал (с номером карты в имени) +- Очищает таблицу `events` в БД Пример вывода: ``` config: config.ini database: database/app_data.db + deleted 5 old events wrote default_profile.json (3 bank_profiles) - wrote scenarios/pixel_7_ozon_fetch_profile.json - wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json - wrote scenarios/tecno_km4_ozon_fetch_profile.json + wrote scenarios/pixel_7_ozon_7919_fetch_profile.json + wrote scenarios/pixel_7_ozon_9865_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): - pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…) - tecno_km4: ozon (49001bb5…) + pixel_7: ozon/7919 (50393cc1…), ozon/9865 (06a1ae15…), dushanbe_city_bank/3258 (36002ac2…) + tecno_km4: ozon/0982 (7e3a7e4e…) ``` Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории. @@ -70,19 +72,12 @@ python tests\mock_server\gen_fetch_transactions.py Скрипт: - Читает БД → находит активные материалы - Находит подключённые ADB-устройства → матчит по `adb_serial` -- Показывает меню: - ``` - Available materials (3): - 1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…) - 2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…) - 3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…) - Pick [1-3] or 'all': - ``` -- После выбора просит открыть список транзакций на устройстве → Enter -- Проходит по каждой транзакции: тап → дамп деталей → назад - (перед каждым тапом проверяет, что мы на списке) -- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках) - и `created_at` (UTC, из таймзоны устройства) +- Показывает меню → выбор материала или `all` +- Проходит по транзакциям на экране: + - **Ozon**: парсит заголовки дат (Сегодня/Вчера/dd месяца), определяет дату каждой транзакции по Y-позиции, тапает для получения времени из деталей, скроллит вниз для следующих + - **Dushanbe**: парсит Выписку, тап в детали, время Dushanbe (UTC+5) +- Дедупликация по `(amount, created_at)` — одинаковые суммы в один день с разным временем сохраняются как отдельные задачи +- Сохраняет `scenarios/pool/*__ft_*.json` с реальными `amount` (в копейках) и `created_at` (UTC) - Если мок запущен — сразу инжектит задачи Флаги: @@ -113,57 +108,48 @@ apiBase=http://127.0.0.1:8888 ### Терминал 1 — mock-сервер ```bash -# macOS +# macOS — полный цикл (генерация данных + запуск) 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 cd tests\mock_server python mock_server.py --port 8888 ``` -Логи в консоли: -``` -[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 -``` - -Флаги: -- `--banks ozon` — генерировать задачи только для указанных банков -- `--banks ozon dushanbe_city_bank` — несколько банков через пробел +Флаги `mock_server.py`: +- `--banks ozon dushanbe_city_bank` — только указанные банки +- `--types FETCH_TRANSACTIONS` — только указанные типы задач - `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3) - `--seed 42` — фиксированный RNG для воспроизводимости +- `--debug` — Flask debug режим (auto-reload, состояние сохраняется между перезагрузками) -Без `--banks` задачи генерируются для всех активных банков. - -Примеры: -```bash -# Только Ozon -./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 -``` +При `--debug`: +- Изменения в `.py` файлах применяются автоматически без перезапуска +- Состояние задач сохраняется в `.mock_state.json` и восстанавливается при reload +- Скриншоты сохраняются в `.mock_screenshots/` (переживают reload) +- При **первом** запуске — чистый старт (events в БД очищаются, state сбрасывается) ### Терминал 2 — ARC ```bash -# macOS -./build/ARC +# macOS (из корня проекта!) +./build_test/ARC # Windows build\Release\ARC.exe ``` При логине мок автоматически ставит задачи: -- 1 × FETCH_PROFILE на каждый материал +- 1 × FETCH_PROFILE на каждый материал (если `--types` не ограничивает) - N × FETCH_TRANSACTIONS (случайные из pool) Задачи раздаются **по 1 на material за поллинг** — следующая только после @@ -172,15 +158,9 @@ build\Release\ARC.exe ### Терминал 3 — отчёт ```bash -# Браузер с авто-обновлением (задачи группируются по устройствам) +# Браузер с авто-обновлением open http://127.0.0.1:8888/_admin/report/html # macOS 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-отчёт `http://127.0.0.1:8888/_admin/report/html` — веб-страница с: -- Сводкой: всего / выполнено / в очереди -- Группировкой по устройствам (Pixel 7, TECNO KM4, …) -- Для каждой задачи: Task JSON слева ↔ Result JSON справа -- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error -- Авто-обновлением каждые 2 секунды (без сброса скролла) - -Также доступен JSON: -```bash -curl -s http://127.0.0.1:8888/_admin/report | jq # полный -curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending -``` +- Сводкой: всего / выполнено / ошибки / в очереди +- Группировкой по **устройствам** → **банкам** (Pixel 7 → ozon, dushanbe_city_bank) +- Для каждой задачи: + - Task JSON и Result JSON (для успешных — свёрнуты, кнопка "Показать JSON") + - Ошибки — развёрнуты сразу с красным бейджем + - Скриншот (если есть) — третья колонка, клик открывает полноэкранный просмотр + - Кнопка "Копировать" на каждом JSON блоке +- Цветовой индикацией: зелёный = completed, красный = error, жёлтый = pending +- Авто-обновлением каждые 2 секунды (состояние свёрнутых/развёрнутых блоков сохраняется) --- -## 6. Ручной инжект задач +## 6. Скриншоты + +Приложение отправляет скриншоты через `POST /monitoring/log` с полем `internal_task_id`. +Мок-сервер: +- Сохраняет PNG на диск в `.mock_screenshots/` +- Привязывает к задаче по `internal_task_id` (точное совпадение с `task_result`) +- Отображает в отчёте как третью колонку рядом с Task/Result JSON +- Клик по скриншоту — полноэкранный просмотр (Escape для закрытия) + +--- + +## 7. Ручной инжект задач ```bash # Одна задача -./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS -curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows - -H "Content-Type: application/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/inject \ + -H "Content-Type: application/json" \ + --data-binary @scenarios/pixel_7_ozon_7919_fetch_profile.json # Повторный бутстрап (без перезапуска мока) curl -X POST http://127.0.0.1:8888/_admin/bootstrap -# Сброс (очистка очереди + результатов + in-flight) +# Сброс (очистка очереди + результатов + скриншотов) curl -X POST http://127.0.0.1:8888/_admin/reset ``` --- -## 7. Админ-эндпойнты мока +## 8. Админ-эндпойнты мока | Метод | Путь | Назначение | |---|---|---| | POST | `/_admin/inject` | Положить задачу (или массив) в очередь | -| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) | +| POST | `/_admin/bootstrap` | Повторный бутстрап | | POST | `/_admin/seed_profile` | Подменить ответ `current_profile` | | POST | `/_admin/seed_devices` | Подменить список устройств | -| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты | +| GET | `/_admin/report` | JSON: полный отчёт | | GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением | | GET | `/_admin/report/pending` | Только незавершённые задачи | | GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления | +| GET | `/_admin/screenshot/` | PNG скриншот по ID | | GET | `/_admin/state` | Счётчики | | POST | `/_admin/reset` | Очистить всё, сбросить бутстрап | --- -## 8. Полный цикл с нуля (новая машина / новые устройства) +## 9. Полный цикл с нуля ```bash # 1. Установка pip install flask -# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства, -# банковские профили и материалы появятся в БД +# 2. Собрать ARC, запустить с боевым сервером → залогиниться +# Устройства, профили и материалы появятся в БД # 3. Переключить config.ini на мок # [net] # apiBase=http://127.0.0.1:8888 # 4. Сгенерировать стартовые данные из БД -python tests/mock_server/gen_mock_data.py +python3 tests/mock_server/gen_mock_data.py # 5. Открыть на устройстве банковское приложение → список транзакций # Сгенерировать пул задач -python tests/mock_server/gen_fetch_transactions.py +python3 tests/mock_server/gen_fetch_transactions.py -# 6. Запустить мок (все банки) -cd tests/mock_server && python mock_server.py --port 8888 -# или только определённые: -# cd tests/mock_server && python mock_server.py --port 8888 --banks ozon +# 6. Запустить мок +cd tests/mock_server && ./run.sh --banks ozon --debug -# 7. Запустить ARC (в отдельном терминале) -./build/ARC # macOS -build\Release\ARC.exe # Windows +# 7. Запустить ARC (из корня проекта, в отдельном терминале) +./build_test/ARC # 8. Открыть отчёт 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 = всего в отчёте # 10. Вернуть config.ini на прод -# [net] -# apiBase=http://93.183.75.134:8005 ``` --- -## 9. Структура файлов +## 10. Структура файлов ``` tests/mock_server/ ├── 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_fetch_transactions.py # генерация pool из живого устройства ├── inject.sh # curl-обёртка для инжекта -├── inject_random.py # рандомный инжект из pool ├── default_profile.json # авто-генерированный профиль для мока -├── seed_profile.example.json # пример ручного засева ├── README.md # эта инструкция ├── WINDOWS.md # Windows-специфичные нюансы -├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body) -│ ├── pixel_7_ozon_fetch_profile.json -│ ├── pixel_7_ozon_fetch_transactions.json -│ ├── tecno_km4_ozon_fetch_profile.json +├── .mock_state.json # состояние задач (debug режим, .gitignore) +├── .mock_screenshots/ # скриншоты с устройств (.gitignore) +├── scenarios/ # FETCH_PROFILE сценарии +│ ├── pixel_7_ozon_7919_fetch_profile.json +│ ├── pixel_7_ozon_9865_fetch_profile.json │ └── ... -├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at -│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json -│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json -│ └── ... -└── fixtures/ # XML/PNG дампы экранов (справочно) - ├── tecno_ozon/ - ├── pixel7_ozon/ - └── pixel7_dushanbe/ +└── scenarios/pool/ # пул FETCH_TRANSACTIONS + ├── pixel_7_ozon_7919_01_minus_108_ft_01.json + ├── pixel_7_ozon_9865_01_minus_108_ft_01.json + ├── pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json + └── ... ``` --- -## 10. Типовые проблемы +## 11. Типовые проблемы | Симптом | Причина | Решение | |---|---|---| | `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` | -| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться | -| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB | -| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC | -| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный | -| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` | -| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг | -| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) | -| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском | +| `No active materials` | Нет профилей с проверенным PIN | Запустить ARC с боевым сервером, добавить PIN | +| `No ADB devices match` | Устройства не подключены | `adb devices`, проверить USB | +| `App restarted` ошибки в отчёте | Events остались от прошлого запуска | Мок-сервер чистит events при старте; используйте `run.sh` или запускайте сервер **до** ARC | +| Задача `pending` навсегда | material в `_in_flight` | `/_admin/reset` и перезапустить ARC | +| `Transaction not found` | Баг поиска — скролл, видимость кнопок | Анализировать логи (device= в ошибке) | +| `Page load error` | Ozon WebView не загрузился | Проблема интернета/устройства, таймаут 5 мин | +| Скриншот не привязан | Нет `internal_task_id` | Обновить ARC (фикс в EventHandler.cpp) | +| Pool material_id не совпадает | БД обновилась, pool устарел | Перегенерировать pool или обновить material_id в файлах | | Порт занят | Другой процесс на 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-уведомления банков - Сетевые задержки/таймауты diff --git a/tests/mock_server/WINDOWS.md b/tests/mock_server/WINDOWS.md index 06f2597..87419ed 100644 --- a/tests/mock_server/WINDOWS.md +++ b/tests/mock_server/WINDOWS.md @@ -28,14 +28,11 @@ powershell -ExecutionPolicy Bypass -File setup.ps1 4. Находит `config.ini` и проверяет **базу данных** 5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает) 6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД -7. Если порт занят — убивает старый процесс -8. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`) -9. Показывает сводку: устройства, профили, материалы, пул транзакций -10. Запускает mock-сервер и открывает HTML-отчёт в браузере - -При первом логине ARC мок автоматически ставит **FETCH_PROFILE** задачи -на каждый материал. **FETCH_TRANSACTIONS** задачи нужно генерировать отдельно -(см. [раздел 3](#3-генерация-fetch_transactions-задач)). +7. Очищает таблицу `events` в БД +8. Если порт занят — убивает старый процесс +9. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`) +10. Показывает сводку: устройства, профили, материалы, пул транзакций +11. Запускает mock-сервер и открывает HTML-отчёт в браузере ### Параметры @@ -44,33 +41,23 @@ powershell -ExecutionPolicy Bypass -File setup.ps1 | `-Port` | 8888 | Порт mock-сервера | | `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) | | `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` | +| `-Types` | все | Типы задач: `FETCH_PROFILE`, `FETCH_TRANSACTIONS` | | `-SkipInstall` | — | Пропустить установку Python/Flask | | `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер | | `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) | +| `-Debug` | — | Flask debug режим (auto-reload при изменении кода) | ### Примеры ```powershell -# Всё автоматически (установка + генерация + запуск) +# Всё автоматически .\setup.ps1 -# На другом порту с 5 транзакциями на материал -.\setup.ps1 -Port 9999 -FtPerMaterial 5 +# Только Ozon, только FETCH_TRANSACTIONS +.\setup.ps1 -Banks ozon -Types FETCH_TRANSACTIONS -# Только Ozon -.\setup.ps1 -Banks ozon - -# Только 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 +# Debug режим с 5 транзакциями на материал +.\setup.ps1 -Banks ozon -FtPerMaterial 5 -Debug # Полная очистка (включая пул транзакций) .\setup.ps1 -Clean @@ -81,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File setup.ps1 ## 2. Вспомогательные скрипты После первого запуска `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` | | `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` | -Скрипты принимают дополнительные аргументы, которые передаются в Python-скрипт: +Скрипты принимают дополнительные аргументы: ```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 run_server.ps1 --port 9999 ``` --- @@ -104,8 +91,6 @@ powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999 ## 3. Генерация FETCH_TRANSACTIONS задач FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства. -Скрипт подключается по ADB, проходит по списку транзакций на экране -(тап -> снятие суммы/даты -> назад) и сохраняет JSON-файлы в `scenarios/pool/`. ### Подготовка @@ -113,158 +98,74 @@ FETCH_TRANSACTIONS задачи генерируются **только вруч 2. Убедитесь что ADB видит устройство: ```powershell tools\adb\windows\adb.exe devices - # Должно показать серийник со статусом "device" ``` 3. Откройте на устройстве банковское приложение на экране **списка транзакций** - - Ozon: раздел "Операции" -> список переводов/пополнений - - Dushanbe City Bank: раздел "Операции" -> список P2P операций ### Запуск ```powershell -# Через вспомогательный скрипт (рекомендуется) cd tests\mock_server 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): - 1. Pixel 7 / ozon / Галина Терехина (material 30bc106c... serial=28291FDH30xxxx) - 2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1...) - 3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5...) - Pick [1-3] or 'all': + pixel_7_ozon_7919_01_minus_108_ft_01.json + pixel_7_ozon_9865_01_minus_108_ft_01.json + pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json ``` -2. Выберите нужный материал (номер) или `all` для всех -3. Скрипт попросит подтвердить что список транзакций открыт -> нажмите **Enter** -4. Автоматически пройдёт по каждой транзакции на экране (**не трогайте телефон!**) -5. Сохранит задачи в `scenarios/pool/` -6. Если mock-сервер запущен — сразу инжектнёт задачи в него -### Флаги +### Важно -| Флаг | Описание | -|---|---| -| `--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`), -и мок будет раздавать задачи из него при бутстрапе. +- Если у банка **два материала** (две карты), генерируются **отдельные файлы** для каждого +- При смене устройств/аккаунтов нужно перегенерировать pool — `material_id` в файлах должны совпадать с `default_profile.json` --- ## 4. Ручной запуск (пошагово) -Если не хотите использовать `setup.ps1`: - ### 4.1. Установка зависимостей ```powershell -# Python 3.9+ (если нет — скачать с python.org, включить «Add to PATH») -python --version - -# Flask +python --version # Python 3.9+ pip install flask ``` ### 4.2. Подготовка config.ini -В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`: - ```ini [net] apiBase=http://127.0.0.1:8888 ``` -> Совет: держите копию `config.ini.mock`, а перед тестом -> `copy config.ini.mock config.ini`. - -### 4.3. Генерация профиля из БД +### 4.3. Генерация данных + запуск ```powershell -# Из корня проекта +# Генерация профиля 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 -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 ``` -Должны увидеть: -``` -[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 - -В отдельном терминале: +### 4.4. Запуск ARC ```powershell -cd build\Release +cd C:\ARC\Ver.18 # или build\Release ARC.exe ``` -Или через CLion: Run -> ARC. - -При логине мок автоматически ставит задачи: -- 1 x FETCH_PROFILE на каждый материал (всегда) -- N x FETCH_TRANSACTIONS из пула (если пул не пуст) - -Задачи раздаются по 1 на material за поллинг — следующая только -после результата предыдущей. - --- ## 5. Мониторинг и отчёт @@ -272,46 +173,37 @@ ARC.exe ```powershell # Открыть отчёт в браузере (авто-обновление каждые 2 сек) 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 # Счётчики (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. Ручной инжект задач ```powershell -# Одна задача из файла +# Одна задача curl -X POST http://127.0.0.1:8888/_admin/inject ^ -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/reset ``` @@ -319,19 +211,16 @@ curl -X POST http://127.0.0.1:8888/_admin/reset ## 7. ADB на Windows -ARC использует ADB из `tools\adb\windows\`. Проверка: +ARC использует ADB из `tools\adb\windows\`: ```powershell tools\adb\windows\adb.exe devices ``` -Должно показать устройства в статусе `device`. - -Если ADB занят другим процессом (Android Studio, scrcpy): +Если ADB занят: ```powershell tools\adb\windows\adb.exe kill-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` — в них путь уже зашит | -| Порт 8888 занят | `setup.ps1` убивает старый процесс автоматически. Вручную: `netstat -ano \| findstr :8888` -> `taskkill /PID /F` | -| `curl` нет | `Invoke-WebRequest` (PowerShell) или `winget install curl` | -| Firewall блокирует | Разрешить `python.exe` в Windows Firewall. Мок слушает только `127.0.0.1` | -| ARC не видит устройства | Проверить USB-драйверы, включить USB debugging, подтвердить RSA-ключ | -| Кракозябры в консоли | `chcp 65001` перед запуском (UTF-8). Скрипты `.ps1` делают это автоматически | -| BOM в config.ini | `setup.ps1` пишет без BOM. Если правили вручную — пересохраните в UTF-8 без BOM (VS Code) | -| No active materials | БД пустая. Запустите ARC с боевым сервером, залогиньтесь, добавьте устройства | -| Пул транзакций пуст | Сгенерируйте: [раздел 3](#3-генерация-fetch_transactions-задач) | -| `No ADB devices match` | Устройства не подключены или serial не совпадает с БД. Проверьте `adb devices` | -| SSL ошибки при сборке | OpenSSL DLL копируются автоматически через CMake post-build | +| `python` не найден | Запустите `setup.ps1` — установит автоматически | +| Порт 8888 занят | `setup.ps1` убивает автоматически. Вручную: `netstat -ano \| findstr :8888` → `taskkill /PID /F` | +| `App restarted` ошибки | Мок-сервер чистит events в БД при старте. Запускайте сервер **до** ARC | +| Pool material_id не совпадает | `gen_mock_data.py` обновил material_id. Перегенерируйте pool | +| `Transaction not found` | Баг поиска — анализировать логи (device= в ошибке) | +| `Page load error` | Ozon WebView не загрузился — проблема интернета/устройства | +| Скриншот не привязался | Нет `internal_task_id` — обновить ARC | +| Кракозябры в консоли | `chcp 65001` (UTF-8). Скрипты `.ps1` делают автоматически | +| No active materials | Нет профилей с проверенным PIN. Запустить ARC с боевым сервером | +| Firewall | Разрешить `python.exe`. Мок слушает только `127.0.0.1` | --- ## 9. Быстрый чеклист ``` -[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -[ ] Устройства в adb devices -> device -[ ] (опционально) powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 +[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -Banks ozon +[ ] Устройства в adb devices → device +[ ] (опционально) gen_transactions.ps1 для пула транзакций [ ] ARC.exe запущен (отдельный терминал) [ ] Браузер: http://127.0.0.1:8888/_admin/report/html [ ] Дождаться completed = всего в отчёте diff --git a/tests/mock_server/application.log b/tests/mock_server/application.log new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_server/debug_detail.xml b/tests/mock_server/debug_detail.xml new file mode 100644 index 0000000..261a109 --- /dev/null +++ b/tests/mock_server/debug_detail.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/default_profile.json b/tests/mock_server/default_profile.json index f70fe44..80cbb93 100644 --- a/tests/mock_server/default_profile.json +++ b/tests/mock_server/default_profile.json @@ -1,7 +1,27 @@ { "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", "state": "enabled", "phone_number": "+79538038318", @@ -9,22 +29,42 @@ "middle_name": null, "last_name": "Терехина", "currency": 643, - "device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078", + "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", "device_label": "Pixel 7", "materials": [ { - "id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9", + "id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb", "state": "enabled", "card_number": "2204321035207919", "currency": 643 }, { - "id": "39592a22-3e79-4438-89d7-778aea72a5e9", + "id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8", "state": "enabled", "card_number": "2204321086109865", "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 + } + ] } ] } diff --git a/tests/mock_server/gen_fetch_transactions.py b/tests/mock_server/gen_fetch_transactions.py index 77a2e77..e1a3de1 100755 --- a/tests/mock_server/gen_fetch_transactions.py +++ b/tests/mock_server/gen_fetch_transactions.py @@ -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) 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") dedup_key = (amount, cat) if dedup_key in processed_keys: diff --git a/tests/mock_server/gen_mock_data.py b/tests/mock_server/gen_mock_data.py index 2f7dad9..c2e0a6a 100755 --- a/tests/mock_server/gen_mock_data.py +++ b/tests/mock_server/gen_mock_data.py @@ -89,10 +89,11 @@ def query_active_materials(db_path: Path): FROM devices d 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 - WHERE bp.status = 'active' - AND m.status = 'active' - AND m.material_id != '' - AND bp.bank_profile_id != '' + WHERE bp.pin_code IS NOT NULL AND bp.pin_code != '' + AND bp.pin_code_checked = 'yes' + AND m.status = 'active' + 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 """).fetchall() conn.close() diff --git a/tests/mock_server/mock_server.py b/tests/mock_server/mock_server.py index 7816f8d..027088a 100644 --- a/tests/mock_server/mock_server.py +++ b/tests/mock_server/mock_server.py @@ -40,7 +40,10 @@ Usage: import argparse import copy import json +import os import random +import re +import shutil import threading import time import uuid @@ -75,6 +78,51 @@ _in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted ta _bootstrap_done = False _bootstrap_ft_count = 3 _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): @@ -173,25 +221,28 @@ def bootstrap_tasks(): if not mid: continue # 1 × FETCH_PROFILE - enqueue_task({ - "bank_name": bank, - "material_id": mid, - "type": "FETCH_PROFILE", - "body": {}, - "_source": "bootstrap", - }) - queued += 1 + if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types: + enqueue_task({ + "bank_name": bank, + "material_id": mid, + "type": "FETCH_PROFILE", + "body": {}, + "_source": "bootstrap", + }) + queued += 1 # N × random FETCH_TRANSACTIONS from pool - pool = _pool_by_material.get(mid, []) - if not pool: - print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})") - continue - pick = random.sample(pool, min(_bootstrap_ft_count, len(pool))) - for t in pick: - enqueue_task(t) - queued += 1 + if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types: + pool = _pool_by_material.get(mid, []) + if not pool: + print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})") + continue + pick = random.sample(pool, min(_bootstrap_ft_count, len(pool))) + for t in pick: + enqueue_task(t) + queued += 1 _bootstrap_done = True + _save_state() print(f"[mock] bootstrap queued {queued} tasks") @@ -359,6 +410,7 @@ def get_tasks(): _in_flight.add(mid) _task_queue.extend(kept) if delivered: + _save_state() log("GET /task/get_tasks -> delivered", [{"type": t.get("type"), "material_id": t.get("material_id")} for t in delivered]) @@ -393,15 +445,72 @@ def task_result(): "result_at": time.time(), "status": "unsolicited", }) + _save_state() return ok({}) # ---------------------------------------------------------------- monitoring +_monitoring_logs: list = [] # {type, event, message, has_image, image_id} +_SCREENSHOTS_DIR = HERE / ".mock_screenshots" + + @app.post("/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({}) +@app.get("/_admin/screenshot/") +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 @app.post("/_admin/inject") def admin_inject(): @@ -445,10 +554,12 @@ def admin_bootstrap(): def admin_report(): """Full task ↔ result pairing report.""" with _lock: + errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e)) summary = { "total": len(_injected), "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"), } return jsonify({ @@ -463,12 +574,25 @@ def admin_report_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(): entries = list(_injected) + errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e)) summary = { "total": len(entries), "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"), } return entries, summary @@ -487,33 +611,7 @@ def admin_report_fragment(): """JSON fragment for JS auto-refresh: rendered body HTML + summary counts.""" with _lock: entries, summary = _snapshot_entries_and_summary() - # Render just the grouped body (without the full page shell) - 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'
' - f'

{_html.escape(label)}' - f'' - f'{g_done}' - f' / {len(gentries)}' - f' (ожидают: {g_pend})' - f'

{rows_html}
' - ) - body = "\n".join(blocks) or '
Отчёт пуст
' + body = _render_grouped_blocks(entries) or '
Отчёт пуст
' return jsonify({"html": body, "summary": summary}) @@ -531,6 +629,8 @@ def _esc(s: str) -> str: def render_entry_html(e) -> str: import html as _html status = e["status"] + is_error = _is_error_entry(e) + display_status = "error" if is_error else status task = e.get("task") or {} result = e.get("result") ttype = _html.escape(task.get("type", "?")) @@ -540,41 +640,70 @@ def render_entry_html(e) -> str: injected_at = _fmt_ts(e.get("injected_at")) result_at = _fmt_ts(e.get("result_at")) + tid = _html.escape(e.get("id", "")) + def json_block(obj): if obj is None: return '' text = json.dumps(obj, ensure_ascii=False, indent=2) - return f'
{_html.escape(text)}
' + escaped = _html.escape(text) + # data-json с escaped JSON для копирования + raw = _html.escape(text, quote=True) + return (f'
' + f'' + f'
{escaped}
') result_summary = "" - if result and isinstance(result.get("data"), dict): - d = result["data"] - if d.get("status") == "error": - result_summary = f'
ERROR: {_html.escape(str(d.get("description", "")))}
' + if is_error: + d = result.get("data", {}) if result else {} + result_summary = f'
ERROR: {_html.escape(str(d.get("description", "")))}
' elif result and isinstance(result.get("data"), list): result_summary = f'
{len(result["data"])} items
' + scr_id = e.get("screenshot_id") + scr_pane = "" + if scr_id: + scr_pane = f""" +
+
Screenshot
+ +
""" + + has_screenshot = bool(scr_id) + panes_cls = "panes panes-3" if has_screenshot else "panes" + + # Успешные — вся задача свёрнута, ошибки — развёрнуты + if display_status == "completed": + panes_html = f""" +
+ +
+
+
Task JSON
{json_block(task)}
+
Result JSON
{result_summary}{json_block(result)}
+ {scr_pane} +
+
+
""" + else: + panes_html = f""" +
+
Task JSON
{json_block(task)}
+
Result JSON
{result_summary}{json_block(result)}
+ {scr_pane} +
""" + return f""" -
+
- {status} + {display_status} {ttype} {bank} {mid}… {src} inj {injected_at} → res {result_at}
-
-
-
Task JSON
- {json_block(task)} -
-
-
Result JSON
- {result_summary} - {json_block(result)} -
-
+ {panes_html}
""" @@ -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 - # Group by device label - groups: dict = defaultdict(list) + # Group by device label → bank_name + devices: dict = defaultdict(lambda: defaultdict(list)) 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 ( "(без устройства)" 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 = [] - for label in sorted(groups.keys()): - gentries = groups[label] - g_total = len(gentries) - 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) + for label in sorted(devices.keys()): + banks = devices[label] + # Device-level stats + all_entries = [e for bl in banks.values() for e in bl] + g_total = len(all_entries) + 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' ошибки: {g_err}' 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' {b_err}' if b_err else '' + rows_html = "\n".join(render_entry_html(e) for e in bentries) + bank_blocks.append( + f'
' + f'

{_html.escape(bank)}' + f'' + f'{b_done}' + f' / {b_total}' + f'{b_err_badge}' + f' (ожидают: {b_pend})' + f'

' + f'{rows_html}
' + ) + group_blocks.append(f"""

@@ -696,14 +852,19 @@ def render_report_html(entries, summary) -> str: {g_done} / {g_total} + {err_badge} (ожидают: {g_pend})

- {rows_html} + {"".join(bank_blocks)}
""") - body_rows = "\n".join(group_blocks) or '
Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.
' + return "\n".join(group_blocks) + + +def render_report_html(entries, summary) -> str: + body_rows = _render_grouped_blocks(entries) or '
Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.
' return f""" @@ -751,6 +912,7 @@ def render_report_html(entries, summary) -> str: overflow: hidden; }} .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.unsolicited {{ border-left: 4px solid var(--unsolicited); }} .head {{ @@ -764,6 +926,7 @@ def render_report_html(entries, summary) -> str: }} .badge.pending {{ background: var(--pending); color: #1a1600; }} .badge.completed {{ background: var(--completed); color: #0a1a0a; }} + .badge.error {{ background: var(--unsolicited); color: #200; }} .badge.unsolicited {{ background: var(--unsolicited); color: #200; }} .type {{ font-weight: 600; color: #9cdcfe; }} .bank {{ color: #ffb86c; }} @@ -771,6 +934,7 @@ def render_report_html(entries, summary) -> str: .src {{ color: var(--muted); font-style: italic; }} .times {{ color: var(--muted); margin-left: auto; font-family: monospace; }} .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.task {{ background: var(--task); }} .pane.result {{ background: var(--result); }} @@ -785,6 +949,37 @@ def render_report_html(entries, summary) -> str: }} .muted {{ color: var(--muted); font-style: italic; }} .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; }} a {{ color: #9cdcfe; }} .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-done {{ color: var(--completed); font-weight: 700; }} .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; }} /* ─── Profile section ─── */ .profile-section {{ @@ -891,8 +1102,8 @@ def render_report_html(entries, summary) -> str:
{summary['total']}
всего
{summary['completed']}
выполнено
+
{summary['errors']}
ошибки
{summary['pending']}
в очереди
-
{summary['unsolicited']}
незапрошенные
{render_profile_section()}
@@ -906,20 +1117,48 @@ def render_report_html(entries, summary) -> str: const summaryEls = {{ total: document.querySelector('.summary .cell:nth-child(1) .n'), completed: document.querySelector('.summary .cell.completed .n'), + errors: document.querySelector('.summary .cell.unsolicited .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() {{ try {{ const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}}); if (!resp.ok) throw new Error(resp.status); const data = await resp.json(); + const saved = saveToggleState(); bodyEl.innerHTML = data.html; + restoreToggleState(saved); summaryEls.total.textContent = data.summary.total; summaryEls.completed.textContent = data.summary.completed; + summaryEls.errors.textContent = data.summary.errors; summaryEls.pending.textContent = data.summary.pending; - summaryEls.unsolicited.textContent = data.summary.unsolicited; statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')'; }} catch (e) {{ statusEl.textContent = '(ошибка обновления: ' + e + ')'; @@ -929,6 +1168,31 @@ def render_report_html(entries, summary) -> str: setInterval(tick, REFRESH_MS); }})(); + + + """ @@ -942,7 +1206,12 @@ def admin_reset(): _injected.clear() _pending_by_key.clear() _in_flight.clear() + _monitoring_logs.clear() _bootstrap_done = False + if _SCREENSHOTS_DIR.exists(): + shutil.rmtree(_SCREENSHOTS_DIR) + if _STATE_FILE.exists(): + _STATE_FILE.unlink() return ok({}) @@ -970,24 +1239,68 @@ if __name__ == "__main__": help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)") parser.add_argument("--seed", type=int, default=None, 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() _bootstrap_ft_count = args.ft_per_material _bootstrap_banks = args.banks + _bootstrap_types = args.types if _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: 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_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) diff --git a/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json new file mode 100644 index 0000000..8e19e4c --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "36002ac2-6f4d-49da-8892-18db723a31c7", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json new file mode 100644 index 0000000..2b84cd1 --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json new file mode 100644 index 0000000..97fb329 --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json b/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json new file mode 100644 index 0000000..1e0be26 --- /dev/null +++ b/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620", + "type": "FETCH_PROFILE", + "body": {} +}