1#![forbid(unsafe_code)]
2
3#[cfg(feature = "input-parser")]
15pub mod input_parser;
16pub mod pane_keyboard;
17pub mod pane_pointer_capture;
18pub mod runtime_options;
19pub mod sdk_event_model;
20pub mod session_record;
21pub mod step_program;
22
23use core::time::Duration;
24use std::collections::VecDeque;
25
26use ftui_backend::{Backend, BackendClock, BackendEventSource, BackendFeatures, BackendPresenter};
27use ftui_core::event::Event;
28use ftui_core::terminal_capabilities::TerminalCapabilities;
29use ftui_render::buffer::Buffer;
30use ftui_render::cell::{Cell, CellAttrs, CellContent};
31use ftui_render::diff::BufferDiff;
32
33const GRAPHEME_FALLBACK_CODEPOINT: u32 = '□' as u32;
34const ATTR_STYLE_MASK: u32 = 0xFF;
35const ATTR_LINK_ID_MAX: u32 = CellAttrs::LINK_ID_MAX;
36const WEB_PATCH_CELL_BYTES: u64 = 16;
37const PATCH_HASH_ALGO: &str = "fnv1a64";
38const FNV64_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
39const FNV64_PRIME: u64 = 0x100000001b3;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum WebBackendError {
44 Unsupported(&'static str),
46}
47
48impl core::fmt::Display for WebBackendError {
49 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
50 match self {
51 Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
52 }
53 }
54}
55
56impl std::error::Error for WebBackendError {}
57
58#[derive(Debug, Default, Clone)]
60pub struct DeterministicClock {
61 now: Duration,
62}
63
64impl DeterministicClock {
65 #[must_use]
67 pub const fn new() -> Self {
68 Self {
69 now: Duration::ZERO,
70 }
71 }
72
73 pub fn set(&mut self, now: Duration) {
75 self.now = now;
76 }
77
78 pub fn advance(&mut self, dt: Duration) {
80 self.now = self.now.saturating_add(dt);
81 }
82}
83
84impl BackendClock for DeterministicClock {
85 fn now_mono(&self) -> Duration {
86 self.now
87 }
88}
89
90#[derive(Debug, Clone)]
94pub struct WebEventSource {
95 size: (u16, u16),
96 features: BackendFeatures,
97 queue: VecDeque<Event>,
98}
99
100impl WebEventSource {
101 #[must_use]
103 pub fn new(width: u16, height: u16) -> Self {
104 Self {
105 size: (width, height),
106 features: BackendFeatures::default(),
107 queue: VecDeque::new(),
108 }
109 }
110
111 pub fn set_size(&mut self, width: u16, height: u16) {
113 self.size = (width, height);
114 }
115
116 #[must_use]
118 pub const fn features(&self) -> BackendFeatures {
119 self.features
120 }
121
122 pub fn push_event(&mut self, event: Event) {
124 self.queue.push_back(event);
125 }
126
127 pub fn drain_events(&mut self) -> impl Iterator<Item = Event> + '_ {
129 self.queue.drain(..)
130 }
131}
132
133impl BackendEventSource for WebEventSource {
134 type Error = WebBackendError;
135
136 fn size(&self) -> Result<(u16, u16), Self::Error> {
137 Ok(self.size)
138 }
139
140 fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error> {
141 self.features = features;
142 Ok(())
143 }
144
145 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
146 let _ = timeout;
148 Ok(!self.queue.is_empty())
149 }
150
151 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
152 Ok(self.queue.pop_front())
153 }
154}
155
156#[derive(Debug, Default, Clone)]
158pub struct WebOutputs {
159 pub logs: Vec<String>,
161 pub last_buffer: Option<Buffer>,
163 pub last_patches: Vec<WebPatchRun>,
165 pub last_patch_stats: Option<WebPatchStats>,
167 pub last_patch_hash: Option<String>,
169 pub last_full_repaint_hint: bool,
171 hash_computed: bool,
173}
174
175impl WebOutputs {
176 pub fn compute_patch_hash(&mut self) -> Option<&str> {
180 if !self.hash_computed && !self.last_patches.is_empty() {
181 self.last_patch_hash = Some(patch_batch_hash(&self.last_patches));
182 self.hash_computed = true;
183 }
184 self.last_patch_hash.as_deref()
185 }
186}
187
188impl WebOutputs {
189 #[must_use]
195 pub fn flatten_patches_u32(&self) -> WebFlatPatchBatch {
196 let total_cells = self
197 .last_patches
198 .iter()
199 .map(|patch| patch.cells.len())
200 .sum::<usize>();
201 let mut cells = Vec::with_capacity(total_cells.saturating_mul(4));
202 let mut spans = Vec::with_capacity(self.last_patches.len().saturating_mul(2));
203
204 for patch in &self.last_patches {
205 spans.push(patch.offset);
206 let len = patch.cells.len().min(u32::MAX as usize) as u32;
207 spans.push(len);
208
209 for cell in &patch.cells {
210 cells.push(cell.bg);
211 cells.push(cell.fg);
212 cells.push(cell.glyph);
213 cells.push(cell.attrs);
214 }
215 }
216
217 WebFlatPatchBatch { cells, spans }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct WebPatchCell {
225 pub bg: u32,
226 pub fg: u32,
227 pub glyph: u32,
228 pub attrs: u32,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct WebPatchRun {
234 pub offset: u32,
235 pub cells: Vec<WebPatchCell>,
236}
237
238#[derive(Debug, Default, Clone, PartialEq, Eq)]
240pub struct WebFlatPatchBatch {
241 pub cells: Vec<u32>,
243 pub spans: Vec<u32>,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub struct WebPatchStats {
250 pub dirty_cells: u32,
251 pub patch_count: u32,
252 pub bytes_uploaded: u64,
253}
254
255#[derive(Debug, Clone)]
257pub struct WebPresenter {
258 caps: TerminalCapabilities,
259 outputs: WebOutputs,
260}
261
262impl WebPresenter {
263 #[must_use]
265 pub fn new() -> Self {
266 Self {
267 caps: TerminalCapabilities::modern(),
268 outputs: WebOutputs::default(),
269 }
270 }
271
272 #[must_use]
274 pub const fn outputs(&self) -> &WebOutputs {
275 &self.outputs
276 }
277
278 pub fn outputs_mut(&mut self) -> &mut WebOutputs {
280 &mut self.outputs
281 }
282
283 pub fn take_outputs(&mut self) -> WebOutputs {
285 std::mem::take(&mut self.outputs)
286 }
287
288 pub fn flatten_patches_into(&self, cells: &mut Vec<u32>, spans: &mut Vec<u32>) {
293 cells.clear();
294 spans.clear();
295
296 let total_cells = self
297 .outputs
298 .last_patches
299 .iter()
300 .map(|p| p.cells.len())
301 .sum::<usize>();
302 cells.reserve(total_cells.saturating_mul(4));
303 spans.reserve(self.outputs.last_patches.len().saturating_mul(2));
304
305 for patch in &self.outputs.last_patches {
306 spans.push(patch.offset);
307 let len = patch.cells.len().min(u32::MAX as usize) as u32;
308 spans.push(len);
309
310 for cell in &patch.cells {
311 cells.push(cell.bg);
312 cells.push(cell.fg);
313 cells.push(cell.glyph);
314 cells.push(cell.attrs);
315 }
316 }
317 }
318
319 pub fn present_ui_owned(
325 &mut self,
326 buf: Buffer,
327 diff: Option<&BufferDiff>,
328 full_repaint_hint: bool,
329 ) {
330 let patches = build_patch_runs(&buf, diff, full_repaint_hint);
331 let stats = patch_batch_stats(&patches);
332 self.outputs.last_buffer = Some(buf);
333 self.outputs.last_patches = patches;
334 self.outputs.last_patch_stats = Some(stats);
335 self.outputs.last_patch_hash = None;
336 self.outputs.hash_computed = false;
337 self.outputs.last_full_repaint_hint = full_repaint_hint;
338 }
339}
340
341impl Default for WebPresenter {
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347impl BackendPresenter for WebPresenter {
348 type Error = WebBackendError;
349
350 fn capabilities(&self) -> &TerminalCapabilities {
351 &self.caps
352 }
353
354 fn write_log(&mut self, text: &str) -> Result<(), Self::Error> {
355 self.outputs.logs.push(text.to_owned());
356 Ok(())
357 }
358
359 fn present_ui(
360 &mut self,
361 buf: &Buffer,
362 diff: Option<&BufferDiff>,
363 full_repaint_hint: bool,
364 ) -> Result<(), Self::Error> {
365 let patches = build_patch_runs(buf, diff, full_repaint_hint);
366 let stats = patch_batch_stats(&patches);
367 self.outputs.last_buffer = Some(buf.clone());
368 self.outputs.last_patches = patches;
369 self.outputs.last_patch_stats = Some(stats);
370 self.outputs.last_patch_hash = None;
371 self.outputs.hash_computed = false;
372 self.outputs.last_full_repaint_hint = full_repaint_hint;
373 Ok(())
374 }
375}
376
377#[must_use]
378fn fnv1a64_extend(mut hash: u64, bytes: &[u8]) -> u64 {
379 for &byte in bytes {
380 hash ^= u64::from(byte);
381 hash = hash.wrapping_mul(FNV64_PRIME);
382 }
383 hash
384}
385
386#[must_use]
387fn cell_to_patch(cell: &Cell) -> WebPatchCell {
388 let glyph = match cell.content {
389 CellContent::EMPTY | CellContent::CONTINUATION => 0,
390 other if other.is_grapheme() => GRAPHEME_FALLBACK_CODEPOINT,
391 other => other.as_char().map_or(0, |c| c as u32),
392 };
393 let style_bits = u32::from(cell.attrs.flags().bits()) & ATTR_STYLE_MASK;
394 let link_id = cell.attrs.link_id().min(ATTR_LINK_ID_MAX);
395 WebPatchCell {
396 bg: cell.bg.0,
397 fg: cell.fg.0,
398 glyph,
399 attrs: style_bits | (link_id << 8),
400 }
401}
402
403#[must_use]
404fn full_buffer_patch(buffer: &Buffer) -> WebPatchRun {
405 let cols = buffer.width();
406 let rows = buffer.height();
407 let total = usize::from(cols) * usize::from(rows);
408 let mut cells = Vec::with_capacity(total);
409 for y in 0..rows {
410 for x in 0..cols {
411 cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
412 }
413 }
414 WebPatchRun { offset: 0, cells }
415}
416
417#[must_use]
418fn diff_to_patches(buffer: &Buffer, diff: &BufferDiff) -> Vec<WebPatchRun> {
419 if diff.is_empty() {
420 return Vec::new();
421 }
422 let width = buffer.width();
423 let height = buffer.height();
424 let cols = u32::from(width);
425 let est_patches = diff.len().div_ceil(8).max(1);
427 let mut patches = Vec::with_capacity(est_patches);
428 let mut span_start: u32 = 0;
429 let mut span_cells: Vec<WebPatchCell> = Vec::with_capacity(diff.len());
430 let mut prev_offset: u32 = 0;
431 let mut has_span = false;
432
433 for &(x, y) in diff.changes() {
434 if x >= width || y >= height {
436 return vec![full_buffer_patch(buffer)];
437 }
438 let offset = u32::from(y) * cols + u32::from(x);
439 if !has_span {
440 span_start = offset;
441 prev_offset = offset;
442 has_span = true;
443 span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
444 continue;
445 }
446 if offset == prev_offset {
447 continue;
448 }
449 if offset == prev_offset + 1 {
450 span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
451 } else {
452 patches.push(WebPatchRun {
453 offset: span_start,
454 cells: std::mem::take(&mut span_cells),
455 });
456 span_start = offset;
457 span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
458 }
459 prev_offset = offset;
460 }
461 if !span_cells.is_empty() {
462 patches.push(WebPatchRun {
463 offset: span_start,
464 cells: span_cells,
465 });
466 }
467 patches
468}
469
470#[must_use]
471fn build_patch_runs(
472 buffer: &Buffer,
473 diff: Option<&BufferDiff>,
474 full_repaint_hint: bool,
475) -> Vec<WebPatchRun> {
476 if full_repaint_hint {
477 return vec![full_buffer_patch(buffer)];
478 }
479 match diff {
480 Some(dirty) => diff_to_patches(buffer, dirty),
481 None => vec![full_buffer_patch(buffer)],
482 }
483}
484
485#[must_use]
486fn patch_batch_stats(patches: &[WebPatchRun]) -> WebPatchStats {
487 let dirty_cells_u64 = patches
488 .iter()
489 .map(|patch| patch.cells.len() as u64)
490 .sum::<u64>();
491 let dirty_cells = dirty_cells_u64.min(u64::from(u32::MAX)) as u32;
492 let patch_count = patches.len().min(u32::MAX as usize) as u32;
493 let bytes_uploaded = dirty_cells_u64.saturating_mul(WEB_PATCH_CELL_BYTES);
494 WebPatchStats {
495 dirty_cells,
496 patch_count,
497 bytes_uploaded,
498 }
499}
500
501#[must_use]
502fn patch_batch_hash(patches: &[WebPatchRun]) -> String {
503 let mut hash = FNV64_OFFSET_BASIS;
504 let patch_count = u64::try_from(patches.len()).unwrap_or(u64::MAX);
505 hash = fnv1a64_extend(hash, &patch_count.to_le_bytes());
506
507 let mut cell_bytes = [0u8; 16];
510 for patch in patches {
511 let cell_count = u64::try_from(patch.cells.len()).unwrap_or(u64::MAX);
512 hash = fnv1a64_extend(hash, &patch.offset.to_le_bytes());
513 hash = fnv1a64_extend(hash, &cell_count.to_le_bytes());
514 for cell in &patch.cells {
515 cell_bytes[0..4].copy_from_slice(&cell.bg.to_le_bytes());
516 cell_bytes[4..8].copy_from_slice(&cell.fg.to_le_bytes());
517 cell_bytes[8..12].copy_from_slice(&cell.glyph.to_le_bytes());
518 cell_bytes[12..16].copy_from_slice(&cell.attrs.to_le_bytes());
519 hash = fnv1a64_extend(hash, &cell_bytes);
520 }
521 }
522
523 format!("{PATCH_HASH_ALGO}:{hash:016x}")
524}
525
526#[derive(Debug, Clone)]
533pub struct WebBackend {
534 clock: DeterministicClock,
535 events: WebEventSource,
536 presenter: WebPresenter,
537}
538
539impl WebBackend {
540 #[must_use]
542 pub fn new(width: u16, height: u16) -> Self {
543 Self {
544 clock: DeterministicClock::new(),
545 events: WebEventSource::new(width, height),
546 presenter: WebPresenter::new(),
547 }
548 }
549
550 pub fn clock_mut(&mut self) -> &mut DeterministicClock {
552 &mut self.clock
553 }
554
555 pub fn events_mut(&mut self) -> &mut WebEventSource {
557 &mut self.events
558 }
559
560 pub fn presenter_mut(&mut self) -> &mut WebPresenter {
562 &mut self.presenter
563 }
564}
565
566impl Backend for WebBackend {
567 type Error = WebBackendError;
568
569 type Clock = DeterministicClock;
570 type Events = WebEventSource;
571 type Presenter = WebPresenter;
572
573 fn clock(&self) -> &Self::Clock {
574 &self.clock
575 }
576
577 fn events(&mut self) -> &mut Self::Events {
578 &mut self.events
579 }
580
581 fn presenter(&mut self) -> &mut Self::Presenter {
582 &mut self.presenter
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use ftui_render::cell::Cell;
590
591 use pretty_assertions::assert_eq;
592
593 #[test]
594 fn deterministic_clock_advances_monotonically() {
595 let mut c = DeterministicClock::new();
596 assert_eq!(c.now_mono(), Duration::ZERO);
597
598 c.advance(Duration::from_millis(10));
599 assert_eq!(c.now_mono(), Duration::from_millis(10));
600
601 c.advance(Duration::from_millis(5));
602 assert_eq!(c.now_mono(), Duration::from_millis(15));
603
604 c.set(Duration::MAX);
606 c.advance(Duration::from_secs(1));
607 assert_eq!(c.now_mono(), Duration::MAX);
608 }
609
610 #[test]
611 fn web_event_source_fifo_queue() {
612 let mut ev = WebEventSource::new(80, 24);
613 assert_eq!(ev.size().unwrap(), (80, 24));
614 assert_eq!(ev.poll_event(Duration::from_millis(0)).unwrap(), false);
615
616 ev.push_event(Event::Tick);
617 ev.push_event(Event::Resize {
618 width: 100,
619 height: 40,
620 });
621
622 assert_eq!(ev.poll_event(Duration::from_millis(0)).unwrap(), true);
623 assert_eq!(ev.read_event().unwrap(), Some(Event::Tick));
624 assert_eq!(
625 ev.read_event().unwrap(),
626 Some(Event::Resize {
627 width: 100,
628 height: 40,
629 })
630 );
631 assert_eq!(ev.read_event().unwrap(), None);
632 }
633
634 #[test]
635 fn presenter_captures_logs_and_last_buffer() {
636 let mut p = WebPresenter::new();
637 p.write_log("hello").unwrap();
638 p.write_log("world").unwrap();
639
640 let buf = Buffer::new(2, 2);
641 p.present_ui(&buf, None, true).unwrap();
642
643 let mut outputs = p.take_outputs();
644 assert_eq!(outputs.logs, vec!["hello", "world"]);
645 assert_eq!(outputs.last_full_repaint_hint, true);
646 assert_eq!(outputs.last_buffer.as_ref().unwrap().width(), 2);
647 assert_eq!(outputs.last_patches.len(), 1);
648 let stats = outputs.last_patch_stats.expect("stats should be present");
649 assert_eq!(stats.patch_count, 1);
650 assert_eq!(stats.dirty_cells, 4);
651 assert_eq!(stats.bytes_uploaded, 64);
652 let hash = outputs
653 .compute_patch_hash()
654 .expect("hash should be present");
655 assert!(hash.starts_with("fnv1a64:"));
656 }
657
658 #[test]
659 fn presenter_emits_incremental_patch_runs_from_diff() {
660 let mut presenter = WebPresenter::new();
661 let old = Buffer::new(6, 2);
662 presenter.present_ui(&old, None, true).unwrap();
663
664 let mut next = Buffer::new(6, 2);
665 next.set_raw(2, 0, Cell::from_char('A'));
666 next.set_raw(3, 0, Cell::from_char('B'));
667 next.set_raw(0, 1, Cell::from_char('C'));
668 let diff = BufferDiff::compute(&old, &next);
669 presenter.present_ui(&next, Some(&diff), false).unwrap();
670
671 let mut outputs = presenter.take_outputs();
672 assert_eq!(outputs.last_full_repaint_hint, false);
673 assert_eq!(outputs.last_patches.len(), 2);
674 assert_eq!(outputs.last_patches[0].offset, 2);
675 assert_eq!(outputs.last_patches[0].cells.len(), 2);
676 assert_eq!(outputs.last_patches[1].offset, 6);
677 assert_eq!(outputs.last_patches[1].cells.len(), 1);
678 let stats = outputs.last_patch_stats.expect("stats should be present");
679 assert_eq!(stats.patch_count, 2);
680 assert_eq!(stats.dirty_cells, 3);
681 assert_eq!(stats.bytes_uploaded, 48);
682 let hash = outputs
683 .compute_patch_hash()
684 .expect("hash should be present");
685 assert!(hash.starts_with("fnv1a64:"));
686 }
687
688 #[test]
689 fn stale_diff_falls_back_to_full_patch() {
690 let old = Buffer::new(4, 2);
691 let mut next = Buffer::new(4, 2);
692 next.set_raw(3, 1, Cell::from_char('X'));
693 let stale_diff = BufferDiff::compute(&old, &next);
694
695 let resized = Buffer::new(2, 1);
696 let patches = build_patch_runs(&resized, Some(&stale_diff), false);
697
698 assert_eq!(patches.len(), 1);
699 assert_eq!(patches[0].offset, 0);
700 assert_eq!(patches[0].cells.len(), 2);
701 }
702
703 #[test]
704 fn patch_batch_hash_is_deterministic() {
705 let patches = vec![
706 WebPatchRun {
707 offset: 2,
708 cells: vec![
709 WebPatchCell {
710 bg: 0x1122_3344,
711 fg: 0x5566_7788,
712 glyph: 'A' as u32,
713 attrs: 0x0000_0001,
714 },
715 WebPatchCell {
716 bg: 0x1122_3344,
717 fg: 0x5566_7788,
718 glyph: 'B' as u32,
719 attrs: 0x0000_0002,
720 },
721 ],
722 },
723 WebPatchRun {
724 offset: 10,
725 cells: vec![WebPatchCell {
726 bg: 0xAABB_CCDD,
727 fg: 0xDDEE_FF00,
728 glyph: '中' as u32,
729 attrs: 0x0000_0010,
730 }],
731 },
732 ];
733
734 let hash_a = patch_batch_hash(&patches);
735 let hash_b = patch_batch_hash(&patches);
736 assert_eq!(hash_a, hash_b);
737 assert!(hash_a.starts_with("fnv1a64:"));
738 }
739
740 #[test]
741 fn patch_batch_hash_changes_with_patch_payload() {
742 let baseline = vec![WebPatchRun {
743 offset: 4,
744 cells: vec![WebPatchCell {
745 bg: 0x0000_00FF,
746 fg: 0xFFFF_FFFF,
747 glyph: 'x' as u32,
748 attrs: 0x0000_0001,
749 }],
750 }];
751 let mut changed = baseline.clone();
752 changed[0].offset = 5;
753
754 let base_hash = patch_batch_hash(&baseline);
755 let changed_hash = patch_batch_hash(&changed);
756 assert_ne!(base_hash, changed_hash);
757
758 changed[0].offset = 4;
759 changed[0].cells[0].glyph = 'y' as u32;
760 let changed_glyph_hash = patch_batch_hash(&changed);
761 assert_ne!(base_hash, changed_glyph_hash);
762 }
763
764 #[test]
765 fn flatten_patches_u32_emits_row_major_cells_and_spans() {
766 let outputs = WebOutputs {
767 last_patches: vec![
768 WebPatchRun {
769 offset: 2,
770 cells: vec![
771 WebPatchCell {
772 bg: 10,
773 fg: 11,
774 glyph: 12,
775 attrs: 13,
776 },
777 WebPatchCell {
778 bg: 20,
779 fg: 21,
780 glyph: 22,
781 attrs: 23,
782 },
783 ],
784 },
785 WebPatchRun {
786 offset: 9,
787 cells: vec![WebPatchCell {
788 bg: 30,
789 fg: 31,
790 glyph: 32,
791 attrs: 33,
792 }],
793 },
794 ],
795 ..WebOutputs::default()
796 };
797
798 let flat = outputs.flatten_patches_u32();
799 assert_eq!(flat.spans, vec![2, 2, 9, 1]);
800 assert_eq!(
801 flat.cells,
802 vec![
803 10, 11, 12, 13, 20, 21, 22, 23, 30, 31, 32, 33
806 ]
807 );
808 }
809
810 #[test]
811 fn flatten_patches_u32_handles_empty_payload() {
812 let outputs = WebOutputs::default();
813 let flat = outputs.flatten_patches_u32();
814 assert!(flat.cells.is_empty());
815 assert!(flat.spans.is_empty());
816 }
817
818 #[test]
821 fn web_backend_error_display() {
822 let err = WebBackendError::Unsupported("test op");
823 assert_eq!(format!("{err}"), "unsupported: test op");
824 }
825
826 #[test]
827 fn web_backend_error_is_std_error() {
828 let err = WebBackendError::Unsupported("foo");
829 let _: &dyn std::error::Error = &err;
830 }
831
832 #[test]
833 fn web_backend_error_eq() {
834 assert_eq!(
835 WebBackendError::Unsupported("a"),
836 WebBackendError::Unsupported("a")
837 );
838 assert_ne!(
839 WebBackendError::Unsupported("a"),
840 WebBackendError::Unsupported("b")
841 );
842 }
843
844 #[test]
847 fn clock_set_overrides_current() {
848 let mut c = DeterministicClock::new();
849 c.set(Duration::from_secs(42));
850 assert_eq!(c.now_mono(), Duration::from_secs(42));
851 }
852
853 #[test]
854 fn clock_default_is_zero() {
855 let c = DeterministicClock::default();
856 assert_eq!(c.now_mono(), Duration::ZERO);
857 }
858
859 #[test]
860 fn clock_clone() {
861 let mut c = DeterministicClock::new();
862 c.advance(Duration::from_millis(100));
863 let c2 = c.clone();
864 assert_eq!(c2.now_mono(), Duration::from_millis(100));
865 }
866
867 #[test]
870 fn event_source_set_size() {
871 let mut ev = WebEventSource::new(80, 24);
872 ev.set_size(120, 50);
873 assert_eq!(ev.size().unwrap(), (120, 50));
874 }
875
876 #[test]
877 fn event_source_drain_events() {
878 let mut ev = WebEventSource::new(80, 24);
879 ev.push_event(Event::Tick);
880 ev.push_event(Event::Tick);
881
882 let drained: Vec<_> = ev.drain_events().collect();
883 assert_eq!(drained.len(), 2);
884 assert_eq!(ev.poll_event(Duration::ZERO).unwrap(), false);
885 }
886
887 #[test]
888 fn event_source_features() {
889 let mut ev = WebEventSource::new(80, 24);
890 let f = BackendFeatures::default();
891 ev.set_features(f).unwrap();
892 let _ = ev.features(); }
894
895 #[test]
896 fn event_source_empty_read_returns_none() {
897 let mut ev = WebEventSource::new(80, 24);
898 assert_eq!(ev.read_event().unwrap(), None);
899 }
900
901 #[test]
904 fn presenter_default_is_new() {
905 let a = WebPresenter::new();
906 let b = WebPresenter::default();
907 assert!(a.outputs().logs.is_empty());
908 assert!(b.outputs().logs.is_empty());
909 }
910
911 #[test]
912 fn presenter_outputs_accessor() {
913 let mut p = WebPresenter::new();
914 p.write_log("test").unwrap();
915 assert_eq!(p.outputs().logs.len(), 1);
916 }
917
918 #[test]
919 fn presenter_outputs_mut() {
920 let mut p = WebPresenter::new();
921 p.outputs_mut().logs.push("manual".to_string());
922 assert_eq!(p.outputs().logs, vec!["manual"]);
923 }
924
925 #[test]
926 fn presenter_take_outputs_clears() {
927 let mut p = WebPresenter::new();
928 p.write_log("a").unwrap();
929 let taken = p.take_outputs();
930 assert_eq!(taken.logs, vec!["a"]);
931 assert!(p.outputs().logs.is_empty());
932 }
933
934 #[test]
935 fn presenter_capabilities_are_modern() {
936 let p = WebPresenter::new();
937 let caps = p.capabilities();
938 assert!(caps.true_color);
939 }
940
941 #[test]
942 fn presenter_present_ui_owned() {
943 let mut p = WebPresenter::new();
944 let buf = Buffer::new(3, 2);
945 p.present_ui_owned(buf, None, true);
946
947 let mut out = p.take_outputs();
948 assert!(out.last_full_repaint_hint);
949 assert_eq!(out.last_buffer.as_ref().unwrap().width(), 3);
950 assert_eq!(out.last_patches.len(), 1);
951 assert!(out.last_patch_stats.is_some());
952 assert!(out.compute_patch_hash().is_some());
953 }
954
955 #[test]
958 fn web_backend_construction() {
959 let mut wb = WebBackend::new(80, 24);
960 assert_eq!(wb.events_mut().size().unwrap(), (80, 24));
961 assert_eq!(wb.clock_mut().now_mono(), Duration::ZERO);
962 }
963
964 #[test]
965 fn web_backend_implements_backend_trait() {
966 let mut wb = WebBackend::new(80, 24);
967 let _ = wb.clock();
969 let _ = wb.events();
970 let _ = wb.presenter();
971 }
972
973 #[test]
976 fn patch_batch_stats_empty() {
977 let stats = patch_batch_stats(&[]);
978 assert_eq!(stats.dirty_cells, 0);
979 assert_eq!(stats.patch_count, 0);
980 assert_eq!(stats.bytes_uploaded, 0);
981 }
982
983 #[test]
984 fn patch_batch_stats_counts_cells() {
985 let patches = vec![
986 WebPatchRun {
987 offset: 0,
988 cells: vec![
989 WebPatchCell {
990 bg: 0,
991 fg: 0,
992 glyph: 0,
993 attrs: 0,
994 };
995 3
996 ],
997 },
998 WebPatchRun {
999 offset: 10,
1000 cells: vec![WebPatchCell {
1001 bg: 0,
1002 fg: 0,
1003 glyph: 0,
1004 attrs: 0,
1005 }],
1006 },
1007 ];
1008 let stats = patch_batch_stats(&patches);
1009 assert_eq!(stats.dirty_cells, 4);
1010 assert_eq!(stats.patch_count, 2);
1011 assert_eq!(stats.bytes_uploaded, 64); }
1013
1014 #[test]
1017 fn patch_hash_empty_is_deterministic() {
1018 let a = patch_batch_hash(&[]);
1019 let b = patch_batch_hash(&[]);
1020 assert_eq!(a, b);
1021 assert!(a.starts_with("fnv1a64:"));
1022 }
1023
1024 #[test]
1027 fn build_patch_runs_full_repaint_hint() {
1028 let buf = Buffer::new(2, 2);
1029 let patches = build_patch_runs(&buf, None, true);
1030 assert_eq!(patches.len(), 1);
1031 assert_eq!(patches[0].offset, 0);
1032 assert_eq!(patches[0].cells.len(), 4);
1033 }
1034
1035 #[test]
1036 fn build_patch_runs_no_diff_triggers_full() {
1037 let buf = Buffer::new(3, 1);
1038 let patches = build_patch_runs(&buf, None, false);
1039 assert_eq!(patches.len(), 1);
1041 assert_eq!(patches[0].cells.len(), 3);
1042 }
1043
1044 #[test]
1047 fn web_outputs_default_is_empty() {
1048 let out = WebOutputs::default();
1049 assert!(out.logs.is_empty());
1050 assert!(out.last_buffer.is_none());
1051 assert!(out.last_patches.is_empty());
1052 assert!(out.last_patch_stats.is_none());
1053 assert!(out.last_patch_hash.is_none());
1054 assert!(!out.last_full_repaint_hint);
1055 }
1056
1057 #[test]
1060 fn cell_to_patch_empty_cell() {
1061 let cell = Cell::default();
1062 let patch = cell_to_patch(&cell);
1063 assert_eq!(patch.glyph, 0);
1065 }
1066
1067 #[test]
1068 fn cell_to_patch_ascii_char() {
1069 let cell = Cell::from_char('A');
1070 let patch = cell_to_patch(&cell);
1071 assert_eq!(patch.glyph, 'A' as u32);
1072 }
1073
1074 #[test]
1075 fn cell_to_patch_preserves_max_link_id() {
1076 use ftui_render::cell::{CellAttrs, StyleFlags};
1077
1078 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(
1079 StyleFlags::UNDERLINE,
1080 CellAttrs::LINK_ID_MAX,
1081 ));
1082 let patch = cell_to_patch(&cell);
1083
1084 assert_eq!(patch.attrs >> 8, CellAttrs::LINK_ID_MAX);
1085 }
1086}