1use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
9pub struct Color {
10 pub r: u8,
12 pub g: u8,
14 pub b: u8,
16}
17
18impl Color {
19 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
21 Self { r, g, b }
22 }
23
24 pub fn from_hex(hex: &str) -> Option<Self> {
26 let hex = hex.strip_prefix('#').unwrap_or(hex);
27 if hex.len() != 6 {
28 return None;
29 }
30 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
31 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
32 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
33 Some(Self { r, g, b })
34 }
35
36 pub fn to_hex(&self) -> String {
38 format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
39 }
40
41 pub const BLACK: Self = Self::rgb(0, 0, 0);
43 pub const WHITE: Self = Self::rgb(255, 255, 255);
45 pub const RED: Self = Self::rgb(255, 0, 0);
47 pub const GREEN: Self = Self::rgb(0, 255, 0);
49 pub const BLUE: Self = Self::rgb(0, 0, 255);
51 pub const CYAN: Self = Self::rgb(0, 255, 255);
53 pub const YELLOW: Self = Self::rgb(255, 255, 0);
55 pub const MAGENTA: Self = Self::rgb(255, 0, 255);
57}
58
59impl fmt::Display for Color {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(f, "{}", self.to_hex())
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Cell {
68 pub ch: char,
70 pub fg: Color,
72 pub bg: Color,
74 pub bold: bool,
76 pub underline: bool,
78}
79
80impl Default for Cell {
81 fn default() -> Self {
82 Self {
83 ch: ' ',
84 fg: Color::WHITE,
85 bg: Color::BLACK,
86 bold: false,
87 underline: false,
88 }
89 }
90}
91
92impl Cell {
93 pub fn new(ch: char) -> Self {
95 Self {
96 ch,
97 ..Default::default()
98 }
99 }
100
101 pub fn with_fg(mut self, fg: Color) -> Self {
103 self.fg = fg;
104 self
105 }
106
107 pub fn with_bg(mut self, bg: Color) -> Self {
109 self.bg = bg;
110 self
111 }
112}
113
114#[derive(Debug, Clone)]
116pub struct TerminalSnapshot {
117 cells: Vec<Cell>,
118 width: u16,
119 height: u16,
120}
121
122impl TerminalSnapshot {
123 pub fn new(width: u16, height: u16) -> Self {
125 let cells = vec![Cell::default(); (width as usize) * (height as usize)];
126 Self {
127 cells,
128 width,
129 height,
130 }
131 }
132
133 pub fn from_string(text: &str, width: u16, height: u16) -> Self {
135 let mut snapshot = Self::new(width, height);
136 for (y, line) in text.lines().enumerate() {
137 if y >= height as usize {
138 break;
139 }
140 for (x, ch) in line.chars().enumerate() {
141 if x >= width as usize {
142 break;
143 }
144 snapshot.set(x as u16, y as u16, Cell::new(ch));
145 }
146 }
147 snapshot
148 }
149
150 pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
152 if x >= self.width || y >= self.height {
153 return None;
154 }
155 let idx = (y as usize) * (self.width as usize) + (x as usize);
156 self.cells.get(idx)
157 }
158
159 pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
161 if x < self.width && y < self.height {
162 let idx = (y as usize) * (self.width as usize) + (x as usize);
163 self.cells[idx] = cell;
164 }
165 }
166
167 pub fn dimensions(&self) -> (u16, u16) {
169 (self.width, self.height)
170 }
171
172 pub fn to_text(&self) -> String {
174 let mut result = String::new();
175 for y in 0..self.height {
176 for x in 0..self.width {
177 if let Some(cell) = self.get(x, y) {
178 result.push(cell.ch);
179 }
180 }
181 result.push('\n');
182 }
183 result
184 }
185
186 pub fn contains(&self, text: &str) -> bool {
188 self.to_text().contains(text)
189 }
190
191 pub fn contains_all(&self, texts: &[&str]) -> bool {
193 let content = self.to_text();
194 texts.iter().all(|t| content.contains(t))
195 }
196
197 pub fn contains_any(&self, texts: &[&str]) -> bool {
199 let content = self.to_text();
200 texts.iter().any(|t| content.contains(t))
201 }
202
203 pub fn fg_color_at(&self, x: u16, y: u16) -> Option<Color> {
205 self.get(x, y).map(|c| c.fg)
206 }
207
208 pub fn bg_color_at(&self, x: u16, y: u16) -> Option<Color> {
210 self.get(x, y).map(|c| c.bg)
211 }
212
213 pub fn count_char(&self, ch: char) -> usize {
215 self.cells.iter().filter(|c| c.ch == ch).count()
216 }
217
218 pub fn find(&self, text: &str) -> Option<(u16, u16)> {
220 let content = self.to_text();
221 let pos = content.find(text)?;
222 let line_start = content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
223 let x = pos - line_start;
224 let y = content[..pos].matches('\n').count();
225 Some((x as u16, y as u16))
226 }
227
228 pub fn region(&self, x: u16, y: u16, width: u16, height: u16) -> Self {
230 let mut result = Self::new(width, height);
231 for dy in 0..height {
232 for dx in 0..width {
233 if let Some(cell) = self.get(x + dx, y + dy) {
234 result.set(dx, dy, cell.clone());
235 }
236 }
237 }
238 result
239 }
240}
241
242impl fmt::Display for TerminalSnapshot {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 write!(f, "{}", self.to_text())
245 }
246}
247
248#[derive(Debug, Clone)]
250pub enum TerminalAssertion {
251 Contains(String),
253 NotContains(String),
255 ColorAt {
257 x: u16,
259 y: u16,
261 expected: Color,
263 },
264 CharAt {
266 x: u16,
268 y: u16,
270 expected: char,
272 },
273 RegionEquals {
275 x: u16,
277 y: u16,
279 width: u16,
281 height: u16,
283 expected: String,
285 },
286}
287
288impl TerminalAssertion {
289 pub fn check(&self, snapshot: &TerminalSnapshot) -> Result<(), String> {
291 match self {
292 Self::Contains(text) => {
293 if snapshot.contains(text) {
294 Ok(())
295 } else {
296 Err(format!("Expected to contain: {}", text))
297 }
298 }
299 Self::NotContains(text) => {
300 if !snapshot.contains(text) {
301 Ok(())
302 } else {
303 Err(format!("Expected not to contain: {}", text))
304 }
305 }
306 Self::ColorAt { x, y, expected } => match snapshot.fg_color_at(*x, *y) {
307 Some(actual) if actual == *expected => Ok(()),
308 Some(actual) => Err(format!(
309 "Color at ({}, {}): expected {}, got {}",
310 x, y, expected, actual
311 )),
312 None => Err(format!("Position ({}, {}) out of bounds", x, y)),
313 },
314 Self::CharAt { x, y, expected } => match snapshot.get(*x, *y) {
315 Some(cell) if cell.ch == *expected => Ok(()),
316 Some(cell) => Err(format!(
317 "Char at ({}, {}): expected '{}', got '{}'",
318 x, y, expected, cell.ch
319 )),
320 None => Err(format!("Position ({}, {}) out of bounds", x, y)),
321 },
322 Self::RegionEquals {
323 x,
324 y,
325 width,
326 height,
327 expected,
328 } => {
329 let region = snapshot.region(*x, *y, *width, *height);
330 let actual = region.to_text().trim_end().to_string();
331 let expected = expected.trim_end();
332 if actual == expected {
333 Ok(())
334 } else {
335 Err(format!(
336 "Region at ({}, {}) {}x{}: expected\n{}\ngot\n{}",
337 x, y, width, height, expected, actual
338 ))
339 }
340 }
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_color_from_hex() {
351 let color = Color::from_hex("#64C8FF").unwrap();
352 assert_eq!(color.r, 100);
353 assert_eq!(color.g, 200);
354 assert_eq!(color.b, 255);
355 }
356
357 #[test]
358 fn test_color_to_hex() {
359 let color = Color::rgb(100, 200, 255);
360 assert_eq!(color.to_hex(), "#64C8FF");
361 }
362
363 #[test]
364 fn test_color_from_hex_invalid() {
365 assert!(Color::from_hex("invalid").is_none());
366 assert!(Color::from_hex("#12345").is_none());
367 assert!(Color::from_hex("#GGGGGG").is_none());
368 }
369
370 #[test]
371 fn test_cell_default() {
372 let cell = Cell::default();
373 assert_eq!(cell.ch, ' ');
374 assert_eq!(cell.fg, Color::WHITE);
375 assert_eq!(cell.bg, Color::BLACK);
376 }
377
378 #[test]
379 fn test_cell_builder() {
380 let cell = Cell::new('A').with_fg(Color::RED).with_bg(Color::BLUE);
381 assert_eq!(cell.ch, 'A');
382 assert_eq!(cell.fg, Color::RED);
383 assert_eq!(cell.bg, Color::BLUE);
384 }
385
386 #[test]
387 fn test_snapshot_new() {
388 let snapshot = TerminalSnapshot::new(80, 24);
389 assert_eq!(snapshot.dimensions(), (80, 24));
390 }
391
392 #[test]
393 fn test_snapshot_from_string() {
394 let snapshot = TerminalSnapshot::from_string("Hello\nWorld", 80, 24);
395 assert!(snapshot.contains("Hello"));
396 assert!(snapshot.contains("World"));
397 }
398
399 #[test]
400 fn test_snapshot_get_set() {
401 let mut snapshot = TerminalSnapshot::new(10, 10);
402 snapshot.set(5, 5, Cell::new('X'));
403 let cell = snapshot.get(5, 5).unwrap();
404 assert_eq!(cell.ch, 'X');
405 }
406
407 #[test]
408 fn test_snapshot_get_out_of_bounds() {
409 let snapshot = TerminalSnapshot::new(10, 10);
410 assert!(snapshot.get(100, 100).is_none());
411 }
412
413 #[test]
414 fn test_snapshot_contains() {
415 let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
416 assert!(snapshot.contains("CPU"));
417 assert!(snapshot.contains("45%"));
418 assert!(!snapshot.contains("GPU"));
419 }
420
421 #[test]
422 fn test_snapshot_contains_all() {
423 let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
424 assert!(snapshot.contains_all(&["CPU", "MEM"]));
425 assert!(!snapshot.contains_all(&["CPU", "GPU"]));
426 }
427
428 #[test]
429 fn test_snapshot_contains_any() {
430 let snapshot = TerminalSnapshot::from_string("CPU 45%", 80, 24);
431 assert!(snapshot.contains_any(&["CPU", "GPU"]));
432 assert!(!snapshot.contains_any(&["GPU", "DISK"]));
433 }
434
435 #[test]
436 fn test_snapshot_find() {
437 let snapshot = TerminalSnapshot::from_string("Hello World", 80, 24);
438 let pos = snapshot.find("World").unwrap();
439 assert_eq!(pos, (6, 0));
440 }
441
442 #[test]
443 fn test_snapshot_count_char() {
444 let snapshot = TerminalSnapshot::from_string("AAABBC", 80, 24);
445 assert_eq!(snapshot.count_char('A'), 3);
446 assert_eq!(snapshot.count_char('B'), 2);
447 assert_eq!(snapshot.count_char('C'), 1);
448 }
449
450 #[test]
451 fn test_snapshot_region() {
452 let snapshot = TerminalSnapshot::from_string("ABCD\nEFGH\nIJKL", 80, 24);
453 let region = snapshot.region(1, 1, 2, 2);
454 assert!(region.contains("FG"));
455 }
456
457 #[test]
458 fn test_assertion_contains() {
459 let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
460 let assertion = TerminalAssertion::Contains("Hello".into());
461 assert!(assertion.check(&snapshot).is_ok());
462
463 let assertion = TerminalAssertion::Contains("World".into());
464 assert!(assertion.check(&snapshot).is_err());
465 }
466
467 #[test]
468 fn test_assertion_not_contains() {
469 let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
470 let assertion = TerminalAssertion::NotContains("World".into());
471 assert!(assertion.check(&snapshot).is_ok());
472
473 let assertion = TerminalAssertion::NotContains("Hello".into());
474 assert!(assertion.check(&snapshot).is_err());
475 }
476
477 #[test]
478 fn test_assertion_color_at() {
479 let mut snapshot = TerminalSnapshot::new(10, 10);
480 snapshot.set(5, 5, Cell::new('X').with_fg(Color::RED));
481
482 let assertion = TerminalAssertion::ColorAt {
483 x: 5,
484 y: 5,
485 expected: Color::RED,
486 };
487 assert!(assertion.check(&snapshot).is_ok());
488
489 let assertion = TerminalAssertion::ColorAt {
490 x: 5,
491 y: 5,
492 expected: Color::BLUE,
493 };
494 assert!(assertion.check(&snapshot).is_err());
495 }
496
497 #[test]
498 fn test_assertion_char_at() {
499 let snapshot = TerminalSnapshot::from_string("ABC", 80, 24);
500 let assertion = TerminalAssertion::CharAt {
501 x: 1,
502 y: 0,
503 expected: 'B',
504 };
505 assert!(assertion.check(&snapshot).is_ok());
506 }
507
508 #[test]
509 fn test_color_display() {
510 let color = Color::rgb(100, 200, 255);
511 assert_eq!(format!("{}", color), "#64C8FF");
512 }
513
514 #[test]
515 fn test_snapshot_display() {
516 let snapshot = TerminalSnapshot::from_string("Test", 10, 1);
517 let display = format!("{}", snapshot);
518 assert!(display.contains("Test"));
519 }
520}