返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / ambient_life / tests.rs
1 use super::*;
2 use ratatui::text::Span;
3
4 fn dot_cameo(area: Rect, age: u128, activity: AmbientActivity) -> (Buffer, AmbientFrameStats) {
5 let mut buf = Buffer::empty(area);
6 let mut stats = AmbientFrameStats::default();
7 pet_cameo::paint(
8 area,
9 &mut buf,
10 Color::Cyan,
11 &[],
12 1.0,
13 WhaleCameo {
14 elapsed_ms: Some(age),
15 anchor_x: area.x + area.width / 2,
16 anchor_y: area.y + area.height / 2,
17 },
18 activity,
19 &mut stats,
20 );
21 (buf, stats)
22 }
23
24 #[test]
25 fn dot_cameo_is_bounded_and_independent_of_draw_history() {
26 let area = Rect::new(7, 11, 80, 24);
27 let (first, stats) = dot_cameo(area, 500, AmbientActivity::Baseline);
28 assert!(stats.marks_painted > 0);
29 dot_cameo(area, 1_900, AmbientActivity::Subagents);
30 dot_cameo(Rect::new(0, 0, 140, 40), 100, AmbientActivity::Baseline);
31 assert_eq!(first, dot_cameo(area, 500, AmbientActivity::Baseline).0);
32 assert_ne!(first, dot_cameo(area, 900, AmbientActivity::Baseline).0);
33 assert_eq!(
34 dot_cameo(area, 2_400, AmbientActivity::Baseline).1,
35 AmbientFrameStats::default()
36 );
37 assert_eq!(
38 dot_cameo(area, u128::MAX, AmbientActivity::Subagents).1,
39 AmbientFrameStats::default()
40 );
41 }
42
43 #[test]
44 fn dot_cameo_withholds_whole_body_when_label_touches_wide_text() {
45 let area = Rect::new(7, 11, 80, 24);
46 let (clear, clear_stats) = dot_cameo(area, 500, AmbientActivity::Baseline);
47 let label_cell = clear
48 .content
49 .iter()
50 .position(|cell| cell.symbol() == "o")
51 .unwrap();
52 let (x, y) = (
53 label_cell % area.width as usize,
54 label_cell / area.width as usize,
55 );
56 let mut lines = vec![Line::from(""); area.height as usize];
57 lines[y] = Line::from(format!("{}鲸", " ".repeat(x)));
58 let mut buf = Buffer::empty(area);
59 buf.set_line(area.x, area.y + y as u16, &lines[y], area.width);
60 let before = buf.clone();
61 let mut stats = AmbientFrameStats::default();
62 pet_cameo::paint(
63 area,
64 &mut buf,
65 Color::Cyan,
66 &lines,
67 1.0,
68 WhaleCameo {
69 elapsed_ms: Some(500),
70 anchor_x: area.x + 40,
71 anchor_y: area.y + 12,
72 },
73 AmbientActivity::Baseline,
74 &mut stats,
75 );
76 assert_eq!(
77 before, buf,
78 "a label collision must withhold every particle too"
79 );
80 assert_eq!(stats.marks_skipped_text, clear_stats.marks_built);
81 assert_eq!(stats.marks_painted, 0);
82 }
83
84 #[test]
85 fn narrow_pod_members_are_withheld_instead_of_stacked() {
86 let area = Rect::new(0, 0, 40, 12);
87 let (buf, stats) = dot_cameo(area, 500, AmbientActivity::Subagents);
88 let text: String = buf.content.iter().map(|cell| cell.symbol()).collect();
89 assert_eq!(text.matches("agent · pod").count(), 1);
90 assert!(stats.marks_clipped > 0);
91 assert_eq!(stats.marks_built, stats.marks_painted + stats.marks_clipped);
92 }
93
94 #[test]
95 fn reduced_motion_builds_and_paints_no_ambient_creatures() {
96 let area = Rect::new(0, 0, 80, 24);
97 let mut buf = Buffer::empty(area);
98 let before = buf.clone();
99 let stats = render_ambient_life(
100 area,
101 &mut buf,
102 (Color::Cyan, Color::Blue),
103 &[],
104 900,
105 0.0,
106 AmbientCursor::default(),
107 WhaleCameo {
108 elapsed_ms: Some(500),
109 anchor_x: 40,
110 anchor_y: 16,
111 },
112 AmbientActivity::Subagents,
113 );
114 assert_eq!(buf, before);
115 assert_eq!(stats, AmbientFrameStats::default());
116 }
117
118 #[test]
119 fn pet_settle_clock_does_not_extend_the_ocean_light_pulse() {
120 let area = Rect::new(0, 0, 80, 24);
121 let ramp = ocean::OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).unwrap();
122 let column = |age| {
123 OceanColumn::new(
124 ramp,
125 area,
126 900,
127 age,
128 crate::tui::underwater::ShellPhase::Done,
129 false,
130 500,
131 40,
132 )
133 };
134 let settled = column(None);
135 let pet_tail = column(Some(900));
136 assert_eq!(pet_tail.completion_elapsed_ms(), Some(900));
137 assert_eq!(pet_tail.ramp_fingerprint(), settled.ramp_fingerprint());
138 for y in 0..area.height {
139 assert_eq!(pet_tail.color_at_y(y), settled.color_at_y(y));
140 }
141 }
142
143 #[test]
144 fn pet_widget_keeps_unknown_distinct_from_sleep_and_keeps_its_label() {
145 use pet_sim::{ChannelId, PetSim, PetState};
146 use ratatui::widgets::Widget;
147 let mut sim = PetSim::whale();
148 let mut state = PetState {
149 channel: ChannelId::Other,
150 lit: 0.3,
151 ..PetState::rest()
152 };
153 sim.step(1.0 / 30.0, &state, false, 1.0);
154 assert!(!sim.frame.hollow, "sleep is still observed");
155 state.observed = 0.15;
156 sim.step(1.0 / 30.0, &state, false, 1.0);
157 assert!(sim.frame.hollow);
158 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
159 pet_widget::PetWidget {
160 sim: &sim,
161 state: &state,
162 }
163 .render(buf.area, &mut buf);
164 let text: String = buf.content.iter().map(|cell| cell.symbol()).collect();
165 assert!(text.contains("other · drift · unobserved"));
166 let mut narrow = Buffer::empty(Rect::new(0, 0, 18, 6));
167 let before = narrow.clone();
168 pet_widget::PetWidget {
169 sim: &sim,
170 state: &state,
171 }
172 .render(narrow.area, &mut narrow);
173 assert_eq!(narrow, before, "never truncate away the uncertainty cue");
174 }
175
176 #[test]
177 fn ambient_min_dimensions_allow_small_windows() {
178 const {
179 assert!(AMBIENT_MIN_WIDTH < 68);
180 assert!(AMBIENT_MIN_HEIGHT < 15);
181 }
182 }
183
184 #[test]
185 fn occupied_text_bounds_skips_string_join() {
186 let line = Line::from(vec![Span::raw(" hello "), Span::raw("world ")]);
187 let (start, end) = occupied_text_bounds(&line).expect("bounds");
188 assert_eq!(start, 2);
189 assert!(end > start);
190 }
191
192 #[test]
193 fn density_scales_with_area() {
194 assert_eq!(
195 LifeDensity::from_area(Rect::new(0, 0, 40, 10)),
196 LifeDensity::Sparse
197 );
198 assert_eq!(
199 LifeDensity::from_area(Rect::new(0, 0, 100, 30)),
200 LifeDensity::Rich
201 );
202 }
203
204 #[test]
205 fn fish_school_uses_one_silhouette_family() {
206 // Never mix lone `>` with full fish bodies; the lead just gains an
207 // eye within the same family.
208 assert_eq!(fish_body(true, false), "><>");
209 assert_eq!(fish_body(false, false), "<><");
210 assert_eq!(fish_body(true, true), "><o>");
211 assert_eq!(fish_body(false, true), "<o><");
212 }
213
214 fn frame_at(t: u128) -> FrameMarks {
215 let area = Rect::new(0, 0, 100, 30);
216 let mut stats = AmbientFrameStats::default();
217 build_frame_marks(
218 area,
219 t,
220 LifeDensity::from_area(area),
221 &[],
222 AmbientCursor::default(),
223 true,
224 &mut stats,
225 )
226 }
227
228 #[test]
229 fn subagent_activity_surfaces_a_whale_pod() {
230 let whale = WhaleCameo {
231 elapsed_ms: Some(500), // Spout phase
232 anchor_x: 50,
233 anchor_y: 20,
234 };
235 let render = |activity: AmbientActivity| -> Vec<String> {
236 let mut buf = Buffer::empty(Rect::new(0, 0, 100, 30));
237 render_ambient_life(
238 buf.area,
239 &mut buf,
240 (Color::Cyan, Color::Blue),
241 &[],
242 12_000,
243 1.0,
244 AmbientCursor::default(),
245 whale,
246 activity,
247 );
248 buf.content
249 .iter()
250 .filter(|cell| {
251 cell.symbol()
252 .chars()
253 .any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch))
254 })
255 .map(|cell| cell.symbol().to_string())
256 .collect()
257 };
258 let baseline = render(AmbientActivity::Baseline);
259 let pod = render(AmbientActivity::Subagents);
260 assert!(
261 pod.len() > baseline.len(),
262 "a subagent pod must surface more whale marks ({}) than the single cameo ({})",
263 pod.len(),
264 baseline.len(),
265 );
266 }
267
268 #[test]
269 fn activity_changes_never_resample_creature_geometry() {
270 // Regression: scaling absolute clock age moved the school by tens of
271 // columns at a Reasoning → Tools transition. Same clock means same pose;
272 // activity still owns ink and the explicitly triggered completion cameo.
273 for elapsed in [0, 60_000, 600_000, 3_600_000] {
274 let render = |activity| {
275 let mut buf = Buffer::empty(Rect::new(0, 0, 100, 30));
276 render_ambient_life(
277 buf.area,
278 &mut buf,
279 (Color::Cyan, Color::Blue),
280 &[],
281 elapsed,
282 1.0,
283 AmbientCursor::default(),
284 WhaleCameo::default(),
285 activity,
286 );
287 buf
288 };
289 let baseline = render(AmbientActivity::Baseline);
290 for activity in [
291 AmbientActivity::Reasoning,
292 AmbientActivity::Reading,
293 AmbientActivity::Tools,
294 AmbientActivity::Subagents,
295 AmbientActivity::Verifying,
296 ] {
297 assert_eq!(
298 baseline,
299 render(activity),
300 "geometry changed at {elapsed}ms: {activity:?}"
301 );
302 }
303 }
304 }
305
306 #[test]
307 fn fish_always_swim_the_way_they_face() {
308 // Wrap-around construction: within one crossing, a right-facing
309 // school only ever moves right, and a left-facing school only left.
310 let area_travel = 100u128 + 21; // width + wedge span (see constants)
311 let cycle_ms = area_travel * SCHOOL_CELL_MS;
312 for cycle in 0u128..6 {
313 // The school clock carries a half-cycle head start, so sampling
314 // at cycle*cycle_ms lands mid-crossing of `cycle`.
315 let t1 = cycle * cycle_ms;
316 let t2 = t1 + SCHOOL_CELL_MS * 3;
317 let lead = |t: u128| {
318 frame_at(t)
319 .marks
320 .into_iter()
321 .find(|mark| mark.glyph.contains('o'))
322 };
323 let (Some(a), Some(b)) = (lead(t1), lead(t2)) else {
324 continue; // school off-screen at this sample — fine
325 };
326 let expect_right = school_swims_right(cycle);
327 if expect_right {
328 assert_eq!(a.glyph, "><o>", "cycle {cycle} facing");
329 assert!(b.x >= a.x, "cycle {cycle}: right-facing fish moved left");
330 } else {
331 assert_eq!(a.glyph, "<o><", "cycle {cycle} facing");
332 assert!(b.x <= a.x, "cycle {cycle}: left-facing fish moved right");
333 }
334 }
335 // Both directions must actually occur across nearby cycles.
336 let dirs: Vec<bool> = (0u128..12).map(school_swims_right).collect();
337 assert!(
338 dirs.iter().any(|d| *d) && dirs.iter().any(|d| !*d),
339 "{dirs:?}"
340 );
341 }
342
343 #[test]
344 fn water_holds_only_fish_bubbles_and_jellyfish() {
345 // Seaweed and bio-dust are gone (2026-07-23): every mark is a fish
346 // body, a bubble glyph, or a jellyfish part.
347 for t in [0u128, 7_500, 33_000, 61_000, 120_000] {
348 for mark in frame_at(t).marks {
349 let ok = matches!(mark.glyph, "><>" | "<><" | "><o>" | "<o><")
350 || matches!(mark.glyph, "·" | "˚" | "°")
351 || JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)
352 || JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph)
353 || JELLY_DOME_TOP_COMPACT.contains(&mark.glyph)
354 || JELLY_DOME_SKIRT_COMPACT.contains(&mark.glyph)
355 || JELLY_TENTACLE_FRAMES.contains(&mark.glyph);
356 assert!(ok, "unexpected ambient glyph {:?} at t={t}", mark.glyph);
357 }
358 }
359 }
360
361 #[test]
362 fn ambient_glyphs_are_ascii_or_have_fallbacks() {
363 // Every glyph the water can paint is either pure ASCII (fish and
364 // the whole jellyfish silhouette, by construction) or carries a
365 // glyphs::ascii_fallback entry (bubbles, whale cameo) so
366 // CODEWHALE_ASCII_SAFE=1 covers the whole field.
367 let mut jellyfish: Vec<&str> = Vec::new();
368 jellyfish.extend(JELLY_DOME_TOP_FRAMES);
369 jellyfish.extend(JELLY_DOME_SKIRT_FRAMES);
370 jellyfish.extend(JELLY_DOME_TOP_COMPACT);
371 jellyfish.extend(JELLY_DOME_SKIRT_COMPACT);
372 jellyfish.extend(JELLY_TENTACLE_FRAMES);
373 for glyph in jellyfish {
374 assert!(glyph.is_ascii(), "jellyfish glyph {glyph:?} must be ASCII");
375 }
376 for glyph in ["><>", "<><", "><o>", "<o><"] {
377 assert!(glyph.is_ascii(), "fish glyph {glyph:?} must be ASCII");
378 }
379 for glyph in ["·", "˚", "°", "≈≈>", "≈", "~"] {
380 assert!(
381 glyph.is_ascii() || crate::tui::glyphs::ascii_fallback(glyph).is_some(),
382 "ambient glyph {glyph:?} lacks an ASCII fallback"
383 );
384 }
385 }
386
387 #[test]
388 fn jellyfish_reads_as_dome_with_lagging_tentacles() {
389 // Find a frame where a full jelly is on-screen and assert its
390 // structure: a two-row dome at least four cells wide, exactly two
391 // tentacle columns one row below the skirt, and both dome and
392 // tentacles holding their brightness floors.
393 let mut seen = false;
394 for probe in 0..240u128 {
395 let t = probe * 500;
396 let frame = frame_at(t);
397 let Some(top) = frame
398 .marks
399 .iter()
400 .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
401 else {
402 continue;
403 };
404 let dome_w = UnicodeWidthStr::width(top.glyph) as u16;
405 assert!(dome_w >= 4, "rich dome too narrow: {:?}", top.glyph);
406 let Some(skirt) = frame.marks.iter().find(|mark| {
407 JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) && mark.x == top.x && mark.y == top.y + 1
408 }) else {
409 panic!("visible jellyfish dome lost its skirt: {frame:?}");
410 };
411 let tentacles: Vec<&AmbientMark> = frame
412 .marks
413 .iter()
414 .filter(|mark| {
415 JELLY_TENTACLE_FRAMES.contains(&mark.glyph)
416 && mark.y == skirt.y + 1
417 && mark.x > top.x
418 && mark.x < top.x + dome_w
419 })
420 .collect();
421 assert_eq!(
422 tentacles.len(),
423 JELLY_TENTACLE_COLUMNS,
424 "visible jellyfish must keep all tentacles: {frame:?}"
425 );
426 let dome_glow = top.brightness.expect("dome pulses");
427 assert!(dome_glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON);
428 for tentacle in &tentacles {
429 let glow = tentacle.brightness.expect("tentacle pulses");
430 assert!(glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON);
431 }
432 seen = true;
433 break;
434 }
435 assert!(seen, "no complete jellyfish found in 120s of frames");
436 }
437
438 #[test]
439 fn jellyfish_tentacles_sway_out_of_phase_and_dome_pulses() {
440 // Over a sweep: the dome shows both pulse frames within a few
441 // JELLY_PULSE_MS periods, each tentacle column cycles its sway
442 // frames, and the trio is not always in lockstep (per-column phase
443 // offset — the lag is what sells "jellyfish").
444 let mut dome_frames = std::collections::BTreeSet::new();
445 let mut column_frames: [std::collections::BTreeSet<&str>; JELLY_TENTACLE_COLUMNS] =
446 Default::default();
447 let mut saw_desync = false;
448 for probe in 0..480u128 {
449 let frame = frame_at(probe * 100);
450 let Some(top) = frame
451 .marks
452 .iter()
453 .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
454 else {
455 continue;
456 };
457 dome_frames.insert(top.glyph);
458 let mut pair = [""; JELLY_TENTACLE_COLUMNS];
459 let mut found = 0usize;
460 for (col, slot) in pair.iter_mut().enumerate() {
461 if let Some(tentacle) = frame.marks.iter().find(|mark| {
462 JELLY_TENTACLE_FRAMES.contains(&mark.glyph)
463 && mark.y == top.y + 2
464 && mark.x == top.x + 1 + 2 * col as u16
465 }) {
466 *slot = tentacle.glyph;
467 column_frames[col].insert(tentacle.glyph);
468 found += 1;
469 }
470 }
471 if found == JELLY_TENTACLE_COLUMNS && pair.iter().any(|glyph| *glyph != pair[0]) {
472 saw_desync = true;
473 }
474 }
475 assert_eq!(
476 dome_frames.len(),
477 JELLY_DOME_TOP_FRAMES.len(),
478 "dome pulse should show every frame: {dome_frames:?}"
479 );
480 for (col, set) in column_frames.iter().enumerate() {
481 assert!(set.len() > 1, "tentacle column {col} never swayed");
482 }
483 assert!(saw_desync, "tentacle columns strobed in lockstep");
484 }
485
486 /// Tentacle columns hanging from the full-size bell. Named so the structural
487 /// assertions read as one decision rather than a repeated magic number.
488 const JELLY_TENTACLE_COLUMNS: usize = 2;
489
490 #[test]
491 fn short_transcript_rows_leave_a_safe_ocean_corridor() {
492 // The active transcript commonly puts short status/tool lines on every
493 // visible row. They own their cells and a one-column gutter, not the whole
494 // terminal width: fish and the t=0 jellyfish remain eligible in the clear
495 // right-hand water without touching a single transcript cell.
496 let area = Rect::new(0, 0, 80, 24);
497 let lines: Vec<Line<'static>> = (0..usize::from(area.height))
498 .map(|row| Line::from(Span::raw(format!("status row {row:02}"))))
499 .collect();
500 let mut buf = Buffer::empty(area);
501 for (row, line) in lines.iter().enumerate() {
502 buf.set_line(area.x, area.y + row as u16, line, area.width);
503 }
504
505 let mut stats = AmbientFrameStats::default();
506 let frame = build_frame_marks(
507 area,
508 0,
509 LifeDensity::from_area(area),
510 &lines,
511 AmbientCursor::default(),
512 true,
513 &mut stats,
514 );
515 paint_marks(
516 area,
517 &mut buf,
518 (Color::Cyan, Color::Blue),
519 &lines,
520 &frame,
521 1.0,
522 &mut stats,
523 );
524 let rendered: String = (0..area.height)
525 .map(|row| {
526 (0..area.width)
527 .map(|column| buf[(column, row)].symbol())
528 .collect::<String>()
529 })
530 .collect::<Vec<_>>()
531 .join("\n");
532 assert!(
533 rendered.contains("><") || rendered.contains("<o"),
534 "short active rows should leave room for fish:\n{rendered}"
535 );
536 assert!(
537 rendered.contains(JELLY_DOME_TOP_FRAMES[0]) || rendered.contains(JELLY_DOME_TOP_FRAMES[1]),
538 "the right corridor should keep the rare visitor eligible at t=0:\n{rendered}"
539 );
540 assert!(stats.marks_painted > 0, "{stats:?}");
541 for (row, line) in lines.iter().enumerate() {
542 let text = line.spans[0].content.as_ref();
543 for (column, ch) in text.chars().enumerate() {
544 assert_eq!(
545 buf[(column as u16, row as u16)].symbol(),
546 ch.to_string(),
547 "ambient life overwrote transcript cell ({column},{row})"
548 );
549 }
550 }
551 }
552
553 #[test]
554 fn a_jellyfish_needs_water_deep_enough_to_hold_it_and_the_school() {
555 // Deep-water budget: with the composition sitting where the 80x24 idle
556 // screen puts it there are four rows of water left, which is the school's
557 // band and nothing else. The jellyfish does not come up into it.
558 let area = Rect::new(0, 0, 80, 18);
559 let mut lines: Vec<Line<'static>> = vec![Line::default(); usize::from(area.height)];
560 for row in [9usize, 10, 12] {
561 lines[row] = Line::from(Span::raw("X".repeat(75)));
562 }
563 let jelly_lane = area.width.saturating_mul(5) / 6;
564 assert!(deep_water_rows(area, &lines, jelly_lane, 5) < JELLY_MIN_DEEP_ROWS);
565 let mut saw_fish = false;
566 for probe in 0..600u128 {
567 let mut stats = AmbientFrameStats::default();
568 let frame = build_frame_marks(
569 area,
570 probe * 250,
571 LifeDensity::from_area(area),
572 &lines,
573 AmbientCursor::default(),
574 true,
575 &mut stats,
576 );
577 for mark in &frame.marks {
578 assert!(
579 !JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)
580 && !JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph)
581 && !JELLY_TENTACLE_FRAMES.contains(&mark.glyph),
582 "shallow water surfaced a jellyfish: {mark:?}"
583 );
584 saw_fish |= mark.glyph.contains("><");
585 }
586 }
587 assert!(saw_fish, "the school should still hold its band");
588 }
589
590 #[test]
591 fn bubbles_grow_as_they_rise_and_dissolve_before_the_top() {
592 // Size and brightness are functions of height risen, not of the clock:
593 // the old table swapped glyph every 320 ms in place, which is a flicker
594 // rather than a rise, and the old clamp parked a spent bubble as an
595 // unattached speck at the top of the field.
596 assert_eq!(bubble_glyph(0), "·");
597 assert_eq!(bubble_glyph(BUBBLE_MAX_RISE_ROWS), "°");
598 assert!(bubble_dissolve(0) > bubble_dissolve(BUBBLE_MAX_RISE_ROWS));
599 assert!((bubble_dissolve(0) - 1.0).abs() < f32::EPSILON);
600 assert!(bubble_dissolve(BUBBLE_MAX_RISE_ROWS) <= BUBBLE_DISSOLVE_CEIL + f32::EPSILON);
601 }
602
603 #[test]
604 fn sparse_water_gets_a_compact_jellyfish() {
605 // Narrow fallback: the Sparse tier swaps the full 5-cell dome for a
606 // 3-cell one with two tentacles — a different silhouette, not just
607 // fewer jellies.
608 let area = Rect::new(0, 0, 48, 12);
609 let mut saw_compact = false;
610 let mut saw_full = false;
611 let mut saw_two_tentacles = false;
612 for probe in 0..240u128 {
613 let mut stats = AmbientFrameStats::default();
614 let frame = build_frame_marks(
615 area,
616 probe * 500,
617 LifeDensity::from_area(area),
618 &[],
619 AmbientCursor::default(),
620 true,
621 &mut stats,
622 );
623 for mark in &frame.marks {
624 saw_compact |= JELLY_DOME_TOP_COMPACT.contains(&mark.glyph);
625 saw_full |= JELLY_DOME_TOP_FRAMES.contains(&mark.glyph);
626 }
627 let Some(top) = frame
628 .marks
629 .iter()
630 .find(|mark| JELLY_DOME_TOP_COMPACT.contains(&mark.glyph))
631 else {
632 continue;
633 };
634 let tentacles = frame
635 .marks
636 .iter()
637 .filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2)
638 .count();
639 assert_eq!(
640 tentacles, 2,
641 "visible compact jelly must keep both tentacles: {frame:?}"
642 );
643 saw_two_tentacles = true;
644 }
645 assert!(saw_compact, "sparse water never showed the compact dome");
646 assert!(!saw_full, "sparse water used the full-size dome");
647 assert!(saw_two_tentacles, "compact jelly lost its tentacles");
648 }
649
650 #[test]
651 fn animated_tentacle_frames_never_collapse_to_punctuation_dots() {
652 assert!(
653 JELLY_TENTACLE_FRAMES
654 .iter()
655 .all(|glyph| matches!(*glyph, "|" | "/" | "\\")),
656 "every animation frame must retain a legible tentacle stroke"
657 );
658 }
659
660 #[test]
661 fn motion_is_a_deterministic_function_of_elapsed_time() {
662 // v0.9.4: positions always ride the monotonic clock (presence fades the
663 // marks in/out instead of parking them), so a fixed t must produce the
664 // exact same complete jellyfish: dome, skirt, and tentacles visible.
665 let area = Rect::new(0, 0, 100, 30);
666 let build = || {
667 let mut stats = AmbientFrameStats::default();
668 build_frame_marks(
669 area,
670 0,
671 LifeDensity::from_area(area),
672 &[],
673 AmbientCursor::default(),
674 true,
675 &mut stats,
676 )
677 };
678 let first = build();
679 let second = build();
680 let pose = |frame: &FrameMarks| {
681 frame
682 .marks
683 .iter()
684 .map(|mark| (mark.glyph, mark.x, mark.y))
685 .collect::<Vec<_>>()
686 };
687 assert_eq!(
688 pose(&first),
689 pose(&second),
690 "motion must be a pure function of t"
691 );
692 let top = first
693 .marks
694 .iter()
695 .find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
696 .expect("jellyfish dome at t=0");
697 assert!(
698 first
699 .marks
700 .iter()
701 .any(|mark| { JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) && mark.y == top.y + 1 }),
702 "jellyfish lost its skirt at t=0"
703 );
704 let tentacles = first
705 .marks
706 .iter()
707 .filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2)
708 .count();
709 assert!(
710 tentacles >= JELLY_TENTACLE_COLUMNS,
711 "jellyfish lost its tentacles at t=0"
712 );
713 }
714
715 #[test]
716 fn glow_helpers_stay_bounded_with_floors() {
717 for t in (0u128..12_000).step_by(97) {
718 let w = wave01(t, FISH_WAVE_MS, 0);
719 assert!((0.0..=1.0).contains(&w), "wave01 out of range: {w}");
720 let g = glint01(t, 2_600, 600, BUBBLE_BRIGHTNESS_FLOOR, 0);
721 assert!(
722 (BUBBLE_BRIGHTNESS_FLOOR..=1.0).contains(&g),
723 "glint01 lost its floor: {g}"
724 );
725 }
726 }
727
728 #[test]
729 fn frame_stats_account_for_every_mark() {
730 let area = Rect::new(0, 0, 100, 30);
731 let mut buf = Buffer::empty(area);
732 let stats = render_ambient_life(
733 area,
734 &mut buf,
735 (Color::Cyan, Color::Blue),
736 &[],
737 12_000,
738 1.0,
739 AmbientCursor::default(),
740 WhaleCameo::default(),
741 AmbientActivity::Baseline,
742 );
743 assert_eq!(
744 stats.marks_built,
745 stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
746 "every built mark is painted, text-skipped, or clipped: {stats:?}"
747 );
748 assert!(stats.marks_painted > 0, "empty water should paint life");
749 // cells_written counts each cell of a multi-cell glyph (and counts
750 // a shared cell once per overlapping mark), so the honest bound is
751 // per painted mark — at most the widest glyph (the 5-cell dome) —
752 // rather than per area cell.
753 assert!(stats.cells_written >= stats.marks_painted);
754 assert!(
755 stats.cells_written <= stats.marks_painted * 5,
756 "cells_written out of proportion: {stats:?}"
757 );
758 }
759
760 #[test]
761 fn frame_stats_stay_within_the_render_budget() {
762 // Largest Rich field with three bounded dot-whale widgets. Every
763 // visible dot cell and label character participates in the same budget.
764 let area = Rect::new(0, 0, 160, 40);
765 let mut buf = Buffer::empty(area);
766 let whale = WhaleCameo {
767 elapsed_ms: Some(500), // Spout: cameo glyph plus spray
768 anchor_x: 80,
769 anchor_y: 26,
770 };
771 let stats = render_ambient_life(
772 area,
773 &mut buf,
774 (Color::Cyan, Color::Blue),
775 &[],
776 12_000,
777 1.0,
778 AmbientCursor::default(),
779 whale,
780 AmbientActivity::Subagents,
781 );
782 assert!(
783 stats.marks_built <= MAX_FRAME_MARKS,
784 "frame budget blown: {stats:?}"
785 );
786 assert_eq!(
787 stats.marks_built,
788 stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
789 "every built mark is accounted for: {stats:?}"
790 );
791 }
792
793 #[test]
794 fn frame_stats_never_overwrite_text() {
795 // Property: on a text-covered field, colliding marks are charged to
796 // marks_skipped_text and no transcript cell is overwritten.
797 //
798 // Two stages guard this and they answer different questions. The build
799 // stage keeps ambient life out of the composition's rows entirely
800 // (`is_open_water`) — a design decision about where the aquarium lives.
801 // The paint stage refuses to write over text whatever the build stage
802 // decided — a correctness backstop, and the one this test exercises,
803 // using the whale cameo: the cameo is an event fired at a deliberate
804 // anchor, so it is the one mark that is allowed to aim at occupied water
805 // and must therefore be withheld here rather than painted.
806 let area = Rect::new(0, 0, 100, 30);
807 let mut buf = Buffer::empty(area);
808 let lines: Vec<Line<'static>> = (0..usize::from(area.height))
809 .map(|_| Line::from(Span::raw("X".repeat(usize::from(area.width)))))
810 .collect();
811 for (i, line) in lines.iter().enumerate() {
812 buf.set_line(area.x, area.y + i as u16, line, area.width);
813 }
814 let stats = render_ambient_life(
815 area,
816 &mut buf,
817 (Color::Cyan, Color::Blue),
818 &lines,
819 12_000,
820 1.0,
821 AmbientCursor::default(),
822 WhaleCameo {
823 elapsed_ms: Some(500),
824 anchor_x: 10,
825 anchor_y: 20,
826 },
827 AmbientActivity::Baseline,
828 );
829 assert!(
830 stats.marks_skipped_text > 0,
831 "full-width text should skip colliding marks: {stats:?}"
832 );
833 assert_eq!(
834 stats.marks_painted, 0,
835 "a fully composed field is not water: {stats:?}"
836 );
837 assert_eq!(
838 stats.marks_built,
839 stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
840 "every built mark is accounted for: {stats:?}"
841 );
842 for (i, line) in lines.iter().enumerate() {
843 let text: String = line
844 .spans
845 .iter()
846 .map(|span| span.content.as_ref())
847 .collect();
848 for (x, ch) in text.chars().enumerate() {
849 assert_eq!(
850 buf[(area.x + x as u16, area.y + i as u16)].symbol(),
851 ch.to_string(),
852 "ambient life overwrote transcript cell ({x},{i})"
853 );
854 }
855 }
856 }
857
858 fn full_jellyfish_frame(x: u16, y: u16) -> FrameMarks {
859 let mark = |x, y, glyph, depth| AmbientMark {
860 x,
861 y,
862 glyph,
863 jellyfish: Some(0),
864 depth,
865 style_mod: None,
866 brightness: None,
867 };
868 FrameMarks {
869 marks: vec![
870 mark(x, y, JELLY_DOME_TOP_FRAMES[0], Depth::Midground),
871 mark(x, y + 1, JELLY_DOME_SKIRT_FRAMES[0], Depth::Midground),
872 mark(x + 1, y + 2, "|", Depth::Background),
873 mark(x + 2, y + 2, "/", Depth::Background),
874 mark(x + 3, y + 2, "\\", Depth::Background),
875 ],
876 }
877 }
878
879 fn paint_fixture(
880 area: Rect,
881 lines: &[Line<'static>],
882 frame: &FrameMarks,
883 ) -> (Buffer, AmbientFrameStats) {
884 let mut buf = Buffer::empty(area);
885 for (row, line) in lines.iter().enumerate() {
886 buf.set_line(area.x, area.y + row as u16, line, area.width);
887 }
888 let mut stats = AmbientFrameStats {
889 marks_built: frame.marks.len() as u32,
890 ..AmbientFrameStats::default()
891 };
892 paint_marks(
893 area,
894 &mut buf,
895 (Color::Cyan, Color::Blue),
896 lines,
897 frame,
898 1.0,
899 &mut stats,
900 );
901 (buf, stats)
902 }
903
904 #[test]
905 fn jellyfish_relocates_as_one_nearest_silhouette() {
906 let area = Rect::new(0, 0, 24, 8);
907 let mut lines = vec![Line::default(); usize::from(area.height)];
908 lines[2] = Line::from(Span::raw(" X"));
909 let (buf, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(10, 2));
910
911 assert_eq!(buf[(10, 2)].symbol(), "X");
912 assert_eq!(buf[(12, 2)].symbol(), ".");
913 assert_eq!(buf[(12, 3)].symbol(), "\\");
914 assert_eq!(buf[(13, 4)].symbol(), "|");
915 assert_eq!(buf[(14, 4)].symbol(), "/");
916 assert_eq!(buf[(15, 4)].symbol(), "\\");
917 assert_eq!(stats.marks_painted, 5);
918 assert_eq!(stats.marks_skipped_text, 0);
919 assert_eq!(stats.marks_clipped, 0);
920 assert_eq!(stats.cells_written, 13);
921 }
922
923 #[test]
924 fn jellyfish_hides_rather_than_vaulting_past_a_long_line() {
925 // This used to place the silhouette at x=33 — a 17-column vault from its
926 // lane, chosen purely from the width of the text on that row. Under a
927 // fast stream that width changes every frame, so the vault was the
928 // teleport users saw. Past the dodge cap the jelly is withheld instead,
929 // which is the same quiet outcome the fish already have.
930 let area = Rect::new(0, 0, 100, 8);
931 let mut lines = vec![Line::default(); usize::from(area.height)];
932 lines[2] = Line::from(Span::raw("X".repeat(32)));
933 let (_, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(16, 2));
934
935 assert_eq!(stats.marks_painted, 0);
936 assert_eq!(stats.marks_skipped_text, 5);
937 assert_eq!(stats.marks_clipped, 0);
938 }
939
940 #[test]
941 fn jellyfish_never_teleports_while_a_line_streams_across_its_lane() {
942 // Regression for the DeepSeek-V4-Flash report: replay a line growing
943 // straight through the silhouette's lane and assert the painted anchor
944 // never jumps. The clock is held fixed, so the only moving input is the
945 // transcript text — the coupling that actually scaled with token
946 // throughput.
947 //
948 // The burst size is the whole point. A slow provider adds about a
949 // character per frame and the old unbounded dodge slid along with it,
950 // which is why this never looked broken on slow models. A fast one adds a
951 // burst per frame, and the dodge moved by the whole burst at once.
952 const STREAM_BURST_CHARS: usize = 9;
953 let area = Rect::new(0, 0, 100, 8);
954 let lane = 40u16;
955 let mut previous: Option<u16> = None;
956 for chars in (0..80usize).step_by(STREAM_BURST_CHARS) {
957 let mut lines = vec![Line::default(); usize::from(area.height)];
958 lines[2] = Line::from(Span::raw("X".repeat(chars)));
959 lines[3] = Line::from(Span::raw("X".repeat(chars.saturating_sub(3))));
960 let (buf, _) = paint_fixture(area, &lines, &full_jellyfish_frame(lane, 2));
961 let anchor = (0..area.width).find(|x| buf[(*x, 2)].symbol() == ".");
962 if let (Some(anchor), Some(previous)) = (anchor, previous) {
963 assert!(
964 anchor.abs_diff(previous) <= 2 * JELLY_MAX_TEXT_DODGE_COLS,
965 "jellyfish teleported {} columns at {chars} streamed chars",
966 anchor.abs_diff(previous)
967 );
968 }
969 if let Some(anchor) = anchor {
970 assert!(
971 anchor.abs_diff(lane) <= JELLY_MAX_TEXT_DODGE_COLS,
972 "jellyfish left its lane by {} columns",
973 anchor.abs_diff(lane)
974 );
975 }
976 previous = anchor;
977 }
978 }
979
980 #[test]
981 fn sparse_jellyfish_row_keeps_a_safe_gap_over_text() {
982 let area = Rect::new(0, 0, 12, 6);
983 let mut lines = vec![Line::default(); usize::from(area.height)];
984 lines[2] = Line::from(Span::raw(" X"));
985 let mark = |x| AmbientMark {
986 x,
987 y: 2,
988 glyph: "|",
989 jellyfish: Some(0),
990 depth: Depth::Background,
991 style_mod: None,
992 brightness: None,
993 };
994 let frame = FrameMarks {
995 marks: vec![mark(0), mark(6)],
996 };
997 let (buf, stats) = paint_fixture(area, &lines, &frame);
998
999 assert_eq!(buf[(0, 2)].symbol(), "|");
1000 assert_eq!(buf[(3, 2)].symbol(), "X");
1001 assert_eq!(buf[(6, 2)].symbol(), "|");
1002 assert_eq!(stats.marks_painted, 2);
1003 }
1004
1005 #[test]
1006 fn fully_blocked_jellyfish_is_suppressed_and_accounted() {
1007 let area = Rect::new(0, 0, 24, 8);
1008 let mut lines = vec![Line::default(); usize::from(area.height)];
1009 for line in lines.iter_mut().take(5).skip(2) {
1010 *line = Line::from(Span::raw("X".repeat(24)));
1011 }
1012 let (buf, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(10, 2));
1013
1014 assert_eq!(stats.marks_painted, 0);
1015 assert_eq!(stats.marks_skipped_text, 5);
1016 assert_eq!(stats.marks_clipped, 0);
1017 assert_eq!(stats.cells_written, 0);
1018 assert_eq!(stats.marks_built, stats.marks_skipped_text);
1019 for row in 2..=4 {
1020 for column in 0..area.width {
1021 assert_eq!(buf[(column, row)].symbol(), "X");
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn too_wide_jellyfish_is_truthfully_clipped_as_a_group() {
1028 let area = Rect::new(0, 0, 4, 8);
1029 let lines = vec![Line::default(); usize::from(area.height)];
1030 let (_, stats) = paint_fixture(area, &lines, &full_jellyfish_frame(0, 2));
1031
1032 assert_eq!(stats.marks_painted, 0);
1033 assert_eq!(stats.marks_skipped_text, 0);
1034 assert_eq!(stats.marks_clipped, 5);
1035 assert_eq!(stats.marks_built, stats.marks_clipped);
1036 }
1037
1038 #[test]
1039 fn caustic_brightness_cross_fades_without_80ms_steps() {
1040 let samples: Vec<f32> = (0..=120)
1041 .map(|frame| caustic_brightness(frame * 16, 18, 2, 1.0))
1042 .collect();
1043
1044 assert!(samples.iter().all(|value| (1.0..=1.08).contains(value)));
1045 assert!(
1046 samples
1047 .windows(2)
1048 .all(|pair| (pair[1] - pair[0]).abs() < 0.02),
1049 "adjacent 60 FPS caustic frames must cross-fade instead of toggling"
1050 );
1051 assert!(
1052 samples.windows(2).any(|pair| pair[0] != pair[1]),
1053 "the continuous caustic must still move"
1054 );
1055 assert_eq!(
1056 caustic_brightness(0, 0, 0, 1.0),
1057 caustic_brightness(0, 12, 0, 1.0),
1058 "the authored caustic topology repeats every 12 columns"
1059 );
1060 assert_eq!(samples.first(), samples.get(60), "960 ms closes one cycle");
1061
1062 let base = Color::Rgb(64, 96, 128);
1063 let colors: Vec<Color> = (0..=60)
1064 .map(|frame| ocean::scale_color(base, caustic_brightness(frame * 16, 18, 2, 1.0)))
1065 .collect();
1066 assert!(
1067 colors.windows(2).any(|pair| pair[0] != pair[1]),
1068 "the cross-fade must survive truecolor channel quantization"
1069 );
1070 assert_eq!(colors.first(), colors.last(), "RGB output closes one cycle");
1071
1072 // Depth fade: the light dissolves to nothing at the band floor instead of
1073 // stopping at full amplitude — the hard cutoff drew a visible horizontal
1074 // line across tall windows.
1075 for frame in 0..=60u128 {
1076 assert_eq!(
1077 caustic_brightness(frame * 16, 18, 2, 0.0),
1078 1.0,
1079 "a fully faded caustic must leave the water untouched"
1080 );
1081 }
1082 }
1083
1084 fn native_frame_at(area: Rect, elapsed_ms: u128) -> FrameMarks {
1085 build_frame_marks(
1086 area,
1087 elapsed_ms,
1088 LifeDensity::from_area(area),
1089 &[],
1090 AmbientCursor::default(),
1091 false,
1092 &mut AmbientFrameStats::default(),
1093 )
1094 }
1095
1096 #[test]
1097 fn native_pose_tables_have_fixed_cell_bounds_and_safe_glyphs() {
1098 for right in [false, true] {
1099 for pose in 0..4 {
1100 for dx in 0..2 {
1101 for dy in 0..2 {
1102 let row = native_poses::fish(right, pose, dx, dy);
1103 assert_eq!(row.width(), 4);
1104 assert!(row.chars().all(
1105 |c| c == ' ' || crate::tui::glyphs::braille_ascii_fallback(c).is_some()
1106 ));
1107 }
1108 }
1109 }
1110 }
1111 for pose in 0..16 {
1112 for dx in 0..2 {
1113 for dy in 0..4 {
1114 let rows = native_poses::jelly(pose, dx, dy);
1115 assert_eq!(rows.len(), 3);
1116 for row in rows {
1117 assert_eq!(row.width(), 5);
1118 assert!(row.chars().all(
1119 |c| c == ' ' || crate::tui::glyphs::braille_ascii_fallback(c).is_some()
1120 ));
1121 }
1122 assert!(rows.iter().all(|r| r.chars().count() == 5));
1123 }
1124 }
1125 }
1126 }
1127
1128 #[test]
1129 fn native_fish_move_within_the_old_whole_cell_dwell() {
1130 let area = Rect::new(0, 0, 100, 30);
1131 let a = native_frame_at(area, 190);
1132 let b = native_frame_at(area, 380);
1133 let (a, b) = (&a.marks[0], &b.marks[0]);
1134 assert_eq!(
1135 (a.x, a.y),
1136 (b.x, b.y),
1137 "both samples share the same cell anchor"
1138 );
1139 assert_ne!(
1140 a.glyph, b.glyph,
1141 "half-cell travel must change the raster within a cell"
1142 );
1143 }
1144
1145 #[test]
1146 fn native_motion_sampling_is_independent_of_cadence_and_history() {
1147 let area = Rect::new(0, 0, 100, 30);
1148 let pose = |t| {
1149 native_frame_at(area, t)
1150 .marks
1151 .iter()
1152 .map(|mark| (mark.x, mark.y, mark.glyph))
1153 .collect::<Vec<_>>()
1154 };
1155 // Deliberately skip, reverse and change the draw cadence before landing
1156 // on shared timestamps. A per-paint simulation would make these differ.
1157 for target in [500, 1_000, 5_200, 9_400, 56_400, 300_800] {
1158 let expected = pose(target);
1159 for step in [16, 33, 117] {
1160 for t in (0..target as usize).step_by(step) {
1161 let _ = pose(t as u128);
1162 }
1163 assert_eq!(expected, pose(target));
1164 }
1165 let _ = pose(target + 10_000);
1166 assert_eq!(expected, pose(target));
1167 }
1168 }
1169
1170 #[test]
1171 fn native_temporal_sweep_retains_population_and_paint_bounds() {
1172 for area in [
1173 Rect::new(0, 0, 40, 12),
1174 Rect::new(0, 0, 80, 24),
1175 Rect::new(0, 0, 140, 40),
1176 ] {
1177 // Cover rise, departure, long absence and next arrival, not just t=0.
1178 let mut saw_jelly = false;
1179 let mut saw_absence = false;
1180 for t in (0..310_000).step_by(250) {
1181 let frame = native_frame_at(area, t);
1182 let count = frame.marks.iter().filter(|m| m.jellyfish.is_some()).count();
1183 assert!(
1184 count == 0 || count == 3,
1185 "native jelly must be one complete group at {t}"
1186 );
1187 saw_jelly |= count == 3;
1188 saw_absence |= count == 0;
1189 assert!(frame.marks.len() <= 13);
1190 let mut buf = Buffer::empty(area);
1191 let mut stats = AmbientFrameStats {
1192 marks_built: frame.marks.len() as u32,
1193 ..Default::default()
1194 };
1195 paint_marks(
1196 area,
1197 &mut buf,
1198 (Color::Cyan, Color::Blue),
1199 &[],
1200 &frame,
1201 1.0,
1202 &mut stats,
1203 );
1204 assert_eq!(
1205 stats.marks_built,
1206 stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped
1207 );
1208 assert!(stats.cells_written <= stats.marks_painted * 5);
1209 }
1210 assert!(
1211 saw_jelly && saw_absence,
1212 "visit lifecycle missing at {area:?}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1218 fn native_jelly_stays_whole_as_wide_text_streams_through_its_lane() {
1219 let area = Rect::new(0, 0, 80, 24);
1220 let mut frame = native_frame_at(area, 0);
1221 frame.marks.retain(|mark| mark.jellyfish.is_some());
1222 assert_eq!(frame.marks.len(), 3);
1223 let y = frame.marks[0].y;
1224 for width in 0..=40 {
1225 let mut lines = vec![Line::default(); area.height as usize];
1226 lines[y as usize] = Line::from("鲸".repeat(width));
1227 let mut buf = Buffer::empty(area);
1228 buf.set_line(0, y, &lines[y as usize], area.width);
1229 let before = buf.clone();
1230 let mut stats = AmbientFrameStats {
1231 marks_built: 3,
1232 ..Default::default()
1233 };
1234 paint_marks(
1235 area,
1236 &mut buf,
1237 (Color::Cyan, Color::Blue),
1238 &lines,
1239 &frame,
1240 1.0,
1241 &mut stats,
1242 );
1243 assert!(
1244 stats.marks_painted == 0 || stats.marks_painted == 3,
1245 "partial jelly at width {width}"
1246 );
1247 for x in 0..(width as u16 * 2).min(area.width) {
1248 assert_eq!(buf[(x, y)], before[(x, y)], "painted over wide text at {x}");
1249 }
1250 }
1251 }
1252
1252 lines RUST