ftui-render 0.3.1

Render kernel: cells, buffers, diffs, and ANSI presentation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Integration tests for Allocation Budget + Pooling Verification (bd-1rz0.30)
//!
//! Verifies that AdaptiveDoubleBuffer stays within allocation budgets during
//! resize operations and that the leak detector correctly tracks allocation patterns.
//!
//! # Performance Budgets
//!
//! - Resize storm: > 80% reallocation avoidance ratio
//! - Memory overhead: < 30% extra capacity on average
//! - Allocation per reflow (no resize): 0 allocations
//! - Allocation per resize (within capacity): ~1 clear operation
//!
//! # Failure Modes
//!
//! | Condition | Behavior | Detection |
//! |-----------|----------|-----------|
//! | Reallocation storm | Perf degradation | avoidance_ratio < 0.5 |
//! | Memory bloat | OOM risk | efficiency < 0.5 |
//! | Allocation leak | Memory growth | LeakDetector alert |

use ftui_render::alloc_budget::{AllocLeakDetector, LeakDetectorConfig};
use ftui_render::buffer::AdaptiveDoubleBuffer;

// ============================================================================
// Pooling Effectiveness: Reallocation Avoidance
// ============================================================================

/// Test that resize storms achieve high reallocation avoidance.
///
/// Simulates rapid terminal resizing (common during window drag) and
/// verifies that the adaptive buffer reuses capacity effectively.
#[test]
fn resize_storm_achieves_high_avoidance() {
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    // Simulate 100 resize events: oscillating around the initial size
    for i in 0..100 {
        let delta_w = ((i % 20) as i16 - 10).unsigned_abs();
        let delta_h = ((i % 10) as i16 - 5).unsigned_abs();
        adb.resize(80 + delta_w, 24 + delta_h);
    }

    let stats = adb.stats();
    let ratio = stats.avoidance_ratio();

    // Budget: > 80% avoidance ratio
    assert!(
        ratio >= 0.80,
        "Resize storm should achieve >= 80% avoidance, got {:.2}%",
        ratio * 100.0
    );
}

/// Test that gradual growth uses over-allocation effectively.
#[test]
fn gradual_growth_reuses_capacity() {
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    // Grow by 1 cell at a time (common during incremental resize)
    for i in 1..=15 {
        adb.resize(80 + i, 24);
    }

    let stats = adb.stats();

    // All 15 resizes should reuse capacity (initial capacity is ~100x30)
    assert!(
        stats.resize_avoided >= 10,
        "Expected >= 10 avoided resizes, got {}",
        stats.resize_avoided
    );
}

/// Test that shrink + grow cycle doesn't thrash allocations.
#[test]
fn shrink_grow_cycle_no_thrash() {
    let mut adb = AdaptiveDoubleBuffer::new(100, 40);

    // Shrink to 80x30, then grow back to 100x40
    // This should stay within the original capacity (125x50)
    for _ in 0..10 {
        adb.resize(80, 30); // Shrink (above 50% threshold)
        adb.resize(100, 40); // Grow back
    }

    let stats = adb.stats();
    let ratio = stats.avoidance_ratio();

    // All 20 resizes should be absorbed by capacity
    assert!(
        ratio >= 0.9,
        "Shrink-grow cycle should achieve >= 90% avoidance, got {:.2}%",
        ratio * 100.0
    );
}

// ============================================================================
// Memory Efficiency: Budget Compliance
// ============================================================================

/// Test that memory overhead stays within budget.
#[test]
fn memory_overhead_within_budget() {
    let test_sizes = [(80, 24), (120, 40), (200, 60), (1000, 500)];

    for (w, h) in test_sizes {
        let adb = AdaptiveDoubleBuffer::new(w, h);
        let efficiency = adb.memory_efficiency();

        // Budget: efficiency > 50% (overhead < 100%)
        // Typical: efficiency ~64% for 1.25x growth factor
        assert!(
            efficiency > 0.50,
            "Memory efficiency for {}x{} should be > 50%, got {:.1}%",
            w,
            h,
            efficiency * 100.0
        );
    }
}

/// Test that very small buffers don't have excessive overhead.
#[test]
fn small_buffer_reasonable_overhead() {
    let adb = AdaptiveDoubleBuffer::new(10, 5);

    // 10x5 = 50 cells, capacity should be ~12x6 = 72 cells
    let efficiency = adb.memory_efficiency();

    // Small buffers have higher relative overhead, but still reasonable
    assert!(
        efficiency > 0.40,
        "Small buffer efficiency should be > 40%, got {:.1}%",
        efficiency * 100.0
    );
}

/// Test that large buffers respect the max overage cap.
#[test]
fn large_buffer_capped_overage() {
    let adb = AdaptiveDoubleBuffer::new(2000, 1000);

    // 2000 * 0.25 = 500, but capped at 200 → 2200
    // 1000 * 0.25 = 250, capped at 200 → 1200
    assert_eq!(adb.capacity_width(), 2200);
    assert_eq!(adb.capacity_height(), 1200);

    // Efficiency should be high for large buffers due to capping
    let efficiency = adb.memory_efficiency();
    assert!(
        efficiency > 0.75,
        "Large buffer efficiency should be > 75%, got {:.1}%",
        efficiency * 100.0
    );
}

// ============================================================================
// Leak Detection Integration
// ============================================================================

/// Test that stable buffer operations don't trigger leak alerts.
#[test]
fn stable_operations_no_leak_alert() {
    let config = LeakDetectorConfig {
        warmup_frames: 20,
        ..LeakDetectorConfig::default()
    };
    let mut detector = AllocLeakDetector::new(config);
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);
    let mut prev_realloc = 0u64;

    // Simulate 100 frames with stable resize pattern
    for frame in 0..100 {
        // Oscillate within capacity
        let w = 80 + (frame % 5) as u16;
        let h = 24 + (frame % 3) as u16;
        adb.resize(w, h);

        // Track PER-FRAME allocation (not cumulative)
        // Did this frame require reallocation?
        let current_realloc = adb.stats().resize_reallocated;
        let did_realloc = current_realloc > prev_realloc;
        prev_realloc = current_realloc;

        // Allocation proxy: stable ~100 with small noise, spike if realloc
        let alloc_proxy = if did_realloc {
            200.0 // Larger value for reallocation frame (one-time spike)
        } else {
            100.0 + (frame % 10) as f64 // Stable baseline with noise
        };

        let alert = detector.observe(alloc_proxy);
        assert!(
            !alert.triggered,
            "Stable resize pattern should not trigger leak alert at frame {}",
            frame
        );
    }
}

/// Test that the detector catches allocation regression.
#[test]
fn detector_catches_allocation_regression() {
    let config = LeakDetectorConfig {
        warmup_frames: 20,
        lambda: 0.3,
        ..LeakDetectorConfig::default()
    };
    let mut detector = AllocLeakDetector::new(config);

    // Warmup: stable allocation count
    for _ in 0..30 {
        detector.observe(100.0);
    }

    // Inject regression: allocations jump by 50%
    let mut detected = false;
    for i in 0..100 {
        let alert = detector.observe(150.0);
        if alert.triggered {
            detected = true;
            assert!(
                i < 50,
                "Should detect regression within 50 frames, took {}",
                i
            );
            break;
        }
    }

    assert!(detected, "Should detect allocation regression");
}

// ============================================================================
// Combined Budget Verification
// ============================================================================

/// Comprehensive test simulating realistic reflow scenario.
///
/// Scenario: Terminal window is being resized during a resize storm while
/// content is being rendered. Verifies:
/// 1. Allocation budget compliance
/// 2. No memory leaks
/// 3. Pooling effectiveness
#[test]
fn e2e_reflow_budget_verification() {
    let config = LeakDetectorConfig {
        warmup_frames: 20,
        ..LeakDetectorConfig::default()
    };
    let mut detector = AllocLeakDetector::new(config);
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    // Track metrics
    let mut total_resizes = 0u64;
    let mut realloc_events = 0u64;
    let mut prev_realloc = 0u64;

    // Simulate 200 frames of resize activity
    for frame in 0..200 {
        // Simulate realistic resize pattern:
        // - Slow growth
        // - Occasional shrink
        // - Some oscillation
        let phase = frame / 50;
        let (w, h) = match phase {
            0 => (80 + (frame % 20) as u16, 24 + (frame % 10) as u16 / 2),
            1 => (100 - (frame % 15) as u16, 29 - (frame % 5) as u16),
            2 => (85 + (frame % 25) as u16, 24 + (frame % 15) as u16),
            _ => (80, 24),
        };

        let old_realloc = adb.stats().resize_reallocated;
        if adb.resize(w, h) {
            total_resizes += 1;
            if adb.stats().resize_reallocated > old_realloc {
                realloc_events += 1;
            }
        }

        // Track PER-FRAME allocation (not cumulative)
        let current_realloc = adb.stats().resize_reallocated;
        let did_realloc = current_realloc > prev_realloc;
        prev_realloc = current_realloc;

        // Allocation proxy: stable ~100 with occasional spike for realloc
        let alloc_proxy = if did_realloc {
            200.0 // One-time spike for reallocation
        } else {
            100.0 + (frame % 15) as f64 // Stable baseline with noise
        };

        // Feed to detector (for observability, not assertion)
        detector.observe(alloc_proxy);
    }

    // Verify final metrics
    let stats = adb.stats();
    let avoidance = stats.avoidance_ratio();
    let efficiency = adb.memory_efficiency();

    // Budget assertions
    // Note: efficiency can be lower in E2E scenarios with large resize ranges
    // because capacity tracks peak size. The key metric is avoidance ratio.
    assert!(
        avoidance >= 0.70,
        "E2E avoidance ratio should be >= 70%, got {:.1}%",
        avoidance * 100.0
    );
    assert!(
        efficiency >= 0.35,
        "E2E memory efficiency should be >= 35%, got {:.1}%",
        efficiency * 100.0
    );

    // JSONL summary for logging
    let summary = format!(
        r#"{{"test":"e2e_reflow_budget","total_resizes":{},"realloc_events":{},"avoidance_ratio":{:.4},"memory_efficiency":{:.4},"final_e_value":{:.4}}}"#,
        total_resizes,
        realloc_events,
        avoidance,
        efficiency,
        detector.e_value(),
    );

    // Verify summary is valid JSONL
    assert!(summary.starts_with('{') && summary.ends_with('}'));
    assert!(summary.contains("\"avoidance_ratio\":"));
}

// ============================================================================
// Property Tests
// ============================================================================

/// Property: resize never panics for valid dimensions.
#[test]
fn property_resize_never_panics() {
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    let test_sizes = [
        (1, 1),
        (10, 5),
        (80, 24),
        (120, 40),
        (200, 60),
        (500, 200),
        (1000, 500),
        (u16::MAX / 2, u16::MAX / 2),
    ];

    for (w, h) in test_sizes {
        adb.resize(w, h);
        assert_eq!(adb.width(), w);
        assert_eq!(adb.height(), h);
    }
}

/// Property: capacity always >= logical dimensions.
#[test]
fn property_capacity_invariant() {
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    // Random-ish resize sequence
    let sizes = [
        (100, 50),
        (50, 25),
        (150, 70),
        (30, 15),
        (200, 100),
        (80, 24),
        (120, 40),
        (60, 30),
        (180, 90),
        (40, 20),
    ];

    for (w, h) in sizes {
        adb.resize(w, h);
        assert!(adb.capacity_width() >= adb.width());
        assert!(adb.capacity_height() >= adb.height());
    }
}

/// Property: stats are consistent (avoided + reallocated = total resizes).
#[test]
fn property_stats_consistent() {
    let mut adb = AdaptiveDoubleBuffer::new(80, 24);

    let mut total_resizes = 0u64;
    let sizes = [
        (90, 28),
        (100, 35),
        (150, 50),
        (50, 20),
        (80, 24),
        (85, 26),
        (200, 80),
        (60, 25),
    ];

    for (w, h) in sizes {
        if adb.resize(w, h) {
            total_resizes += 1;
        }
    }

    let stats = adb.stats();
    let total_from_stats = stats.resize_avoided + stats.resize_reallocated;

    assert_eq!(
        total_from_stats, total_resizes,
        "Stats should be consistent: avoided({}) + reallocated({}) = {}",
        stats.resize_avoided, stats.resize_reallocated, total_resizes
    );
}