diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 150c49d..efbc89b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -17568,7 +17568,11 @@ bool UHyperTwistCoachDashboardWidget::StartLiveAttemptTimer() { if (UHyperTwistTrainingSubsystem* TrainingSubsystem = UHyperTwistTrainingRuntimeLibrary::GetTrainingSubsystem(this)) { - const bool bStarted = TrainingSubsystem->StartActiveLiveTimer(); + bool bStarted = TrainingSubsystem->StartActiveLiveTimerForCurrentDrillCase(); + if (!bStarted) + { + bStarted = TrainingSubsystem->StartActiveLiveTimer(); + } RefreshLiveAttemptClock(); UpdateDashboardPresentation(); return bStarted; @@ -17606,7 +17610,17 @@ FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitLiveTime { if (UHyperTwistTrainingSubsystem* TrainingSubsystem = UHyperTwistTrainingRuntimeLibrary::GetTrainingSubsystem(this)) { - const FHyperTwistTrainingRunStepResult StepResult = TrainingSubsystem->SubmitActiveLiveTimedAttempt(Result); + FHyperTwistTrainingRunStepResult StepResult; + const FHyperTwistTrainingDrillTimerCompletionResult CompletionResult = + TrainingSubsystem->SubmitAndAdvanceActiveDrillTimerAttempt(Result); + if (CompletionResult.bSubmitSucceeded) + { + StepResult = CompletionResult.SubmitStepResult; + } + else + { + StepResult = TrainingSubsystem->SubmitActiveLiveTimedAttempt(Result); + } LastStepResult = StepResult; RefreshTrainingState(); RefreshLiveAttemptClock(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index b88aaec..088c3bf 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -5067,6 +5067,21 @@ bool UHyperTwistTrainingSubsystem::StartActiveLiveTimer() return HasActiveLiveTimer(); } +bool UHyperTwistTrainingSubsystem::StartActiveLiveTimerForCurrentDrillCase() +{ + if (!HasActiveMethodDrillRun() + || !HasActiveRun() + || !ActiveMethodDrillRunState.CurrentCase.IsStructurallyValid() + || !ActiveRunState.Session.IsStructurallyValid() + || !ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() + || ActiveMethodDrillRunState.CurrentCase.CaseId != ActiveRunState.CurrentSelection.TrainingCase.CaseId) + { + return false; + } + + return StartActiveLiveTimer(); +} + bool UHyperTwistTrainingSubsystem::AdvanceActiveLiveTimerToSolvePhase() { RefreshActiveLiveTimerState(); @@ -5267,6 +5282,33 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingSubsystem::SubmitActiveLiveT return StepResult; } +FHyperTwistTrainingDrillTimerCompletionResult UHyperTwistTrainingSubsystem::SubmitAndAdvanceActiveDrillTimerAttempt( + const EHyperTwistTrainingAttemptResult Result +) +{ + FHyperTwistTrainingDrillTimerCompletionResult CompletionResult; + if (!HasActiveMethodDrillRun() + || !HasActiveRun() + || !ActiveMethodDrillRunState.CurrentCase.IsStructurallyValid() + || !ActiveRunState.Session.IsStructurallyValid() + || !ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() + || ActiveMethodDrillRunState.CurrentCase.CaseId != ActiveRunState.CurrentSelection.TrainingCase.CaseId) + { + return CompletionResult; + } + + CompletionResult.SubmitStepResult = SubmitActiveLiveTimedAttempt(Result); + CompletionResult.bSubmitSucceeded = CompletionResult.SubmitStepResult.IsStructurallyValid(); + if (CompletionResult.bSubmitSucceeded) + { + CompletionResult.UpdatedDrillRunState = AdvanceActiveMethodDrillCase(); + CompletionResult.bAdvanceSucceeded = CompletionResult.UpdatedDrillRunState.IsStructurallyValid(); + OnDrillTimerCompleted.Broadcast(CompletionResult); + } + + return CompletionResult; +} + FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool bAbortSession) { if (!HasActiveRun()) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index eebe252..9f80b4b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -9,12 +9,21 @@ #include "HyperTwistTraining/HyperTwistTrainingTypes.h" #include "HyperTwistTrainingSubsystem.generated.h" +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( + FOnHyperTwistTrainingDrillTimerCompleted, + const FHyperTwistTrainingDrillTimerCompletionResult&, + CompletionResult +); + UCLASS(Config = Game, DefaultConfig) class UNREALHYPERTWIST_API UHyperTwistTrainingSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: + UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Training|Drill") + FOnHyperTwistTrainingDrillTimerCompleted OnDrillTimerCompleted; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") bool HasActiveRun() const; @@ -554,6 +563,9 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") bool StartActiveLiveTimer(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Drill") + bool StartActiveLiveTimerForCurrentDrillCase(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") bool AdvanceActiveLiveTimerToSolvePhase(); @@ -575,6 +587,11 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") FHyperTwistTrainingRunStepResult SubmitActiveLiveTimedAttempt(EHyperTwistTrainingAttemptResult Result); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Drill") + FHyperTwistTrainingDrillTimerCompletionResult SubmitAndAdvanceActiveDrillTimerAttempt( + EHyperTwistTrainingAttemptResult Result + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") FHyperTwistTrainingSession CompleteActiveRun(bool bAbortSession); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index af9b951..7eac702 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -3862,6 +3862,29 @@ struct FHyperTwistTrainingMethodDrillRunState } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingDrillTimerCompletionResult +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bSubmitSucceeded = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAdvanceSucceeded = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingRunStepResult SubmitStepResult; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingMethodDrillRunState UpdatedDrillRunState; + + bool IsStructurallyValid() const + { + return bSubmitSucceeded && SubmitStepResult.IsStructurallyValid(); + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingMethodDrillSummary { diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound1TrainingTimerTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound1TrainingTimerTest.cpp new file mode 100644 index 0000000..adad451 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound1TrainingTimerTest.cpp @@ -0,0 +1,339 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "Engine/GameInstance.h" +#include "HyperTwistTraining/HyperTwistTrainingLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "UObject/Package.h" +#include "UObject/UnrealType.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistCubeDeskBound1TrainingTimerTestInternal +{ + template + PropertyType* FindTypedProperty(UObject* Object, const FName PropertyName) + { + return Object != nullptr + ? CastField(Object->GetClass()->FindPropertyByName(PropertyName)) + : nullptr; + } + + template + bool SetStructProperty(UObject* Object, const FName PropertyName, const StructType& Value) + { + if (FStructProperty* Property = FindTypedProperty(Object, PropertyName)) + { + if (Property->Struct == StructType::StaticStruct()) + { + *Property->ContainerPtrToValuePtr(Object) = Value; + return true; + } + } + + return false; + } + + bool SetBoolProperty(UObject* Object, const FName PropertyName, const bool bValue) + { + if (FBoolProperty* Property = FindTypedProperty(Object, PropertyName)) + { + Property->SetPropertyValue_InContainer(Object, bValue); + return true; + } + + return false; + } + + FHyperTwistTrainingDeck FindSampleMethodDrillDeck() + { + const FHyperTwistContentPack SamplePack = UHyperTwistTrainingLibrary::MakeSampleClassicContentPack(); + for (const FHyperTwistTrainingTrack& Track : SamplePack.Tracks) + { + for (const FHyperTwistTrainingDeck& Deck : Track.Decks) + { + if (!Deck.IsStructurallyValid()) + { + continue; + } + + const FHyperTwistTrainingMethodDrillDefinition DrillDefinition = + UHyperTwistTrainingLibrary::BuildMethodDrillDefinitionFromDeck( + Deck, + EHyperTwistTrainingDrillModeKind::Recognition, + EHyperTwistTrainingDrillVisibilityPolicy::PromptOnly, + true, + EHyperTwistTrainingDrillEntrySource::Catalog + ); + if (DrillDefinition.IsStructurallyValid()) + { + return Deck; + } + } + } + + return FHyperTwistTrainingDeck(); + } + + FHyperTwistTrainingCaseSelection MakeSingleCaseSelection( + const FHyperTwistTrainingDeck& SourceDeck, + const FHyperTwistTrainingCase& TrainingCase, + const TArray& Memories, + const EHyperTwistTrainingDeliveryMode Mode + ) + { + FHyperTwistTrainingDeck SingleCaseDeck = SourceDeck; + SingleCaseDeck.Cases.Reset(); + SingleCaseDeck.Cases.Add(TrainingCase); + return UHyperTwistTrainingLibrary::SelectNextTrainingCase( + SingleCaseDeck, + Memories, + Mode, + FString() + ); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound1DrillTimerCouplingTest, + "HyperTwist.CleanRoom.CubeDesk.Bound1.DrillTimerCoupling", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound1DrillTimerCouplingTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(GetTransientPackage()); + TestNotNull(TEXT("Game instance outer must be created for subsystem construction."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject(GameInstance); + TestNotNull(TEXT("Training subsystem instance must be created."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + FHyperTwistTrainingDeck Deck = HyperTwistCubeDeskBound1TrainingTimerTestInternal::FindSampleMethodDrillDeck(); + TestTrue(TEXT("A structurally valid sample drill deck must be available."), Deck.IsStructurallyValid()); + if (!Deck.IsStructurallyValid()) + { + return false; + } + + Deck.TimingPolicy.bInspectionEnabled = false; + Deck.TimingPolicy.InspectionDurationMs = 0; + + FHyperTwistTrainingRunState RunState = UHyperTwistTrainingLibrary::StartTrainingRun( + Deck, + TEXT("bound1-user"), + TEXT("bound1-session"), + EHyperTwistTrainingDeliveryMode::Timer + ); + TestTrue(TEXT("Sample deck must start a structurally valid timer run."), RunState.IsStructurallyValid()); + if (!RunState.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistTrainingMethodDrillDefinition DrillDefinition = + UHyperTwistTrainingLibrary::BuildMethodDrillDefinitionFromDeck( + Deck, + EHyperTwistTrainingDrillModeKind::Recognition, + EHyperTwistTrainingDrillVisibilityPolicy::PromptOnly, + true, + EHyperTwistTrainingDrillEntrySource::Catalog + ); + TestTrue(TEXT("Sample deck must build a structurally valid drill definition."), DrillDefinition.IsStructurallyValid()); + if (!DrillDefinition.IsStructurallyValid()) + { + return false; + } + + FHyperTwistTrainingMethodDrillRunState DrillRunState = UHyperTwistTrainingLibrary::StartMethodDrillRun( + DrillDefinition, + RunState.Session.UserId, + TEXT("bound1-drill-run") + ); + TestTrue(TEXT("Drill run must initialize from the drill definition."), DrillRunState.IsStructurallyValid()); + DrillRunState = UHyperTwistTrainingLibrary::StartNextMethodDrillCase(DrillRunState); + TestTrue(TEXT("Drill run must advance into an active current case."), DrillRunState.CurrentCase.IsStructurallyValid()); + if (!DrillRunState.CurrentCase.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistTrainingCaseSelection MatchingSelection = + HyperTwistCubeDeskBound1TrainingTimerTestInternal::MakeSingleCaseSelection( + Deck, + DrillRunState.CurrentCase, + RunState.Memories, + EHyperTwistTrainingDeliveryMode::Timer + ); + TestTrue(TEXT("Timer run must be able to select the drill current case."), MatchingSelection.IsStructurallyValid()); + if (!MatchingSelection.IsStructurallyValid()) + { + return false; + } + + RunState.CurrentSelection = MatchingSelection; + RunState.Session.CurrentCaseId = MatchingSelection.TrainingCase.CaseId; + + TestTrue( + TEXT("Test setup must write the active run state into the subsystem."), + HyperTwistCubeDeskBound1TrainingTimerTestInternal::SetStructProperty( + TrainingSubsystem, + TEXT("ActiveRunState"), + RunState + ) + ); + TestTrue( + TEXT("Test setup must mark the subsystem run state as active."), + HyperTwistCubeDeskBound1TrainingTimerTestInternal::SetBoolProperty( + TrainingSubsystem, + TEXT("bHasActiveRun"), + true + ) + ); + TestTrue( + TEXT("Test setup must write the active drill run state into the subsystem."), + HyperTwistCubeDeskBound1TrainingTimerTestInternal::SetStructProperty( + TrainingSubsystem, + TEXT("ActiveMethodDrillRunState"), + DrillRunState + ) + ); + TestTrue( + TEXT("Test setup must mark the subsystem drill run as active."), + HyperTwistCubeDeskBound1TrainingTimerTestInternal::SetBoolProperty( + TrainingSubsystem, + TEXT("bHasActiveMethodDrillRun"), + true + ) + ); + + TestTrue( + TEXT("Drill-aware timer start must succeed when the active drill case matches the timer case."), + TrainingSubsystem->StartActiveLiveTimerForCurrentDrillCase() + ); + + const FHyperTwistTrainingLiveTimerState StartedTimerState = TrainingSubsystem->GetActiveLiveTimerState(); + TestTrue(TEXT("Timer state must be active after a successful drill-aware start."), StartedTimerState.IsStructurallyValid()); + TestEqual( + TEXT("Drill-aware timer start must bind the timer to the drill case."), + StartedTimerState.CaseId, + DrillRunState.CurrentCase.CaseId + ); + TestEqual( + TEXT("Inspection-disabled drill-aware starts must enter the solving phase directly."), + StartedTimerState.Phase, + EHyperTwistTrainingLiveTimerPhase::Solving + ); + + TestNotNull( + TEXT("Subsystem class must expose the drill timer completion delegate surface."), + TrainingSubsystem->GetClass()->FindPropertyByName(TEXT("OnDrillTimerCompleted")) + ); + + const FHyperTwistTrainingDrillTimerCompletionResult CompletionResult = + TrainingSubsystem->SubmitAndAdvanceActiveDrillTimerAttempt(EHyperTwistTrainingAttemptResult::Success); + + TestTrue(TEXT("Drill-aware timer completion must submit the active timed attempt."), CompletionResult.bSubmitSucceeded); + TestTrue(TEXT("Drill-aware timer completion must return a structurally valid submit step."), CompletionResult.SubmitStepResult.IsStructurallyValid()); + TestTrue(TEXT("Drill-aware timer completion must advance the drill after submit."), CompletionResult.bAdvanceSucceeded); + TestTrue(TEXT("Drill-aware timer completion result must stay structurally valid."), CompletionResult.IsStructurallyValid()); + TestTrue( + TEXT("Drill advance must retain at least one completed case after submit."), + CompletionResult.UpdatedDrillRunState.CompletedCaseIds.Num() > 0 + ); + TestEqual( + TEXT("Advancing the drill must record the completed case id."), + CompletionResult.UpdatedDrillRunState.CompletedCaseIds[0], + DrillRunState.CurrentCase.CaseId + ); + + const FHyperTwistTrainingLiveTimerState CompletedTimerState = TrainingSubsystem->GetActiveLiveTimerState(); + TestFalse(TEXT("Successful drill timer completion must clear the active live timer flag."), TrainingSubsystem->HasActiveLiveTimer()); + TestEqual( + TEXT("Successful drill timer completion must reset the timer phase to idle."), + CompletedTimerState.Phase, + EHyperTwistTrainingLiveTimerPhase::Idle + ); + + const FHyperTwistTrainingMethodDrillRunState UpdatedSubsystemDrillState = TrainingSubsystem->GetActiveMethodDrillRunState(); + TestEqual( + TEXT("Subsystem drill state must advance to the same completed case list as the returned result."), + UpdatedSubsystemDrillState.CompletedCaseIds.Num(), + CompletionResult.UpdatedDrillRunState.CompletedCaseIds.Num() + ); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound1DashboardFallbackTest, + "HyperTwist.CleanRoom.CubeDesk.Bound1.DashboardFallback", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound1DashboardFallbackTest::RunTest(const FString& Parameters) +{ + const FString DashboardSourcePath = FPaths::Combine( + FPaths::ProjectDir(), + TEXT("Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp") + ); + const FString SubsystemSourcePath = FPaths::Combine( + FPaths::ProjectDir(), + TEXT("Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp") + ); + + FString DashboardSource; + TestTrue( + TEXT("Dashboard source must be readable for fallback wiring inspection."), + FFileHelper::LoadFileToString(DashboardSource, *DashboardSourcePath) + ); + if (DashboardSource.IsEmpty()) + { + return false; + } + + FString SubsystemSource; + TestTrue( + TEXT("Subsystem source must be readable for drill completion broadcast inspection."), + FFileHelper::LoadFileToString(SubsystemSource, *SubsystemSourcePath) + ); + if (SubsystemSource.IsEmpty()) + { + return false; + } + + TestTrue( + TEXT("Dashboard timer start must prefer the drill-aware timer entry point."), + DashboardSource.Contains(TEXT("StartActiveLiveTimerForCurrentDrillCase")) + ); + TestTrue( + TEXT("Dashboard timer start must retain the generic timer fallback path."), + DashboardSource.Contains(TEXT("bStarted = TrainingSubsystem->StartActiveLiveTimer();")) + ); + TestTrue( + TEXT("Dashboard timed submit must prefer the drill-aware submit-and-advance path."), + DashboardSource.Contains(TEXT("SubmitAndAdvanceActiveDrillTimerAttempt(Result)")) + ); + TestTrue( + TEXT("Dashboard timed submit must retain the generic timed-attempt fallback path."), + DashboardSource.Contains(TEXT("StepResult = TrainingSubsystem->SubmitActiveLiveTimedAttempt(Result);")) + ); + TestTrue( + TEXT("Subsystem drill completion must broadcast the drill timer completion result."), + SubsystemSource.Contains(TEXT("OnDrillTimerCompleted.Broadcast(CompletionResult);")) + ); + + return true; +} + +#endif // WITH_AUTOMATION_TESTS