2026-07-09 02:59:53 +03:00
|
|
|
//! Golden Visual Regression Test Harness
|
|
|
|
|
//!
|
|
|
|
|
//! Purpose:
|
|
|
|
|
//! Port v3's deterministic golden hash concept to v4 through the public Engine API boundary.
|
|
|
|
|
//! These tests validate that Engine output remains deterministic and pixel-perfect across changes.
|
|
|
|
|
//!
|
|
|
|
|
//! Design:
|
|
|
|
|
//! - Uses only `hcie_engine_api::Engine` public API (no internal crate access)
|
|
|
|
|
//! - Creates deterministic canvases, layers, colors, vector shapes, brush strokes, filters
|
|
|
|
|
//! - Hashes `engine.get_composite_pixels()` with SHA-256
|
|
|
|
|
//! - Compares against inline golden hashes
|
|
|
|
|
//!
|
|
|
|
|
//! Regeneration:
|
|
|
|
|
//! Set `HCIE_REGEN_GOLDENS=1` to print computed hashes after test verification.
|
|
|
|
|
//!
|
|
|
|
|
//! Golden cases:
|
|
|
|
|
//! - white_canvas_empty_document: validates blank document/composite baseline
|
|
|
|
|
//! - green_rect_draw_and_composite: validates draw_filled_rect_rgba
|
|
|
|
|
//! - red_rect_over_green_rect: validates layer compositing order
|
|
|
|
|
//! - vector_rect_golden: validates vector rendering through Engine API
|
|
|
|
|
//! - brush_stroke_golden: validates brush engine + draw pipeline
|
|
|
|
|
//! - invert_filter_golden: validates filter pipeline and deterministic output
|
|
|
|
|
//! - undo_after_rect_golden: validates history snapshot and restoration
|
|
|
|
|
|
|
|
|
|
use hcie_engine_api::Engine;
|
2026-07-16 22:10:22 +03:00
|
|
|
use hcie_engine_api::{BrushStyle, BrushTip, VectorShape};
|
2026-07-09 02:59:53 +03:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
|
|
|
|
/// Compute SHA-256 hash of pixel buffer for golden comparison.
|
|
|
|
|
fn hash_pixels(pixels: &[u8]) -> String {
|
|
|
|
|
let mut hasher = Sha256::new();
|
|
|
|
|
hasher.update(pixels);
|
|
|
|
|
format!("{:x}", hasher.finalize())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: White canvas, empty document baseline.
|
|
|
|
|
/// Validates that a blank document produces deterministic all-white/transparent RGBA output.
|
|
|
|
|
#[test]
|
|
|
|
|
fn white_canvas_empty_document() {
|
|
|
|
|
let mut engine = Engine::new(8, 8);
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
|
|
|
|
|
// Engine::new creates a transparent document by default
|
|
|
|
|
assert_eq!(pixels.len(), 8 * 8 * 4, "Canvas size mismatch");
|
|
|
|
|
// Transparent pixels: alpha = 0
|
|
|
|
|
for i in (0..pixels.len()).step_by(4) {
|
|
|
|
|
assert_eq!(pixels[i + 3], 0, "Alpha channel should be 0 (transparent)");
|
|
|
|
|
}
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Green rectangle draw and composite.
|
|
|
|
|
/// Validates `draw_filled_rect_rgba` produces deterministic output.
|
|
|
|
|
#[test]
|
|
|
|
|
fn green_rect_draw_and_composite() {
|
|
|
|
|
let mut engine = Engine::new(16, 16);
|
|
|
|
|
engine.draw_filled_rect_rgba(4, 4, 12, 12, [0, 255, 0, 255]);
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
|
|
|
|
|
assert_eq!(pixels.len(), 16 * 16 * 4);
|
|
|
|
|
// Check center pixel is green
|
|
|
|
|
let center_idx = (8 * 16 + 8) * 4;
|
|
|
|
|
assert_eq!(pixels[center_idx], 0);
|
|
|
|
|
assert_eq!(pixels[center_idx + 1], 255);
|
|
|
|
|
assert_eq!(pixels[center_idx + 2], 0);
|
|
|
|
|
assert_eq!(pixels[center_idx + 3], 255);
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Red rectangle over green rectangle.
|
|
|
|
|
/// Validates layer compositing order with two layers.
|
|
|
|
|
#[test]
|
|
|
|
|
fn red_rect_over_green_rect() {
|
|
|
|
|
let mut engine = Engine::new(16, 16);
|
|
|
|
|
|
|
|
|
|
// Bottom layer: green rect
|
|
|
|
|
let green_id = engine.add_layer("green");
|
|
|
|
|
engine.set_active_layer(green_id);
|
|
|
|
|
engine.draw_filled_rect_rgba(2, 2, 14, 14, [0, 255, 0, 255]);
|
|
|
|
|
|
|
|
|
|
// Top layer: red rect
|
|
|
|
|
let red_id = engine.add_layer("red");
|
|
|
|
|
engine.set_active_layer(red_id);
|
|
|
|
|
engine.draw_filled_rect_rgba(6, 6, 10, 10, [255, 0, 0, 255]);
|
|
|
|
|
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
assert_eq!(pixels.len(), 16 * 16 * 4);
|
|
|
|
|
|
|
|
|
|
// Center pixel should be red (top layer wins)
|
|
|
|
|
let center_idx = (8 * 16 + 8) * 4;
|
|
|
|
|
assert_eq!(pixels[center_idx], 255);
|
|
|
|
|
assert_eq!(pixels[center_idx + 1], 0);
|
|
|
|
|
assert_eq!(pixels[center_idx + 2], 0);
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Vector shape rendering.
|
|
|
|
|
/// Validates vector shapes are rendered deterministically through Engine API.
|
|
|
|
|
#[test]
|
|
|
|
|
fn vector_rect_golden() {
|
|
|
|
|
let mut engine = Engine::new(16, 16);
|
|
|
|
|
let shape = VectorShape::Rect {
|
2026-07-20 01:43:22 +03:00
|
|
|
name: String::new(),
|
2026-07-16 22:10:22 +03:00
|
|
|
x1: 0.0,
|
|
|
|
|
y1: 0.0,
|
|
|
|
|
x2: 16.0,
|
|
|
|
|
y2: 16.0,
|
2026-07-09 02:59:53 +03:00
|
|
|
stroke: 1.0,
|
|
|
|
|
color: [255, 0, 0, 255],
|
|
|
|
|
fill_color: [255, 0, 0, 255],
|
|
|
|
|
fill: true,
|
|
|
|
|
radius: 0.0,
|
|
|
|
|
angle: 0.0,
|
|
|
|
|
opacity: 1.0,
|
|
|
|
|
hardness: 1.0,
|
|
|
|
|
};
|
|
|
|
|
engine.add_vector_shape(shape);
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
|
|
|
|
|
assert_eq!(pixels.len(), 16 * 16 * 4);
|
|
|
|
|
|
|
|
|
|
// Verify at least one red pixel (vector rect was drawn)
|
2026-07-16 22:10:22 +03:00
|
|
|
let has_red = pixels
|
|
|
|
|
.chunks(4)
|
|
|
|
|
.any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
|
2026-07-09 02:59:53 +03:00
|
|
|
assert!(has_red, "Vector rect should contain red pixels");
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Brush stroke rendering.
|
|
|
|
|
/// Validates brush engine + draw pipeline produces deterministic output.
|
|
|
|
|
#[test]
|
|
|
|
|
fn brush_stroke_golden() {
|
|
|
|
|
let mut engine = Engine::new(16, 16);
|
|
|
|
|
let brush = BrushTip {
|
|
|
|
|
style: BrushStyle::Round,
|
|
|
|
|
size: 4.0,
|
|
|
|
|
opacity: 1.0,
|
|
|
|
|
hardness: 1.0,
|
|
|
|
|
spacing: 0.1,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
engine.set_brush_tip(brush);
|
|
|
|
|
engine.begin_stroke(engine.active_layer_id(), 8.0, 8.0);
|
|
|
|
|
engine.stroke_to(engine.active_layer_id(), 12.0, 12.0, 1.0);
|
|
|
|
|
engine.end_stroke(engine.active_layer_id());
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
|
|
|
|
|
assert_eq!(pixels.len(), 16 * 16 * 4);
|
|
|
|
|
|
|
|
|
|
// Brush stroke should produce non-white pixels
|
|
|
|
|
let has_non_white = pixels.iter().any(|&p| p < 255);
|
2026-07-16 22:10:22 +03:00
|
|
|
assert!(
|
|
|
|
|
has_non_white,
|
|
|
|
|
"Brush stroke should produce non-white pixels"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Invert filter.
|
|
|
|
|
/// Validates filter pipeline produces deterministic output.
|
|
|
|
|
#[test]
|
|
|
|
|
fn invert_filter_golden() {
|
|
|
|
|
let mut engine = Engine::new(8, 8);
|
|
|
|
|
engine.draw_filled_rect_rgba(0, 0, 8, 8, [100, 150, 200, 255]);
|
|
|
|
|
engine.apply_filter("invert", serde_json::json!({}));
|
|
|
|
|
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
|
|
|
|
assert_eq!(pixels.len(), 8 * 8 * 4);
|
|
|
|
|
|
|
|
|
|
// Center pixel should be inverted
|
|
|
|
|
let idx = (4 * 8 + 4) * 4;
|
|
|
|
|
assert_eq!(pixels[idx], 155); // 255 - 100
|
|
|
|
|
assert_eq!(pixels[idx + 1], 105); // 255 - 150
|
|
|
|
|
assert_eq!(pixels[idx + 2], 55); // 255 - 200
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
hash_pixels(&pixels),
|
|
|
|
|
"35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Undo after rectangle.
|
|
|
|
|
/// Validates history snapshot and restoration works correctly.
|
|
|
|
|
#[test]
|
|
|
|
|
fn undo_after_rect_golden() {
|
|
|
|
|
let mut engine = Engine::new(8, 8);
|
|
|
|
|
|
|
|
|
|
// Initial state: transparent
|
|
|
|
|
let before = hash_pixels(&engine.get_composite_pixels());
|
|
|
|
|
|
|
|
|
|
// Draw green rect
|
|
|
|
|
engine.draw_filled_rect_rgba(2, 2, 6, 6, [0, 255, 0, 255]);
|
|
|
|
|
|
|
|
|
|
// Undo
|
|
|
|
|
engine.undo();
|
|
|
|
|
|
|
|
|
|
let after = hash_pixels(&engine.get_composite_pixels());
|
|
|
|
|
|
|
|
|
|
// After undo, should match initial state
|
|
|
|
|
assert_eq!(before, after, "Undo should restore original canvas state");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test: Vector fill toggle and delete.
|
|
|
|
|
/// Validates that set_vector_shape_fill and delete_vector_shape work correctly.
|
|
|
|
|
#[test]
|
|
|
|
|
fn vector_fill_toggle_and_delete() {
|
|
|
|
|
let mut engine = Engine::new(16, 16);
|
|
|
|
|
|
|
|
|
|
// Create a rect with fill=false
|
|
|
|
|
let shape = VectorShape::Rect {
|
2026-07-20 01:43:22 +03:00
|
|
|
name: String::new(),
|
2026-07-16 22:10:22 +03:00
|
|
|
x1: 2.0,
|
|
|
|
|
y1: 2.0,
|
|
|
|
|
x2: 14.0,
|
|
|
|
|
y2: 14.0,
|
2026-07-09 02:59:53 +03:00
|
|
|
stroke: 1.0,
|
|
|
|
|
color: [255, 0, 0, 255],
|
|
|
|
|
fill_color: [0, 0, 255, 255],
|
|
|
|
|
fill: false,
|
|
|
|
|
radius: 0.0,
|
|
|
|
|
angle: 0.0,
|
|
|
|
|
opacity: 1.0,
|
|
|
|
|
hardness: 1.0,
|
|
|
|
|
};
|
|
|
|
|
engine.add_vector_shape(shape);
|
|
|
|
|
let layer_id = engine.active_layer_id();
|
|
|
|
|
|
|
|
|
|
// Verify fill is initially false
|
|
|
|
|
let shapes = engine.active_vector_shapes().unwrap();
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
shapes[0].fill(),
|
|
|
|
|
Some(false),
|
|
|
|
|
"fill should be false initially"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
|
|
|
|
|
// Toggle fill ON
|
|
|
|
|
engine.set_vector_shape_fill(layer_id, 0, true);
|
|
|
|
|
let shapes = engine.active_vector_shapes().unwrap();
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
shapes[0].fill(),
|
|
|
|
|
Some(true),
|
|
|
|
|
"fill should be true after toggle"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
|
|
|
|
|
// Verify composite pixels contain blue fill
|
|
|
|
|
let pixels = engine.get_composite_pixels();
|
2026-07-16 22:10:22 +03:00
|
|
|
let has_blue = pixels
|
|
|
|
|
.chunks(4)
|
|
|
|
|
.any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
|
2026-07-09 02:59:53 +03:00
|
|
|
assert!(has_blue, "should have blue fill pixels after enabling fill");
|
|
|
|
|
|
|
|
|
|
// Toggle fill OFF
|
|
|
|
|
engine.set_vector_shape_fill(layer_id, 0, false);
|
|
|
|
|
let shapes = engine.active_vector_shapes().unwrap();
|
2026-07-16 22:10:22 +03:00
|
|
|
assert_eq!(
|
|
|
|
|
shapes[0].fill(),
|
|
|
|
|
Some(false),
|
|
|
|
|
"fill should be false after toggling off"
|
|
|
|
|
);
|
2026-07-09 02:59:53 +03:00
|
|
|
|
|
|
|
|
// Delete the shape
|
|
|
|
|
engine.delete_vector_shape(layer_id, 0);
|
|
|
|
|
let shapes = engine.active_vector_shapes().unwrap();
|
|
|
|
|
assert!(shapes.is_empty(), "shapes should be empty after delete");
|
2026-07-16 22:10:22 +03:00
|
|
|
}
|