use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::RwLock;
use zero_commands::{
Command, DispatchOutput, FrictionDecision, ModeTarget, OutputLine, OverlayTarget, ReplayKind,
};
use zero_engine_client::{EngineEvent, EngineState, RateBudget};
use zero_operator_state::friction::FrictionLevel;
use crate::app::event_ring::EventRing;
use crate::app::log::{ConversationLog, EntryKind, LogEntry};
use crate::app::mode::Mode;
use crate::app::picker::SlashPicker;
use crate::app::prompt::PromptBuffer;
use crate::app::session::SessionSink;
use crate::theme::Theme;
const CEREMONY_LINES: &[&str] = &[
"first live position observed.",
"from here on every fill is real. so is every loss.",
"type /risk to see what the engine is watching for you. /break takes you out of the seat.",
];
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug)]
pub struct AppState {
pub mode: Mode,
pub log: ConversationLog,
pub prompt: PromptBuffer,
pub theme: Theme,
pub engine: Arc<RwLock<EngineState>>,
pub should_quit: bool,
pub pending_input: Option<String>,
pub sink: Option<SessionSink>,
pub overlay: Option<ActiveOverlay>,
pub picker: Option<SlashPicker>,
pub screen_reader: bool,
pub log_scroll: u16,
pub event_ring: EventRing,
pub live_stream_visible: bool,
pub verbose: bool,
pub wrap_off: bool,
pub coaching_notices: Vec<String>,
pub rate_budget: Option<RateBudget>,
pub first_live_trade_recorded: bool,
pub risk_overlay_last_dismissed_at: Option<Instant>,
pub risk_overlay_last_seen_alert_pct: Option<f64>,
pub risk_overlay_last_trigger: Option<RiskOverlayTrigger>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskOverlayTrigger {
Friction(FrictionLevel),
Proximity,
}
#[derive(Debug, Clone)]
pub enum ActiveOverlay {
State,
Verdict(Box<zero_engine_client::Evaluation>),
FrictionPause(FrictionPause),
Risk {
trigger: RiskOverlayTrigger,
opened_at: Instant,
},
}
fn trigger_rank(t: RiskOverlayTrigger) -> u8 {
match t {
RiskOverlayTrigger::Proximity => 1,
RiskOverlayTrigger::Friction(FrictionLevel::L3) => 2,
RiskOverlayTrigger::Friction(FrictionLevel::L4) => 3,
RiskOverlayTrigger::Friction(_) => 0,
}
}
fn trigger_strictly_escalates(prev: RiskOverlayTrigger, next: RiskOverlayTrigger) -> bool {
trigger_rank(next) > trigger_rank(prev)
}
impl ActiveOverlay {
#[must_use]
pub fn from_target(t: OverlayTarget) -> Self {
match t {
OverlayTarget::State => Self::State,
OverlayTarget::Verdict(eval) => Self::Verdict(eval),
}
}
}
#[derive(Debug, Clone)]
pub struct FrictionPause {
pub command: Command,
pub level: FrictionLevel,
pub started_at: Instant,
pub pause: Duration,
pub confirm_word: Option<String>,
pub confirm_input: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrictionOutcome {
Confirmed,
Pending,
}
impl FrictionPause {
#[must_use]
pub fn from_decision(
command: Command,
decision: &FrictionDecision,
now: Instant,
) -> Option<Self> {
match decision {
FrictionDecision::Proceed | FrictionDecision::HardStop { .. } => None,
FrictionDecision::Pause { pause, level } => Some(Self {
command,
level: *level,
started_at: now,
pause: *pause,
confirm_word: None,
confirm_input: String::new(),
}),
FrictionDecision::TypedConfirm { pause, level } => {
let word = decision.confirm_word().map_or_else(
|| zero_commands::TYPED_CONFIRM_WORD.to_string(),
std::borrow::Cow::into_owned,
);
Some(Self {
command,
level: *level,
started_at: now,
pause: *pause,
confirm_word: Some(word),
confirm_input: String::new(),
})
}
FrictionDecision::WaitAndReread {
pause,
level,
phrase,
} => Some(Self {
command,
level: *level,
started_at: now,
pause: *pause,
confirm_word: Some(phrase.clone()),
confirm_input: String::new(),
}),
}
}
#[must_use]
pub fn remaining(&self, now: Instant) -> Duration {
self.pause
.saturating_sub(now.saturating_duration_since(self.started_at))
}
#[must_use]
pub fn pause_elapsed(&self, now: Instant) -> bool {
self.remaining(now).is_zero()
}
#[must_use]
pub fn confirm_word_matches(&self) -> bool {
match &self.confirm_word {
None => false,
Some(word) => self.confirm_input.trim() == word.as_str(),
}
}
#[must_use]
pub fn outcome(&self, now: Instant) -> FrictionOutcome {
if !self.pause_elapsed(now) {
return FrictionOutcome::Pending;
}
match self.confirm_word {
None => FrictionOutcome::Confirmed,
Some(_) => {
if self.confirm_word_matches() {
FrictionOutcome::Confirmed
} else {
FrictionOutcome::Pending
}
}
}
}
pub fn push_char(&mut self, c: char, now: Instant) {
if self.confirm_word.is_none() || !self.pause_elapsed(now) {
return;
}
if self.confirm_input.len() < 32 {
self.confirm_input.push(c);
}
}
pub fn pop_char(&mut self, now: Instant) {
if self.confirm_word.is_none() || !self.pause_elapsed(now) {
return;
}
self.confirm_input.pop();
}
}
impl AppState {
#[must_use]
pub fn new(engine: Arc<RwLock<EngineState>>) -> Self {
Self::new_with_sink(engine, None)
}
#[must_use]
pub fn new_with_sink(engine: Arc<RwLock<EngineState>>, sink: Option<SessionSink>) -> Self {
let first_live_trade_recorded = match &sink {
None => true,
Some(s) => matches!(
s.store()
.get_milestone(zero_session::milestones::FIRST_LIVE_TRADE_AT),
Ok(Some(_))
),
};
let mut s = Self {
mode: Mode::default(),
log: ConversationLog::with_capacity(2048),
prompt: PromptBuffer::new(),
theme: Theme::default(),
engine,
should_quit: false,
pending_input: None,
sink,
overlay: None,
picker: None,
screen_reader: false,
log_scroll: 0,
event_ring: EventRing::new(),
live_stream_visible: false,
verbose: false,
wrap_off: false,
coaching_notices: Vec::new(),
rate_budget: None,
first_live_trade_recorded,
risk_overlay_last_dismissed_at: None,
risk_overlay_last_seen_alert_pct: None,
risk_overlay_last_trigger: None,
};
s.push(LogEntry::new(
EntryKind::System,
"zero — Ctrl+1..5 switch modes, Ctrl+C or /quit exits, /help for commands.",
));
s.poll_risk_overlay(Instant::now());
s
}
pub fn append_silent(&mut self, entry: LogEntry) {
self.log.push(entry);
}
pub fn push(&mut self, entry: LogEntry) {
if let Some(s) = &self.sink {
s.record(&entry);
}
self.log.push(entry);
}
pub fn push_system(&mut self, text: impl Into<String>) {
self.push(LogEntry::new(EntryKind::System, text));
}
pub fn submit_prompt(&mut self) {
let Some(line) = self.prompt.take() else {
return;
};
if line.trim().is_empty() {
return;
}
let echo = if line.contains('\n') {
line.replace('\n', " ↵ ")
} else {
line.clone()
};
self.push(LogEntry::new(EntryKind::Prompt, format!("> {echo}")));
self.pending_input = Some(line);
self.picker = None;
self.scroll_log_to_bottom();
}
pub fn apply_dispatch(&mut self, out: DispatchOutput) {
if out.clear_log {
self.log = ConversationLog::with_capacity(2048);
}
for line in out.lines {
let (kind, text) = match line {
OutputLine::System(t) => (EntryKind::System, t),
OutputLine::Command(t) => (EntryKind::Command, t),
OutputLine::Warn(t) => (EntryKind::Warn, t),
OutputLine::Alert(t) => (EntryKind::Alert, t),
};
self.push(LogEntry::new(kind, text));
}
for rl in out.replay_lines {
let kind = replay_kind_to_entry(rl.kind);
let entry = if let Some(ts) =
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(rl.at_ms)
{
LogEntry::new(kind, rl.text).at(ts)
} else {
LogEntry::new(kind, rl.text)
};
self.append_silent(entry);
}
if let Some(target) = out.mode_change {
self.mode = mode_from_target(target);
}
if let Some(ov) = out.show_overlay {
self.overlay = Some(ActiveOverlay::from_target(ov));
} else if out.dismiss_overlay {
self.overlay = None;
}
if let (Some(decision), Some(cmd)) = (out.friction, out.pending_command)
&& !matches!(decision, FrictionDecision::Proceed)
&& let Some(fp) = FrictionPause::from_decision(cmd, &decision, Instant::now())
{
self.overlay = Some(ActiveOverlay::FrictionPause(fp));
}
if out.quit {
self.should_quit = true;
}
if let Some(v) = out.verbose_toggle {
self.verbose = v;
}
if let Some(w) = out.wrap_off_toggle {
self.wrap_off = w;
}
if out.coaching_reset {
self.coaching_notices.clear();
}
}
pub fn dismiss_overlay(&mut self) {
self.dismiss_overlay_at(Instant::now());
}
pub fn dismiss_overlay_at(&mut self, now: Instant) {
if matches!(self.overlay, Some(ActiveOverlay::Risk { .. })) {
self.risk_overlay_last_dismissed_at = Some(now);
let alert_pct = self
.engine
.read()
.risk
.as_ref()
.and_then(|r| r.value.last_drawdown_alert_pct);
self.risk_overlay_last_seen_alert_pct = alert_pct;
}
self.overlay = None;
}
pub const RISK_DISMISS_COOLDOWN: Duration = Duration::from_secs(60);
pub const GUARDRAIL_PROXIMITY_PP: f64 = 0.5;
pub fn poll_risk_overlay(&mut self, now: Instant) {
if matches!(
self.overlay,
Some(
ActiveOverlay::State | ActiveOverlay::FrictionPause(_) | ActiveOverlay::Verdict(_)
)
) {
return;
}
let (trigger, current_alert_pct) = {
let eng = self.engine.read();
let friction = eng.operator_state.as_ref().map(|s| s.value.friction);
let (drawdown, alert) = eng.risk.as_ref().map_or((None, None), |r| {
(r.value.drawdown_pct, r.value.last_drawdown_alert_pct)
});
let proximity_hit = match (drawdown, alert) {
(Some(d), Some(a)) => (d - a).abs() <= Self::GUARDRAIL_PROXIMITY_PP,
_ => false,
};
let trigger = match friction {
Some(FrictionLevel::L4) => Some(RiskOverlayTrigger::Friction(FrictionLevel::L4)),
Some(FrictionLevel::L3) => Some(RiskOverlayTrigger::Friction(FrictionLevel::L3)),
_ if proximity_hit => Some(RiskOverlayTrigger::Proximity),
_ => None,
};
(trigger, alert)
};
let Some(trigger) = trigger else {
return;
};
if let Some(ActiveOverlay::Risk {
trigger: current, ..
}) = self.overlay
{
if trigger_strictly_escalates(current, trigger) {
self.overlay = Some(ActiveOverlay::Risk {
trigger,
opened_at: now,
});
self.risk_overlay_last_trigger = Some(trigger);
}
return;
}
if let Some(dismissed_at) = self.risk_overlay_last_dismissed_at {
let within_cooldown = now.duration_since(dismissed_at) < Self::RISK_DISMISS_COOLDOWN;
let fresh_alert = match (current_alert_pct, self.risk_overlay_last_seen_alert_pct) {
(Some(cur), Some(prev)) => (cur - prev).abs() > f64::EPSILON,
(Some(_), None) | (None, Some(_)) => true,
(None, None) => false,
};
let escalates = self
.risk_overlay_last_trigger
.is_some_and(|prev| trigger_strictly_escalates(prev, trigger));
if within_cooldown && !fresh_alert && !escalates {
return;
}
}
self.overlay = Some(ActiveOverlay::Risk {
trigger,
opened_at: now,
});
self.risk_overlay_last_trigger = Some(trigger);
}
pub fn refresh_picker(&mut self) {
if self.overlay.is_some() {
self.picker = None;
return;
}
let first = self
.prompt
.line(0)
.map(|chars| chars.iter().collect::<String>())
.unwrap_or_default();
let prev_name = self
.picker
.as_ref()
.and_then(SlashPicker::selected)
.map(|m| m.info.name);
let new_picker = SlashPicker::from_prompt_line(&first);
self.picker = new_picker.map(|mut p| {
if let Some(name) = prev_name
&& let Some(i) = p.matches().iter().position(|m| m.info.name == name)
{
for _ in 0..i {
p.select_next();
}
}
p
});
}
pub fn scroll_log_up(&mut self, rows: u16) {
self.log_scroll = self.log_scroll.saturating_add(rows);
}
pub fn scroll_log_down(&mut self, rows: u16) {
self.log_scroll = self.log_scroll.saturating_sub(rows);
}
pub fn scroll_log_to_bottom(&mut self) {
self.log_scroll = 0;
}
pub fn toggle_screen_reader(&mut self) -> bool {
self.screen_reader = !self.screen_reader;
self.screen_reader
}
pub fn toggle_live_stream(&mut self) -> bool {
self.live_stream_visible = !self.live_stream_visible;
self.live_stream_visible
}
pub fn record_engine_event(&mut self, evt: EngineEvent) {
self.maybe_fire_first_live_trade_ceremony(&evt);
self.event_ring.push_event(evt);
}
pub fn record_engine_event_at(&mut self, evt: EngineEvent, ts: chrono::DateTime<chrono::Utc>) {
self.maybe_fire_first_live_trade_ceremony(&evt);
self.event_ring.push_event_at(evt, ts);
}
fn maybe_fire_first_live_trade_ceremony(&mut self, evt: &EngineEvent) {
if self.first_live_trade_recorded {
return;
}
let EngineEvent::Positions(p) = evt else {
return;
};
if p.items.is_empty() {
return;
}
self.first_live_trade_recorded = true;
if let Some(sink) = &self.sink {
let now = chrono::Utc::now().to_rfc3339();
if let Err(e) = sink
.store()
.set_milestone(zero_session::milestones::FIRST_LIVE_TRADE_AT, &now)
{
tracing::warn!(err = %e, "first-live-trade milestone write failed");
}
}
for text in CEREMONY_LINES {
self.push(LogEntry::new(EntryKind::System, *text));
}
}
pub fn record_events_lagged(&mut self, skipped: u64) {
self.event_ring.push_lagged(skipped);
}
pub fn record_events_lagged_at(&mut self, skipped: u64, ts: chrono::DateTime<chrono::Utc>) {
self.event_ring.push_lagged_at(skipped, ts);
}
#[must_use]
pub fn take_confirmed_friction_command(&mut self, now: Instant) -> Option<Command> {
let confirmed = match self.overlay.as_ref() {
Some(ActiveOverlay::FrictionPause(fp)) => {
matches!(fp.outcome(now), FrictionOutcome::Confirmed)
}
_ => false,
};
if !confirmed {
return None;
}
match self.overlay.take() {
Some(ActiveOverlay::FrictionPause(fp)) => Some(fp.command),
_ => None,
}
}
}
fn mode_from_target(t: ModeTarget) -> Mode {
match t {
ModeTarget::Conversation => Mode::Conversation,
ModeTarget::Positions => Mode::Positions,
ModeTarget::Decisions => Mode::Decisions,
ModeTarget::Heat => Mode::Heat,
ModeTarget::Cockpit => Mode::Cockpit,
}
}
const fn replay_kind_to_entry(k: ReplayKind) -> EntryKind {
match k {
ReplayKind::Prompt => EntryKind::Prompt,
ReplayKind::System => EntryKind::System,
ReplayKind::Command => EntryKind::Command,
ReplayKind::Warn => EntryKind::Warn,
ReplayKind::Alert => EntryKind::Alert,
}
}
#[cfg(test)]
mod tests {
use super::*;
use zero_commands::OverlayTarget;
fn mk() -> AppState {
AppState::new(EngineState::shared())
}
fn is_state(ov: Option<&ActiveOverlay>) -> bool {
matches!(ov, Some(ActiveOverlay::State))
}
fn is_friction(ov: Option<&ActiveOverlay>) -> bool {
matches!(ov, Some(ActiveOverlay::FrictionPause(_)))
}
#[test]
fn apply_dispatch_honors_verbose_toggle_absolute() {
let mut s = mk();
assert!(!s.verbose);
s.apply_dispatch(DispatchOutput {
verbose_toggle: Some(true),
..Default::default()
});
assert!(s.verbose);
s.apply_dispatch(DispatchOutput {
verbose_toggle: Some(true),
..Default::default()
});
assert!(s.verbose);
s.apply_dispatch(DispatchOutput {
verbose_toggle: Some(false),
..Default::default()
});
assert!(!s.verbose);
s.verbose = true;
s.apply_dispatch(DispatchOutput::default());
assert!(s.verbose);
}
#[test]
fn apply_dispatch_honors_wrap_off_absolute_and_leaves_unrelated_alone() {
let mut s = mk();
assert!(!s.wrap_off);
s.apply_dispatch(DispatchOutput {
wrap_off_toggle: Some(true),
..Default::default()
});
assert!(s.wrap_off);
s.apply_dispatch(DispatchOutput::default());
assert!(s.wrap_off, "unrelated dispatch must not clear the opt-out");
}
#[test]
fn apply_dispatch_honors_coaching_reset_signal() {
let mut s = mk();
s.coaching_notices.push("loss-reaction < 2m".into());
s.coaching_notices.push("velocity 2x baseline".into());
s.apply_dispatch(DispatchOutput {
coaching_reset: true,
..Default::default()
});
assert!(s.coaching_notices.is_empty());
s.coaching_notices.push("fresh notice".into());
s.apply_dispatch(DispatchOutput::default());
assert_eq!(s.coaching_notices.len(), 1);
}
#[test]
fn apply_dispatch_sets_overlay_from_show_overlay() {
let mut s = mk();
assert!(s.overlay.is_none());
let out = DispatchOutput {
show_overlay: Some(OverlayTarget::State),
..Default::default()
};
s.apply_dispatch(out);
assert!(is_state(s.overlay.as_ref()));
}
#[test]
fn dismiss_overlay_is_idempotent() {
let mut s = mk();
s.overlay = Some(ActiveOverlay::State);
s.dismiss_overlay();
assert!(s.overlay.is_none());
s.dismiss_overlay();
assert!(s.overlay.is_none());
}
#[test]
fn apply_dispatch_preserves_overlay_when_not_signaled() {
let mut s = mk();
s.overlay = Some(ActiveOverlay::State);
let out = DispatchOutput {
mode_change: Some(ModeTarget::Heat),
..Default::default()
};
s.apply_dispatch(out);
assert!(
is_state(s.overlay.as_ref()),
"unrelated dispatches must not close the overlay"
);
}
#[test]
fn apply_dispatch_honors_explicit_dismiss_overlay() {
let mut s = mk();
s.overlay = Some(ActiveOverlay::State);
let out = DispatchOutput {
dismiss_overlay: true,
..Default::default()
};
s.apply_dispatch(out);
assert!(
s.overlay.is_none(),
"explicit dismiss_overlay must clear the overlay"
);
}
#[test]
fn apply_dispatch_show_overlay_wins_over_dismiss() {
let mut s = mk();
s.overlay = Some(ActiveOverlay::State);
let out = DispatchOutput {
show_overlay: Some(OverlayTarget::State),
dismiss_overlay: true,
..Default::default()
};
s.apply_dispatch(out);
assert!(
is_state(s.overlay.as_ref()),
"show_overlay must win when both are set"
);
}
#[test]
fn apply_dispatch_opens_friction_overlay_on_l1_pause() {
let mut s = mk();
let out = DispatchOutput {
friction: Some(FrictionDecision::Pause {
pause: Duration::from_secs(3),
level: FrictionLevel::L1,
}),
pending_command: Some(Command::Execute),
..Default::default()
};
s.apply_dispatch(out);
assert!(
is_friction(s.overlay.as_ref()),
"L1 should open friction overlay"
);
if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
assert_eq!(fp.level, FrictionLevel::L1);
assert!(fp.confirm_word.is_none());
}
}
#[test]
fn apply_dispatch_opens_friction_overlay_on_l2_typed_confirm() {
let mut s = mk();
let out = DispatchOutput {
friction: Some(FrictionDecision::TypedConfirm {
pause: Duration::from_secs(10),
level: FrictionLevel::L2,
}),
pending_command: Some(Command::Execute),
..Default::default()
};
s.apply_dispatch(out);
if let Some(ActiveOverlay::FrictionPause(fp)) = &s.overlay {
assert_eq!(fp.level, FrictionLevel::L2);
assert_eq!(fp.confirm_word.as_deref(), Some("execute"));
} else {
panic!("expected FrictionPause, got {:?}", s.overlay);
}
}
#[test]
fn apply_dispatch_without_pending_command_does_not_open_friction() {
let mut s = mk();
let out = DispatchOutput {
friction: Some(FrictionDecision::Pause {
pause: Duration::from_secs(3),
level: FrictionLevel::L1,
}),
pending_command: None,
..Default::default()
};
s.apply_dispatch(out);
assert!(s.overlay.is_none());
}
#[test]
fn l1_pause_completes_after_duration_elapses() {
let now = Instant::now();
let fp = FrictionPause {
command: Command::Execute,
level: FrictionLevel::L1,
started_at: now,
pause: Duration::from_secs(3),
confirm_word: None,
confirm_input: String::new(),
};
assert_eq!(fp.outcome(now), FrictionOutcome::Pending);
assert_eq!(
fp.outcome(now + Duration::from_millis(2_999)),
FrictionOutcome::Pending,
);
assert_eq!(
fp.outcome(now + Duration::from_secs(3)),
FrictionOutcome::Confirmed,
);
}
#[test]
fn l2_requires_both_pause_and_word() {
let now = Instant::now();
let mut fp = FrictionPause {
command: Command::Execute,
level: FrictionLevel::L2,
started_at: now,
pause: Duration::from_secs(10),
confirm_word: Some("execute".into()),
confirm_input: String::new(),
};
let past_pause = now + Duration::from_secs(10);
for c in "execute".chars() {
fp.push_char(c, now + Duration::from_secs(1));
}
assert!(
fp.confirm_input.is_empty(),
"input during mandatory pause must be ignored"
);
assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
for c in "exec".chars() {
fp.push_char(c, past_pause);
}
assert_eq!(fp.confirm_input, "exec");
assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
assert!(!fp.confirm_word_matches());
for c in "ute".chars() {
fp.push_char(c, past_pause);
}
assert_eq!(fp.confirm_input, "execute");
assert!(fp.confirm_word_matches());
assert_eq!(fp.outcome(past_pause), FrictionOutcome::Confirmed);
fp.pop_char(past_pause);
assert_eq!(fp.outcome(past_pause), FrictionOutcome::Pending);
}
#[test]
fn take_confirmed_command_consumes_overlay() {
let now = Instant::now();
let fp = FrictionPause {
command: Command::Execute,
level: FrictionLevel::L1,
started_at: now,
pause: Duration::from_secs(0),
confirm_word: None,
confirm_input: String::new(),
};
let mut s = mk();
s.overlay = Some(ActiveOverlay::FrictionPause(fp));
let taken = s.take_confirmed_friction_command(now);
assert_eq!(taken, Some(Command::Execute));
assert!(s.overlay.is_none());
assert!(s.take_confirmed_friction_command(now).is_none());
}
#[test]
fn take_confirmed_leaves_pending_overlay_in_place() {
let now = Instant::now();
let fp = FrictionPause {
command: Command::Execute,
level: FrictionLevel::L1,
started_at: now,
pause: Duration::from_secs(3),
confirm_word: None,
confirm_input: String::new(),
};
let mut s = mk();
s.overlay = Some(ActiveOverlay::FrictionPause(fp));
let taken = s.take_confirmed_friction_command(now + Duration::from_secs(1));
assert_eq!(taken, None, "still within pause window");
assert!(matches!(s.overlay, Some(ActiveOverlay::FrictionPause(_))));
}
#[test]
fn take_confirmed_ignores_non_friction_overlays() {
let mut s = mk();
s.overlay = Some(ActiveOverlay::State);
let taken = s.take_confirmed_friction_command(Instant::now());
assert!(taken.is_none());
assert!(is_state(s.overlay.as_ref()));
}
#[test]
fn live_stream_starts_hidden_and_toggles_round_trip() {
let mut s = mk();
assert!(
!s.live_stream_visible,
"new state must start with the pane hidden"
);
let on = s.toggle_live_stream();
assert!(on && s.live_stream_visible);
let off = s.toggle_live_stream();
assert!(!off && !s.live_stream_visible);
}
#[test]
fn record_engine_event_appends_to_ring() {
let mut s = mk();
assert_eq!(s.event_ring.len(), 0);
s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
assert_eq!(s.event_ring.len(), 2);
}
#[test]
fn record_events_lagged_appends_marker_without_losing_prior_events() {
let mut s = mk();
s.record_engine_event(EngineEvent::Heartbeat(chrono::Utc::now()));
s.record_events_lagged(4);
assert_eq!(s.event_ring.len(), 2);
}
fn mk_with_fresh_store() -> (AppState, std::sync::Arc<zero_session::Store>) {
use zero_session::Store;
let store = std::sync::Arc::new(Store::open_in_memory().unwrap());
let id = store
.start_session("01HCRM", None, "0.3.0-test", None)
.unwrap();
let sink = crate::app::session::SessionSink::new(
std::sync::Arc::clone(&store),
id,
"01HCRM".into(),
);
(
AppState::new_with_sink(EngineState::shared(), Some(sink)),
store,
)
}
fn positions_with_items(n: usize) -> EngineEvent {
use zero_engine_client::models::{Position, Positions};
let items = (0..n)
.map(|i| Position {
symbol: format!("COIN{i}"),
..Position::default()
})
.collect();
EngineEvent::Positions(Box::new(Positions {
items,
account_value: None,
total_unrealized_pnl: None,
}))
}
#[test]
fn ceremony_suppressed_without_sink() {
let mut s = mk();
let before = s.log.len();
s.record_engine_event(positions_with_items(3));
assert_eq!(
s.log.len(),
before,
"no-persist run must not render the ceremony"
);
assert!(s.first_live_trade_recorded);
}
#[test]
fn ceremony_fires_on_first_nonempty_positions_and_persists_milestone() {
use zero_session::milestones::FIRST_LIVE_TRADE_AT;
let (mut s, store) = mk_with_fresh_store();
assert!(!s.first_live_trade_recorded);
assert_eq!(store.get_milestone(FIRST_LIVE_TRADE_AT).unwrap(), None);
let before = s.log.len();
s.record_engine_event(positions_with_items(1));
assert_eq!(
s.log.len() - before,
CEREMONY_LINES.len(),
"exactly one ceremony line per CEREMONY_LINES entry"
);
assert!(s.first_live_trade_recorded);
let stored = store
.get_milestone(FIRST_LIVE_TRADE_AT)
.unwrap()
.expect("milestone was set");
assert!(
chrono::DateTime::parse_from_rfc3339(&stored).is_ok(),
"milestone value must be RFC-3339 (got {stored:?})"
);
}
#[test]
fn ceremony_never_fires_on_empty_positions() {
let (mut s, _store) = mk_with_fresh_store();
let before = s.log.len();
s.record_engine_event(positions_with_items(0));
assert_eq!(
s.log.len(),
before,
"empty positions must not fire ceremony"
);
assert!(!s.first_live_trade_recorded);
}
#[test]
fn ceremony_fires_at_most_once_per_session() {
let (mut s, _store) = mk_with_fresh_store();
s.record_engine_event(positions_with_items(1));
let after_first = s.log.len();
s.record_engine_event(positions_with_items(1));
s.record_engine_event(positions_with_items(2));
assert_eq!(
s.log.len(),
after_first,
"subsequent Positions events must not re-fire the ceremony"
);
}
#[test]
fn ceremony_suppressed_when_milestone_already_set() {
use zero_session::Store;
use zero_session::milestones::FIRST_LIVE_TRADE_AT;
let store = std::sync::Arc::new(Store::open_in_memory().unwrap());
store
.set_milestone(FIRST_LIVE_TRADE_AT, "2026-04-20T12:00:00Z")
.unwrap();
let id = store
.start_session("01HCRM2", None, "0.3.0-test", None)
.unwrap();
let sink = crate::app::session::SessionSink::new(
std::sync::Arc::clone(&store),
id,
"01HCRM2".into(),
);
let mut s = AppState::new_with_sink(EngineState::shared(), Some(sink));
assert!(
s.first_live_trade_recorded,
"latch must pre-close on hydrate"
);
let before = s.log.len();
s.record_engine_event(positions_with_items(5));
assert_eq!(
s.log.len(),
before,
"a seasoned operator must never see the first-trade ceremony"
);
}
mod risk_overlay {
use super::*;
use chrono::TimeZone;
use zero_engine_client::{Risk, Source, Stat};
use zero_operator_state::{Label, Snapshot, StateVector, friction::FrictionLevel};
fn frozen() -> chrono::DateTime<chrono::Utc> {
chrono::Utc.with_ymd_and_hms(2026, 4, 21, 18, 0, 0).unwrap()
}
fn snap_at(label: Label, friction: FrictionLevel) -> Stat<Snapshot> {
let snap = Snapshot {
label,
friction,
vector: StateVector::default(),
as_of: frozen(),
version: 1,
};
Stat::new(snap, Source::Ws).with_as_of(frozen())
}
fn risk_stat(
drawdown_pct: Option<f64>,
alert_pct: Option<f64>,
halted: bool,
) -> Stat<Risk> {
let risk = Risk {
drawdown_pct,
last_drawdown_alert_pct: alert_pct,
halted,
..Risk::default()
};
Stat::new(risk, Source::Ws).with_as_of(frozen())
}
fn empty_state() -> AppState {
let s = AppState::new_with_sink(EngineState::shared(), None);
assert!(s.overlay.is_none(), "empty engine => no overlay");
s
}
fn seed_l3(state: &AppState) {
let mut eng = state.engine.write();
eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L3));
}
fn seed_l4(state: &AppState) {
let mut eng = state.engine.write();
eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L4));
}
fn seed_proximity(state: &AppState, drawdown: f64, alert: f64) {
let mut eng = state.engine.write();
eng.risk = Some(risk_stat(Some(drawdown), Some(alert), false));
}
#[test]
fn poll_opens_overlay_on_l3_snapshot() {
let mut s = empty_state();
seed_l3(&s);
s.poll_risk_overlay(Instant::now());
match &s.overlay {
Some(ActiveOverlay::Risk { trigger, .. }) => {
assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L3));
}
other => panic!("expected Risk overlay, got {other:?}"),
}
}
#[test]
fn poll_opens_overlay_on_l4_snapshot() {
let mut s = empty_state();
seed_l4(&s);
s.poll_risk_overlay(Instant::now());
match &s.overlay {
Some(ActiveOverlay::Risk { trigger, .. }) => {
assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L4));
}
other => panic!("expected Risk overlay, got {other:?}"),
}
}
#[test]
fn poll_opens_overlay_on_drawdown_proximity_below_threshold() {
let mut s = empty_state();
seed_proximity(&s, 4.6, 5.0);
s.poll_risk_overlay(Instant::now());
match &s.overlay {
Some(ActiveOverlay::Risk { trigger, .. }) => {
assert_eq!(*trigger, RiskOverlayTrigger::Proximity);
}
other => panic!("expected Risk overlay, got {other:?}"),
}
}
#[test]
fn poll_does_not_open_overlay_when_drawdown_far_from_alert() {
let mut s = empty_state();
seed_proximity(&s, 3.0, 5.0);
s.poll_risk_overlay(Instant::now());
assert!(s.overlay.is_none(), "2.0pp distance must not open");
}
#[test]
fn dismiss_then_poll_within_cooldown_does_not_reopen() {
let mut s = empty_state();
seed_l3(&s);
let t0 = Instant::now();
s.poll_risk_overlay(t0);
assert!(matches!(s.overlay, Some(ActiveOverlay::Risk { .. })));
s.dismiss_overlay_at(t0 + Duration::from_secs(5));
assert!(s.overlay.is_none());
s.poll_risk_overlay(t0 + Duration::from_secs(15));
assert!(
s.overlay.is_none(),
"same-trigger reopen inside cooldown must be suppressed"
);
}
#[test]
fn dismiss_then_poll_after_cooldown_reopens() {
let mut s = empty_state();
seed_l3(&s);
let t0 = Instant::now();
s.poll_risk_overlay(t0);
s.dismiss_overlay_at(t0 + Duration::from_secs(5));
let t_reopen = t0 + Duration::from_secs(5) + AppState::RISK_DISMISS_COOLDOWN;
s.poll_risk_overlay(t_reopen);
assert!(
matches!(s.overlay, Some(ActiveOverlay::Risk { .. })),
"past cooldown, signal still live => must reopen, got {:?}",
s.overlay,
);
}
#[test]
fn escalation_l3_to_l4_overrides_cooldown() {
let mut s = empty_state();
seed_l3(&s);
let t0 = Instant::now();
s.poll_risk_overlay(t0);
s.dismiss_overlay_at(t0 + Duration::from_secs(5));
seed_l4(&s);
s.poll_risk_overlay(t0 + Duration::from_secs(15));
match &s.overlay {
Some(ActiveOverlay::Risk { trigger, .. }) => {
assert_eq!(
*trigger,
RiskOverlayTrigger::Friction(FrictionLevel::L4),
"L4 escalation must override dismiss-cooldown",
);
}
other => panic!("expected L4 Risk overlay, got {other:?}"),
}
}
#[test]
fn fresh_alert_threshold_overrides_cooldown() {
let mut s = empty_state();
seed_proximity(&s, 4.6, 5.0);
let t0 = Instant::now();
s.poll_risk_overlay(t0);
assert!(matches!(s.overlay, Some(ActiveOverlay::Risk { .. })));
s.dismiss_overlay_at(t0 + Duration::from_secs(5));
{
let mut eng = s.engine.write();
eng.risk = Some(risk_stat(Some(5.6), Some(6.0), false));
}
s.poll_risk_overlay(t0 + Duration::from_secs(15));
assert!(
matches!(s.overlay, Some(ActiveOverlay::Risk { .. })),
"fresh guardrail threshold must override cooldown",
);
}
#[test]
fn poll_does_not_stomp_friction_pause_overlay() {
let mut s = empty_state();
seed_l3(&s);
let fp = FrictionPause {
command: Command::Help,
level: FrictionLevel::L1,
pause: Duration::from_secs(3),
started_at: Instant::now(),
confirm_word: None,
confirm_input: String::new(),
};
s.overlay = Some(ActiveOverlay::FrictionPause(fp));
s.poll_risk_overlay(Instant::now());
assert!(
matches!(s.overlay, Some(ActiveOverlay::FrictionPause(_))),
"poll must defer to an active friction pause",
);
}
#[test]
fn l4_mirror_on_construction_opens_overlay_with_hardstop_trigger() {
let engine = EngineState::shared();
{
let mut eng = engine.write();
eng.operator_state = Some(snap_at(Label::Tilt, FrictionLevel::L4));
}
let s = AppState::new_with_sink(engine, None);
match &s.overlay {
Some(ActiveOverlay::Risk { trigger, .. }) => {
assert_eq!(*trigger, RiskOverlayTrigger::Friction(FrictionLevel::L4));
}
other => panic!("expected L4 Risk overlay at construction, got {other:?}"),
}
}
}
}