Add repository round-trip verification

This commit is contained in:
axiomlogicnexus 2026-04-27 03:36:28 +02:00
parent 9cd39e53e8
commit 2ecbe3460d
7 changed files with 352 additions and 0 deletions

View file

@ -3,11 +3,20 @@
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h"
#include "HAL/FileManager.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
namespace HyperTwistTrainingPersistenceLibraryInternal
{
template <typename TStruct>
FString SerializeStructToJson(const TStruct& Value)
{
FString Json;
FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0);
return Json;
}
FString SanitizePathToken(const FString& Value)
{
FString Sanitized = Value;
@ -31,6 +40,22 @@ namespace HyperTwistTrainingPersistenceLibraryInternal
: RepositoryPath;
}
FString ResolveVerificationPath(const FString& RepositoryPath)
{
if (RepositoryPath.IsEmpty())
{
return FPaths::Combine(
FPaths::ProjectSavedDir(),
TEXT("HyperTwist"),
TEXT("training-repository-roundtrip.json")
);
}
return FPaths::IsRelative(RepositoryPath)
? FPaths::Combine(FPaths::ProjectSavedDir(), RepositoryPath)
: RepositoryPath;
}
FString ResolveTimerExportPath(
const FString& UserId,
const FString& DeckId,
@ -55,6 +80,14 @@ namespace HyperTwistTrainingPersistenceLibraryInternal
? FPaths::Combine(FPaths::ProjectSavedDir(), ExportPath)
: ExportPath;
}
void AppendVerificationNote(TArray<FString>& Notes, const FString& Note)
{
if (!Note.IsEmpty())
{
Notes.Add(Note);
}
}
}
FString UHyperTwistTrainingPersistenceLibrary::GetDefaultTrainingRepositoryPath()
@ -67,6 +100,16 @@ FString UHyperTwistTrainingPersistenceLibrary::ResolveTrainingRepositoryPath(con
return HyperTwistTrainingPersistenceLibraryInternal::ResolvePath(RepositoryPath);
}
FString UHyperTwistTrainingPersistenceLibrary::GetDefaultTrainingRepositoryVerificationPath()
{
return HyperTwistTrainingPersistenceLibraryInternal::ResolveVerificationPath(FString());
}
FString UHyperTwistTrainingPersistenceLibrary::ResolveTrainingRepositoryVerificationPath(const FString& RepositoryPath)
{
return HyperTwistTrainingPersistenceLibraryInternal::ResolveVerificationPath(RepositoryPath);
}
FString UHyperTwistTrainingPersistenceLibrary::GetDefaultTrainingTimerExportPath(const FString& UserId, const FString& DeckId)
{
return HyperTwistTrainingPersistenceLibraryInternal::ResolveTimerExportPath(UserId, DeckId, FString());
@ -149,6 +192,146 @@ bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingRepositoryStateFromFile(
return true;
}
FHyperTwistTrainingRepositoryRoundTripVerification
UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& RepositoryPath
)
{
FHyperTwistTrainingRepositoryRoundTripVerification Verification;
Verification.ResolvedPath = ResolveTrainingRepositoryVerificationPath(RepositoryPath);
const FHyperTwistTrainingRepositoryState ExpectedRepositoryState =
UHyperTwistTrainingRepositoryLibrary::RepairRepositoryState(RepositoryState);
Verification.ExpectedRunRecordCount = ExpectedRepositoryState.RunRecords.Num();
Verification.ExpectedLearnerStateCount = ExpectedRepositoryState.LearnerStates.Num();
Verification.ExpectedReviewPlanCount = ExpectedRepositoryState.ReviewPlans.Num();
Verification.ExpectedStoredSessionTemplateCount = ExpectedRepositoryState.StoredSessionTemplates.Num();
Verification.ExpectedMethodDrillFavoriteCount = ExpectedRepositoryState.StoredMethodDrillFavorites.Num();
Verification.ExpectedMethodDrillBatchCount = ExpectedRepositoryState.StoredMethodDrillBatches.Num();
Verification.ExpectedCoachActionPlanCount = ExpectedRepositoryState.StoredCoachActionPlans.Num();
Verification.ExpectedCoachSessionQueueCount = ExpectedRepositoryState.StoredCoachSessionQueues.Num();
Verification.ExpectedCoachSessionQueueHistoryCount =
ExpectedRepositoryState.StoredCoachSessionQueueHistory.Num();
Verification.ExpectedIntegrityReport =
UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(ExpectedRepositoryState);
FString SavedPath;
Verification.bSaveSucceeded = SaveTrainingRepositoryStateToFile(
ExpectedRepositoryState,
Verification.ResolvedPath,
SavedPath
);
Verification.ResolvedPath = SavedPath;
if (!Verification.bSaveSucceeded)
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-save-failed")
);
return Verification;
}
FHyperTwistTrainingRepositoryState LoadedRepositoryState;
FString LoadedPath;
Verification.bLoadSucceeded = LoadTrainingRepositoryStateFromFile(
Verification.ResolvedPath,
LoadedRepositoryState,
LoadedPath
);
Verification.ResolvedPath = LoadedPath;
if (!Verification.bLoadSucceeded)
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-load-failed")
);
return Verification;
}
Verification.LoadedRunRecordCount = LoadedRepositoryState.RunRecords.Num();
Verification.LoadedLearnerStateCount = LoadedRepositoryState.LearnerStates.Num();
Verification.LoadedReviewPlanCount = LoadedRepositoryState.ReviewPlans.Num();
Verification.LoadedStoredSessionTemplateCount = LoadedRepositoryState.StoredSessionTemplates.Num();
Verification.LoadedMethodDrillFavoriteCount = LoadedRepositoryState.StoredMethodDrillFavorites.Num();
Verification.LoadedMethodDrillBatchCount = LoadedRepositoryState.StoredMethodDrillBatches.Num();
Verification.LoadedCoachActionPlanCount = LoadedRepositoryState.StoredCoachActionPlans.Num();
Verification.LoadedCoachSessionQueueCount = LoadedRepositoryState.StoredCoachSessionQueues.Num();
Verification.LoadedCoachSessionQueueHistoryCount =
LoadedRepositoryState.StoredCoachSessionQueueHistory.Num();
Verification.LoadedIntegrityReport =
UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(LoadedRepositoryState);
Verification.bRepositoryCountsMatch =
Verification.ExpectedRunRecordCount == Verification.LoadedRunRecordCount
&& Verification.ExpectedLearnerStateCount == Verification.LoadedLearnerStateCount
&& Verification.ExpectedReviewPlanCount == Verification.LoadedReviewPlanCount
&& Verification.ExpectedStoredSessionTemplateCount == Verification.LoadedStoredSessionTemplateCount
&& Verification.ExpectedMethodDrillFavoriteCount == Verification.LoadedMethodDrillFavoriteCount
&& Verification.ExpectedMethodDrillBatchCount == Verification.LoadedMethodDrillBatchCount
&& Verification.ExpectedCoachActionPlanCount == Verification.LoadedCoachActionPlanCount
&& Verification.ExpectedCoachSessionQueueCount == Verification.LoadedCoachSessionQueueCount
&& Verification.ExpectedCoachSessionQueueHistoryCount
== Verification.LoadedCoachSessionQueueHistoryCount;
if (!Verification.bRepositoryCountsMatch)
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-count-mismatch")
);
}
const FString ExpectedRepositoryJson =
UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson(ExpectedRepositoryState);
const FString LoadedRepositoryJson =
UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson(LoadedRepositoryState);
Verification.bSerializedStateMatch =
!ExpectedRepositoryJson.IsEmpty()
&& !LoadedRepositoryJson.IsEmpty()
&& ExpectedRepositoryJson == LoadedRepositoryJson;
if (!Verification.bSerializedStateMatch)
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-serialized-state-mismatch")
);
}
const FString ExpectedIntegrityJson =
HyperTwistTrainingPersistenceLibraryInternal::SerializeStructToJson(Verification.ExpectedIntegrityReport);
const FString LoadedIntegrityJson =
HyperTwistTrainingPersistenceLibraryInternal::SerializeStructToJson(Verification.LoadedIntegrityReport);
Verification.bIntegrityReportMatch =
!ExpectedIntegrityJson.IsEmpty()
&& !LoadedIntegrityJson.IsEmpty()
&& ExpectedIntegrityJson == LoadedIntegrityJson;
if (!Verification.bIntegrityReportMatch)
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-integrity-report-mismatch")
);
}
if (Verification.LoadedIntegrityReport.HasIntegrityIssues())
{
HyperTwistTrainingPersistenceLibraryInternal::AppendVerificationNote(
Verification.VerificationNotes,
TEXT("roundtrip-loaded-state-still-has-integrity-issues")
);
}
Verification.bDidRoundTripPass =
Verification.bSaveSucceeded
&& Verification.bLoadSucceeded
&& Verification.bRepositoryCountsMatch
&& Verification.bSerializedStateMatch
&& Verification.bIntegrityReportMatch
&& !Verification.LoadedIntegrityReport.HasIntegrityIssues();
return Verification;
}
bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingTimerExportPacketFromFile(
const FString& UserId,
const FString& DeckId,

View file

@ -114,6 +114,19 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRuntimeLibrary::
return FHyperTwistTrainingRepositoryIntegrityReport();
}
FHyperTwistTrainingRepositoryRoundTripVerification
UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingRepositoryRoundTripVerification(
UObject* WorldContextObject
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject))
{
return TrainingSubsystem->GetActiveRepositoryRoundTripVerification();
}
return FHyperTwistTrainingRepositoryRoundTripVerification();
}
FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject))
@ -811,6 +824,19 @@ bool UHyperTwistTrainingRuntimeLibrary::LoadTrainingRepositoryStateFromDefaultPa
return false;
}
FHyperTwistTrainingRepositoryRoundTripVerification
UHyperTwistTrainingRuntimeLibrary::VerifyTrainingRepositoryRoundTripAtDefaultPath(
UObject* WorldContextObject
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject))
{
return TrainingSubsystem->VerifyTrainingRepositoryRoundTripAtDefaultPath();
}
return FHyperTwistTrainingRepositoryRoundTripVerification();
}
bool UHyperTwistTrainingRuntimeLibrary::SaveTrainingTimerExportPacketToDefaultPath(
UObject* WorldContextObject,
const FString& UserId,

View file

@ -53,6 +53,12 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingSubsystem::GetAc
return ActiveRepositoryIntegrityReport;
}
FHyperTwistTrainingRepositoryRoundTripVerification
UHyperTwistTrainingSubsystem::GetActiveRepositoryRoundTripVerification() const
{
return ActiveRepositoryRoundTripVerification;
}
FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingSubsystem::GetActiveDeckHistorySummary() const
{
return ActiveDeckHistorySummary;
@ -574,6 +580,17 @@ bool UHyperTwistTrainingSubsystem::LoadTrainingRepositoryStateFromDefaultPath(FS
return true;
}
FHyperTwistTrainingRepositoryRoundTripVerification
UHyperTwistTrainingSubsystem::VerifyTrainingRepositoryRoundTripAtDefaultPath()
{
ActiveRepositoryRoundTripVerification =
UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
TrainingRepositoryState,
FString()
);
return ActiveRepositoryRoundTripVerification;
}
bool UHyperTwistTrainingSubsystem::SaveTrainingTimerExportPacketToDefaultPath(
const FString& UserId,
const FString& DeckId,

View file

@ -17,6 +17,12 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence")
static FString ResolveTrainingRepositoryPath(const FString& RepositoryPath);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence")
static FString GetDefaultTrainingRepositoryVerificationPath();
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence")
static FString ResolveTrainingRepositoryVerificationPath(const FString& RepositoryPath);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence")
static FString GetDefaultTrainingTimerExportPath(const FString& UserId, const FString& DeckId);
@ -48,6 +54,12 @@ public:
FString& OutResolvedPath
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Persistence")
static FHyperTwistTrainingRepositoryRoundTripVerification VerifyTrainingRepositoryStateRoundTrip(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& RepositoryPath
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Persistence")
static bool LoadTrainingTimerExportPacketFromFile(
const FString& UserId,

View file

@ -45,6 +45,11 @@ public:
UObject* WorldContextObject
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject"))
static FHyperTwistTrainingRepositoryRoundTripVerification GetActiveTrainingRepositoryRoundTripVerification(
UObject* WorldContextObject
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject"))
static FHyperTwistTrainingDeckHistorySummary GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject);
@ -317,6 +322,11 @@ public:
FString& OutResolvedPath
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject"))
static FHyperTwistTrainingRepositoryRoundTripVerification VerifyTrainingRepositoryRoundTripAtDefaultPath(
UObject* WorldContextObject
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject"))
static bool SaveTrainingTimerExportPacketToDefaultPath(
UObject* WorldContextObject,

View file

@ -38,6 +38,9 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training")
FHyperTwistTrainingRepositoryIntegrityReport GetActiveRepositoryIntegrityReport() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training")
FHyperTwistTrainingRepositoryRoundTripVerification GetActiveRepositoryRoundTripVerification() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training")
FHyperTwistTrainingDeckHistorySummary GetActiveDeckHistorySummary() const;
@ -260,6 +263,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training")
bool LoadTrainingRepositoryStateFromDefaultPath(FString& OutResolvedPath);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training")
FHyperTwistTrainingRepositoryRoundTripVerification VerifyTrainingRepositoryRoundTripAtDefaultPath();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training")
bool SaveTrainingTimerExportPacketToDefaultPath(
const FString& UserId,
@ -423,6 +429,9 @@ private:
UPROPERTY()
FHyperTwistTrainingRepositoryIntegrityReport ActiveRepositoryIntegrityReport;
UPROPERTY()
FHyperTwistTrainingRepositoryRoundTripVerification ActiveRepositoryRoundTripVerification;
UPROPERTY()
FHyperTwistTrainingDeckHistorySummary ActiveDeckHistorySummary;

View file

@ -4087,3 +4087,98 @@ struct FHyperTwistTrainingRepositoryIntegrityReport
|| MissingCoachSessionQueueHistorySourceSessionCount > 0;
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingRepositoryRoundTripVerification
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ResolvedPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bSaveSucceeded = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bLoadSucceeded = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bRepositoryCountsMatch = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bSerializedStateMatch = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bIntegrityReportMatch = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bDidRoundTripPass = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedRunRecordCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedRunRecordCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedLearnerStateCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedLearnerStateCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedReviewPlanCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedReviewPlanCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedStoredSessionTemplateCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedStoredSessionTemplateCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedMethodDrillFavoriteCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedMethodDrillFavoriteCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedMethodDrillBatchCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedMethodDrillBatchCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedCoachActionPlanCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedCoachActionPlanCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedCoachSessionQueueCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedCoachSessionQueueCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedCoachSessionQueueHistoryCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedCoachSessionQueueHistoryCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FHyperTwistTrainingRepositoryIntegrityReport ExpectedIntegrityReport;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FHyperTwistTrainingRepositoryIntegrityReport LoadedIntegrityReport;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FString> VerificationNotes;
bool IsStructurallyValid() const
{
return !ResolvedPath.IsEmpty() && bSaveSucceeded && bLoadSucceeded;
}
};