| 1 | use super::*; |
| 2 | use ratatui::text::Span; |
| 3 | |
| 4 | #[test] |
| 5 | fn ambient_min_dimensions_allow_small_windows() { |
| 6 | const { |
| 7 | assert!(AMBIENT_MIN_WIDTH < 68); |
| 8 | assert!(AMBIENT_MIN_HEIGHT < 15); |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | #[test] |
| 13 | fn occupied_text_bounds_skips_string_join() { |
| 14 | let line = Line::from(vec![Span::raw(" hello "), Span::raw("world ")]); |
| 15 | let (start, end) = occupied_text_bounds(&line).expect("bounds"); |
| 16 | assert_eq!(start, 2); |
| 17 | assert!(end > start); |
| 18 | } |
| 19 | |
| 20 | #[test] |
| 21 | fn whale_cameo_is_brief() { |
| 22 | assert_eq!(whale_cameo_phase(0), WhaleCameoPhase::Breach); |
| 23 | assert_eq!(whale_cameo_phase(500), WhaleCameoPhase::Spout); |
| 24 | assert_eq!(whale_cameo_phase(1_200), WhaleCameoPhase::Fluke); |
| 25 | assert_eq!(whale_cameo_phase(2_000), WhaleCameoPhase::Submerge); |
| 26 | assert_eq!(whale_cameo_phase(3_000), WhaleCameoPhase::Hidden); |
| 27 | } |
| 28 | |
| 29 | #[test] |
| 30 | fn density_scales_with_area() { |
| 31 | assert_eq!( |
| 32 | LifeDensity::from_area(Rect::new(0, 0, 40, 10)), |
| 33 | LifeDensity::Sparse |
| 34 | ); |
| 35 | assert_eq!( |
| 36 | LifeDensity::from_area(Rect::new(0, 0, 100, 30)), |
| 37 | LifeDensity::Rich |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | #[test] |
| 42 | fn fish_school_uses_one_silhouette_family() { |
| 43 | // Never mix lone `>` with full fish bodies; the lead just gains an |
| 44 | // eye within the same family. |
| 45 | assert_eq!(fish_body(true, false), "><>"); |
| 46 | assert_eq!(fish_body(false, false), "<><"); |
| 47 | assert_eq!(fish_body(true, true), "><o>"); |
| 48 | assert_eq!(fish_body(false, true), "<o><"); |
| 49 | } |
| 50 | |
| 51 | fn frame_at(t: u128) -> FrameMarks { |
| 52 | let area = Rect::new(0, 0, 100, 30); |
| 53 | let mut stats = AmbientFrameStats::default(); |
| 54 | build_frame_marks( |
| 55 | area, |
| 56 | t, |
| 57 | LifeDensity::from_area(area), |
| 58 | AmbientCursor::default(), |
| 59 | WhaleCameo::default(), |
| 60 | &mut stats, |
| 61 | ) |
| 62 | } |
| 63 | |
| 64 | #[test] |
| 65 | fn fish_always_swim_the_way_they_face() { |
| 66 | // Wrap-around construction: within one crossing, a right-facing |
| 67 | // school only ever moves right, and a left-facing school only left. |
| 68 | let area_travel = 100u128 + 21; // width + wedge span (see constants) |
| 69 | let cycle_ms = area_travel * SCHOOL_CELL_MS; |
| 70 | for cycle in 0u128..6 { |
| 71 | // The school clock carries a half-cycle head start, so sampling |
| 72 | // at cycle*cycle_ms lands mid-crossing of `cycle`. |
| 73 | let t1 = cycle * cycle_ms; |
| 74 | let t2 = t1 + SCHOOL_CELL_MS * 3; |
| 75 | let lead = |t: u128| { |
| 76 | frame_at(t) |
| 77 | .marks |
| 78 | .into_iter() |
| 79 | .find(|mark| mark.glyph.contains('o')) |
| 80 | }; |
| 81 | let (Some(a), Some(b)) = (lead(t1), lead(t2)) else { |
| 82 | continue; // school off-screen at this sample — fine |
| 83 | }; |
| 84 | let expect_right = school_swims_right(cycle); |
| 85 | if expect_right { |
| 86 | assert_eq!(a.glyph, "><o>", "cycle {cycle} facing"); |
| 87 | assert!(b.x >= a.x, "cycle {cycle}: right-facing fish moved left"); |
| 88 | } else { |
| 89 | assert_eq!(a.glyph, "<o><", "cycle {cycle} facing"); |
| 90 | assert!(b.x <= a.x, "cycle {cycle}: left-facing fish moved right"); |
| 91 | } |
| 92 | } |
| 93 | // Both directions must actually occur across nearby cycles. |
| 94 | let dirs: Vec<bool> = (0u128..12).map(school_swims_right).collect(); |
| 95 | assert!( |
| 96 | dirs.iter().any(|d| *d) && dirs.iter().any(|d| !*d), |
| 97 | "{dirs:?}" |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | #[test] |
| 102 | fn water_holds_only_fish_bubbles_and_jellyfish() { |
| 103 | // Seaweed and bio-dust are gone (2026-07-23): every mark is a fish |
| 104 | // body, a bubble glyph, or a jellyfish part. |
| 105 | for t in [0u128, 7_500, 33_000, 61_000, 120_000] { |
| 106 | for mark in frame_at(t).marks { |
| 107 | let ok = matches!(mark.glyph, "><>" | "<><" | "><o>" | "<o><") |
| 108 | || matches!(mark.glyph, "·" | "˚" | "°") |
| 109 | || JELLY_DOME_TOP_FRAMES.contains(&mark.glyph) |
| 110 | || JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) |
| 111 | || JELLY_DOME_TOP_COMPACT.contains(&mark.glyph) |
| 112 | || JELLY_DOME_SKIRT_COMPACT.contains(&mark.glyph) |
| 113 | || JELLY_TENTACLE_FRAMES.contains(&mark.glyph); |
| 114 | assert!(ok, "unexpected ambient glyph {:?} at t={t}", mark.glyph); |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn ambient_glyphs_are_ascii_or_have_fallbacks() { |
| 121 | // Every glyph the water can paint is either pure ASCII (fish and |
| 122 | // the whole jellyfish silhouette, by construction) or carries a |
| 123 | // glyphs::ascii_fallback entry (bubbles, whale cameo) so |
| 124 | // CODEWHALE_ASCII_SAFE=1 covers the whole field. |
| 125 | let mut jellyfish: Vec<&str> = Vec::new(); |
| 126 | jellyfish.extend(JELLY_DOME_TOP_FRAMES); |
| 127 | jellyfish.extend(JELLY_DOME_SKIRT_FRAMES); |
| 128 | jellyfish.extend(JELLY_DOME_TOP_COMPACT); |
| 129 | jellyfish.extend(JELLY_DOME_SKIRT_COMPACT); |
| 130 | jellyfish.extend(JELLY_TENTACLE_FRAMES); |
| 131 | for glyph in jellyfish { |
| 132 | assert!(glyph.is_ascii(), "jellyfish glyph {glyph:?} must be ASCII"); |
| 133 | } |
| 134 | for glyph in ["><>", "<><", "><o>", "<o><"] { |
| 135 | assert!(glyph.is_ascii(), "fish glyph {glyph:?} must be ASCII"); |
| 136 | } |
| 137 | for glyph in ["·", "˚", "°", "≈≈>", "≈", "~"] { |
| 138 | assert!( |
| 139 | glyph.is_ascii() || crate::tui::glyphs::ascii_fallback(glyph).is_some(), |
| 140 | "ambient glyph {glyph:?} lacks an ASCII fallback" |
| 141 | ); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn jellyfish_reads_as_dome_with_lagging_tentacles() { |
| 147 | // Find a frame where a full jelly is on-screen and assert its |
| 148 | // structure: a two-row dome at least four cells wide, exactly three |
| 149 | // tentacle columns one row below the skirt, and both dome and |
| 150 | // tentacles holding their brightness floors. |
| 151 | let mut seen = false; |
| 152 | for probe in 0..240u128 { |
| 153 | let t = probe * 500; |
| 154 | let frame = frame_at(t); |
| 155 | let Some(top) = frame |
| 156 | .marks |
| 157 | .iter() |
| 158 | .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)) |
| 159 | else { |
| 160 | continue; |
| 161 | }; |
| 162 | let dome_w = UnicodeWidthStr::width(top.glyph) as u16; |
| 163 | assert!(dome_w >= 4, "rich dome too narrow: {:?}", top.glyph); |
| 164 | let Some(skirt) = frame.marks.iter().find(|mark| { |
| 165 | JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) && mark.x == top.x && mark.y == top.y + 1 |
| 166 | }) else { |
| 167 | panic!("visible jellyfish dome lost its skirt: {frame:?}"); |
| 168 | }; |
| 169 | let tentacles: Vec<&AmbientMark> = frame |
| 170 | .marks |
| 171 | .iter() |
| 172 | .filter(|mark| { |
| 173 | JELLY_TENTACLE_FRAMES.contains(&mark.glyph) |
| 174 | && mark.y == skirt.y + 1 |
| 175 | && mark.x > top.x |
| 176 | && mark.x < top.x + dome_w |
| 177 | }) |
| 178 | .collect(); |
| 179 | assert_eq!( |
| 180 | tentacles.len(), |
| 181 | 3, |
| 182 | "visible jellyfish must keep all tentacles: {frame:?}" |
| 183 | ); |
| 184 | let dome_glow = top.brightness.expect("dome pulses"); |
| 185 | assert!(dome_glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON); |
| 186 | for tentacle in &tentacles { |
| 187 | let glow = tentacle.brightness.expect("tentacle pulses"); |
| 188 | assert!(glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON); |
| 189 | } |
| 190 | seen = true; |
| 191 | break; |
| 192 | } |
| 193 | assert!(seen, "no complete jellyfish found in 120s of frames"); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn jellyfish_tentacles_sway_out_of_phase_and_dome_pulses() { |
| 198 | // Over a sweep: the dome shows both pulse frames within a few |
| 199 | // JELLY_PULSE_MS periods, each tentacle column cycles its sway |
| 200 | // frames, and the trio is not always in lockstep (per-column phase |
| 201 | // offset — the lag is what sells "jellyfish"). |
| 202 | let mut dome_frames = std::collections::BTreeSet::new(); |
| 203 | let mut column_frames: [std::collections::BTreeSet<&str>; 3] = Default::default(); |
| 204 | let mut saw_desync = false; |
| 205 | for probe in 0..480u128 { |
| 206 | let frame = frame_at(probe * 100); |
| 207 | let Some(top) = frame |
| 208 | .marks |
| 209 | .iter() |
| 210 | .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)) |
| 211 | else { |
| 212 | continue; |
| 213 | }; |
| 214 | dome_frames.insert(top.glyph); |
| 215 | let mut trio = [""; 3]; |
| 216 | let mut found = 0usize; |
| 217 | for (col, slot) in trio.iter_mut().enumerate() { |
| 218 | if let Some(tentacle) = frame.marks.iter().find(|mark| { |
| 219 | JELLY_TENTACLE_FRAMES.contains(&mark.glyph) |
| 220 | && mark.y == top.y + 2 |
| 221 | && mark.x == top.x + 1 + col as u16 |
| 222 | }) { |
| 223 | *slot = tentacle.glyph; |
| 224 | column_frames[col].insert(tentacle.glyph); |
| 225 | found += 1; |
| 226 | } |
| 227 | } |
| 228 | if found == 3 && !(trio[0] == trio[1] && trio[1] == trio[2]) { |
| 229 | saw_desync = true; |
| 230 | } |
| 231 | } |
| 232 | assert_eq!( |
| 233 | dome_frames.len(), |
| 234 | JELLY_DOME_TOP_FRAMES.len(), |
| 235 | "dome pulse should show every frame: {dome_frames:?}" |
| 236 | ); |
| 237 | for (col, set) in column_frames.iter().enumerate() { |
| 238 | assert!(set.len() > 1, "tentacle column {col} never swayed"); |
| 239 | } |
| 240 | assert!(saw_desync, "tentacle columns strobed in lockstep"); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn sparse_water_gets_a_compact_jellyfish() { |
| 245 | // Narrow fallback: the Sparse tier swaps the full 5-cell dome for a |
| 246 | // 3-cell one with two tentacles — a different silhouette, not just |
| 247 | // fewer jellies. |
| 248 | let area = Rect::new(0, 0, 48, 12); |
| 249 | let mut saw_compact = false; |
| 250 | let mut saw_full = false; |
| 251 | let mut saw_two_tentacles = false; |
| 252 | for probe in 0..240u128 { |
| 253 | let mut stats = AmbientFrameStats::default(); |
| 254 | let frame = build_frame_marks( |
| 255 | area, |
| 256 | probe * 500, |
| 257 | LifeDensity::from_area(area), |
| 258 | AmbientCursor::default(), |
| 259 | WhaleCameo::default(), |
| 260 | &mut stats, |
| 261 | ); |
| 262 | for mark in &frame.marks { |
| 263 | saw_compact |= JELLY_DOME_TOP_COMPACT.contains(&mark.glyph); |
| 264 | saw_full |= JELLY_DOME_TOP_FRAMES.contains(&mark.glyph); |
| 265 | } |
| 266 | let Some(top) = frame |
| 267 | .marks |
| 268 | .iter() |
| 269 | .find(|mark| JELLY_DOME_TOP_COMPACT.contains(&mark.glyph)) |
| 270 | else { |
| 271 | continue; |
| 272 | }; |
| 273 | let tentacles = frame |
| 274 | .marks |
| 275 | .iter() |
| 276 | .filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2) |
| 277 | .count(); |
| 278 | assert_eq!( |
| 279 | tentacles, 2, |
| 280 | "visible compact jelly must keep both tentacles: {frame:?}" |
| 281 | ); |
| 282 | saw_two_tentacles = true; |
| 283 | } |
| 284 | assert!(saw_compact, "sparse water never showed the compact dome"); |
| 285 | assert!(!saw_full, "sparse water used the full-size dome"); |
| 286 | assert!(saw_two_tentacles, "compact jelly lost its tentacles"); |
| 287 | } |
| 288 | |
| 289 | #[test] |
| 290 | fn animated_tentacle_frames_never_collapse_to_punctuation_dots() { |
| 291 | assert!( |
| 292 | JELLY_TENTACLE_FRAMES |
| 293 | .iter() |
| 294 | .all(|glyph| matches!(*glyph, "|" | "/" | "\\")), |
| 295 | "every animation frame must retain a legible tentacle stroke" |
| 296 | ); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn motion_is_a_deterministic_function_of_elapsed_time() { |
| 301 | // v0.9.4: positions always ride the monotonic clock (presence fades the |
| 302 | // marks in/out instead of parking them), so a fixed t must produce the |
| 303 | // exact same complete jellyfish: dome, skirt, and tentacles visible. |
| 304 | let area = Rect::new(0, 0, 100, 30); |
| 305 | let build = || { |
| 306 | let mut stats = AmbientFrameStats::default(); |
| 307 | build_frame_marks( |
| 308 | area, |
| 309 | 0, |
| 310 | LifeDensity::from_area(area), |
| 311 | AmbientCursor::default(), |
| 312 | WhaleCameo::default(), |
| 313 | &mut stats, |
| 314 | ) |
| 315 | }; |
| 316 | let first = build(); |
| 317 | let second = build(); |
| 318 | let pose = |frame: &FrameMarks| { |
| 319 | frame |
| 320 | .marks |
| 321 | .iter() |
| 322 | .map(|mark| (mark.glyph, mark.x, mark.y)) |
| 323 | .collect::<Vec<_>>() |
| 324 | }; |
| 325 | assert_eq!( |
| 326 | pose(&first), |
| 327 | pose(&second), |
| 328 | "motion must be a pure function of t" |
| 329 | ); |
| 330 | let top = first |
| 331 | .marks |
| 332 | .iter() |
| 333 | .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)) |
| 334 | .expect("jellyfish dome at t=0"); |
| 335 | assert!( |
| 336 | first |
| 337 | .marks |
| 338 | .iter() |
| 339 | .any(|mark| { JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) && mark.y == top.y + 1 }), |
| 340 | "jellyfish lost its skirt at t=0" |
| 341 | ); |
| 342 | let tentacles = first |
| 343 | .marks |
| 344 | .iter() |
| 345 | .filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2) |
| 346 | .count(); |
| 347 | assert!(tentacles >= 3, "jellyfish lost its tentacles at t=0"); |
| 348 | } |
| 349 | |
| 350 | #[test] |
| 351 | fn glow_helpers_stay_bounded_with_floors() { |
| 352 | for t in (0u128..12_000).step_by(97) { |
| 353 | let w = wave01(t, FISH_WAVE_MS, 0); |
| 354 | assert!((0.0..=1.0).contains(&w), "wave01 out of range: {w}"); |
| 355 | let g = glint01(t, 2_600, 600, BUBBLE_BRIGHTNESS_FLOOR, 0); |
| 356 | assert!( |
| 357 | (BUBBLE_BRIGHTNESS_FLOOR..=1.0).contains(&g), |
| 358 | "glint01 lost its floor: {g}" |
| 359 | ); |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | #[test] |
| 364 | fn frame_stats_account_for_every_mark() { |
| 365 | let area = Rect::new(0, 0, 100, 30); |
| 366 | let mut buf = Buffer::empty(area); |
| 367 | let stats = render_ambient_life( |
| 368 | area, |
| 369 | &mut buf, |
| 370 | (Color::Cyan, Color::Blue), |
| 371 | &[], |
| 372 | 12_000, |
| 373 | 1.0, |
| 374 | AmbientCursor::default(), |
| 375 | WhaleCameo::default(), |
| 376 | ); |
| 377 | assert_eq!( |
| 378 | stats.marks_built, |
| 379 | stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped, |
| 380 | "every built mark is painted, text-skipped, or clipped: {stats:?}" |
| 381 | ); |
| 382 | assert!(stats.marks_painted > 0, "empty water should paint life"); |
| 383 | // cells_written counts each cell of a multi-cell glyph (and counts |
| 384 | // a shared cell once per overlapping mark), so the honest bound is |
| 385 | // per painted mark — at most the widest glyph (the 5-cell dome) — |
| 386 | // rather than per area cell. |
| 387 | assert!(stats.cells_written >= stats.marks_painted); |
| 388 | assert!( |
| 389 | stats.cells_written <= stats.marks_painted * 5, |
| 390 | "cells_written out of proportion: {stats:?}" |
| 391 | ); |
| 392 | } |
| 393 | |
| 394 | #[test] |
| 395 | fn frame_stats_stay_within_the_render_budget() { |
| 396 | // Worst case on the largest Rich field with the whale cameo active: |
| 397 | // 7 fish + 2 jellies x 5 parts + 2 bubbles + 2 cameo cells. Anything |
| 398 | // more is a leak in the O(1)-per-frame budget. |
| 399 | let area = Rect::new(0, 0, 160, 40); |
| 400 | let mut buf = Buffer::empty(area); |
| 401 | let whale = WhaleCameo { |
| 402 | elapsed_ms: Some(500), // Spout: cameo glyph plus spray |
| 403 | anchor_x: 80, |
| 404 | anchor_y: 26, |
| 405 | }; |
| 406 | let stats = render_ambient_life( |
| 407 | area, |
| 408 | &mut buf, |
| 409 | (Color::Cyan, Color::Blue), |
| 410 | &[], |
| 411 | 12_000, |
| 412 | 1.0, |
| 413 | AmbientCursor::default(), |
| 414 | whale, |
| 415 | ); |
| 416 | assert!( |
| 417 | stats.marks_built <= MAX_FRAME_MARKS, |
| 418 | "frame budget blown: {stats:?}" |
| 419 | ); |
| 420 | assert_eq!( |
| 421 | stats.marks_built, |
| 422 | stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped, |
| 423 | "every built mark is accounted for: {stats:?}" |
| 424 | ); |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn frame_stats_never_overwrite_text() { |
| 429 | // Property: on a text-covered field, colliding marks are charged to |
| 430 | // marks_skipped_text and no transcript cell is overwritten. |
| 431 | let area = Rect::new(0, 0, 100, 30); |
| 432 | let mut buf = Buffer::empty(area); |
| 433 | let lines: Vec<Line<'static>> = (0..usize::from(area.height)) |
| 434 | .map(|i| { |
| 435 | Line::from(Span::raw(format!( |
| 436 | "transcript row {i:02} occupies the water" |
| 437 | ))) |
| 438 | }) |
| 439 | .collect(); |
| 440 | for (i, line) in lines.iter().enumerate() { |
| 441 | buf.set_line(area.x, area.y + i as u16, line, area.width); |
| 442 | } |
| 443 | let stats = render_ambient_life( |
| 444 | area, |
| 445 | &mut buf, |
| 446 | (Color::Cyan, Color::Blue), |
| 447 | &lines, |
| 448 | 12_000, |
| 449 | 1.0, |
| 450 | AmbientCursor::default(), |
| 451 | WhaleCameo::default(), |
| 452 | ); |
| 453 | assert!( |
| 454 | stats.marks_skipped_text > 0, |
| 455 | "text-covered water should skip some marks: {stats:?}" |
| 456 | ); |
| 457 | assert_eq!( |
| 458 | stats.marks_built, |
| 459 | stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped, |
| 460 | "every built mark is accounted for: {stats:?}" |
| 461 | ); |
| 462 | for (i, line) in lines.iter().enumerate() { |
| 463 | let text: String = line |
| 464 | .spans |
| 465 | .iter() |
| 466 | .map(|span| span.content.as_ref()) |
| 467 | .collect(); |
| 468 | for (x, ch) in text.chars().enumerate() { |
| 469 | assert_eq!( |
| 470 | buf[(area.x + x as u16, area.y + i as u16)].symbol(), |
| 471 | ch.to_string(), |
| 472 | "ambient life overwrote transcript cell ({x},{i})" |
| 473 | ); |
| 474 | } |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | fn full_jellyfish_frame(x: u16, y: u16) -> FrameMarks { |
| 479 | let mark = |x, y, glyph, depth| AmbientMark { |
| 480 | x, |
| 481 | y, |
| 482 | glyph, |
| 483 | jellyfish: Some(0), |
| 484 | depth, |
| 485 | style_mod: None, |
| 486 | brightness: None, |
| 487 | }; |
| 488 | FrameMarks { |
| 489 | marks: vec![ |
| 490 | mark(x, y, JELLY_DOME_TOP_FRAMES[0], Depth::Midground), |
| 491 | mark(x, y + 1, JELLY_DOME_SKIRT_FRAMES[0], Depth::Midground), |
| 492 | mark(x + 1, y + 2, "|", Depth::Background), |
| 493 | mark(x + 2, y + 2, "/", Depth::Background), |
| 494 | mark(x + 3, y + 2, "\\", Depth::Background), |
| 495 | ], |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | fn paint_fixture( |
| 500 | area: Rect, |
| 501 | lines: &[Line<'static>], |
| 502 | frame: &FrameMarks, |
| 503 | ) -> (Buffer, AmbientFrameStats) { |
| 504 | let mut buf = Buffer::empty(area); |
| 505 | for (row, line) in lines.iter().enumerate() { |
| 506 | buf.set_line(area.x, area.y + row as u16, line, area.width); |
| 507 | } |
| 508 | let mut stats = AmbientFrameStats { |
| 509 | marks_built: frame.marks.len() as u32, |
| 510 | ..AmbientFrameStats::default() |
| 511 | }; |
| 512 | paint_marks( |
| 513 | area, |
| 514 | &mut buf, |
| 515 | (Color::Cyan, Color::Blue), |
| 516 | lines, |
| 517 | frame, |
| 518 | 1.0, |
| 519 | &mut stats, |
| 520 | ); |
| 521 | (buf, stats) |
| 522 | } |
| 523 | |
| 524 | #[test] |
| 525 | fn jellyfish_relocates_as_one_nearest_silhouette() { |
| 526 | let area = Rect::new(0, 0, 24, 8); |
| 527 | let mut lines = vec![Line::default(); usize::from(area.height)]; |
| 528 | lines[2] = Line::from(Span::raw(" X")); |
| 529 | let (buf, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(10, 2)); |
| 530 | |
| 531 | assert_eq!(buf[(10, 2)].symbol(), "X"); |
| 532 | assert_eq!(buf[(12, 2)].symbol(), "."); |
| 533 | assert_eq!(buf[(12, 3)].symbol(), "\\"); |
| 534 | assert_eq!(buf[(13, 4)].symbol(), "|"); |
| 535 | assert_eq!(buf[(14, 4)].symbol(), "/"); |
| 536 | assert_eq!(buf[(15, 4)].symbol(), "\\"); |
| 537 | assert_eq!(stats.marks_painted, 5); |
| 538 | assert_eq!(stats.marks_skipped_text, 0); |
| 539 | assert_eq!(stats.marks_clipped, 0); |
| 540 | assert_eq!(stats.cells_written, 13); |
| 541 | } |
| 542 | |
| 543 | #[test] |
| 544 | fn jellyfish_hides_rather_than_vaulting_past_a_long_line() { |
| 545 | // This used to place the silhouette at x=33 — a 17-column vault from its |
| 546 | // lane, chosen purely from the width of the text on that row. Under a |
| 547 | // fast stream that width changes every frame, so the vault was the |
| 548 | // teleport users saw. Past the dodge cap the jelly is withheld instead, |
| 549 | // which is the same quiet outcome the fish already have. |
| 550 | let area = Rect::new(0, 0, 100, 8); |
| 551 | let mut lines = vec![Line::default(); usize::from(area.height)]; |
| 552 | lines[2] = Line::from(Span::raw("X".repeat(32))); |
| 553 | let (_, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(16, 2)); |
| 554 | |
| 555 | assert_eq!(stats.marks_painted, 0); |
| 556 | assert_eq!(stats.marks_skipped_text, 5); |
| 557 | assert_eq!(stats.marks_clipped, 0); |
| 558 | } |
| 559 | |
| 560 | #[test] |
| 561 | fn jellyfish_never_teleports_while_a_line_streams_across_its_lane() { |
| 562 | // Regression for the DeepSeek-V4-Flash report: replay a line growing |
| 563 | // straight through the silhouette's lane and assert the painted anchor |
| 564 | // never jumps. The clock is held fixed, so the only moving input is the |
| 565 | // transcript text — the coupling that actually scaled with token |
| 566 | // throughput. |
| 567 | // |
| 568 | // The burst size is the whole point. A slow provider adds about a |
| 569 | // character per frame and the old unbounded dodge slid along with it, |
| 570 | // which is why this never looked broken on slow models. A fast one adds a |
| 571 | // burst per frame, and the dodge moved by the whole burst at once. |
| 572 | const STREAM_BURST_CHARS: usize = 9; |
| 573 | let area = Rect::new(0, 0, 100, 8); |
| 574 | let lane = 40u16; |
| 575 | let mut previous: Option<u16> = None; |
| 576 | for chars in (0..80usize).step_by(STREAM_BURST_CHARS) { |
| 577 | let mut lines = vec![Line::default(); usize::from(area.height)]; |
| 578 | lines[2] = Line::from(Span::raw("X".repeat(chars))); |
| 579 | lines[3] = Line::from(Span::raw("X".repeat(chars.saturating_sub(3)))); |
| 580 | let (buf, _) = paint_fixture(area, &lines, &full_jellyfish_frame(lane, 2)); |
| 581 | let anchor = (0..area.width).find(|x| buf[(*x, 2)].symbol() == "."); |
| 582 | if let (Some(anchor), Some(previous)) = (anchor, previous) { |
| 583 | assert!( |
| 584 | anchor.abs_diff(previous) <= 2 * JELLY_MAX_TEXT_DODGE_COLS, |
| 585 | "jellyfish teleported {} columns at {chars} streamed chars", |
| 586 | anchor.abs_diff(previous) |
| 587 | ); |
| 588 | } |
| 589 | if let Some(anchor) = anchor { |
| 590 | assert!( |
| 591 | anchor.abs_diff(lane) <= JELLY_MAX_TEXT_DODGE_COLS, |
| 592 | "jellyfish left its lane by {} columns", |
| 593 | anchor.abs_diff(lane) |
| 594 | ); |
| 595 | } |
| 596 | previous = anchor; |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | #[test] |
| 601 | fn sparse_jellyfish_row_keeps_a_safe_gap_over_text() { |
| 602 | let area = Rect::new(0, 0, 12, 6); |
| 603 | let mut lines = vec![Line::default(); usize::from(area.height)]; |
| 604 | lines[2] = Line::from(Span::raw(" X")); |
| 605 | let mark = |x| AmbientMark { |
| 606 | x, |
| 607 | y: 2, |
| 608 | glyph: "|", |
| 609 | jellyfish: Some(0), |
| 610 | depth: Depth::Background, |
| 611 | style_mod: None, |
| 612 | brightness: None, |
| 613 | }; |
| 614 | let frame = FrameMarks { |
| 615 | marks: vec![mark(0), mark(6)], |
| 616 | }; |
| 617 | let (buf, stats) = paint_fixture(area, &lines, &frame); |
| 618 | |
| 619 | assert_eq!(buf[(0, 2)].symbol(), "|"); |
| 620 | assert_eq!(buf[(3, 2)].symbol(), "X"); |
| 621 | assert_eq!(buf[(6, 2)].symbol(), "|"); |
| 622 | assert_eq!(stats.marks_painted, 2); |
| 623 | } |
| 624 | |
| 625 | #[test] |
| 626 | fn fully_blocked_jellyfish_is_suppressed_and_accounted() { |
| 627 | let area = Rect::new(0, 0, 24, 8); |
| 628 | let mut lines = vec![Line::default(); usize::from(area.height)]; |
| 629 | for line in lines.iter_mut().take(5).skip(2) { |
| 630 | *line = Line::from(Span::raw("X".repeat(24))); |
| 631 | } |
| 632 | let (buf, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(10, 2)); |
| 633 | |
| 634 | assert_eq!(stats.marks_painted, 0); |
| 635 | assert_eq!(stats.marks_skipped_text, 5); |
| 636 | assert_eq!(stats.marks_clipped, 0); |
| 637 | assert_eq!(stats.cells_written, 0); |
| 638 | assert_eq!(stats.marks_built, stats.marks_skipped_text); |
| 639 | for row in 2..=4 { |
| 640 | for column in 0..area.width { |
| 641 | assert_eq!(buf[(column, row)].symbol(), "X"); |
| 642 | } |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | #[test] |
| 647 | fn too_wide_jellyfish_is_truthfully_clipped_as_a_group() { |
| 648 | let area = Rect::new(0, 0, 4, 8); |
| 649 | let lines = vec![Line::default(); usize::from(area.height)]; |
| 650 | let (_, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(0, 2)); |
| 651 | |
| 652 | assert_eq!(stats.marks_painted, 0); |
| 653 | assert_eq!(stats.marks_skipped_text, 0); |
| 654 | assert_eq!(stats.marks_clipped, 5); |
| 655 | assert_eq!(stats.marks_built, stats.marks_clipped); |
| 656 | } |
| 657 |