use super::*;
use crate::tui::layout::Point;
use crate::tui::pet::PetKind;
use pixtuoid_core::state::{ActivityState, AgentSlot, GlobalDeskIndex, SceneState};
use pixtuoid_core::AgentId;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
fn slot(id: AgentId, floor_idx: usize, desk_index: usize, started: SystemTime) -> AgentSlot {
AgentSlot {
agent_id: id,
source: Arc::from("cc"),
session_id: Arc::from("s"),
cwd: Arc::from(Path::new("/repo")),
label: Arc::from("a"),
state: ActivityState::Idle,
state_started_at: started,
created_at: started,
last_event_at: started,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(desk_index),
floor_idx,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
}
}
fn render_until_settled<B: Backend<Error: Send + Sync + 'static>>(
r: &mut TuiRenderer<B>,
scene: &SceneState,
pack: &Pack,
now: &mut SystemTime,
target_floor: usize,
) {
for _ in 0..60 {
*now += Duration::from_millis(33);
r.render(scene, pack, *now).expect("render");
if r.current_floor() == target_floor && r.transition().is_none() {
return;
}
}
panic!("floor transition to {target_floor} did not settle");
}
fn pack() -> Pack {
crate::tui::embedded_pack::load_sprite_pack(None).expect("embedded pack")
}
fn t0() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
fn normal_theme() -> &'static crate::tui::theme::Theme {
crate::tui::theme::theme_by_name("normal").expect("normal theme")
}
fn dark_theme() -> &'static crate::tui::theme::Theme {
crate::tui::theme::theme_by_name("cyberpunk").expect("cyberpunk theme")
}
fn build(cols: u16, rows: u16, kinds: Vec<PetKind>) -> TuiRenderer<TestBackend> {
build_pets(
cols,
rows,
kinds
.into_iter()
.map(crate::tui::pet::Pet::defaulted)
.collect(),
)
}
fn build_pets(cols: u16, rows: u16, pets: Vec<crate::tui::pet::Pet>) -> TuiRenderer<TestBackend> {
TuiRenderer::new(
Terminal::new(TestBackend::new(cols, rows)).expect("test backend"),
normal_theme(),
pets,
)
}
fn idle(id: &str, desk: usize, started: SystemTime) -> AgentSlot {
slot(AgentId::from_transcript_path(id), 0, desk, started)
}
fn active(id: &str, desk: usize, detail: &str, started: SystemTime) -> AgentSlot {
let mut s = idle(id, desk, started);
s.state = ActivityState::Active {
tool_use_id: Some(Arc::from("t")),
detail: Some(Arc::from(detail)),
};
s.last_event_at = started;
s
}
fn scene_with(agents: Vec<AgentSlot>, cap: usize) -> SceneState {
let mut s = SceneState::uniform(cap);
for a in agents {
s.agents.insert(a.agent_id, a);
}
s
}
fn frame_text(buf: &ratatui::buffer::Buffer) -> String {
let area = buf.area;
let mut out = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell((x, y)) {
out.push_str(cell.symbol());
}
}
out.push('\n');
}
out
}
fn lum(c: pixtuoid_core::sprite::Rgb) -> f32 {
0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32
}
fn avg_lum(buf: &RgbBuffer, x0: u16, y0: u16, w: u16, h: u16) -> f32 {
let mut sum = 0.0;
let mut n = 0u32;
for y in y0..(y0 + h).min(buf.height) {
for x in x0..(x0 + w).min(buf.width) {
sum += lum(buf.get(x, y));
n += 1;
}
}
if n == 0 {
0.0
} else {
sum / n as f32
}
}
fn region_diff(a: &RgbBuffer, b: &RgbBuffer, x0: u16, y0: u16, w: u16, h: u16) -> u64 {
let mut d = 0u64;
for y in y0..(y0 + h).min(a.height).min(b.height) {
for x in x0..(x0 + w).min(a.width).min(b.width) {
let (p, q) = (a.get(x, y), b.get(x, y));
d += (p.r as i32 - q.r as i32).unsigned_abs() as u64
+ (p.g as i32 - q.g as i32).unsigned_abs() as u64
+ (p.b as i32 - q.b as i32).unsigned_abs() as u64;
}
}
d
}
#[test]
fn offscreen_floor_freezes_and_resyncs_on_return() {
let pack = crate::tui::embedded_pack::load_sprite_pack(None).expect("embedded pack");
let theme = crate::tui::theme::ALL_THEMES[0];
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let cap = 16;
let mut scene = SceneState::uniform(cap);
let a = AgentId::from_transcript_path("/h/floor0.jsonl");
let b = AgentId::from_transcript_path("/h/floor1.jsonl");
scene
.agents
.insert(a, slot(a, 0, 0, t0 - Duration::from_secs(120)));
scene.agents.insert(b, slot(b, 1, cap, t0));
let term = Terminal::new(TestBackend::new(100, 40)).expect("test backend");
let mut r = TuiRenderer::new(term, theme, vec![]);
let mut now = t0;
for _ in 0..10 {
r.render(&scene, &pack, now).expect("render");
now += Duration::from_millis(33);
}
assert_eq!(r.current_floor(), 0);
assert!(
r.floor_motion(0).and_then(|m| m.get(&a)).is_some(),
"floor-0 agent should have a MotionState after warm-up"
);
r.navigate_floor(1, now);
render_until_settled(&mut r, &scene, &pack, &mut now, 1);
let frozen_at = r
.floor_motion(0)
.and_then(|m| m.get(&a))
.map(|ms| ms.last_advanced_at)
.expect("floor-0 motion present");
for _ in 0..900 {
now += Duration::from_millis(33);
r.render(&scene, &pack, now).expect("render");
}
let still_frozen = r
.floor_motion(0)
.and_then(|m| m.get(&a))
.map(|ms| ms.last_advanced_at)
.expect("floor-0 motion present");
assert_eq!(
frozen_at, still_frozen,
"off-screen floor 0 motion must stay frozen while floor 1 is visible"
);
let back_at = now;
r.navigate_floor(0, now);
render_until_settled(&mut r, &scene, &pack, &mut now, 0);
let ms = r
.floor_motion(0)
.and_then(|m| m.get(&a))
.expect("floor-0 motion present");
assert!(
ms.wander_phase_started_at >= back_at,
"floor-0 agent must resync its wander clock on return (got an anchor before the switch-back ⇒ replay)"
);
}
fn two_floor_scene() -> SceneState {
let cap = 16;
scene_with(
vec![
idle("/n/0.jsonl", 0, t0() - Duration::from_secs(120)),
slot(AgentId::from_transcript_path("/n/1.jsonl"), 1, cap, t0()),
],
cap,
)
}
#[test]
fn floor_transition_completes_and_lands() {
let p = pack();
let scene = two_floor_scene();
let mut r = build(100, 40, vec![]);
let mut now = t0();
r.render(&scene, &p, now).unwrap();
assert_eq!(r.current_floor(), 0);
r.navigate_floor(1, now);
assert!(
r.transition().is_some(),
"navigation should begin a transition"
);
now += Duration::from_millis(450);
r.render(&scene, &p, now).unwrap();
assert!(r.transition().is_some(), "still transitioning mid-slide");
assert!(
r.cached_layout().is_none(),
"layout is cleared during a transition"
);
now += Duration::from_millis(600); r.render(&scene, &p, now).unwrap();
assert!(r.transition().is_none(), "transition complete");
assert_eq!(r.current_floor(), 1, "landed on the target floor");
assert!(
r.cached_layout().is_some(),
"layout recomputed after landing"
);
}
#[test]
fn navigation_blocked_during_active_transition() {
let cap = 16;
let scene = scene_with(
vec![
idle("/b/0.jsonl", 0, t0()),
slot(AgentId::from_transcript_path("/b/1.jsonl"), 1, cap, t0()),
slot(
AgentId::from_transcript_path("/b/2.jsonl"),
2,
2 * cap,
t0(),
),
],
cap,
);
let mut r = build(100, 40, vec![]);
let now = t0();
r.render(&scene, &pack(), now).unwrap();
r.navigate_floor(1, now);
r.navigate_floor(2, now); assert_eq!(
r.transition().map(|t| t.to_floor),
Some(1),
"a second navigate during a transition is a no-op"
);
}
#[test]
fn navigate_floor_clears_pinned_agent() {
let cap = 16;
let a = AgentId::from_transcript_path("/pin/0.jsonl");
let scene = scene_with(
vec![
slot(a, 0, 0, t0()),
slot(AgentId::from_transcript_path("/pin/1.jsonl"), 1, cap, t0()),
],
cap,
);
let mut r = build(100, 40, vec![]);
let now = t0();
r.render(&scene, &pack(), now).unwrap();
r.set_pinned_agent(Some(a));
r.navigate_floor(1, now);
assert!(r.pinned_agent().is_none(), "navigation unpins the agent");
}
#[test]
fn transition_cancelled_when_target_floor_disappears() {
let cap = 16;
let f1 = slot(AgentId::from_transcript_path("/c/1.jsonl"), 1, cap, t0());
let mut scene = scene_with(vec![idle("/c/0.jsonl", 0, t0()), f1.clone()], cap);
let mut r = build(100, 40, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
r.navigate_floor(1, now);
assert!(r.transition().is_some());
scene.agents.remove(&f1.agent_id);
now += Duration::from_millis(100);
r.render(&scene, &pack(), now).unwrap();
assert!(
r.transition().is_none(),
"transition to a vanished floor must cancel (no infinite slide)"
);
assert_eq!(r.current_floor(), 0);
}
#[test]
fn floor_buffers_grow_on_overflow() {
let cap = 16;
let mut r = build(100, 40, vec![]);
let now = t0();
let one = scene_with(vec![idle("/g/0.jsonl", 0, t0())], cap);
r.render(&one, &pack(), now).unwrap();
assert!(r.floor_buf(1).is_none(), "only one floor allocated");
let two = scene_with(
vec![
idle("/g/0.jsonl", 0, t0()),
slot(AgentId::from_transcript_path("/g/1.jsonl"), 1, cap, t0()),
],
cap,
);
r.render(&two, &pack(), now).unwrap();
assert!(
r.floor_buf(1).is_some(),
"floor-1 buffer allocated after overflow"
);
}
#[test]
fn per_floor_layout_seeds_differ() {
let scene = two_floor_scene();
let mut r = build(100, 40, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
let seed0 = r.current_floor_seed();
r.navigate_floor(1, now);
render_until_settled(&mut r, &scene, &pack(), &mut now, 1);
assert_ne!(
seed0,
r.current_floor_seed(),
"each floor must use a distinct layout seed"
);
}
#[test]
fn theme_switch_recolors_floor() {
let scene = scene_with(vec![idle("/t/0.jsonl", 0, t0())], 16);
let mut r = build(100, 40, vec![]);
let now = t0();
r.render(&scene, &pack(), now).unwrap();
let before = r.buf().clone();
r.set_theme(dark_theme());
r.render(&scene, &pack(), now).unwrap();
let d = region_diff(&before, r.buf(), 0, 0, before.width, before.height);
assert!(
d > 5_000,
"switching to a different theme must recolor the floor (diff={d})"
);
}
#[test]
fn walkable_debug_toggle_tints_blocked_pixels_and_is_reversible() {
let scene = scene_with(vec![idle("/t/0.jsonl", 0, t0())], 16);
let mut r = build(120, 60, vec![]);
let now = t0();
r.render(&scene, &pack(), now).unwrap();
let before = r.buf().clone();
let layout = r.cached_layout().expect("layout").clone();
let (bx, by) = (0..layout.buf_h)
.flat_map(|y| (0..layout.buf_w).map(move |x| (x, y)))
.find(|&(x, y)| y > layout.top_margin + 4 && !layout.is_walkable(x, y))
.expect("some blocked cell below the wall band");
r.set_debug_walkable(true);
r.render(&scene, &pack(), now).unwrap();
let on = r.buf().clone();
let to_red = |c: pixtuoid_core::sprite::Rgb| {
(c.r as i32 - 220).abs() + (c.g as i32 - 60).abs() + (c.b as i32 - 60).abs()
};
assert!(
to_red(on.get(bx, by)) < to_red(before.get(bx, by)),
"debug overlay must tint a blocked cell toward red (was {:?}, now {:?})",
before.get(bx, by),
on.get(bx, by),
);
let on_diff = region_diff(&before, &on, 0, 0, before.width, before.height);
assert!(
on_diff > 1_000,
"the debug layer must visibly change the frame"
);
r.set_debug_walkable(false);
r.render(&scene, &pack(), now).unwrap();
let off_diff = region_diff(&before, r.buf(), 0, 0, before.width, before.height);
assert!(
off_diff < 200,
"toggling the debug layer off must restore the scene (diff={off_diff})"
);
}
#[test]
fn occupied_floor_stays_lit() {
let scene = scene_with(vec![active("/lit/0.jsonl", 0, "Edit x", t0())], 16);
let mut r = build(100, 40, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
now += Duration::from_millis(2000);
r.render(&scene, &pack(), now).unwrap();
let early = avg_lum(r.buf(), 0, 0, r.buf().width, r.buf().height);
for _ in 0..700 {
now += Duration::from_millis(33);
r.render(&scene, &pack(), now).unwrap();
}
let late = avg_lum(r.buf(), 0, 0, r.buf().width, r.buf().height);
assert!(
late > early * 0.9,
"occupied floor must stay lit (early={early:.1}, late={late:.1})"
);
}
#[test]
fn too_small_terminal_returns_no_layout_no_panic() {
let scene = scene_with(vec![idle("/sm/0.jsonl", 0, t0())], 16);
let mut r = build(15, 8, vec![]); r.render(&scene, &pack(), t0())
.expect("render must not panic");
assert!(
r.cached_layout().is_none(),
"a too-small terminal yields no layout"
);
}
#[test]
fn colliding_labels_with_multibyte_session_ids_do_not_panic() {
let mut scene = SceneState::uniform(16);
let mut mk = |id: &str, desk: usize| {
let a = AgentId::from_transcript_path(id);
let mut s = slot(a, 0, desk, t0());
s.label = Arc::from("rx\u{00b7}proj");
s.session_id = Arc::from("/na\u{00ef}vet\u{00e9}/app");
scene.agents.insert(a, s);
};
mk("/mb/0.jsonl", 0);
mk("/mb/1.jsonl", 1);
let mut r = build(120, 40, vec![]);
r.render(&scene, &pack(), t0())
.expect("render must not panic on a multi-byte session_id");
}
#[test]
fn no_layout_frame_zeroes_the_popup_hit_box() {
let scene = scene_with(vec![idle("/nl/0.jsonl", 0, t0())], 16);
let mut r = build(100, 16, vec![]);
r.set_version_popup(true, t0());
let t = t0() + Duration::from_millis(150); assert!(
r.version_popup_scale(t) > 0.0,
"the popup is animating this frame"
);
r.render(&scene, &pack(), t).expect("render");
assert!(
r.cached_layout().is_none(),
"no layout produced at this size"
);
assert_eq!(
r.last_popup_scale(),
0.0,
"a footer-only frame paints no popup → no stale hit-box"
);
}
#[test]
fn departed_agent_motion_is_evicted_on_a_non_current_floor() {
let cap = 16;
let a = AgentId::from_transcript_path("/ev/floor0.jsonl");
let b = AgentId::from_transcript_path("/ev/floor1.jsonl");
let scene = scene_with(
vec![
slot(a, 0, 0, t0() - Duration::from_secs(120)),
slot(b, 1, cap, t0() - Duration::from_secs(120)),
],
cap,
);
let mut r = build(100, 40, vec![]);
let mut now = t0();
for _ in 0..10 {
r.render(&scene, &pack(), now).expect("render");
now += Duration::from_millis(33);
}
r.navigate_floor(1, now);
render_until_settled(&mut r, &scene, &pack(), &mut now, 1);
for _ in 0..10 {
r.render(&scene, &pack(), now).expect("render");
now += Duration::from_millis(33);
}
assert!(
r.floor_motion(1).and_then(|m| m.get(&b)).is_some(),
"floor-1 agent B should have a MotionState after visiting floor 1"
);
r.navigate_floor(0, now);
render_until_settled(&mut r, &scene, &pack(), &mut now, 0);
let scene_without_b = scene_with(vec![slot(a, 0, 0, t0() - Duration::from_secs(120))], cap);
now += Duration::from_millis(33);
r.evict_missing(&scene_without_b);
r.render(&scene_without_b, &pack(), now).expect("render");
assert_eq!(
r.floor_motion(1).map(|m| m.contains_key(&b)),
Some(false),
"a departed agent's MotionState must be evicted even on a non-current floor"
);
}
#[test]
fn evict_missing_drops_history_and_motion_on_every_floor() {
let cap = 16;
let a = AgentId::from_transcript_path("/ev2/floor0.jsonl");
let b = AgentId::from_transcript_path("/ev2/floor1.jsonl");
let scene = scene_with(vec![slot(a, 0, 0, t0()), slot(b, 1, cap, t0())], cap);
let mut r = build(100, 40, vec![]);
let mut now = t0();
for _ in 0..5 {
now += Duration::from_millis(33);
r.render(&scene, &pack(), now).expect("render");
}
r.navigate_floor(1, now);
render_until_settled(&mut r, &scene, &pack(), &mut now, 1);
for _ in 0..5 {
now += Duration::from_millis(33);
r.render(&scene, &pack(), now).expect("render");
}
assert_eq!(
r.floor_history(0).map(|h| h.contains(a)),
Some(true),
"floor-0 history should hold agent A after its entry frames"
);
assert_eq!(
r.floor_history(1).map(|h| h.contains(b)),
Some(true),
"floor-1 history should hold agent B after its entry frames"
);
assert!(
r.floor_motion(1).and_then(|m| m.get(&b)).is_some(),
"floor-1 motion should hold agent B"
);
let empty = SceneState::uniform(cap);
r.evict_missing(&empty);
for floor in 0..2 {
assert_eq!(
r.floor_history(floor)
.map(|h| h.contains(a) || h.contains(b)),
Some(false),
"departed agents' PoseHistory must be evicted on floor {floor}"
);
assert_eq!(
r.floor_motion(floor)
.map(|m| m.contains_key(&a) || m.contains_key(&b)),
Some(false),
"departed agents' MotionState must be evicted on floor {floor}"
);
}
}
#[test]
fn floor_transition_clears_stale_pet_position() {
let cap = 16;
let mut scene = SceneState::uniform(cap);
let a = AgentId::from_transcript_path("/pettrans/f0.jsonl");
let b = AgentId::from_transcript_path("/pettrans/f1.jsonl");
scene.agents.insert(a, slot(a, 0, 0, t0()));
scene.agents.insert(b, slot(b, 1, cap, t0()));
let mut r = build(100, 40, vec![PetKind::Cat]);
let mut now = t0();
for _ in 0..3 {
r.render(&scene, &pack(), now).expect("render");
now += Duration::from_millis(33);
}
assert!(
r.cached_pet_pos().is_some(),
"a pet should be drawn on the normal floor-0 frame"
);
r.navigate_floor(1, now);
r.render(&scene, &pack(), now).expect("render"); assert!(
r.cached_pet_pos().is_none(),
"an in-flight floor transition must clear the stale pet position"
);
}
#[test]
fn coffee_state_evicted_when_agent_leaves_scene() {
let id = AgentId::from_transcript_path("/cof/leave.jsonl");
let scene = scene_with(vec![slot(id, 0, 0, t0())], 16);
let mut r = build(100, 40, vec![]);
r.inject_coffee(id, t0());
r.render(&scene, &pack(), t0()).unwrap();
assert!(r.coffee_holders_contains(id));
let empty = SceneState::uniform(16);
r.render(&empty, &pack(), t0() + Duration::from_millis(33))
.unwrap();
assert!(
!r.coffee_holders_contains(id),
"coffee state must be evicted when the agent leaves (no leak)"
);
}
#[test]
fn coffee_persists_through_floor_transition() {
let p = pack();
let step = Duration::from_millis(500);
let cap = 16;
let n_f0 = 10usize;
let mut agents: Vec<_> = (0..n_f0)
.map(|i| {
idle(
&format!("/cof/f0_{i}.jsonl"),
i,
t0() - Duration::from_secs(120),
)
})
.collect();
agents.push(slot(
AgentId::from_transcript_path("/cof/f1.jsonl"),
1,
cap,
t0(),
));
let scene = scene_with(agents, cap);
let f0_ids: Vec<AgentId> = (0..n_f0)
.map(|i| AgentId::from_transcript_path(&format!("/cof/f0_{i}.jsonl")))
.collect();
let mut scratch = build(100, 40, vec![]);
let mut now = t0();
scratch.render(&scene, &p, now).unwrap();
let mut hit = None;
'outer: for _ in 0..400 {
now += step;
scratch.render(&scene, &p, now).unwrap();
for &id in &f0_ids {
if scratch.coffee_holders_contains(id) {
hit = Some((id, now));
break 'outer;
}
}
}
let (agent, detect_at) = hit.expect("a floor-0 wanderer should fetch coffee while wandering");
let mut r = build(100, 40, vec![]);
let mut t = t0();
r.render(&scene, &p, t).unwrap();
while t + step < detect_at {
t += step;
r.render(&scene, &p, t).unwrap();
}
assert!(
!r.coffee_holders_contains(agent),
"agent must not yet hold coffee before the transition"
);
r.navigate_floor(1, t);
assert!(r.transition().is_some(), "navigation begins a transition");
r.render(&scene, &p, detect_at).unwrap();
assert!(
r.coffee_holders_contains(agent),
"a coffee run completing mid-transition must persist (regression: \
render_transition_floor dropped new_coffee_carriers)"
);
}
#[test]
fn injected_coffee_changes_desk_render() {
let id = AgentId::from_transcript_path("/cof/steam.jsonl");
let scene = scene_with(
vec![idle("/cof/steam.jsonl", 0, t0() - Duration::from_secs(30))],
16,
);
let t1 = t0() + Duration::from_millis(33);
let mut base = build(100, 40, vec![]);
base.render(&scene, &pack(), t0()).unwrap();
base.render(&scene, &pack(), t1).unwrap();
let baseline = base.buf().clone();
let desk = base.cached_layout().expect("layout").home_desks[0];
let mut r = build(100, 40, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
r.inject_coffee(id, t0()); r.render(&scene, &pack(), t1).unwrap();
let d = region_diff(
&baseline,
r.buf(),
desk.x.saturating_sub(2),
desk.y.saturating_sub(6),
18,
14,
);
assert!(
d > 0,
"coffee state should alter the desk render (cup + steam)"
);
}
#[test]
fn no_pet_when_pets_disabled() {
let scene = scene_with(vec![active("/pet/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(100, 40, vec![]); r.render(&scene, &pack(), t0()).unwrap();
assert!(r.cached_pet_pos().is_none(), "no pet when none enabled");
}
#[test]
fn pet_present_when_enabled() {
let scene = scene_with(vec![active("/pet/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(100, 40, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
assert!(r.cached_pet_pos().is_some(), "a cat should be placed");
}
#[test]
fn pet_position_varies_over_its_cycle() {
let scene = scene_with(vec![active("/pet/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(100, 40, vec![PetKind::Cat]);
let mut seen = std::collections::HashSet::new();
for i in 0..5 {
let now = t0() + Duration::from_secs(i * 10);
r.render(&scene, &pack(), now).unwrap();
if let Some(PetFrame { pos, anim, .. }) = r.cached_pet_pos() {
seen.insert((pos.x, pos.y, anim));
}
}
assert!(
seen.len() >= 2,
"pet should move/animate across its 40s cycle, saw {} distinct states",
seen.len()
);
}
#[test]
fn petting_freezes_pet_position() {
let scene = scene_with(vec![active("/pet/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(100, 40, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, kind, .. } = r.cached_pet_pos().expect("pet placed");
r.set_active_pet(Some(PetState {
petted_at: t0(),
pet_pos: pos,
kind,
floor_idx: 0,
}));
r.render(&scene, &pack(), t0() + Duration::from_millis(500))
.unwrap();
let PetFrame { pos: pos2, .. } = r.cached_pet_pos().expect("pet still placed");
assert_eq!(pos, pos2, "a petted pet holds its position");
}
#[test]
fn pet_walk_is_frame_stable() {
let scene = scene_with(vec![active("/pstab/0.jsonl", 0, "Edit", t0())], 16);
let now = t0() + Duration::from_millis(5_000); let mut r1 = build(160, 80, vec![PetKind::Cat]);
let mut r2 = build(160, 80, vec![PetKind::Cat]);
r1.render(&scene, &pack(), now).unwrap();
r2.render(&scene, &pack(), now).unwrap();
assert_eq!(
r1.cached_pet_pos().map(|f| (f.pos.x, f.pos.y)),
r2.cached_pet_pos().map(|f| (f.pos.x, f.pos.y)),
"identical `now` must give identical pet position (no flash)"
);
}
#[test]
fn pet_walk_never_clips_through_furniture() {
let scene = scene_with(vec![active("/pwalk/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(160, 80, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout after prime").clone();
for cycle in 0u64..4 {
for step in 0..35u64 {
let now = t0() + Duration::from_millis(cycle * 40_000 + step * 400);
r.render(&scene, &pack(), now).unwrap();
if let Some(PetFrame { pos, anim, .. }) = r.cached_pet_pos() {
if anim == PetKind::Cat.walk_anim() {
assert!(
crate::tui::pathfind::point_in_walkable_cell(&layout.walkable, pos),
"walking pet at ({},{}) is in a blocked routing cell (cycle={cycle} step={step})",
pos.x,
pos.y
);
}
}
}
}
}
#[test]
fn pet_rest_pos_is_walkable() {
let scene = scene_with(vec![active("/prest/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(160, 80, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout after prime").clone();
for cycle in 0u64..4 {
for step in 0..10u64 {
let now = t0() + Duration::from_millis(cycle * 40_000 + 14_200 + step * 2_600);
r.render(&scene, &pack(), now).unwrap();
if let Some(PetFrame { pos, anim, .. }) = r.cached_pet_pos() {
if anim != PetKind::Cat.walk_anim() {
assert!(
layout.walkable.is_walkable(pos.x, pos.y),
"resting pet at ({},{}) is on a blocked cell (cycle={cycle} step={step})",
pos.x,
pos.y
);
}
}
}
}
}
#[test]
fn pet_leg_boundary_no_pop() {
let scene = scene_with(vec![active("/pbnd/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(160, 80, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0() + Duration::from_millis(39_600))
.unwrap();
let before = r.cached_pet_pos().map(|f| (f.pos.x, f.pos.y));
r.render(&scene, &pack(), t0() + Duration::from_millis(40_040))
.unwrap();
let after = r.cached_pet_pos().map(|f| (f.pos.x, f.pos.y));
if let (Some((x0, y0)), Some((x1, y1))) = (before, after) {
let gap = (x0 as i32 - x1 as i32).unsigned_abs() + (y0 as i32 - y1 as i32).unsigned_abs();
assert!(
gap <= 16,
"pet leg boundary teleports (gap={gap}px, ({x0},{y0})→({x1},{y1}))"
);
}
}
#[test]
fn version_popup_entrance_reaches_full_scale() {
let mut r = build(100, 40, vec![]);
r.set_version_popup(true, t0());
let s = r.version_popup_scale(t0() + Duration::from_millis(250));
assert!(s > 0.99, "entrance eases to ~1.0, got {s}");
}
#[test]
fn version_popup_dismissal_reaches_zero() {
let mut r = build(100, 40, vec![]);
r.set_version_popup(true, t0());
let mid = t0() + Duration::from_millis(250);
r.set_version_popup(false, mid);
let s = r.version_popup_scale(mid + Duration::from_millis(200));
assert!(s < 0.01, "dismissal eases to ~0.0, got {s}");
}
#[test]
fn version_popup_interrupt_continues_from_edge() {
let mut r = build(100, 40, vec![]);
r.set_version_popup(true, t0());
let half = t0() + Duration::from_millis(100);
let scale_at_interrupt = r.version_popup_scale(half);
r.set_version_popup(false, half);
let s = r.version_popup_scale(half + Duration::from_millis(1));
assert!(
(s - scale_at_interrupt).abs() < 0.2,
"interrupted animation continues from current scale ({scale_at_interrupt}), not a snap (got {s})"
);
}
fn gateway_scene(
state: pixtuoid_core::state::DaemonState,
entered_at: SystemTime,
last_seen: SystemTime,
sessions: u32,
) -> SceneState {
gateway_scene_runs(state, entered_at, last_seen, sessions, &[])
}
fn gateway_scene_runs(
state: pixtuoid_core::state::DaemonState,
entered_at: SystemTime,
last_seen: SystemTime,
sessions: u32,
runs: &[&str],
) -> SceneState {
let mut s = SceneState::uniform(16);
s.daemons_mut().insert(
pixtuoid_core::source::openclaw::SOURCE_NAME.to_string(),
pixtuoid_core::state::DaemonPresence {
state,
active_sessions: sessions,
last_seen,
entered_at,
in_flight_run_keys: runs.iter().map(|s| s.to_string()).collect(),
current_pid: Some(1),
},
);
s
}
fn bubble_px(buf: &RgbBuffer) -> usize {
let bubble = pixtuoid_core::sprite::Rgb {
r: 0xd6,
g: 0xf2,
b: 0xf8,
};
let mut n = 0;
for y in 0..buf.height {
for x in 0..buf.width {
if buf.get(x, y) == bubble {
n += 1;
}
}
}
n
}
fn lobster_px(buf: &RgbBuffer) -> usize {
let reds = [
pixtuoid_core::sprite::Rgb {
r: 0xd2,
g: 0x40,
b: 0x2f,
}, pixtuoid_core::sprite::Rgb {
r: 0xe8,
g: 0x55,
b: 0x40,
}, pixtuoid_core::sprite::Rgb {
r: 0xc8,
g: 0x38,
b: 0x28,
}, pixtuoid_core::sprite::Rgb {
r: 0x9e,
g: 0x2a,
b: 0x20,
}, ];
let mut n = 0;
for y in 0..buf.height {
for x in 0..buf.width {
if reds.contains(&buf.get(x, y)) {
n += 1;
}
}
}
n
}
#[test]
fn no_gateway_mascot_without_presence() {
let scene = SceneState::uniform(16);
let mut r = build(160, 80, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
assert_eq!(lobster_px(r.buf()), 0, "no presence ⇒ no lobster pixels");
}
#[test]
fn gateway_mascot_present_when_up() {
let scene = gateway_scene(
pixtuoid_core::state::DaemonState::Idle,
t0() - Duration::from_secs(20),
t0(),
0,
);
let mut r = build(160, 80, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
assert!(
lobster_px(r.buf()) > 10,
"a live gateway ⇒ the lobster scuttles the floor"
);
}
#[test]
fn gateway_mascot_busy_bubbles_track_runs_not_sessions() {
let entered = t0() - Duration::from_secs(20);
let idle = gateway_scene(pixtuoid_core::state::DaemonState::Idle, entered, t0(), 1);
let busy = gateway_scene_runs(
pixtuoid_core::state::DaemonState::Busy,
entered,
t0(),
1,
&["r1", "r2"],
);
let mut r = build(160, 80, vec![]);
let mut busy_max = 0;
let mut idle_max = 0;
for k in 0..8u64 {
let now = t0() + Duration::from_millis(k * 130);
r.render(&busy, &pack(), now).unwrap();
busy_max = busy_max.max(bubble_px(r.buf()));
r.render(&idle, &pack(), now).unwrap();
idle_max = idle_max.max(bubble_px(r.buf()));
}
assert!(busy_max > 0, "an in-flight run ⇒ activity bubbles render");
assert_eq!(idle_max, 0, "a persistent idle session must NOT bubble");
}
#[test]
fn gateway_mascot_walks_out_then_is_gone() {
let mut r = build(160, 80, vec![]);
let leaving = gateway_scene(
pixtuoid_core::state::DaemonState::Down,
t0() - Duration::from_secs(20),
t0() - Duration::from_millis(400),
0,
);
r.render(&leaving, &pack(), t0()).unwrap();
assert!(
lobster_px(r.buf()) > 0,
"mid walk-out, the lobster is still visible"
);
let gone = gateway_scene(
pixtuoid_core::state::DaemonState::Down,
t0() - Duration::from_secs(30),
t0() - Duration::from_secs(10),
0,
);
r.render(&gone, &pack(), t0()).unwrap();
assert_eq!(
lobster_px(r.buf()),
0,
"after the walk-out, the lobster has left"
);
}
#[test]
fn gateway_mascot_wanders_over_time() {
let scene = gateway_scene(
pixtuoid_core::state::DaemonState::Idle,
t0() - Duration::from_secs(20),
t0(),
0,
);
let mut r = build(160, 80, vec![]);
let mut tops = std::collections::HashSet::new();
for k in 0..8u64 {
let now = t0() + Duration::from_secs(k * 3);
r.render(&scene, &pack(), now).unwrap();
let buf = r.buf();
'scan: for y in 0..buf.height {
for x in 0..buf.width {
let reds = [
pixtuoid_core::sprite::Rgb {
r: 0xd2,
g: 0x40,
b: 0x2f,
},
pixtuoid_core::sprite::Rgb {
r: 0xe8,
g: 0x55,
b: 0x40,
},
];
if reds.contains(&buf.get(x, y)) {
tops.insert((x, y));
break 'scan;
}
}
}
}
assert!(
tops.len() >= 2,
"the lobster should wander to ≥2 distinct positions, saw {}",
tops.len()
);
}
#[test]
fn help_overlay_renders_shortcuts() {
let scene = scene_with(vec![idle("/help/0.jsonl", 0, t0())], 16);
let mut r = build(100, 40, vec![]);
r.set_help_open(true);
r.render(&scene, &pack(), t0()).unwrap();
assert!(r.help_open());
let text = frame_text(r.frame_buffer());
assert!(
text.contains("theme") || text.contains("Keyboard") || text.contains("help"),
"help overlay should list shortcuts; frame was:\n{text}"
);
}
#[test]
fn footer_shows_floor_indicator_on_multi_floor() {
let scene = two_floor_scene();
let mut r = build(120, 40, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("1/2") || text.contains("F1"),
"multi-floor footer should show a floor indicator; frame:\n{text}"
);
}
#[test]
fn furniture_hit_test_resolves_against_rendered_layout() {
let scene = scene_with(vec![idle("/hit/0.jsonl", 0, t0())], 16);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout");
let desk = layout.home_desks[0];
let hit = crate::tui::hit_test::hit_test_furniture(layout, desk.x + 4, desk.y / 2 + 1);
assert_eq!(
hit,
Some("Desk"),
"a desk pixel should hit the Desk furniture in the cached layout"
);
}
#[test]
fn coffee_machine_hit_test_resolves_on_pantry() {
use crate::tui::layout::WaypointKind;
let scene = scene_with(vec![idle("/cm/0.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout");
let pantry = layout
.waypoints
.iter()
.find(|w| w.kind == WaypointKind::Pantry)
.expect("a 140×48 office must lay out a pantry"); let cx = pantry.pos.x;
let cy = pantry.pos.y / 2;
let mut found = false;
for dx in -14i32..=14 {
for dy in -4i32..=4 {
let mx = (cx as i32 + dx).max(0) as u16;
let my = (cy as i32 + dy).max(0) as u16;
if crate::tui::hit_test::hit_test_coffee_machine(layout, mx, my) {
found = true;
}
}
}
assert!(
found,
"the coffee machine should be hit-testable somewhere on the pantry counter"
);
}
#[test]
fn pet_hit_test_resolves_at_pet_position() {
let scene = scene_with(vec![active("/ph/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(120, 44, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, anim, kind } = r.cached_pet_pos().expect("pet placed");
assert!(
crate::tui::hit_test::hit_test_pet(kind, pos, anim, pos.x, pos.y / 2),
"clicking the pet's own position should hit it"
);
}
#[test]
fn agent_label_painted_above_character() {
let mut s = idle("/lbl/0.jsonl", 0, t0() - Duration::from_secs(300));
s.label = Arc::from("ZQXLBL");
let scene = scene_with(vec![s], 16);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("ZQXLBL"),
"the agent's label should be painted above it"
);
}
#[test]
fn pinned_agent_renders_stats_tooltip() {
let a = AgentId::from_transcript_path("/pintip/0.jsonl");
let scene = scene_with(vec![slot(a, 0, 0, t0() - Duration::from_secs(600))], 16);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let before = frame_text(r.frame_buffer());
assert!(!before.contains("calls"));
r.set_pinned_agent(Some(a));
r.render(&scene, &pack(), t0()).unwrap();
let after = frame_text(r.frame_buffer());
assert!(
after.contains("calls") && after.contains("active"),
"pinned tooltip should show the agent stat line"
);
}
#[test]
fn footer_shows_agent_count() {
let scene = scene_with(
vec![
active("/f/0.jsonl", 0, "Edit", t0()),
idle("/f/1.jsonl", 1, t0()),
idle("/f/2.jsonl", 2, t0()),
],
16,
);
let mut r = build(140, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("agents") && text.contains('3'),
"full-width footer shows the agent count; frame footer area:\n{}",
text.lines().last().unwrap_or("")
);
}
#[test]
fn footer_shows_source_death_warning() {
let scene = scene_with(vec![idle("/f/0.jsonl", 0, t0())], 16);
let mut r = build(140, 44, vec![]);
r.set_source_warning(Some(
"claude-code source died — its agents are frozen; restart pixtuoid (see log)".into(),
));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("source died") && text.contains("restart pixtuoid"),
"the footer must surface a dead source (#157); footer row:\n{}",
text.lines().last().unwrap_or("")
);
r.set_source_warning(None);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
!text.contains("source died"),
"footer returns to stats when no source is dead"
);
}
#[test]
fn source_death_warning_survives_floor_transition() {
let scene = two_floor_scene();
let mut r = build(120, 44, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
r.set_source_warning(Some(
"claude-code source died — its agents are frozen; restart pixtuoid (see log)".into(),
));
r.navigate_floor(1, now);
now += Duration::from_millis(200); r.render(&scene, &pack(), now).unwrap();
assert!(r.transition().is_some(), "still mid-transition");
let text = frame_text(r.frame_buffer());
assert!(
text.contains("source died"),
"the warning must not vanish during the ~400ms floor slide"
);
}
#[test]
fn version_popup_active_during_floor_transition() {
let scene = two_floor_scene();
let mut r = build(120, 44, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
r.set_version_popup(true, now);
r.navigate_floor(1, now);
now += Duration::from_millis(200); r.render(&scene, &pack(), now).unwrap();
assert!(r.transition().is_some(), "still mid-transition");
assert!(
r.last_popup_scale() > 0.0,
"version popup must keep animating through a floor transition"
);
}
#[test]
fn help_overlay_renders_during_floor_transition() {
let scene = two_floor_scene();
let mut r = build(120, 44, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
r.set_help_open(true);
r.navigate_floor(1, now);
now += Duration::from_millis(200);
r.render(&scene, &pack(), now).unwrap();
assert!(r.transition().is_some());
let text = frame_text(r.frame_buffer());
assert!(
text.contains("theme") || text.contains("Keyboard") || text.contains("help"),
"help overlay must paint over a floor transition"
);
}
#[test]
fn tool_glow_tint_differs_by_tool() {
let render_tool = |detail: &str| -> (RgbBuffer, Point) {
let scene = scene_with(
vec![active(
"/tg/0.jsonl",
0,
detail,
t0() - Duration::from_secs(300),
)],
16,
);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let desk = r.cached_layout().expect("layout").home_desks[0];
(r.buf().clone(), desk)
};
let (edit, desk) = render_tool("Edit src/main.rs");
let (bash, _) = render_tool("Bash npm test");
let d = region_diff(
&edit,
&bash,
desk.x.saturating_sub(2),
desk.y.saturating_sub(6),
20,
16,
);
assert!(
d > 200,
"Edit vs Bash should tint the cubicle measurably differently (diff={d})"
);
}
#[test]
fn coffee_machine_tooltip_on_hover() {
let scene = scene_with(vec![idle("/tt/c.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout");
let mut hover = None;
'scan: for my in 0..48u16 {
for mx in 0..140u16 {
if crate::tui::hit_test::hit_test_coffee_machine(layout, mx, my) {
hover = Some((mx, my));
break 'scan;
}
}
}
let hover = hover.expect("coffee machine should be hit-testable");
r.set_mouse_pos(Some(hover));
r.render(&scene, &pack(), t0()).unwrap();
assert!(
frame_text(r.frame_buffer()).contains("Ivan"),
"hovering the coffee machine shows the Buy-Ivan-a-coffee tooltip"
);
}
#[test]
fn furniture_tooltip_on_hover_over_empty_desk() {
let scene = scene_with(vec![idle("/tt/f.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout");
if layout.home_desks.len() < 2 {
return;
}
let d1 = layout.home_desks[1];
r.set_mouse_pos(Some((d1.x + 4, d1.y / 2 + 1)));
r.render(&scene, &pack(), t0()).unwrap();
assert!(
frame_text(r.frame_buffer()).contains("Desk"),
"hovering an empty desk shows the Desk furniture tooltip"
);
}
#[test]
fn pet_tooltip_on_hover() {
let scene = scene_with(vec![active("/tt/p.jsonl", 0, "Edit", t0())], 16);
let mut r = build(140, 48, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, .. } = r.cached_pet_pos().expect("cat placed");
r.set_mouse_pos(Some((pos.x, pos.y / 2)));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("Cat") || text.contains("purr"),
"hovering the cat shows its tooltip"
);
}
#[test]
fn pet_tooltip_shows_custom_name() {
let scene = scene_with(vec![active("/tt/cn.jsonl", 0, "Edit", t0())], 16);
let cat = crate::tui::pet::Pet {
kind: PetKind::Cat,
name: "Luna".to_string(),
};
let mut r = build_pets(140, 48, vec![cat]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, .. } = r.cached_pet_pos().expect("cat placed");
r.set_mouse_pos(Some((pos.x, pos.y / 2)));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("Luna"),
"hovering the cat shows its custom name; got:\n{text}"
);
assert!(
!text.contains("Office Cat"),
"custom name replaces the default, not appended"
);
}
#[test]
fn pet_tooltip_falls_back_to_default_name_when_not_configured() {
let scene = scene_with(vec![active("/tt/fb.jsonl", 0, "Edit", t0())], 16);
let mut r = build(140, 48, vec![PetKind::Cat]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, .. } = r.cached_pet_pos().expect("cat placed");
r.set_mouse_pos(Some((pos.x, pos.y / 2)));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("Office Cat"),
"an unconfigured cat falls back to the default name; got:\n{text}"
);
}
#[test]
fn theme_picker_renders_theme_names() {
let scene = scene_with(vec![idle("/tp/0.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.set_theme_picker(Some(0));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("cyberpunk") || text.contains("normal"),
"the theme picker lists theme names"
);
}
#[test]
fn version_popup_paints_when_open() {
let scene = scene_with(vec![idle("/vp/0.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let baseline = r.buf().clone();
r.set_version_popup(true, t0());
let t1 = t0() + Duration::from_millis(250);
r.render(&scene, &pack(), t1).unwrap();
assert!(
r.last_popup_scale() > 0.9,
"popup should be near full scale"
);
let d = region_diff(&baseline, r.buf(), 0, 0, baseline.width, baseline.height);
assert!(
d > 1000,
"an open version popup must paint over the scene (diff={d})"
);
}
#[test]
fn weather_variants_render_without_panic_and_vary() {
let scene = scene_with(vec![idle("/w/0.jsonl", 0, t0())], 16);
let mut r = build(120, 44, vec![]);
let mut sigs = std::collections::HashSet::new();
for step in 0..120u64 {
let now = t0() + Duration::from_secs(step * 600 + 12 * 3600);
r.render(&scene, &pack(), now).unwrap();
let buf = r.buf();
let mut s: u64 = 0;
for y in 0..(buf.height / 4).max(1) {
for x in (0..buf.width).step_by(7) {
let c = buf.get(x, y);
s = s
.wrapping_mul(1099511628211)
.wrapping_add((c.r as u64) << 16 | (c.g as u64) << 8 | c.b as u64);
}
}
sigs.insert(s);
}
assert!(
sigs.len() >= 4,
"weather/time variation should produce several distinct window renders, saw {}",
sigs.len()
);
}
fn region_text(buf: &ratatui::buffer::Buffer, cx: u16, cy: u16, cw: u16, ch: u16) -> String {
let area = buf.area;
let mut out = String::new();
for y in cy..(cy + ch).min(area.y + area.height) {
for x in cx..(cx + cw).min(area.x + area.width) {
if let Some(cell) = buf.cell((x, y)) {
out.push_str(cell.symbol());
}
}
}
out
}
#[test]
fn meeting_room_fills_and_hosts_group_chitchat() {
let pack = pack();
let mut now = t0();
let cap = 64;
let n_agents = 40usize;
let mut scene = SceneState::uniform(cap);
for i in 0..n_agents {
let id = AgentId::from_transcript_path(&format!("/h/mtg{i}.jsonl"));
let started = now - Duration::from_secs(5 + (i as u64 * 11) % 80);
scene.agents.insert(id, slot(id, 0, i, started));
}
let mut r = build(160, 56, vec![]);
r.render(&scene, &pack, now).expect("render");
let layout = r.cached_layout().expect("layout").clone();
let mr = layout
.meeting_room
.expect("floor 0 must have a meeting room at this size");
let mut r0 = build(160, 56, vec![]);
r0.render(&SceneState::uniform(cap), &pack, now)
.expect("render");
let baseline = r0.buf().clone();
let slot_count = layout
.waypoints
.iter()
.filter(|w| {
matches!(
w.kind,
crate::tui::layout::WaypointKind::MeetingSofa
| crate::tui::layout::WaypointKind::MeetingStand
)
})
.count();
assert!(slot_count >= 4, "expected meeting slots, got {slot_count}");
let cell_y0 = (mr.y / 2).saturating_sub(4);
let cell_h = mr.height / 2 + 8;
const BUDGET: usize = 1200; let mut saw_characters = false;
let mut chat_iter: Option<usize> = None;
for iter in 1..=BUDGET {
now += Duration::from_millis(250);
r.render(&scene, &pack, now).expect("render");
if !saw_characters {
let d = region_diff(&baseline, r.buf(), mr.x, mr.y, mr.width, mr.height);
saw_characters = d > 4_000;
}
if chat_iter.is_none() {
let text = region_text(r.frame_buffer(), mr.x, cell_y0, mr.width + 6, cell_h);
if crate::tui::chitchat::CHITCHAT_LINES
.iter()
.any(|l| text.contains(l))
{
chat_iter = Some(iter);
}
}
if saw_characters && chat_iter.is_some() {
break;
}
}
assert!(
saw_characters,
"agents never visibly occupied the meeting room"
);
let chat_iter = chat_iter.expect("no group chitchat bubble ever appeared in the meeting room");
assert!(
chat_iter < (BUDGET * 3) / 4,
"group chitchat took {chat_iter}/{BUDGET} iterations — fill margin eroded; \
expected within 3/4 of the budget"
);
}
#[test]
fn meeting_glass_partition_connects_at_window_and_corner() {
let mut r = build(192, 80, vec![]);
let scene = scene_with(vec![idle("/h/glass.jsonl", 0, t0())], 16);
r.render(&scene, &pack(), t0()).expect("render");
let layout = r.cached_layout().expect("layout").clone();
let v_x = layout
.room_walls
.iter()
.find(|w| w.start.x == w.end.x)
.map(|w| w.start.x)
.expect("standard floor has a vertical divider");
let h_y = layout
.room_walls
.iter()
.find(|w| w.start.y == w.end.y)
.map(|w| w.start.y)
.expect("standard floor has a horizontal divider");
let top_wall_h = layout.top_margin - 4;
let buf = r.buf();
let dist = |a: pixtuoid_core::sprite::Rgb, b: pixtuoid_core::sprite::Rgb| {
(a.r as i32 - b.r as i32).abs()
+ (a.g as i32 - b.g as i32).abs()
+ (a.b as i32 - b.b as i32).abs()
};
let glass_lit = buf.get(v_x, layout.top_margin + 2);
let glass_soft = buf.get(v_x + 2, layout.top_margin + 2);
let floor_ref = buf.get(v_x.saturating_sub(8), top_wall_h + 6);
let is_glass = |p: pixtuoid_core::sprite::Rgb| {
dist(p, glass_lit).min(dist(p, glass_soft)) < dist(p, floor_ref)
};
assert!(
is_glass(buf.get(v_x, top_wall_h + 1)),
"vertical divider should connect up to the window band (no floor gap)"
);
assert!(
is_glass(buf.get(v_x + 2, h_y + 2)),
"vertical divider should fill the inside corner at the horizontal wall"
);
}
#[test]
fn furniture_hit_test_covers_every_kind_on_real_layouts() {
use crate::tui::hit_test::hit_test_furniture;
use crate::tui::layout::{
Layout, PlantKind, PodDecor, WallDecor, WaypointKind, MAX_VISIBLE_DESKS,
};
use std::collections::HashSet;
let labels_on = |layout: &Layout| -> HashSet<&'static str> {
let mut set = HashSet::new();
for cy in 0..(layout.buf_h / 2) {
for cx in 0..layout.buf_w {
if let Some(l) = hit_test_furniture(layout, cx, cy) {
set.insert(l);
}
}
}
set
};
let mut covered: HashSet<&'static str> = HashSet::new();
for seed in [0u64, 3] {
let layout = Layout::compute_with_seed(160, 200, MAX_VISIBLE_DESKS, seed)
.unwrap_or_else(|| panic!("layout for seed {seed}"));
let labels = labels_on(&layout);
for wp in &layout.waypoints {
let want = match wp.kind {
WaypointKind::Pantry => Some("Pantry Counter"),
WaypointKind::PhoneBooth => Some("Phone Booth"),
WaypointKind::StandingDesk => Some("Standing Desk"),
WaypointKind::VendingMachine => Some("Vending Machine"),
WaypointKind::Printer => Some("Printer"),
WaypointKind::Couch | WaypointKind::MeetingSofa | WaypointKind::MeetingStand => {
None
}
};
if let Some(label) = want {
assert!(
labels.contains(label),
"seed {seed}: waypoint {:?} → label {label:?} never resolved",
wp.kind
);
}
}
if !layout.meeting_furniture.is_empty() {
assert!(labels.contains("Meeting Sofa"), "seed {seed}: Meeting Sofa");
assert!(
labels.contains("Meeting Table"),
"seed {seed}: Meeting Table"
);
}
if layout.pantry_table.is_some() {
assert!(labels.contains("Pantry Table"), "seed {seed}: Pantry Table");
}
if !layout.pantry_chairs.is_empty() {
assert!(labels.contains("Chair"), "seed {seed}: Chair");
}
if layout.floor_lamp.is_some() {
assert!(labels.contains("Floor Lamp"), "seed {seed}: Floor Lamp");
}
if layout.couch_sprite_center.is_some() {
assert!(labels.contains("Lounge Sofa"), "seed {seed}: Lounge Sofa");
}
if layout.lounge_side_table.is_some() {
assert!(labels.contains("Side Table"), "seed {seed}: Side Table");
}
for item in &layout.plants {
let label = match item.kind {
PlantKind::Ficus => "Ficus",
PlantKind::Tall => "Tall Plant",
PlantKind::Flower => "Flower Pot",
PlantKind::Succulent => "Succulent",
};
assert!(labels.contains(label), "seed {seed}: plant {:?}", item.kind);
}
for item in &layout.wall_decor {
let label = match item.kind {
WallDecor::Whiteboard => "Whiteboard",
WallDecor::Bookshelf => "Bookshelf",
WallDecor::BulletinBoard => "Bulletin Board",
WallDecor::ExitSign => "Exit Sign",
WallDecor::MeetingScreen => "Meeting Screen",
};
assert!(
labels.contains(label),
"seed {seed}: wall decor {:?}",
item.kind
);
}
for item in &layout.pod_decor {
let label = match item.kind {
PodDecor::PlantTall => "Tall Plant",
PodDecor::Whiteboard => "Whiteboard",
PodDecor::Tv => "TV Stand",
PodDecor::PhoneBooth => "Phone Booth",
PodDecor::StandingDesk => "Standing Desk",
};
assert!(
labels.contains(label),
"seed {seed}: pod decor {:?}",
item.kind
);
}
covered.extend(labels);
}
for label in [
"Coat Rack",
"Doormat",
"Water Cooler",
"Trash Bin",
"Elevator",
] {
assert!(
covered.contains(label),
"procedural/room item {label:?} never resolved across seeds"
);
}
}
#[test]
fn hovering_an_agent_marks_its_label() {
let mut s = idle("/hov/0.jsonl", 0, t0() - Duration::from_secs(300));
s.label = Arc::from("HOVERME");
let scene = scene_with(vec![s], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let desk = r.cached_layout().expect("layout").home_desks[0];
let cell_x = desk.x + 2;
let cell_y = desk.y.saturating_sub(4) / 2 + 1;
r.set_mouse_pos(Some((cell_x, cell_y)));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("\u{25b8}HOVERME") || text.contains("\u{25b8}"),
"hovering an agent should add the ▸ marker to its label; frame:\n{text}"
);
}
#[test]
fn pinned_active_agent_tooltip_shows_state_and_detail() {
let mut a = active(
"/ttA/0.jsonl",
0,
"Edit src/lib.rs",
t0() - Duration::from_secs(600),
);
a.active_ms = 120_000; let id = a.agent_id;
let scene = scene_with(vec![a], 16);
let mut r = build(120, 44, vec![]);
r.set_pinned_agent(Some(id));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("Active"), "active state arm: {text}");
assert!(text.contains("Edit src/lib.rs"), "detail line: {text}");
assert!(
text.contains('%') && !text.contains("--%"),
"numeric active %: {text}"
);
}
#[test]
fn pinned_waiting_agent_tooltip_shows_reason() {
let mut a = idle("/ttW/0.jsonl", 0, t0() - Duration::from_secs(60));
a.state = ActivityState::Waiting {
reason: Arc::from("permission to edit"),
};
let id = a.agent_id;
let scene = scene_with(vec![a], 16);
let mut r = build(120, 44, vec![]);
r.set_pinned_agent(Some(id));
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("Waiting"), "waiting state arm: {text}");
assert!(text.contains("permission"), "reason line: {text}");
}
#[test]
fn exiting_agent_label_uses_exiting_color() {
let mut a = idle("/ttE/0.jsonl", 0, t0() - Duration::from_secs(10));
a.label = Arc::from("LEAVING");
a.exiting_at = Some(t0());
let scene = scene_with(vec![a], 16);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0() + Duration::from_millis(100))
.unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("LEAVING"), "exiting agent label: {text}");
}
#[test]
fn pinned_then_removed_agent_is_a_safe_noop() {
let id = AgentId::from_transcript_path("/ttGone/0.jsonl");
let scene = scene_with(vec![slot(id, 0, 0, t0())], 16);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
r.set_pinned_agent(Some(id));
let empty = SceneState::uniform(16);
r.render(&empty, &pack(), t0() + Duration::from_millis(33))
.expect("render must not panic when the pinned agent vanished");
}
#[test]
fn pet_tooltip_shows_cooldown_reaction_for_cat_and_dog() {
for (kind, word) in [(PetKind::Cat, "purr"), (PetKind::Dog, "woof")] {
let scene = scene_with(vec![active("/ck/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(140, 48, vec![kind]);
r.render(&scene, &pack(), t0()).unwrap();
let PetFrame { pos, .. } = r.cached_pet_pos().expect("pet placed");
r.set_active_pet(Some(PetState {
petted_at: t0(),
pet_pos: pos,
kind,
floor_idx: 0,
}));
r.set_mouse_pos(Some((pos.x, pos.y / 2)));
r.render(&scene, &pack(), t0() + Duration::from_millis(200))
.unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains(word),
"{kind:?} on cooldown should show '{word}'; got:\n{text}"
);
}
}
#[test]
fn pet_tooltip_shows_sleeping_when_all_idle() {
let scene = scene_with(
vec![idle("/slp/0.jsonl", 0, t0() - Duration::from_secs(300))],
16,
);
let mut r = build(160, 64, vec![PetKind::Cat]);
let mut hit = None;
for i in 0..40u64 {
let now = t0() + Duration::from_secs(i);
r.render(&scene, &pack(), now).unwrap();
if let Some(PetFrame { pos, anim, .. }) = r.cached_pet_pos() {
if anim == PetKind::Cat.sleep_anim() {
hit = Some((pos, now));
break;
}
}
}
let (pos, now) = hit.expect("a long-idle cat must enter its sleep anim within the window");
r.set_mouse_pos(Some((pos.x, pos.y / 2)));
r.render(&scene, &pack(), now).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("sleeping"),
"hovering a sleeping cat shows the sleeping line; got:\n{text}"
);
}
#[test]
fn furniture_tooltip_flips_below_near_top_edge() {
let scene = scene_with(vec![idle("/flip/0.jsonl", 0, t0())], 16);
let mut r = build(140, 48, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout");
let mut top_hit = None;
'scan: for my in 0..6u16 {
for mx in 0..140u16 {
if crate::tui::hit_test::hit_test_furniture(layout, mx, my).is_some() {
top_hit = Some((mx, my));
break 'scan;
}
}
}
let (mx, my) = top_hit.expect("some furniture must hover-test near the top edge");
r.set_mouse_pos(Some((mx, my)));
r.render(&scene, &pack(), t0())
.expect("top-edge furniture hover must flip the tooltip below without panic");
}
#[test]
fn agent_tooltip_flips_up_near_bottom_edge() {
let scene = scene_with(
vec![idle("/flup/0.jsonl", 0, t0() - Duration::from_secs(120))],
16,
);
let mut r = build(120, 44, vec![]);
r.render(&scene, &pack(), t0()).unwrap();
let layout = r.cached_layout().expect("layout").clone();
let bottom_desk = layout
.home_desks
.iter()
.max_by_key(|d| d.y)
.copied()
.expect("a home desk");
let id = AgentId::from_transcript_path("/flup/0.jsonl");
r.set_pinned_agent(Some(id));
let my = (44u16).saturating_sub(2);
r.set_mouse_pos(Some((bottom_desk.x, my)));
r.render(&scene, &pack(), t0())
.expect("bottom-edge hover must not panic");
}
#[test]
fn layout_compute_none_bails_to_footer_only() {
let scene = scene_with(vec![idle("/lc/0.jsonl", 0, t0())], 16);
let mut r = build(28, 40, vec![]);
r.render(&scene, &pack(), t0())
.expect("render must not error on the compute-None bail");
assert!(
r.cached_layout().is_none(),
"a layout that fails compute yields no cached layout"
);
}
#[test]
fn transition_on_too_small_terminal_clears_interaction_state() {
let scene = two_floor_scene();
let mut r = build(18, 10, vec![PetKind::Cat]);
let now = t0();
r.render(&scene, &pack(), now).unwrap();
r.navigate_floor(1, now);
r.render(&scene, &pack(), now + Duration::from_millis(100))
.expect("transition render on a tiny terminal must not panic");
assert!(r.cached_layout().is_none());
assert!(r.cached_pet_pos().is_none());
assert_eq!(r.last_popup_scale(), 0.0);
}
#[test]
fn debug_walkable_getter_reflects_setter() {
let mut r = build(100, 40, vec![]);
assert!(!r.debug_walkable());
r.set_debug_walkable(true);
assert!(r.debug_walkable());
r.set_debug_walkable(false);
assert!(!r.debug_walkable());
}
#[test]
fn already_expired_active_pet_clears_on_render() {
let scene = scene_with(vec![active("/exp/0.jsonl", 0, "Edit", t0())], 16);
let mut r = build(100, 40, vec![PetKind::Cat]);
r.set_active_pet(Some(PetState {
petted_at: t0() - Duration::from_secs(3600), pet_pos: Point { x: 10, y: 10 },
kind: PetKind::Cat,
floor_idx: 0,
}));
r.render(&scene, &pack(), t0()).unwrap();
assert!(
r.active_pet_ref().is_none(),
"an already-expired pet state must be cleared on render"
);
}
#[test]
fn current_floor_clamps_when_floor_count_drops() {
let cap = 16;
let two = two_floor_scene();
let mut r = build(100, 40, vec![]);
let mut now = t0();
r.render(&two, &pack(), now).unwrap();
r.navigate_floor(1, now);
render_until_settled(&mut r, &two, &pack(), &mut now, 1);
assert_eq!(r.current_floor(), 1);
let one = scene_with(vec![idle("/clamp/0.jsonl", 0, t0())], cap);
r.render(&one, &pack(), now).unwrap();
assert_eq!(
r.current_floor(),
0,
"current_floor clamps when floors shrink"
);
}
#[test]
fn theme_picker_renders_during_floor_transition() {
let scene = two_floor_scene();
let mut r = build(140, 48, vec![]);
let mut now = t0();
r.render(&scene, &pack(), now).unwrap();
r.set_theme_picker(Some(0));
r.navigate_floor(1, now);
now += Duration::from_millis(200);
r.render(&scene, &pack(), now).unwrap();
assert!(r.transition().is_some(), "still mid-transition");
let text = frame_text(r.frame_buffer());
assert!(
text.contains("cyberpunk") || text.contains("normal"),
"theme picker must paint over a floor transition; frame:\n{text}"
);
}
use crate::tui::dashboard::{build_dashboard_rows, DashboardFolds};
#[test]
fn dashboard_popup_renders_labels_states_and_live_tool() {
let mut r = build(120, 44, vec![]);
let mut a = active("/h/alpha.jsonl", 0, "Edit reducer.rs", t0());
a.label = Arc::from("cc\u{b7}alpha");
let mut b = idle("/h/beta.jsonl", 1, t0());
b.label = Arc::from("cc\u{b7}beta");
let scene = scene_with(vec![a, b], 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let first = rows[0].agent_id;
r.set_dashboard_frame(true, rows, Some(first), 0);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("Agents ("), "header missing:\n{text}");
assert!(text.contains("cc\u{b7}alpha"), "alpha row missing:\n{text}");
assert!(text.contains("cc\u{b7}beta"), "beta row missing:\n{text}");
assert!(
text.contains("Edit reducer.rs"),
"live tool detail missing:\n{text}"
);
assert!(text.contains("idle"), "idle state missing:\n{text}");
}
#[test]
fn connection_panel_renders_both_facets_borderless() {
use crate::tui::connection::{ConnState, ConnectionRow, LiveInfo};
let mut r = build(120, 44, vec![]);
let scene = scene_with(vec![], 16);
let rows = vec![
ConnectionRow {
source_id: "claude",
label_prefix: "cc",
display_name: "Claude Code",
state: ConnState::Connected,
config_path: Some(std::path::PathBuf::from("~/.claude/settings.json")),
target: None,
health: None,
},
ConnectionRow {
source_id: "antigravity",
label_prefix: "ag",
display_name: "Antigravity",
state: ConnState::Disconnected,
config_path: None,
target: None,
health: None,
},
];
let live = vec![
LiveInfo {
agents: 2,
last_event_age: Some(std::time::Duration::from_secs(3)),
dead: false,
},
LiveInfo {
agents: 1,
last_event_age: Some(std::time::Duration::from_secs(12)),
dead: false,
},
];
r.set_connection_frame(
true,
rows,
live,
0,
None,
None,
"socket /tmp/p.sock (listening)".into(),
);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("Sources"), "title missing:\n{text}");
assert!(text.contains("[cc]"), "cc badge missing:\n{text}");
assert!(text.contains("[ag]"), "ag badge missing:\n{text}");
assert!(text.contains("2 agents"), "live count missing:\n{text}");
assert!(text.contains("socket"), "socket line missing:\n{text}");
assert!(
text.contains("installed at"),
"connected detail (install path) missing:\n{text}"
);
let popup = dash_popup(r.frame_buffer());
for g in [
'\u{256d}', '\u{256e}', '\u{2570}', '\u{256f}', '\u{2502}', '\u{2500}',
] {
assert!(
!popup.contains(g),
"Sources panel must be borderless, found {g}:\n{popup}"
);
}
}
#[test]
fn connection_panel_health_flag_and_detail_preempt_the_install_path() {
use crate::tui::connection::{ConnState, ConnectionRow, LiveInfo};
let mut r = build(120, 44, vec![]);
let scene = scene_with(vec![], 16);
let rows = vec![ConnectionRow {
source_id: "reasonix",
label_prefix: "rx",
display_name: "Reasonix",
state: ConnState::Connected,
config_path: Some(std::path::PathBuf::from("~/.reasonix/settings.json")),
target: None,
health: Some("install broken: shim binary missing".into()), }];
r.set_connection_frame(
true,
rows,
vec![LiveInfo::default()],
0,
None,
None,
"socket /tmp/p.sock (listening)".into(),
);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains('\u{26a0}'),
"the per-row health flag (⚠) must render (health string carries none):\n{text}"
);
assert!(
text.contains("install broken"),
"the full reason must show in the detail line:\n{text}"
);
assert!(
!text.contains("installed at"),
"the health verdict must PREEMPT the install-path hint:\n{text}"
);
}
#[test]
fn connection_panel_armed_shows_confirm_prompt() {
use crate::tui::connection::{ConnState, ConnectionRow, LiveInfo};
let mut r = build(120, 44, vec![]);
let scene = scene_with(vec![], 16);
let rows = vec![ConnectionRow {
source_id: "codex",
label_prefix: "cx",
display_name: "Codex",
state: ConnState::Connected,
config_path: Some(std::path::PathBuf::from("~/.codex/config.toml")),
target: None,
health: None,
}];
r.set_connection_frame(
true,
rows,
vec![LiveInfo::default()],
0,
Some(0),
None,
String::new(),
);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
text.contains("(y/n)"),
"armed confirm prompt missing:\n{text}"
);
assert!(text.contains("Codex"), "armed target name missing:\n{text}");
}
#[test]
fn dashboard_collapsed_big_tree_shows_badge_and_hides_children() {
let mut r = build(120, 44, vec![]);
let root_id = AgentId::from_transcript_path("/h/root.jsonl");
let mut root = slot(root_id, 0, 0, t0());
root.label = Arc::from("cc\u{b7}root");
let mut agents = vec![root];
for i in 0..6 {
let cid = AgentId::from_transcript_path(&format!("/h/root/subagents/agent-{i}.jsonl"));
let mut c = slot(cid, 0, 1 + i, t0());
c.label = Arc::from(format!("explorer{i}").as_str());
c.parent_id = Some(root_id);
agents.push(c);
}
let scene = scene_with(agents, 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
r.set_dashboard_frame(true, rows, Some(root_id), 0);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(text.contains("cc\u{b7}root"), "root row missing:\n{text}");
assert!(
text.contains("(6)"),
"collapsed hidden-count badge missing:\n{text}"
);
assert!(
text.contains("Agents (1)"),
"collapsed tree must list exactly one row:\n{text}"
);
}
#[test]
fn dashboard_closed_paints_no_popup() {
let mut r = build(120, 44, vec![]);
let scene = scene_with(vec![idle("/h/a.jsonl", 0, t0())], 16);
r.set_dashboard_frame(false, Vec::new(), None, 0);
r.render(&scene, &pack(), t0()).unwrap();
let text = frame_text(r.frame_buffer());
assert!(
!text.contains("Agents ("),
"no dashboard popup when closed:\n{text}"
);
}
fn dash_popup(buf: &ratatui::buffer::Buffer) -> String {
let tb = crate::tui::theme::NORMAL.ui.tooltip_bg;
let bg = ratatui::style::Color::Rgb(tb.r, tb.g, tb.b);
let area = buf.area;
let mut out = String::new();
for y in area.y..area.y + area.height {
let mut row = String::new();
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell((x, y)) {
if cell.bg == bg {
row.push_str(cell.symbol());
}
}
}
if !row.trim().is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&row);
}
}
out
}
#[test]
fn dashboard_renders_waiting_reason_and_active_without_detail() {
let mut r = build(120, 44, vec![]);
let mut w = idle("/h/w.jsonl", 0, t0());
w.label = Arc::from("cc\u{b7}wait");
w.state = ActivityState::Waiting {
reason: Arc::from("permission"),
};
let mut a = idle("/h/a.jsonl", 1, t0());
a.label = Arc::from("cc\u{b7}act");
a.state = ActivityState::Active {
tool_use_id: Some(Arc::from("t")),
detail: None,
};
let scene = scene_with(vec![w, a], 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
r.set_dashboard_frame(true, rows, None, 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(
popup.contains("waiting: permission"),
"waiting reason missing:\n{popup}"
);
assert!(
popup.contains("\u{25cf} active"),
"active-without-detail must render the bare state word:\n{popup}"
);
}
#[test]
fn dashboard_scrolls_to_keep_a_deep_selection_visible() {
let mut agents = Vec::new();
for i in 0..20 {
let mut s = idle(&format!("/h/r{i}.jsonl"), i, t0());
s.label = Arc::from(format!("row{i:02}").as_str());
s.floor_idx = i % 10;
agents.push(s);
}
let scene = scene_with(agents, 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let row18 = rows[18].agent_id;
let mut r = build(120, 44, vec![]);
r.set_dashboard_frame(true, rows, Some(row18), 0);
r.render(&scene, &pack(), t0()).unwrap();
let buf = r.frame_buffer();
let text = frame_text(buf);
let popup = dash_popup(buf);
assert!(text.contains("Agents (20)"), "all 20 rows counted:\n{text}");
assert!(
popup.contains("row18"),
"deep selection scrolled into view (painter re-clamps scroll to the real window):\n{popup}"
);
assert!(
!popup.contains("row00"),
"top row must scroll off when a deep row is selected:\n{popup}"
);
}
#[test]
fn dashboard_empty_scene_shows_placeholder() {
let mut r = build(120, 44, vec![]);
let scene = scene_with(vec![], 16);
r.set_dashboard_frame(true, Vec::new(), None, 0);
r.render(&scene, &pack(), t0()).unwrap();
assert!(
frame_text(r.frame_buffer()).contains("No active agents"),
"empty dashboard must show the placeholder"
);
}
#[test]
fn dashboard_badge_text_present_for_cc_and_cx() {
let mut r = build(120, 44, vec![]);
let mut cc_slot = idle("/h/cc.jsonl", 0, t0());
cc_slot.source = Arc::from("claude-code");
cc_slot.label = Arc::from("cc\u{b7}alpha");
let mut cx_slot = idle("/h/cx.jsonl", 1, t0());
cx_slot.source = Arc::from("codex");
cx_slot.label = Arc::from("cx\u{b7}beta");
let scene = scene_with(vec![cc_slot, cx_slot], 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
r.set_dashboard_frame(true, rows, None, 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(popup.contains("[cc]"), "cc badge missing:\n{popup}");
assert!(popup.contains("[cx]"), "cx badge missing:\n{popup}");
}
#[test]
fn dashboard_overflow_cue_appears_below_when_more_than_viewport() {
let mut agents = Vec::new();
for i in 0..20 {
let mut s = idle(&format!("/h/r{i}.jsonl"), i % 16, t0());
s.label = Arc::from(format!("overflow{i:02}").as_str());
s.floor_idx = i % 10;
agents.push(s);
}
let scene = scene_with(agents, 32);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let mut r = build(120, 44, vec![]);
r.set_dashboard_frame(true, rows, None, 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(
popup.contains('\u{22ee}'),
"overflow cue ⋮ must appear:\n{popup}"
);
}
#[test]
fn dashboard_overflow_cue_absent_when_all_visible() {
let mut agents = Vec::new();
for i in 0..8 {
let mut s = idle(&format!("/h/r{i}.jsonl"), i, t0());
s.label = Arc::from(format!("fit{i:02}").as_str());
agents.push(s);
}
let scene = scene_with(agents, 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let mut r = build(120, 44, vec![]);
r.set_dashboard_frame(true, rows, None, 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(
!popup.contains('\u{22ee}'),
"no overflow cue for 8 rows:\n{popup}"
);
}
#[test]
fn dashboard_overflow_cue_keeps_a_bottom_navigated_selection_visible() {
let mut agents = Vec::new();
for i in 0..25 {
let mut s = idle(&format!("/h/r{i}.jsonl"), i, t0());
s.label = Arc::from(format!("row{i:02}").as_str());
s.floor_idx = i % 10;
agents.push(s);
}
let scene = scene_with(agents, 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let row20 = rows[20].agent_id;
let mut r = build(120, 44, vec![]);
r.set_dashboard_frame(true, rows, Some(row20), 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(
popup.contains("row20"),
"selected bottom row must stay visible when a cue shows:\n{popup}"
);
assert!(
popup.contains('\u{22ee}'),
"overflow cue present (rows below):\n{popup}"
);
}
#[test]
fn dashboard_overflow_no_blank_line_when_selection_is_last_row() {
let mut agents = Vec::new();
for i in 0..17 {
let mut s = idle(&format!("/h/r{i}.jsonl"), i, t0());
s.label = Arc::from(format!("row{i:02}").as_str());
s.floor_idx = i % 10;
agents.push(s);
}
let scene = scene_with(agents, 16);
let rows = build_dashboard_rows(&scene, &DashboardFolds::default());
let last = rows[16].agent_id;
let mut r = build(120, 44, vec![]);
r.set_dashboard_frame(true, rows, Some(last), 0);
r.render(&scene, &pack(), t0()).unwrap();
let popup = dash_popup(r.frame_buffer());
assert!(
popup.contains("row16"),
"selected last row visible:\n{popup}"
);
assert!(
popup.contains("row01"),
"all 16 visible lines must be filled — no blank reserved cue line:\n{popup}"
);
assert!(
!popup.contains('\u{22ee}'),
"no cue when scrolled to the end (nothing below):\n{popup}"
);
}