add testing and plan milestones

This commit is contained in:
2026-01-16 19:50:59 +01:00
parent 25b0603913
commit 2e097e4e54
8 changed files with 2540 additions and 28 deletions

View File

@@ -20,6 +20,8 @@
#include "hot_reload.h"
#include "desktop_platform.h"
#include "testing/ui_inspector.h"
#include "testing/action_recorder.h"
#include "testing/action_player.h"
// Command-line options
struct Options {
@@ -31,6 +33,8 @@ struct Options {
std::string output_dir = "dump";
std::string log_file; // If set, write logs to this file
std::string hierarchy_file; // If set, dump UI hierarchy to this file each frame
std::string record_file; // If set, record actions to JSON file
std::string playback_file; // If set, play back actions from JSON file
};
// Global log file stream
@@ -89,6 +93,43 @@ static mosis::IKernel* g_kernel = nullptr;
static std::filesystem::path g_assets_path;
static mosis::testing::UIInspector g_ui_inspector;
// Recording/playback state
static std::unique_ptr<mosis::testing::ActionRecorder> g_recorder;
static std::unique_ptr<mosis::testing::ActionPlayer> g_player;
static std::string g_record_file_path;
// Key callback for F5 (recording control)
bool HandleKeyDown(Rml::Context* context, Rml::Input::KeyIdentifier key, int key_modifier, float native_dp_ratio, bool priority) {
// F5: Toggle recording / Save recording
if (key == Rml::Input::KI_F5 && g_recorder) {
if (g_recorder->IsRecording()) {
g_recorder->StopRecording();
if (g_recorder->SaveToFile(g_record_file_path)) {
LogMessage("Recording saved to: " + g_record_file_path);
} else {
LogMessage("ERROR: Failed to save recording to: " + g_record_file_path);
}
} else {
g_recorder->StartRecording();
LogMessage("Recording started (press F5 to stop and save)");
}
return true; // Consumed
}
// F6: Pause/resume playback
if (key == Rml::Input::KI_F6 && g_player) {
if (g_player->IsPlaying()) {
g_player->Pause();
LogMessage("Playback paused");
} else if (g_player->IsPaused()) {
g_player->Resume();
LogMessage("Playback resumed");
}
return true; // Consumed
}
return false; // Not consumed, let RmlUi handle it
}
int main(int argc, const char* argv[])
{
@@ -206,6 +247,25 @@ int main(int argc, const char* argv[])
// Start kernel
kernel->Start();
// Initialize recording if enabled
if (!opts.record_file.empty()) {
g_recorder = std::make_unique<mosis::testing::ActionRecorder>(opts.width, opts.height);
g_record_file_path = opts.record_file;
LogMessage("Recording mode enabled. Press F5 to start recording.");
}
// Initialize playback if enabled
if (!opts.playback_file.empty()) {
g_player = std::make_unique<mosis::testing::ActionPlayer>(context);
if (g_player->LoadFromFile(opts.playback_file)) {
LogMessage("Loaded playback file: " + opts.playback_file);
g_player->Start();
} else {
LogMessage("ERROR: Failed to load playback file: " + opts.playback_file);
g_player.reset();
}
}
// Setup hot-reload
std::unique_ptr<mosis::desktop::HotReload> hot_reload;
if (!opts.dump_mode) {
@@ -228,8 +288,16 @@ int main(int argc, const char* argv[])
hot_reload->CheckForChanges();
}
// Process events and update
running = Backend::ProcessEvents(context);
// Process events and update (with key callback for F5/F6 control)
running = Backend::ProcessEvents(context, HandleKeyDown);
// Update playback if active
if (g_player && g_player->IsPlaying()) {
g_player->Update();
if (g_player->IsFinished()) {
LogMessage("Playback complete");
}
}
// Update kernel (processes tasks, updates time, etc.)
kernel->Update();
@@ -249,6 +317,16 @@ int main(int argc, const char* argv[])
}
// Cleanup
// Stop and save recording if still active
if (g_recorder && g_recorder->IsRecording()) {
g_recorder->StopRecording();
if (g_recorder->SaveToFile(g_record_file_path)) {
LogMessage("Recording saved on exit to: " + g_record_file_path);
}
}
g_recorder.reset();
g_player.reset();
kernel->Stop();
kernel.reset();
g_kernel = nullptr;
@@ -282,11 +360,19 @@ void PrintUsage(const char* program)
std::cout << " --output DIR Output directory for dump mode (default: dump)" << std::endl;
std::cout << " --log FILE Write log output to file (for automated testing)" << std::endl;
std::cout << " --hierarchy FILE Continuously dump UI hierarchy to JSON file" << std::endl;
std::cout << " --record FILE Record actions to JSON file (F5 to start/stop)" << std::endl;
std::cout << " --playback FILE Play back recorded actions from JSON file" << std::endl;
std::cout << std::endl;
std::cout << "Recording Controls:" << std::endl;
std::cout << " F5 Start/stop recording (when --record is enabled)" << std::endl;
std::cout << " F6 Pause/resume playback (when --playback is enabled)" << std::endl;
std::cout << std::endl;
std::cout << "Examples:" << std::endl;
std::cout << " " << program << " assets/apps/home/home.rml" << std::endl;
std::cout << " " << program << " assets/apps/home/home.rml --resolution 720x1280" << std::endl;
std::cout << " " << program << " assets/apps/home/home.rml --dump" << std::endl;
std::cout << " " << program << " assets/apps/home/home.rml --record test.json" << std::endl;
std::cout << " " << program << " assets/apps/home/home.rml --playback test.json" << std::endl;
}
Options ParseOptions(int argc, const char* argv[])
@@ -313,6 +399,10 @@ Options ParseOptions(int argc, const char* argv[])
opts.log_file = argv[++i];
} else if (arg == "--hierarchy" && i + 1 < argc) {
opts.hierarchy_file = argv[++i];
} else if (arg == "--record" && i + 1 < argc) {
opts.record_file = argv[++i];
} else if (arg == "--playback" && i + 1 < argc) {
opts.playback_file = argv[++i];
} else if (arg[0] != '-') {
opts.document_path = arg;
}

View File

@@ -84,35 +84,132 @@ bool VisualCapture::CaptureScreenshot(const std::string& path) {
return true;
}
// Helper struct for loaded PNG data
struct PNGImage {
int width = 0;
int height = 0;
std::vector<uint8_t> pixels; // RGBA format
bool valid = false;
};
// Load a PNG file into memory
static PNGImage LoadPNG(const std::string& path) {
PNGImage image;
FILE* fp = fopen(path.c_str(), "rb");
if (!fp) return image;
// Check PNG signature
uint8_t header[8];
if (fread(header, 1, 8, fp) != 8 || png_sig_cmp(header, 0, 8)) {
fclose(fp);
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_set_sig_bytes(png, 8);
png_read_info(png, info);
image.width = png_get_image_width(png, info);
image.height = png_get_image_height(png, info);
int color_type = png_get_color_type(png, info);
int bit_depth = png_get_bit_depth(png, info);
// Convert to RGBA if necessary
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);
// Allocate memory and read image
image.pixels.resize(image.width * image.height * 4);
std::vector<png_bytep> row_pointers(image.height);
for (int y = 0; y < image.height; y++) {
row_pointers[y] = image.pixels.data() + y * image.width * 4;
}
png_read_image(png, row_pointers.data());
png_read_end(png, nullptr);
png_destroy_read_struct(&png, &info, nullptr);
fclose(fp);
image.valid = true;
return image;
}
// Compare two pixels with tolerance
static bool PixelsMatch(const uint8_t* p1, const uint8_t* p2, int tolerance = 2) {
for (int c = 0; c < 4; c++) {
if (std::abs(static_cast<int>(p1[c]) - static_cast<int>(p2[c])) > tolerance) {
return false;
}
}
return true;
}
float VisualCapture::CompareImages(const std::string& path1, const std::string& path2) {
// Load both images and compare pixel by pixel
// For simplicity, just check if files exist for now
// A full implementation would load PNGs and compute difference
FILE* fp1 = fopen(path1.c_str(), "rb");
FILE* fp2 = fopen(path2.c_str(), "rb");
if (!fp1 || !fp2) {
if (fp1) fclose(fp1);
if (fp2) fclose(fp2);
return 1.0f; // Can't compare, assume different
// Load both images
PNGImage img1 = LoadPNG(path1);
PNGImage img2 = LoadPNG(path2);
// Check if both loaded successfully
if (!img1.valid || !img2.valid) {
return 1.0f; // Can't compare, assume completely different
}
// Read and compare file sizes as quick check
fseek(fp1, 0, SEEK_END);
fseek(fp2, 0, SEEK_END);
long size1 = ftell(fp1);
long size2 = ftell(fp2);
fclose(fp1);
fclose(fp2);
// If sizes differ significantly, images are different
if (std::abs(size1 - size2) > 1000) {
return 0.5f; // Moderately different
// Check dimensions match
if (img1.width != img2.width || img1.height != img2.height) {
return 1.0f; // Different dimensions = completely different
}
return 0.0f; // Assume similar if sizes match
// Count differing pixels
int total_pixels = img1.width * img1.height;
int diff_pixels = 0;
for (int i = 0; i < total_pixels; i++) {
const uint8_t* p1 = img1.pixels.data() + i * 4;
const uint8_t* p2 = img2.pixels.data() + i * 4;
if (!PixelsMatch(p1, p2)) {
diff_pixels++;
}
}
// Return difference ratio (0.0 = identical, 1.0 = completely different)
return static_cast<float>(diff_pixels) / static_cast<float>(total_pixels);
}
} // namespace mosis::testing