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

@@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec<FontData> { ... }
## 7. Yeni Ozellik Onerileri
### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]`
### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]`
**Aciklama:**
Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle.
@@ -714,7 +714,7 @@ Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir ka
---
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]`
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILDI]`
**Aciklama:**
Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded.
@@ -745,7 +745,7 @@ Template'te header/footer tanimi icin `condition` alani:
---
### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]`
### 7.7 QR Code Eleman Tipi `[bu var zaten, barcode özelliklerinden barkod tipi seçilebiliyor qr olarak]`
**Mevcut Durum:**
`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis.
@@ -773,7 +773,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
## 8. Kucuk Ama Degerli Iyilestirmeler
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]`
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/chart_render.rs`
@@ -781,7 +781,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
---
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]`
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILDI]`
**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551)

View File

@@ -97,6 +97,20 @@ pub struct ContainerStyle {
pub border_style: Option<String>,
}
// --- Condition (v-if benzeri koşullu gösterim) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Condition {
/// Data JSON'daki alan yolu (ör: "toplamlar.iskonto")
pub path: String,
/// Karşılaştırma operatörü: eq, neq, gt, gte, lt, lte, empty, not_empty
pub operator: String,
/// Karşılaştırılacak değer (empty/not_empty için gerekmez)
#[serde(default)]
pub value: Option<serde_json::Value>,
}
// --- Binding ---
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -234,6 +248,8 @@ pub struct ChartStyle {
#[serde(rename_all = "camelCase")]
pub struct ChartElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub chart_type: ChartType,
@@ -340,6 +356,26 @@ impl TemplateElement {
}
}
pub fn condition(&self) -> Option<&Condition> {
match self {
Self::Container(e) => e.condition.as_ref(),
Self::StaticText(e) => e.condition.as_ref(),
Self::Text(e) => e.condition.as_ref(),
Self::Line(e) => e.condition.as_ref(),
Self::RepeatingTable(e) => e.condition.as_ref(),
Self::Image(e) => e.condition.as_ref(),
Self::PageNumber(e) => e.condition.as_ref(),
Self::Barcode(e) => e.condition.as_ref(),
Self::PageBreak(e) => e.condition.as_ref(),
Self::CurrentDate(e) => e.condition.as_ref(),
Self::Shape(e) => e.condition.as_ref(),
Self::Checkbox(e) => e.condition.as_ref(),
Self::CalculatedText(e) => e.condition.as_ref(),
Self::RichText(e) => e.condition.as_ref(),
Self::Chart(e) => e.condition.as_ref(),
}
}
pub fn size(&self) -> &SizeConstraint {
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
width: SizeValue::Auto,
@@ -373,6 +409,8 @@ impl TemplateElement {
#[serde(rename_all = "camelCase")]
pub struct RichTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(default)]
@@ -385,6 +423,8 @@ pub struct RichTextElement {
pub struct ContainerElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
@@ -424,6 +464,8 @@ fn default_start() -> String {
#[serde(rename_all = "camelCase")]
pub struct StaticTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
@@ -434,6 +476,8 @@ pub struct StaticTextElement {
#[serde(rename_all = "camelCase")]
pub struct TextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
@@ -445,6 +489,8 @@ pub struct TextElement {
#[serde(rename_all = "camelCase")]
pub struct LineElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: LineStyle,
@@ -454,6 +500,8 @@ pub struct LineElement {
#[serde(rename_all = "camelCase")]
pub struct ImageElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub src: Option<String>,
@@ -465,6 +513,8 @@ pub struct ImageElement {
#[serde(rename_all = "camelCase")]
pub struct PageNumberElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
@@ -475,6 +525,8 @@ pub struct PageNumberElement {
#[serde(rename_all = "camelCase")]
pub struct BarcodeElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub format: String, // qr, ean13, ean8, code128, code39
@@ -487,6 +539,8 @@ pub struct BarcodeElement {
#[serde(rename_all = "camelCase")]
pub struct RepeatingTableElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub data_source: ArrayBinding,
@@ -504,12 +558,16 @@ fn default_true() -> Option<bool> {
#[serde(rename_all = "camelCase")]
pub struct PageBreakElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentDateElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
@@ -520,6 +578,8 @@ pub struct CurrentDateElement {
#[serde(rename_all = "camelCase")]
pub struct ShapeElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
@@ -539,6 +599,8 @@ pub struct CheckboxStyle {
#[serde(rename_all = "camelCase")]
pub struct CheckboxElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub checked: Option<bool>, // statik değer
@@ -550,6 +612,8 @@ pub struct CheckboxElement {
#[serde(rename_all = "camelCase")]
pub struct CalculatedTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
@@ -572,6 +636,10 @@ pub struct Template {
pub root: ContainerElement,
#[serde(default)]
pub format_config: Option<FormatConfig>,
/// Lokalizasyon: "tr-TR", "en-US", "de-DE", "fr-FR" vb.
/// Belirtilirse ve format_config yoksa, locale'den FormatConfig türetilir.
#[serde(default)]
pub locale: Option<String>,
}
/// Sayı/para birimi formatlama ayarları.
@@ -617,3 +685,53 @@ impl Default for FormatConfig {
}
}
}
impl FormatConfig {
/// Locale string'inden FormatConfig türet.
/// Desteklenen locale'ler: tr-TR, en-US, de-DE, fr-FR.
/// Bilinmeyen locale → Türk formatı (varsayılan).
pub fn from_locale(locale: &str) -> Self {
match locale {
"en-US" | "en" => Self {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
},
"de-DE" | "de" => Self {
thousands_separator: ".".to_string(),
decimal_separator: ",".to_string(),
currency_symbol: "".to_string(),
currency_position: "suffix".to_string(),
},
"fr-FR" | "fr" => Self {
thousands_separator: " ".to_string(),
decimal_separator: ",".to_string(),
currency_symbol: "".to_string(),
currency_position: "suffix".to_string(),
},
"en-GB" | "gb" => Self {
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
currency_symbol: "£".to_string(),
currency_position: "prefix".to_string(),
},
// tr-TR veya bilinmeyen → Türk formatı
_ => Self::default(),
}
}
}
impl Template {
/// Template'in etkin FormatConfig'ini döndür.
/// Öncelik: format_config > locale > varsayılan (tr-TR).
pub fn effective_format_config(&self) -> FormatConfig {
if let Some(ref fc) = self.format_config {
fc.clone()
} else if let Some(ref locale) = self.locale {
FormatConfig::from_locale(locale)
} else {
FormatConfig::default()
}
}
}

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useSnapGuides } from '../useSnapGuides'
import type { ElementLayout } from '../../core/layout-types'
function makeLayout(
id: string,
x: number,
y: number,
w: number,
h: number,
): ElementLayout {
return {
id,
x_mm: x,
y_mm: y,
width_mm: w,
height_mm: h,
element_type: 'static_text',
style: {},
} as ElementLayout
}
describe('useSnapGuides', () => {
let guides: ReturnType<typeof useSnapGuides>
beforeEach(() => {
guides = useSnapGuides()
})
describe('collectEdges', () => {
it('collects page edges and element edges', () => {
const layoutMap: Record<string, ElementLayout> = {
el1: makeLayout('el1', 10, 20, 50, 30),
}
guides.collectEdges(layoutMap, 'excluded', 210, 297)
// After collecting, calculateSnap should work
const result = guides.calculateSnap(0, 0, 10, 10)
expect(result).toBeDefined()
})
it('excludes the dragged element', () => {
const layoutMap: Record<string, ElementLayout> = {
dragged: makeLayout('dragged', 50, 50, 20, 20),
other: makeLayout('other', 100, 100, 30, 30),
}
guides.collectEdges(layoutMap, 'dragged', 210, 297)
// Snap to "other" element's left edge (100mm)
const result = guides.calculateSnap(99.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(100) // snaps to other's left edge
})
})
describe('calculateSnap', () => {
it('returns proposed position when no edges cached', () => {
const result = guides.calculateSnap(42, 73, 10, 10)
expect(result.snappedX_mm).toBe(42)
expect(result.snappedY_mm).toBe(73)
expect(result.guides).toHaveLength(0)
})
it('snaps left edge to page left (0)', () => {
guides.collectEdges({}, 'none', 210, 297)
// Proposed x=0.5 → should snap to 0 (within 1.5mm threshold)
const result = guides.calculateSnap(0.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(0)
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 0 })
})
it('snaps right edge to page right', () => {
guides.collectEdges({}, 'none', 210, 297)
// Element 20mm wide, proposed x=189 → right edge = 209, should snap to 210
const result = guides.calculateSnap(189, 50, 20, 20)
expect(result.snappedX_mm).toBe(190) // 210 - 20 = 190
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 210 })
})
it('snaps center to page center', () => {
guides.collectEdges({}, 'none', 210, 297)
// Element 20mm wide, center at 105mm → x = 95
// Proposed x=94.5 → center = 104.5, should snap to 105 → x = 95
const result = guides.calculateSnap(94.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(95) // 105 - 10 = 95
})
it('snaps top edge to page top', () => {
guides.collectEdges({}, 'none', 210, 297)
const result = guides.calculateSnap(50, 1.0, 20, 20)
expect(result.snappedY_mm).toBe(0)
expect(result.guides).toContainEqual({ type: 'horizontal', position_mm: 0 })
})
it('does not snap when outside threshold', () => {
guides.collectEdges({}, 'none', 210, 297)
// Proposed x=50, far from any edge → no snap
const result = guides.calculateSnap(50, 50, 20, 20)
expect(result.snappedX_mm).toBe(50)
expect(result.snappedY_mm).toBe(50)
})
it('snaps to other element edges', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 30, 40, 50, 20),
}
guides.collectEdges(layoutMap, 'dragged', 210, 297)
// Snap dragged element's left to ref's right (30+50=80)
const result = guides.calculateSnap(79.5, 50, 20, 20)
expect(result.snappedX_mm).toBe(80)
})
it('snaps both axes simultaneously', () => {
guides.collectEdges({}, 'none', 210, 297)
// Near page origin
const result = guides.calculateSnap(0.5, 0.5, 20, 20)
expect(result.snappedX_mm).toBe(0)
expect(result.snappedY_mm).toBe(0)
expect(result.guides).toHaveLength(2)
})
it('updates activeGuides ref', () => {
guides.collectEdges({}, 'none', 210, 297)
guides.calculateSnap(0.5, 0.5, 20, 20)
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
})
})
describe('calculateResizeSnap', () => {
it('returns proposed value when no edges', () => {
const result = guides.calculateResizeSnap('right', 42)
expect(result).toBe(42)
})
it('snaps right edge to nearest vertical', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 100, 50, 40, 20),
}
guides.collectEdges(layoutMap, 'resizing', 210, 297)
// Snap to ref's left edge (100mm)
const result = guides.calculateResizeSnap('right', 99.5)
expect(result).toBe(100)
})
it('snaps bottom edge to nearest horizontal', () => {
const layoutMap: Record<string, ElementLayout> = {
ref: makeLayout('ref', 50, 80, 40, 20),
}
guides.collectEdges(layoutMap, 'resizing', 210, 297)
// Snap to ref's top edge (80mm)
const result = guides.calculateResizeSnap('bottom', 79.5)
expect(result).toBe(80)
})
it('does not snap when outside threshold', () => {
guides.collectEdges({}, 'none', 210, 297)
const result = guides.calculateResizeSnap('right', 50)
expect(result).toBe(50) // no edge near 50mm
})
})
describe('clearGuides', () => {
it('clears active guides and cached edges', () => {
guides.collectEdges({}, 'none', 210, 297)
guides.calculateSnap(0.5, 0.5, 10, 10)
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
guides.clearGuides()
expect(guides.activeGuides.value).toHaveLength(0)
// After clear, calculateSnap should return unsnapped
const result = guides.calculateSnap(0.5, 0.5, 10, 10)
expect(result.snappedX_mm).toBe(0.5)
})
})
})

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { useUndoRedo } from '../useUndoRedo'
describe('useUndoRedo', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts with initial snapshot', () => {
const source = ref({ value: 1 })
const { canUndo, canRedo } = useUndoRedo(source)
expect(canUndo()).toBe(false) // only 1 snapshot (initial)
expect(canRedo()).toBe(false)
})
it('records snapshot after debounce', async () => {
const source = ref({ value: 1 })
const { canUndo } = useUndoRedo(source)
source.value = { value: 2 }
await vi.advanceTimersByTimeAsync(350) // debounce = 300ms
expect(canUndo()).toBe(true)
})
it('undo restores previous state', async () => {
const source = ref({ count: 0 })
const { undo, canUndo } = useUndoRedo(source)
source.value = { count: 1 }
await vi.advanceTimersByTimeAsync(350)
source.value = { count: 2 }
await vi.advanceTimersByTimeAsync(350)
expect(source.value.count).toBe(2)
undo()
expect(source.value.count).toBe(1)
undo()
expect(source.value.count).toBe(0)
})
it('redo restores undone state', async () => {
const source = ref({ count: 0 })
const { undo, redo, canRedo } = useUndoRedo(source)
source.value = { count: 1 }
await vi.advanceTimersByTimeAsync(350)
undo()
expect(source.value.count).toBe(0)
expect(canRedo()).toBe(true)
redo()
expect(source.value.count).toBe(1)
expect(canRedo()).toBe(false)
})
it('new mutation clears redo stack', async () => {
const source = ref({ v: 'a' })
const { undo, redo, canRedo } = useUndoRedo(source)
source.value = { v: 'b' }
await vi.advanceTimersByTimeAsync(350)
undo()
expect(canRedo()).toBe(true)
// New mutation after undo → clears redo
source.value = { v: 'c' }
await vi.advanceTimersByTimeAsync(350)
expect(canRedo()).toBe(false)
})
it('respects maxHistory limit', async () => {
const source = ref({ n: 0 })
const { canUndo, undo } = useUndoRedo(source, 3) // max 3 snapshots
source.value = { n: 1 }
await vi.advanceTimersByTimeAsync(350)
source.value = { n: 2 }
await vi.advanceTimersByTimeAsync(350)
source.value = { n: 3 }
await vi.advanceTimersByTimeAsync(350)
// Stack: [1, 2, 3] (initial 0 was shifted out)
// 3 snapshots, can undo twice (back to 1)
undo()
expect(source.value.n).toBe(2)
undo()
expect(source.value.n).toBe(1)
// Can't undo further (stack has only 1 left)
expect(canUndo()).toBe(false)
})
it('skips duplicate snapshots', async () => {
const source = ref({ x: 1 })
const { canUndo } = useUndoRedo(source)
// Set same value
source.value = { x: 1 }
await vi.advanceTimersByTimeAsync(350)
expect(canUndo()).toBe(false) // no new snapshot since value same
})
it('debounces rapid changes into one snapshot', async () => {
const source = ref({ n: 0 })
const { undo } = useUndoRedo(source)
// Rapid changes within debounce window
source.value = { n: 1 }
await vi.advanceTimersByTimeAsync(100)
source.value = { n: 2 }
await vi.advanceTimersByTimeAsync(100)
source.value = { n: 3 }
await vi.advanceTimersByTimeAsync(350) // trigger debounce
// Only one snapshot recorded (n=3), so one undo goes to initial
undo()
expect(source.value.n).toBe(0)
})
it('undo with only initial snapshot does nothing', () => {
const source = ref({ v: 'init' })
const { undo } = useUndoRedo(source)
undo() // should not crash
expect(source.value.v).toBe('init')
})
it('redo with empty redo stack does nothing', () => {
const source = ref({ v: 'init' })
const { redo } = useUndoRedo(source)
redo() // should not crash
expect(source.value.v).toBe('init')
})
})

View File

@@ -162,6 +162,8 @@ pub struct PieChartLayout {
pub inner_radius: f64,
pub slices: Vec<PieSlice>,
pub show_labels: bool,
/// Category name labels + leader lines outside slices
pub show_cat_labels: bool,
pub label_font: f64,
pub label_color: String,
}
@@ -955,6 +957,7 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
inner_radius: inner_r,
slices,
show_labels,
show_cat_labels: show_labels,
label_font,
label_color,
}

View File

@@ -48,7 +48,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
}
// Legend render
if cl.legend_show && data.series.len() > 1 {
if cl.legend_show {
render_legend(&mut svg, data, &cl, width_mm, height_mm);
}
@@ -251,7 +251,7 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
// Category name label outside slice with leader line
if !slice.cat_label_text.is_empty() {
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
@@ -362,3 +362,250 @@ fn escape_xml(s: &str) -> String {
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_resolve::{ChartSeries, ResolvedChartData};
use dreport_core::models::{ChartAxis, ChartLabels, ChartLegend, ChartStyle, ChartTitle, ChartType};
fn make_bar_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
ResolvedChartData {
chart_type: ChartType::Bar,
categories: categories.into_iter().map(|s| s.to_string()).collect(),
series: series
.into_iter()
.map(|(name, values)| ChartSeries {
name: name.to_string(),
values,
})
.collect(),
title: None,
legend: None,
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
}
}
fn make_line_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
let mut data = make_bar_data(categories, series);
data.chart_type = ChartType::Line;
data
}
fn make_pie_data(categories: Vec<&str>, values: Vec<f64>) -> ResolvedChartData {
ResolvedChartData {
chart_type: ChartType::Pie,
categories: categories.into_iter().map(|s| s.to_string()).collect(),
series: vec![ChartSeries {
name: "data".to_string(),
values,
}],
title: None,
legend: None,
labels: None,
axis: None,
style: ChartStyle::default(),
group_mode: None,
}
}
#[test]
fn test_bar_chart_svg_structure() {
let data = make_bar_data(vec!["A", "B", "C"], vec![("Sales", vec![10.0, 20.0, 30.0])]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
// 3 categories × 1 series = 3 bars (each with rx="0.5")
let bar_count = svg.matches(r#"rx="0.5""#).count();
assert_eq!(bar_count, 3, "expected 3 bars for 3 categories, got {}", bar_count);
}
#[test]
fn test_bar_chart_with_labels() {
let mut data = make_bar_data(vec!["A", "B"], vec![("S1", vec![10.0, 20.0])]);
data.labels = Some(ChartLabels {
show: true,
font_size: None,
color: None,
});
let svg = render_svg(&data, 100.0, 60.0);
// Labels shown → should contain text elements with formatted values
assert!(svg.contains("<text"), "labels enabled but no text elements found");
}
#[test]
fn test_line_chart_svg_structure() {
let data = make_line_data(vec!["Jan", "Feb", "Mar"], vec![("Revenue", vec![5.0, 15.0, 10.0])]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
// Should contain polyline for the series
assert!(svg.contains("<polyline"), "line chart should contain polyline");
}
#[test]
fn test_line_chart_with_points() {
let mut data = make_line_data(vec!["A", "B", "C"], vec![("S1", vec![1.0, 2.0, 3.0])]);
data.style.show_points = Some(true);
let svg = render_svg(&data, 100.0, 60.0);
// 3 data points → 3 circles
let circle_count = svg.matches("<circle").count();
assert_eq!(circle_count, 3, "expected 3 circles for 3 data points, got {}", circle_count);
}
#[test]
fn test_pie_chart_svg_structure() {
let data = make_pie_data(vec!["A", "B", "C"], vec![50.0, 30.0, 20.0]);
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.starts_with("<svg"));
// 3 slices → 3 path elements
let path_count = svg.matches("<path d=").count();
assert_eq!(path_count, 3, "expected 3 pie slices, got {}", path_count);
}
#[test]
fn test_pie_chart_percentage_labels() {
let mut data = make_pie_data(vec!["A", "B"], vec![75.0, 25.0]);
data.labels = Some(ChartLabels {
show: true,
font_size: None,
color: None,
});
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.contains("75%"), "should show 75% label");
assert!(svg.contains("25%"), "should show 25% label");
}
#[test]
fn test_legend_renders_for_multi_series() {
let mut data = make_bar_data(
vec!["A", "B"],
vec![("Series 1", vec![10.0, 20.0]), ("Series 2", vec![15.0, 25.0])],
);
data.legend = Some(ChartLegend {
show: true,
position: None,
font_size: None,
});
let svg = render_svg(&data, 100.0, 60.0);
// Multi-series + legend.show → legend should render
assert!(svg.contains("Series 1"), "legend should show series name");
assert!(svg.contains("Series 2"), "legend should show second series name");
}
#[test]
fn test_legend_hidden_for_single_series() {
let data = make_bar_data(vec!["A", "B"], vec![("Only", vec![10.0, 20.0])]);
let svg = render_svg(&data, 100.0, 60.0);
// legend: None → legend_show=false → legend not rendered
// The text "Only" might appear in x-axis labels, so check for legend swatch rect pattern
// Legend renders swatch rects with width="2.5" height="2.5"
let legend_swatch = svg.contains(r#"width="2.5" height="2.5""#);
assert!(!legend_swatch, "single series should not render legend swatches");
}
#[test]
fn test_empty_categories_bar_chart() {
let data = make_bar_data(vec![], vec![("S", vec![])]);
let svg = render_svg(&data, 100.0, 60.0);
// Should still produce valid SVG (bg rect + no bars)
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn test_empty_series_bar_chart() {
let data = make_bar_data(vec!["A", "B"], vec![]);
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn test_empty_pie_chart() {
let data = make_pie_data(vec![], vec![]);
let svg = render_svg(&data, 80.0, 80.0);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
// No slices
assert!(!svg.contains("<path d="), "empty pie should have no slices");
}
#[test]
fn test_title_rendered() {
let mut data = make_bar_data(vec!["A"], vec![("S", vec![10.0])]);
data.title = Some(ChartTitle {
text: "My Chart Title".to_string(),
font_size: Some(4.0),
color: Some("#333".to_string()),
align: None,
});
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.contains("My Chart Title"), "title should be rendered");
}
#[test]
fn test_axis_labels_rendered() {
let mut data = make_bar_data(vec!["Q1", "Q2"], vec![("Sales", vec![100.0, 200.0])]);
data.axis = Some(ChartAxis {
x_label: Some("Quarter".to_string()),
y_label: Some("Revenue".to_string()),
show_grid: None,
grid_color: None,
});
let svg = render_svg(&data, 100.0, 60.0);
assert!(svg.contains("Quarter"), "x axis label should be rendered");
assert!(svg.contains("Revenue"), "y axis label should be rendered");
}
#[test]
fn test_axis_labels_not_on_pie() {
let mut data = make_pie_data(vec!["A", "B"], vec![50.0, 50.0]);
data.axis = Some(ChartAxis {
x_label: Some("X Label".to_string()),
y_label: Some("Y Label".to_string()),
show_grid: None,
grid_color: None,
});
let svg = render_svg(&data, 80.0, 80.0);
// Pie charts should not render axis labels
assert!(!svg.contains("X Label"), "pie chart should not have x axis label");
assert!(!svg.contains("Y Label"), "pie chart should not have y axis label");
}
#[test]
fn test_escape_xml_special_chars() {
assert_eq!(escape_xml("a & b"), "a &amp; b");
assert_eq!(escape_xml("<script>"), "&lt;script&gt;");
assert_eq!(escape_xml(r#"say "hi""#), "say &quot;hi&quot;");
assert_eq!(escape_xml("normal"), "normal");
}
#[test]
fn test_donut_chart_inner_radius() {
let mut data = make_pie_data(vec!["A", "B"], vec![60.0, 40.0]);
data.style.inner_radius = Some(0.5);
let svg = render_svg(&data, 80.0, 80.0);
// Donut chart uses arc paths with inner radius → the path should contain "A" commands
// for both outer and inner arcs
let path_count = svg.matches("<path d=").count();
assert_eq!(path_count, 2, "donut chart should have 2 slices");
}
}

View File

@@ -106,6 +106,8 @@ pub struct ResolvedData {
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
/// element_id → çözümlenmiş chart verisi
pub charts: HashMap<String, ResolvedChartData>,
/// Koşulu sağlamayan (gizlenmesi gereken) element ID'leri
pub hidden_elements: std::collections::HashSet<String>,
}
#[derive(Debug, Clone)]
@@ -146,30 +148,91 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
charts: HashMap::new(),
hidden_elements: std::collections::HashSet::new(),
};
let fc = template.effective_format_config();
if let Some(ref header) = template.header {
resolve_element(
&TemplateElement::Container(header.clone()),
data,
&mut resolved,
&fc,
);
}
resolve_element(
&TemplateElement::Container(template.root.clone()),
data,
&mut resolved,
&fc,
);
if let Some(ref footer) = template.footer {
resolve_element(
&TemplateElement::Container(footer.clone()),
data,
&mut resolved,
&fc,
);
}
resolved
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) {
/// Koşul değerlendirme: Condition struct'ındaki path, operator, value ile data'yı karşılaştır.
fn evaluate_condition(condition: &dreport_core::models::Condition, data: &Value) -> bool {
let actual = resolve_path(data, &condition.path);
match condition.operator.as_str() {
"empty" => matches!(actual, Value::Null) || actual.as_str().is_some_and(|s| s.is_empty()),
"not_empty" => !matches!(actual, Value::Null) && !actual.as_str().is_some_and(|s| s.is_empty()),
"eq" => {
if let Some(ref expected) = condition.value {
json_values_eq(actual, expected)
} else {
actual.is_null()
}
}
"neq" => {
if let Some(ref expected) = condition.value {
!json_values_eq(actual, expected)
} else {
!actual.is_null()
}
}
op @ ("gt" | "gte" | "lt" | "lte") => {
let a = actual.as_f64().unwrap_or(0.0);
let b = condition.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0);
match op {
"gt" => a > b,
"gte" => a >= b,
"lt" => a < b,
"lte" => a <= b,
_ => unreachable!(),
}
}
_ => true, // bilinmeyen operator → göster
}
}
/// İki JSON değerini karşılaştır (tip dönüşümlü).
fn json_values_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(),
(Value::String(a), Value::String(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Null, Value::Null) => true,
// Çapraz tip karşılaştırma: sayı string vs sayı
(Value::String(s), Value::Number(n)) | (Value::Number(n), Value::String(s)) => {
s.parse::<f64>().ok() == n.as_f64()
}
_ => a == b,
}
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData, format_config: &dreport_core::models::FormatConfig) {
// Koşul kontrolü: condition varsa ve sağlanmıyorsa, hidden olarak işaretle ve çık
if let Some(condition) = el.condition() && !evaluate_condition(condition, data) {
resolved.hidden_elements.insert(el.id().to_string());
return;
}
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
@@ -228,7 +291,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
let raw = value_to_string(v);
// Sütun formatı varsa uygula (currency, percentage, number, date)
if let Some(ref fmt) = col.format {
crate::expr_eval::apply_format(&raw, Some(fmt.as_str()))
crate::expr_eval::apply_format_with_config(&raw, Some(fmt.as_str()), format_config)
} else {
raw
}
@@ -243,7 +306,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
TemplateElement::Container(e) => {
for child in &e.children {
resolve_element(child, data, resolved);
resolve_element(child, data, resolved, format_config);
}
}
TemplateElement::CurrentDate(e) => {
@@ -268,7 +331,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
TemplateElement::CalculatedText(e) => {
let result = crate::expr_eval::evaluate_expression(&e.expression, data);
let formatted = crate::expr_eval::apply_format(&result, e.format.as_deref());
let formatted = crate::expr_eval::apply_format_with_config(&result, e.format.as_deref(), format_config);
// Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin
let text = if formatted.is_empty() {
" ".to_string()
@@ -477,8 +540,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -490,6 +555,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_name".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(),
@@ -525,8 +591,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -538,6 +606,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_no".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(),
@@ -570,8 +639,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -583,6 +654,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(),
@@ -608,8 +680,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -621,6 +695,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
data_source: ArrayBinding {
@@ -677,8 +752,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -690,6 +767,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
data_source: ArrayBinding {
@@ -728,8 +806,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -741,6 +821,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_missing".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
style: TextStyle::default(),

View File

@@ -896,6 +896,261 @@ mod tests {
);
}
#[test]
fn test_break_inside_avoid_group_moves_to_new_page() {
// break_inside: avoid olan container grubunun mevcut sayfaya sığmadığında
// komple yeni sayfaya taşınması gerekir.
let mut break_modes = HashMap::new();
break_modes.insert("group".to_string(), "avoid".to_string());
// group container: y=250, h=80 → bottom=330 > 297 (sayfa yüksekliği)
// ama 80mm tek sayfaya sığar → yeni sayfaya geçmeli
let mut group = make_element("group", 250.0, 80.0, "container");
group.children = vec!["g_child1".to_string(), "g_child2".to_string()];
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 250.0, "text"),
group,
make_element("g_child1", 250.0, 40.0, "text"),
make_element("g_child2", 290.0, 40.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes,
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2, "avoid group should cause a new page");
// el1 sayfada 1, group + children sayfada 2
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[1].elements.iter().any(|e| e.id == "group"));
assert!(pages[1].elements.iter().any(|e| e.id == "g_child1"));
assert!(pages[1].elements.iter().any(|e| e.id == "g_child2"));
}
#[test]
fn test_avoid_group_larger_than_page_stays_in_flow() {
// break_inside: avoid olan container sayfadan büyükse, normal akışa devam etmeli.
// Yeni sayfaya atlamamalı çünkü zaten sığmıyor.
let mut break_modes = HashMap::new();
break_modes.insert("big_group".to_string(), "avoid".to_string());
let mut big = make_element("big_group", 0.0, 400.0, "container");
big.children = vec!["bg_child".to_string()];
let input = PageSplitInput {
body_elements: vec![
big,
make_element("bg_child", 0.0, 400.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes,
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
// Sayfa 1'de grup başlamalı (sığmasa da mecbur)
assert!(pages[0].elements.iter().any(|e| e.id == "big_group"));
}
#[test]
fn test_element_exactly_at_page_boundary() {
// Eleman tam sayfa sınırına denk geldiğinde doğru sayfalanmalı.
// İki eleman: 148.5mm + 148.5mm = 297mm → tam sığar, tek sayfa.
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 148.5, "text"),
make_element("el2", 148.5, 148.5, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "elements exactly filling page should fit in 1 page");
assert_eq!(pages[0].elements.len(), 2);
}
#[test]
fn test_element_one_mm_over_page_boundary() {
// Eleman sayfa sınırını 1mm aşıyorsa yeni sayfaya geçmeli.
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 148.5, "text"),
make_element("el2", 148.5, 149.5, "text"), // bottom = 298 > 297
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2, "element exceeding page by 1mm should go to page 2");
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[1].elements.iter().any(|e| e.id == "el2"));
}
#[test]
fn test_single_element_larger_than_page() {
// Sayfadan büyük tek eleman — mecburen sayfa 1'de kalmalı.
let input = PageSplitInput {
body_elements: vec![
make_element("huge", 0.0, 500.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "single oversized element should stay on page 1");
assert_eq!(pages[0].elements[0].id, "huge");
}
#[test]
fn test_no_repeat_header_tables_suppresses_header() {
// no_repeat_header_tables'a eklenen tablonun header'ı tekrarlanmamalı.
let mut tbl_wrapper = make_element("tbl", 0.0, 200.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 0.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 0.0, 20.0, "static_text");
let rows: Vec<ElementLayout> = (0..5)
.flat_map(|i| {
let y = 20.0 + (i as f64) * 30.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 30.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 30.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![tbl_wrapper, tbl_header, tbl_hdr_0];
body_elements.extend(rows);
let mut no_repeat = HashSet::new();
no_repeat.insert("tbl".to_string());
let input = PageSplitInput {
body_elements,
page_height_mm: 120.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: no_repeat,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "table should split across pages");
// Sayfa 2'de tekrarlanan header OLMAMALI
let page2_has_repeated_header = pages[1]
.elements
.iter()
.any(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header");
assert!(
!page2_has_repeated_header,
"no_repeat_header_tables should suppress header repetition on page 2"
);
}
#[test]
fn test_empty_body_produces_single_page() {
let input = PageSplitInput {
body_elements: vec![],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "empty body should produce 1 page");
}
#[test]
fn test_content_height_zero_returns_single_page() {
// Header + footer sayfayı dolduruyor → content_height <= 0
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
],
page_height_mm: 100.0,
header_height_mm: 60.0,
footer_height_mm: 50.0,
header_elements: vec![make_element("hdr", 0.0, 60.0, "text")],
footer_elements: vec![make_element("ftr", 0.0, 50.0, "text")],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
no_repeat_header_tables: HashSet::new(),
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1, "zero content height should produce 1 page");
}
#[test]
fn test_repeated_header_no_gap_with_rows() {
// Tekrarlanan header ile ilk satır arasında boşluk olmamalı.

View File

@@ -1126,7 +1126,7 @@ fn render_chart(
);
}
if !slice.cat_label_text.is_empty() {
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
chart_line_seg(
surface,
slice.leader_start_x,
@@ -1165,7 +1165,7 @@ fn render_chart(
}
// Legend render
if cl.legend_show && data.series.len() > 1 {
if cl.legend_show {
let legend = compute_legend(data, &cl, base_x_mm, base_y_mm, w_mm, h_mm);
for item in &legend.items {
let color = parse_color(color_at(&cl.palette, item.color_idx));
@@ -1599,8 +1599,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
@@ -1625,6 +1627,7 @@ mod tests {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1643,6 +1646,7 @@ mod tests {
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1659,6 +1663,7 @@ mod tests {
}),
TemplateElement::Text(TextElement {
id: "firma".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1700,6 +1705,300 @@ mod tests {
println!("Full pipeline PDF: {}", out_path.display());
}
// --- parse_color tests ---
#[test]
fn test_parse_color_6_digit_hex() {
let c = parse_color("#FF8800");
assert_eq!(c, rgb::Color::new(255, 136, 0));
}
#[test]
fn test_parse_color_3_digit_hex() {
let c = parse_color("#F80");
assert_eq!(c, rgb::Color::new(255, 136, 0)); // F*17=255, 8*17=136, 0*17=0
}
#[test]
fn test_parse_color_without_hash() {
let c = parse_color("00FF00");
assert_eq!(c, rgb::Color::new(0, 255, 0));
}
#[test]
fn test_parse_color_black() {
let c = parse_color("#000000");
assert_eq!(c, rgb::Color::new(0, 0, 0));
}
#[test]
fn test_parse_color_white() {
let c = parse_color("#FFFFFF");
assert_eq!(c, rgb::Color::new(255, 255, 255));
}
#[test]
fn test_parse_color_invalid_length() {
// Invalid length → defaults to (0,0,0)
let c = parse_color("#ABCD");
assert_eq!(c, rgb::Color::new(0, 0, 0));
}
#[test]
fn test_parse_color_empty() {
let c = parse_color("");
assert_eq!(c, rgb::Color::new(0, 0, 0));
}
// --- build_rect_path tests ---
#[test]
fn test_build_rect_path_no_radius() {
let path = build_rect_path(10.0, 20.0, 100.0, 50.0, 0.0);
assert!(path.is_some(), "should produce valid rect path with no radius");
}
#[test]
fn test_build_rect_path_with_radius() {
let path = build_rect_path(0.0, 0.0, 100.0, 50.0, 5.0);
assert!(path.is_some(), "should produce valid rounded rect path");
}
#[test]
fn test_build_rect_path_radius_clamped() {
// Radius larger than half the smaller dimension → should be clamped
let path = build_rect_path(0.0, 0.0, 20.0, 10.0, 100.0);
assert!(path.is_some(), "should clamp radius and produce valid path");
}
// --- build_ellipse_path tests ---
#[test]
fn test_build_ellipse_path() {
let path = build_ellipse_path(10.0, 20.0, 60.0, 40.0);
assert!(path.is_some(), "should produce valid ellipse path");
}
#[test]
fn test_build_ellipse_path_circle() {
// Equal width and height → circle
let path = build_ellipse_path(0.0, 0.0, 50.0, 50.0);
assert!(path.is_some(), "should produce valid circle path");
}
// --- mm/pt conversion tests ---
#[test]
fn test_mm_to_pt_conversion() {
// 25.4mm = 72pt (1 inch)
let result = mm(25.4);
assert!((result - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", result);
}
#[test]
fn test_mm_zero() {
assert_eq!(mm(0.0), 0.0);
}
#[test]
fn test_pt_conversion() {
let result = pt(25.4);
assert!((result - 72.0).abs() < 0.01);
}
// --- render_pdf integration with various element types ---
#[test]
fn test_render_pdf_with_line_element() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![ElementLayout {
id: "line1".to_string(),
x_mm: 15.0,
y_mm: 50.0,
width_mm: 180.0,
height_mm: 0.5,
element_type: "line".to_string(),
content: Some(ResolvedContent::Line),
style: ResolvedStyle {
stroke_color: Some("#FF0000".to_string()),
stroke_width: Some(1.0),
..Default::default()
},
children: vec![],
}],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("should render line element");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_render_pdf_with_container_background() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![ElementLayout {
id: "box".to_string(),
x_mm: 20.0,
y_mm: 20.0,
width_mm: 170.0,
height_mm: 100.0,
element_type: "container".to_string(),
content: None,
style: ResolvedStyle {
background_color: Some("#E0E0E0".to_string()),
border_color: Some("#333333".to_string()),
border_width: Some(0.5),
border_radius: Some(3.0),
..Default::default()
},
children: vec![],
}],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("should render container bg");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_render_pdf_with_shape_element() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![ElementLayout {
id: "shape1".to_string(),
x_mm: 50.0,
y_mm: 50.0,
width_mm: 40.0,
height_mm: 40.0,
element_type: "shape".to_string(),
content: Some(ResolvedContent::Shape {
shape_type: "ellipse".to_string(),
}),
style: ResolvedStyle {
background_color: Some("#3366FF".to_string()),
border_color: Some("#000000".to_string()),
border_width: Some(1.0),
..Default::default()
},
children: vec![],
}],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("should render shape element");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_render_pdf_with_checkbox() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![
ElementLayout {
id: "cb_checked".to_string(),
x_mm: 15.0,
y_mm: 15.0,
width_mm: 5.0,
height_mm: 5.0,
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked: true }),
style: ResolvedStyle::default(),
children: vec![],
},
ElementLayout {
id: "cb_unchecked".to_string(),
x_mm: 15.0,
y_mm: 25.0,
width_mm: 5.0,
height_mm: 5.0,
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked: false }),
style: ResolvedStyle::default(),
children: vec![],
},
],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("should render checkbox elements");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_render_pdf_empty_page() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("empty page should still render");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn test_render_pdf_multi_page() {
let layout = LayoutResult {
pages: vec![
PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![ElementLayout {
id: "p1".to_string(),
x_mm: 15.0,
y_mm: 15.0,
width_mm: 180.0,
height_mm: 10.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text { value: "Page 1".to_string() }),
style: ResolvedStyle { font_size: Some(12.0), ..Default::default() },
children: vec![],
}],
},
PageLayout {
page_index: 1,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![ElementLayout {
id: "p2".to_string(),
x_mm: 15.0,
y_mm: 15.0,
width_mm: 180.0,
height_mm: 10.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text { value: "Page 2".to_string() }),
style: ResolvedStyle { font_size: Some(12.0), ..Default::default() },
children: vec![],
}],
},
],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("multi-page should render");
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 200, "multi-page PDF should have reasonable size");
}
// --- detect_image_format tests ---
#[test]
fn test_detect_png() {
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];

View File

@@ -328,6 +328,7 @@ mod tests {
fn test_container_to_style_direction() {
let el = ContainerElement {
id: "test".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "row".to_string(),
@@ -354,6 +355,7 @@ mod tests {
fn test_container_to_style_absolute() {
let el = ContainerElement {
id: "test".to_string(),
condition: None,
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
size: SizeConstraint::default(),
direction: "column".to_string(),

View File

@@ -233,6 +233,7 @@ pub fn expand_table(
.map(|(i, col)| {
let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_hdr_{}", table.id, i),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -254,6 +255,7 @@ pub fn expand_table(
});
TemplateElement::Container(ContainerElement {
id: format!("{}_hdr_{}_wrap", table.id, i),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: effective_widths[i].clone(),
@@ -282,6 +284,7 @@ pub fn expand_table(
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_header", table.id),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -313,6 +316,7 @@ pub fn expand_table(
if table.style.border_color.is_some() {
children.push(TemplateElement::Line(LineElement {
id: format!("{}_header_line", table.id),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -340,6 +344,7 @@ pub fn expand_table(
let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -361,6 +366,7 @@ pub fn expand_table(
});
TemplateElement::Container(ContainerElement {
id: format!("{}_r{}c{}_wrap", table.id, row_idx, col_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: effective_widths[col_idx].clone(),
@@ -396,6 +402,7 @@ pub fn expand_table(
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_row_{}", table.id, row_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -427,6 +434,7 @@ pub fn expand_table(
// Wrapper container (column direction, tüm tablo)
ContainerElement {
id: table.id.clone(),
condition: None,
position: table.position.clone(),
size: table.size.clone(),
direction: "column".to_string(),
@@ -471,6 +479,7 @@ mod tests {
RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -497,6 +506,7 @@ mod tests {
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
charts: HashMap::new(),
hidden_elements: std::collections::HashSet::new(),
}
}
@@ -729,6 +739,7 @@ mod tests {
let table = RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },

View File

@@ -245,6 +245,10 @@ fn build_container(
let mut children_ids = Vec::new();
for child in &el.children {
// Koşullu render: hidden_elements'te olan elemanları atla
if resolved.hidden_elements.contains(child.id()) {
continue;
}
let child_node = build_element(
child,
taffy,
@@ -896,8 +900,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
@@ -922,6 +928,7 @@ mod tests {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -940,6 +947,7 @@ mod tests {
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -956,6 +964,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "body".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1040,8 +1049,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
@@ -1065,6 +1076,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::Container(ContainerElement {
id: "row".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1089,6 +1101,7 @@ mod tests {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "left".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1106,6 +1119,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "right".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -1168,8 +1182,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
@@ -1193,6 +1209,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "abs_text".to_string(),
condition: None,
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
size: SizeConstraint {
width: SizeValue::Fixed { value: 100.0 },
@@ -1257,8 +1274,10 @@ mod tests {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
direction: "column".to_string(),
@@ -1277,6 +1296,7 @@ mod tests {
// Header row
TemplateElement::Container(ContainerElement {
id: "c_header".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
direction: "row".to_string(),
@@ -1290,6 +1310,7 @@ mod tests {
// Sol: firma bilgileri
TemplateElement::Container(ContainerElement {
id: "c_firma".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
direction: "column".to_string(),
@@ -1302,6 +1323,7 @@ mod tests {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_unvan".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1313,6 +1335,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_adres".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1324,6 +1347,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_il".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1334,6 +1358,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_tel".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1344,6 +1369,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vd".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1354,6 +1380,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vn".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1367,6 +1394,7 @@ mod tests {
// Sağ: fatura başlığı
TemplateElement::Container(ContainerElement {
id: "c_fatura_baslik".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
direction: "column".to_string(),
@@ -1379,6 +1407,7 @@ mod tests {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_baslik".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1390,6 +1419,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_no".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1400,6 +1430,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_tarih".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {
@@ -1410,6 +1441,7 @@ mod tests {
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_vade".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
style: TextStyle {

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");
}

View File

@@ -18,8 +18,10 @@ fn simple_template() -> Template {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -36,6 +38,7 @@ fn simple_template() -> Template {
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -161,8 +164,10 @@ fn test_compute_layout_with_data_binding() {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -179,6 +184,7 @@ fn test_compute_layout_with_data_binding() {
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "bound_text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -227,8 +233,10 @@ fn test_compute_layout_multiple_children_ordering() {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -246,6 +254,7 @@ fn test_compute_layout_multiple_children_ordering() {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "first".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -260,6 +269,7 @@ fn test_compute_layout_multiple_children_ordering() {
}),
TemplateElement::StaticText(StaticTextElement {
id: "second".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },

View File

@@ -21,8 +21,10 @@ fn simple_template() -> Template {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -39,6 +41,7 @@ fn simple_template() -> Template {
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -89,8 +92,10 @@ fn test_render_pdf_with_multiple_elements() {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -108,6 +113,7 @@ fn test_render_pdf_with_multiple_elements() {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "header".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -123,6 +129,7 @@ fn test_render_pdf_with_multiple_elements() {
}),
TemplateElement::Line(LineElement {
id: "sep".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -136,6 +143,7 @@ fn test_render_pdf_with_multiple_elements() {
}),
TemplateElement::StaticText(StaticTextElement {
id: "body".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -182,8 +190,10 @@ fn test_render_pdf_with_container_styles() {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -205,6 +215,7 @@ fn test_render_pdf_with_container_styles() {
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -244,8 +255,10 @@ fn test_page_break_produces_multiple_pages() {
header: None,
footer: None,
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
@@ -263,6 +276,7 @@ fn test_page_break_produces_multiple_pages() {
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "t1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
@@ -277,9 +291,11 @@ fn test_page_break_produces_multiple_pages() {
}),
TemplateElement::PageBreak(PageBreakElement {
id: "pb1".to_string(),
condition: None,
}),
TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },

File diff suppressed because one or more lines are too long