improvements

This commit is contained in:
2026-04-07 02:55:16 +03:00
parent 5ffc6d866c
commit 09dc2b4ecd
16 changed files with 1876 additions and 14 deletions

View File

@@ -25,12 +25,14 @@ fn base_template() -> Template {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
condition: None,
padding: Padding {
top: 15.0,
right: 15.0,
@@ -57,6 +59,7 @@ fn test_1_2_text_wrapping_layout_height() {
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "long_text".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa
height: SizeValue::Auto,
@@ -92,6 +95,7 @@ fn test_1_2_text_wrapping_pdf_renders() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "wrap_pdf".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
@@ -123,6 +127,7 @@ fn test_1_3_image_object_fit_in_layout() {
tpl.root.children.push(TemplateElement::Image(ImageElement {
id: "img_contain".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 30.0 },
@@ -164,6 +169,7 @@ fn test_1_4_italic_font_in_pdf() {
.push(TemplateElement::StaticText(StaticTextElement {
id: "italic_text".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -201,6 +207,7 @@ fn test_1_4_bold_italic_font_in_pdf() {
.push(TemplateElement::StaticText(StaticTextElement {
id: "bold_italic".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -234,6 +241,7 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_no_repeat".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -299,6 +307,7 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_repeat".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -382,6 +391,7 @@ fn test_2_2_table_column_format_currency() {
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_fmt".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -451,6 +461,7 @@ fn test_2_3_rounded_rectangle_renders() {
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "rounded_shape".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
height: SizeValue::Fixed { value: 30.0 },
@@ -496,6 +507,7 @@ fn test_2_3_container_border_radius_renders() {
.push(TemplateElement::StaticText(StaticTextElement {
id: "text_in_rounded".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
@@ -594,6 +606,7 @@ fn test_ellipse_shape_renders() {
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "ellipse".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 20.0 },
@@ -613,3 +626,437 @@ fn test_ellipse_shape_renders() {
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf.starts_with(b"%PDF"));
}
// =============================================================================
// 7.1 Conditional Rendering
// =============================================================================
#[test]
fn test_7_1_condition_gt_hides_element() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "always_visible".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Visible".to_string(),
}));
tpl.root.children.push(TemplateElement::Text(TextElement {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: None,
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
}));
let fonts = load_test_fonts();
// iskonto = 0 → koşul sağlanmaz, element gizlenmeli
let data_no_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 0 } });
let layout = compute_layout(&tpl, &data_no_iskonto, &fonts).unwrap();
let page = &layout.pages[0];
assert!(
!page.elements.iter().any(|e| e.id == "conditional_text"),
"iskonto=0 durumunda conditional_text gizlenmeli"
);
assert!(
page.elements.iter().any(|e| e.id == "always_visible"),
"koşulsuz eleman her zaman görünmeli"
);
}
#[test]
fn test_7_1_condition_gt_shows_element() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Text(TextElement {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: None,
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
}));
let fonts = load_test_fonts();
// iskonto = 500 → koşul sağlanır, element görünmeli
let data_with_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 500 } });
let layout = compute_layout(&tpl, &data_with_iskonto, &fonts).unwrap();
let page = &layout.pages[0];
assert!(
page.elements.iter().any(|e| e.id == "conditional_text"),
"iskonto>0 durumunda conditional_text görünmeli"
);
}
#[test]
fn test_7_1_condition_eq_operator() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "status_text".to_string(),
condition: Some(Condition {
path: "durum".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!("aktif")),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Aktif".to_string(),
}));
let fonts = load_test_fonts();
// durum = "aktif" → görünür
let layout = compute_layout(&tpl, &serde_json::json!({"durum": "aktif"}), &fonts).unwrap();
assert!(layout.pages[0].elements.iter().any(|e| e.id == "status_text"));
// durum = "pasif" → gizli
let layout = compute_layout(&tpl, &serde_json::json!({"durum": "pasif"}), &fonts).unwrap();
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "status_text"));
}
#[test]
fn test_7_1_condition_empty_not_empty() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "show_if_exists".to_string(),
condition: Some(Condition {
path: "note".to_string(),
operator: "not_empty".to_string(),
value: None,
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Has note".to_string(),
}));
let fonts = load_test_fonts();
// note yok → gizli
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
// note var → görünür
let layout = compute_layout(&tpl, &serde_json::json!({"note": "merhaba"}), &fonts).unwrap();
assert!(layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
// note boş string → gizli
let layout = compute_layout(&tpl, &serde_json::json!({"note": ""}), &fonts).unwrap();
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
}
#[test]
fn test_7_1_condition_on_container_hides_children() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Container(ContainerElement {
id: "cond_container".to_string(),
condition: Some(Condition {
path: "show".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!(true)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "child_text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Child".to_string(),
})],
}));
let fonts = load_test_fonts();
// show=false → container ve çocukları gizli
let layout = compute_layout(&tpl, &serde_json::json!({"show": false}), &fonts).unwrap();
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "cond_container"));
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "child_text"));
// show=true → container ve çocukları görünür
let layout = compute_layout(&tpl, &serde_json::json!({"show": true}), &fonts).unwrap();
assert!(layout.pages[0].elements.iter().any(|e| e.id == "cond_container"));
assert!(layout.pages[0].elements.iter().any(|e| e.id == "child_text"));
}
// =============================================================================
// 7.5 Localization / FormatConfig from locale
// =============================================================================
#[test]
fn test_7_5_locale_en_us_currency() {
let config = FormatConfig::from_locale("en-US");
assert_eq!(config.thousands_separator, ",");
assert_eq!(config.decimal_separator, ".");
assert_eq!(config.currency_symbol, "$");
assert_eq!(config.currency_position, "prefix");
}
#[test]
fn test_7_5_locale_de_de_currency() {
let config = FormatConfig::from_locale("de-DE");
assert_eq!(config.thousands_separator, ".");
assert_eq!(config.decimal_separator, ",");
assert_eq!(config.currency_symbol, "");
assert_eq!(config.currency_position, "suffix");
}
#[test]
fn test_7_5_locale_fr_fr_currency() {
let config = FormatConfig::from_locale("fr-FR");
assert_eq!(config.thousands_separator, " ");
assert_eq!(config.decimal_separator, ",");
assert_eq!(config.currency_symbol, "");
}
#[test]
fn test_7_5_locale_tr_default() {
let config = FormatConfig::from_locale("tr-TR");
assert_eq!(config, FormatConfig::default());
}
#[test]
fn test_7_5_unknown_locale_falls_back_to_default() {
let config = FormatConfig::from_locale("xx-XX");
assert_eq!(config, FormatConfig::default());
}
#[test]
fn test_7_5_effective_format_config_priority() {
// format_config set → onu kullan
let tpl = Template {
id: "t1".to_string(),
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![],
},
format_config: Some(FormatConfig {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
}),
locale: Some("de-DE".to_string()),
};
let fc = tpl.effective_format_config();
assert_eq!(fc.currency_symbol, "$"); // format_config kullanılır, de-DE değil
}
#[test]
fn test_7_5_effective_format_config_locale_fallback() {
let tpl = Template {
id: "t1".to_string(),
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![],
},
format_config: None,
locale: Some("en-US".to_string()),
};
let fc = tpl.effective_format_config();
assert_eq!(fc.currency_symbol, "$");
assert_eq!(fc.currency_position, "prefix");
}
#[test]
fn test_7_5_locale_affects_table_currency_format() {
let mut tpl = base_template();
tpl.locale = Some("en-US".to_string());
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_locale".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {
id: "col_price".to_string(),
field: "price".to_string(),
title: "Price".to_string(),
width: SizeValue::Fr { value: 1.0 },
align: "right".to_string(),
format: Some("currency".to_string()),
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}));
let data = serde_json::json!({
"items": [
{ "price": 1500 }
]
});
// data_resolve seviyesinde kontrol: locale en-US → $ prefix, comma thousands
let resolved = dreport_layout::data_resolve::resolve_template(&tpl, &data);
let table = resolved.tables.get("tbl_locale").expect("tbl_locale should be resolved");
assert_eq!(table.rows.len(), 1);
assert_eq!(table.rows[0][0], "$1,500.00");
}
// =============================================================================
// 8.1 Chart Legend — tek seri durumunda da render edilmeli
// =============================================================================
#[test]
fn test_8_1_legend_renders_for_single_series() {
use dreport_layout::chart_render::render_svg;
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
let data = ResolvedChartData {
chart_type: ChartType::Bar,
categories: vec!["A".to_string(), "B".to_string()],
series: vec![ChartSeries {
name: "Revenue".to_string(),
values: vec![100.0, 200.0],
}],
title: None,
legend: Some(ChartLegend { show: true, position: None, font_size: None }),
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
};
let svg = render_svg(&data, 100.0, 60.0);
let has_swatch = svg.contains(r#"width="2.5" height="2.5""#);
assert!(has_swatch, "tek serili chart'ta legend.show=true olunca legend render edilmeli");
assert!(svg.contains("Revenue"), "legend seri adını göstermeli");
}
#[test]
fn test_8_1_legend_hidden_when_show_false() {
use dreport_layout::chart_render::render_svg;
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
let data = ResolvedChartData {
chart_type: ChartType::Bar,
categories: vec!["A".to_string()],
series: vec![ChartSeries {
name: "Sales".to_string(),
values: vec![50.0],
}],
title: None,
legend: Some(ChartLegend { show: false, position: None, font_size: None }),
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
};
let svg = render_svg(&data, 100.0, 60.0);
let has_swatch = svg.contains(r#"width="2.5" height="2.5""#);
assert!(!has_swatch, "legend.show=false olunca legend render edilmemeli");
}
// =============================================================================
// 8.2 Pie Chart Label Kontrolü
// =============================================================================
#[test]
fn test_8_2_pie_labels_hidden_when_show_false() {
use dreport_layout::chart_render::render_svg;
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
let data = ResolvedChartData {
chart_type: ChartType::Pie,
categories: vec!["Gida".to_string(), "Ulasim".to_string(), "Kira".to_string()],
series: vec![ChartSeries {
name: "data".to_string(),
values: vec![50.0, 30.0, 20.0],
}],
title: None,
legend: None,
labels: Some(ChartLabels { show: false, font_size: None, color: None }),
axis: None,
style: ChartStyle::default(),
group_mode: None,
};
let svg = render_svg(&data, 80.0, 80.0);
assert!(!svg.contains("Gida"), "labels.show=false iken kategori adı görünmemeli");
assert!(!svg.contains("Ulasim"), "labels.show=false iken kategori adı görünmemeli");
assert!(!svg.contains("50%"), "labels.show=false iken yüzde etiketi görünmemeli");
}
#[test]
fn test_8_2_pie_labels_shown_when_show_true() {
use dreport_layout::chart_render::render_svg;
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
let data = ResolvedChartData {
chart_type: ChartType::Pie,
categories: vec!["Gida".to_string(), "Ulasim".to_string()],
series: vec![ChartSeries {
name: "data".to_string(),
values: vec![75.0, 25.0],
}],
title: None,
legend: None,
labels: Some(ChartLabels { show: true, font_size: None, color: None }),
axis: None,
style: ChartStyle::default(),
group_mode: None,
};
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.contains("Gida"), "labels.show=true iken kategori adı görünmeli");
assert!(svg.contains("75%"), "labels.show=true iken yüzde etiketi görünmeli");
}