work in progress
This commit is contained in:
207
designer-test/src/hierarchy_reader.cpp
Normal file
207
designer-test/src/hierarchy_reader.cpp
Normal 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
|
||||
91
designer-test/src/hierarchy_reader.h
Normal file
91
designer-test/src/hierarchy_reader.h
Normal 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
|
||||
103
designer-test/src/log_parser.cpp
Normal file
103
designer-test/src/log_parser.cpp
Normal 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
|
||||
55
designer-test/src/log_parser.h
Normal file
55
designer-test/src/log_parser.h
Normal 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
364
designer-test/src/main.cpp
Normal 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;
|
||||
}
|
||||
277
designer-test/src/test_runner.cpp
Normal file
277
designer-test/src/test_runner.cpp
Normal 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
|
||||
149
designer-test/src/test_runner.h
Normal file
149
designer-test/src/test_runner.h
Normal 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
|
||||
265
designer-test/src/window_controller.cpp
Normal file
265
designer-test/src/window_controller.cpp
Normal 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, ¤tRect);
|
||||
|
||||
// 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
|
||||
77
designer-test/src/window_controller.h
Normal file
77
designer-test/src/window_controller.h
Normal 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
|
||||
Reference in New Issue
Block a user