mirror of
https://github.com/bangfireball/Phonecast_VR.git
synced 2026-10-18 01:00:20 +02:00
704 lines
36 KiB
C++
704 lines
36 KiB
C++
#include "phonecast/apps/Receiver.h"
|
|
#include "phonecast/core/config/AppConfig.h"
|
|
#include "phonecast/core/protocol/StreamProtocol.h"
|
|
#include "phonecast/core/streaming/DecoderRecoveryController.h"
|
|
#include "phonecast/core/streaming/GeneratedVideoSource.h"
|
|
#include "phonecast/vr/interaction/ControllerShortcut.h"
|
|
#include "phonecast/vr/interaction/OverlayInteractionController.h"
|
|
#include "phonecast/vr/interaction/PanelDepth.h"
|
|
#include "phonecast/vr/interaction/StartLocation.h"
|
|
#include "phonecast/vr/overlay/GlanceController.h"
|
|
#include "phonecast/vr/overlay/OverlayController.h"
|
|
#include "phonecast/vr/overlay/OverlaySettingsStore.h"
|
|
#include "phonecast/vr/overlay/SettingsMenuController.h"
|
|
#include "phonecast/vr/overlay/WristMenuGesture.h"
|
|
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <cmath>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
class FakeLogger final : public phonecast::core::ILogger {
|
|
public:
|
|
void Log(phonecast::core::LogLevel, std::string_view, std::string_view) override { ++entries; }
|
|
int entries{0};
|
|
};
|
|
|
|
class FakeOverlay final : public phonecast::vr::IOverlayRenderer {
|
|
public:
|
|
bool Start(const phonecast::vr::OverlaySettings&, std::string&) override {
|
|
started = true;
|
|
return startResult;
|
|
}
|
|
bool SubmitFrame(const phonecast::core::VideoFrame& frame, std::string&) override {
|
|
++frames;
|
|
lastSequence = frame.sequence;
|
|
return submitResult;
|
|
}
|
|
bool ApplySettings(const phonecast::vr::OverlaySettings&, std::string&) override {
|
|
return true;
|
|
}
|
|
bool SetVisible(bool, std::string&) override { return true; }
|
|
bool PumpEvents() override { return keepRunning; }
|
|
void Stop() noexcept override { stopped = true; }
|
|
bool startResult{true};
|
|
bool submitResult{true};
|
|
bool keepRunning{true};
|
|
bool started{false};
|
|
bool stopped{false};
|
|
int frames{0};
|
|
std::uint64_t lastSequence{0};
|
|
};
|
|
|
|
int failures = 0;
|
|
void Check(bool condition, const char* message) {
|
|
if (!condition) {
|
|
std::cerr << "FAIL: " << message << '\n';
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
void TestConfig() {
|
|
const auto defaults = phonecast::core::ParseCommandLine({});
|
|
Check(defaults.status == phonecast::core::ParseStatus::Success, "default config parses");
|
|
Check(defaults.config.framesPerSecond == 30, "default FPS is 30");
|
|
|
|
const auto custom = phonecast::core::ParseCommandLine(
|
|
{"--width", "800", "--height", "600", "--fps", "60", "--alpha", "0.5"});
|
|
Check(custom.status == phonecast::core::ParseStatus::Success, "custom config parses");
|
|
Check(custom.config.textureWidth == 800 && custom.config.textureHeight == 600,
|
|
"custom dimensions apply");
|
|
Check(custom.config.framesPerSecond == 60 && custom.config.overlayAlpha == 0.5F,
|
|
"custom FPS and alpha apply");
|
|
Check(phonecast::core::ParseCommandLine({"--fps", "0"}).status ==
|
|
phonecast::core::ParseStatus::Error,
|
|
"invalid FPS rejected");
|
|
Check(phonecast::core::ParseCommandLine({"--unknown", "1"}).status ==
|
|
phonecast::core::ParseStatus::Error,
|
|
"unknown option rejected");
|
|
Check(phonecast::core::ParseCommandLine({"--alpha", "nan"}).status ==
|
|
phonecast::core::ParseStatus::Error,
|
|
"non-finite values rejected");
|
|
}
|
|
|
|
void TestDecoderRecovery() {
|
|
using Controller = phonecast::core::DecoderRecoveryController;
|
|
Controller recovery;
|
|
const auto start = Controller::TimePoint{};
|
|
|
|
Check(!recovery.ObserveWindow(30, 1), "decoded output keeps watchdog healthy");
|
|
Check(!recovery.ObserveWindow(0, 0), "static input does not trip watchdog");
|
|
Check(!recovery.ObserveWindow(30, 0), "first decode stall window is tolerated");
|
|
Check(!recovery.ObserveWindow(30, 0), "second decode stall window is tolerated");
|
|
Check(recovery.ObserveWindow(30, 0), "third active decode stall requests recovery");
|
|
|
|
recovery.Begin(start);
|
|
Check(recovery.Active() && !recovery.Ready(start), "recovery starts with a short reset delay");
|
|
const auto first = start + std::chrono::milliseconds(100);
|
|
Check(recovery.Ready(first), "first decoder restart becomes ready");
|
|
recovery.RecordAttempt(false, first);
|
|
Check(recovery.Active() && recovery.Attempts() == 1,
|
|
"failed restart schedules another bounded attempt");
|
|
Check(!recovery.Ready(first + std::chrono::milliseconds(249)),
|
|
"restart backoff is enforced");
|
|
const auto second = first + std::chrono::milliseconds(250);
|
|
Check(recovery.Ready(second), "second restart becomes ready after backoff");
|
|
recovery.RecordAttempt(false, second);
|
|
const auto third = second + std::chrono::milliseconds(500);
|
|
Check(recovery.Ready(third), "third restart uses increasing backoff");
|
|
recovery.RecordAttempt(false, third);
|
|
Check(!recovery.Active() && recovery.Exhausted() &&
|
|
recovery.Attempts() == Controller::MaximumAttempts(),
|
|
"decoder restart attempts are bounded");
|
|
|
|
recovery.Reset();
|
|
recovery.Begin(start);
|
|
recovery.RecordAttempt(true, first);
|
|
Check(!recovery.Active() && !recovery.Exhausted(),
|
|
"successful decoder restart clears recovery state");
|
|
}
|
|
|
|
void TestGeneratedFrames() {
|
|
phonecast::core::GeneratedVideoSource source(64, 64);
|
|
phonecast::core::VideoFrame first;
|
|
phonecast::core::VideoFrame second;
|
|
std::string error;
|
|
Check(source.NextFrame(first, error) && first.IsValid(), "first generated frame is valid");
|
|
Check(source.NextFrame(second, error) && second.IsValid(), "second generated frame is valid");
|
|
Check(first.sequence == 0 && second.sequence == 1, "frame sequence increments");
|
|
Check(first.pixels != second.pixels, "animation changes frame pixels");
|
|
}
|
|
|
|
void TestStreamProtocol() {
|
|
using namespace phonecast::core::protocol;
|
|
Message outgoing;
|
|
outgoing.type = MessageType::VideoFrame;
|
|
outgoing.flags = MessageFlags::KeyFrame;
|
|
outgoing.sequence = 42;
|
|
outgoing.timestampMicros = 123456;
|
|
outgoing.width = 1080;
|
|
outgoing.height = 1920;
|
|
outgoing.payload = {0, 0, 0, 1, 0x65};
|
|
const auto bytes = Serialize(outgoing);
|
|
Check(bytes.size() == kHeaderSize + outgoing.payload.size(), "protocol message serializes");
|
|
|
|
Message parsed;
|
|
std::uint32_t payloadSize = 0;
|
|
std::string error;
|
|
Check(ParseHeader(bytes.data(), kHeaderSize, parsed, payloadSize, error),
|
|
"protocol header parses");
|
|
Check(parsed.type == MessageType::VideoFrame && parsed.sequence == 42,
|
|
"protocol identity fields round trip");
|
|
Check(parsed.flags == MessageFlags::KeyFrame && parsed.timestampMicros == 123456,
|
|
"protocol timing fields round trip");
|
|
Check(parsed.width == 1080 && parsed.height == 1920 && payloadSize == 5,
|
|
"protocol dimensions and payload length round trip");
|
|
|
|
auto corrupt = bytes;
|
|
corrupt[0] = 'X';
|
|
Check(!ParseHeader(corrupt.data(), kHeaderSize, parsed, payloadSize, error),
|
|
"invalid protocol magic is rejected");
|
|
corrupt = bytes;
|
|
corrupt[8] = 0x7f;
|
|
Check(!ParseHeader(corrupt.data(), kHeaderSize, parsed, payloadSize, error),
|
|
"oversized payload is rejected");
|
|
Message keyFrameRequest;
|
|
keyFrameRequest.type = MessageType::RequestKeyFrame;
|
|
const auto requestBytes = Serialize(keyFrameRequest);
|
|
Check(ParseHeader(requestBytes.data(), kHeaderSize, parsed, payloadSize, error) &&
|
|
parsed.type == MessageType::RequestKeyFrame && payloadSize == 0,
|
|
"key-frame recovery request round trips");
|
|
Check(IsValidPairCode("123456") && !IsValidPairCode("12345x"),
|
|
"pair codes require six digits");
|
|
|
|
phonecast::core::PointerEvent pointer;
|
|
pointer.type = phonecast::core::PointerEvent::Type::Scroll;
|
|
pointer.normalizedX = 0.25F;
|
|
pointer.normalizedY = 0.75F;
|
|
pointer.scrollDelta = -0.5F;
|
|
const auto pointerBytes = SerializePointerEvent(pointer);
|
|
phonecast::core::PointerEvent parsedPointer;
|
|
Check(ParsePointerEvent(pointerBytes.data(), pointerBytes.size(), parsedPointer, error) &&
|
|
parsedPointer.type == phonecast::core::PointerEvent::Type::Scroll &&
|
|
parsedPointer.normalizedX == 0.25F && parsedPointer.normalizedY == 0.75F &&
|
|
parsedPointer.scrollDelta == -0.5F,
|
|
"normalized remote input round trips");
|
|
auto invalidPointer = pointerBytes;
|
|
invalidPointer[4] = 0x7f;
|
|
invalidPointer[5] = 0x80;
|
|
invalidPointer[6] = 0x00;
|
|
invalidPointer[7] = 0x00;
|
|
Check(!ParsePointerEvent(invalidPointer.data(), invalidPointer.size(), parsedPointer, error),
|
|
"non-finite remote input is rejected");
|
|
|
|
NotificationEvent notification;
|
|
notification.applicationName = "Messages";
|
|
notification.title = "Are you coming?";
|
|
notification.body = "Dinner is ready";
|
|
notification.packageName = "com.example.messages";
|
|
notification.postedAtMillis = 123456789;
|
|
notification.actionToken = 987654321;
|
|
notification.contentRedacted = false;
|
|
const auto notificationBytes = SerializeNotification(notification);
|
|
NotificationEvent parsedNotification;
|
|
Check(ParseNotification(notificationBytes.data(), notificationBytes.size(),
|
|
parsedNotification, error) &&
|
|
parsedNotification.applicationName == notification.applicationName &&
|
|
parsedNotification.title == notification.title &&
|
|
parsedNotification.body == notification.body &&
|
|
parsedNotification.packageName == notification.packageName &&
|
|
parsedNotification.postedAtMillis == notification.postedAtMillis &&
|
|
parsedNotification.actionToken == notification.actionToken &&
|
|
!parsedNotification.contentRedacted,
|
|
"privacy-filtered notification payload round trips");
|
|
std::vector<std::uint8_t> legacyNotification(
|
|
notificationBytes.begin(), notificationBytes.begin() + 18);
|
|
legacyNotification[0] = 1;
|
|
legacyNotification.insert(legacyNotification.end(),
|
|
notificationBytes.begin() + 26, notificationBytes.end());
|
|
Check(ParseNotification(legacyNotification.data(), legacyNotification.size(),
|
|
parsedNotification, error) &&
|
|
parsedNotification.actionToken == 0,
|
|
"legacy notification payloads remain readable without an action");
|
|
Message notificationOpen;
|
|
notificationOpen.type = MessageType::NotificationOpen;
|
|
notificationOpen.sequence = notification.actionToken;
|
|
const auto notificationOpenBytes = Serialize(notificationOpen);
|
|
Check(ParseHeader(notificationOpenBytes.data(), kHeaderSize, parsed, payloadSize, error) &&
|
|
parsed.type == MessageType::NotificationOpen &&
|
|
parsed.sequence == notification.actionToken && payloadSize == 0,
|
|
"notification open action token round trips in the common header");
|
|
|
|
auto malformedNotification = notificationBytes;
|
|
malformedNotification[2] = 0x7f;
|
|
Check(!ParseNotification(malformedNotification.data(), malformedNotification.size(),
|
|
parsedNotification, error),
|
|
"malformed notification lengths are rejected");
|
|
|
|
RemoteControlStatus remoteStatus{true, false};
|
|
const auto remoteStatusBytes = SerializeRemoteControlStatus(remoteStatus);
|
|
RemoteControlStatus parsedRemoteStatus;
|
|
Check(ParseRemoteControlStatus(remoteStatusBytes.data(), remoteStatusBytes.size(),
|
|
parsedRemoteStatus, error) &&
|
|
parsedRemoteStatus.appEnabled &&
|
|
!parsedRemoteStatus.accessibilityEnabled &&
|
|
!parsedRemoteStatus.Ready(),
|
|
"remote-control consent gates round trip independently");
|
|
Check(!ParseRemoteControlStatus(remoteStatusBytes.data(), 1, parsedRemoteStatus, error),
|
|
"malformed remote-control gate status is rejected");
|
|
}
|
|
|
|
void TestOverlayInteraction() {
|
|
const auto start = phonecast::vr::StartLocation(0.35F, 0.1F, 0.85F);
|
|
const float length = std::sqrt(0.35F*0.35F + 0.1F*0.1F + 0.85F*0.85F);
|
|
Check(start[3] == 0.35F && start[7] == 0.1F && start[11] == -0.85F &&
|
|
std::fabs(start[2]*(-0.35F) + start[6]*(-0.1F) + start[10]*0.85F - length) < 0.001F,
|
|
"start location retains position and its normal faces the snapshot user");
|
|
Check(phonecast::vr::AdjustPanelDepth(1.0F, 1.0F, 0.04F) > 1.0F &&
|
|
phonecast::vr::AdjustPanelDepth(1.0F, -1.0F, 0.04F) < 1.0F &&
|
|
phonecast::vr::AdjustPanelDepth(1.0F, 0.1F, 0.04F) == 1.0F &&
|
|
phonecast::vr::AdjustPanelDepth(3.0F, 1.0F, 0.04F) == 3.0F &&
|
|
phonecast::vr::AdjustPanelDepth(0.20F, -1.0F, 0.04F) == 0.20F,
|
|
"move-handle stick depth has correct direction, deadzone, and bounds");
|
|
phonecast::vr::PanelDepthScroll scroll;
|
|
scroll.Add(0.1F, true, true);
|
|
Check(std::fabs(scroll.TakeDistance(1.0F) - 1.005F) < 0.0001F,
|
|
"smooth scroll increments have no axis deadzone and positive means farther");
|
|
Check(scroll.TakeDistance(1.0F) == 1.0F, "scroll increments are consumed once");
|
|
scroll.Add(-1.0F, true, true);
|
|
Check(std::fabs(scroll.TakeDistance(1.0F) - 0.97F) < 0.0001F,
|
|
"negative scroll means nearer with bounded per-pump displacement");
|
|
scroll.Add(1.0F, false, true);
|
|
scroll.Add(1.0F, true, false);
|
|
scroll.Add(std::numeric_limits<float>::infinity(), true, true);
|
|
Check(scroll.TakeDistance(1.0F) == 1.0F && scroll.discreteEvents == 1 && scroll.smoothEvents == 2,
|
|
"duplicate discrete events, other-hand input, and nonfinite deltas cannot move a drag");
|
|
for (int i = 0; i < 100; ++i) scroll.Add(1.0F, true, true);
|
|
Check(std::fabs(scroll.TakeDistance(1.0F) - 1.03F) < 0.0001F,
|
|
"bursty scroll cannot create an uncontrolled depth jump");
|
|
scroll.Add(1.0F, true, true);
|
|
Check(scroll.TakeDistance(3.0F) == 3.0F, "scroll depth clamps far bound");
|
|
scroll.Add(-1.0F, true, true);
|
|
Check(scroll.TakeDistance(0.20F) == 0.20F, "scroll depth clamps near bound");
|
|
scroll.Add(1.0F, true, true);
|
|
scroll = {};
|
|
Check(scroll.TakeDistance(1.0F) == 1.0F && scroll.smoothEvents == 0,
|
|
"drag cancellation clears pending depth and diagnostic counts");
|
|
phonecast::vr::OverlayInteractionController interaction;
|
|
interaction.SetSurfaceSize(1000, 2000);
|
|
const auto down = interaction.PointerDown(250.0F, 500.0F);
|
|
const auto move = interaction.PointerMove(750.0F, 1500.0F);
|
|
const auto back = interaction.Back();
|
|
Check(down.type == phonecast::core::PointerEvent::Type::Down &&
|
|
down.normalizedX == 0.25F && down.normalizedY == 0.75F,
|
|
"OpenVR coordinates map to normalized top-left phone coordinates");
|
|
Check(move.sequence == down.sequence + 1 && move.normalizedX == 0.75F &&
|
|
move.normalizedY == 0.25F,
|
|
"pointer motion retains ordering and coordinate orientation");
|
|
Check(back.type == phonecast::core::PointerEvent::Type::Back &&
|
|
back.sequence == move.sequence + 1,
|
|
"Android Back is represented independently of renderer APIs");
|
|
|
|
interaction.SetSurfaceSize(1000, 2000, 100);
|
|
const auto top = interaction.PointerDown(250.0F, 2100.0F);
|
|
const auto bottom = interaction.PointerUp(250.0F, 100.0F);
|
|
Check(interaction.IsGrabHandle(50.0F) && !interaction.IsGrabHandle(100.0F),
|
|
"the bottom inset is reserved for the overlay grab handle");
|
|
Check(interaction.IsBackButton(50.0F, 50.0F) &&
|
|
!interaction.IsBackButton(100.0F, 50.0F) &&
|
|
!interaction.IsBackButton(50.0F, 100.0F),
|
|
"the lower-left handle corner is reserved for Android Back");
|
|
Check(interaction.IsCloseButton(850.0F, 50.0F) &&
|
|
!interaction.IsCloseButton(50.0F, 50.0F) &&
|
|
!interaction.IsCloseButton(850.0F, 100.0F),
|
|
"Close is separated from Back and placed beside the resize corner");
|
|
Check(interaction.IsResizeButton(950.0F, 50.0F) &&
|
|
!interaction.IsResizeButton(899.0F, 50.0F) &&
|
|
!interaction.IsResizeButton(950.0F, 100.0F),
|
|
"the lower-right handle corner is reserved for resizing the phone");
|
|
Check(top.normalizedY == 0.0F && bottom.normalizedY == 1.0F,
|
|
"the grab handle inset does not offset phone touch coordinates");
|
|
}
|
|
|
|
void TestOverlayControls() {
|
|
phonecast::vr::OverlaySettings initial;
|
|
initial.widthMeters = 0.65F;
|
|
initial.distanceMeters = 1.0F;
|
|
phonecast::vr::OverlayController controls(initial);
|
|
controls.Apply(phonecast::vr::OverlayAction::MoveRight);
|
|
controls.Apply(phonecast::vr::OverlayAction::MoveUp);
|
|
controls.Apply(phonecast::vr::OverlayAction::ScaleUp);
|
|
Check(controls.Settings().offsetXMeters > 0.0F &&
|
|
controls.Settings().offsetYMeters > 0.0F,
|
|
"overlay controls move the transform");
|
|
Check(controls.Settings().widthMeters > initial.widthMeters,
|
|
"overlay controls change scale");
|
|
controls.Apply(phonecast::vr::OverlayAction::ToggleVisibility);
|
|
Check(!controls.Visible(), "overlay visibility toggles");
|
|
controls.Apply(phonecast::vr::OverlayAction::Reset);
|
|
Check(controls.Visible() && controls.Settings().offsetXMeters == 0.0F &&
|
|
controls.Settings().widthMeters == initial.widthMeters,
|
|
"overlay reset restores initial state");
|
|
for (int count = 0; count < 100; ++count) {
|
|
controls.Apply(phonecast::vr::OverlayAction::OpacityDown);
|
|
controls.Apply(phonecast::vr::OverlayAction::DistanceNearer);
|
|
}
|
|
Check(controls.Settings().alpha == 0.0F && controls.Settings().distanceMeters == 0.2F,
|
|
"overlay controls clamp safe ranges");
|
|
|
|
controls.Apply(phonecast::vr::OverlayAction::WorldLocked);
|
|
Check(controls.Settings().placementMode == phonecast::vr::PlacementMode::WorldLocked &&
|
|
!controls.Settings().worldTransformValid,
|
|
"world mode requests a fresh anchor");
|
|
auto anchored = controls.Settings();
|
|
anchored.worldTransformValid = true;
|
|
controls.ReplaceSettings(anchored);
|
|
const float originalWorldX = controls.Settings().worldTransform[3];
|
|
controls.Apply(phonecast::vr::OverlayAction::MoveRight);
|
|
Check(controls.Settings().worldTransform[3] > originalWorldX,
|
|
"world controls move the absolute anchor");
|
|
controls.Apply(phonecast::vr::OverlayAction::LeftControllerLocked);
|
|
Check(controls.Settings().placementMode == phonecast::vr::PlacementMode::LeftControllerLocked,
|
|
"left controller mode is selectable");
|
|
controls.Apply(phonecast::vr::OverlayAction::MoveRight);
|
|
controls.Apply(phonecast::vr::OverlayAction::MoveUp);
|
|
controls.Apply(phonecast::vr::OverlayAction::DistanceFarther);
|
|
controls.Apply(phonecast::vr::OverlayAction::ScaleDown);
|
|
controls.Apply(phonecast::vr::OverlayAction::ControllerTiltUp);
|
|
controls.Apply(phonecast::vr::OverlayAction::ControllerYawRight);
|
|
controls.Apply(phonecast::vr::OverlayAction::ControllerOrientationNext);
|
|
Check(controls.Settings().leftController.lateralMeters > 0.0F &&
|
|
controls.Settings().leftController.heightMeters > 0.10F &&
|
|
controls.Settings().leftController.distanceMeters > 0.18F &&
|
|
controls.Settings().leftController.scale < 1.0F &&
|
|
controls.Settings().leftController.tiltDegrees > 0.0F &&
|
|
controls.Settings().leftController.yawDegrees > 0.0F &&
|
|
controls.Settings().leftController.orientation ==
|
|
phonecast::vr::ControllerOrientation::WorldUpright,
|
|
"active hand calibration is independently adjustable");
|
|
Check(controls.Settings().rightController.lateralMeters == 0.0F,
|
|
"inactive hand calibration is unchanged");
|
|
controls.Apply(phonecast::vr::OverlayAction::ResetControllerCalibration);
|
|
Check(controls.Settings().leftController.lateralMeters == 0.0F &&
|
|
controls.Settings().leftController.scale == 1.0F,
|
|
"active hand calibration resets");
|
|
}
|
|
|
|
void TestControllerShortcut() {
|
|
using phonecast::vr::ControllerDockShortcutAction;
|
|
using phonecast::vr::GlanceInput;
|
|
using phonecast::vr::PlacementMode;
|
|
using phonecast::vr::RadialMenuAction;
|
|
|
|
Check(ControllerDockShortcutAction(GlanceInput::LeftController, false,
|
|
PlacementMode::LeftControllerLocked) == RadialMenuAction::LeftControllerLocked,
|
|
"a hidden phone is reopened on the requested controller");
|
|
Check(ControllerDockShortcutAction(GlanceInput::LeftController, true,
|
|
PlacementMode::RightControllerLocked) == RadialMenuAction::LeftControllerLocked,
|
|
"a different controller receives the visible phone");
|
|
Check(ControllerDockShortcutAction(GlanceInput::LeftController, true,
|
|
PlacementMode::LeftControllerLocked) == RadialMenuAction::PinCurrentPosition,
|
|
"repeating the left dock shortcut pins at the current position");
|
|
Check(ControllerDockShortcutAction(GlanceInput::RightController, true,
|
|
PlacementMode::RightControllerLocked) == RadialMenuAction::PinCurrentPosition,
|
|
"repeating the right dock shortcut pins at the current position");
|
|
}
|
|
|
|
void TestGlanceMode() {
|
|
phonecast::vr::OverlaySettings base;
|
|
base.placementMode = phonecast::vr::PlacementMode::HeadLocked;
|
|
base.widthMeters = 0.8F;
|
|
phonecast::vr::GlanceController glance;
|
|
Check(glance.State() == phonecast::vr::GlanceState::Hidden && !glance.Visible(),
|
|
"glance mode starts hidden");
|
|
glance.SetHand(phonecast::vr::GlanceHand::Right);
|
|
Check(glance.Hand() == phonecast::vr::GlanceHand::Right,
|
|
"keyboard fallback can select the glance hand");
|
|
glance.Cycle(glance.Hand());
|
|
auto presented = glance.PresentationSettings(base);
|
|
Check(glance.State() == phonecast::vr::GlanceState::Glance && glance.Visible() &&
|
|
presented.placementMode == phonecast::vr::PlacementMode::RightControllerLocked &&
|
|
presented.widthMeters < base.widthMeters,
|
|
"glance preview uses the initiating hand and reduced size");
|
|
glance.Cycle(phonecast::vr::GlanceHand::Right);
|
|
presented = glance.PresentationSettings(base);
|
|
Check(glance.State() == phonecast::vr::GlanceState::Expanded &&
|
|
presented.placementMode == base.placementMode &&
|
|
presented.widthMeters == base.widthMeters,
|
|
"expanded view restores normal placement and size");
|
|
glance.Cycle(phonecast::vr::GlanceHand::Right);
|
|
Check(glance.State() == phonecast::vr::GlanceState::Pinned && glance.Visible(),
|
|
"expanded view can be pinned");
|
|
glance.Cycle(phonecast::vr::GlanceHand::Right);
|
|
Check(glance.State() == phonecast::vr::GlanceState::Hidden && !glance.Visible(),
|
|
"pinned view cycles back to hidden");
|
|
glance.ToggleExpanded();
|
|
Check(glance.State() == phonecast::vr::GlanceState::Expanded,
|
|
"quick toggle opens the expanded view");
|
|
glance.ToggleExpanded();
|
|
Check(!glance.Visible(), "quick toggle hides a visible view");
|
|
glance.ShowGlance(phonecast::vr::GlanceHand::Left);
|
|
presented = glance.PresentationSettings(base);
|
|
Check(glance.State() == phonecast::vr::GlanceState::Glance &&
|
|
presented.placementMode == phonecast::vr::PlacementMode::LeftControllerLocked,
|
|
"radial menu can request a left-hand glance preview");
|
|
glance.ShowPinned();
|
|
Check(glance.State() == phonecast::vr::GlanceState::Pinned && glance.Visible(),
|
|
"radial menu can pin the normal presentation");
|
|
}
|
|
|
|
void TestOverlaySettingsPersistence() {
|
|
const auto path = std::filesystem::temp_directory_path() / "phonecast-overlay-settings-test.ini";
|
|
std::error_code ignored;
|
|
std::filesystem::remove(path, ignored);
|
|
phonecast::vr::OverlaySettingsStore store(path);
|
|
phonecast::vr::OverlaySettings saved;
|
|
saved.placementMode = phonecast::vr::PlacementMode::WorldLocked;
|
|
saved.worldTransformValid = true;
|
|
saved.worldTransform[3] = 2.25F;
|
|
saved.widthMeters = 0.9F;
|
|
saved.leftController.distanceMeters = 0.42F;
|
|
saved.leftController.heightMeters = -0.12F;
|
|
saved.leftController.lateralMeters = 0.08F;
|
|
saved.leftController.tiltDegrees = 25.0F;
|
|
saved.leftController.yawDegrees = -15.0F;
|
|
saved.leftController.scale = 1.4F;
|
|
saved.leftController.orientation = phonecast::vr::ControllerOrientation::ControllerRelative;
|
|
saved.rightController.orientation = phonecast::vr::ControllerOrientation::Wrist;
|
|
saved.glancePreviewScale = 0.7F;
|
|
saved.radialLongPressMilliseconds = 850;
|
|
saved.notificationWidthMeters = 0.75F;
|
|
saved.notificationDistanceMeters = 0.65F;
|
|
saved.notificationOffsetXMeters = -0.15F;
|
|
saved.notificationOffsetYMeters = 0.05F;
|
|
saved.startDistanceMeters = 1.1F;
|
|
saved.startOffsetXMeters = 0.22F;
|
|
saved.startOffsetYMeters = -0.12F;
|
|
std::string error;
|
|
Check(store.Save(saved, error), "overlay settings save");
|
|
phonecast::vr::OverlaySettings loaded;
|
|
bool found = false;
|
|
Check(store.Load(loaded, found, error) && found, "overlay settings load");
|
|
Check(loaded.placementMode == phonecast::vr::PlacementMode::WorldLocked &&
|
|
loaded.worldTransformValid && loaded.worldTransform[3] == 2.25F &&
|
|
loaded.widthMeters == 0.9F &&
|
|
loaded.leftController.distanceMeters == 0.42F &&
|
|
loaded.leftController.heightMeters == -0.12F &&
|
|
loaded.leftController.lateralMeters == 0.08F &&
|
|
loaded.leftController.tiltDegrees == 25.0F &&
|
|
loaded.leftController.yawDegrees == -15.0F &&
|
|
loaded.leftController.scale == 1.4F &&
|
|
loaded.leftController.orientation == phonecast::vr::ControllerOrientation::ControllerRelative &&
|
|
loaded.rightController.orientation == phonecast::vr::ControllerOrientation::Wrist &&
|
|
loaded.glancePreviewScale == 0.7F &&
|
|
loaded.radialLongPressMilliseconds == 850 &&
|
|
loaded.notificationWidthMeters == 0.75F &&
|
|
loaded.notificationDistanceMeters == 0.65F &&
|
|
loaded.notificationOffsetXMeters == -0.15F &&
|
|
loaded.notificationOffsetYMeters == 0.05F &&
|
|
loaded.startDistanceMeters == 1.1F &&
|
|
loaded.startOffsetXMeters == 0.22F && loaded.startOffsetYMeters == -0.12F,
|
|
"overlay settings round trip");
|
|
std::ifstream source(path);
|
|
std::string text((std::istreambuf_iterator<char>(source)), std::istreambuf_iterator<char>());
|
|
source.close();
|
|
text.replace(text.find("version=5"), 9, "version=4");
|
|
{ std::ofstream output(path); output << text; }
|
|
Check(store.Load(loaded, found, error) && found && loaded.startOffsetXMeters == 0.35F &&
|
|
loaded.startDistanceMeters == 0.85F,
|
|
"version four migrates with safer start-location defaults");
|
|
Check(store.Save(saved, error), "restore version five settings");
|
|
std::ifstream current(path);
|
|
text.assign(std::istreambuf_iterator<char>(current), std::istreambuf_iterator<char>());
|
|
current.close();
|
|
const auto key = text.find("start_distance=");
|
|
text.replace(key, text.find('\n', key) - key, "start_distance=nan");
|
|
{ std::ofstream output(path); output << text; }
|
|
const float before = loaded.startDistanceMeters;
|
|
Check(!store.Load(loaded, found, error) && loaded.startDistanceMeters == before,
|
|
"nonfinite start location is rejected without replacing live settings");
|
|
std::filesystem::remove(path, ignored);
|
|
}
|
|
|
|
void TestSettingsMenu() {
|
|
phonecast::vr::OverlaySettings initial;
|
|
initial.widthMeters = 0.65F;
|
|
phonecast::vr::SettingsMenuController menu;
|
|
menu.Open(initial);
|
|
Check(menu.IsOpen() && menu.View().title == "PHONECAST SETTINGS",
|
|
"settings menu opens at its root");
|
|
auto rendererUpdate = initial;
|
|
rendererUpdate.widthMeters = 0.80F;
|
|
rendererUpdate.worldTransformValid = true;
|
|
rendererUpdate.worldTransform[3] = 1.25F;
|
|
menu.MergeRendererUpdate(rendererUpdate);
|
|
Check(menu.Draft().widthMeters == 0.80F && menu.Draft().worldTransformValid &&
|
|
menu.Draft().worldTransform[3] == 1.25F &&
|
|
menu.Original().widthMeters == initial.widthMeters &&
|
|
!menu.Original().worldTransformValid,
|
|
"renderer resize and anchors merge into the draft without changing cancel state");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Activate);
|
|
Check(menu.View().title == "APPEARANCE", "settings menu opens Appearance");
|
|
Check(menu.Handle(phonecast::vr::SettingsMenuCommand::Increase) ==
|
|
phonecast::vr::SettingsMenuResult::Updated &&
|
|
menu.Draft().widthMeters > initial.widthMeters,
|
|
"settings changes update the transactional draft");
|
|
Check(menu.View().normalizedValues[0] >= 0.0F &&
|
|
menu.Handle({phonecast::vr::SettingsMenuCommand::SetNormalized, 1.0F}) ==
|
|
phonecast::vr::SettingsMenuResult::Updated &&
|
|
menu.Draft().widthMeters == 2.0F,
|
|
"settings sliders expose and apply normalized values");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Back);
|
|
for (int index = 0; index < 9; ++index)
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
Check(menu.Handle(phonecast::vr::SettingsMenuCommand::Activate) ==
|
|
phonecast::vr::SettingsMenuResult::Applied && !menu.IsOpen(),
|
|
"settings can be applied explicitly");
|
|
|
|
menu.Open(initial);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Activate);
|
|
for (int i = 0; i < 4; ++i) menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Activate);
|
|
Check(menu.View().showStartLocationPreview && menu.View().title == "START LOCATION",
|
|
"Placement Start Location requests a separate dummy preview");
|
|
menu.Handle({phonecast::vr::SettingsMenuCommand::SetNormalized, 1.0F});
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle({phonecast::vr::SettingsMenuCommand::SetNormalized, 0.0F});
|
|
Check(menu.Draft().startDistanceMeters == 2.5F && menu.Draft().startOffsetXMeters == -1.0F &&
|
|
menu.Original().startDistanceMeters == initial.startDistanceMeters,
|
|
"start-location sliders are bounded and preserve the cancel snapshot");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Back);
|
|
Check(!menu.View().showStartLocationPreview, "leaving Start Location removes its dummy preview");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Back);
|
|
Check(!menu.IsOpen(), "start location changes can be cancelled");
|
|
|
|
menu.Open(initial);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Activate);
|
|
Check(menu.View().title == "GLANCE AND CONTROLS", "Glance settings are reachable");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Increase);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Increase);
|
|
Check(menu.Draft().glancePreviewScale > initial.glancePreviewScale &&
|
|
menu.Draft().radialLongPressMilliseconds > initial.radialLongPressMilliseconds,
|
|
"Glance preview and long-press settings are editable");
|
|
Check(menu.Handle(phonecast::vr::SettingsMenuCommand::Back) ==
|
|
phonecast::vr::SettingsMenuResult::None,
|
|
"Back returns from a settings page");
|
|
for (int index = 0; index < 5; ++index)
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Activate);
|
|
Check(menu.View().title == "NOTIFICATIONS" &&
|
|
menu.View().showNotificationPreview,
|
|
"Notification placement settings request a live card preview");
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Increase);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Decrease);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Decrease);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::NextItem);
|
|
menu.Handle(phonecast::vr::SettingsMenuCommand::Decrease);
|
|
Check(menu.Draft().notificationWidthMeters > initial.notificationWidthMeters &&
|
|
menu.Draft().notificationDistanceMeters < initial.notificationDistanceMeters &&
|
|
menu.Draft().notificationOffsetXMeters < initial.notificationOffsetXMeters &&
|
|
menu.Draft().notificationOffsetYMeters < initial.notificationOffsetYMeters,
|
|
"notification size and head-relative placement are editable");
|
|
Check(menu.Handle(phonecast::vr::SettingsMenuCommand::Back) ==
|
|
phonecast::vr::SettingsMenuResult::None,
|
|
"Back returns from notification settings");
|
|
Check(menu.Handle(phonecast::vr::SettingsMenuCommand::Back) ==
|
|
phonecast::vr::SettingsMenuResult::Cancelled && !menu.IsOpen() &&
|
|
menu.Original().widthMeters == initial.widthMeters,
|
|
"Back from the root cancels the transaction");
|
|
}
|
|
|
|
void TestWristMenuGesture() {
|
|
using phonecast::vr::WristMenuGestureAction;
|
|
phonecast::vr::WristMenuGesture gesture;
|
|
phonecast::vr::WristMenuGestureObservation observation;
|
|
|
|
observation.nowMilliseconds = 100;
|
|
Check(gesture.Update(observation).action == WristMenuGestureAction::None,
|
|
"neutral wrists arm the gesture without opening");
|
|
observation.leftFacing = true;
|
|
observation.nowMilliseconds = 200;
|
|
Check(gesture.Update(observation).action == WristMenuGestureAction::None,
|
|
"wrist gesture waits for its opening dwell");
|
|
observation.nowMilliseconds = 1700;
|
|
const auto halfway = gesture.Update(observation);
|
|
Check(halfway.action == WristMenuGestureAction::None &&
|
|
halfway.progress > 0.45F && halfway.progress < 0.55F,
|
|
"wrist gesture reports visible hold progress");
|
|
observation.nowMilliseconds = 3200;
|
|
Check(gesture.Update(observation).action == WristMenuGestureAction::OpenLeft,
|
|
"stable three-second left wrist flip opens the left menu");
|
|
|
|
observation.menuVisible = true;
|
|
observation.leftFacing = false;
|
|
observation.nowMilliseconds = 1200;
|
|
Check(gesture.Update(observation).action == WristMenuGestureAction::None,
|
|
"open menu remains available after the wrist moves away");
|
|
|
|
gesture.Reset();
|
|
observation = {};
|
|
observation.nowMilliseconds = 100;
|
|
gesture.Update(observation);
|
|
observation.rightFacing = true;
|
|
observation.nowMilliseconds = 200;
|
|
gesture.Update(observation);
|
|
observation.nowMilliseconds = 3200;
|
|
Check(gesture.Update(observation).action == WristMenuGestureAction::OpenRight,
|
|
"stable three-second right wrist flip opens the right menu");
|
|
}
|
|
|
|
void TestReceiverLifecycle() {
|
|
phonecast::core::GeneratedVideoSource source(64, 64);
|
|
FakeOverlay overlay;
|
|
FakeLogger logger;
|
|
phonecast::apps::Receiver receiver(source, overlay, logger);
|
|
std::string error;
|
|
Check(receiver.Start({}, error), "receiver starts");
|
|
Check(receiver.Tick(error), "receiver submits a frame");
|
|
Check(overlay.frames == 1 && receiver.SubmittedFrames() == 1, "submission is counted");
|
|
receiver.Stop();
|
|
receiver.Stop();
|
|
Check(overlay.stopped, "renderer stops safely");
|
|
|
|
FakeOverlay quittingOverlay;
|
|
phonecast::apps::Receiver quittingReceiver(source, quittingOverlay, logger);
|
|
Check(quittingReceiver.Start({}, error), "second receiver starts");
|
|
quittingOverlay.keepRunning = false;
|
|
error = "stale";
|
|
Check(!quittingReceiver.Tick(error) && error.empty(), "runtime shutdown is clean");
|
|
quittingReceiver.Stop();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
TestConfig();
|
|
TestDecoderRecovery();
|
|
TestGeneratedFrames();
|
|
TestStreamProtocol();
|
|
TestOverlayInteraction();
|
|
TestOverlayControls();
|
|
TestControllerShortcut();
|
|
TestGlanceMode();
|
|
TestOverlaySettingsPersistence();
|
|
TestSettingsMenu();
|
|
TestWristMenuGesture();
|
|
TestReceiverLifecycle();
|
|
if (failures == 0) std::cout << "All PhoneCast tests passed.\n";
|
|
return failures == 0 ? 0 : 1;
|
|
}
|