/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
tests/session_controller_tests.cpp
3 609 строк
166 KB
DemienMedich
feat: add profile weekly goals
03 авг 2026, 01:15
03 авг 2026, 01:15
a8f4bdb
Код
Авторство
О чём код?
#include "session_controller.h" #include <iterator> #include <QDate> #include <QDateTime> #include <QDir> #include <QFile> #include <QFileInfo> #include <QJsonArray> #include <QJsonDocument> #include <QJsonObject> #include <QTemporaryDir> #include <QUrl> #include <QtTest> namespace { bool writeJson(const QString &path, const QJsonObject &object) { QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(QJsonDocument(object).toJson()) > 0; } bool writeFile(const QString &path, const QByteArray &bytes) { QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size(); } QJsonObject stateRoot() { return { {"schemaVersion", 1}, {"libraryVersion", "test"}, {"selectedPlanId", "plan"}, {"plans", QJsonArray{ QJsonObject{ {"id", "plan"}, {"name", "Plan"}, {"goal", "Goal"}, {"steps", QJsonArray{ QJsonObject{{"exerciseId", "rep"}, {"targetOverride", 2}, {"restSecondsOverride", 1}}, QJsonObject{{"exerciseId", "timer"}, {"targetOverride", 2}, {"restSecondsOverride", 0}} }} } }}, {"sessions", QJsonArray{ QJsonObject{ {"planId", "plan"}, {"planName", "Plan"}, {"endedAt", "2026-06-15T10:00:00"}, {"totalWorkSeconds", 120}, {"completedAll", true}, {"feedback", "ok"}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"exerciseName", "Rep"}, {"metric", "repetitions"}, {"actualValue", 8}, {"completed", true}, {"loadNote", "clean"} }}} }, QJsonObject{ {"planId", "plan"}, {"planName", "Plan"}, {"endedAt", "2026-06-14T10:00:00"}, {"totalWorkSeconds", 60}, {"completedAll", false}, {"feedback", "hard"}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "timer"}, {"exerciseName", "Timer"}, {"metric", "seconds"}, {"actualValue", 45}, {"completed", false} }}} } }}, {"pendingDraft", QJsonValue::Null} }; } QJsonObject catalogRoot() { return { {"schemaVersion", 1}, {"libraryVersion", "test"}, {"exercises", QJsonArray{ QJsonObject{{"id", "rep"}, {"name", "Rep"}, {"metric", "repetitions"}, {"defaultTarget", 2}, {"defaultRestSeconds", 1}, {"category", "Push"}}, QJsonObject{ {"id", "timer"}, {"name", "Timer"}, {"metric", "seconds"}, {"defaultTarget", 2}, {"defaultRestSeconds", 0}, {"category", "Core"}, {"equipment", "Floor"}, {"description", "Timer description"}, {"primaryCue", "Brace"}, {"secondaryCue", "Breathe"}, {"loadRecommendation", "Add seconds"}, {"availabilityNote", "No equipment"} } }} }; } QJsonObject catalogRootWithFlow() { QJsonObject root = catalogRoot(); QJsonArray exercises = root.value("exercises").toArray(); exercises.append(QJsonObject{{"id", "warmup_march_place"}, {"name", "Warmup"}, {"metric", "seconds"}, {"defaultTarget", 2}, {"defaultRestSeconds", 0}}); exercises.append(QJsonObject{{"id", "switch_90_90"}, {"name", "Cooldown"}, {"metric", "seconds"}, {"defaultTarget", 2}, {"defaultRestSeconds", 0}}); root.insert("exercises", exercises); return root; } QJsonObject legacyWingChunPlan() { return { {"id", "wingchun-structure"}, {"name", "Wing Chun"}, {"goal", "Structure"}, {"warmupIncluded", true}, {"warmupStepCount", 4}, {"cooldownIncluded", true}, {"cooldownStepCount", 2}, {"steps", QJsonArray{ QJsonObject{{"exerciseId", "yijkym_stance"}, {"targetOverride", 90}, {"sets", 2}, {"restSecondsOverride", 20}}, QJsonObject{{"exerciseId", "siu_lim_tao"}, {"targetOverride", 180}, {"sets", 1}, {"restSecondsOverride", 30}}, QJsonObject{{"exerciseId", "tan_sao_drill"}, {"targetOverride", 45}, {"sets", 2}, {"restSecondsOverride", 15}}, QJsonObject{{"exerciseId", "bong_sao_drill"}, {"targetOverride", 45}, {"sets", 2}, {"restSecondsOverride", 15}}, QJsonObject{{"exerciseId", "pak_sa_drill"}, {"targetOverride", 20}, {"sets", 3}, {"restSecondsOverride", 20}}, QJsonObject{{"exerciseId", "chain_punch_round"}, {"targetOverride", 60}, {"sets", 4}, {"restSecondsOverride", 30}}, QJsonObject{{"exerciseId", "footer_kick"}, {"targetOverride", 10}, {"sets", 3}, {"restSecondsOverride", 25}}, QJsonObject{{"exerciseId", "wrist_extension_lean"}, {"targetOverride", 25}, {"sets", 2}, {"restSecondsOverride", 20}}, }} }; } QJsonObject wingChunPlanFrom(const QJsonObject &root) { for (const QJsonValue &value : root.value("plans").toArray()) { const QJsonObject plan = value.toObject(); if (plan.value("id").toString() == QStringLiteral("wingchun-structure")) return plan; } return {}; } QJsonObject legacyQigongPlan() { static constexpr const char *exerciseIds[] = { "wuji_breathing", "embrace_tree", "baduanjin_1", "baduanjin_2", "baduanjin_3", "baduanjin_4", "baduanjin_5", "baduanjin_6", "baduanjin_7", "baduanjin_8", "shadow_box_round", "burpee_interval", "mountain_climber", "world_greatest_stretch", "hip_flexor_stretch", }; QJsonArray steps; for (const char *exerciseId : exerciseIds) { steps.append(QJsonObject{ {"coachNote", QStringLiteral("Старая подсказка")}, {"exerciseId", exerciseId}, {"restSecondsOverride", 15}, {"sets", 1}, {"targetOverride", 10}, }); } return QJsonObject{ {"id", "qigong-mobility"}, {"name", "Qigong"}, {"goal", "Mobility"}, {"steps", steps}, }; } QJsonObject planWithId(const QJsonObject &root, const QString &planId) { for (const QJsonValue &value : root.value("plans").toArray()) { const QJsonObject plan = value.toObject(); if (plan.value("id").toString() == planId) return plan; } return {}; } QJsonObject planForProfile( const QJsonObject &root, const QString &planId, const QString &profileId) { for (const QJsonValue &value : root.value("plans").toArray()) { if (!value.isObject()) continue; const QJsonObject plan = value.toObject(); if (plan.value("id").toString().compare(planId, Qt::CaseInsensitive) == 0 && plan.value("profileId").toString().compare(profileId, Qt::CaseInsensitive) == 0) { return plan; } } return {}; } QJsonObject firstObjectForProfile(const QJsonArray &items, const QString &profileId) { for (const QJsonValue &value : items) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (object.value("profileId").toString().compare(profileId, Qt::CaseInsensitive) == 0) { return object; } } return {}; } QJsonObject readJsonObject(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {}; return QJsonDocument::fromJson(file.readAll()).object(); } void finishSession(SessionController &controller) { controller.start(); controller.skipCountdown(); controller.advanceOneSecond(); controller.advanceOneSecond(); controller.advanceOneSecond(); controller.advanceOneSecond(); controller.advanceOneSecond(); controller.advanceOneSecond(); controller.skipCountdown(); controller.skipCountdown(); controller.advanceOneSecond(); controller.advanceOneSecond(); } } // namespace class SessionControllerTests : public QObject { Q_OBJECT private slots: void installsStarterStateForEmptyStorage(); void selectsScheduledPlanForCurrentDayOnStartup(); void reportsRestDayWhenNoPlanIsScheduled(); void migratesLegacyWingChunPlanOnce(); void preservesCustomizedLegacyWingChunPlan(); void migratesLegacyQigongBaduanjin4Once(); void preservesCustomizedQigongBaduanjin4(); void preservesQigongPlanWithChangedSignature(); void savesOnlyAfterConfirmation(); void exposesPendingResultAndAdaptationPreview(); void startsAndSavesQuickWorkoutSeparately(); void upgradesQuickWorkoutIntoOneFullSession(); void appliesEasyAdaptationWhenSaving(); void appliesHardAdaptationWhenSaving(); void skipsCurrentExerciseAndSavesIncompleteStep(); void finishesEarlyAndSavesCompletedProgress(); void restoresPendingFinishedResultAfterRestart(); void replacesExerciseDuringActiveWorkout(); void discardLeavesStateUntouched(); void abortsActiveWorkoutAndClearsDraft(); void restoresPersistedDraft(); void rejectsRecoverableDraftOwnedByAnotherProfile(); void tracksCurrentStepIndex(); void exposesRoundSetSideAndSectionPosition(); void pausesAndCheckpointsWhenApplicationIsBackgrounded(); void persistsCurrentLoadNoteInDraftAndSession(); void exposesDefaultProfileForOldState(); void createsAndSelectsProfilesAtomically(); void migratesLegacyOwnershipToSelectedProfile(); void migratesPartiallyOwnedLegacyStateToActiveProfile(); void migratesExistingNastyaCourseLegacyHistoryToRoman(); void keepsLegacyPlansAwayFromEmptyNewProfile(); void restoresSelectedPlanPerProfileAndRejectsForeignPlan(); void clearsPlanUndoWhenSwitchingProfile(); void keepsDuplicatePlanIdsIsolatedByProfile(); void separatesWorkoutHistoryByProfile(); void selectsAndPersistsPlan(); void duplicatesSelectedPlanAtomically(); void deletesSelectedPlanAtomically(); void renamesSelectedPlanAtomically(); void updatesSelectedPlanStepAtomically(); void updatesSelectedPlanFlowOptionsAtomically(); void exposesExpandedWorkoutQueueSteps(); void appendsSelectedPlanStepAtomically(); void duplicatesSelectedPlanStepAtomically(); void removesSelectedPlanStepAtomically(); void movesSelectedPlanStepAtomically(); void exposesSelectedPlanSteps(); void exposesExerciseFrameUrls(); void exposesProgressSummary(); void comparesRollingWeeksAndFlagsSharpIncrease(); void exposesProfileSpecificWeeklyGoals(); void exposesPreWorkoutRecommendation(); void exposesLatestSessionSteps(); void filtersExerciseLibrary(); void exposesNutritionSummary(); void exposesApplicationInfo(); void logsBodyWeightAtomically(); void selectsNutritionPresetAtomically(); void logsBodyMeasurementsAtomically(); void marksNutritionAdherenceAtomically(); void logsPhotoProgressAtomically(); void logsRecoveryCheckInAtomically(); void importsHealthConnectDataAtomically(); void importsHealthConnectForDeviceOwnerProfile(); void importsTargetedHealthDataWithoutTouchingOtherProfile(); void rejectsHealthDataTargetedAtFormerDeviceOwner(); void keepsDefaultWeightWhenHealthOwnerIsAnotherProfileAcrossRestart(); void configuresTrainingCycleAtomically(); void extendsCycleAndStagesReturnAfterMissedWorkouts(); void completesFourWeekCycleReviewAtomically(); void isolatesCycleReviewByProfile(); void appliesCurrentCycleWeekAtWorkoutStart(); void appliesRecentRecoveryCheckInAtWorkoutStart(); void appliesHeartRateLoadAtWorkoutStart(); void separatesNutritionAndRecoveryByProfile(); void exportsLocalData(); void createsAndRestoresLocalBackup(); void restoresHealthConnectOwnerFromBackupOnDesktop(); void doesNotRestoreBackupWhileFinishedSessionNeedsDecision(); void persistsHealthSourcePreferences(); void persistsWorkoutReminderSettings(); void clearsPersonalDataButKeepsPlans(); }; void SessionControllerTests::installsStarterStateForEmptyStorage() { QTemporaryDir directory; QVERIFY(directory.isValid()); SessionController controller( directory.path(), QStringLiteral(":/resources/exercises.json")); QVERIFY2(controller.ready(), qPrintable(controller.status())); QVERIFY(QFileInfo::exists(directory.filePath("native-state.json"))); const QJsonObject installed = readJsonObject(directory.filePath("native-state.json")); QVERIFY(!installed.value("plans").toArray().isEmpty()); const QJsonObject romanSettings = installed.value("profileSettings").toObject() .value(QStringLiteral("роман")).toObject(); QCOMPARE(romanSettings.value("weeklyGoalMin").toInt(), 4); QCOMPARE(romanSettings.value("weeklyGoalMax").toInt(), 6); QCOMPARE(romanSettings.value("rescueWeeklyGoalMin").toInt(), 2); } void SessionControllerTests::selectsScheduledPlanForCurrentDayOnStartup() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); const int today = QDate::currentDate().dayOfWeek(); const int otherDay = today == 7 ? 1 : today + 1; root.insert("selectedPlanId", "other"); root.insert("plans", QJsonArray{ QJsonObject{{"id", "other"}, {"name", "Other"}, {"goal", "Later"}, {"dayOfWeek", otherDay}, {"steps", QJsonArray{QJsonObject{{"exerciseId", "rep"}, {"targetOverride", 2}}}}}, QJsonObject{{"id", "today"}, {"name", "Today"}, {"goal", "Now"}, {"dayOfWeek", today}, {"steps", QJsonArray{QJsonObject{{"exerciseId", "rep"}, {"targetOverride", 2}}}}} }); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedPlanId(), QString("today")); QVERIFY(controller.hasScheduledPlanToday()); QCOMPARE(controller.scheduledPlanTodayName(), QString("Today")); } void SessionControllerTests::reportsRestDayWhenNoPlanIsScheduled() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); const int today = QDate::currentDate().dayOfWeek(); const int otherDay = today == 7 ? 1 : today + 1; QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); plan.insert("dayOfWeek", otherDay); plans.replace(0, plan); root.insert("plans", plans); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(!controller.hasScheduledPlanToday()); QVERIFY(controller.scheduledPlanTodayName().isEmpty()); QCOMPARE(controller.selectedPlanId(), QString("plan")); } void SessionControllerTests::migratesLegacyWingChunPlanOnce() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(legacyWingChunPlan()); root.insert("plans", plans); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); } const QJsonObject migratedRoot = readJsonObject(statePath); const QJsonObject migratedPlan = wingChunPlanFrom(migratedRoot); QVERIFY(!migratedPlan.isEmpty()); QCOMPARE(migratedPlan.value("warmupIncluded").toBool(), true); QCOMPARE(migratedPlan.value("warmupStepCount").toInt(), 4); QCOMPARE(migratedPlan.value("cooldownIncluded").toBool(), true); QCOMPARE(migratedPlan.value("cooldownStepCount").toInt(), 2); struct ExpectedStep { const char *exerciseId; int target; int sets; int restSeconds; }; static constexpr ExpectedStep expectedSteps[] = { {"yijkym_stance", 60, 2, 20}, {"siu_lim_tao", 120, 1, 30}, {"tan_sao_drill", 30, 1, 15}, {"bong_sao_drill", 30, 1, 15}, {"dan_chi_sao", 30, 1, 15}, {"pak_sa_drill", 10, 2, 20}, {"gaun_sa_drill", 8, 1, 20}, {"chain_punch_round", 45, 3, 30}, {"footer_kick", 6, 2, 25}, {"wrist_extension_lean", 25, 1, 20}, }; const QJsonArray steps = migratedPlan.value("steps").toArray(); QCOMPARE(steps.size(), static_cast<int>(std::size(expectedSteps))); for (int index = 0; index < steps.size(); ++index) { const QJsonObject step = steps.at(index).toObject(); const ExpectedStep &expected = expectedSteps[index]; QCOMPARE(step.value("exerciseId").toString(), QString::fromLatin1(expected.exerciseId)); QCOMPARE(step.value("targetOverride").toInt(), expected.target); QCOMPARE(step.value("sets").toInt(), expected.sets); QCOMPARE(step.value("restSecondsOverride").toInt(), expected.restSeconds); QVERIFY(!step.value("coachNote").toString().isEmpty()); } const QJsonArray migrations = migratedRoot.value("contentMigrations").toArray(); QCOMPARE(migrations, QJsonArray{QStringLiteral("wingchun-structure-10-step-v1")}); QFile firstSavedFile(statePath); QVERIFY(firstSavedFile.open(QIODevice::ReadOnly)); const QByteArray firstSavedBytes = firstSavedFile.readAll(); firstSavedFile.close(); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); } QFile secondSavedFile(statePath); QVERIFY(secondSavedFile.open(QIODevice::ReadOnly)); QCOMPARE(secondSavedFile.readAll(), firstSavedBytes); } void SessionControllerTests::preservesCustomizedLegacyWingChunPlan() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject customPlan = legacyWingChunPlan(); QJsonArray customSteps = customPlan.value("steps").toArray(); QJsonObject customizedChainPunches = customSteps.at(5).toObject(); customizedChainPunches.insert("targetOverride", 61); customSteps.replace(5, customizedChainPunches); customPlan.insert("steps", customSteps); customPlan.insert("profileId", "default"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(customPlan); root.insert("plans", plans); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); const QJsonObject savedRoot = readJsonObject(statePath); QCOMPARE(wingChunPlanFrom(savedRoot), customPlan); QVERIFY(!savedRoot.contains("contentMigrations")); } void SessionControllerTests::migratesLegacyQigongBaduanjin4Once() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject qigongPlan = legacyQigongPlan(); QJsonArray originalSteps = qigongPlan.value("steps").toArray(); QJsonObject adaptedBaduanjin2 = originalSteps.at(3).toObject(); adaptedBaduanjin2.insert("targetOverride", 73); originalSteps.replace(3, adaptedBaduanjin2); qigongPlan.insert("steps", originalSteps); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(qigongPlan); root.insert("plans", plans); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); } const QJsonObject migratedRoot = readJsonObject(statePath); const QJsonObject migratedPlan = planWithId(migratedRoot, QStringLiteral("qigong-mobility")); const QJsonArray migratedSteps = migratedPlan.value("steps").toArray(); QCOMPARE(migratedSteps.at(3).toObject().value("targetOverride").toInt(), 73); const QJsonObject migratedBaduanjin4 = migratedSteps.at(5).toObject(); QCOMPARE(migratedBaduanjin4.value("targetOverride").toInt(), 45); QCOMPARE(migratedBaduanjin4.value("sets").toInt(), 1); QCOMPARE(migratedBaduanjin4.value("restSecondsOverride").toInt(), 15); QCOMPARE( migratedBaduanjin4.value("coachNote").toString(), QStringLiteral("Мягко поворачивай шею и грудной отдел, без рывка и запрокидывания головы.")); QCOMPARE( migratedRoot.value("contentMigrations").toArray(), QJsonArray{QStringLiteral("qigong-baduanjin4-timed-v1")}); QFile firstSavedFile(statePath); QVERIFY(firstSavedFile.open(QIODevice::ReadOnly)); const QByteArray firstSavedBytes = firstSavedFile.readAll(); firstSavedFile.close(); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); } QFile secondSavedFile(statePath); QVERIFY(secondSavedFile.open(QIODevice::ReadOnly)); QCOMPARE(secondSavedFile.readAll(), firstSavedBytes); } void SessionControllerTests::preservesCustomizedQigongBaduanjin4() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject customPlan = legacyQigongPlan(); QJsonArray customSteps = customPlan.value("steps").toArray(); QJsonObject customBaduanjin4 = customSteps.at(5).toObject(); customBaduanjin4.insert("targetOverride", 11); customSteps.replace(5, customBaduanjin4); customPlan.insert("steps", customSteps); customPlan.insert("profileId", "default"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(customPlan); root.insert("plans", plans); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); const QJsonObject savedRoot = readJsonObject(statePath); QCOMPARE(planWithId(savedRoot, QStringLiteral("qigong-mobility")), customPlan); QVERIFY(!savedRoot.contains("contentMigrations")); } void SessionControllerTests::preservesQigongPlanWithChangedSignature() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject customPlan = legacyQigongPlan(); QJsonArray customSteps = customPlan.value("steps").toArray(); QJsonObject changedStep = customSteps.at(7).toObject(); changedStep.insert("exerciseId", QStringLiteral("custom_qigong_step")); customSteps.replace(7, changedStep); customPlan.insert("steps", customSteps); customPlan.insert("profileId", "default"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(customPlan); root.insert("plans", plans); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); const QJsonObject savedRoot = readJsonObject(statePath); QCOMPARE(planWithId(savedRoot, QStringLiteral("qigong-mobility")), customPlan); QVERIFY(!savedRoot.contains("contentMigrations")); } void SessionControllerTests::savesOnlyAfterConfirmation() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QVERIFY(controller.ready()); finishSession(controller); QVERIFY(controller.needsSave()); QCOMPARE(controller.pendingResultSummary(), QString("1 мин • 2 шагов • закрыта")); QFile before(statePath); QVERIFY(before.open(QIODevice::ReadOnly)); const QByteArray beforeBytes = before.readAll(); before.close(); QCOMPARE(QJsonDocument::fromJson(beforeBytes).object().value("sessions").toArray().size(), 2); controller.saveFinishedSession("ok"); QVERIFY(!controller.needsSave()); QCOMPARE(controller.pendingResultSummary(), QString()); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile after(statePath); QVERIFY(after.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(after.readAll()).object(); QCOMPARE(saved.value("sessions").toArray().size(), 3); QCOMPARE(controller.totalSessions(), 3); } void SessionControllerTests::exposesPendingResultAndAdaptationPreview() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); finishSession(controller); QVERIFY(controller.needsSave()); const QVariantMap details = controller.pendingResultDetails(); QCOMPARE(details.value("planName").toString(), QString("Plan")); QVERIFY(!details.value("resultTitle").toString().isEmpty()); QVERIFY(!details.value("resultStatus").toString().isEmpty()); QCOMPARE(details.value("workMinutes").toInt(), 1); QVERIFY(details.value("workRestText").toString().contains("1")); QCOMPARE(details.value("stepCount").toInt(), 2); QCOMPARE(details.value("completedStepCount").toInt(), 2); QCOMPARE(details.value("skippedStepCount").toInt(), 0); QCOMPARE(details.value("completionRate").toInt(), 100); QVERIFY(details.value("completedText").toString().contains("2")); QVERIFY(!details.value("volumeText").toString().isEmpty()); QVERIFY(!details.value("recommendationTitle").toString().isEmpty()); QVERIFY(!details.value("recommendationText").toString().isEmpty()); QVERIFY(details.value("completedAll").toBool()); QVERIFY(controller.adaptationPreview("Легко").contains("+1")); QVERIFY(controller.adaptationPreview("Тяжело").contains("отдых")); } void SessionControllerTests::startsAndSavesQuickWorkoutSeparately() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRootWithFlow())); SessionController controller(directory.path(), catalogPath); const QJsonObject planBefore = planWithId(readJsonObject(statePath), "plan"); controller.startQuickWorkout(); QVERIFY(controller.active()); QCOMPARE(controller.currentWorkoutPosition().value("totalRounds").toInt(), 1); QVERIFY(controller.status().contains(QString::fromUtf8("Пятиминутка"))); const QVariantList queue = controller.workoutQueueSteps(); QVERIFY(queue.size() >= 3); QVERIFY(queue.size() <= 6); QVERIFY(queue.first().toMap().value("isWarmup").toBool()); QVERIFY(queue.last().toMap().value("isCooldown").toBool()); for (int guard = 0; controller.active() && guard < 1000; ++guard) { if (controller.quickDecisionPending()) { controller.finishQuickWorkout(); continue; } if (controller.phaseTitle().startsWith(QString::fromUtf8("Повторения"))) { for (int rep = 0; rep < 20; ++rep) controller.addRep(); controller.completeCurrent(); } else { controller.advanceOneSecond(); } } QVERIFY(!controller.active()); QVERIFY(controller.needsSave()); const QVariantMap details = controller.pendingResultDetails(); QVERIFY(details.value("quickSession").toBool()); QCOMPARE(details.value("sessionMode").toString(), QString("quick")); QCOMPARE(details.value("resultTitle").toString(), QString::fromUtf8("Пятиминутка закрыта")); QCOMPARE(details.value("completionRate").toInt(), 100); QVERIFY(controller.adaptationPreview("Легко").contains(QString::fromUtf8("не изменит"))); controller.saveFinishedSession("Легко", true); const QJsonObject saved = readJsonObject(statePath); const QJsonObject session = saved.value("sessions").toArray().last().toObject(); QCOMPARE(session.value("sessionMode").toString(), QString("quick")); QVERIFY(session.value("completedAll").toBool()); QCOMPARE(planWithId(saved, "plan"), planBefore); QCOMPARE(controller.completionRateText(), QString("50%")); QCOMPARE(controller.weeklySummary().value("quickSessions").toInt(), 1); QCOMPARE(controller.weeklySummary().value("fullSessions").toInt(), 0); QCOMPARE(controller.recentSessions().first().toMap().value("modeText").toString(), QString::fromUtf8("5 минут")); } void SessionControllerTests::upgradesQuickWorkoutIntoOneFullSession() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRootWithFlow())); SessionController controller(directory.path(), catalogPath); controller.startQuickWorkout(2); QVERIFY(controller.active()); for (int guard = 0; !controller.quickDecisionPending() && guard < 1000; ++guard) { if (controller.phaseTitle().startsWith(QString::fromUtf8("Повторения"))) { for (int rep = 0; rep < 20; ++rep) controller.addRep(); controller.completeCurrent(); } else { controller.advanceOneSecond(); } } QVERIFY(controller.quickDecisionPending()); const int completedAtCheckpoint = controller.currentStepIndex(); QVERIFY(completedAtCheckpoint > 0); controller.continueQuickWorkout(); QVERIFY(controller.active()); QVERIFY(!controller.quickDecisionPending()); for (int guard = 0; controller.active() && guard < 2000; ++guard) { if (controller.phaseTitle().startsWith(QString::fromUtf8("Повторения"))) { for (int rep = 0; rep < 20; ++rep) controller.addRep(); controller.completeCurrent(); } else { controller.advanceOneSecond(); } } QVERIFY(!controller.active()); QVERIFY(controller.needsSave()); const QVariantMap details = controller.pendingResultDetails(); QVERIFY(!details.value("quickSession").toBool()); QCOMPARE(details.value("sessionMode").toString(), QString("full")); QCOMPARE(details.value("completionRate").toInt(), 100); QVERIFY(details.value("completedStepCount").toInt() > completedAtCheckpoint); controller.saveFinishedSession("Нормально", false); const QJsonObject saved = readJsonObject(statePath); const QJsonArray sessions = saved.value("sessions").toArray(); QCOMPARE(sessions.size(), 3); QCOMPARE(sessions.last().toObject().value("sessionMode").toString(), QString("full")); } void SessionControllerTests::appliesEasyAdaptationWhenSaving() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); finishSession(controller); controller.saveFinishedSession("Легко", true); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); const QJsonArray steps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(steps.at(0).toObject().value("targetOverride").toInt(), 3); QCOMPARE(steps.at(1).toObject().value("targetOverride").toInt(), 7); QVERIFY(controller.status().contains("адаптирован")); } void SessionControllerTests::appliesHardAdaptationWhenSaving() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); finishSession(controller); controller.saveFinishedSession("Тяжело", true); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); const QJsonArray steps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(steps.at(0).toObject().value("targetOverride").toInt(), 1); QCOMPARE(steps.at(0).toObject().value("restSecondsOverride").toInt(), 11); QCOMPARE(steps.at(1).toObject().value("targetOverride").toInt(), 1); QCOMPARE(steps.at(1).toObject().value("restSecondsOverride").toInt(), 10); QVERIFY(controller.status().contains("адаптирован")); } void SessionControllerTests::skipsCurrentExerciseAndSavesIncompleteStep() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.start(); controller.skipCountdown(); controller.skipCurrentExercise(); controller.skipCountdown(); controller.skipCountdown(); controller.advanceOneSecond(); controller.advanceOneSecond(); QVERIFY(controller.needsSave()); controller.saveFinishedSession("skipped"); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); const QJsonObject session = saved.value("sessions").toArray().last().toObject(); const QJsonObject firstStep = session.value("steps").toArray().first().toObject(); QVERIFY(!firstStep.value("completed").toBool()); QCOMPARE(firstStep.value("exerciseId").toString(), QString("rep")); QCOMPARE(session.value("completedAll").toBool(), false); } void SessionControllerTests::finishesEarlyAndSavesCompletedProgress() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.start(); controller.skipCountdown(); controller.addRep(); controller.finishActiveWorkout(); QVERIFY(!controller.active()); QVERIFY(controller.needsSave()); QVERIFY(controller.pendingResultSummary().contains("частично")); const QVariantMap details = controller.pendingResultDetails(); QCOMPARE(details.value("resultStatus").toString(), QString("partial")); QCOMPARE(details.value("completedStepCount").toInt(), 0); QCOMPARE(details.value("stepCount").toInt(), 2); QCOMPARE(details.value("completionRate").toInt(), 0); controller.saveFinishedSession("Нормально"); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); const QJsonObject session = saved.value("sessions").toArray().last().toObject(); QCOMPARE(session.value("completedAll").toBool(), false); QCOMPARE(session.value("steps").toArray().size(), 1); QCOMPARE(session.value("steps").toArray().first().toObject().value("actualValue").toInt(), 1); QVERIFY(saved.value("pendingDraft").isNull()); } void SessionControllerTests::restoresPendingFinishedResultAfterRestart() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); { SessionController first(directory.path(), catalogPath); first.start(); first.skipCountdown(); first.addRep(); first.addRep(); first.completeCurrent(); first.finishActiveWorkout(); QVERIFY(first.needsSave()); const QJsonObject persisted = readJsonObject(statePath); QVERIFY(persisted.value("pendingResult").isObject()); QVERIFY(persisted.value("pendingDraft").isNull()); } SessionController restored(directory.path(), catalogPath); QVERIFY(restored.needsSave()); QVERIFY(!restored.hasRecoverableDraft()); QCOMPARE(restored.pendingResultDetails().value("completionRate").toInt(), 50); restored.saveFinishedSession("Нормально"); QVERIFY(!restored.needsSave()); const QJsonObject saved = readJsonObject(statePath); QVERIFY(saved.value("pendingResult").isNull()); } void SessionControllerTests::replacesExerciseDuringActiveWorkout() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject catalog = catalogRoot(); QJsonArray exercises = catalog.value("exercises").toArray(); exercises.append(QJsonObject{ {"id", "alternate"}, {"name", "Alternate"}, {"metric", "repetitions"}, {"defaultTarget", 6}, {"defaultRestSeconds", 5}, {"primaryCue", "Alternate cue"} }); catalog.insert("exercises", exercises); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalog)); SessionController controller(directory.path(), catalogPath); controller.start(); QVERIFY(!controller.workoutReplacementExercises().isEmpty()); controller.skipCountdown(); controller.addRep(); controller.replaceCurrentExercise("alternate"); QCOMPARE(controller.exerciseName(), QString("Alternate")); QCOMPARE(controller.phaseTitle(), QString("Подготовка")); QCOMPARE(controller.currentCoachNote(), QString("Alternate cue")); QVERIFY(controller.status().contains("сброшен")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); const QJsonObject draft = saved.value("pendingDraft").toObject(); QCOMPARE(draft.value("plan").toObject().value("steps").toArray().first().toObject().value("exerciseId").toString(), QString("alternate")); controller.abortActiveWorkout(); } void SessionControllerTests::discardLeavesStateUntouched() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); finishSession(controller); QVERIFY(controller.needsSave()); controller.discardFinishedSession(); QVERIFY(!controller.needsSave()); QFile after(statePath); QVERIFY(after.open(QIODevice::ReadOnly)); const QJsonObject discarded = QJsonDocument::fromJson(after.readAll()).object(); QCOMPARE(discarded.value("sessions").toArray().size(), 2); QVERIFY(discarded.value("pendingDraft").isNull()); } void SessionControllerTests::abortsActiveWorkoutAndClearsDraft() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.start(); controller.skipCountdown(); controller.setCurrentLoadNote("abort me"); QVERIFY(controller.active()); controller.abortActiveWorkout(); QVERIFY(!controller.active()); QVERIFY(controller.ready()); QCOMPARE(controller.currentLoadNote(), QString()); QFile after(statePath); QVERIFY(after.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(after.readAll()).object(); QCOMPARE(saved.value("sessions").toArray().size(), 2); QVERIFY(saved.value("pendingDraft").isNull()); } void SessionControllerTests::restoresPersistedDraft() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); { SessionController first(directory.path(), catalogPath); first.start(); QCOMPARE(first.currentStepIndex(), 1); first.skipCountdown(); first.addRep(); QVERIFY(first.active()); } SessionController restored(directory.path(), catalogPath); QVERIFY(restored.hasRecoverableDraft()); QVERIFY(!restored.ready()); QCOMPARE(restored.recoverableDraftSummary(), QString("Plan • шаг 1 / 2 • закрыто 0")); restored.restoreDraft(); QVERIFY(!restored.hasRecoverableDraft()); QCOMPARE(restored.recoverableDraftSummary(), QString()); QVERIFY(restored.active()); QCOMPARE(restored.currentStepIndex(), 1); QCOMPARE(restored.counterText(), QString("1")); } void SessionControllerTests::rejectsRecoverableDraftOwnedByAnotherProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("selectedPlanId", "plan"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); { SessionController roman(directory.path(), catalogPath); roman.start(); roman.skipCountdown(); roman.addRep(); QVERIFY(roman.active()); } QJsonObject persisted = readJsonObject(statePath); QVERIFY(persisted.value("pendingDraft").isObject()); QCOMPARE( persisted.value("pendingDraft").toObject().value("plan").toObject().value("profileId").toString(), QString("default")); persisted.insert("selectedProfileId", "nastya"); persisted.insert("selectedPlanId", "nastya-plan"); QVERIFY(writeJson(statePath, persisted)); SessionController nastya(directory.path(), catalogPath); QCOMPARE(nastya.selectedProfileId(), QString("nastya")); QCOMPARE(nastya.selectedPlanId(), QString("nastya-plan")); QVERIFY(nastya.hasRecoverableDraft()); QVERIFY(!nastya.active()); nastya.restoreDraft(); QVERIFY(!nastya.active()); QVERIFY(nastya.hasRecoverableDraft()); QVERIFY(nastya.status().contains(QString::fromUtf8("другому профилю"), Qt::CaseInsensitive)); QVERIFY(readJsonObject(statePath).value("pendingDraft").isObject()); } void SessionControllerTests::tracksCurrentStepIndex() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.currentStepIndex(), 0); controller.start(); QCOMPARE(controller.currentStepIndex(), 1); QCOMPARE(controller.completedStepText(), QString("закрыто 0 / 2")); QCOMPARE(controller.phaseProgress(), 0.0); controller.advanceOneSecond(); QVERIFY(controller.phaseProgress() > 0.0); QCOMPARE(controller.phaseProgressText(), QString("1 / 8 сек")); QCOMPARE(controller.currentCoachNote(), QString()); QCOMPARE(controller.currentStepSummary(), QString("повторы • цель 2 • отдых 1 сек")); QCOMPARE(controller.currentExerciseTechnique().value("primaryCue").toString(), QString()); controller.skipCountdown(); controller.completeCurrent(); QCOMPARE(controller.currentStepIndex(), 2); QCOMPARE(controller.completedStepText(), QString("закрыто 1 / 2")); QCOMPARE(controller.currentStepSummary(), QString("секунды • цель 2 • отдых 0 сек")); QCOMPARE(controller.currentExerciseTechnique().value("category").toString(), QString("Core")); QCOMPARE(controller.currentExerciseTechnique().value("equipment").toString(), QString("Floor")); QCOMPARE(controller.currentExerciseTechnique().value("primaryCue").toString(), QString("Brace")); QCOMPARE(controller.currentExerciseTechnique().value("secondaryCue").toString(), QString("Breathe")); QCOMPARE(controller.currentExerciseTechnique().value("loadRecommendation").toString(), QString("Add seconds")); QCOMPARE(controller.currentExerciseTechnique().value("availabilityNote").toString(), QString("No equipment")); controller.skipCountdown(); QCOMPARE(controller.currentStepIndex(), 2); controller.skipCountdown(); controller.advanceOneSecond(); controller.advanceOneSecond(); QCOMPARE(controller.currentStepIndex(), 0); QVERIFY(controller.needsSave()); } void SessionControllerTests::exposesRoundSetSideAndSectionPosition() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); plan.insert("roundCount", 2); plan.insert("steps", QJsonArray{QJsonObject{ {"exerciseId", "side"}, {"targetOverride", 1}, {"restSecondsOverride", 0}, {"sets", 1}}}); plans.replace(0, plan); root.insert("plans", plans); QJsonObject catalog = catalogRoot(); QJsonArray exercises = catalog.value("exercises").toArray(); exercises.append(QJsonObject{ {"id", "side"}, {"name", "Side"}, {"metric", "repetitions"}, {"defaultTarget", 1}, {"defaultRestSeconds", 0}, {"alternatingSides", true}}); catalog.insert("exercises", exercises); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalog)); SessionController controller(directory.path(), catalogPath); controller.startWithRoundCount(2); QVariantMap position = controller.currentWorkoutPosition(); QCOMPARE(position.value("section").toString(), QString("work")); QCOMPARE(position.value("round").toInt(), 1); QCOMPARE(position.value("totalRounds").toInt(), 2); QCOMPARE(position.value("set").toInt(), 1); QCOMPARE(position.value("totalSets").toInt(), 1); QCOMPARE(position.value("side").toString(), QString("left")); controller.skipCountdown(); controller.addRep(); controller.completeCurrent(); position = controller.currentWorkoutPosition(); QVERIFY(position.value("sideSwitchActive").toBool()); QCOMPARE(position.value("side").toString(), QString("right")); QVERIFY(controller.phaseTitle().startsWith(QString::fromUtf8("Смена стороны"))); controller.skipCountdown(); controller.skipCountdown(); controller.addRep(); controller.completeCurrent(); position = controller.currentWorkoutPosition(); QCOMPARE(position.value("round").toInt(), 2); QCOMPARE(position.value("side").toString(), QString("left")); controller.abortActiveWorkout(); } void SessionControllerTests::pausesAndCheckpointsWhenApplicationIsBackgrounded() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.start(); QVERIFY(controller.active()); QVERIFY(!controller.paused()); controller.suspendWorkoutClock(); QVERIFY(controller.paused()); QJsonObject saved = readJsonObject(statePath); QCOMPARE(saved.value("pendingDraft").toObject().value("phase").toInt(), 6); controller.resumeWorkoutClock(); QVERIFY(controller.paused()); QVERIFY(controller.status().contains(QString::fromUtf8("Продолжить"))); controller.togglePause(); QVERIFY(!controller.paused()); controller.prepareForExit(); saved = readJsonObject(statePath); QVERIFY(saved.value("pendingDraft").isObject()); controller.abortActiveWorkout(); } void SessionControllerTests::persistsCurrentLoadNoteInDraftAndSession() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); { SessionController first(directory.path(), catalogPath); first.start(); first.skipCountdown(); first.updateCurrentSetLog("8,0", 7, 2, " slow tempo "); QCOMPARE(first.currentLoadNote(), QString("slow tempo")); const QVariantMap currentLog = first.currentSetLog(); QCOMPARE(currentLog.value("loadKg").toDouble(), 8.0); QCOMPARE(currentLog.value("effortRating").toInt(), 7); QCOMPARE(currentLog.value("discomfortRating").toInt(), 2); first.addRep(); QVERIFY(first.active()); } QFile draftFile(statePath); QVERIFY(draftFile.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(draftFile.readAll()).object(); draftFile.close(); QCOMPARE(saved.value("pendingDraft").toObject().value("currentLoadNote").toString(), QString("slow tempo")); QCOMPARE(saved.value("pendingDraft").toObject().value("currentExternalLoadKg").toDouble(), 8.0); QCOMPARE(saved.value("pendingDraft").toObject().value("currentEffortRating").toInt(), 7); QCOMPARE(saved.value("pendingDraft").toObject().value("currentDiscomfortRating").toInt(), 2); SessionController restored(directory.path(), catalogPath); restored.restoreDraft(); QCOMPARE(restored.currentLoadNote(), QString("slow tempo")); QCOMPARE(restored.currentSetLog().value("loadKg").toDouble(), 8.0); for (int i = 0; i < 6; ++i) restored.advanceOneSecond(); QVERIFY(restored.lastCompletedSetLog().value("available").toBool()); QCOMPARE(restored.lastCompletedSetLog().value("exerciseName").toString(), QString("Rep")); restored.updateLastCompletedSetLog("9", 8, 3, "after set"); QCOMPARE(restored.lastCompletedSetLog().value("loadKg").toDouble(), 9.0); QCOMPARE(restored.lastCompletedSetLog().value("effortRating").toInt(), 8); restored.skipCountdown(); restored.skipCountdown(); restored.advanceOneSecond(); restored.advanceOneSecond(); QVERIFY(restored.needsSave()); restored.updateLastCompletedSetLog("4", 6, 1, "last timer"); QCOMPARE(restored.lastCompletedSetLog().value("exerciseName").toString(), QString("Timer")); QCOMPARE(restored.lastCompletedSetLog().value("loadKg").toDouble(), 4.0); restored.saveFinishedSession("ok"); QFile sessionFile(statePath); QVERIFY(sessionFile.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(sessionFile.readAll()).object(); const QJsonArray sessions = saved.value("sessions").toArray(); const QJsonObject firstStep = sessions.last().toObject().value("steps").toArray().first().toObject(); QCOMPARE(firstStep.value("externalLoadKg").toDouble(), 9.0); QCOMPARE(firstStep.value("effortRating").toInt(), 8); QCOMPARE(firstStep.value("discomfortRating").toInt(), 3); QCOMPARE(firstStep.value("loadNote").toString(), QString("after set")); const QVariantList journal = restored.loadJournalEntries(); QVERIFY(!journal.isEmpty()); QCOMPARE(journal.first().toMap().value("externalLoadKg").toDouble(), 9.0); } void SessionControllerTests::exposesDefaultProfileForOldState() { QTemporaryDir directory; QVERIFY(directory.isValid()); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedProfileId(), QString("default")); QCOMPARE(controller.selectedProfileName(), QString::fromUtf8("Семья")); QVariantList profiles = controller.profiles(); QCOMPARE(profiles.size(), 1); QVariantMap profile = profiles.first().toMap(); QCOMPARE(profile.value("id").toString(), QString("default")); QCOMPARE(profile.value("name").toString(), QString::fromUtf8("Семья")); QCOMPARE(profile.value("selected").toBool(), true); } void SessionControllerTests::createsAndSelectsProfilesAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.createProfile(QString::fromUtf8("Мама")); QCOMPARE(controller.selectedProfileName(), QString::fromUtf8("Мама")); QCOMPARE(controller.profiles().size(), 2); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("profiles").toArray().size(), 2); QVERIFY(!saved.value("selectedProfileId").toString().isEmpty()); controller.selectProfile("default"); QCOMPARE(controller.selectedProfileId(), QString("default")); QCOMPARE(controller.selectedProfileName(), QString::fromUtf8("Семья")); QFile afterSelectFile(statePath); QVERIFY(afterSelectFile.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(afterSelectFile.readAll()).object(); afterSelectFile.close(); QCOMPARE(saved.value("selectedProfileId").toString(), QString("default")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.selectProfile("missing"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::migratesLegacyOwnershipToSelectedProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "roman"}, {"name", "Roman"}} }); root.insert("selectedProfileId", "roman"); root.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"loggedAt", "2026-07-10T08:00:00"}, {"weightKg", 75.0}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedProfileId(), QString("roman")); QCOMPARE(controller.selectedPlanId(), QString("plan")); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("roman")); QVERIFY(controller.ready()); } const QJsonObject migrated = readJsonObject(statePath); QCOMPARE(migrated.value("plans").toArray().first().toObject().value("profileId").toString(), QString("roman")); for (const QJsonValue &value : migrated.value("sessions").toArray()) { QCOMPARE(value.toObject().value("profileId").toString(), QString("roman")); } QCOMPARE( migrated.value("bodyweightHistory").toArray().first().toObject().value("profileId").toString(), QString("roman")); QCOMPARE( migrated.value("profileSettings").toObject().value("roman").toObject().value("selectedPlanId").toString(), QString("plan")); const QByteArray migratedBytes = QJsonDocument(migrated).toJson(); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); } QFile unchanged(statePath); QVERIFY(unchanged.open(QIODevice::ReadOnly)); QCOMPARE(unchanged.readAll(), migratedBytes); } void SessionControllerTests::migratesPartiallyOwnedLegacyStateToActiveProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "roman"}, {"name", "Roman"}} }); root.insert("selectedProfileId", "roman"); QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); plan.insert("profileId", "roman"); plans.replace(0, plan); root.insert("plans", plans); root.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"loggedAt", "2026-07-11T08:00:00"}, {"weightKg", 74.8}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVERIFY(controller.ready()); const QJsonObject migrated = readJsonObject(statePath); for (const QJsonValue &value : migrated.value("sessions").toArray()) { QCOMPARE(value.toObject().value("profileId").toString(), QString("roman")); } QCOMPARE( migrated.value("bodyweightHistory").toArray().first().toObject().value("profileId").toString(), QString("roman")); } void SessionControllerTests::migratesExistingNastyaCourseLegacyHistoryToRoman() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "roman"}, {"name", QString::fromUtf8("Роман")}}, QJsonObject{{"id", "nastya"}, {"name", QString::fromUtf8("Настя")}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "roman"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "nastya"); root.insert("selectedPlanId", "nastya-plan"); root.insert("profileSettings", QJsonObject{ {"roman", QJsonObject{{"selectedPlanId", "plan"}, {"initialWeightKg", 75.0}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}, {"initialWeightKg", 60.0}}} }); root.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"loggedAt", "2026-07-11T08:00:00"}, {"weightKg", 74.8}} }); root.insert("wearableDailyHistory", QJsonArray{ QJsonObject{{"profileId", "roman"}, {"date", "2026-07-11"}, {"steps", 8000}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("nastya")); QCOMPARE(controller.selectedPlanId(), QString("nastya-plan")); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("roman")); QCOMPARE(controller.totalSessions(), 0); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("60,0 кг")); controller.selectProfile("roman"); QCOMPARE(controller.totalSessions(), 2); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("74,8 кг")); const QJsonObject migrated = readJsonObject(statePath); for (const QJsonValue &value : migrated.value("sessions").toArray()) { QCOMPARE(value.toObject().value("profileId").toString(), QString("roman")); } QCOMPARE( migrated.value("bodyweightHistory").toArray().first().toObject().value("profileId").toString(), QString("roman")); QCOMPARE( migrated.value("wearableDailyHistory").toArray().first().toObject().value("profileId").toString(), QString("roman")); } void SessionControllerTests::keepsLegacyPlansAwayFromEmptyNewProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "mom"}, {"name", "Mom"}} }); root.insert("selectedProfileId", "mom"); root.insert("selectedPlanId", ""); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedProfileId(), QString("mom")); QVERIFY(controller.selectedPlanId().isEmpty()); QVERIFY(controller.plans().isEmpty()); QVERIFY(!controller.ready()); const QJsonObject saved = readJsonObject(statePath); QCOMPARE(saved.value("plans").toArray().first().toObject().value("profileId").toString(), QString("default")); QCOMPARE( saved.value("profileSettings").toObject().value("default").toObject().value("selectedPlanId").toString(), QString("plan")); QVERIFY(saved.value("profileSettings").toObject().value("mom").toObject().value("selectedPlanId").toString().isEmpty()); } void SessionControllerTests::restoresSelectedPlanPerProfileAndRejectsForeignPlan() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "mom"}, {"name", "Mom"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject defaultPlan = plans.first().toObject(); defaultPlan.insert("profileId", "default"); plans.replace(0, defaultPlan); QJsonObject momPlan = defaultPlan; momPlan.insert("id", "mom-plan"); momPlan.insert("name", "Mom Plan"); momPlan.insert("profileId", "mom"); plans.append(momPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("selectedPlanId", "plan"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"mom", QJsonObject{{"selectedPlanId", "mom-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); { SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedPlanId(), QString("plan")); controller.selectProfile("MOM"); QCOMPARE(controller.selectedProfileId(), QString("mom")); QCOMPARE(controller.selectedPlanId(), QString("mom-plan")); const QByteArray beforeForeignSelection = QJsonDocument(readJsonObject(statePath)).toJson(); controller.selectPlan("plan"); QCOMPARE(controller.selectedPlanId(), QString("mom-plan")); QCOMPARE(QJsonDocument(readJsonObject(statePath)).toJson(), beforeForeignSelection); controller.selectProfile("default"); QCOMPARE(controller.selectedPlanId(), QString("plan")); controller.selectProfile("mom"); QCOMPARE(controller.selectedPlanId(), QString("mom-plan")); } SessionController reloaded(directory.path(), directory.filePath("exercises.json")); QCOMPARE(reloaded.selectedProfileId(), QString("mom")); QCOMPARE(reloaded.selectedPlanId(), QString("mom-plan")); reloaded.selectProfile("default"); QCOMPARE(reloaded.selectedPlanId(), QString("plan")); } void SessionControllerTests::clearsPlanUndoWhenSwitchingProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("selectedPlanId", "plan"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.updateSelectedPlanFlowOptions(true, 1, false, 0); QVERIFY(controller.canUndo()); QVERIFY(planWithId(readJsonObject(statePath), QStringLiteral("plan")).value("warmupIncluded").toBool()); controller.selectProfile("nastya"); QCOMPARE(controller.selectedPlanId(), QString("nastya-plan")); QVERIFY(!controller.canUndo()); controller.undoLastPlanEdit(); const QJsonObject afterUndoAttempt = readJsonObject(statePath); QVERIFY(planWithId(afterUndoAttempt, QStringLiteral("plan")).value("warmupIncluded").toBool()); QVERIFY(!planWithId(afterUndoAttempt, QStringLiteral("nastya-plan")).value("warmupIncluded").toBool()); controller.selectProfile("default"); QVERIFY(controller.selectedPlanOptions().value("warmupIncluded").toBool()); } void SessionControllerTests::keepsDuplicatePlanIdsIsolatedByProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "roman"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); root.insert("selectedProfileId", "roman"); root.insert("selectedPlanId", "shared-plan"); root.insert("healthConnectOwnerProfileId", "roman"); root.insert("profileSettings", QJsonObject{ {"roman", QJsonObject{{"selectedPlanId", "shared-plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "shared-plan"}}} }); QJsonObject romanPlan = root.value("plans").toArray().first().toObject(); romanPlan.insert("id", "shared-plan"); romanPlan.insert("profileId", "roman"); romanPlan.insert("name", "Roman original"); romanPlan.insert("goal", "Roman goal"); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("profileId", "nastya"); nastyaPlan.insert("name", "Nastya original"); nastyaPlan.insert("goal", "Nastya goal"); QJsonArray nastyaSteps = nastyaPlan.value("steps").toArray(); QJsonObject nastyaFirstStep = nastyaSteps.first().toObject(); nastyaFirstStep.insert("targetOverride", 20); nastyaFirstStep.insert("coachNote", "nastya-only"); nastyaSteps.replace(0, nastyaFirstStep); QJsonObject nastyaSecondStep = nastyaSteps.at(1).toObject(); nastyaSecondStep.insert("targetOverride", 30); nastyaSteps.replace(1, nastyaSecondStep); nastyaPlan.insert("steps", nastyaSteps); QJsonObject romanFallback = romanPlan; romanFallback.insert("id", "roman-fallback"); romanFallback.insert("name", "Roman fallback"); root.insert("plans", QJsonArray{nastyaPlan, romanPlan, romanFallback}); QJsonArray sessions = root.value("sessions").toArray(); for (int index = 0; index < sessions.size(); ++index) { QJsonObject session = sessions.at(index).toObject(); session.insert("profileId", "roman"); sessions.replace(index, session); } root.insert("sessions", sessions); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("roman")); QCOMPARE(controller.selectedPlanId(), QString("shared-plan")); controller.renameSelectedPlan("Roman renamed", "Roman renamed goal"); QJsonObject saved = readJsonObject(statePath); QCOMPARE(planForProfile(saved, "shared-plan", "roman").value("name").toString(), QString("Roman renamed")); QCOMPARE(planForProfile(saved, "shared-plan", "nastya").value("name").toString(), QString("Nastya original")); controller.updateSelectedPlanStep(0, "rep", "2", "1", "roman-only"); saved = readJsonObject(statePath); const QJsonObject romanEditedStep = planForProfile(saved, "shared-plan", "roman") .value("steps").toArray().first().toObject(); const QJsonObject nastyaUneditedStep = planForProfile(saved, "shared-plan", "nastya") .value("steps").toArray().first().toObject(); QCOMPARE(romanEditedStep.value("coachNote").toString(), QString("roman-only")); QCOMPARE(nastyaUneditedStep.value("coachNote").toString(), QString("nastya-only")); QCOMPARE(nastyaUneditedStep.value("targetOverride").toInt(), 20); finishSession(controller); QVERIFY(controller.needsSave()); controller.saveFinishedSession(QString::fromUtf8("Легко"), true); saved = readJsonObject(statePath); const QJsonArray adaptedRomanSteps = planForProfile(saved, "shared-plan", "roman").value("steps").toArray(); const QJsonArray untouchedNastyaSteps = planForProfile(saved, "shared-plan", "nastya").value("steps").toArray(); QCOMPARE(adaptedRomanSteps.at(0).toObject().value("targetOverride").toInt(), 3); QCOMPARE(adaptedRomanSteps.at(1).toObject().value("targetOverride").toInt(), 7); QCOMPARE(untouchedNastyaSteps.at(0).toObject().value("targetOverride").toInt(), 20); QCOMPARE(untouchedNastyaSteps.at(1).toObject().value("targetOverride").toInt(), 30); controller.deleteSelectedPlan(); saved = readJsonObject(statePath); QVERIFY(planForProfile(saved, "shared-plan", "roman").isEmpty()); QVERIFY(!planForProfile(saved, "shared-plan", "nastya").isEmpty()); QCOMPARE(planForProfile(saved, "shared-plan", "nastya").value("name").toString(), QString("Nastya original")); QCOMPARE(controller.selectedPlanId(), QString("roman-fallback")); } void SessionControllerTests::separatesWorkoutHistoryByProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("default")); QCOMPARE(controller.totalSessions(), 2); controller.createProfile("Mom"); QCOMPARE(controller.selectedProfileId(), QString("mom")); QCOMPARE(controller.totalSessions(), 0); QVERIFY(controller.selectedPlanId().isEmpty()); QVERIFY(!controller.ready()); const QString momPlanPath = directory.filePath("mom-plan.json"); QVERIFY(writeJson(momPlanPath, QJsonObject{ {"name", "Mom Plan"}, {"goal", "Own plan"}, {"steps", QJsonArray{ QJsonObject{{"exerciseId", "rep"}, {"targetOverride", 2}, {"restSecondsOverride", 1}}, QJsonObject{{"exerciseId", "timer"}, {"targetOverride", 2}, {"restSecondsOverride", 0}} }} })); controller.importPlan(momPlanPath); controller.selectPlan("mom-plan"); QCOMPARE(controller.selectedPlanId(), QString("mom-plan")); QVERIFY(controller.ready()); finishSession(controller); QVERIFY(controller.needsSave()); controller.saveFinishedSession("ok"); QCOMPARE(controller.totalSessions(), 1); QCOMPARE(controller.recentSessions().size(), 1); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonArray sessions = saved.value("sessions").toArray(); QCOMPARE(sessions.size(), 3); QCOMPARE(sessions.last().toObject().value("profileId").toString(), QString("mom")); controller.selectProfile("default"); QCOMPARE(controller.totalSessions(), 2); QCOMPARE(controller.recentSessions().size(), 2); } void SessionControllerTests::selectsAndPersistsPlan() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject second = plans.first().toObject(); second.insert("id", "second"); second.insert("name", "Second"); plans.append(second); root.insert("plans", plans); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.plans().size(), 2); controller.selectPlan("second"); QCOMPARE(controller.selectedPlanId(), QString("second")); QFile file(directory.filePath("native-state.json")); QVERIFY(file.open(QIODevice::ReadOnly)); QCOMPARE(QJsonDocument::fromJson(file.readAll()).object().value("selectedPlanId").toString(), QString("second")); } void SessionControllerTests::duplicatesSelectedPlanAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.duplicateSelectedPlan(" Plan copy "); QCOMPARE(controller.plans().size(), 2); QCOMPARE(controller.selectedPlanId(), QString("plan-copy")); QCOMPARE(controller.planName(), QString("Plan copy")); QCOMPARE(controller.selectedPlanSteps().size(), 2); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("selectedPlanId").toString(), QString("plan-copy")); const QJsonArray savedPlans = saved.value("plans").toArray(); QCOMPARE(savedPlans.size(), 2); const QJsonObject copied = savedPlans.last().toObject(); QCOMPARE(copied.value("id").toString(), QString("plan-copy")); QCOMPARE(copied.value("basePlanId").toString(), QString("plan")); QCOMPARE(copied.value("steps").toArray().size(), 2); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.duplicateSelectedPlan(" "); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::deletesSelectedPlanAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject second = plans.first().toObject(); second.insert("id", "second"); second.insert("name", "Second"); plans.append(second); root.insert("plans", plans); root.insert("selectedPlanId", "second"); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.selectedPlanId(), QString("second")); controller.deleteSelectedPlan(); QCOMPARE(controller.plans().size(), 1); QCOMPARE(controller.selectedPlanId(), QString("plan")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("selectedPlanId").toString(), QString("plan")); QCOMPARE(saved.value("plans").toArray().size(), 1); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.deleteSelectedPlan(); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::renamesSelectedPlanAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.renameSelectedPlan(" New name ", " New goal "); QCOMPARE(controller.planName(), QString("New name")); QCOMPARE(controller.planGoal(), QString("New goal")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonObject savedPlan = saved.value("plans").toArray().first().toObject(); QCOMPARE(savedPlan.value("name").toString(), QString("New name")); QCOMPARE(savedPlan.value("goal").toString(), QString("New goal")); QCOMPARE(savedPlan.value("steps").toArray().size(), 2); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.renameSelectedPlan(" ", "ignored"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::updatesSelectedPlanStepAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.updateSelectedPlanStep(0, "timer", "12", "45", " steady "); QVariantMap firstStep = controller.selectedPlanSteps().first().toMap(); QCOMPARE(firstStep.value("exerciseId").toString(), QString("timer")); QCOMPARE(firstStep.value("exerciseName").toString(), QString("Timer")); QCOMPARE(firstStep.value("target").toInt(), 12); QCOMPARE(firstStep.value("restSeconds").toInt(), 45); QCOMPARE(firstStep.value("coachNote").toString(), QString("steady")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonObject savedStep = saved.value("plans").toArray().first().toObject().value("steps").toArray().first().toObject(); QCOMPARE(savedStep.value("exerciseId").toString(), QString("timer")); QCOMPARE(savedStep.value("targetOverride").toInt(), 12); QCOMPARE(savedStep.value("restSecondsOverride").toInt(), 45); QCOMPARE(savedStep.value("coachNote").toString(), QString("steady")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.updateSelectedPlanStep(0, "missing", "12", "45", "bad"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::updatesSelectedPlanFlowOptionsAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.updateSelectedPlanFlowOptions(true, 5, true, 3); QVariantMap options = controller.selectedPlanOptions(); QCOMPARE(options.value("warmupIncluded").toBool(), true); QCOMPARE(options.value("warmupStepCount").toInt(), 5); QCOMPARE(options.value("cooldownIncluded").toBool(), true); QCOMPARE(options.value("cooldownStepCount").toInt(), 3); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonObject savedPlan = saved.value("plans").toArray().first().toObject(); QCOMPARE(savedPlan.value("warmupIncluded").toBool(), true); QCOMPARE(savedPlan.value("warmupStepCount").toInt(), 5); QCOMPARE(savedPlan.value("cooldownIncluded").toBool(), true); QCOMPARE(savedPlan.value("cooldownStepCount").toInt(), 3); } void SessionControllerTests::exposesExpandedWorkoutQueueSteps() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRootWithFlow())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.updateSelectedPlanFlowOptions(true, 1, true, 1); QCOMPARE(controller.selectedPlanSteps().size(), 2); QCOMPARE(controller.workoutQueueSteps().size(), 2); controller.start(); const QVariantList queue = controller.workoutQueueSteps(); QCOMPARE(queue.size(), 4); QVERIFY(queue.first().toMap().value("isWarmup").toBool()); QCOMPARE(queue.first().toMap().value("exerciseId").toString(), QString("warmup_march_place")); QVERIFY(queue.last().toMap().value("isCooldown").toBool()); QCOMPARE(queue.last().toMap().value("exerciseId").toString(), QString("switch_90_90")); QCOMPARE(controller.currentStepIndex(), 1); } void SessionControllerTests::appendsSelectedPlanStepAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.appendSelectedPlanStep("timer", " hold "); QCOMPARE(controller.selectedPlanSteps().size(), 3); const QVariantMap appended = controller.selectedPlanSteps().last().toMap(); QCOMPARE(appended.value("exerciseId").toString(), QString("timer")); QCOMPARE(appended.value("target").toInt(), 2); QCOMPARE(appended.value("restSeconds").toInt(), 0); QCOMPARE(appended.value("coachNote").toString(), QString("hold")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonArray savedSteps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(savedSteps.size(), 3); const QJsonObject savedStep = savedSteps.last().toObject(); QCOMPARE(savedStep.value("exerciseId").toString(), QString("timer")); QCOMPARE(savedStep.value("targetOverride").toInt(), 2); QCOMPARE(savedStep.value("restSecondsOverride").toInt(), 0); QCOMPARE(savedStep.value("coachNote").toString(), QString("hold")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.appendSelectedPlanStep("missing", "bad"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::duplicatesSelectedPlanStepAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.duplicateSelectedPlanStep(0); QCOMPARE(controller.selectedPlanSteps().size(), 3); QCOMPARE(controller.selectedPlanSteps().at(0).toMap().value("exerciseId").toString(), QString("rep")); QCOMPARE(controller.selectedPlanSteps().at(1).toMap().value("exerciseId").toString(), QString("rep")); QCOMPARE(controller.selectedPlanSteps().at(2).toMap().value("exerciseId").toString(), QString("timer")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QJsonArray savedSteps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(savedSteps.size(), 3); QCOMPARE(savedSteps.at(0).toObject().value("exerciseId").toString(), QString("rep")); QCOMPARE(savedSteps.at(1).toObject().value("exerciseId").toString(), QString("rep")); QCOMPARE(savedSteps.at(2).toObject().value("exerciseId").toString(), QString("timer")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.duplicateSelectedPlanStep(99); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::removesSelectedPlanStepAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.removeSelectedPlanStep(0); QCOMPARE(controller.selectedPlanSteps().size(), 1); QCOMPARE(controller.selectedPlanSteps().first().toMap().value("exerciseId").toString(), QString("timer")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QJsonArray savedSteps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(savedSteps.size(), 1); QCOMPARE(savedSteps.first().toObject().value("exerciseId").toString(), QString("timer")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.removeSelectedPlanStep(0); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::movesSelectedPlanStepAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.moveSelectedPlanStep(0, 1); QCOMPARE(controller.selectedPlanSteps().first().toMap().value("exerciseId").toString(), QString("timer")); QCOMPARE(controller.selectedPlanSteps().last().toMap().value("exerciseId").toString(), QString("rep")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QJsonArray savedSteps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(savedSteps.first().toObject().value("exerciseId").toString(), QString("timer")); QCOMPARE(savedSteps.last().toObject().value("exerciseId").toString(), QString("rep")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.moveSelectedPlanStep(0, -1); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::exposesSelectedPlanSteps() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); plans.append(QJsonObject{ {"id", "second"}, {"name", "Second"}, {"goal", "Other"}, {"steps", QJsonArray{ QJsonObject{{"exerciseId", "timer"}, {"targetOverride", 9}, {"restSecondsOverride", 4}, {"coachNote", "steady"}} }} }); root.insert("plans", plans); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVariantList steps = controller.selectedPlanSteps(); QCOMPARE(steps.size(), 2); QVariantMap first = steps.first().toMap(); QCOMPARE(first.value("index").toInt(), 1); QCOMPARE(first.value("exerciseName").toString(), QString("Rep")); QCOMPARE(first.value("metricText").toString(), QString("повторы")); QCOMPARE(first.value("target").toInt(), 2); QCOMPARE(first.value("restSeconds").toInt(), 1); controller.selectPlan("second"); steps = controller.selectedPlanSteps(); QCOMPARE(steps.size(), 1); controller.start(); QCOMPARE(controller.currentCoachNote(), QString("steady")); controller.abortActiveWorkout(); first = steps.first().toMap(); QCOMPARE(first.value("exerciseName").toString(), QString("Timer")); QCOMPARE(first.value("metricText").toString(), QString("секунды")); QCOMPARE(first.value("target").toInt(), 9); QCOMPARE(first.value("restSeconds").toInt(), 4); QCOMPARE(first.value("coachNote").toString(), QString("steady")); QCOMPARE(first.value("category").toString(), QString("Core")); QCOMPARE(first.value("equipment").toString(), QString("Floor")); QCOMPARE(first.value("primaryCue").toString(), QString("Brace")); QCOMPARE(first.value("secondaryCue").toString(), QString("Breathe")); QCOMPARE(first.value("loadRecommendation").toString(), QString("Add seconds")); QCOMPARE(first.value("availabilityNote").toString(), QString("No equipment")); } void SessionControllerTests::exposesExerciseFrameUrls() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString frameDirectory = directory.filePath("Assets/ExerciseFrames"); QVERIFY(QDir().mkpath(frameDirectory)); for (int frame = 1; frame <= 9; ++frame) { QVERIFY(writeFile( QDir(frameDirectory).filePath(QStringLiteral("rep_%1.png").arg(frame)), QByteArray("frame") + QByteArray::number(frame))); } QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller( directory.path(), directory.filePath("exercises.json"), {}, frameDirectory); const QVariantList library = controller.exerciseLibrary(); QCOMPARE(library.first().toMap().value("frameUrls").toList().size(), 9); QCOMPARE(controller.selectedPlanSteps().first().toMap().value("frameUrls").toList().size(), 9); QCOMPARE(controller.currentExerciseFrameUrls().size(), 0); controller.start(); QCOMPARE(controller.currentExerciseFrameUrls().size(), 9); QVERIFY(controller.currentExerciseFrameUrls().first().toString().startsWith("file:///")); } void SessionControllerTests::exposesProgressSummary() { QTemporaryDir directory; QVERIFY(directory.isValid()); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.totalSessions(), 2); QCOMPARE(controller.totalWorkMinutes(), 3); QCOMPARE(controller.completionRateText(), QString("50%")); QCOMPARE(controller.recentSessions().size(), 2); controller.setProgressPlanFilter("missing"); QCOMPARE(controller.recentSessions().size(), 0); QCOMPARE(controller.progressDayBuckets().size(), 0); QCOMPARE(controller.progressExerciseBuckets().size(), 0); controller.setProgressPlanFilter("plan"); QCOMPARE(controller.recentSessions().size(), 2); QCOMPARE(controller.progressDayBuckets().size(), 2); QCOMPARE(controller.progressExerciseBuckets().size(), 2); controller.setProgressPlanFilter("all"); const QVariantList buckets = controller.progressDayBuckets(); QCOMPARE(buckets.size(), 2); QCOMPARE(buckets.first().toMap().value("dateText").toString(), QString("15.06")); QCOMPARE(buckets.first().toMap().value("sessionCount").toInt(), 1); QCOMPARE(buckets.first().toMap().value("completedCount").toInt(), 1); QCOMPARE(buckets.first().toMap().value("workMinutes").toInt(), 2); const QVariantList exerciseBuckets = controller.progressExerciseBuckets(); QCOMPARE(exerciseBuckets.size(), 2); QCOMPARE(exerciseBuckets.first().toMap().value("exerciseId").toString(), QString("rep")); QCOMPARE(exerciseBuckets.first().toMap().value("actualTotal").toInt(), 8); QCOMPARE(exerciseBuckets.first().toMap().value("averageActual").toInt(), 8); QCOMPARE(exerciseBuckets.first().toMap().value("lastDoneText").toString(), QString("15.06")); QVariantList repEntries = exerciseBuckets.first().toMap().value("recentEntries").toList(); QCOMPARE(repEntries.size(), 1); QCOMPARE(repEntries.first().toMap().value("dateText").toString(), QString("15.06")); QCOMPARE(repEntries.first().toMap().value("actualValue").toInt(), 8); QCOMPARE(exerciseBuckets.last().toMap().value("metricText").toString(), QString("сек")); QCOMPARE(exerciseBuckets.last().toMap().value("averageActual").toInt(), 45); QCOMPARE(exerciseBuckets.last().toMap().value("lastDoneText").toString(), QString("14.06")); QCOMPARE(exerciseBuckets.last().toMap().value("recentEntries").toList().first().toMap().value("actualValue").toInt(), 45); } void SessionControllerTests::comparesRollingWeeksAndFlagsSharpIncrease() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); const QDate today = QDate::currentDate(); const auto session = [](const QDate &date, int workSeconds, bool completed, double heartRateAvg = 0.0) { QJsonObject object{ {"planId", "plan"}, {"planName", "Plan"}, {"endedAt", QDateTime(date, QTime(18, 0)).toString(Qt::ISODate)}, {"totalWorkSeconds", workSeconds}, {"completedAll", completed}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"exerciseName", "Rep"}, {"actualValue", 10}, {"completed", completed} }}} }; if (heartRateAvg > 0.0) { object.insert("heartRateAvg", heartRateAvg); object.insert("heartRateMin", qRound(heartRateAvg) - 10); object.insert("heartRateMax", qRound(heartRateAvg) + 10); object.insert("heartRateSampleCount", 3); } return object; }; root.insert("sessions", QJsonArray{ session(today.addDays(-8), 2400, true, 100.0), session(today.addDays(-3), 2400, true, 100.0), session(today.addDays(-1), 2400, false, 150.0) }); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); const QVariantMap summary = controller.weeklySummary(); QCOMPARE(summary.value("totalSessions").toInt(), 2); QCOMPARE(summary.value("totalWorkMinutes").toInt(), 80); QCOMPARE(summary.value("previousSessions").toInt(), 1); QCOMPARE(summary.value("previousWorkMinutes").toInt(), 40); QCOMPARE(summary.value("sessionsDeltaText").toString(), QString("+1")); QCOMPARE(summary.value("workMinutesDeltaText").toString(), QString("+40")); QCOMPARE(summary.value("workChangePercent").toInt(), 100); QVERIFY(summary.value("heartRateLoadAvailable").toBool()); QCOMPARE(summary.value("heartRateLoad").toInt(), 100); QCOMPARE(summary.value("previousHeartRateLoad").toInt(), 40); QCOMPARE(summary.value("heartRateLoadDeltaText").toString(), QString("+60")); QCOMPARE(summary.value("heartRateLoadChangePercent").toInt(), 150); QCOMPARE(summary.value("loadStatusId").toString(), QString("sharp-hr-increase")); QVERIFY(summary.value("undertrainedAreasActive").toBool()); QVERIFY(summary.value("undertrainedAreasText").toString().contains("Core")); } void SessionControllerTests::exposesProfileSpecificWeeklyGoals() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QDate today = QDate::currentDate(); const auto plan = [&today](const QString &id, const QString &profileId) { return QJsonObject{ {"id", id}, {"profileId", profileId}, {"name", id}, {"goal", "Goal"}, {"dayOfWeek", today.dayOfWeek()}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"targetOverride", 2}, {"restSecondsOverride", 1} }}} }; }; const auto session = [&today](const QString &profileId, int dayOffset, bool quick = false) { return QJsonObject{ {"profileId", profileId}, {"planId", profileId + "-plan"}, {"planName", "Plan"}, {"sessionMode", quick ? "quick" : "full"}, {"endedAt", QDateTime(today.addDays(dayOffset), QTime(18, 0)).toString(Qt::ISODate)}, {"totalWorkSeconds", quick ? 300 : 1200}, {"completedAll", true}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"exerciseName", "Rep"}, {"actualValue", 2}, {"completed", true} }}} }; }; QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "roman"}, {"name", QStringLiteral("Роман")}}, QJsonObject{{"id", "nastya"}, {"name", QStringLiteral("Настя")}} }); root.insert("selectedProfileId", "roman"); root.insert("selectedPlanId", "roman-plan"); root.insert("plans", QJsonArray{plan("roman-plan", "roman"), plan("nastya-plan", "nastya")}); root.insert("profileSettings", QJsonObject{ {"roman", QJsonObject{{"selectedPlanId", "roman-plan"}, {"weeklyGoalMin", 4}, {"weeklyGoalMax", 6}, {"rescueWeeklyGoalMin", 2}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}, {"weeklyGoalMin", 3}, {"weeklyGoalMax", 3}, {"rescueWeeklyGoalMin", 1}, {"returnRampSessionsRemaining", 2}}} }); root.insert("sessions", QJsonArray{ session("roman", -4), session("roman", -2), session("roman", 0), session("roman", -1, true), session("nastya", -3), session("nastya", 0) }); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVariantMap summary = controller.weeklySummary(); QCOMPARE(summary.value("weeklyGoalMin").toInt(), 4); QCOMPARE(summary.value("weeklyGoalMax").toInt(), 6); QCOMPARE(summary.value("effectiveWeeklyGoalMin").toInt(), 4); QCOMPARE(summary.value("fullSessions").toInt(), 3); QCOMPARE(summary.value("quickSessions").toInt(), 1); QCOMPARE(summary.value("weeklyGoalProgress").toInt(), 75); QCOMPARE(summary.value("weeklyGoalRemaining").toInt(), 1); QVERIFY(!summary.value("weeklyGoalAchieved").toBool()); QVERIFY(!summary.value("rescueGoalActive").toBool()); controller.selectProfile("nastya"); summary = controller.weeklySummary(); QCOMPARE(summary.value("weeklyGoalMin").toInt(), 3); QCOMPARE(summary.value("weeklyGoalMax").toInt(), 3); QCOMPARE(summary.value("fullSessions").toInt(), 2); QCOMPARE(summary.value("quickSessions").toInt(), 0); QVERIFY(summary.value("rescueGoalActive").toBool()); QCOMPARE(summary.value("effectiveWeeklyGoalMin").toInt(), 1); QCOMPARE(summary.value("weeklyGoalProgress").toInt(), 100); QVERIFY(summary.value("weeklyGoalAchieved").toBool()); QCOMPARE(summary.value("weeklyGoalStatusId").toString(), QString("rescue-complete")); } void SessionControllerTests::exposesPreWorkoutRecommendation() { QTemporaryDir directory; QVERIFY(directory.isValid()); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVariantMap recommendation = controller.preWorkoutRecommendation(); QCOMPARE(recommendation.value("title").toString(), QString("Держать нагрузку")); QVERIFY(recommendation.value("detail").toString().contains("оставить")); QVERIFY(recommendation.value("hasHistory").toBool()); QCOMPARE(recommendation.value("lastFeedback").toString(), QString("ok")); QTemporaryDir emptyDirectory; QVERIFY(emptyDirectory.isValid()); QJsonObject emptyState = stateRoot(); emptyState.insert("sessions", QJsonArray{}); QVERIFY(writeJson(emptyDirectory.filePath("native-state.json"), emptyState)); QVERIFY(writeJson(emptyDirectory.filePath("exercises.json"), catalogRoot())); SessionController firstRun(emptyDirectory.path(), emptyDirectory.filePath("exercises.json")); recommendation = firstRun.preWorkoutRecommendation(); QCOMPARE(recommendation.value("title").toString(), QString("Первый запуск плана")); QVERIFY(!recommendation.value("hasHistory").toBool()); } void SessionControllerTests::exposesLatestSessionSteps() { QTemporaryDir directory; QVERIFY(directory.isValid()); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QVariantList steps = controller.latestSessionSteps(); QCOMPARE(steps.size(), 1); QVariantMap first = steps.first().toMap(); QCOMPARE(first.value("index").toInt(), 1); QCOMPARE(first.value("exerciseName").toString(), QString("Rep")); QCOMPARE(first.value("metricText").toString(), QString("повт")); QCOMPARE(first.value("actualValue").toInt(), 8); QCOMPARE(first.value("completed").toBool(), true); QCOMPARE(first.value("statusText").toString(), QString("выполнено")); QCOMPARE(first.value("loadNote").toString(), QString("clean")); controller.setProgressPlanFilter("missing"); QCOMPARE(controller.latestSessionSteps().size(), 0); } void SessionControllerTests::filtersExerciseLibrary() { QTemporaryDir directory; QVERIFY(directory.isValid()); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.exerciseLibrary().size(), 2); controller.setExerciseMetricFilter("seconds"); QCOMPARE(controller.exerciseLibrary().size(), 1); QVariantMap timer = controller.exerciseLibrary().first().toMap(); QCOMPARE(timer.value("category").toString(), QString("Core")); QCOMPARE(timer.value("equipment").toString(), QString("Floor")); QCOMPARE(timer.value("description").toString(), QString("Timer description")); QCOMPARE(timer.value("primaryCue").toString(), QString("Brace")); QCOMPARE(timer.value("secondaryCue").toString(), QString("Breathe")); QCOMPARE(timer.value("loadRecommendation").toString(), QString("Add seconds")); QCOMPARE(timer.value("availabilityNote").toString(), QString("No equipment")); controller.setExerciseSearch("timer"); QCOMPARE(controller.exerciseLibrary().size(), 1); controller.setExerciseSearch("rep"); QCOMPARE(controller.exerciseLibrary().size(), 0); } void SessionControllerTests::exposesNutritionSummary() { QTemporaryDir directory; QVERIFY(directory.isValid()); QJsonObject root = stateRoot(); root.insert("currentBodyWeightKg", 76.0); root.insert("nutritionPresetId", "cheap"); root.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"loggedAt", "2026-06-16T08:00:00"}, {"weightKg", 76.2}}, QJsonObject{{"loggedAt", "2026-06-09T08:00:00"}, {"weightKg", 75.8}} }); root.insert("bodyMeasurementHistory", QJsonArray{QJsonObject{ {"loggedAt", "2026-06-16T08:05:00"}, {"waistCm", 80.0}, {"shouldersCm", 120.0}, {"chestCm", 101.0}, {"armCm", 34.0} }}); root.insert("nutritionAdherenceHistory", QJsonArray{ QJsonObject{{"date", "2026-06-16T00:00:00"}, {"statusId", "good"}}, QJsonObject{{"date", "2026-06-15T00:00:00"}, {"statusId", "partial"}}, QJsonObject{{"date", "2026-06-14T00:00:00"}, {"statusId", "off"}} }); root.insert("photoProgressHistory", QJsonArray{QJsonObject{{"loggedAt", "2026-06-16T08:10:00"}, {"imagePath", "photo.jpg"}}}); root.insert("recoveryCheckInHistory", QJsonArray{QJsonObject{ {"planName", "Plan"}, {"loggedAt", "2026-06-16T09:00:00"}, {"energyScore", 4}, {"sleepScore", 3}, {"jointScore", 5}, {"note", "ok"} }}); QVERIFY(writeJson(directory.filePath("native-state.json"), root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); const QVariantMap summary = controller.nutritionSummary(); QCOMPARE(summary.value("trainingCaloriesText").toString(), QString("2950–3150")); QCOMPARE(summary.value("proteinTargetText").toString(), QString("160–170 г")); QCOMPARE(summary.value("currentWeightText").toString(), QString("76,2 кг")); QCOMPARE(summary.value("presetText").toString(), QString("cheap")); QCOMPARE(summary.value("photoEntryCount").toInt(), 1); QCOMPARE(summary.value("adherenceText").toString(), QString("50% за 3 отметок • 1 ok • 1 частично • 1 срыв")); QCOMPARE(controller.nutritionMeals().size(), 4); QCOMPARE(controller.nutritionWeightEntries().size(), 2); QCOMPARE(controller.nutritionMeasurementEntries().first().toMap().value("ratioText").toString(), QString("1,5")); QCOMPARE(controller.nutritionAdherenceDays().first().toMap().value("statusText").toString(), QString("выполнено")); QCOMPARE(controller.nutritionPhotoEntries().first().toMap().value("fileName").toString(), QString("photo.jpg")); QCOMPARE(controller.recoveryCheckIns().first().toMap().value("scoreText").toString(), QString("Энергия 4/5 • Сон 3/5 • Суставы 5/5")); } void SessionControllerTests::exposesApplicationInfo() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString catalogPath = directory.filePath("exercises.json"); const QString frameDirectory = directory.filePath("frames"); QVERIFY(writeJson(directory.filePath("native-state.json"), stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath, {}, frameDirectory); const QVariantMap info = controller.applicationInfo(); QCOMPARE(info.value("applicationName").toString(), QString("BodyweightBaseCpp")); QVERIFY(!info.value("applicationVersion").toString().isEmpty()); QVERIFY(info.value("qtVersion").toString().startsWith("6.")); QCOMPARE(info.value("storageDirectory").toString(), QDir::toNativeSeparators(directory.path())); QCOMPARE(info.value("exerciseFrameDirectory").toString(), QDir::toNativeSeparators(frameDirectory)); QCOMPARE(info.value("thirdPartyNoticeFile").toString(), QString("THIRD_PARTY_NOTICES.txt")); QCOMPARE(info.value("privacyNoticeFile").toString(), QString("PRIVACY_AND_DATA.txt")); QVERIFY(info.value("qtLicenseNotice").toString().contains("Qt runtime")); QVERIFY(info.value("privacyNotice").toString().contains("локально")); } void SessionControllerTests::logsBodyWeightAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.logBodyWeight("77,4"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("77,4 кг")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("currentBodyWeightKg").toDouble(), 77.4); QCOMPARE(saved.value("bodyweightHistory").toArray().size(), 1); QCOMPARE(saved.value("bodyweightHistory").toArray().first().toObject().value("weightKg").toDouble(), 77.4); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.logBodyWeight("700"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::selectsNutritionPresetAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("nutritionPresetId", "balanced"); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); QCOMPARE(controller.nutritionSummary().value("presetText").toString(), QString("balanced")); const QString balancedDescription = controller.nutritionMeals().first().toMap().value("description").toString(); QVERIFY(balancedDescription.contains("Овсянка 120 г")); controller.selectNutritionPreset("cheap"); QCOMPARE(controller.nutritionSummary().value("presetText").toString(), QString("cheap")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); const QString cheapDescription = controller.nutritionMeals().first().toMap().value("description").toString(); QVERIFY(cheapDescription.contains("Овсянка, 4 яйца")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("nutritionPresetId").toString(), QString("cheap")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.selectNutritionPreset("invalid"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::logsBodyMeasurementsAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.logBodyMeasurements("80,0", "120.0", "101", "34,5"); QCOMPARE(controller.nutritionSummary().value("ratioText").toString(), QString("1,5 плечи/талия")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonArray history = saved.value("bodyMeasurementHistory").toArray(); QCOMPARE(history.size(), 1); const QJsonObject entry = history.first().toObject(); QCOMPARE(entry.value("waistCm").toDouble(), 80.0); QCOMPARE(entry.value("shouldersCm").toDouble(), 120.0); QCOMPARE(entry.value("chestCm").toDouble(), 101.0); QCOMPARE(entry.value("armCm").toDouble(), 34.5); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.logBodyMeasurements("80", "120", "101", "5"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::marksNutritionAdherenceAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("nutritionAdherenceHistory", QJsonArray{ QJsonObject{{"date", "2026-06-01T00:00:00.000"}, {"statusId", "good"}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.markNutritionAdherence("partial"); QCOMPARE(controller.nutritionAdherenceDays().first().toMap().value("statusText").toString(), QString("частично")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QJsonArray history = saved.value("nutritionAdherenceHistory").toArray(); QCOMPARE(history.size(), 2); QJsonObject today = history.first().toObject(); QCOMPARE(today.value("statusId").toString(), QString("partial")); QCOMPARE(QDateTime::fromString(today.value("date").toString(), Qt::ISODate).date(), QDate::currentDate()); controller.markNutritionAdherence("good"); QFile replacedFile(statePath); QVERIFY(replacedFile.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(replacedFile.readAll()).object(); replacedFile.close(); history = saved.value("nutritionAdherenceHistory").toArray(); QCOMPARE(history.size(), 2); QCOMPARE(history.first().toObject().value("statusId").toString(), QString("good")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.markNutritionAdherence("bad"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::logsRecoveryCheckInAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.logRecoveryCheckIn(4, 3, 5, "normal"); QCOMPARE(controller.recoveryCheckIns().first().toMap().value("scoreText").toString(), QString("Энергия 4/5 • Сон 3/5 • Суставы 5/5")); QCOMPARE(controller.nutritionSummary().value("recoveryText").toString(), QString("1 отметок • энергия 4,0/5 • сон 3,0/5 • суставы 5,0/5")); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonArray history = saved.value("recoveryCheckInHistory").toArray(); QCOMPARE(history.size(), 1); const QJsonObject entry = history.first().toObject(); QCOMPARE(entry.value("planName").toString(), QString("Plan")); QCOMPARE(entry.value("energyScore").toInt(), 4); QCOMPARE(entry.value("sleepScore").toInt(), 3); QCOMPARE(entry.value("jointScore").toInt(), 5); QCOMPARE(entry.value("note").toString(), QString("normal")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.logRecoveryCheckIn(0, 3, 5, "bad"); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::logsPhotoProgressAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.logPhotoProgress(" C:/progress/photo-01.jpg "); QCOMPARE(controller.nutritionPhotoEntries().first().toMap().value("fileName").toString(), QString("photo-01.jpg")); QCOMPARE(controller.nutritionSummary().value("photoEntryCount").toInt(), 1); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); const QJsonArray history = saved.value("photoProgressHistory").toArray(); QCOMPARE(history.size(), 1); QCOMPARE(history.first().toObject().value("imagePath").toString(), QString("C:/progress/photo-01.jpg")); const QByteArray afterValid = QJsonDocument(saved).toJson(); controller.logPhotoProgress(" "); QFile afterInvalid(statePath); QVERIFY(afterInvalid.open(QIODevice::ReadOnly)); QCOMPARE(afterInvalid.readAll(), afterValid); } void SessionControllerTests::importsHealthConnectDataAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QString importPath = directory.filePath("health-connect.json"); QJsonObject initialState = stateRoot(); QJsonArray initialSessions = initialState.value("sessions").toArray(); initialSessions.append(QJsonObject{ {"id", "heart-rate-session"}, {"profileId", "default"}, {"planId", "plan"}, {"planName", "Plan"}, {"startedAt", "2026-06-29T10:00:00Z"}, {"endedAt", "2026-06-29T10:30:00Z"}, {"totalWorkSeconds", 1200}, {"completedAll", true}, {"steps", QJsonArray{}} }); initialState.insert("sessions", initialSessions); initialState.insert("currentBodyWeightKg", 75.0); initialState.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"loggedAt", "2026-07-01T12:00:00Z"}, {"weightKg", 75.0}} }); QVERIFY(writeJson(statePath, initialState)); QVERIFY(writeJson(catalogPath, catalogRoot())); QVERIFY(writeJson(importPath, QJsonObject{ {"schemaVersion", 1}, {"source", "health-connect"}, {"deviceModel", "Redmi Watch 5"}, {"days", QJsonArray{ QJsonObject{{"date", "2026-06-20"}, {"steps", 8240}, {"sleepMinutes", 412}, {"heartRateAvg", 71.5}, {"restingHeartRateAvg", 60.0}}, QJsonObject{{"date", "2026-06-29"}, {"steps", 3210}, {"sleepMinutes", 465}, {"oxygenSaturationAvg", 97.2}, {"restingHeartRateAvg", 65.0}, {"activeCaloriesKcal", 412.5}, {"weightKg", 74.6}, {"weightRecordedAt", "2026-06-29T08:15:00Z"}, {"weightOrigin", "com.huami.watch.hmwatchmanager"}} }}, {"heartRateSamples", QJsonArray{ QJsonObject{{"at", "2026-06-29T10:05:00Z"}, {"bpm", 110}}, QJsonObject{{"at", "2026-06-29T10:15:00Z"}, {"bpm", 130}}, QJsonObject{{"at", "2026-06-29T11:00:00Z"}, {"bpm", 90}} }} })); SessionController controller(directory.path(), catalogPath); QVERIFY(!controller.wearableSummary().value("available").toBool()); controller.importHealthConnectData(importPath); QVERIFY(controller.wearableSummary().value("available").toBool()); QCOMPARE(controller.wearableSummary().value("dayCount").toInt(), 2); QVERIFY(controller.wearableSummary().value("detail").toString().contains("3")); QVERIFY(controller.wearableStatistics().value("available").toBool()); QCOMPARE(controller.wearableStatistics().value("dayCount").toInt(), 2); QCOMPARE(controller.wearableDays().size(), 2); QVERIFY(controller.wearableStatistics().value("weightSourceText").toString().contains("com.huami")); QVERIFY(controller.wearableStatistics().value("restingHeartRateText").toString().contains("65")); QCOMPARE(controller.wearableStatistics().value("restingHeartRateDelta").toInt(), 5); QCOMPARE(controller.wearableStatistics().value("restingHeartRateTrendId").toString(), QString("higher")); QCOMPARE(controller.wearableDays().last().toMap().value("restingHeartRateAvg").toDouble(), 65.0); const QVariantMap enrichedSession = controller.recentSessions().first().toMap(); QVERIFY(enrichedSession.value("heartRateAvailable").toBool()); QVERIFY(enrichedSession.value("heartRateText").toString().contains("120")); QVERIFY(enrichedSession.value("heartRateLoadAvailable").toBool()); QCOMPARE(enrichedSession.value("heartRateLoad").toInt(), 24); QCOMPARE(enrichedSession.value("heartRateLoadText").toString(), QString("нагрузка HR 24")); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("74,6 кг")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); QCOMPARE(saved.value("wearableDailyHistory").toArray().size(), 2); QCOMPARE(saved.value("bodyweightHistory").toArray().size(), 2); QCOMPARE(saved.value("currentBodyWeightKg").toDouble(), 74.6); const QJsonObject savedHeartRateSession = saved.value("sessions").toArray().last().toObject(); QCOMPARE(qRound(savedHeartRateSession.value("heartRateAvg").toDouble()), 120); QCOMPARE(savedHeartRateSession.value("heartRateMin").toInt(), 110); QCOMPARE(savedHeartRateSession.value("heartRateMax").toInt(), 130); QCOMPARE(savedHeartRateSession.value("heartRateSampleCount").toInt(), 2); bool hasSyncedWeight = false; for (const QJsonValue &value : saved.value("bodyweightHistory").toArray()) { if (value.toObject().value("source").toString() == QStringLiteral("health-connect")) hasSyncedWeight = true; } QVERIFY(hasSyncedWeight); file.close(); QJsonObject replacement{ {"schemaVersion", 1}, {"source", "health-connect"}, {"days", QJsonArray{QJsonObject{{"date", "2026-06-29"}, {"steps", 9999}, {"sleepMinutes", 480}}}} }; QVERIFY(writeJson(importPath, replacement)); controller.importHealthConnectData(importPath); QFile replacedFile(statePath); QVERIFY(replacedFile.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(replacedFile.readAll()).object(); QCOMPARE(saved.value("wearableDailyHistory").toArray().size(), 2); QCOMPARE(saved.value("bodyweightHistory").toArray().size(), 2); } void SessionControllerTests::importsHealthConnectForDeviceOwnerProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QString importPath = directory.filePath("health-connect-owner.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "nastya"); root.insert("selectedPlanId", "nastya-plan"); root.insert("healthConnectOwnerProfileId", "default"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); QVERIFY(writeJson(importPath, QJsonObject{ {"schemaVersion", 1}, {"source", "health-connect"}, {"days", QJsonArray{QJsonObject{ {"date", "2026-07-18"}, {"steps", 8000}, {"weightKg", 74.2}, {"weightRecordedAt", "2026-07-18T08:00:00Z"} }}} })); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("nastya")); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("default")); controller.importHealthConnectData(importPath); QVERIFY(!controller.wearableSummary().value("available").toBool()); const QJsonObject saved = readJsonObject(statePath); const QJsonObject wearable = saved.value("wearableDailyHistory").toArray().last().toObject(); QCOMPARE(wearable.value("profileId").toString(), QString("default")); bool ownerWeightFound = false; for (const QJsonValue &value : saved.value("bodyweightHistory").toArray()) { const QJsonObject weight = value.toObject(); if (weight.value("source").toString() != QString("health-connect")) continue; QCOMPARE(weight.value("profileId").toString(), QString("default")); ownerWeightFound = true; } QVERIFY(ownerWeightFound); controller.selectProfile("default"); QVERIFY(controller.wearableSummary().value("available").toBool()); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("74,2 кг")); } void SessionControllerTests::importsTargetedHealthDataWithoutTouchingOtherProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QString importPath = directory.filePath("health-connect-targeted.json"); const QString date = QDate::currentDate().toString(Qt::ISODate); const QString startedAt = date + QStringLiteral("T10:00:00Z"); const QString endedAt = date + QStringLiteral("T10:30:00Z"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "nastya"); root.insert("selectedPlanId", "nastya-plan"); root.insert("healthConnectOwnerProfileId", "default"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}, {"initialWeightKg", 75.0}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}, {"initialWeightKg", 60.0}}} }); root.insert("wearableDailyHistory", QJsonArray{ QJsonObject{{"profileId", "default"}, {"date", date}, {"steps", 100}, {"source", "health-connect"}}, QJsonObject{{"profileId", "nastya"}, {"date", date}, {"steps", 200}, {"source", "health-connect"}} }); root.insert("bodyweightHistory", QJsonArray{ QJsonObject{{"profileId", "default"}, {"loggedAt", date + "T08:00:00Z"}, {"weightKg", 75.0}, {"source", "health-connect"}}, QJsonObject{{"profileId", "nastya"}, {"loggedAt", date + "T08:05:00Z"}, {"weightKg", 60.0}, {"source", "health-connect"}} }); root.insert("sessions", QJsonArray{ QJsonObject{{"id", "roman-session"}, {"profileId", "default"}, {"planId", "plan"}, {"planName", "Roman Plan"}, {"startedAt", startedAt}, {"endedAt", endedAt}, {"totalWorkSeconds", 1200}, {"completedAll", true}, {"steps", QJsonArray{}}}, QJsonObject{{"id", "nastya-session"}, {"profileId", "nastya"}, {"planId", "nastya-plan"}, {"planName", "Nastya Plan"}, {"startedAt", startedAt}, {"endedAt", endedAt}, {"totalWorkSeconds", 1200}, {"completedAll", true}, {"steps", QJsonArray{}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); QVERIFY(writeJson(importPath, QJsonObject{ {"schemaVersion", 1}, {"source", "health-connect"}, {"targetProfileId", "default"}, {"days", QJsonArray{QJsonObject{ {"date", date}, {"steps", 999}, {"weightKg", 74.2}, {"weightRecordedAt", date + "T08:30:00Z"} }}}, {"heartRateSamples", QJsonArray{ QJsonObject{{"at", date + "T10:05:00Z"}, {"bpm", 110}}, QJsonObject{{"at", date + "T10:15:00Z"}, {"bpm", 130}} }} })); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("nastya")); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("default")); controller.importHealthConnectData(importPath); const QJsonObject saved = readJsonObject(statePath); QCOMPARE(saved.value("healthConnectOwnerProfileId").toString(), QString("default")); const QJsonArray wearableHistory = saved.value("wearableDailyHistory").toArray(); QCOMPARE(wearableHistory.size(), 2); QCOMPARE(firstObjectForProfile(wearableHistory, "default").value("steps").toInt(), 999); QCOMPARE(firstObjectForProfile(wearableHistory, "nastya").value("steps").toInt(), 200); const QJsonArray weights = saved.value("bodyweightHistory").toArray(); QCOMPARE(weights.size(), 2); QCOMPARE(firstObjectForProfile(weights, "default").value("weightKg").toDouble(), 74.2); QCOMPARE(firstObjectForProfile(weights, "nastya").value("weightKg").toDouble(), 60.0); const QJsonArray sessions = saved.value("sessions").toArray(); const QJsonObject romanSession = firstObjectForProfile(sessions, "default"); const QJsonObject nastyaSession = firstObjectForProfile(sessions, "nastya"); QCOMPARE(qRound(romanSession.value("heartRateAvg").toDouble()), 120); QCOMPARE(romanSession.value("heartRateSampleCount").toInt(), 2); QVERIFY(!nastyaSession.contains("heartRateAvg")); QVERIFY(!nastyaSession.contains("heartRateSampleCount")); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("60,0 кг")); controller.selectProfile("default"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("74,2 кг")); } void SessionControllerTests::rejectsHealthDataTargetedAtFormerDeviceOwner() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QString importPath = directory.filePath("health-connect-stale-owner.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "nastya"); root.insert("selectedPlanId", "nastya-plan"); root.insert("healthConnectOwnerProfileId", "default"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); QVERIFY(writeJson(importPath, QJsonObject{ {"schemaVersion", 1}, {"source", "health-connect"}, {"targetProfileId", "nastya"}, {"days", QJsonArray{QJsonObject{ {"date", QDate::currentDate().toString(Qt::ISODate)}, {"steps", 12345} }}} })); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("default")); const QJsonObject before = readJsonObject(statePath); controller.importHealthConnectData(importPath); QCOMPARE(readJsonObject(statePath), before); QVERIFY(controller.status().contains(QString::fromUtf8("устарел"), Qt::CaseInsensitive)); QVERIFY(controller.wearableDays().isEmpty()); } void SessionControllerTests::keepsDefaultWeightWhenHealthOwnerIsAnotherProfileAcrossRestart() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QString importPath = directory.filePath("health-connect-nastya-weight.json"); const QString date = QDate::currentDate().toString(Qt::ISODate); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Family"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject defaultPlan = plans.first().toObject(); defaultPlan.insert("profileId", "default"); plans.replace(0, defaultPlan); QJsonObject nastyaPlan = defaultPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("selectedPlanId", "plan"); root.insert("healthConnectOwnerProfileId", "nastya"); root.insert("currentBodyWeightKg", 82.0); root.remove("bodyweightHistory"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}, {"initialWeightKg", 60.0}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); QVERIFY(writeJson(importPath, QJsonObject{ {"schemaVersion", 1}, {"source", "health-connect"}, {"targetProfileId", "nastya"}, {"days", QJsonArray{QJsonObject{ {"date", date}, {"weightKg", 59.4}, {"weightRecordedAt", date + "T08:00:00Z"} }}} })); { SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.selectedProfileId(), QString("default")); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("nastya")); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("82,0 кг")); controller.importHealthConnectData(importPath); QCOMPARE(readJsonObject(statePath).value("currentBodyWeightKg").toDouble(), 82.0); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("82,0 кг")); QVERIFY(controller.nutritionWeightEntries().isEmpty()); controller.selectProfile("nastya"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("59,4 кг")); QCOMPARE(controller.nutritionWeightEntries().size(), 1); } SessionController restarted(directory.path(), catalogPath); QCOMPARE(restarted.selectedProfileId(), QString("nastya")); QCOMPARE(restarted.healthConnectOwnerProfileId(), QString("nastya")); QCOMPARE(restarted.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("59,4 кг")); QCOMPARE(readJsonObject(statePath).value("currentBodyWeightKg").toDouble(), 82.0); restarted.selectProfile("default"); QCOMPARE(restarted.nutritionSummary().value("currentWeightText").toString(), QString::fromUtf8("82,0 кг")); QVERIFY(restarted.nutritionWeightEntries().isEmpty()); QCOMPARE(readJsonObject(statePath).value("currentBodyWeightKg").toDouble(), 82.0); } void SessionControllerTests::configuresTrainingCycleAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QVERIFY(!controller.trainingCycle().value("active").toBool()); controller.configureTrainingCycle(26); QVERIFY(controller.trainingCycle().value("active").toBool()); QCOMPARE(controller.trainingCycle().value("durationWeeks").toInt(), 26); QCOMPARE(controller.trainingCycleWeeks().size(), 26); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); QCOMPARE(saved.value("trainingCycles").toArray().size(), 1); QCOMPARE(saved.value("sessions").toArray().size(), 2); file.close(); controller.clearTrainingCycle(); QVERIFY(!controller.trainingCycle().value("active").toBool()); } void SessionControllerTests::extendsCycleAndStagesReturnAfterMissedWorkouts() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); const QDate today = QDate::currentDate(); QJsonObject root = stateRoot(); root.insert("sessions", QJsonArray{}); QJsonObject plan = root.value("plans").toArray().first().toObject(); plan.insert("dayOfWeek", today.dayOfWeek()); plan.insert("steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"targetOverride", 10}, {"restSecondsOverride", 20}, {"sets", 2} }}); root.insert("plans", QJsonArray{plan}); root.insert("trainingCycles", QJsonArray{QJsonObject{ {"id", "missed-cycle"}, {"profileId", "default"}, {"startDate", today.addDays(-21).toString(Qt::ISODate)}, {"durationWeeks", 4} }}); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); const QVariantMap continuity = controller.trainingContinuity(); QCOMPARE(continuity.value("recentMissedCount").toInt(), 3); QCOMPARE(continuity.value("targetPercent").toInt(), 70); QCOMPARE(continuity.value("recommendedMaxRounds").toInt(), 1); QCOMPARE(continuity.value("title").toString(), QString::fromUtf8("Раскачка после паузы")); const QVariantMap cycle = controller.trainingCycle(); QVERIFY(cycle.value("extended").toBool()); QCOMPARE(cycle.value("missedCount").toInt(), 3); QVERIFY(cycle.value("extensionDays").toInt() > 0); const QDate originalEnd = QDate::fromString(cycle.value("originalEndDate").toString(), "dd.MM.yyyy"); const QDate adjustedEnd = QDate::fromString(cycle.value("endDate").toString(), "dd.MM.yyyy"); QVERIFY(adjustedEnd > originalEnd); controller.startWithRoundCount(4); QVERIFY(controller.active()); const QVariantMap firstStep = controller.workoutQueueSteps().first().toMap(); QCOMPARE(firstStep.value("target").toInt(), 6); QCOMPARE(firstStep.value("sets").toInt(), 1); QCOMPARE(firstStep.value("restSeconds").toInt(), 45); QCOMPARE(controller.currentWorkoutPosition().value("totalRounds").toInt(), 1); controller.skipCountdown(); controller.addRep(); controller.completeCurrent(); QVERIFY(controller.needsSave()); controller.saveFinishedSession("ok"); const QJsonObject saved = readJsonObject(statePath); QCOMPARE(saved.value("profileSettings").toObject().value("default").toObject() .value("returnRampSessionsRemaining").toInt(), 2); SessionController restarted(directory.path(), catalogPath); const QVariantMap nextStage = restarted.trainingContinuity(); QCOMPARE(nextStage.value("recentMissedCount").toInt(), 0); QCOMPARE(nextStage.value("rampSessionsRemaining").toInt(), 2); QCOMPARE(nextStage.value("targetPercent").toInt(), 85); QCOMPARE(nextStage.value("title").toString(), QString::fromUtf8("Раскачка продолжается")); } void SessionControllerTests::completesFourWeekCycleReviewAtomically() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("trainingCycles", QJsonArray{QJsonObject{ {"id", "review-cycle"}, {"profileId", "default"}, {"startDate", QDate::currentDate().addDays(-21).toString(Qt::ISODate)}, {"durationWeeks", 26} }}); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.trainingCycle().value("currentWeek").toInt(), 4); QVERIFY(controller.cycleReview().value("due").toBool()); QCOMPARE(controller.cycleReview().value("reviewWeek").toInt(), 4); controller.completeCycleReview("progress"); QVERIFY(!controller.cycleReview().value("due").toBool()); QCOMPARE(controller.cycleReview().value("lastDecision").toString(), QString("progress")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("trainingCycleReviews").toArray().size(), 1); const QJsonArray steps = saved.value("plans").toArray().first().toObject().value("steps").toArray(); QCOMPARE(steps.first().toObject().value("targetOverride").toInt(), 3); QCOMPARE(steps.last().toObject().value("targetOverride").toInt(), 7); controller.completeCycleReview("progress"); QFile unchanged(statePath); QVERIFY(unchanged.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(unchanged.readAll()).object(); QCOMPARE(saved.value("trainingCycleReviews").toArray().size(), 1); QCOMPARE(saved.value("plans").toArray().first().toObject().value("steps").toArray().first().toObject().value("targetOverride").toInt(), 3); } void SessionControllerTests::isolatesCycleReviewByProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); root.insert("trainingCycles", QJsonArray{QJsonObject{ {"id", "roman-cycle"}, {"profileId", "default"}, {"startDate", QDate::currentDate().addDays(-21).toString(Qt::ISODate)}, {"durationWeeks", 26} }}); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QVERIFY(controller.cycleReview().value("due").toBool()); const QJsonObject before = readJsonObject(statePath); const QJsonObject nastyaBefore = planWithId(before, QStringLiteral("nastya-plan")); const int romanTargetBefore = planWithId(before, QStringLiteral("plan")) .value("steps").toArray().first().toObject().value("targetOverride").toInt(); controller.completeCycleReview("progress"); const QJsonObject after = readJsonObject(statePath); const int romanTargetAfter = planWithId(after, QStringLiteral("plan")) .value("steps").toArray().first().toObject().value("targetOverride").toInt(); QCOMPARE(romanTargetAfter, romanTargetBefore + 1); QCOMPARE(planWithId(after, QStringLiteral("nastya-plan")), nastyaBefore); QCOMPARE(after.value("trainingCycleReviews").toArray().last().toObject().value("profileId").toString(), QString("default")); } void SessionControllerTests::appliesCurrentCycleWeekAtWorkoutStart() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); QJsonArray steps = plan.value("steps").toArray(); QJsonObject first = steps.first().toObject(); first.insert("targetOverride", 10); first.insert("restSecondsOverride", 20); first.insert("sets", 2); steps.replace(0, first); plan.insert("steps", steps); plans.replace(0, plan); root.insert("plans", plans); const QDate today = QDate::currentDate(); const QDate monday = today.addDays(1 - today.dayOfWeek()); root.insert("trainingCycles", QJsonArray{QJsonObject{ {"id", "test-cycle"}, {"profileId", "default"}, {"startDate", monday.toString(Qt::ISODate)}, {"durationWeeks", 26} }}); root.insert("wearableDailyHistory", QJsonArray{QJsonObject{ {"profileId", "default"}, {"date", today.toString(Qt::ISODate)}, {"sleepMinutes", 300} }}); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.start(); QVERIFY(controller.active()); const QVariantList queue = controller.workoutQueueSteps(); QCOMPARE(queue.first().toMap().value("target").toInt(), 7); QCOMPARE(queue.first().toMap().value("sets").toInt(), 1); QCOMPARE(queue.first().toMap().value("restSeconds").toInt(), 40); controller.abortActiveWorkout(); } void SessionControllerTests::appliesRecentRecoveryCheckInAtWorkoutStart() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); QJsonArray steps = plan.value("steps").toArray(); QJsonObject first = steps.first().toObject(); first.insert("targetOverride", 10); first.insert("restSecondsOverride", 20); first.insert("sets", 2); steps.replace(0, first); plan.insert("steps", steps); plans.replace(0, plan); root.insert("plans", plans); root.insert("recoveryCheckInHistory", QJsonArray{QJsonObject{ {"profileId", "default"}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)}, {"energyScore", 3}, {"sleepScore", 4}, {"jointScore", 2} }}); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); const QVariantMap recommendation = controller.preWorkoutRecommendation(); QCOMPARE(recommendation.value("title").toString(), QString("Восстановительная нагрузка")); QVERIFY(recommendation.value("detail").toString().contains("суставы 2/5")); QCOMPARE(recommendation.value("recoveryPercent").toInt(), 80); controller.start(); QVERIFY(controller.active()); const QVariantMap firstStep = controller.workoutQueueSteps().first().toMap(); QCOMPARE(firstStep.value("target").toInt(), 8); QCOMPARE(firstStep.value("sets").toInt(), 1); QCOMPARE(firstStep.value("restSeconds").toInt(), 35); controller.abortActiveWorkout(); } void SessionControllerTests::appliesHeartRateLoadAtWorkoutStart() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); QJsonArray plans = root.value("plans").toArray(); QJsonObject plan = plans.first().toObject(); QJsonArray steps = plan.value("steps").toArray(); QJsonObject first = steps.first().toObject(); first.insert("targetOverride", 10); first.insert("restSecondsOverride", 20); first.insert("sets", 2); steps.replace(0, first); plan.insert("steps", steps); plans.replace(0, plan); root.insert("plans", plans); const QDate today = QDate::currentDate(); const auto session = [](const QDate &date, int workSeconds, double heartRateAvg) { return QJsonObject{ {"planId", "plan"}, {"planName", "Plan"}, {"endedAt", QDateTime(date, QTime(18, 0)).toString(Qt::ISODate)}, {"totalWorkSeconds", workSeconds}, {"completedAll", true}, {"heartRateAvg", heartRateAvg}, {"heartRateMin", qRound(heartRateAvg) - 10}, {"heartRateMax", qRound(heartRateAvg) + 10}, {"heartRateSampleCount", 3}, {"steps", QJsonArray{QJsonObject{ {"exerciseId", "rep"}, {"exerciseName", "Rep"}, {"actualValue", 10}, {"completed", true} }}} }; }; root.insert("sessions", QJsonArray{ session(today.addDays(-8), 2400, 100.0), session(today.addDays(-3), 2400, 100.0), session(today.addDays(-1), 2400, 150.0) }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); const QVariantMap recommendation = controller.preWorkoutRecommendation(); QCOMPARE(recommendation.value("title").toString(), QString("Умеренная нагрузка")); QVERIFY(recommendation.value("detail").toString().contains("Пульсовая нагрузка выросла")); QCOMPARE(recommendation.value("loadPercent").toInt(), 90); controller.start(); QVERIFY(controller.active()); const QVariantMap firstStep = controller.workoutQueueSteps().first().toMap(); QCOMPARE(firstStep.value("target").toInt(), 9); QCOMPARE(firstStep.value("sets").toInt(), 2); QCOMPARE(firstStep.value("restSeconds").toInt(), 30); controller.abortActiveWorkout(); } void SessionControllerTests::separatesNutritionAndRecoveryByProfile() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.logBodyWeight("80"); controller.logBodyMeasurements("80", "120", "100", "35"); controller.markNutritionAdherence("good"); controller.logPhotoProgress("C:/progress/default.jpg"); controller.logRecoveryCheckIn(5, 4, 3, "default"); QCOMPARE(controller.nutritionSummary().value("weightEntryCount").toInt(), 1); QCOMPARE(controller.nutritionSummary().value("measurementEntryCount").toInt(), 1); QCOMPARE(controller.nutritionSummary().value("photoEntryCount").toInt(), 1); QCOMPARE(controller.nutritionSummary().value("adherenceEntryCount").toInt(), 1); QCOMPARE(controller.recoveryCheckIns().size(), 1); controller.createProfile("Mom"); QCOMPARE(controller.selectedProfileId(), QString("mom")); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("75,0 кг")); QCOMPARE(controller.nutritionSummary().value("weightEntryCount").toInt(), 0); QCOMPARE(controller.nutritionMeasurementEntries().size(), 0); QCOMPARE(controller.nutritionAdherenceDays().size(), 0); QCOMPARE(controller.nutritionPhotoEntries().size(), 0); QCOMPARE(controller.recoveryCheckIns().size(), 0); controller.logBodyWeight("65"); controller.logBodyMeasurements("70", "105", "90", "30"); controller.markNutritionAdherence("partial"); controller.logPhotoProgress("C:/progress/mom.jpg"); controller.logRecoveryCheckIn(3, 5, 4, "mom"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("65,0 кг")); QCOMPARE(controller.nutritionSummary().value("weightEntryCount").toInt(), 1); QCOMPARE(controller.nutritionAdherenceDays().first().toMap().value("statusText").toString(), QString("частично")); QCOMPARE(controller.recoveryCheckIns().first().toMap().value("note").toString(), QString("mom")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); file.close(); QCOMPARE(saved.value("bodyweightHistory").toArray().first().toObject().value("profileId").toString(), QString("mom")); QCOMPARE(saved.value("bodyMeasurementHistory").toArray().first().toObject().value("profileId").toString(), QString("mom")); QCOMPARE(saved.value("nutritionAdherenceHistory").toArray().first().toObject().value("profileId").toString(), QString("mom")); QCOMPARE(saved.value("photoProgressHistory").toArray().first().toObject().value("profileId").toString(), QString("mom")); QCOMPARE(saved.value("recoveryCheckInHistory").toArray().first().toObject().value("profileId").toString(), QString("mom")); controller.selectProfile("default"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("80,0 кг")); QCOMPARE(controller.nutritionSummary().value("weightEntryCount").toInt(), 1); QCOMPARE(controller.nutritionAdherenceDays().first().toMap().value("statusText").toString(), QString("выполнено")); QCOMPARE(controller.recoveryCheckIns().first().toMap().value("note").toString(), QString("default")); } void SessionControllerTests::exportsLocalData() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); const QString exportPath = directory.filePath("export.json"); controller.exportLocalData(QUrl::fromLocalFile(exportPath).toString()); QVERIFY(QFile::exists(exportPath)); QFile exported(exportPath); QVERIFY(exported.open(QIODevice::ReadOnly)); const QJsonObject root = QJsonDocument::fromJson(exported.readAll()).object(); QCOMPARE(root.value("schemaVersion").toInt(), 1); QCOMPARE(root.value("sessions").toArray().size(), 2); QVERIFY(controller.status().contains("экспорт")); } void SessionControllerTests::createsAndRestoresLocalBackup() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("currentBodyWeightKg", 75.0); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.createLocalBackup(); QCOMPARE(QDir(directory.filePath("backups")).entryList({"native-state-*.json"}, QDir::Files).size(), 1); controller.logBodyWeight("82,5"); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("82,5 кг")); controller.restoreLatestBackup(); QCOMPARE(controller.nutritionSummary().value("currentWeightText").toString(), QString("75,0 кг")); } void SessionControllerTests::restoresHealthConnectOwnerFromBackupOnDesktop() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QJsonObject root = stateRoot(); root.insert("profiles", QJsonArray{ QJsonObject{{"id", "default"}, {"name", "Roman"}}, QJsonObject{{"id", "nastya"}, {"name", "Nastya"}} }); QJsonArray plans = root.value("plans").toArray(); QJsonObject romanPlan = plans.first().toObject(); romanPlan.insert("profileId", "default"); plans.replace(0, romanPlan); QJsonObject nastyaPlan = romanPlan; nastyaPlan.insert("id", "nastya-plan"); nastyaPlan.insert("name", "Nastya Plan"); nastyaPlan.insert("profileId", "nastya"); plans.append(nastyaPlan); root.insert("plans", plans); root.insert("selectedProfileId", "default"); root.insert("selectedPlanId", "plan"); root.insert("healthConnectOwnerProfileId", "default"); root.insert("profileSettings", QJsonObject{ {"default", QJsonObject{{"selectedPlanId", "plan"}}}, {"nastya", QJsonObject{{"selectedPlanId", "nastya-plan"}}} }); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("default")); controller.createLocalBackup(); QCOMPARE(QDir(directory.filePath("backups")).entryList({"native-state-*.json"}, QDir::Files).size(), 1); controller.setHealthConnectOwnerProfile("nastya"); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("nastya")); QCOMPARE(readJsonObject(statePath).value("healthConnectOwnerProfileId").toString(), QString("nastya")); controller.restoreLatestBackup(); QCOMPARE(controller.healthConnectOwnerProfileId(), QString("default")); QCOMPARE(readJsonObject(statePath).value("healthConnectOwnerProfileId").toString(), QString("default")); } void SessionControllerTests::doesNotRestoreBackupWhileFinishedSessionNeedsDecision() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); controller.createLocalBackup(); QCOMPARE(QDir(directory.filePath("backups")).entryList({"native-state-*.json"}, QDir::Files).size(), 1); finishSession(controller); QVERIFY(controller.needsSave()); QVERIFY(!controller.active()); const QJsonObject beforeRestoreAttempt = readJsonObject(statePath); QVERIFY(beforeRestoreAttempt.value("pendingDraft").isNull()); QVERIFY(beforeRestoreAttempt.value("pendingResult").isObject()); controller.restoreLatestBackup(); QVERIFY(controller.needsSave()); QCOMPARE(readJsonObject(statePath), beforeRestoreAttempt); QVERIFY(controller.status().contains(QString::fromUtf8("незавершённой тренировки"), Qt::CaseInsensitive)); controller.saveFinishedSession("ok", false); QVERIFY(!controller.needsSave()); QCOMPARE(readJsonObject(statePath).value("sessions").toArray().size(), 3); } void SessionControllerTests::persistsHealthSourcePreferences() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.healthActivitySource(), QString("com.xiaomi.wearable")); QCOMPARE(controller.healthWeightSource(), QString("com.google.android.apps.fitness")); controller.setHealthSourcePreferences("auto", "com.xiaomi.wearable"); QCOMPARE(controller.healthActivitySource(), QString("auto")); QCOMPARE(controller.healthWeightSource(), QString("com.xiaomi.wearable")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); QCOMPARE(saved.value("healthActivitySource").toString(), QString("auto")); QCOMPARE(saved.value("healthWeightSource").toString(), QString("com.xiaomi.wearable")); } void SessionControllerTests::persistsWorkoutReminderSettings() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); const QString catalogPath = directory.filePath("exercises.json"); QVERIFY(writeJson(statePath, stateRoot())); QVERIFY(writeJson(catalogPath, catalogRoot())); SessionController controller(directory.path(), catalogPath); QCOMPARE(controller.workoutReminderEnabled(), true); QCOMPARE(controller.workoutReminderTime(), QString("18:00")); controller.setWorkoutReminderSettings(false, "07:35"); QCOMPARE(controller.workoutReminderEnabled(), false); QCOMPARE(controller.workoutReminderTime(), QString("07:35")); controller.setWorkoutReminderSettings(true, "28:90"); QCOMPARE(controller.workoutReminderEnabled(), false); QCOMPARE(controller.workoutReminderTime(), QString("07:35")); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); QCOMPARE(saved.value("workoutReminderEnabled").toBool(), false); QCOMPARE(saved.value("workoutReminderTime").toString(), QString("07:35")); } void SessionControllerTests::clearsPersonalDataButKeepsPlans() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString statePath = directory.filePath("native-state.json"); QJsonObject root = stateRoot(); root.insert("currentBodyWeightKg", 88.0); root.insert("bodyweightHistory", QJsonArray{QJsonObject{{"weightKg", 88.0}}}); root.insert("bodyMeasurementHistory", QJsonArray{QJsonObject{{"waistCm", 90.0}}}); root.insert("nutritionAdherenceHistory", QJsonArray{QJsonObject{{"statusId", "good"}}}); root.insert("photoProgressHistory", QJsonArray{QJsonObject{{"imagePath", "C:/progress/photo.jpg"}}}); root.insert("recoveryCheckInHistory", QJsonArray{QJsonObject{{"energyScore", 5}}}); root.insert("nutritionPresetId", "cheap"); QVERIFY(writeJson(statePath, root)); QVERIFY(writeJson(directory.filePath("exercises.json"), catalogRoot())); SessionController controller(directory.path(), directory.filePath("exercises.json")); controller.clearPersonalData(); QCOMPARE(controller.totalSessions(), 0); QCOMPARE(controller.plans().size(), 1); QCOMPARE(controller.nutritionWeightEntries().size(), 0); QCOMPARE(controller.nutritionMeasurementEntries().size(), 0); QCOMPARE(controller.nutritionAdherenceDays().size(), 0); QCOMPARE(controller.nutritionPhotoEntries().size(), 0); QCOMPARE(controller.recoveryCheckIns().size(), 0); QVERIFY(QFile::exists(directory.filePath("native-state.backup.json"))); QFile file(statePath); QVERIFY(file.open(QIODevice::ReadOnly)); const QJsonObject saved = QJsonDocument::fromJson(file.readAll()).object(); QCOMPARE(saved.value("sessions").toArray().size(), 0); QCOMPARE(saved.value("plans").toArray().size(), 1); QCOMPARE(saved.value("photoProgressHistory").toArray().size(), 0); QCOMPARE(saved.value("bodyweightHistory").toArray().size(), 0); QCOMPARE(saved.value("bodyMeasurementHistory").toArray().size(), 0); QCOMPARE(saved.value("nutritionAdherenceHistory").toArray().size(), 0); QCOMPARE(saved.value("recoveryCheckInHistory").toArray().size(), 0); QVERIFY(!saved.contains("currentBodyWeightKg")); QVERIFY(saved.value("pendingDraft").isNull()); } QTEST_MAIN(SessionControllerTests) #include "session_controller_tests.moc"