From 1f20628b7aaafd057ac8ac5f2ce1bdefbd3826d6 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 18 May 2026 06:30:28 +0200 Subject: [PATCH] Add twisty Bound 4 visualization packet --- .../HyperTwistPuzzleViewerComponent.cpp | 266 ++++++++++++++++++ .../HyperTwistPuzzleViewerComponent.h | 25 +- .../HyperTwistViewerTypes.h | 190 +++++++++++++ ...yperTwistTwistyBound4VisualizationTest.cpp | 241 ++++++++++++++++ 4 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound4VisualizationTest.cpp diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp index cb305f3..d670bea 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp @@ -308,6 +308,104 @@ namespace HyperTwistPuzzleViewerComponentInternal : 0; } + EHyperTwistViewerVisualizationMode ResolvePreferredVisualizationMode( + const EHyperTwistViewerVisualizationPreference Preference + ) + { + return Preference == EHyperTwistViewerVisualizationPreference::Prefer3D + ? EHyperTwistViewerVisualizationMode::ThreeD + : EHyperTwistViewerVisualizationMode::TwoD; + } + + EHyperTwistViewerPresentationMode ResolvePresentationMode( + const EHyperTwistViewerPresentationMode RequestedMode, + const bool bAllowExpandedView, + const bool bAllowFullscreen + ) + { + switch (RequestedMode) + { + case EHyperTwistViewerPresentationMode::Fullscreen: + if (bAllowFullscreen) + { + return EHyperTwistViewerPresentationMode::Fullscreen; + } + if (bAllowExpandedView) + { + return EHyperTwistViewerPresentationMode::Expanded; + } + return EHyperTwistViewerPresentationMode::Inline; + + case EHyperTwistViewerPresentationMode::Expanded: + return bAllowExpandedView + ? EHyperTwistViewerPresentationMode::Expanded + : EHyperTwistViewerPresentationMode::Inline; + + default: + return EHyperTwistViewerPresentationMode::Inline; + } + } + + bool SupportsThreeDVisualization( + const FHyperTwistViewerEmbeddedHostBootstrapState& BootstrapState, + const FHyperTwistTrainingViewerEmbedContract& EmbedContract, + const FHyperTwistTrainingSpatialUiContract& UiContract, + const FHyperTwistTrainingSpatialEffectBoundary& EffectBoundary, + const FHyperTwistTrainingSpatialStateMotionContract& StateMotionContract + ) + { + return BootstrapState.bReadyForEmbed + && BootstrapState.BindingMode == EHyperTwistViewerEmbeddedHostBindingMode::ScissoredView + && BootstrapState.RenderStateProfile == TEXT("classic-replay") + && BootstrapState.SpatialEventSurfaceTags.Contains(TEXT("scissored view embedding")) + && BootstrapState.SpatialEventSurfaceTags.Contains(TEXT("HTML overlay bridge")) + && EmbedContract.ShippableBehaviorTags.Contains(TEXT("orbit-controls")) + && UiContract.StateSurfaceTags.Contains(TEXT("HTML/spatial overlay bridge")) + && EffectBoundary.EffectFamilies.Contains(TEXT("tone mapping and highlight effects")) + && StateMotionContract.MotionKinds.Contains(TEXT("reduced-motion support")); + } + + EHyperTwistViewerVisualizationFallbackReason ResolveThreeDFallbackReason( + const FHyperTwistViewerEmbeddedHostBootstrapState& BootstrapState, + const FHyperTwistTrainingViewerEmbedContract& EmbedContract, + const FHyperTwistTrainingSpatialUiContract& UiContract, + const FHyperTwistTrainingSpatialEffectBoundary& EffectBoundary, + const FHyperTwistTrainingSpatialStateMotionContract& StateMotionContract + ) + { + if (BootstrapState.BindingMode != EHyperTwistViewerEmbeddedHostBindingMode::ScissoredView) + { + return EHyperTwistViewerVisualizationFallbackReason::HostBindingDoesNotSupport3D; + } + + if (BootstrapState.RenderStateProfile != TEXT("classic-replay")) + { + return EHyperTwistViewerVisualizationFallbackReason::RenderProfilePrefers2D; + } + + return SupportsThreeDVisualization(BootstrapState, EmbedContract, UiContract, EffectBoundary, StateMotionContract) + ? EHyperTwistViewerVisualizationFallbackReason::None + : EHyperTwistViewerVisualizationFallbackReason::MissingSupportContract; + } + + bool CanApplyPresentationMode( + const FHyperTwistViewerVisualizationShellState& VisualizationState, + const EHyperTwistViewerPresentationMode NewMode + ) + { + switch (NewMode) + { + case EHyperTwistViewerPresentationMode::Fullscreen: + return VisualizationState.bSupportsFullscreen; + + case EHyperTwistViewerPresentationMode::Expanded: + return VisualizationState.bSupportsExpandedView; + + default: + return true; + } + } + FHyperTwistViewerPositionSnapshot BuildPositionSnapshot( const TArray& MoveList, const TArray& MoveBoundaryTimesMs, @@ -487,6 +585,7 @@ void UHyperTwistPuzzleViewerComponent::LoadSceneContext(const FHyperTwistSimulat SceneContext = InContext; PlaybackState.bSceneContextLoaded = InContext.IsStructurallyValid(); HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState(); + VisualizationShellState = FHyperTwistViewerVisualizationShellState(); // Reset playhead — loaded content has changed. PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; @@ -507,6 +606,7 @@ void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray MoveBoundaryTimesMs; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h index 312f521..bde0845 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h @@ -49,6 +49,37 @@ enum class EHyperTwistViewerEmbeddedHostBindingMode : uint8 ScissoredView UMETA(DisplayName = "Scissored View") }; +UENUM(BlueprintType) +enum class EHyperTwistViewerVisualizationMode : uint8 +{ + TwoD UMETA(DisplayName = "2D"), + ThreeD UMETA(DisplayName = "3D") +}; + +UENUM(BlueprintType) +enum class EHyperTwistViewerVisualizationPreference : uint8 +{ + Prefer2D UMETA(DisplayName = "Prefer 2D"), + Prefer3D UMETA(DisplayName = "Prefer 3D") +}; + +UENUM(BlueprintType) +enum class EHyperTwistViewerVisualizationFallbackReason : uint8 +{ + None UMETA(DisplayName = "None"), + HostBindingDoesNotSupport3D UMETA(DisplayName = "Host Binding Does Not Support 3D"), + RenderProfilePrefers2D UMETA(DisplayName = "Render Profile Prefers 2D"), + MissingSupportContract UMETA(DisplayName = "Missing Support Contract") +}; + +UENUM(BlueprintType) +enum class EHyperTwistViewerPresentationMode : uint8 +{ + Inline UMETA(DisplayName = "Inline"), + Expanded UMETA(DisplayName = "Expanded"), + Fullscreen UMETA(DisplayName = "Fullscreen") +}; + // --------------------------------------------------------------------------- // Control action — what the caller wants the viewer to do // --------------------------------------------------------------------------- @@ -571,6 +602,165 @@ struct UNREALHYPERTWIST_API FHyperTwistViewerEmbeddedHostBootstrapState } }; +// --------------------------------------------------------------------------- +// Bound 4 visualization-shell records +// --------------------------------------------------------------------------- + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerVisualizationShellRequest +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistViewerEmbeddedHostBootstrapRequest HostBootstrapRequest; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerVisualizationPreference VisualizationPreference = + EHyperTwistViewerVisualizationPreference::Prefer3D; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPresentationMode InitialPresentationMode = EHyperTwistViewerPresentationMode::Inline; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAllow2DFallback = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAllowExpandedView = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAllowFullscreen = true; + + bool IsStructurallyValid() const + { + return HostBootstrapRequest.IsStructurallyValid(); + } +}; + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerVisualizationShellState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString VisualizationId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString BootstrapId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString AdapterProjectionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString ViewerAdapterContractId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString BrowserEmbedContractId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString SpatialSceneContractId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString SpatialUiContractId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString SpatialEffectBoundaryId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString SpatialStateMotionContractId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString SceneContextId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString RenderStateProfile; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerVisualizationMode PreferredMode = EHyperTwistViewerVisualizationMode::ThreeD; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerVisualizationMode ActiveMode = EHyperTwistViewerVisualizationMode::TwoD; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerVisualizationFallbackReason FallbackReason = + EHyperTwistViewerVisualizationFallbackReason::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPresentationMode PresentationMode = EHyperTwistViewerPresentationMode::Inline; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bReadyForVisualization = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bUsingFallback = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bSupportsExpandedView = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bSupportsFullscreen = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bUsesSpatialRendering = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray ViewerBehaviorTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray ViewerAnnotationSurfaceTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray SpatialUiComponentFamilies; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray SpatialUiStateSurfaceTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray SpatialSceneEventSurfaceTags; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray SpatialEffectFamilies; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray MotionKinds; + + bool IsStructurallyValid() const + { + if (!bReadyForVisualization) + { + return VisualizationId.IsEmpty() + && BootstrapId.IsEmpty() + && AdapterProjectionId.IsEmpty() + && ViewerAdapterContractId.IsEmpty() + && BrowserEmbedContractId.IsEmpty() + && SpatialSceneContractId.IsEmpty() + && SpatialUiContractId.IsEmpty() + && SpatialEffectBoundaryId.IsEmpty() + && SpatialStateMotionContractId.IsEmpty() + && SceneContextId.IsEmpty() + && RenderStateProfile.IsEmpty(); + } + + return !VisualizationId.IsEmpty() + && !BootstrapId.IsEmpty() + && !AdapterProjectionId.IsEmpty() + && !ViewerAdapterContractId.IsEmpty() + && !BrowserEmbedContractId.IsEmpty() + && !SpatialSceneContractId.IsEmpty() + && !SpatialUiContractId.IsEmpty() + && !SpatialEffectBoundaryId.IsEmpty() + && !SpatialStateMotionContractId.IsEmpty() + && !SceneContextId.IsEmpty() + && !RenderStateProfile.IsEmpty() + && ViewerBehaviorTags.Num() > 0 + && ViewerAnnotationSurfaceTags.Num() > 0 + && SpatialUiComponentFamilies.Num() > 0 + && SpatialUiStateSurfaceTags.Num() > 0 + && SpatialSceneEventSurfaceTags.Num() > 0 + && SpatialEffectFamilies.Num() > 0 + && MotionKinds.Num() > 0; + } +}; + // --------------------------------------------------------------------------- // Runtime surface - minimal compact replay-player bundle exposed through the // training runtime library without moving shell ownership out of simulation. diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound4VisualizationTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound4VisualizationTest.cpp new file mode 100644 index 0000000..3d4a12a --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound4VisualizationTest.cpp @@ -0,0 +1,241 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" +#include "JsonObjectConverter.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistTwistyBound4VisualizationTestInternal +{ + template + FString SerializeStructToJson(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + + FHyperTwistReplayEvent MakeReplayEvent( + const FString& EventId, + const int32 Sequence, + const int32 TimeMs, + const EHyperTwistReplayEventType EventType, + const FString& PayloadJson + ) + { + FHyperTwistReplayEvent Event; + Event.EventId = EventId; + Event.Sequence = Sequence; + Event.TimeMs = TimeMs; + Event.EventType = EventType; + Event.PayloadJson = PayloadJson; + return Event; + } + + FHyperTwistReplayPacket MakeReplayPacket() + { + const FHyperTwistPuzzleState SampleState = UHyperTwistContractLibrary::MakeSampleClassicPuzzleState(); + + FHyperTwistStateSnapshot Snapshot; + Snapshot.SnapshotId = TEXT("snapshot-bound4"); + Snapshot.State = SampleState; + Snapshot.DerivedHash = TEXT("snapshot-bound4-hash"); + + FHyperTwistReplayMovePayload FirstMovePayload; + FirstMovePayload.Notation = TEXT("R"); + FirstMovePayload.TransformationRef = TEXT("classic/r"); + FirstMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayMovePayload SecondMovePayload; + SecondMovePayload.Notation = TEXT("U"); + SecondMovePayload.TransformationRef = TEXT("classic/u"); + SecondMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayMovePayload ThirdMovePayload; + ThirdMovePayload.Notation = TEXT("F'"); + ThirdMovePayload.TransformationRef = TEXT("classic/f-prime"); + ThirdMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayPacket ReplayPacket; + ReplayPacket.ReplayId = TEXT("replay-bound4-001"); + ReplayPacket.SessionId = TEXT("session-bound4-001"); + ReplayPacket.PuzzleDefinition = SampleState.Definition; + ReplayPacket.CaptureMode = EHyperTwistCaptureMode::Imported; + ReplayPacket.Events = { + MakeReplayEvent( + TEXT("evt-001"), + 1, + 0, + EHyperTwistReplayEventType::StateSnapshot, + SerializeStructToJson(Snapshot) + ), + MakeReplayEvent( + TEXT("evt-002"), + 2, + 0, + EHyperTwistReplayEventType::TimerStart, + TEXT("") + ), + MakeReplayEvent( + TEXT("evt-003"), + 3, + 200, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(FirstMovePayload) + ), + MakeReplayEvent( + TEXT("evt-004"), + 4, + 400, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(SecondMovePayload) + ), + MakeReplayEvent( + TEXT("evt-005"), + 5, + 700, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(ThirdMovePayload) + ), + MakeReplayEvent( + TEXT("evt-006"), + 6, + 1000, + EHyperTwistReplayEventType::SolveEnd, + TEXT("") + ) + }; + + return ReplayPacket; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTwistyBound4VisualizationShellTest, + "HyperTwist.CleanRoom.TwistyJs.Bound4.VisualizationShell", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTwistyBound4VisualizationShellTest::RunTest(const FString& Parameters) +{ + UHyperTwistPuzzleViewerComponent* Viewer = NewObject(); + TestNotNull(TEXT("Viewer component instance must be created."), Viewer); + TestTrue( + TEXT("Replay packet load must succeed for the Bound 4 visualization-shell test."), + Viewer->LoadReplayPacket(HyperTwistTwistyBound4VisualizationTestInternal::MakeReplayPacket()) + ); + + FHyperTwistViewerVisualizationShellRequest Request; + Request.HostBootstrapRequest.HostInstanceId = TEXT("visual-host-01"); + Request.HostBootstrapRequest.BindingMode = EHyperTwistViewerEmbeddedHostBindingMode::ScissoredView; + Request.HostBootstrapRequest.bBindTimelineSurface = true; + Request.HostBootstrapRequest.bBindScrubberSurface = true; + Request.HostBootstrapRequest.bBindStatusSurface = true; + Request.HostBootstrapRequest.bAllowPointerInput = true; + Request.VisualizationPreference = EHyperTwistViewerVisualizationPreference::Prefer3D; + Request.InitialPresentationMode = EHyperTwistViewerPresentationMode::Expanded; + Request.bAllow2DFallback = true; + Request.bAllowExpandedView = true; + Request.bAllowFullscreen = true; + + FHyperTwistViewerVisualizationShellState VisualizationState; + TestTrue( + TEXT("The viewer must prepare a bounded visualization shell."), + Viewer->TryBuildVisualizationShell(Request, VisualizationState) + ); + TestTrue(TEXT("The visualization shell state must be structurally valid."), VisualizationState.IsStructurallyValid()); + TestEqual(TEXT("The visualization shell should preserve the bootstrap id."), VisualizationState.BootstrapId, Viewer->GetHostBootstrapState().BootstrapId); + TestEqual(TEXT("The visualization shell should preserve the scene context id."), VisualizationState.SceneContextId, Viewer->GetSceneContext().SceneContextId); + TestEqual(TEXT("The visualization shell should preserve the classic-cubing viewer adapter contract."), VisualizationState.ViewerAdapterContractId, FString(TEXT("classic-cubing-viewer-adapter-stack"))); + TestEqual(TEXT("The visualization shell should reference the browser viewer embed contract."), VisualizationState.BrowserEmbedContractId, FString(TEXT("browser-viewer-embed"))); + TestEqual(TEXT("The visualization shell should reference the browser spatial scene contract."), VisualizationState.SpatialSceneContractId, FString(TEXT("browser-spatial-scene-stack"))); + TestEqual(TEXT("The visualization shell should reference the browser spatial UI contract."), VisualizationState.SpatialUiContractId, FString(TEXT("browser-spatial-ui-stack"))); + TestEqual(TEXT("The visualization shell should reference the browser effect boundary."), VisualizationState.SpatialEffectBoundaryId, FString(TEXT("browser-spatial-effect-boundary"))); + TestEqual(TEXT("The visualization shell should reference the browser state-motion contract."), VisualizationState.SpatialStateMotionContractId, FString(TEXT("browser-spatial-state-motion-stack"))); + TestEqual(TEXT("The preferred visualization mode should stay 3D."), VisualizationState.PreferredMode, EHyperTwistViewerVisualizationMode::ThreeD); + TestEqual(TEXT("The active visualization mode should stay 3D when the support stack is available."), VisualizationState.ActiveMode, EHyperTwistViewerVisualizationMode::ThreeD); + TestFalse(TEXT("The 3D shell should not require a fallback in the scissored-view path."), VisualizationState.bUsingFallback); + TestTrue(TEXT("The 3D shell should mark spatial rendering active."), VisualizationState.bUsesSpatialRendering); + TestEqual(TEXT("The requested expanded presentation mode should be preserved."), VisualizationState.PresentationMode, EHyperTwistViewerPresentationMode::Expanded); + TestTrue(TEXT("The visualization shell should support fullscreen when requested."), VisualizationState.bSupportsFullscreen); + TestTrue(TEXT("The visualization shell should support expanded mode when requested."), VisualizationState.bSupportsExpandedView); + TestTrue(TEXT("The visualization shell should surface embeddable browser-viewer behavior tags."), VisualizationState.ViewerBehaviorTags.Contains(TEXT("embeddable-viewer"))); + TestTrue(TEXT("The visualization shell should surface browser-viewer annotation tags."), VisualizationState.ViewerAnnotationSurfaceTags.Contains(TEXT("focus-annotation"))); + TestTrue(TEXT("The visualization shell should surface world-anchored UI tags."), VisualizationState.SpatialUiStateSurfaceTags.Contains(TEXT("HTML/spatial overlay bridge"))); + TestTrue(TEXT("The visualization shell should surface browser scene event tags."), VisualizationState.SpatialSceneEventSurfaceTags.Contains(TEXT("scissored view embedding"))); + TestTrue(TEXT("The visualization shell should surface effect-family tags."), VisualizationState.SpatialEffectFamilies.Contains(TEXT("tone mapping and highlight effects"))); + TestTrue(TEXT("The visualization shell should surface motion-kind tags."), VisualizationState.MotionKinds.Contains(TEXT("reduced-motion support"))); + TestEqual(TEXT("The stored visualization shell state should match the returned visualization id."), Viewer->GetVisualizationShellState().VisualizationId, VisualizationState.VisualizationId); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTwistyBound4FallbackPolicyTest, + "HyperTwist.CleanRoom.TwistyJs.Bound4.FallbackPolicy", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTwistyBound4FallbackPolicyTest::RunTest(const FString& Parameters) +{ + UHyperTwistPuzzleViewerComponent* Viewer = NewObject(); + TestNotNull(TEXT("Viewer component instance must be created."), Viewer); + TestTrue( + TEXT("Replay packet load must succeed for the Bound 4 fallback-policy test."), + Viewer->LoadReplayPacket(HyperTwistTwistyBound4VisualizationTestInternal::MakeReplayPacket()) + ); + + FHyperTwistViewerVisualizationShellRequest Request; + Request.HostBootstrapRequest.HostInstanceId = TEXT("visual-host-02"); + Request.HostBootstrapRequest.BindingMode = EHyperTwistViewerEmbeddedHostBindingMode::InlineCanvas; + Request.HostBootstrapRequest.bBindTimelineSurface = true; + Request.HostBootstrapRequest.bBindScrubberSurface = true; + Request.HostBootstrapRequest.bBindStatusSurface = true; + Request.HostBootstrapRequest.bAllowPointerInput = true; + Request.VisualizationPreference = EHyperTwistViewerVisualizationPreference::Prefer3D; + Request.InitialPresentationMode = EHyperTwistViewerPresentationMode::Inline; + Request.bAllow2DFallback = true; + Request.bAllowExpandedView = true; + Request.bAllowFullscreen = true; + + FHyperTwistViewerVisualizationShellState VisualizationState; + TestTrue( + TEXT("The viewer must still build a bounded visualization shell when 3D falls back to 2D."), + Viewer->TryBuildVisualizationShell(Request, VisualizationState) + ); + TestTrue(TEXT("The fallback visualization shell state must be structurally valid."), VisualizationState.IsStructurallyValid()); + TestEqual(TEXT("The preferred visualization mode should remain 3D."), VisualizationState.PreferredMode, EHyperTwistViewerVisualizationMode::ThreeD); + TestEqual(TEXT("Inline-canvas host binding should fall back to the 2D shell."), VisualizationState.ActiveMode, EHyperTwistViewerVisualizationMode::TwoD); + TestTrue(TEXT("The 2D shell should report that it is using the shared fallback policy."), VisualizationState.bUsingFallback); + TestEqual( + TEXT("The fallback reason should identify the host-binding constraint."), + VisualizationState.FallbackReason, + EHyperTwistViewerVisualizationFallbackReason::HostBindingDoesNotSupport3D + ); + TestFalse(TEXT("The fallback 2D shell should not mark spatial rendering active."), VisualizationState.bUsesSpatialRendering); + TestEqual(TEXT("The initial inline presentation mode should be preserved."), VisualizationState.PresentationMode, EHyperTwistViewerPresentationMode::Inline); + + FHyperTwistViewerVisualizationShellState FullscreenState; + TestTrue( + TEXT("Fullscreen should remain a visualization-local concern even on the 2D fallback shell."), + Viewer->TrySetVisualizationPresentationMode(EHyperTwistViewerPresentationMode::Fullscreen, FullscreenState) + ); + TestEqual(TEXT("The presentation mode should move to fullscreen."), FullscreenState.PresentationMode, EHyperTwistViewerPresentationMode::Fullscreen); + TestEqual(TEXT("Fullscreen should preserve the active 2D fallback mode."), FullscreenState.ActiveMode, EHyperTwistViewerVisualizationMode::TwoD); + + FHyperTwistViewerVisualizationShellState ExpandedState; + TestTrue( + TEXT("Expanded view should also remain a visualization-local concern."), + Viewer->TrySetVisualizationPresentationMode(EHyperTwistViewerPresentationMode::Expanded, ExpandedState) + ); + TestEqual(TEXT("The presentation mode should move to expanded."), ExpandedState.PresentationMode, EHyperTwistViewerPresentationMode::Expanded); + TestEqual(TEXT("Expanded view should preserve the active 2D fallback mode."), ExpandedState.ActiveMode, EHyperTwistViewerVisualizationMode::TwoD); + TestEqual(TEXT("The stored visualization shell state should now reflect the expanded mode."), Viewer->GetVisualizationShellState().PresentationMode, EHyperTwistViewerPresentationMode::Expanded); + + return true; +} + +#endif // WITH_AUTOMATION_TESTS