/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
tests/session_controller_tests.cpp
1 399 строк
64 KB
DemienMedich
Separate nutrition data by profile
21 июн 2026, 01:04
21 июн 2026, 01:04
f254a45
Код
Авторство
О чём код?
#include "session_controller.h" #include <QDate> #include <QDateTime> #include <QDir> #include <QFile> #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}}, 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"} } }} }; } void finishSession(SessionController &controller) { controller.start(); controller.skipCountdown(); controller.completeCurrent(); controller.skipCountdown(); controller.skipCountdown(); controller.advanceOneSecond(); controller.advanceOneSecond(); } } // namespace class SessionControllerTests : public QObject { Q_OBJECT private slots: void savesOnlyAfterConfirmation(); void exposesPendingResultAndAdaptationPreview(); void appliesEasyAdaptationWhenSaving(); void appliesHardAdaptationWhenSaving(); void skipsCurrentExerciseAndSavesIncompleteStep(); void discardLeavesStateUntouched(); void abortsActiveWorkoutAndClearsDraft(); void restoresPersistedDraft(); void tracksCurrentStepIndex(); void persistsCurrentLoadNoteInDraftAndSession(); void exposesDefaultProfileForOldState(); void createsAndSelectsProfilesAtomically(); void separatesWorkoutHistoryByProfile(); void selectsAndPersistsPlan(); void duplicatesSelectedPlanAtomically(); void deletesSelectedPlanAtomically(); void renamesSelectedPlanAtomically(); void updatesSelectedPlanStepAtomically(); void appendsSelectedPlanStepAtomically(); void duplicatesSelectedPlanStepAtomically(); void removesSelectedPlanStepAtomically(); void movesSelectedPlanStepAtomically(); void exposesSelectedPlanSteps(); void exposesExerciseFrameUrls(); void exposesProgressSummary(); void exposesPreWorkoutRecommendation(); void exposesLatestSessionSteps(); void filtersExerciseLibrary(); void exposesNutritionSummary(); void exposesApplicationInfo(); void logsBodyWeightAtomically(); void selectsNutritionPresetAtomically(); void logsBodyMeasurementsAtomically(); void marksNutritionAdherenceAtomically(); void logsPhotoProgressAtomically(); void logsRecoveryCheckInAtomically(); void separatesNutritionAndRecoveryByProfile(); void exportsLocalData(); void clearsPersonalDataButKeepsPlans(); }; 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")); QCOMPARE(details.value("workMinutes").toInt(), 1); QCOMPARE(details.value("stepCount").toInt(), 2); QCOMPARE(details.value("completedStepCount").toInt(), 2); QVERIFY(details.value("completedAll").toBool()); QVERIFY(controller.adaptationPreview("Легко").contains("+1")); QVERIFY(controller.adaptationPreview("Тяжело").contains("отдых")); } 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(), true); } 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("3")); } 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::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.setCurrentLoadNote(" slow tempo "); QCOMPARE(first.currentLoadNote(), QString("slow tempo")); 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")); SessionController restored(directory.path(), catalogPath); restored.restoreDraft(); QCOMPARE(restored.currentLoadNote(), QString("slow tempo")); restored.completeCurrent(); restored.skipCountdown(); restored.skipCountdown(); restored.advanceOneSecond(); restored.advanceOneSecond(); QVERIFY(restored.needsSave()); restored.saveFinishedSession("ok"); QFile sessionFile(statePath); QVERIFY(sessionFile.open(QIODevice::ReadOnly)); saved = QJsonDocument::fromJson(sessionFile.readAll()).object(); const QJsonArray sessions = saved.value("sessions").toArray(); QCOMPARE(sessions.last().toObject().value("steps").toArray().first().toObject().value("loadNote").toString(), QString("slow tempo")); } 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::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); 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::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)); QVERIFY(writeFile(QDir(frameDirectory).filePath("rep_1.png"), "frame1")); QVERIFY(writeFile(QDir(frameDirectory).filePath("rep_2.png"), "frame2")); 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(), 2); QCOMPARE(controller.selectedPlanSteps().first().toMap().value("frameUrls").toList().size(), 2); QCOMPARE(controller.currentExerciseFrameUrls().size(), 0); controller.start(); QCOMPARE(controller.currentExerciseFrameUrls().size(), 2); 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::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::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::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"