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