Initial FrameAssistant baseline from the vrdashboard template
Template stripped to the overlay platform (examples, demo UI and Frame test package removed), renamed to FrameAssistant, and working rules for the project recorded in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle { color: "#0000ff" }
|
||||
@@ -0,0 +1,35 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
|
||||
# Unit tests run without SteamVR or a headset. They use the "offscreen" QPA platform;
|
||||
# tests that need a GPU context QSKIP themselves when none is available.
|
||||
|
||||
qt_add_executable(tst_inputbridge tst_inputbridge.cpp)
|
||||
target_link_libraries(tst_inputbridge PRIVATE vrdashboard_core Qt6::Test)
|
||||
add_test(NAME inputbridge COMMAND tst_inputbridge)
|
||||
|
||||
qt_add_executable(tst_render tst_render.cpp)
|
||||
target_link_libraries(tst_render PRIVATE vrdashboard_core Qt6::Test)
|
||||
qt_add_qml_module(tst_render
|
||||
URI RenderTest
|
||||
VERSION 1.0
|
||||
QML_FILES TwoTone.qml Spinner.qml)
|
||||
add_test(NAME render_opengl COMMAND tst_render)
|
||||
set_tests_properties(render_opengl PROPERTIES ENVIRONMENT "VRDASH_TEST_API=opengl")
|
||||
add_test(NAME render_vulkan COMMAND tst_render)
|
||||
set_tests_properties(render_vulkan PROPERTIES ENVIRONMENT "VRDASH_TEST_API=vulkan")
|
||||
|
||||
set_tests_properties(inputbridge PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# Two surfaces in one app (snapshot mode): set-up, hooks, rendering per surface.
|
||||
qt_add_executable(tst_surfaces tst_surfaces.cpp)
|
||||
target_link_libraries(tst_surfaces PRIVATE vrdashboard_core)
|
||||
qt_add_qml_module(tst_surfaces
|
||||
URI SurfacesTest
|
||||
VERSION 1.0
|
||||
QML_FILES Red.qml Blue.qml)
|
||||
add_test(NAME surfaces_opengl COMMAND tst_surfaces --graphics-api opengl)
|
||||
add_test(NAME surfaces_vulkan COMMAND tst_surfaces --graphics-api vulkan)
|
||||
add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl)
|
||||
set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1")
|
||||
set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
|
||||
PROPERTIES SKIP_RETURN_CODE 77)
|
||||
@@ -0,0 +1,3 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle { color: "#ff0000" }
|
||||
@@ -0,0 +1,10 @@
|
||||
import QtQuick
|
||||
|
||||
// Continuously animated scene: keeps the on-demand renderer busy (pacing test).
|
||||
Item {
|
||||
Rectangle {
|
||||
width: 16; height: 16
|
||||
color: "white"
|
||||
NumberAnimation on rotation { from: 0; to: 360; duration: 1000; loops: Animation.Infinite }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import QtQuick
|
||||
|
||||
// Top half red, bottom half blue: lets the render test check content and orientation.
|
||||
Item {
|
||||
Rectangle { width: parent.width; height: parent.height / 2; color: "#ff0000" }
|
||||
Rectangle { y: parent.height / 2; width: parent.width; height: parent.height / 2; color: "#0000ff" }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <QFocusEvent>
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickRenderControl>
|
||||
#include <QQuickWindow>
|
||||
#include <QtTest>
|
||||
|
||||
#include "input/OverlayInputBridge.h"
|
||||
|
||||
// Feeds synthetic SteamVR overlay events into OverlayInputBridge and checks
|
||||
// that the QML scene reacts. No graphics are initialized; event delivery
|
||||
// doesn't need them.
|
||||
class InputBridgeTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void init() {
|
||||
m_renderControl = std::make_unique<QQuickRenderControl>();
|
||||
m_window = std::make_unique<QQuickWindow>(m_renderControl.get());
|
||||
m_window->setGeometry(0, 0, kSize.width(), kSize.height());
|
||||
|
||||
QQmlComponent component(&m_engine);
|
||||
component.setData(R"(
|
||||
import QtQuick
|
||||
Item {
|
||||
width: 400; height: 300
|
||||
property int clicks: 0
|
||||
MouseArea {
|
||||
objectName: "top"; width: 400; height: 100
|
||||
onClicked: parent.clicks++
|
||||
}
|
||||
Flickable {
|
||||
objectName: "flick"; y: 100; width: 400; height: 100
|
||||
contentHeight: 1000
|
||||
}
|
||||
TextInput {
|
||||
objectName: "input"; y: 250; width: 400; height: 50
|
||||
}
|
||||
})",
|
||||
QUrl());
|
||||
m_root.reset(qobject_cast<QQuickItem*>(component.create()));
|
||||
QVERIFY2(m_root, qPrintable(component.errorString()));
|
||||
m_root->setParentItem(m_window->contentItem());
|
||||
|
||||
QFocusEvent focusIn(QEvent::FocusIn, Qt::ActiveWindowFocusReason);
|
||||
QCoreApplication::sendEvent(m_window.get(), &focusIn);
|
||||
|
||||
m_bridge =
|
||||
std::make_unique<OverlayInputBridge>(m_window.get(), kSize, kOverlay);
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
m_bridge.reset();
|
||||
m_root.reset();
|
||||
m_window.reset();
|
||||
m_renderControl.reset();
|
||||
}
|
||||
|
||||
void clickUsesGlCoordinates() {
|
||||
// GL y=250 (from bottom) is Qt y=50: inside the top MouseArea.
|
||||
click(200, 250);
|
||||
QCOMPARE(m_root->property("clicks").toInt(), 1);
|
||||
// GL y=50 is Qt y=250: below it, must not count.
|
||||
click(200, 50);
|
||||
QCOMPARE(m_root->property("clicks").toInt(), 1);
|
||||
}
|
||||
|
||||
void smoothScrollMovesFlickable() {
|
||||
auto* flick = m_root->findChild<QQuickItem*>("flick");
|
||||
mouse(vr::VREvent_MouseMove, 200, kSize.height() - 150);
|
||||
for (int i = 0; i < 5; ++i) send(scrollEvent(0.f, -1.f));
|
||||
QTRY_VERIFY(flick->property("contentY").toReal() > 0);
|
||||
}
|
||||
|
||||
void keyboardCharsReachFocusedTextInput() {
|
||||
auto* input = m_root->findChild<QQuickItem*>("input");
|
||||
input->forceActiveFocus();
|
||||
QVERIFY(input->hasActiveFocus());
|
||||
|
||||
send(keyboardEvent("ab", kOverlay));
|
||||
send(keyboardEvent("\xc3\xa4", kOverlay)); // UTF-8 'ä'
|
||||
send(keyboardEvent("\b", kOverlay));
|
||||
// Other overlay: ignored
|
||||
QVERIFY(!m_bridge->handleEvent(keyboardEvent("c", kOverlay + 1)));
|
||||
QCOMPARE(input->property("text").toString(), QStringLiteral("ab"));
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr QSize kSize{400, 300};
|
||||
static constexpr vr::VROverlayHandle_t kOverlay = 42;
|
||||
|
||||
void send(const vr::VREvent_t& event) {
|
||||
QVERIFY(m_bridge->handleEvent(event));
|
||||
}
|
||||
|
||||
void mouse(uint32_t type, float x, float y) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = type;
|
||||
event.data.mouse.x = x;
|
||||
event.data.mouse.y = y;
|
||||
event.data.mouse.button = vr::VRMouseButton_Left;
|
||||
send(event);
|
||||
}
|
||||
|
||||
void click(float x, float y) {
|
||||
mouse(vr::VREvent_MouseMove, x, y);
|
||||
mouse(vr::VREvent_MouseButtonDown, x, y);
|
||||
mouse(vr::VREvent_MouseButtonUp, x, y);
|
||||
}
|
||||
|
||||
static vr::VREvent_t scrollEvent(float dx, float dy) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = vr::VREvent_ScrollSmooth;
|
||||
event.data.scroll.xdelta = dx;
|
||||
event.data.scroll.ydelta = dy;
|
||||
return event;
|
||||
}
|
||||
|
||||
static vr::VREvent_t keyboardEvent(const char* utf8,
|
||||
vr::VROverlayHandle_t overlay) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = vr::VREvent_KeyboardCharInput;
|
||||
qstrncpy(event.data.keyboard.cNewInput, utf8,
|
||||
sizeof(event.data.keyboard.cNewInput));
|
||||
event.data.keyboard.overlayHandle = overlay;
|
||||
return event;
|
||||
}
|
||||
|
||||
QQmlEngine m_engine;
|
||||
std::unique_ptr<QQuickRenderControl> m_renderControl;
|
||||
std::unique_ptr<QQuickWindow> m_window;
|
||||
std::unique_ptr<QQuickItem> m_root;
|
||||
std::unique_ptr<OverlayInputBridge> m_bridge;
|
||||
};
|
||||
|
||||
QTEST_MAIN(InputBridgeTest)
|
||||
#include "tst_inputbridge.moc"
|
||||
@@ -0,0 +1,118 @@
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickWindow>
|
||||
#include <QtTest>
|
||||
|
||||
#include "render/OffscreenQuickRenderer.h"
|
||||
#include "render/RenderBackend.h"
|
||||
|
||||
// Renders a known scene through the real offscreen pipeline (backend +
|
||||
// QQuickRenderControl) and checks the texture content via readback. The backend
|
||||
// comes from VRDASH_TEST_API (opengl / vulkan): the graphics API is
|
||||
// process-global, so CTest runs one process each.
|
||||
class RenderTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
GraphicsApi api = GraphicsApi::OpenGL;
|
||||
const QString apiName =
|
||||
qEnvironmentVariable("VRDASH_TEST_API", QStringLiteral("opengl"));
|
||||
QVERIFY2(parseGraphicsApi(apiName, &api), qPrintable(apiName));
|
||||
m_backend = createRenderBackend(api);
|
||||
if (!m_backend)
|
||||
QSKIP(qPrintable(apiName + QStringLiteral(" not compiled in")));
|
||||
QQuickWindow::setGraphicsApi(m_backend->graphicsApi());
|
||||
QString error;
|
||||
if (!m_backend->initialize({}, &error))
|
||||
QSKIP(qPrintable(
|
||||
QStringLiteral("No %1 context: %2").arg(m_backend->name(), error)));
|
||||
}
|
||||
|
||||
void rendersSceneUpright() {
|
||||
QQmlEngine engine;
|
||||
const QSize size(64, 32);
|
||||
OffscreenQuickRenderer renderer(*m_backend, size);
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
|
||||
qPrintable(error));
|
||||
|
||||
int sinkCalls = 0;
|
||||
vr::Texture_t submitted{};
|
||||
renderer.setFrameSink(
|
||||
[&](const vr::Texture_t& texture, const vr::VRTextureBounds_t&) {
|
||||
++sinkCalls;
|
||||
submitted = texture;
|
||||
});
|
||||
renderer.renderNow();
|
||||
|
||||
QCOMPARE(sinkCalls, 1);
|
||||
QVERIFY(submitted.handle != nullptr);
|
||||
const auto expectedType =
|
||||
m_backend->graphicsApi() == QSGRendererInterface::Vulkan
|
||||
? vr::TextureType_Vulkan
|
||||
: vr::TextureType_OpenGL;
|
||||
QCOMPARE(submitted.eType, expectedType);
|
||||
if (submitted.eType == vr::TextureType_Vulkan) {
|
||||
const auto* vk =
|
||||
static_cast<const vr::VRVulkanTextureData_t*>(submitted.handle);
|
||||
QVERIFY(vk->m_nImage != 0 && vk->m_pDevice && vk->m_pQueue &&
|
||||
vk->m_pInstance);
|
||||
QCOMPARE(vk->m_nWidth, uint32_t(size.width()));
|
||||
}
|
||||
QVERIFY(!m_backend->deviceName().isEmpty());
|
||||
|
||||
const QImage image = m_backend->readback();
|
||||
QCOMPARE(image.size(), size);
|
||||
QCOMPARE(image.pixelColor(size.width() / 2, 4), QColor(255, 0, 0));
|
||||
QCOMPARE(image.pixelColor(size.width() / 2, size.height() - 4),
|
||||
QColor(0, 0, 255));
|
||||
}
|
||||
|
||||
// Several frames in a row exercise the per-frame state handling (e.g.
|
||||
// Vulkan layout transitions between Qt's frames and the SteamVR
|
||||
// hand-off).
|
||||
void rendersRepeatedly() {
|
||||
QQmlEngine engine;
|
||||
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
|
||||
qPrintable(error));
|
||||
for (int i = 0; i < 5; ++i) renderer.renderNow();
|
||||
QCOMPARE(m_backend->readback().pixelColor(16, 2), QColor(255, 0, 0));
|
||||
}
|
||||
|
||||
// An animated scene must render at the fps cap even when each frame
|
||||
// takes noticeable time: pacing is measured from frame start, not added
|
||||
// after the frame.
|
||||
void pacingHoldsFrameRateWithSlowFrames() {
|
||||
QQmlEngine engine;
|
||||
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "Spinner", &error),
|
||||
qPrintable(error));
|
||||
int frames = 0;
|
||||
renderer.setFrameSink(
|
||||
[&](const vr::Texture_t&, const vr::VRTextureBounds_t&) {
|
||||
++frames;
|
||||
QThread::usleep(3000); // e.g. a slow SteamVR hand-off
|
||||
});
|
||||
renderer.setMaxFps(60);
|
||||
renderer.setActive(true);
|
||||
QTest::qWait(300); // warm-up
|
||||
frames = 0;
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
QTest::qWait(2000);
|
||||
const double fps = frames * 1000.0 / timer.elapsed();
|
||||
renderer.setActive(false);
|
||||
qInfo("measured %.1f fps", fps);
|
||||
QVERIFY2(fps > 57.0 && fps < 63.0, qPrintable(QString::number(fps)));
|
||||
}
|
||||
|
||||
void cleanupTestCase() { m_backend.reset(); }
|
||||
|
||||
private:
|
||||
std::unique_ptr<RenderBackend> m_backend;
|
||||
};
|
||||
|
||||
QTEST_MAIN(RenderTest)
|
||||
#include "tst_render.moc"
|
||||
@@ -0,0 +1,116 @@
|
||||
// An app with two surfaces, run in snapshot mode: checks surface set-up
|
||||
// (overlay keys, names, sizes, shared engine, backends), the lifecycle hooks
|
||||
// and that every surface renders its own scene. No SteamVR involved.
|
||||
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
|
||||
|
||||
#include <QDir>
|
||||
#include <QImage>
|
||||
#include <QTemporaryDir>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app/DashboardApplication.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool check(bool condition, const char* what) {
|
||||
if (!condition) fprintf(stderr, "FAIL: %s\n", what);
|
||||
return condition;
|
||||
}
|
||||
|
||||
class TwoSurfaceApp : public DashboardApplication {
|
||||
public:
|
||||
TwoSurfaceApp(int& argc, char** argv) : DashboardApplication(argc, argv) {
|
||||
setQmlModule(QStringLiteral("SurfacesTest"));
|
||||
setDisplayName(QStringLiteral("Test App"));
|
||||
setAppKey(QStringLiteral("config.key"));
|
||||
addSurface({.id = QStringLiteral("red"),
|
||||
.qmlRootType = QStringLiteral("Red"),
|
||||
.textureSize = QSize(64, 32)});
|
||||
addSurface({.id = QStringLiteral("blue"),
|
||||
.qmlRootType = QStringLiteral("Blue"),
|
||||
.displayName = QStringLiteral("Blue tab"),
|
||||
.textureSize = QSize(32, 32)});
|
||||
}
|
||||
|
||||
QString expectedKey; // --app-key overrides setAppKey()
|
||||
int loadingCalls = 0;
|
||||
int startedCalls = 0;
|
||||
bool ok = true;
|
||||
|
||||
protected:
|
||||
void loading() override {
|
||||
++loadingCalls;
|
||||
Surface* red = surface(QStringLiteral("red"));
|
||||
Surface* blue = surface(QStringLiteral("blue"));
|
||||
ok &= check(surfaces().size() == 2 && red && blue, "two surfaces by id");
|
||||
ok &= check(surface() == red, "primary is the first");
|
||||
ok &= check(surface(QStringLiteral("none")) == nullptr, "unknown id");
|
||||
ok &= check(engine() != nullptr, "shared engine");
|
||||
ok &= check(mode() == DashboardMode::Snapshot, "mode");
|
||||
ok &= check(red && blue && red->backend() && blue->backend() &&
|
||||
red->backend() != blue->backend(),
|
||||
"a backend per surface");
|
||||
ok &= check(appKey() == expectedKey, "app key (setAppKey / --app-key)");
|
||||
ok &= check(red && red->overlayKey() == expectedKey + QLatin1String(".red"),
|
||||
"overlay key = <app key>.<id>");
|
||||
ok &= check(red && red->displayName() == QLatin1String("Test App"),
|
||||
"tab name defaults to the app name");
|
||||
ok &= check(blue && blue->displayName() == QLatin1String("Blue tab"),
|
||||
"tab name from config");
|
||||
ok &= check(blue && blue->textureSize() == QSize(32, 32),
|
||||
"texture size from config");
|
||||
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
|
||||
}
|
||||
void started() override {
|
||||
++startedCalls;
|
||||
const QList<Surface*> all = surfaces();
|
||||
ok &= check(all.size() == 2 && all[0]->window() && all[1]->window() &&
|
||||
all[0]->window() != all[1]->window(),
|
||||
"a window per surface");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QTemporaryDir dir;
|
||||
const std::string snapshot =
|
||||
QDir(dir.path()).filePath(QStringLiteral("shot.png")).toStdString();
|
||||
// VRDASH_TEST_CLI_KEY=1: also pass --app-key (must win over setAppKey()).
|
||||
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
|
||||
std::vector<std::string> args = {argv[0], "--snapshot", snapshot};
|
||||
if (cliKey) {
|
||||
args.emplace_back("--app-key");
|
||||
args.emplace_back("cli.key");
|
||||
}
|
||||
for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]);
|
||||
std::vector<char*> cargs;
|
||||
for (std::string& arg : args) cargs.push_back(arg.data());
|
||||
int cargc = int(cargs.size());
|
||||
|
||||
TwoSurfaceApp app(cargc, cargs.data());
|
||||
app.expectedKey =
|
||||
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
|
||||
const int result = app.exec();
|
||||
if (app.loadingCalls == 0) {
|
||||
fprintf(stderr, "SKIP: no render backend (no GPU context?)\n");
|
||||
return 77;
|
||||
}
|
||||
bool ok = app.ok;
|
||||
ok &= check(result == 0, "exec() succeeded");
|
||||
ok &= check(app.loadingCalls == 1 && app.startedCalls == 1,
|
||||
"each hook called once");
|
||||
|
||||
const QImage red(QDir(dir.path()).filePath(QStringLiteral("shot-red.png")));
|
||||
const QImage blue(QDir(dir.path()).filePath(QStringLiteral("shot-blue.png")));
|
||||
ok &= check(red.size() == QSize(64, 32) &&
|
||||
red.pixelColor(10, 10) == QColor(255, 0, 0),
|
||||
"red surface rendered");
|
||||
ok &= check(blue.size() == QSize(32, 32) &&
|
||||
blue.pixelColor(10, 10) == QColor(0, 0, 255),
|
||||
"blue surface rendered");
|
||||
fprintf(stderr, ok ? "PASS\n" : "FAILED\n");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user