work in progress
This commit is contained in:
131
designer/src/testing/action_player.cpp
Normal file
131
designer/src/testing/action_player.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
// Action player implementation
|
||||
#include "action_player.h"
|
||||
#include "service_interface.h"
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
void ActionPlayer::LoadActions(const std::vector<Action>& actions) {
|
||||
m_actions = actions;
|
||||
Reset();
|
||||
}
|
||||
|
||||
void ActionPlayer::LoadFromFile(const std::string& path) {
|
||||
ActionRecorder recorder;
|
||||
recorder.LoadFromFile(path);
|
||||
m_actions = recorder.GetActions();
|
||||
Reset();
|
||||
}
|
||||
|
||||
void ActionPlayer::Play() {
|
||||
if (m_actions.empty()) {
|
||||
std::cout << "No actions to play" << std::endl;
|
||||
return;
|
||||
}
|
||||
m_playing = true;
|
||||
std::cout << "Playback started" << std::endl;
|
||||
}
|
||||
|
||||
void ActionPlayer::Pause() {
|
||||
m_playing = false;
|
||||
std::cout << "Playback paused at action " << m_current_index << std::endl;
|
||||
}
|
||||
|
||||
void ActionPlayer::Stop() {
|
||||
m_playing = false;
|
||||
Reset();
|
||||
std::cout << "Playback stopped" << std::endl;
|
||||
}
|
||||
|
||||
void ActionPlayer::Reset() {
|
||||
m_current_index = 0;
|
||||
m_elapsed_time_ms = 0;
|
||||
}
|
||||
|
||||
void ActionPlayer::StepForward() {
|
||||
if (m_current_index < m_actions.size()) {
|
||||
ExecuteAction(m_actions[m_current_index]);
|
||||
++m_current_index;
|
||||
}
|
||||
}
|
||||
|
||||
void ActionPlayer::Update(double delta_time_ms) {
|
||||
if (!m_playing || m_current_index >= m_actions.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_elapsed_time_ms += delta_time_ms;
|
||||
|
||||
// Execute all actions whose timestamp has passed
|
||||
while (m_current_index < m_actions.size()) {
|
||||
const auto& action = m_actions[m_current_index];
|
||||
if (action.timestamp_ms <= m_elapsed_time_ms) {
|
||||
ExecuteAction(action);
|
||||
++m_current_index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if finished
|
||||
if (m_current_index >= m_actions.size()) {
|
||||
m_playing = false;
|
||||
std::cout << "Playback finished" << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
void ActionPlayer::ExecuteAction(const Action& action) {
|
||||
if (!m_kernel) {
|
||||
std::cerr << "No kernel set for action player" << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case ActionType::Tap:
|
||||
std::cout << "Execute tap at (" << action.x << ", " << action.y << ")" << std::endl;
|
||||
m_kernel->OnTouchDown(static_cast<float>(action.x), static_cast<float>(action.y));
|
||||
m_kernel->OnTouchUp(static_cast<float>(action.x), static_cast<float>(action.y));
|
||||
break;
|
||||
|
||||
case ActionType::Swipe:
|
||||
std::cout << "Execute swipe from (" << action.x1 << ", " << action.y1
|
||||
<< ") to (" << action.x2 << ", " << action.y2 << ")" << std::endl;
|
||||
// Simplified swipe - just start and end
|
||||
m_kernel->OnTouchDown(static_cast<float>(action.x1), static_cast<float>(action.y1));
|
||||
m_kernel->OnTouchMove(static_cast<float>(action.x2), static_cast<float>(action.y2));
|
||||
m_kernel->OnTouchUp(static_cast<float>(action.x2), static_cast<float>(action.y2));
|
||||
break;
|
||||
|
||||
case ActionType::LongPress:
|
||||
std::cout << "Execute long press at (" << action.x << ", " << action.y
|
||||
<< ") for " << action.duration_ms << "ms" << std::endl;
|
||||
m_kernel->OnTouchDown(static_cast<float>(action.x), static_cast<float>(action.y));
|
||||
// In a real implementation, we'd hold for duration
|
||||
m_kernel->OnTouchUp(static_cast<float>(action.x), static_cast<float>(action.y));
|
||||
break;
|
||||
|
||||
case ActionType::Button:
|
||||
std::cout << "Execute button: " << action.button << std::endl;
|
||||
if (action.button == "back") {
|
||||
m_kernel->OnBackButton();
|
||||
} else if (action.button == "home") {
|
||||
m_kernel->OnHomeButton();
|
||||
} else if (action.button == "recents") {
|
||||
m_kernel->OnRecentsButton();
|
||||
}
|
||||
break;
|
||||
|
||||
case ActionType::Wait:
|
||||
std::cout << "Wait " << action.duration_ms << "ms" << std::endl;
|
||||
// Wait is handled by timestamp comparison
|
||||
break;
|
||||
}
|
||||
|
||||
// Call callback if set
|
||||
if (m_action_callback) {
|
||||
m_action_callback(action, m_current_index);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mosis::testing
|
||||
60
designer/src/testing/action_player.h
Normal file
60
designer/src/testing/action_player.h
Normal file
@@ -0,0 +1,60 @@
|
||||
// Action player for replaying recorded UI interactions
|
||||
#pragma once
|
||||
|
||||
#include "action_recorder.h"
|
||||
#include <functional>
|
||||
|
||||
namespace mosis {
|
||||
class IKernel;
|
||||
}
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
// Callback for when an action is executed
|
||||
using ActionCallback = std::function<void(const Action&, size_t index)>;
|
||||
|
||||
// Plays back recorded actions
|
||||
class ActionPlayer {
|
||||
public:
|
||||
ActionPlayer() = default;
|
||||
|
||||
// Set the kernel for executing actions
|
||||
void SetKernel(IKernel* kernel) { m_kernel = kernel; }
|
||||
|
||||
// Load actions to play
|
||||
void LoadActions(const std::vector<Action>& actions);
|
||||
void LoadFromFile(const std::string& path);
|
||||
|
||||
// Playback control
|
||||
void Play();
|
||||
void Pause();
|
||||
void Stop();
|
||||
void Reset();
|
||||
|
||||
// Step through one action at a time
|
||||
void StepForward();
|
||||
|
||||
// Update (call each frame)
|
||||
void Update(double delta_time_ms);
|
||||
|
||||
// State
|
||||
bool IsPlaying() const { return m_playing; }
|
||||
bool IsFinished() const { return m_current_index >= m_actions.size(); }
|
||||
size_t GetCurrentIndex() const { return m_current_index; }
|
||||
size_t GetActionCount() const { return m_actions.size(); }
|
||||
|
||||
// Callbacks
|
||||
void SetActionCallback(ActionCallback callback) { m_action_callback = callback; }
|
||||
|
||||
private:
|
||||
void ExecuteAction(const Action& action);
|
||||
|
||||
IKernel* m_kernel = nullptr;
|
||||
std::vector<Action> m_actions;
|
||||
size_t m_current_index = 0;
|
||||
double m_elapsed_time_ms = 0;
|
||||
bool m_playing = false;
|
||||
ActionCallback m_action_callback;
|
||||
};
|
||||
|
||||
} // namespace mosis::testing
|
||||
191
designer/src/testing/action_recorder.cpp
Normal file
191
designer/src/testing/action_recorder.cpp
Normal file
@@ -0,0 +1,191 @@
|
||||
// Action recorder implementation
|
||||
#include "action_recorder.h"
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
nlohmann::json Action::ToJson() const {
|
||||
nlohmann::json j;
|
||||
|
||||
switch (type) {
|
||||
case ActionType::Tap:
|
||||
j["type"] = "tap";
|
||||
j["x"] = x;
|
||||
j["y"] = y;
|
||||
break;
|
||||
case ActionType::Swipe:
|
||||
j["type"] = "swipe";
|
||||
j["x1"] = x1;
|
||||
j["y1"] = y1;
|
||||
j["x2"] = x2;
|
||||
j["y2"] = y2;
|
||||
j["duration"] = duration_ms;
|
||||
break;
|
||||
case ActionType::LongPress:
|
||||
j["type"] = "long_press";
|
||||
j["x"] = x;
|
||||
j["y"] = y;
|
||||
j["duration"] = duration_ms;
|
||||
break;
|
||||
case ActionType::Button:
|
||||
j["type"] = "button";
|
||||
j["button"] = button;
|
||||
break;
|
||||
case ActionType::Wait:
|
||||
j["type"] = "wait";
|
||||
j["duration"] = duration_ms;
|
||||
break;
|
||||
}
|
||||
|
||||
j["timestamp"] = timestamp_ms;
|
||||
return j;
|
||||
}
|
||||
|
||||
Action Action::FromJson(const nlohmann::json& j) {
|
||||
Action action;
|
||||
action.timestamp_ms = j.value("timestamp", 0);
|
||||
|
||||
std::string type_str = j.value("type", "");
|
||||
if (type_str == "tap") {
|
||||
action.type = ActionType::Tap;
|
||||
action.x = j.value("x", 0.0);
|
||||
action.y = j.value("y", 0.0);
|
||||
} else if (type_str == "swipe") {
|
||||
action.type = ActionType::Swipe;
|
||||
action.x1 = j.value("x1", 0.0);
|
||||
action.y1 = j.value("y1", 0.0);
|
||||
action.x2 = j.value("x2", 0.0);
|
||||
action.y2 = j.value("y2", 0.0);
|
||||
action.duration_ms = j.value("duration", 0);
|
||||
} else if (type_str == "long_press") {
|
||||
action.type = ActionType::LongPress;
|
||||
action.x = j.value("x", 0.0);
|
||||
action.y = j.value("y", 0.0);
|
||||
action.duration_ms = j.value("duration", 0);
|
||||
} else if (type_str == "button") {
|
||||
action.type = ActionType::Button;
|
||||
action.button = j.value("button", "");
|
||||
} else if (type_str == "wait") {
|
||||
action.type = ActionType::Wait;
|
||||
action.duration_ms = j.value("duration", 0);
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
void ActionRecorder::StartRecording() {
|
||||
m_recording = true;
|
||||
m_start_time = std::chrono::steady_clock::now();
|
||||
m_actions.clear();
|
||||
std::cout << "Action recording started" << std::endl;
|
||||
}
|
||||
|
||||
void ActionRecorder::StopRecording() {
|
||||
m_recording = false;
|
||||
std::cout << "Action recording stopped. Recorded " << m_actions.size() << " actions" << std::endl;
|
||||
}
|
||||
|
||||
void ActionRecorder::RecordTap(double x, double y) {
|
||||
if (!m_recording) return;
|
||||
|
||||
Action action;
|
||||
action.type = ActionType::Tap;
|
||||
action.x = x;
|
||||
action.y = y;
|
||||
action.timestamp_ms = GetTimestamp();
|
||||
m_actions.push_back(action);
|
||||
}
|
||||
|
||||
void ActionRecorder::RecordSwipe(double x1, double y1, double x2, double y2, int duration_ms) {
|
||||
if (!m_recording) return;
|
||||
|
||||
Action action;
|
||||
action.type = ActionType::Swipe;
|
||||
action.x1 = x1;
|
||||
action.y1 = y1;
|
||||
action.x2 = x2;
|
||||
action.y2 = y2;
|
||||
action.duration_ms = duration_ms;
|
||||
action.timestamp_ms = GetTimestamp();
|
||||
m_actions.push_back(action);
|
||||
}
|
||||
|
||||
void ActionRecorder::RecordLongPress(double x, double y, int duration_ms) {
|
||||
if (!m_recording) return;
|
||||
|
||||
Action action;
|
||||
action.type = ActionType::LongPress;
|
||||
action.x = x;
|
||||
action.y = y;
|
||||
action.duration_ms = duration_ms;
|
||||
action.timestamp_ms = GetTimestamp();
|
||||
m_actions.push_back(action);
|
||||
}
|
||||
|
||||
void ActionRecorder::RecordButton(const std::string& button) {
|
||||
if (!m_recording) return;
|
||||
|
||||
Action action;
|
||||
action.type = ActionType::Button;
|
||||
action.button = button;
|
||||
action.timestamp_ms = GetTimestamp();
|
||||
m_actions.push_back(action);
|
||||
}
|
||||
|
||||
void ActionRecorder::RecordWait(int duration_ms) {
|
||||
if (!m_recording) return;
|
||||
|
||||
Action action;
|
||||
action.type = ActionType::Wait;
|
||||
action.duration_ms = duration_ms;
|
||||
action.timestamp_ms = GetTimestamp();
|
||||
m_actions.push_back(action);
|
||||
}
|
||||
|
||||
void ActionRecorder::SaveToFile(const std::string& path) const {
|
||||
nlohmann::json j;
|
||||
j["actions"] = nlohmann::json::array();
|
||||
|
||||
for (const auto& action : m_actions) {
|
||||
j["actions"].push_back(action.ToJson());
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (file) {
|
||||
file << j.dump(2);
|
||||
std::cout << "Saved " << m_actions.size() << " actions to " << path << std::endl;
|
||||
} else {
|
||||
std::cerr << "Failed to save actions to " << path << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
void ActionRecorder::LoadFromFile(const std::string& path) {
|
||||
std::ifstream file(path);
|
||||
if (!file) {
|
||||
std::cerr << "Failed to load actions from " << path << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json j;
|
||||
file >> j;
|
||||
|
||||
m_actions.clear();
|
||||
for (const auto& action_json : j["actions"]) {
|
||||
m_actions.push_back(Action::FromJson(action_json));
|
||||
}
|
||||
|
||||
std::cout << "Loaded " << m_actions.size() << " actions from " << path << std::endl;
|
||||
}
|
||||
|
||||
void ActionRecorder::Clear() {
|
||||
m_actions.clear();
|
||||
}
|
||||
|
||||
int64_t ActionRecorder::GetTimestamp() const {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(now - m_start_time);
|
||||
return duration.count();
|
||||
}
|
||||
|
||||
} // namespace mosis::testing
|
||||
69
designer/src/testing/action_recorder.h
Normal file
69
designer/src/testing/action_recorder.h
Normal file
@@ -0,0 +1,69 @@
|
||||
// Action recorder for UI testing automation
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
// Action types for recording user interactions
|
||||
enum class ActionType {
|
||||
Tap,
|
||||
Swipe,
|
||||
LongPress,
|
||||
Button,
|
||||
Wait
|
||||
};
|
||||
|
||||
// Represents a single recorded action
|
||||
struct Action {
|
||||
ActionType type;
|
||||
double x = 0, y = 0; // For tap, long_press
|
||||
double x1 = 0, y1 = 0; // For swipe start
|
||||
double x2 = 0, y2 = 0; // For swipe end
|
||||
int duration_ms = 0; // For swipe, long_press, wait
|
||||
std::string button; // For button actions (back, home, recents)
|
||||
int64_t timestamp_ms = 0; // Time offset from recording start
|
||||
|
||||
nlohmann::json ToJson() const;
|
||||
static Action FromJson(const nlohmann::json& j);
|
||||
};
|
||||
|
||||
// Records user interactions into a sequence of actions
|
||||
class ActionRecorder {
|
||||
public:
|
||||
ActionRecorder() = default;
|
||||
|
||||
// Start/stop recording
|
||||
void StartRecording();
|
||||
void StopRecording();
|
||||
bool IsRecording() const { return m_recording; }
|
||||
|
||||
// Record individual actions
|
||||
void RecordTap(double x, double y);
|
||||
void RecordSwipe(double x1, double y1, double x2, double y2, int duration_ms);
|
||||
void RecordLongPress(double x, double y, int duration_ms);
|
||||
void RecordButton(const std::string& button);
|
||||
void RecordWait(int duration_ms);
|
||||
|
||||
// Get recorded actions
|
||||
const std::vector<Action>& GetActions() const { return m_actions; }
|
||||
|
||||
// Save/load to JSON
|
||||
void SaveToFile(const std::string& path) const;
|
||||
void LoadFromFile(const std::string& path);
|
||||
|
||||
// Clear recorded actions
|
||||
void Clear();
|
||||
|
||||
private:
|
||||
int64_t GetTimestamp() const;
|
||||
|
||||
bool m_recording = false;
|
||||
std::vector<Action> m_actions;
|
||||
std::chrono::steady_clock::time_point m_start_time;
|
||||
};
|
||||
|
||||
} // namespace mosis::testing
|
||||
183
designer/src/testing/ui_inspector.cpp
Normal file
183
designer/src/testing/ui_inspector.cpp
Normal file
@@ -0,0 +1,183 @@
|
||||
// UI Inspector implementation
|
||||
#include "ui_inspector.h"
|
||||
#include <RmlUi/Core.h>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <filesystem>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
nlohmann::json UIInspector::DumpDocument(Rml::ElementDocument* document) const {
|
||||
nlohmann::json j;
|
||||
|
||||
if (!document) {
|
||||
return j;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
j["timestamp"] = std::time(nullptr);
|
||||
j["screen"] = document->GetSourceURL();
|
||||
j["resolution"] = {
|
||||
{"width", document->GetContext()->GetDimensions().x},
|
||||
{"height", document->GetContext()->GetDimensions().y}
|
||||
};
|
||||
|
||||
// Dump element tree
|
||||
j["elements"] = DumpElement(document);
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
nlohmann::json UIInspector::DumpElement(Rml::Element* element) const {
|
||||
nlohmann::json j;
|
||||
|
||||
if (!element) {
|
||||
return j;
|
||||
}
|
||||
|
||||
j["tag"] = element->GetTagName();
|
||||
j["id"] = element->GetId();
|
||||
|
||||
// Get classes
|
||||
nlohmann::json classes = nlohmann::json::array();
|
||||
Rml::String class_attr = element->GetAttribute<Rml::String>("class", "");
|
||||
if (!class_attr.empty()) {
|
||||
// Split by space
|
||||
size_t start = 0;
|
||||
size_t end;
|
||||
while ((end = class_attr.find(' ', start)) != std::string::npos) {
|
||||
if (end > start) {
|
||||
classes.push_back(class_attr.substr(start, end - start));
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
if (start < class_attr.size()) {
|
||||
classes.push_back(class_attr.substr(start));
|
||||
}
|
||||
}
|
||||
j["classes"] = classes;
|
||||
|
||||
// Get bounds
|
||||
auto bounds = GetBounds(element);
|
||||
j["bounds"] = bounds.ToJson();
|
||||
|
||||
// Visibility
|
||||
j["visible"] = IsVisible(element);
|
||||
|
||||
// Children
|
||||
nlohmann::json children = nlohmann::json::array();
|
||||
for (int i = 0; i < element->GetNumChildren(); ++i) {
|
||||
Rml::Element* child = element->GetChild(i);
|
||||
if (child && child->GetTagName() != "#text") {
|
||||
children.push_back(DumpElement(child));
|
||||
}
|
||||
}
|
||||
|
||||
// Text content (only for leaf elements without children to avoid huge JSON)
|
||||
if (children.empty()) {
|
||||
std::string text = GetText(element);
|
||||
// Only store short text to avoid huge JSON and escaping issues
|
||||
if (!text.empty() && text.length() < 200) {
|
||||
j["text"] = text;
|
||||
} else {
|
||||
j["text"] = nlohmann::json(); // null
|
||||
}
|
||||
} else {
|
||||
j["text"] = nlohmann::json(); // null for non-leaf elements
|
||||
j["children"] = children;
|
||||
}
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
Rml::Element* UIInspector::FindById(Rml::ElementDocument* document, const std::string& id) const {
|
||||
if (!document) return nullptr;
|
||||
return document->GetElementById(id);
|
||||
}
|
||||
|
||||
std::vector<Rml::Element*> UIInspector::FindByClass(Rml::ElementDocument* document, const std::string& class_name) const {
|
||||
std::vector<Rml::Element*> results;
|
||||
|
||||
if (!document) return results;
|
||||
|
||||
Rml::ElementList elements;
|
||||
document->GetElementsByClassName(elements, class_name);
|
||||
|
||||
for (auto* element : elements) {
|
||||
results.push_back(element);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
bool UIInspector::IsVisible(Rml::Element* element) const {
|
||||
if (!element) return false;
|
||||
|
||||
// Check if element has zero size
|
||||
auto box = element->GetBox();
|
||||
if (box.GetSize().x <= 0 || box.GetSize().y <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check display property
|
||||
auto display = element->GetProperty<Rml::Style::Display>("display");
|
||||
if (display == Rml::Style::Display::None) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check visibility property
|
||||
auto visibility = element->GetProperty<Rml::Style::Visibility>("visibility");
|
||||
if (visibility == Rml::Style::Visibility::Hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ElementBounds UIInspector::GetBounds(Rml::Element* element) const {
|
||||
ElementBounds bounds = {0, 0, 0, 0};
|
||||
|
||||
if (!element) return bounds;
|
||||
|
||||
auto abs_offset = element->GetAbsoluteOffset(Rml::BoxArea::Border);
|
||||
auto box = element->GetBox();
|
||||
|
||||
bounds.x = abs_offset.x;
|
||||
bounds.y = abs_offset.y;
|
||||
bounds.width = box.GetSize(Rml::BoxArea::Border).x;
|
||||
bounds.height = box.GetSize(Rml::BoxArea::Border).y;
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
std::string UIInspector::GetText(Rml::Element* element) const {
|
||||
if (!element) return "";
|
||||
return element->GetInnerRML();
|
||||
}
|
||||
|
||||
void UIInspector::SaveToFile(Rml::ElementDocument* document, const std::string& path, bool quiet) const {
|
||||
nlohmann::json j = DumpDocument(document);
|
||||
|
||||
// Write to temp file first, then rename for atomic update
|
||||
std::string tempPath = path + ".tmp";
|
||||
std::ofstream file(tempPath);
|
||||
if (file) {
|
||||
file << j.dump(2);
|
||||
file.close(); // Ensure file is closed and flushed
|
||||
|
||||
// Atomic rename (on Windows, need to remove destination first)
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(path, ec);
|
||||
std::filesystem::rename(tempPath, path, ec);
|
||||
|
||||
if (ec && !quiet) {
|
||||
std::cerr << "Failed to rename temp file: " << ec.message() << std::endl;
|
||||
} else if (!quiet) {
|
||||
std::cout << "Saved UI hierarchy to " << path << std::endl;
|
||||
}
|
||||
} else {
|
||||
std::cerr << "Failed to save UI hierarchy to " << path << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mosis::testing
|
||||
53
designer/src/testing/ui_inspector.h
Normal file
53
designer/src/testing/ui_inspector.h
Normal file
@@ -0,0 +1,53 @@
|
||||
// UI Inspector for dumping element hierarchy
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
|
||||
namespace Rml {
|
||||
class Element;
|
||||
class ElementDocument;
|
||||
}
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
// Element bounds
|
||||
struct ElementBounds {
|
||||
float x, y, width, height;
|
||||
|
||||
nlohmann::json ToJson() const {
|
||||
return {{"x", x}, {"y", y}, {"width", width}, {"height", height}};
|
||||
}
|
||||
};
|
||||
|
||||
// Inspects and dumps UI element hierarchy
|
||||
class UIInspector {
|
||||
public:
|
||||
UIInspector() = default;
|
||||
|
||||
// Dump the element tree of a document to JSON
|
||||
nlohmann::json DumpDocument(Rml::ElementDocument* document) const;
|
||||
|
||||
// Dump a single element and its children
|
||||
nlohmann::json DumpElement(Rml::Element* element) const;
|
||||
|
||||
// Find element by ID
|
||||
Rml::Element* FindById(Rml::ElementDocument* document, const std::string& id) const;
|
||||
|
||||
// Find elements by class
|
||||
std::vector<Rml::Element*> FindByClass(Rml::ElementDocument* document, const std::string& class_name) const;
|
||||
|
||||
// Check if element is visible
|
||||
bool IsVisible(Rml::Element* element) const;
|
||||
|
||||
// Get element bounds (in screen coordinates)
|
||||
ElementBounds GetBounds(Rml::Element* element) const;
|
||||
|
||||
// Get element text content
|
||||
std::string GetText(Rml::Element* element) const;
|
||||
|
||||
// Save hierarchy to file (quiet=true suppresses log message)
|
||||
void SaveToFile(Rml::ElementDocument* document, const std::string& path, bool quiet = false) const;
|
||||
};
|
||||
|
||||
} // namespace mosis::testing
|
||||
244
designer/src/testing/visual_capture.cpp
Normal file
244
designer/src/testing/visual_capture.cpp
Normal file
@@ -0,0 +1,244 @@
|
||||
// Visual capture implementation
|
||||
#include "visual_capture.h"
|
||||
#include <png.h>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
// OpenGL header (from RmlUi backend)
|
||||
#include <RmlUi_Include_GL3.h>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
ImageData VisualCapture::CaptureFramebuffer(uint32_t width, uint32_t height) const {
|
||||
ImageData image;
|
||||
image.width = width;
|
||||
image.height = height;
|
||||
image.pixels.resize(width * height * 4);
|
||||
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.pixels.data());
|
||||
|
||||
// Flip vertically (OpenGL origin is bottom-left)
|
||||
FlipVertically(image);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
bool VisualCapture::SavePNG(const ImageData& image, const std::string& path) const {
|
||||
if (!image.IsValid()) {
|
||||
std::cerr << "Invalid image data" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
FILE* fp = fopen(path.c_str(), "wb");
|
||||
if (!fp) {
|
||||
std::cerr << "Failed to open file for writing: " << path << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
|
||||
if (!png) {
|
||||
fclose(fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
png_infop info = png_create_info_struct(png);
|
||||
if (!info) {
|
||||
png_destroy_write_struct(&png, nullptr);
|
||||
fclose(fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setjmp(png_jmpbuf(png))) {
|
||||
png_destroy_write_struct(&png, &info);
|
||||
fclose(fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
png_init_io(png, fp);
|
||||
|
||||
png_set_IHDR(png, info, image.width, image.height, 8,
|
||||
PNG_COLOR_TYPE_RGBA, PNG_INTERLACE_NONE,
|
||||
PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
|
||||
|
||||
png_write_info(png, info);
|
||||
|
||||
// Write rows
|
||||
std::vector<png_bytep> rows(image.height);
|
||||
for (uint32_t y = 0; y < image.height; ++y) {
|
||||
rows[y] = const_cast<png_bytep>(image.pixels.data() + y * image.width * 4);
|
||||
}
|
||||
|
||||
png_write_image(png, rows.data());
|
||||
png_write_end(png, nullptr);
|
||||
|
||||
png_destroy_write_struct(&png, &info);
|
||||
fclose(fp);
|
||||
|
||||
std::cout << "Saved screenshot to " << path << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
ImageData VisualCapture::LoadPNG(const std::string& path) const {
|
||||
ImageData image;
|
||||
|
||||
FILE* fp = fopen(path.c_str(), "rb");
|
||||
if (!fp) {
|
||||
std::cerr << "Failed to open file for reading: " << path << std::endl;
|
||||
return image;
|
||||
}
|
||||
|
||||
png_structp png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
|
||||
if (!png) {
|
||||
fclose(fp);
|
||||
return image;
|
||||
}
|
||||
|
||||
png_infop info = png_create_info_struct(png);
|
||||
if (!info) {
|
||||
png_destroy_read_struct(&png, nullptr, nullptr);
|
||||
fclose(fp);
|
||||
return image;
|
||||
}
|
||||
|
||||
if (setjmp(png_jmpbuf(png))) {
|
||||
png_destroy_read_struct(&png, &info, nullptr);
|
||||
fclose(fp);
|
||||
return image;
|
||||
}
|
||||
|
||||
png_init_io(png, fp);
|
||||
png_read_info(png, info);
|
||||
|
||||
image.width = png_get_image_width(png, info);
|
||||
image.height = png_get_image_height(png, info);
|
||||
png_byte color_type = png_get_color_type(png, info);
|
||||
png_byte bit_depth = png_get_bit_depth(png, info);
|
||||
|
||||
// Convert to RGBA
|
||||
if (bit_depth == 16) {
|
||||
png_set_strip_16(png);
|
||||
}
|
||||
if (color_type == PNG_COLOR_TYPE_PALETTE) {
|
||||
png_set_palette_to_rgb(png);
|
||||
}
|
||||
if (color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8) {
|
||||
png_set_expand_gray_1_2_4_to_8(png);
|
||||
}
|
||||
if (png_get_valid(png, info, PNG_INFO_tRNS)) {
|
||||
png_set_tRNS_to_alpha(png);
|
||||
}
|
||||
if (color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_GRAY || color_type == PNG_COLOR_TYPE_PALETTE) {
|
||||
png_set_filler(png, 0xFF, PNG_FILLER_AFTER);
|
||||
}
|
||||
if (color_type == PNG_COLOR_TYPE_GRAY || color_type == PNG_COLOR_TYPE_GRAY_ALPHA) {
|
||||
png_set_gray_to_rgb(png);
|
||||
}
|
||||
|
||||
png_read_update_info(png, info);
|
||||
|
||||
// Read rows
|
||||
image.pixels.resize(image.width * image.height * 4);
|
||||
std::vector<png_bytep> rows(image.height);
|
||||
for (uint32_t y = 0; y < image.height; ++y) {
|
||||
rows[y] = image.pixels.data() + y * image.width * 4;
|
||||
}
|
||||
|
||||
png_read_image(png, rows.data());
|
||||
|
||||
png_destroy_read_struct(&png, &info, nullptr);
|
||||
fclose(fp);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
CompareResult VisualCapture::Compare(const ImageData& actual, const ImageData& expected, double threshold) const {
|
||||
CompareResult result;
|
||||
|
||||
if (!actual.IsValid() || !expected.IsValid()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (actual.width != expected.width || actual.height != expected.height) {
|
||||
std::cerr << "Image dimensions don't match" << std::endl;
|
||||
return result;
|
||||
}
|
||||
|
||||
uint32_t total_pixels = actual.width * actual.height;
|
||||
uint32_t diff_pixels = 0;
|
||||
|
||||
for (uint32_t i = 0; i < actual.pixels.size(); i += 4) {
|
||||
int dr = std::abs(static_cast<int>(actual.pixels[i]) - static_cast<int>(expected.pixels[i]));
|
||||
int dg = std::abs(static_cast<int>(actual.pixels[i+1]) - static_cast<int>(expected.pixels[i+1]));
|
||||
int db = std::abs(static_cast<int>(actual.pixels[i+2]) - static_cast<int>(expected.pixels[i+2]));
|
||||
int da = std::abs(static_cast<int>(actual.pixels[i+3]) - static_cast<int>(expected.pixels[i+3]));
|
||||
|
||||
// If any channel differs significantly, count as different
|
||||
if (dr > 2 || dg > 2 || db > 2 || da > 2) {
|
||||
++diff_pixels;
|
||||
}
|
||||
}
|
||||
|
||||
result.diff_pixels = diff_pixels;
|
||||
result.diff_percent = static_cast<double>(diff_pixels) / total_pixels;
|
||||
result.match = result.diff_percent <= threshold;
|
||||
|
||||
if (!result.match) {
|
||||
result.diff_image = GenerateDiff(actual, expected);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
ImageData VisualCapture::GenerateDiff(const ImageData& actual, const ImageData& expected) const {
|
||||
ImageData diff;
|
||||
|
||||
if (!actual.IsValid() || !expected.IsValid()) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
diff.width = actual.width;
|
||||
diff.height = actual.height;
|
||||
diff.pixels.resize(actual.pixels.size());
|
||||
|
||||
for (uint32_t i = 0; i < actual.pixels.size(); i += 4) {
|
||||
int dr = std::abs(static_cast<int>(actual.pixels[i]) - static_cast<int>(expected.pixels[i]));
|
||||
int dg = std::abs(static_cast<int>(actual.pixels[i+1]) - static_cast<int>(expected.pixels[i+1]));
|
||||
int db = std::abs(static_cast<int>(actual.pixels[i+2]) - static_cast<int>(expected.pixels[i+2]));
|
||||
|
||||
if (dr > 2 || dg > 2 || db > 2) {
|
||||
// Highlight difference in red
|
||||
diff.pixels[i] = 255;
|
||||
diff.pixels[i+1] = 0;
|
||||
diff.pixels[i+2] = 0;
|
||||
diff.pixels[i+3] = 255;
|
||||
} else {
|
||||
// Dim the matching pixels
|
||||
diff.pixels[i] = actual.pixels[i] / 3;
|
||||
diff.pixels[i+1] = actual.pixels[i+1] / 3;
|
||||
diff.pixels[i+2] = actual.pixels[i+2] / 3;
|
||||
diff.pixels[i+3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
void VisualCapture::FlipVertically(ImageData& image) const {
|
||||
if (!image.IsValid()) return;
|
||||
|
||||
size_t row_size = image.width * 4;
|
||||
std::vector<uint8_t> temp_row(row_size);
|
||||
|
||||
for (uint32_t y = 0; y < image.height / 2; ++y) {
|
||||
uint8_t* top = image.pixels.data() + y * row_size;
|
||||
uint8_t* bottom = image.pixels.data() + (image.height - 1 - y) * row_size;
|
||||
|
||||
memcpy(temp_row.data(), top, row_size);
|
||||
memcpy(top, bottom, row_size);
|
||||
memcpy(bottom, temp_row.data(), row_size);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mosis::testing
|
||||
51
designer/src/testing/visual_capture.h
Normal file
51
designer/src/testing/visual_capture.h
Normal file
@@ -0,0 +1,51 @@
|
||||
// Visual capture for screenshots and image comparison
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mosis::testing {
|
||||
|
||||
// PNG image data
|
||||
struct ImageData {
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
std::vector<uint8_t> pixels; // RGBA format
|
||||
|
||||
bool IsValid() const { return width > 0 && height > 0 && !pixels.empty(); }
|
||||
};
|
||||
|
||||
// Result of image comparison
|
||||
struct CompareResult {
|
||||
bool match = false;
|
||||
double diff_percent = 0.0;
|
||||
uint32_t diff_pixels = 0;
|
||||
ImageData diff_image;
|
||||
};
|
||||
|
||||
// Captures and compares screenshots
|
||||
class VisualCapture {
|
||||
public:
|
||||
VisualCapture() = default;
|
||||
|
||||
// Capture current framebuffer to image
|
||||
ImageData CaptureFramebuffer(uint32_t width, uint32_t height) const;
|
||||
|
||||
// Save image to PNG file
|
||||
bool SavePNG(const ImageData& image, const std::string& path) const;
|
||||
|
||||
// Load PNG file to image
|
||||
ImageData LoadPNG(const std::string& path) const;
|
||||
|
||||
// Compare two images
|
||||
CompareResult Compare(const ImageData& actual, const ImageData& expected, double threshold = 0.01) const;
|
||||
|
||||
// Generate diff image (highlights differences)
|
||||
ImageData GenerateDiff(const ImageData& actual, const ImageData& expected) const;
|
||||
|
||||
// Utility: flip image vertically (for OpenGL coordinate conversion)
|
||||
void FlipVertically(ImageData& image) const;
|
||||
};
|
||||
|
||||
} // namespace mosis::testing
|
||||
Reference in New Issue
Block a user