work in progress

This commit is contained in:
2026-01-16 12:43:06 +01:00
parent 77a9579025
commit c8ee7defe8
236 changed files with 11405 additions and 28 deletions

View File

@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.22.1)
project(designer-test)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Windows-only project
if(NOT WIN32)
message(FATAL_ERROR "designer-test is Windows-only")
endif()
# Find nlohmann_json for test result output
find_package(nlohmann_json CONFIG REQUIRED)
# Main test executable
add_executable(designer-test
src/main.cpp
src/window_controller.cpp
src/test_runner.cpp
src/log_parser.cpp
src/hierarchy_reader.cpp
)
target_include_directories(designer-test PRIVATE src)
target_link_libraries(designer-test PRIVATE
nlohmann_json::nlohmann_json
)
# Link Windows libraries
target_link_libraries(designer-test PRIVATE
user32
shell32
)

View File

@@ -0,0 +1,207 @@
// Hierarchy reader implementation
#include "hierarchy_reader.h"
#include <fstream>
#include <iostream>
#include <thread>
#include <chrono>
namespace mosis::test {
void HierarchyReader::SetHierarchyPath(const std::filesystem::path& path) {
m_path = path;
m_loaded = false;
}
bool HierarchyReader::Reload() {
if (m_path.empty()) {
return false;
}
// Retry a few times with delay to handle file write race conditions
for (int attempt = 0; attempt < 5; ++attempt) {
if (attempt > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
std::ifstream file(m_path);
if (!file.is_open()) {
continue; // File might not exist yet, retry
}
try {
m_json = nlohmann::json::parse(file);
m_loaded = true;
return true;
} catch (const nlohmann::json::parse_error& e) {
// File might be partially written, retry
if (attempt == 4) {
std::cerr << "Failed to parse hierarchy JSON after 5 attempts: " << e.what() << std::endl;
}
}
}
std::cerr << "Failed to load hierarchy file: " << m_path << std::endl;
m_loaded = false;
return false;
}
std::string HierarchyReader::GetScreen() const {
if (!m_loaded) return "";
return m_json.value("screen", "");
}
std::string HierarchyReader::GetScreenName() const {
std::string screen = GetScreen();
// Extract just the filename from the path
size_t lastSlash = screen.find_last_of("/\\");
if (lastSlash != std::string::npos) {
screen = screen.substr(lastSlash + 1);
}
return screen;
}
std::vector<ElementInfo> HierarchyReader::GetAllElements() const {
std::vector<ElementInfo> results;
if (!m_loaded || !m_json.contains("elements")) {
return results;
}
SearchElements(m_json["elements"], [](const ElementInfo&) {
return true; // Match all elements
}, results);
return results;
}
int HierarchyReader::GetWidth() const {
if (!m_loaded) return 0;
if (m_json.contains("resolution") && m_json["resolution"].contains("width")) {
return m_json["resolution"]["width"].get<int>();
}
return 0;
}
int HierarchyReader::GetHeight() const {
if (!m_loaded) return 0;
if (m_json.contains("resolution") && m_json["resolution"].contains("height")) {
return m_json["resolution"]["height"].get<int>();
}
return 0;
}
ElementInfo HierarchyReader::ParseElement(const nlohmann::json& j) const {
ElementInfo info;
info.tag = j.value("tag", "");
info.id = j.value("id", "");
info.visible = j.value("visible", false);
// Parse classes array
if (j.contains("classes") && j["classes"].is_array()) {
for (const auto& cls : j["classes"]) {
if (cls.is_string()) {
info.classes.push_back(cls.get<std::string>());
}
}
}
// Parse bounds
if (j.contains("bounds") && j["bounds"].is_object()) {
const auto& b = j["bounds"];
info.bounds.x = b.value("x", 0.0f);
info.bounds.y = b.value("y", 0.0f);
info.bounds.width = b.value("width", 0.0f);
info.bounds.height = b.value("height", 0.0f);
}
// Parse text
if (j.contains("text") && !j["text"].is_null()) {
info.text = j["text"].get<std::string>();
}
return info;
}
void HierarchyReader::SearchElements(
const nlohmann::json& j,
std::function<bool(const ElementInfo&)> predicate,
std::vector<ElementInfo>& results) const
{
if (!j.is_object()) return;
// Parse current element
ElementInfo info = ParseElement(j);
if (predicate(info)) {
results.push_back(info);
}
// Recurse into children
if (j.contains("children") && j["children"].is_array()) {
for (const auto& child : j["children"]) {
SearchElements(child, predicate, results);
}
}
}
std::optional<ElementInfo> HierarchyReader::FindById(const std::string& id) const {
if (!m_loaded || !m_json.contains("elements")) {
return std::nullopt;
}
std::vector<ElementInfo> results;
SearchElements(m_json["elements"], [&id](const ElementInfo& e) {
return e.id == id;
}, results);
if (!results.empty()) {
return results[0];
}
return std::nullopt;
}
std::vector<ElementInfo> HierarchyReader::FindByClass(const std::string& className) const {
std::vector<ElementInfo> results;
if (!m_loaded || !m_json.contains("elements")) {
return results;
}
SearchElements(m_json["elements"], [&className](const ElementInfo& e) {
for (const auto& cls : e.classes) {
if (cls == className) return true;
}
return false;
}, results);
return results;
}
std::vector<ElementInfo> HierarchyReader::FindByTag(const std::string& tagName) const {
std::vector<ElementInfo> results;
if (!m_loaded || !m_json.contains("elements")) {
return results;
}
SearchElements(m_json["elements"], [&tagName](const ElementInfo& e) {
return e.tag == tagName;
}, results);
return results;
}
std::optional<ElementInfo> HierarchyReader::FindFirst(
std::function<bool(const ElementInfo&)> predicate) const
{
if (!m_loaded || !m_json.contains("elements")) {
return std::nullopt;
}
std::vector<ElementInfo> results;
SearchElements(m_json["elements"], predicate, results);
if (!results.empty()) {
return results[0];
}
return std::nullopt;
}
} // namespace mosis::test

View File

@@ -0,0 +1,91 @@
// Hierarchy reader for parsing UI dump JSON files
#pragma once
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
#include <optional>
#include <filesystem>
namespace mosis::test {
// Element bounds from hierarchy
struct ElementBounds {
float x = 0;
float y = 0;
float width = 0;
float height = 0;
// Get center point
int centerX() const { return static_cast<int>(x + width / 2); }
int centerY() const { return static_cast<int>(y + height / 2); }
};
// Element info from hierarchy
struct ElementInfo {
std::string tag;
std::string id;
std::vector<std::string> classes;
ElementBounds bounds;
bool visible = false;
std::string text;
};
// Reads and parses UI hierarchy dump files
class HierarchyReader {
public:
HierarchyReader() = default;
// Set the path to the hierarchy file
void SetHierarchyPath(const std::filesystem::path& path);
// Reload the hierarchy from file
bool Reload();
// Check if hierarchy is loaded
bool IsLoaded() const { return m_loaded; }
// Get screen URL from hierarchy
std::string GetScreen() const;
// Get screen name (filename only) from hierarchy
std::string GetScreenName() const;
// Get resolution from hierarchy
int GetWidth() const;
int GetHeight() const;
// Get all elements (for debugging)
std::vector<ElementInfo> GetAllElements() const;
// Find element by ID
std::optional<ElementInfo> FindById(const std::string& id) const;
// Find elements by class name
std::vector<ElementInfo> FindByClass(const std::string& className) const;
// Find elements by tag name
std::vector<ElementInfo> FindByTag(const std::string& tagName) const;
// Find first visible element matching a predicate
std::optional<ElementInfo> FindFirst(
std::function<bool(const ElementInfo&)> predicate) const;
// Get raw JSON (for debugging)
const nlohmann::json& GetJson() const { return m_json; }
private:
// Parse element info from JSON node
ElementInfo ParseElement(const nlohmann::json& j) const;
// Recursively search for elements
void SearchElements(const nlohmann::json& j,
std::function<bool(const ElementInfo&)> predicate,
std::vector<ElementInfo>& results) const;
std::filesystem::path m_path;
nlohmann::json m_json;
bool m_loaded = false;
};
} // namespace mosis::test

View File

@@ -0,0 +1,103 @@
// Log parser implementation
#include "log_parser.h"
#include <fstream>
#include <iostream>
#include <chrono>
#include <thread>
namespace mosis::test {
LogParser::LogParser(const std::filesystem::path& logPath)
: m_logPath(logPath) {
}
void LogParser::SetLogPath(const std::filesystem::path& path) {
m_logPath = path;
Clear();
}
bool LogParser::Reload() {
if (m_logPath.empty() || !std::filesystem::exists(m_logPath)) {
return false;
}
std::ifstream file(m_logPath);
if (!file.is_open()) {
return false;
}
// Read all lines
m_entries.clear();
std::string line;
while (std::getline(file, line)) {
if (!line.empty()) {
LogEntry entry;
entry.message = line;
m_entries.push_back(entry);
}
}
return true;
}
void LogParser::Clear() {
m_entries.clear();
m_lastReadPosition = 0;
}
std::optional<LogEntry> LogParser::FindEntry(const std::string& pattern) const {
// Search from newest to oldest
for (auto it = m_entries.rbegin(); it != m_entries.rend(); ++it) {
if (it->message.find(pattern) != std::string::npos) {
return *it;
}
}
return std::nullopt;
}
bool LogParser::Contains(const std::string& pattern) const {
return FindEntry(pattern).has_value();
}
std::vector<LogEntry> LogParser::FindAllEntries(const std::string& pattern) const {
std::vector<LogEntry> results;
for (const auto& entry : m_entries) {
if (entry.message.find(pattern) != std::string::npos) {
results.push_back(entry);
}
}
return results;
}
bool LogParser::WaitForEntry(const std::string& pattern, int timeoutMs) {
auto startTime = std::chrono::steady_clock::now();
while (true) {
Reload();
if (Contains(pattern)) {
return true;
}
auto elapsed = std::chrono::steady_clock::now() - startTime;
if (std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() >= timeoutMs) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
std::vector<LogEntry> LogParser::GetLastEntries(size_t count) const {
if (count >= m_entries.size()) {
return m_entries;
}
std::vector<LogEntry> result;
result.reserve(count);
for (size_t i = m_entries.size() - count; i < m_entries.size(); ++i) {
result.push_back(m_entries[i]);
}
return result;
}
} // namespace mosis::test

View File

@@ -0,0 +1,55 @@
// Log file parser for verifying navigation
#pragma once
#include <string>
#include <vector>
#include <optional>
#include <filesystem>
namespace mosis::test {
// Represents a parsed log entry
struct LogEntry {
std::string message;
std::string timestamp; // If available
};
class LogParser {
public:
LogParser() = default;
explicit LogParser(const std::filesystem::path& logPath);
// Set the log file path
void SetLogPath(const std::filesystem::path& path);
// Reload log file from disk
bool Reload();
// Clear cached entries (for fresh start)
void Clear();
// Get all entries
const std::vector<LogEntry>& GetEntries() const { return m_entries; }
// Search for a pattern in log entries (returns first match or nullopt)
std::optional<LogEntry> FindEntry(const std::string& pattern) const;
// Check if pattern exists in log
bool Contains(const std::string& pattern) const;
// Get entries matching a pattern
std::vector<LogEntry> FindAllEntries(const std::string& pattern) const;
// Wait for a pattern to appear in the log (reloads periodically)
bool WaitForEntry(const std::string& pattern, int timeoutMs = 5000);
// Get last N entries
std::vector<LogEntry> GetLastEntries(size_t count) const;
private:
std::filesystem::path m_logPath;
std::vector<LogEntry> m_entries;
size_t m_lastReadPosition = 0;
};
} // namespace mosis::test

364
designer-test/src/main.cpp Normal file
View File

@@ -0,0 +1,364 @@
// Mosis Designer Test - Automated UI Testing
// Sends input events directly to the designer window using element coordinates from hierarchy dump
#include "test_runner.h"
#include <iostream>
#include <filesystem>
#include <thread>
#include <chrono>
using namespace mosis::test;
// Helper: Scale coordinates from hierarchy (logical) to window (physical) space
void ScaleToPhysical(TestContext& ctx, int& x, int& y) {
// The hierarchy reports coordinates in RmlUi's logical space
// which may be DPI-scaled. We need to convert to physical window coordinates.
int hierarchyWidth = ctx.hierarchy.GetWidth();
int hierarchyHeight = ctx.hierarchy.GetHeight();
int windowWidth = ctx.window.GetInfo().clientWidth;
int windowHeight = ctx.window.GetInfo().clientHeight;
if (hierarchyWidth > 0 && hierarchyHeight > 0) {
x = static_cast<int>(x * static_cast<float>(windowWidth) / hierarchyWidth);
y = static_cast<int>(y * static_cast<float>(windowHeight) / hierarchyHeight);
}
}
// Helper: Click on an element found by ID in the hierarchy
bool ClickById(TestContext& ctx, const std::string& id) {
ctx.hierarchy.Reload();
// Debug: Print hierarchy info
std::cout << " Hierarchy: " << ctx.hierarchy.GetWidth() << "x" << ctx.hierarchy.GetHeight()
<< ", screen: " << ctx.hierarchy.GetScreenName() << std::endl;
auto element = ctx.hierarchy.FindById(id);
if (!element) {
std::cerr << " Element not found: #" << id << std::endl;
// List available IDs for debugging
auto allElements = ctx.hierarchy.GetAllElements();
std::cerr << " Available IDs: ";
for (const auto& e : allElements) {
if (!e.id.empty()) {
std::cerr << "#" << e.id << " ";
}
}
std::cerr << std::endl;
return false;
}
if (!element->visible) {
std::cerr << " Element not visible: #" << id << std::endl;
return false;
}
int x = element->bounds.centerX();
int y = element->bounds.centerY();
int logicalX = x, logicalY = y;
ScaleToPhysical(ctx, x, y);
std::cout << " Clicking #" << id << " at logical(" << logicalX << "," << logicalY
<< ") -> physical(" << x << "," << y << ")" << std::endl;
ctx.window.SendClick(x, y);
return true;
}
// Helper: Click on an element found by class name (first visible match)
bool ClickByClass(TestContext& ctx, const std::string& className) {
ctx.hierarchy.Reload();
auto elements = ctx.hierarchy.FindByClass(className);
for (const auto& element : elements) {
if (element.visible && element.bounds.width > 0 && element.bounds.height > 0) {
int x = element.bounds.centerX();
int y = element.bounds.centerY();
ScaleToPhysical(ctx, x, y);
std::cout << " Clicking ." << className << " at physical(" << x << "," << y << ")" << std::endl;
ctx.window.SendClick(x, y);
return true;
}
}
std::cerr << " No visible element found with class: " << className << std::endl;
return false;
}
// Helper: Click on an element found by class name at a specific index
bool ClickByClassIndex(TestContext& ctx, const std::string& className, int index) {
ctx.hierarchy.Reload();
auto elements = ctx.hierarchy.FindByClass(className);
int visibleIndex = 0;
for (const auto& element : elements) {
if (element.visible && element.bounds.width > 0 && element.bounds.height > 0) {
if (visibleIndex == index) {
int x = element.bounds.centerX();
int y = element.bounds.centerY();
ScaleToPhysical(ctx, x, y);
std::cout << " Clicking ." << className << "[" << index << "] at physical("
<< x << "," << y << ")" << std::endl;
ctx.window.SendClick(x, y);
return true;
}
visibleIndex++;
}
}
std::cerr << " Element ." << className << "[" << index << "] not found (only "
<< visibleIndex << " visible)" << std::endl;
return false;
}
// Helper: Go back to home screen by clicking back button multiple times
void GoHome(TestContext& ctx) {
// Click back button (app-bar-nav) up to 5 times to ensure we're at home
for (int i = 0; i < 5; ++i) {
ctx.hierarchy.Reload();
// Look for the back button by class
auto elements = ctx.hierarchy.FindByClass("app-bar-nav");
if (!elements.empty()) {
auto& btn = elements[0];
if (btn.visible && btn.bounds.width > 0) {
int x = btn.bounds.centerX();
int y = btn.bounds.centerY();
ScaleToPhysical(ctx, x, y);
ctx.window.SendClick(x, y);
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(400));
}
// Extra wait for animations to fully complete and hierarchy to update
std::this_thread::sleep_for(std::chrono::milliseconds(800));
}
// Test: Navigate to dialer by clicking Phone dock icon
bool TestNavigateToDialer(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Click on Phone dock icon (dock-phone id)
if (!ClickById(ctx, "dock-phone")) {
return false;
}
// Wait for navigation
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
// Reload log and check for navigation
ctx.log.Reload();
return ctx.log.Contains("Loaded screen: apps/dialer/dialer.rml") ||
ctx.log.Contains("apps/dialer");
}
// Test: Navigate to messages
bool TestNavigateToMessages(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Click on Messages dock icon
if (!ClickById(ctx, "dock-messages")) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
ctx.log.Reload();
return ctx.log.Contains("Loaded screen: apps/messages/messages.rml");
}
// Test: Navigate to contacts
bool TestNavigateToContacts(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Click on Contacts dock icon
if (!ClickById(ctx, "dock-contacts")) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
ctx.log.Reload();
return ctx.log.Contains("Loaded screen: apps/contacts/contacts.rml");
}
// Test: Navigate to browser
bool TestNavigateToBrowser(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Click on Browser dock icon
if (!ClickById(ctx, "dock-browser")) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
ctx.log.Reload();
return ctx.log.Contains("Loaded screen: apps/browser/browser.rml");
}
// Test: Navigate to settings (in app grid, not dock)
bool TestNavigateToSettings(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Settings is in the app grid - find by ID
if (!ClickById(ctx, "app-settings")) {
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
ctx.log.Reload();
return ctx.log.Contains("Loaded screen: apps/settings/settings.rml");
}
// Test: Multiple navigation sequence from home
bool TestNavigationSequence(TestContext& ctx) {
// Ensure we start from home screen
GoHome(ctx);
ctx.log.Clear();
// Go to dialer from home
if (!ClickById(ctx, "dock-phone")) {
std::cerr << " Failed to click dock-phone" << std::endl;
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
// Go back to home
GoHome(ctx);
// Go to messages from home
if (!ClickById(ctx, "dock-messages")) {
std::cerr << " Failed to click dock-messages" << std::endl;
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
// Go back to home
GoHome(ctx);
// Go to contacts from home
if (!ClickById(ctx, "dock-contacts")) {
std::cerr << " Failed to click dock-contacts" << std::endl;
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
ctx.log.Reload();
// Verify all navigations happened
bool hasDialer = ctx.log.Contains("apps/dialer");
bool hasMessages = ctx.log.Contains("apps/messages");
bool hasContacts = ctx.log.Contains("apps/contacts");
std::cout << " Dialer: " << (hasDialer ? "yes" : "no") << std::endl;
std::cout << " Messages: " << (hasMessages ? "yes" : "no") << std::endl;
std::cout << " Contacts: " << (hasContacts ? "yes" : "no") << std::endl;
return hasDialer && hasMessages && hasContacts;
}
int main(int argc, char* argv[]) {
std::cout << "Mosis Designer Test v0.2.0" << std::endl;
std::cout << "==========================" << std::endl;
std::cout << "Using UI hierarchy for element coordinates" << std::endl;
// Determine paths
// exe is at: designer-test/build/Debug/designer-test.exe
// project root is: MosisService/
std::filesystem::path exePath = std::filesystem::absolute(argv[0]).parent_path();
std::filesystem::path projectRoot = exePath.parent_path().parent_path().parent_path(); // Up 3 levels
std::filesystem::path designerPath = projectRoot / "designer" / "build" / "Debug" / "mosis-designer.exe";
std::filesystem::path documentPath = projectRoot / "src" / "main" / "assets" / "apps" / "home" / "home.rml";
std::filesystem::path logPath = exePath / "designer_test.log";
std::filesystem::path hierarchyPath = exePath / "ui_hierarchy.json";
std::filesystem::path resultsPath = exePath / "test_results.json";
// Allow command-line overrides
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--designer" && i + 1 < argc) {
designerPath = argv[++i];
} else if (arg == "--document" && i + 1 < argc) {
documentPath = argv[++i];
} else if (arg == "--log" && i + 1 < argc) {
logPath = argv[++i];
} else if (arg == "--hierarchy" && i + 1 < argc) {
hierarchyPath = argv[++i];
} else if (arg == "--results" && i + 1 < argc) {
resultsPath = argv[++i];
} else if (arg == "--help") {
std::cout << "Usage: designer-test [options]" << std::endl;
std::cout << "Options:" << std::endl;
std::cout << " --designer PATH Path to mosis-designer.exe" << std::endl;
std::cout << " --document PATH Path to initial RML document" << std::endl;
std::cout << " --log PATH Path for log file" << std::endl;
std::cout << " --hierarchy PATH Path for UI hierarchy dump file" << std::endl;
std::cout << " --results PATH Path for JSON test results" << std::endl;
std::cout << " --help Show this help" << std::endl;
return 0;
}
}
std::cout << "Designer: " << designerPath << std::endl;
std::cout << "Document: " << documentPath << std::endl;
std::cout << "Log file: " << logPath << std::endl;
std::cout << "Hierarchy: " << hierarchyPath << std::endl;
// Check if designer exists
if (!std::filesystem::exists(designerPath)) {
std::cerr << "ERROR: Designer not found at: " << designerPath << std::endl;
std::cerr << "Build the designer first: cd designer && cmake --build build --config Debug" << std::endl;
return 1;
}
if (!std::filesystem::exists(documentPath)) {
std::cerr << "ERROR: Document not found at: " << documentPath << std::endl;
return 1;
}
// Create test runner
TestRunner runner;
runner.SetDesignerPath(designerPath);
runner.SetDocumentPath(documentPath);
runner.SetLogPath(logPath);
runner.SetHierarchyPath(hierarchyPath);
runner.SetStartupTimeoutMs(15000);
runner.SetActionDelayMs(500);
// Register tests
runner.AddTest("Navigate to Dialer", TestNavigateToDialer);
runner.AddTest("Navigate to Messages", TestNavigateToMessages);
runner.AddTest("Navigate to Contacts", TestNavigateToContacts);
runner.AddTest("Navigate to Browser", TestNavigateToBrowser);
runner.AddTest("Navigation Sequence", TestNavigationSequence);
// Start designer
std::cout << "\nStarting designer..." << std::endl;
if (!runner.StartDesigner()) {
std::cerr << "Failed to start designer" << std::endl;
return 1;
}
std::cout << "Designer started successfully" << std::endl;
std::cout << "Window info:" << std::endl;
const auto& info = runner.GetWindow().GetInfo();
std::cout << " Scale: " << info.scaleX << " x " << info.scaleY << std::endl;
// Run tests
auto results = runner.RunAll();
// Save results
results.SaveToFile(resultsPath);
// Stop designer
std::cout << "Stopping designer..." << std::endl;
runner.StopDesigner();
// Return exit code based on results
return (results.failed + results.errors) > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,277 @@
// Test runner implementation
#include "test_runner.h"
#include <iostream>
#include <fstream>
#include <chrono>
#include <thread>
namespace mosis::test {
nlohmann::json TestResult::ToJson() const {
nlohmann::json j;
j["name"] = name;
j["status"] = status == TestStatus::Passed ? "passed" :
status == TestStatus::Failed ? "failed" :
status == TestStatus::Skipped ? "skipped" : "error";
j["message"] = message;
j["duration_ms"] = durationMs;
return j;
}
nlohmann::json TestSuiteResult::ToJson() const {
nlohmann::json j;
j["name"] = name;
j["summary"] = {
{"passed", passed},
{"failed", failed},
{"skipped", skipped},
{"errors", errors},
{"total", static_cast<int>(tests.size())},
{"duration_ms", totalDurationMs}
};
nlohmann::json testsJson = nlohmann::json::array();
for (const auto& test : tests) {
testsJson.push_back(test.ToJson());
}
j["tests"] = testsJson;
return j;
}
void TestSuiteResult::SaveToFile(const std::filesystem::path& path) const {
std::ofstream file(path);
if (file.is_open()) {
file << ToJson().dump(2);
std::cout << "Test results saved to: " << path << std::endl;
} else {
std::cerr << "Failed to save test results to: " << path << std::endl;
}
}
TestRunner::TestRunner() = default;
TestRunner::~TestRunner() {
StopDesigner();
}
void TestRunner::SetDesignerPath(const std::filesystem::path& path) {
m_designerPath = path;
}
void TestRunner::SetDocumentPath(const std::filesystem::path& path) {
m_documentPath = path;
}
void TestRunner::SetLogPath(const std::filesystem::path& path) {
m_logPath = path;
m_log.SetLogPath(path);
}
void TestRunner::SetHierarchyPath(const std::filesystem::path& path) {
m_hierarchyPath = path;
m_hierarchy.SetHierarchyPath(path);
}
void TestRunner::AddTest(const std::string& name, TestFunc func) {
m_tests.push_back({name, std::move(func)});
}
bool TestRunner::StartDesigner() {
if (m_designerPath.empty() || m_documentPath.empty()) {
std::cerr << "Designer or document path not set" << std::endl;
return false;
}
// Delete old log file
if (!m_logPath.empty() && std::filesystem::exists(m_logPath)) {
std::filesystem::remove(m_logPath);
}
// Delete old hierarchy file
if (!m_hierarchyPath.empty() && std::filesystem::exists(m_hierarchyPath)) {
std::filesystem::remove(m_hierarchyPath);
}
// Build command line
std::string cmdLine = "\"" + m_designerPath.string() + "\" \"" + m_documentPath.string() + "\"";
if (!m_logPath.empty()) {
cmdLine += " --log \"" + m_logPath.string() + "\"";
}
if (!m_hierarchyPath.empty()) {
cmdLine += " --hierarchy \"" + m_hierarchyPath.string() + "\"";
}
std::cout << "Starting designer: " << cmdLine << std::endl;
// Create process
STARTUPINFOA si = {};
si.cb = sizeof(si);
PROCESS_INFORMATION pi = {};
// Need a mutable buffer for CreateProcess
std::vector<char> cmdLineBuffer(cmdLine.begin(), cmdLine.end());
cmdLineBuffer.push_back('\0');
if (!CreateProcessA(
nullptr,
cmdLineBuffer.data(),
nullptr,
nullptr,
FALSE,
0,
nullptr,
nullptr,
&si,
&pi)) {
std::cerr << "Failed to start designer process: " << GetLastError() << std::endl;
return false;
}
m_designerProcess = pi.hProcess;
CloseHandle(pi.hThread);
std::cout << "Designer process started with PID: " << pi.dwProcessId << std::endl;
// Wait for window to appear
if (!m_window.WaitForWindow("Mosis Designer", m_startupTimeoutMs)) {
std::cerr << "Designer window did not appear" << std::endl;
StopDesigner();
return false;
}
// Resize window to full phone resolution to ensure consistent UI layout
m_window.ResizeToPhone();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// Wait for document to load
std::this_thread::sleep_for(std::chrono::milliseconds(2000));
// Verify log file was created
if (!m_logPath.empty()) {
m_log.Reload();
if (!m_log.Contains("Document loaded successfully")) {
std::cerr << "Warning: Document load confirmation not found in log" << std::endl;
}
}
return true;
}
void TestRunner::StopDesigner() {
if (m_designerProcess) {
// Try graceful close first
m_window.Close();
// Wait briefly for graceful exit
if (WaitForSingleObject(m_designerProcess, 2000) == WAIT_TIMEOUT) {
// Force terminate
TerminateProcess(m_designerProcess, 1);
}
CloseHandle(m_designerProcess);
m_designerProcess = nullptr;
}
}
TestResult TestRunner::ExecuteTest(const TestCase& test) {
TestResult result;
result.name = test.name;
auto startTime = std::chrono::steady_clock::now();
try {
// Reload hierarchy before each test
m_hierarchy.Reload();
TestContext ctx{m_window, m_log, m_hierarchy};
// Clear log before test for cleaner verification
m_log.Clear();
bool passed = test.func(ctx);
result.status = passed ? TestStatus::Passed : TestStatus::Failed;
result.message = passed ? "Test passed" : "Test assertions failed";
} catch (const std::exception& e) {
result.status = TestStatus::Error;
result.message = std::string("Exception: ") + e.what();
}
auto endTime = std::chrono::steady_clock::now();
result.durationMs = std::chrono::duration<double, std::milli>(endTime - startTime).count();
return result;
}
TestResult TestRunner::RunTest(const std::string& name) {
for (const auto& test : m_tests) {
if (test.name == name) {
return ExecuteTest(test);
}
}
TestResult result;
result.name = name;
result.status = TestStatus::Error;
result.message = "Test not found";
return result;
}
TestSuiteResult TestRunner::RunAll() {
TestSuiteResult suite;
suite.name = "Mosis Designer UI Tests";
auto suiteStart = std::chrono::steady_clock::now();
std::cout << "\n========================================" << std::endl;
std::cout << "Running " << m_tests.size() << " tests" << std::endl;
std::cout << "========================================\n" << std::endl;
for (const auto& test : m_tests) {
std::cout << "Running: " << test.name << "..." << std::endl;
TestResult result = ExecuteTest(test);
suite.tests.push_back(result);
switch (result.status) {
case TestStatus::Passed:
suite.passed++;
std::cout << " PASSED (" << result.durationMs << "ms)" << std::endl;
break;
case TestStatus::Failed:
suite.failed++;
std::cout << " FAILED: " << result.message << std::endl;
break;
case TestStatus::Skipped:
suite.skipped++;
std::cout << " SKIPPED: " << result.message << std::endl;
break;
case TestStatus::Error:
suite.errors++;
std::cout << " ERROR: " << result.message << std::endl;
break;
}
// Delay between tests
std::this_thread::sleep_for(std::chrono::milliseconds(m_actionDelayMs));
}
auto suiteEnd = std::chrono::steady_clock::now();
suite.totalDurationMs = std::chrono::duration<double, std::milli>(suiteEnd - suiteStart).count();
std::cout << "\n========================================" << std::endl;
std::cout << "Test Results:" << std::endl;
std::cout << " Passed: " << suite.passed << std::endl;
std::cout << " Failed: " << suite.failed << std::endl;
std::cout << " Skipped: " << suite.skipped << std::endl;
std::cout << " Errors: " << suite.errors << std::endl;
std::cout << " Total: " << suite.tests.size() << std::endl;
std::cout << " Duration: " << suite.totalDurationMs << "ms" << std::endl;
std::cout << "========================================\n" << std::endl;
return suite;
}
} // namespace mosis::test

View File

@@ -0,0 +1,149 @@
// Test runner for designer UI tests
#pragma once
#include "window_controller.h"
#include "log_parser.h"
#include "hierarchy_reader.h"
#include <string>
#include <vector>
#include <functional>
#include <filesystem>
#include <nlohmann/json.hpp>
namespace mosis::test {
// Test result status
enum class TestStatus {
Passed,
Failed,
Skipped,
Error
};
// Individual test result
struct TestResult {
std::string name;
TestStatus status;
std::string message;
double durationMs;
nlohmann::json ToJson() const;
};
// Test suite result
struct TestSuiteResult {
std::string name;
std::vector<TestResult> tests;
int passed = 0;
int failed = 0;
int skipped = 0;
int errors = 0;
double totalDurationMs = 0;
nlohmann::json ToJson() const;
void SaveToFile(const std::filesystem::path& path) const;
};
// Test context passed to each test
struct TestContext {
WindowController& window;
LogParser& log;
HierarchyReader& hierarchy;
};
// Test function signature
using TestFunc = std::function<bool(TestContext&)>;
// Individual test case
struct TestCase {
std::string name;
TestFunc func;
};
// App icon positions for tests
struct AppPosition {
int x;
int y;
};
// Predefined positions on phone screen (540x960)
namespace AppPositions {
// Navigation buttons (in app-bar, 56px high starting after 24px status bar)
// Status bar: y=0-24, App bar: y=24-80, back button at left with 16px padding
// Use y=40 which is in app-bar on screens with app-bar, but in padding on home
constexpr AppPosition BackButton = {40, 40}; // In app-bar zone, safe on home screen
// App grid positions - Row 1 (starting around y=100 after app-bar)
constexpr AppPosition Phone = {67, 120};
constexpr AppPosition Messages = {202, 120};
constexpr AppPosition Contacts = {337, 120};
constexpr AppPosition Browser = {472, 120};
// Row 2
constexpr AppPosition Gallery = {67, 220};
constexpr AppPosition Camera = {202, 220};
constexpr AppPosition Settings = {337, 220};
constexpr AppPosition Music = {472, 220};
// Dock positions - 80px tall at bottom, items centered at y = height - 40
// 4 items with space-around in 540px - 64px padding = 476px content
// Each item 56px, total 224px, remaining 252px = ~63px per item spacing
// Item centers: ~92, ~211, ~330, ~449
// Note: Using 920 for Y (phone space), will be scaled by window size
// Adjusted first item X slightly right due to hit testing issues
constexpr AppPosition DockPhone = {100, 920};
constexpr AppPosition DockMessages = {211, 920};
constexpr AppPosition DockContacts = {330, 920};
constexpr AppPosition DockBrowser = {449, 920};
}
class TestRunner {
public:
TestRunner();
~TestRunner();
// Configuration
void SetDesignerPath(const std::filesystem::path& path);
void SetDocumentPath(const std::filesystem::path& path);
void SetLogPath(const std::filesystem::path& path);
void SetHierarchyPath(const std::filesystem::path& path);
void SetStartupTimeoutMs(int ms) { m_startupTimeoutMs = ms; }
void SetActionDelayMs(int ms) { m_actionDelayMs = ms; }
// Add test cases
void AddTest(const std::string& name, TestFunc func);
// Run all tests
TestSuiteResult RunAll();
// Run a specific test by name
TestResult RunTest(const std::string& name);
// Start/stop designer
bool StartDesigner();
void StopDesigner();
// Get components for manual testing
WindowController& GetWindow() { return m_window; }
LogParser& GetLog() { return m_log; }
HierarchyReader& GetHierarchy() { return m_hierarchy; }
private:
TestResult ExecuteTest(const TestCase& test);
std::filesystem::path m_designerPath;
std::filesystem::path m_documentPath;
std::filesystem::path m_logPath;
std::filesystem::path m_hierarchyPath;
int m_startupTimeoutMs = 15000;
int m_actionDelayMs = 500;
std::vector<TestCase> m_tests;
WindowController m_window;
LogParser m_log;
HierarchyReader m_hierarchy;
HANDLE m_designerProcess = nullptr;
};
} // namespace mosis::test

View File

@@ -0,0 +1,265 @@
// Window controller implementation
#include "window_controller.h"
#include <iostream>
#include <chrono>
#include <thread>
namespace mosis::test {
bool WindowController::FindWindow(const std::string& title) {
m_hwnd = ::FindWindowA(nullptr, title.c_str());
if (!m_hwnd) {
return false;
}
m_info.hwnd = m_hwnd;
// Get window rect
::GetWindowRect(m_hwnd, &m_info.windowRect);
// Get client rect (relative to window)
RECT clientRect;
::GetClientRect(m_hwnd, &clientRect);
m_info.clientRect = clientRect;
// Convert client area origin to screen coordinates
POINT clientOrigin = {0, 0};
::ClientToScreen(m_hwnd, &clientOrigin);
m_info.clientX = clientOrigin.x;
m_info.clientY = clientOrigin.y;
// Calculate client size and DPI scale
int clientWidth = clientRect.right - clientRect.left;
int clientHeight = clientRect.bottom - clientRect.top;
m_info.clientWidth = clientWidth;
m_info.clientHeight = clientHeight;
m_info.scaleX = static_cast<float>(clientWidth) / PHONE_WIDTH;
m_info.scaleY = static_cast<float>(clientHeight) / PHONE_HEIGHT;
std::cout << "Found window: " << title << std::endl;
std::cout << " Window rect: " << m_info.windowRect.left << "," << m_info.windowRect.top
<< " - " << m_info.windowRect.right << "," << m_info.windowRect.bottom << std::endl;
std::cout << " Client origin: " << m_info.clientX << "," << m_info.clientY << std::endl;
std::cout << " Client size: " << clientWidth << "x" << clientHeight << std::endl;
std::cout << " DPI scale: " << m_info.scaleX << " x " << m_info.scaleY << std::endl;
return true;
}
bool WindowController::WaitForWindow(const std::string& title, int timeoutMs) {
auto startTime = std::chrono::steady_clock::now();
while (true) {
if (FindWindow(title)) {
return true;
}
auto elapsed = std::chrono::steady_clock::now() - startTime;
if (std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() >= timeoutMs) {
std::cerr << "Timeout waiting for window: " << title << std::endl;
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
LPARAM WindowController::PhoneToClientLParam(int phoneX, int phoneY) {
// Scale phone coordinates to client coordinates
int clientX = static_cast<int>(phoneX * m_info.scaleX);
int clientY = static_cast<int>(phoneY * m_info.scaleY);
// Clamp to client area bounds
clientX = std::max(0, std::min(clientX, static_cast<int>(PHONE_WIDTH * m_info.scaleX) - 1));
clientY = std::max(0, std::min(clientY, static_cast<int>(PHONE_HEIGHT * m_info.scaleY) - 1));
return MAKELPARAM(clientX, clientY);
}
bool WindowController::SendMouseDown(int phoneX, int phoneY) {
if (!m_hwnd) return false;
// Convert to screen coordinates for SendInput
int clientX = static_cast<int>(phoneX * m_info.scaleX);
int clientY = static_cast<int>(phoneY * m_info.scaleY);
int screenX = m_info.clientX + clientX;
int screenY = m_info.clientY + clientY;
// Use SendInput for proper GLFW compatibility
// First move the cursor to the position
SetCursorPos(screenX, screenY);
// Send mouse down via SendInput
INPUT input = {};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
SendInput(1, &input, sizeof(INPUT));
std::cout << "MouseDown at phone(" << phoneX << "," << phoneY << ") -> screen("
<< screenX << "," << screenY << ")" << std::endl;
return true;
}
bool WindowController::SendMouseUp(int phoneX, int phoneY) {
if (!m_hwnd) return false;
// Send mouse up via SendInput
INPUT input = {};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
SendInput(1, &input, sizeof(INPUT));
std::cout << "MouseUp at phone(" << phoneX << "," << phoneY << ")" << std::endl;
return true;
}
bool WindowController::SendMouseMove(int phoneX, int phoneY) {
if (!m_hwnd) return false;
int clientX = static_cast<int>(phoneX * m_info.scaleX);
int clientY = static_cast<int>(phoneY * m_info.scaleY);
int screenX = m_info.clientX + clientX;
int screenY = m_info.clientY + clientY;
SetCursorPos(screenX, screenY);
return true;
}
bool WindowController::SendClick(int phoneX, int phoneY) {
if (!m_hwnd) return false;
// Save current cursor position
POINT oldPos;
GetCursorPos(&oldPos);
// Ensure window is active
SetForegroundWindow(m_hwnd);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Perform click
SendMouseDown(phoneX, phoneY);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
SendMouseUp(phoneX, phoneY);
// Restore cursor position (optional - comment out if cursor restoration causes issues)
// std::this_thread::sleep_for(std::chrono::milliseconds(50));
// SetCursorPos(oldPos.x, oldPos.y);
return true;
}
bool WindowController::SendKey(UINT vkCode) {
if (!m_hwnd) return false;
// Send key down and up
LPARAM lParam = 1; // Repeat count = 1
::PostMessage(m_hwnd, WM_KEYDOWN, vkCode, lParam);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
::PostMessage(m_hwnd, WM_KEYUP, vkCode, lParam | (1 << 30) | (1 << 31));
return true;
}
bool WindowController::SendChar(char c) {
if (!m_hwnd) return false;
::PostMessage(m_hwnd, WM_CHAR, static_cast<WPARAM>(c), 0);
return true;
}
bool WindowController::Activate() {
if (!m_hwnd) return false;
::SetForegroundWindow(m_hwnd);
return true;
}
bool WindowController::Close() {
if (!m_hwnd) return false;
::PostMessage(m_hwnd, WM_CLOSE, 0, 0);
return true;
}
bool WindowController::ResizeToPhone() {
if (!m_hwnd) return false;
// Get current window rect to preserve position
RECT currentRect;
::GetWindowRect(m_hwnd, &currentRect);
// Calculate window size needed for phone-sized client area
RECT desiredRect = {0, 0, PHONE_WIDTH, PHONE_HEIGHT};
DWORD style = ::GetWindowLong(m_hwnd, GWL_STYLE);
DWORD exStyle = ::GetWindowLong(m_hwnd, GWL_EXSTYLE);
::AdjustWindowRectEx(&desiredRect, style, FALSE, exStyle);
int newWidth = desiredRect.right - desiredRect.left;
int newHeight = desiredRect.bottom - desiredRect.top;
// Check screen dimensions
int screenWidth = ::GetSystemMetrics(SM_CXSCREEN);
int screenHeight = ::GetSystemMetrics(SM_CYSCREEN);
std::cout << "ResizeToPhone: screen=" << screenWidth << "x" << screenHeight
<< ", needed=" << newWidth << "x" << newHeight << std::endl;
// If screen is too small, we can't resize to full phone size
if (newHeight > screenHeight) {
std::cout << " Warning: Screen too small for full phone resolution, keeping current size" << std::endl;
return true; // Not an error, just can't resize
}
// Move window to top-left to ensure it fits on screen
int newX = 0;
int newY = 0;
std::cout << " Moving to (" << newX << "," << newY << ") size " << newWidth << "x" << newHeight << std::endl;
::MoveWindow(m_hwnd, newX, newY, newWidth, newHeight, TRUE);
// Re-acquire window info
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return FindWindow("Mosis Designer");
}
bool WindowController::SendClickFromBottom(int clientX, int offsetFromBottom) {
if (!m_hwnd) return false;
// Calculate Y position from bottom of client area
int clientY = m_info.clientHeight - offsetFromBottom;
// Convert to screen coordinates
int screenX = m_info.clientX + clientX;
int screenY = m_info.clientY + clientY;
// Save current cursor position
POINT oldPos;
GetCursorPos(&oldPos);
// Ensure window is active
SetForegroundWindow(m_hwnd);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Move cursor and click
SetCursorPos(screenX, screenY);
INPUT input = {};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
SendInput(1, &input, sizeof(INPUT));
std::this_thread::sleep_for(std::chrono::milliseconds(50));
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
SendInput(1, &input, sizeof(INPUT));
std::cout << "ClickFromBottom at client(" << clientX << "," << clientY
<< ") -> screen(" << screenX << "," << screenY << ")" << std::endl;
return true;
}
} // namespace mosis::test

View File

@@ -0,0 +1,77 @@
// Window controller for sending input events to the designer
#pragma once
#define NOMINMAX
#include <Windows.h>
#include <string>
#include <optional>
namespace mosis::test {
// Phone UI dimensions (default resolution)
constexpr int PHONE_WIDTH = 540;
constexpr int PHONE_HEIGHT = 960;
// Window information
struct WindowInfo {
HWND hwnd;
RECT windowRect;
RECT clientRect;
int clientX; // Client area X in screen coords
int clientY; // Client area Y in screen coords
int clientWidth; // Client area width
int clientHeight; // Client area height
float scaleX; // DPI scale X
float scaleY; // DPI scale Y
};
class WindowController {
public:
WindowController() = default;
~WindowController() = default;
// Find the designer window by title
bool FindWindow(const std::string& title = "Mosis Designer");
// Check if window is valid
bool IsValid() const { return m_hwnd != nullptr; }
// Get window info
const WindowInfo& GetInfo() const { return m_info; }
// Send mouse events at phone coordinates (0-540, 0-960)
// These coordinates are scaled to match the actual window size
bool SendMouseDown(int phoneX, int phoneY);
bool SendMouseUp(int phoneX, int phoneY);
bool SendMouseMove(int phoneX, int phoneY);
bool SendClick(int phoneX, int phoneY);
// Send click at position relative to bottom of window
// clientX is absolute X in client area, offsetFromBottom is Y offset from bottom edge
bool SendClickFromBottom(int clientX, int offsetFromBottom);
// Send keyboard events
bool SendKey(UINT vkCode);
bool SendChar(char c);
// Bring window to foreground
bool Activate();
// Close the window
bool Close();
// Resize window to match phone dimensions
bool ResizeToPhone();
// Wait for window to appear (returns false on timeout)
bool WaitForWindow(const std::string& title, int timeoutMs = 10000);
private:
// Convert phone coordinates to client coordinates (accounting for DPI scale)
LPARAM PhoneToClientLParam(int phoneX, int phoneY);
HWND m_hwnd = nullptr;
WindowInfo m_info = {};
};
} // namespace mosis::test

7
designer-test/vcpkg.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "designer-test",
"version-string": "0.1.0",
"dependencies": [
"nlohmann-json"
]
}