refactor & improvements

This commit is contained in:
2026-03-29 22:35:57 +03:00
parent cdaf91927b
commit f0a1835fa2
63 changed files with 4803 additions and 7387 deletions

View File

@@ -0,0 +1,4 @@
{
"company": "Acme Test Corp.",
"date": "2026-01-15"
}

View File

@@ -0,0 +1,73 @@
{
"id": "visual_test",
"name": "Visual Test",
"page": { "width": 210, "height": 297 },
"fonts": ["Noto Sans"],
"root": {
"id": "root",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"direction": "column",
"gap": 5,
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "header",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 18, "fontWeight": "bold", "color": "#1a1a1a" },
"content": "VISUAL TEST DOCUMENT"
},
{
"id": "line1",
"type": "line",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "strokeColor": "#333333", "strokeWidth": 0.5 }
},
{
"id": "info_box",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "column",
"gap": 2,
"padding": { "top": 5, "right": 5, "bottom": 5, "left": 5 },
"align": "start",
"justify": "start",
"style": { "backgroundColor": "#f0f4f8", "borderColor": "#cbd5e1", "borderWidth": 0.5 },
"children": [
{
"id": "company",
"type": "text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 12, "fontWeight": "bold", "color": "#1e293b" },
"binding": { "type": "scalar", "path": "company" }
},
{
"id": "date_text",
"type": "text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 10, "color": "#64748b" },
"binding": { "type": "scalar", "path": "date" }
}
]
},
{
"id": "body_text",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "fontSize": 11, "color": "#334155" },
"content": "This is a visual regression test document. Layout and text rendering should be consistent across runs."
}
]
}
}

View File

@@ -0,0 +1,321 @@
//! Integration tests for the layout engine's compute_layout() public API.
use dreport_core::models::*;
use dreport_layout::{compute_layout, FontData, LayoutResult};
fn load_test_fonts() -> Vec<FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("backend/fonts");
let mut fonts = Vec::new();
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
// Map NotoSans → "Noto Sans", NotoSansMono → "Noto Sans Mono"
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: std::fs::read(&path).unwrap(),
});
}
}
fonts
}
fn simple_template() -> Template {
Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "Hello World".to_string(),
})],
},
}
}
#[test]
fn test_compute_layout_single_page() {
let template = simple_template();
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result: LayoutResult = compute_layout(&template, &data, &fonts);
assert_eq!(result.pages.len(), 1);
let page = &result.pages[0];
assert_eq!(page.width_mm, 210.0);
assert_eq!(page.height_mm, 297.0);
}
#[test]
fn test_compute_layout_elements_within_page() {
let template = simple_template();
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let page = &result.pages[0];
// Should have at least root + title = 2 elements
assert!(
page.elements.len() >= 2,
"Expected at least 2 elements, got {}",
page.elements.len()
);
for el in &page.elements {
// All positions should be non-negative
assert!(
el.x_mm >= 0.0,
"Element {} has negative x: {}",
el.id,
el.x_mm
);
assert!(
el.y_mm >= 0.0,
"Element {} has negative y: {}",
el.id,
el.y_mm
);
// All dimensions should be non-negative
assert!(
el.width_mm >= 0.0,
"Element {} has negative width: {}",
el.id,
el.width_mm
);
assert!(
el.height_mm >= 0.0,
"Element {} has negative height: {}",
el.id,
el.height_mm
);
// Elements should be within page bounds (with small tolerance for rounding)
assert!(
el.x_mm + el.width_mm <= page.width_mm + 1.0,
"Element {} exceeds page width: x={}+w={} > {}",
el.id,
el.x_mm,
el.width_mm,
page.width_mm
);
assert!(
el.y_mm + el.height_mm <= page.height_mm + 1.0,
"Element {} exceeds page height: y={}+h={} > {}",
el.id,
el.y_mm,
el.height_mm,
page.height_mm
);
}
}
#[test]
fn test_compute_layout_text_content_resolved() {
let template = simple_template();
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let page = &result.pages[0];
let title = page.elements.iter().find(|e| e.id == "title").unwrap();
match &title.content {
Some(dreport_layout::ResolvedContent::Text { value }) => {
assert_eq!(value, "Hello World");
}
other => panic!("Expected Text content, got {:?}", other),
}
}
#[test]
fn test_compute_layout_with_data_binding() {
let template = Template {
id: "t1".to_string(),
name: "Binding Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 10.0,
right: 10.0,
bottom: 10.0,
left: 10.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![TemplateElement::Text(TextElement {
id: "bound_text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: None,
binding: ScalarBinding {
path: "company.name".to_string(),
},
})],
},
};
let data = serde_json::json!({
"company": { "name": "Acme Corp" }
});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let page = &result.pages[0];
let bound = page
.elements
.iter()
.find(|e| e.id == "bound_text")
.unwrap();
match &bound.content {
Some(dreport_layout::ResolvedContent::Text { value }) => {
assert_eq!(value, "Acme Corp");
}
other => panic!("Expected Text content, got {:?}", other),
}
}
#[test]
fn test_compute_layout_multiple_children_ordering() {
let template = Template {
id: "t1".to_string(),
name: "Order Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
top: 10.0,
right: 10.0,
bottom: 10.0,
left: 10.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "first".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: "First".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "second".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
..Default::default()
},
content: "Second".to_string(),
}),
],
},
};
let data = serde_json::json!({});
let fonts = load_test_fonts();
let result = compute_layout(&template, &data, &fonts);
let page = &result.pages[0];
let first = page.elements.iter().find(|e| e.id == "first").unwrap();
let second = page.elements.iter().find(|e| e.id == "second").unwrap();
// In column direction, second should be below first
assert!(
second.y_mm > first.y_mm,
"Second element (y={}) should be below first (y={})",
second.y_mm,
first.y_mm
);
}

View File

@@ -0,0 +1,256 @@
//! PDF render integration tests.
//! Only compiled on non-WASM targets since pdf_render uses krilla (native only).
#![cfg(not(target_arch = "wasm32"))]
use dreport_core::models::*;
use dreport_layout::{compute_layout, FontData};
fn load_test_fonts() -> Vec<FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("backend/fonts");
let mut fonts = Vec::new();
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: std::fs::read(&path).unwrap(),
});
}
}
fonts
}
fn simple_template() -> Template {
Template {
id: "pdf_test".to_string(),
name: "PDF Test".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "PDF Render Test".to_string(),
})],
},
}
}
#[test]
fn test_render_pdf_produces_valid_output() {
let template = simple_template();
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
// PDF should not be empty
assert!(
!pdf_bytes.is_empty(),
"PDF output should not be empty"
);
// PDF should start with %PDF magic bytes
assert!(
pdf_bytes.starts_with(b"%PDF"),
"PDF output should start with %PDF magic bytes, got: {:?}",
&pdf_bytes[..std::cmp::min(10, pdf_bytes.len())]
);
}
#[test]
fn test_render_pdf_with_multiple_elements() {
let template = Template {
id: "pdf_multi".to_string(),
name: "PDF Multi".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
top: 15.0,
right: 15.0,
bottom: 15.0,
left: 15.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "header".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(16.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "sep".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::StaticText(StaticTextElement {
id: "body".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: "Bu bir test belgesidir.".to_string(),
}),
],
},
};
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF"));
// A PDF with multiple elements should be reasonably sized
assert!(
pdf_bytes.len() > 100,
"PDF with multiple elements should be >100 bytes, got {}",
pdf_bytes.len()
);
}
#[test]
fn test_render_pdf_with_container_styles() {
let template = Template {
id: "pdf_styled".to_string(),
name: "PDF Styled".to_string(),
page: PageSettings {
width: 210.0,
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 20.0,
right: 20.0,
bottom: 20.0,
left: 20.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: Some("#f0f0f0".to_string()),
border_color: Some("#333333".to_string()),
border_width: Some(1.0),
..Default::default()
},
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "text".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
style: TextStyle {
font_size: Some(12.0),
color: Some("#ff0000".to_string()),
..Default::default()
},
content: "Styled text".to_string(),
})],
},
};
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF"));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,205 @@
//! Visual regression tests for PDF rendering.
//!
//! Generates PDF from fixture template+data, converts to PNG via pdftoppm,
//! and compares against reference snapshots.
//!
//! Set UPDATE_SNAPSHOTS=1 to update reference images.
#![cfg(not(target_arch = "wasm32"))]
mod visual {
use std::fs;
use std::path::Path;
use std::process::Command;
use dreport_core::models::Template;
use dreport_layout::{compute_layout, FontData};
use dreport_layout::pdf_render::render_pdf;
fn fixtures_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn snapshots_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots")
}
fn load_test_fonts() -> Vec<FontData> {
let font_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("backend/fonts");
let mut fonts = Vec::new();
for entry in fs::read_dir(&font_dir).expect("backend/fonts directory not found") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let family = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.split('-')
.next()
.unwrap_or("Unknown")
.to_string();
let family = if family == "NotoSansMono" {
"Noto Sans Mono".to_string()
} else if family == "NotoSans" {
"Noto Sans".to_string()
} else {
family
};
fonts.push(FontData {
family,
data: fs::read(&path).unwrap(),
});
}
}
fonts
}
fn generate_test_pdf(template_name: &str, data_name: &str) -> Vec<u8> {
let template_json = fs::read_to_string(fixtures_dir().join(template_name)).unwrap();
let data_json = fs::read_to_string(fixtures_dir().join(data_name)).unwrap();
let template: Template = serde_json::from_str(&template_json).unwrap();
let data: serde_json::Value = serde_json::from_str(&data_json).unwrap();
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
render_pdf(&layout, &fonts).expect("PDF render failed")
}
fn pdf_to_png(pdf_bytes: &[u8], output_path: &Path) -> bool {
// Write PDF to temp file
let temp_pdf = output_path.with_extension("pdf");
fs::write(&temp_pdf, pdf_bytes).unwrap();
// pdftoppm appends .png to the output prefix, so strip the extension
let output_prefix = output_path.with_extension("");
let result = Command::new("pdftoppm")
.args(["-png", "-r", "150", "-singlefile"])
.arg(&temp_pdf)
.arg(&output_prefix)
.output();
// Clean up temp PDF
let _ = fs::remove_file(&temp_pdf);
match result {
Ok(output) => {
if !output.status.success() {
eprintln!(
"pdftoppm failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return false;
}
true
}
Err(_) => {
eprintln!("pdftoppm not available - skipping visual test");
false
}
}
}
fn compare_images(
actual_path: &Path,
reference_path: &Path,
max_diff_ratio: f64,
) -> Result<f64, String> {
let actual =
image::open(actual_path).map_err(|e| format!("Failed to open actual: {}", e))?;
let reference =
image::open(reference_path).map_err(|e| format!("Failed to open reference: {}", e))?;
let actual_rgba = actual.to_rgba8();
let reference_rgba = reference.to_rgba8();
if actual_rgba.dimensions() != reference_rgba.dimensions() {
return Err(format!(
"Dimension mismatch: actual {:?} vs reference {:?}",
actual_rgba.dimensions(),
reference_rgba.dimensions()
));
}
let total_pixels = (actual_rgba.width() * actual_rgba.height()) as f64;
let mut diff_pixels = 0u64;
for (a, r) in actual_rgba.pixels().zip(reference_rgba.pixels()) {
// Allow per-channel tolerance of 2 for font rendering differences
let channel_diff = a
.0
.iter()
.zip(r.0.iter())
.any(|(ac, rc)| (*ac as i32 - *rc as i32).unsigned_abs() > 2);
if channel_diff {
diff_pixels += 1;
}
}
let diff_ratio = diff_pixels as f64 / total_pixels;
if diff_ratio > max_diff_ratio {
Err(format!(
"Visual diff too large: {:.4}% pixels differ (threshold: {:.4}%)",
diff_ratio * 100.0,
max_diff_ratio * 100.0
))
} else {
Ok(diff_ratio)
}
}
#[test]
fn test_visual_snapshot_basic() {
let pdf_bytes =
generate_test_pdf("visual_test_template.json", "visual_test_data.json");
assert!(!pdf_bytes.is_empty(), "PDF should not be empty");
let snap_dir = snapshots_dir();
fs::create_dir_all(&snap_dir).unwrap();
let actual_png = snap_dir.join("visual_test_actual.png");
let reference_png = snap_dir.join("visual_test_reference.png");
if !pdf_to_png(&pdf_bytes, &actual_png) {
eprintln!("Skipping visual comparison - pdftoppm not available");
return;
}
let update_snapshots = std::env::var("UPDATE_SNAPSHOTS").is_ok();
if !reference_png.exists() || update_snapshots {
// First run or explicit update: save as reference
fs::copy(&actual_png, &reference_png).unwrap();
println!("Reference snapshot saved to {:?}", reference_png);
// Clean up actual
let _ = fs::remove_file(&actual_png);
return;
}
// Compare
match compare_images(&actual_png, &reference_png, 0.01) {
Ok(diff) => {
println!(
"Visual test passed: {:.4}% pixels differ",
diff * 100.0
);
let _ = fs::remove_file(&actual_png);
}
Err(err) => {
// Keep actual for debugging
panic!(
"Visual regression detected: {}. Actual saved at {:?}",
err, actual_png
);
}
}
}
}