use std::sync::{Arc, Mutex};
use zero_commands::config::MockConfig;
use zero_commands::{
ConfigDoctorFinding, DispatchContext, ModeTarget, OutputLine, OverlayTarget, ReplayEvent,
ReplayKind, RiskDirection, SessionError, SessionSource, SessionSummary, dispatch,
};
use zero_engine_client::{EngineState, HttpClient};
use zero_testkit::mock_engine::MockEngine;
async fn ctx_with_mock() -> (MockEngine, DispatchContext) {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None).expect("client");
let ctx = DispatchContext::new(Some(client), EngineState::shared());
(mock, ctx)
}
#[tokio::test]
async fn status_renders_engine_summary() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/status").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let line = &out.lines[0];
let OutputLine::Command(s) = line else {
panic!("expected Command line, got {line:?}");
};
assert!(s.contains("regime="));
assert!(s.contains("equity="));
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("recovery: recovered") && s.contains("journal=durable"))),
"recovery row missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn brief_emits_headline_or_system() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/brief").await.unwrap().unwrap();
assert!(!out.lines.is_empty());
mock.shutdown().await;
}
#[tokio::test]
async fn risk_command_decodes_summary() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/risk").await.unwrap().unwrap();
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("risk: OK"), "state prefix: {s}");
assert!(s.contains("equity="), "equity field: {s}");
assert!(s.contains("peak="), "peak-equity field: {s}");
assert!(s.contains("dd="), "drawdown field: {s}");
assert!(s.contains("daily-pnl="), "daily-pnl field: {s}");
assert!(s.contains("daily-loss="), "daily-loss field: {s}");
assert!(s.contains("open="), "open-count field: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn hl_status_renders_read_only_exchange_status() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/hl-status BTC").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("hl: enabled"), "enabled row: {s}");
assert!(s.contains("secrets_required=false"), "secrets field: {s}");
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("BTC"))),
"BTC mid row missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn hl_account_renders_read_only_account_truth() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/hl-account").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("hl-account:"), "account row: {s}");
assert!(s.contains("equity=$10000.00"), "equity field: {s}");
assert!(s.contains("open_orders=1"), "open orders field: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn hl_reconcile_renders_reconciliation_status() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/hl-reconcile").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("hl-reconcile: status=ok"), "reconcile row: {s}");
assert!(
s.contains("risk_increasing_allowed=true"),
"risk gate field: {s}"
);
mock.shutdown().await;
}
#[tokio::test]
async fn live_certify_renders_dry_run_certification_status() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/live-certify").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("live-certify: passed=true"), "certify row: {s}");
assert!(
s.contains("live_start_certified=true"),
"certification field: {s}"
);
assert!(s.contains("drills=10/10"), "drill count: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn live_cockpit_renders_readiness_and_next_action() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/live-cockpit").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("live-cockpit:"), "cockpit row: {s}");
assert!(s.contains("live_mode=refused"), "live mode field: {s}");
assert!(s.contains("risk_allowed=false"), "risk allowed field: {s}");
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("next:"))),
"next action missing: {:?}",
out.lines
);
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("operator: handle=mock-operator"))),
"operator context missing: {:?}",
out.lines
);
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("breaker:dead_man"))),
"dead_man breaker missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn live_evidence_renders_hash_only_bundle() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/live-evidence").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("live-evidence:"), "evidence row: {s}");
assert!(s.contains("live_mode=refused"), "live mode field: {s}");
assert!(s.contains("artifacts=9"), "artifact count: {s}");
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("signature: status=unsigned_local"))
),
"signature line missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn live_receipts_renders_public_safe_receipt_summary() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/live-receipts").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("live-receipts:"), "receipts row: {s}");
assert!(s.contains("status=empty"), "status field: {s}");
assert!(s.contains("total=0"), "total field: {s}");
assert!(s.contains("accepted=0"), "accepted field: {s}");
assert!(s.contains("hash=sha256:cdcdcdcdcdcd..."), "hash field: {s}");
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("operator: handle=mock-operator"))
),
"operator context missing: {:?}",
out.lines
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("credentials=false") && s.contains("idempotency_tokens=false"))
),
"privacy boundary missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn live_canary_policy_renders_readiness_and_claim_boundary() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/live-canary").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("live-canary:"), "canary row: {s}");
assert!(s.contains("ready=false"), "readiness field: {s}");
assert!(s.contains("armed=false"), "armed field: {s}");
assert!(s.contains("qualified=true"), "qualification field: {s}");
assert!(
s.contains("publishable=false"),
"publishable live proof boundary: {s}"
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("next: keep_public_claim_at_refusal_proof"))
),
"next action missing: {:?}",
out.lines
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("refusal_qualified=true"))
),
"refusal proof boundary missing: {:?}",
out.lines
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("phase:qualification pass"))
),
"qualification phase missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn runtime_parity_renders_production_ooda_boundary() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/runtime-parity").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("runtime-parity:"), "parity row: {s}");
assert!(s.contains("ok=true"), "ok field: {s}");
assert!(
s.contains("production_ooda=true"),
"production parity field: {s}"
);
assert!(
s.contains("live_trading_claimed=false"),
"live claim boundary: {s}"
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("live-shadow: mode=disabled-fail-closed") && s.contains("adapter_orders=0"))
),
"live-shadow row missing: {:?}",
out.lines
);
assert!(
out.lines.iter().any(
|line| matches!(line, OutputLine::System(s) if s.contains("operator_owned_canary_required=true"))
),
"canary boundary missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn immune_renders_risk_blocking_breakers() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/immune").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(
s.contains("immune: risk_increasing_allowed=false"),
"immune row: {s}"
);
assert!(s.contains("open=2"), "open count: {s}");
assert!(
out.lines
.iter()
.any(|line| matches!(line, OutputLine::System(s) if s.contains("dead_man"))),
"dead_man breaker missing: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn quote_renders_active_quote_source() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/quote BTC").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("quote BTC:"), "quote prefix: {s}");
assert!(s.contains("40500.0000"), "price: {s}");
assert!(s.contains("source=paper:static"), "source: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn quote_without_coin_emits_usage_hint() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/quote").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert!(
matches!(&out.lines[0], OutputLine::Warn(s) if s.contains("/quote <coin>")),
"usage line: {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn execute_posts_real_order_shape_after_friction() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/execute BTC buy 0.001")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Increases));
assert!(
matches!(&out.lines[0], OutputLine::Alert(s) if s.contains("/execute — accepted") && s.contains("BTC buy 0.001") && s.contains("receipt=sha256:")),
"execute line: {:?}",
out.lines
);
let captured = mock.received_executes();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].body["coin"], "BTC");
assert_eq!(captured[0].body["side"], "buy");
assert_eq!(captured[0].body["size"], 0.001);
assert!(
captured[0].body["idempotency_key"]
.as_str()
.is_some_and(|key| !key.is_empty()),
"missing idempotency key: {:?}",
captured[0].body
);
mock.shutdown().await;
}
#[tokio::test]
async fn execute_usage_does_not_post() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/execute BTC buy nope")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert!(
matches!(&out.lines[0], OutputLine::Warn(s) if s.contains("size must be a positive number")),
"usage line: {:?}",
out.lines
);
assert!(mock.received_executes().is_empty());
mock.shutdown().await;
}
#[tokio::test]
async fn risk_reducers_post_live_control_endpoints() {
let (mock, ctx) = ctx_with_mock().await;
let kill = dispatch(&ctx, "/kill").await.unwrap().unwrap();
assert!(
matches!(&kill.lines[0], OutputLine::Alert(s) if s.contains("live kill accepted")),
"kill line: {:?}",
kill.lines,
);
let pause = dispatch(&ctx, "/pause-entries").await.unwrap().unwrap();
assert!(
matches!(&pause.lines[0], OutputLine::Alert(s) if s.contains("live entries pause accepted")),
"pause line: {:?}",
pause.lines,
);
let flatten = dispatch(&ctx, "/flatten-all").await.unwrap().unwrap();
assert!(
matches!(&flatten.lines[0], OutputLine::Alert(s) if s.contains("live flatten accepted") && s.contains("orders=1")),
"flatten line: {:?}",
flatten.lines,
);
assert_eq!(
mock.received_live_controls(),
vec!["/live/kill", "/live/pause", "/live/flatten"],
);
mock.shutdown().await;
}
#[tokio::test]
async fn risk_flags_equity_above_peak_inconsistency() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_stale_risk_equity = true);
let out = dispatch(&ctx, "/risk").await.unwrap().unwrap();
let OutputLine::Command(primary) = &out.lines[0] else {
panic!("expected Command line first, got {:?}", out.lines);
};
assert!(
primary.contains("equity=$638.49"),
"equity still rendered: {primary}"
);
assert!(
primary.contains("peak=$577.34"),
"peak still rendered: {primary}"
);
assert!(
primary.contains("dd=—"),
"dd must be suppressed when equity > peak: {primary}"
);
assert!(
!primary.contains("dd=0.22%"),
"stale dd percent must not leak through: {primary}"
);
let warn = out
.lines
.iter()
.find_map(|l| match l {
OutputLine::Warn(s) => Some(s.clone()),
_ => None,
})
.expect("warn line present");
assert!(
warn.to_lowercase().contains("inconsistent"),
"warn flags the inconsistency: {warn}"
);
assert!(
warn.contains("equity > peak"),
"warn names the contradiction: {warn}"
);
mock.shutdown().await;
}
#[tokio::test]
async fn regime_without_coin_uses_market_label() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/regime").await.unwrap().unwrap();
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.starts_with("regime[market]"));
mock.shutdown().await;
}
#[tokio::test]
async fn regime_with_coin_uses_coin_label() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/regime SOL").await.unwrap().unwrap();
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("[SOL]"));
mock.shutdown().await;
}
#[tokio::test]
async fn regime_empty_body_alerts_instead_of_emdashes() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_empty_regime = true);
let out = dispatch(&ctx, "/regime").await.unwrap().unwrap();
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert on empty regime, got {:?}", out.lines);
};
assert!(s.contains("regime[market]"), "alert scopes the coin: {s}");
assert!(
s.to_lowercase().contains("empty") || s.to_lowercase().contains("no regime"),
"alert explains the empty body: {s}"
);
assert!(!s.contains("—"), "must not render em-dash data: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn regime_error_envelope_surfaces_engine_message() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_regime_error_envelope = true);
let out = dispatch(&ctx, "/regime FAKE").await.unwrap().unwrap();
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert on error envelope, got {:?}", out.lines);
};
assert!(s.contains("[FAKE]"), "alert scopes the coin: {s}");
assert!(
s.contains("coin not found"),
"alert echoes the engine message verbatim: {s}"
);
mock.shutdown().await;
}
#[tokio::test]
async fn positions_lists_items() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/pos").await.unwrap().unwrap();
assert!(!out.lines.is_empty());
mock.shutdown().await;
}
#[tokio::test]
async fn mode_switch_emits_target() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/heat-mode").await.unwrap().unwrap();
assert_eq!(out.mode_change, Some(ModeTarget::Heat));
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let out = dispatch(&ctx, "/cockpit-mode").await.unwrap().unwrap();
assert_eq!(out.mode_change, Some(ModeTarget::Cockpit));
assert_eq!(out.risk, Some(RiskDirection::Neutral));
mock.shutdown().await;
}
#[tokio::test]
async fn heat_readout_summarizes_risk() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/heat").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert_eq!(out.mode_change, None, "inline command must not mode-switch");
assert_eq!(out.lines.len(), 1, "heat emits exactly one line");
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.starts_with("heat: "), "line prefix is stable: {s}");
assert!(s.contains("dd="), "drawdown field present: {s}");
assert!(s.contains("daily-loss="), "daily-loss field present: {s}");
assert!(s.contains("open="), "open-count field present: {s}");
assert!(s.contains("halted="), "halted flag present: {s}");
assert!(s.contains("floor="), "capital-floor flag present: {s}");
assert!(
!s.contains("CRITICAL"),
"healthy mock should not report CRITICAL: {s}"
);
mock.shutdown().await;
}
#[tokio::test]
async fn heat_surfaces_alert_when_engine_rejects() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_server_error = true);
let out = dispatch(&ctx, "/heat").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
assert!(matches!(out.lines[0], OutputLine::Alert(_)));
let OutputLine::Alert(s) = &out.lines[0] else {
unreachable!();
};
assert!(s.starts_with("heat: "), "alert prefix matches: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn heat_without_http_alerts_operator() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/heat").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
assert!(matches!(out.lines[0], OutputLine::Alert(_)));
}
#[tokio::test]
async fn quit_is_risk_reducer_and_flags_quit() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/quit").await.unwrap().unwrap();
assert!(out.quit);
assert_eq!(out.risk, Some(RiskDirection::Reduces));
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_opens_verdict_overlay_with_engine_payload() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/evaluate BTC").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert!(
out.lines.is_empty(),
"verdict dispatch must not emit duplicate text lines: {:?}",
out.lines
);
match out.show_overlay {
Some(OverlayTarget::Verdict(eval)) => {
assert_eq!(eval.coin.as_deref(), Some("BTC"));
assert_eq!(eval.verdict(), "REJECT");
assert_eq!(eval.layers.len(), 3);
assert_eq!(eval.layers[0].layer, "layer_0");
assert_eq!(eval.direction.as_deref(), Some("NONE"));
assert!(eval.conviction.is_some());
}
other => panic!("expected Verdict overlay, got {other:?}"),
}
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_without_coin_emits_usage_hint() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/evaluate").await.unwrap().unwrap();
assert!(
out.show_overlay.is_none(),
"missing coin must NOT open an overlay"
);
assert_eq!(out.lines.len(), 1);
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn usage hint, got {:?}", out.lines);
};
assert!(s.contains("/evaluate"), "hint mentions command: {s}");
assert!(s.contains("coin"), "hint explains missing arg: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_http_404_surfaces_alert_without_overlay() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_not_found = true);
let out = dispatch(&ctx, "/evaluate BTC").await.unwrap().unwrap();
assert!(
out.show_overlay.is_none(),
"404 must NOT open an overlay — the operator deserves the error"
);
assert!(
out.dismiss_overlay,
"HTTP failure on /evaluate must dismiss stale overlays"
);
assert_eq!(out.lines.len(), 1);
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert on 404, got {:?}", out.lines);
};
assert!(
s.to_lowercase().contains("evaluate"),
"alert mentions command: {s}"
);
assert!(s.contains("BTC"), "alert mentions coin: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_http_500_surfaces_alert_without_overlay() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_server_error = true);
let out = dispatch(&ctx, "/evaluate ETH").await.unwrap().unwrap();
assert!(out.show_overlay.is_none(), "500 must NOT open an overlay");
assert!(
out.dismiss_overlay,
"HTTP 500 on /evaluate must dismiss stale overlays"
);
assert!(
matches!(out.lines.first(), Some(OutputLine::Alert(_))),
"expected Alert, got {:?}",
out.lines
);
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_empty_response_emits_alert_and_dismisses_overlay() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_empty_evaluation = true);
let out = dispatch(&ctx, "/evaluate BTC").await.unwrap().unwrap();
assert!(
out.show_overlay.is_none(),
"empty 200 must NOT open an overlay: {:?}",
out.show_overlay
);
assert!(out.dismiss_overlay, "empty 200 must dismiss stale overlays");
let OutputLine::Alert(s) = out.lines.first().expect("alert line") else {
panic!("expected Alert on empty evaluate, got {:?}", out.lines);
};
assert!(
s.to_lowercase().contains("empty"),
"alert explains why: {s}"
);
assert!(s.contains("BTC"), "alert mentions coin: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_warns_on_extra_args_and_still_runs() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/evaluate BTC short now")
.await
.unwrap()
.unwrap();
assert!(
matches!(out.show_overlay, Some(OverlayTarget::Verdict(_))),
"evaluate must still run despite the extras: {:?}",
out.show_overlay
);
let warns: Vec<_> = out
.lines
.iter()
.filter_map(|l| match l {
OutputLine::Warn(s) => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(warns.len(), 1, "exactly one warn line: {:?}", out.lines);
let w = &warns[0];
assert!(w.contains("short"), "warn echoes ignored token: {w}");
assert!(w.contains("now"), "warn echoes all ignored tokens: {w}");
mock.shutdown().await;
}
#[tokio::test]
async fn clear_dismisses_stale_overlay() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/clear").await.unwrap().unwrap();
assert!(out.clear_log, "/clear must clear the log");
assert!(
out.dismiss_overlay,
"/clear must also dismiss any stale overlay"
);
mock.shutdown().await;
}
#[tokio::test]
async fn evaluate_no_http_client_surfaces_setup_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/evaluate BTC").await.unwrap().unwrap();
assert!(out.show_overlay.is_none());
let line = &out.lines[0];
let OutputLine::Alert(s) = line else {
panic!("expected Alert on missing client, got {line:?}");
};
assert!(s.contains("engine client"), "setup hint: {s}");
}
#[tokio::test]
async fn pulse_renders_mock_events() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/pulse").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert!(out.show_overlay.is_none(), "/pulse is inline, not overlay");
assert!(
out.lines.len() >= 2,
"mock returns two events: {:?}",
out.lines
);
let joined = out
.lines
.iter()
.map(|l| match l {
OutputLine::System(s)
| OutputLine::Command(s)
| OutputLine::Warn(s)
| OutputLine::Alert(s) => s.clone(),
})
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("BTC"), "{joined}");
assert!(joined.contains("edge_floor cleared"), "{joined}");
mock.shutdown().await;
}
#[tokio::test]
async fn pulse_without_http_client_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/pulse").await.unwrap().unwrap();
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert, got {:?}", out.lines);
};
assert!(s.contains("engine client"), "setup hint: {s}");
}
#[tokio::test]
async fn pulse_http_500_surfaces_alert() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_server_error = true);
let out = dispatch(&ctx, "/pulse").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("pulse")));
mock.shutdown().await;
}
#[tokio::test]
async fn approaching_sorts_by_distance_to_gate() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/approaching").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let first = match &out.lines[0] {
OutputLine::Command(s) => s,
other => panic!("expected Command, got {other:?}"),
};
assert!(
first.contains("AVAX"),
"closest-to-gate row must be first: {first}"
);
let second = match &out.lines[1] {
OutputLine::Command(s) => s,
other => panic!("expected Command, got {other:?}"),
};
assert!(second.contains("LINK"), "LINK second: {second}");
mock.shutdown().await;
}
#[tokio::test]
async fn approaching_without_http_client_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/approaching").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(_))));
}
#[tokio::test]
async fn approaching_http_500_surfaces_alert() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_server_error = true);
let out = dispatch(&ctx, "/approaching").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("approaching")));
mock.shutdown().await;
}
#[tokio::test]
async fn approaching_404_explains_endpoint_is_missing() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_approaching_not_found = true);
let out = dispatch(&ctx, "/approaching").await.unwrap().unwrap();
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert on 404, got {:?}", out.lines);
};
assert!(s.contains("approaching"), "alert mentions the command: {s}");
assert!(
s.to_lowercase().contains("engine") && (s.contains("not expose") || s.contains("missing")),
"alert explains engine-side cause: {s}"
);
assert!(
!s.contains("not found: /approaching"),
"raw HttpError leak: {s}"
);
mock.shutdown().await;
}
#[tokio::test]
async fn rejections_renders_mock_entry() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/rejections").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("SOL"), "coin: {s}");
assert!(s.contains("stage2"), "stage: {s}");
assert!(s.contains("volume"), "reason: {s}");
mock.shutdown().await;
}
#[tokio::test]
async fn rejections_with_coin_filter_passes_through() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/rejections SOL").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Command(_))));
mock.shutdown().await;
}
#[tokio::test]
async fn rejections_without_http_client_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/rejections").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(_))));
}
#[tokio::test]
async fn rejections_http_500_surfaces_alert() {
let (mock, ctx) = ctx_with_mock().await;
mock.with_overrides(|o| o.force_server_error = true);
let out = dispatch(&ctx, "/rejections").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("rejections")));
mock.shutdown().await;
}
#[tokio::test]
async fn kill_is_reducer_even_as_stub() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/kill").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Reduces));
matches!(out.lines[0], OutputLine::Alert(_));
mock.shutdown().await;
}
#[derive(Default, Debug)]
struct TestSessions {
inner: Mutex<TestInner>,
}
#[derive(Default, Debug)]
struct TestInner {
rows: Vec<SessionSummary>,
events: std::collections::HashMap<String, Vec<ReplayEvent>>,
labels: std::collections::HashMap<String, String>,
current: Option<String>,
}
impl TestSessions {
fn new(current: &str) -> Arc<Self> {
let s = Self {
inner: Mutex::new(TestInner {
current: Some(current.to_string()),
..TestInner::default()
}),
};
Arc::new(s)
}
fn add(&self, row: SessionSummary, events: Vec<ReplayEvent>) {
let mut g = self.inner.lock().unwrap();
g.events.insert(row.ulid.clone(), events);
g.rows.push(row);
g.rows.sort_by(|a, b| b.started_at_ms.cmp(&a.started_at_ms));
}
}
impl SessionSource for TestSessions {
fn current_ulid(&self) -> Option<String> {
self.inner.lock().unwrap().current.clone()
}
fn list(&self, limit: u32) -> Result<Vec<SessionSummary>, SessionError> {
let g = self.inner.lock().unwrap();
Ok(g.rows
.iter()
.take(usize::try_from(limit).unwrap_or(usize::MAX))
.cloned()
.collect())
}
fn find(&self, needle: &str) -> Result<SessionSummary, SessionError> {
let g = self.inner.lock().unwrap();
let ulid = g
.labels
.get(needle)
.cloned()
.or_else(|| {
g.rows
.iter()
.find(|s| s.ulid == needle)
.map(|s| s.ulid.clone())
})
.ok_or(SessionError::NotFound)?;
g.rows
.iter()
.find(|s| s.ulid == ulid)
.cloned()
.ok_or(SessionError::NotFound)
}
fn list_events(&self, ulid: &str, limit: u32) -> Result<Vec<ReplayEvent>, SessionError> {
Ok(self
.inner
.lock()
.unwrap()
.events
.get(ulid)
.cloned()
.unwrap_or_default()
.into_iter()
.take(usize::try_from(limit).unwrap_or(usize::MAX))
.collect())
}
fn save_label(&self, ulid: &str, label: &str) -> Result<(), SessionError> {
self.inner
.lock()
.unwrap()
.labels
.insert(label.to_string(), ulid.to_string());
Ok(())
}
fn fork_from_current(&self) -> Result<Option<String>, SessionError> {
let mut g = self.inner.lock().unwrap();
let Some(parent) = g.current.clone() else {
return Ok(None);
};
let child = format!("{parent}X");
g.current = Some(child.clone());
let next_ts = g.rows.first().map_or(0, |r| r.started_at_ms + 1);
g.rows.insert(
0,
SessionSummary {
ulid: child.clone(),
started_at_ms: next_ts,
ended_at_ms: None,
engine_base_url: None,
cli_version: "test".into(),
parent_ulid: Some(parent),
n_events: 0,
},
);
Ok(Some(child))
}
}
fn ctx_with_sessions(sessions: Arc<TestSessions>) -> DispatchContext {
DispatchContext::new(None, EngineState::shared()).with_sessions(sessions)
}
#[tokio::test]
async fn sessions_without_store_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/sessions").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("persistence")));
assert!(out.replay_lines.is_empty());
}
#[tokio::test]
async fn sessions_empty_store_emits_honest_empty_state() {
let src = TestSessions::new("01HX");
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/sessions").await.unwrap().unwrap();
assert!(matches!(
out.lines.first(),
Some(OutputLine::System(s)) if s.contains("no prior sessions")
));
}
#[tokio::test]
async fn sessions_lists_newest_first_with_current_marker() {
let src = TestSessions::new("01HB");
src.add(
SessionSummary {
ulid: "01HA".into(),
started_at_ms: 1_700_000_000_000,
ended_at_ms: Some(1_700_000_600_000),
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 7,
},
vec![],
);
src.add(
SessionSummary {
ulid: "01HB".into(),
started_at_ms: 1_700_001_000_000,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 3,
},
vec![],
);
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/sessions").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 3);
let row_a = match &out.lines[1] {
OutputLine::System(s) => s.clone(),
other => panic!("expected System row, got {other:?}"),
};
assert!(row_a.contains("01HB"), "first row must be newest: {row_a}");
assert!(
row_a.trim_start().starts_with('*'),
"current session must be flagged with *: {row_a}",
);
}
#[tokio::test]
async fn resume_missing_needle_prints_usage_hint() {
let src = TestSessions::new("01HX");
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/resume").await.unwrap().unwrap();
assert!(matches!(
out.lines.first(),
Some(OutputLine::System(s)) if s.contains("ulid|label")
));
assert!(
out.replay_lines.is_empty(),
"no events must replay on usage hint"
);
}
#[tokio::test]
async fn resume_unknown_needle_surfaces_alert() {
let src = TestSessions::new("01HX");
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/resume nope").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("no session")));
}
#[tokio::test]
async fn resume_emits_banner_plus_replay_lines() {
let src = TestSessions::new("01HNEW");
src.add(
SessionSummary {
ulid: "01HOLD".into(),
started_at_ms: 1_700_000_000_000,
ended_at_ms: Some(1_700_000_900_000),
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 2,
},
vec![
ReplayEvent {
kind: ReplayKind::Prompt,
at_ms: 1_700_000_000_500,
text: "> /status".into(),
},
ReplayEvent {
kind: ReplayKind::Command,
at_ms: 1_700_000_001_000,
text: "regime=trend".into(),
},
],
);
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/resume 01HOLD").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
assert!(matches!(&out.lines[0], OutputLine::Command(s) if s.contains("resuming 01HOLD")));
assert_eq!(out.replay_lines.len(), 2);
assert_eq!(out.replay_lines[0].kind, ReplayKind::Prompt);
assert_eq!(out.replay_lines[0].at_ms, 1_700_000_000_500);
assert_eq!(out.replay_lines[1].text, "regime=trend");
}
#[tokio::test]
async fn fork_without_current_surfaces_alert() {
#[derive(Default)]
struct Empty;
impl SessionSource for Empty {
fn current_ulid(&self) -> Option<String> {
None
}
fn list(&self, _: u32) -> Result<Vec<SessionSummary>, SessionError> {
Ok(vec![])
}
fn find(&self, _: &str) -> Result<SessionSummary, SessionError> {
Err(SessionError::NotFound)
}
fn list_events(&self, _: &str, _: u32) -> Result<Vec<ReplayEvent>, SessionError> {
Ok(vec![])
}
fn save_label(&self, _: &str, _: &str) -> Result<(), SessionError> {
Ok(())
}
fn fork_from_current(&self) -> Result<Option<String>, SessionError> {
Ok(None)
}
}
let ctx = DispatchContext::new(None, EngineState::shared())
.with_sessions(Arc::new(Empty) as Arc<dyn SessionSource>);
let out = dispatch(&ctx, "/fork").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("no current")));
}
#[tokio::test]
async fn fork_echoes_new_ulid_and_swaps_current() {
let src = TestSessions::new("01HPARENT");
let ctx = ctx_with_sessions(Arc::clone(&src));
let out = dispatch(&ctx, "/fork").await.unwrap().unwrap();
let line = match &out.lines[0] {
OutputLine::Command(s) => s.clone(),
other => panic!("expected Command, got {other:?}"),
};
assert!(line.contains("/fork"), "line: {line}");
assert!(
line.contains("01HPARENTX"),
"new ulid should appear: {line}"
);
assert_eq!(src.current_ulid().as_deref(), Some("01HPARENTX"));
}
#[tokio::test]
async fn save_without_label_prints_usage_hint() {
let src = TestSessions::new("01HX");
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/save").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::System(s)) if s.contains("<label>")));
}
#[tokio::test]
async fn replay_without_store_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/replay 01HX").await.unwrap().unwrap();
assert!(matches!(
out.lines.first(),
Some(OutputLine::Alert(s)) if s.contains("/replay") && s.contains("persistence")
));
assert!(out.replay_lines.is_empty());
}
#[tokio::test]
async fn replay_missing_needle_prints_usage_hint_with_replay_verb() {
let src = TestSessions::new("01HX");
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/replay").await.unwrap().unwrap();
assert!(matches!(
out.lines.first(),
Some(OutputLine::System(s))
if s.contains("/replay") && s.contains("ulid|label")
));
}
#[tokio::test]
async fn replay_emits_replaying_banner_and_does_not_switch_active() {
let src = TestSessions::new("01HCURRENT");
src.add(
SessionSummary {
ulid: "01HOLD".into(),
started_at_ms: 1_700_000_000_000,
ended_at_ms: Some(1_700_000_900_000),
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 1,
},
vec![ReplayEvent {
kind: ReplayKind::System,
at_ms: 1_700_000_000_500,
text: "boot".into(),
}],
);
let ctx = ctx_with_sessions(Arc::clone(&src));
let out = dispatch(&ctx, "/replay 01HOLD").await.unwrap().unwrap();
assert!(matches!(
&out.lines[0],
OutputLine::Command(s) if s.contains("replaying 01HOLD")
));
assert_eq!(out.replay_lines.len(), 1);
assert_eq!(src.current_ulid().as_deref(), Some("01HCURRENT"));
}
#[tokio::test]
async fn share_without_store_emits_alert() {
let ctx = DispatchContext::new(None, EngineState::shared());
let out = dispatch(&ctx, "/share").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("/share")));
}
#[tokio::test]
async fn share_unknown_needle_surfaces_alert() {
let src = TestSessions::new("01HX");
src.add(
SessionSummary {
ulid: "01HX".into(),
started_at_ms: 1_700_000_000_000,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 0,
},
vec![],
);
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/share nope").await.unwrap().unwrap();
assert!(matches!(out.lines.first(), Some(OutputLine::Alert(s)) if s.contains("no session")));
}
#[tokio::test]
async fn share_current_session_emits_header_and_json_block() {
let src = TestSessions::new("01HCUR");
src.add(
SessionSummary {
ulid: "01HCUR".into(),
started_at_ms: 1_700_000_000_000,
ended_at_ms: None,
engine_base_url: Some("http://e:8080".into()),
cli_version: "0.3.0".into(),
parent_ulid: Some("01HPREV".into()),
n_events: 2,
},
vec![
ReplayEvent {
kind: ReplayKind::Prompt,
at_ms: 1_700_000_000_500,
text: "> /status".into(),
},
ReplayEvent {
kind: ReplayKind::Command,
at_ms: 1_700_000_001_000,
text: "regime=trend".into(),
},
],
);
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/share").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 2);
let OutputLine::Command(hdr) = &out.lines[0] else {
panic!("expected Command header, got {:?}", out.lines[0]);
};
assert!(hdr.contains("01HCUR"), "header ulid: {hdr}");
assert!(hdr.contains("2 event(s)"), "header count: {hdr}");
let OutputLine::System(body) = &out.lines[1] else {
panic!("expected System body, got {:?}", out.lines[1]);
};
let v: serde_json::Value = serde_json::from_str(body).expect("share body must be valid JSON");
assert_eq!(v["ulid"], "01HCUR");
assert_eq!(v["engine_base_url"], "http://e:8080");
assert_eq!(v["parent_ulid"], "01HPREV");
let events = v["events"].as_array().expect("events array");
assert_eq!(events.len(), 2);
assert_eq!(events[0]["kind"], "prompt");
assert_eq!(events[0]["text"], "> /status");
assert_eq!(events[1]["kind"], "command");
}
#[tokio::test]
async fn share_explicit_ulid_overrides_current_session() {
let src = TestSessions::new("01HCUR");
src.add(
SessionSummary {
ulid: "01HCUR".into(),
started_at_ms: 1,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 0,
},
vec![],
);
src.add(
SessionSummary {
ulid: "01HOLD".into(),
started_at_ms: 2,
ended_at_ms: Some(3),
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 1,
},
vec![ReplayEvent {
kind: ReplayKind::System,
at_ms: 2,
text: "historical".into(),
}],
);
let ctx = ctx_with_sessions(src);
let out = dispatch(&ctx, "/share 01HOLD").await.unwrap().unwrap();
let OutputLine::System(body) = &out.lines[1] else {
panic!("expected System body, got {:?}", out.lines[1]);
};
let v: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(v["ulid"], "01HOLD", "explicit arg must override current");
assert_eq!(v["events"][0]["text"], "historical");
}
#[tokio::test]
async fn save_echoes_label_to_current_ulid_and_resolves_on_find() {
let src = TestSessions::new("01HX");
src.add(
SessionSummary {
ulid: "01HX".into(),
started_at_ms: 1,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 0,
},
vec![],
);
let ctx = ctx_with_sessions(Arc::clone(&src));
let out = dispatch(&ctx, "/save pre-cpi").await.unwrap().unwrap();
let line = match &out.lines[0] {
OutputLine::Command(s) => s.clone(),
other => panic!("expected Command, got {other:?}"),
};
assert!(line.contains("pre-cpi"));
assert!(line.contains("01HX"));
let hit = src.find("pre-cpi").unwrap();
assert_eq!(hit.ulid, "01HX");
}
fn ctx_with_config(src: Arc<MockConfig>) -> DispatchContext {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
ctx.with_config(src)
}
#[tokio::test]
async fn config_bare_emits_usage_warn() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/config").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.starts_with("/config"));
assert!(s.contains("show"));
assert!(s.contains("doctor"));
}
#[tokio::test]
async fn config_unknown_action_names_what_was_rejected() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/config secrets").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(
s.contains("'secrets'"),
"usage line names the bad token: {s}"
);
}
#[tokio::test]
async fn config_show_alerts_when_no_source_attached() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/config show").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
assert!(matches!(out.lines[0], OutputLine::Alert(_)));
}
#[tokio::test]
async fn config_show_renders_every_row() {
let src = Arc::new(
MockConfig::new()
.with_row("handle", "forge")
.with_row("api_url", "https://api.getzero.dev")
.with_row("theme", "phosphor"),
);
let ctx = ctx_with_config(src);
let out = dispatch(&ctx, "/config show").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 4, "header + 3 rows: {:?}", out.lines);
let OutputLine::Command(header) = &out.lines[0] else {
panic!("expected Command header, got {:?}", out.lines[0]);
};
assert!(
header.contains("3 field(s)"),
"header reports count: {header}"
);
let body: Vec<&str> = out.lines[1..]
.iter()
.map(|l| match l {
OutputLine::System(s) => s.as_str(),
other => panic!("expected System row, got {other:?}"),
})
.collect();
assert!(body.iter().any(|s| s.contains("forge")));
assert!(body.iter().any(|s| s.contains("api.getzero.dev")));
assert!(body.iter().any(|s| s.contains("phosphor")));
}
#[tokio::test]
async fn config_show_empty_rows_is_honest() {
let src = Arc::new(MockConfig::new());
let ctx = ctx_with_config(src);
let out = dispatch(&ctx, "/config show").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("no config loaded"));
assert!(s.contains("zero init"));
}
#[tokio::test]
async fn config_doctor_header_promotes_on_errors() {
let src = Arc::new(
MockConfig::new()
.with_finding(ConfigDoctorFinding::ok("config file readable"))
.with_finding(ConfigDoctorFinding::warn("default theme; no override set"))
.with_finding(ConfigDoctorFinding::error("engine token missing")),
);
let ctx = ctx_with_config(src);
let out = dispatch(&ctx, "/config doctor").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 4);
let OutputLine::Alert(header) = &out.lines[0] else {
panic!("expected Alert header, got {:?}", out.lines[0]);
};
assert!(
header.contains("errors=1"),
"header advertises error count: {header}"
);
assert!(
header.contains("warnings=1"),
"header advertises warning count: {header}"
);
assert!(matches!(out.lines[1], OutputLine::System(_)));
assert!(matches!(out.lines[2], OutputLine::Warn(_)));
assert!(matches!(out.lines[3], OutputLine::Alert(_)));
}
#[tokio::test]
async fn config_doctor_clean_run_is_command_header() {
let src = Arc::new(
MockConfig::new()
.with_finding(ConfigDoctorFinding::ok("config file readable"))
.with_finding(ConfigDoctorFinding::ok("engine token resolvable")),
);
let ctx = ctx_with_config(src);
let out = dispatch(&ctx, "/config doctor").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 3);
let OutputLine::Command(header) = &out.lines[0] else {
panic!("expected Command header on clean run, got {:?}", out.lines);
};
assert!(header.contains("errors=0"));
assert!(header.contains("warnings=0"));
}
#[tokio::test]
async fn config_doctor_no_source_alerts() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/config doctor").await.unwrap().unwrap();
assert_eq!(out.lines.len(), 1);
assert!(matches!(out.lines[0], OutputLine::Alert(_)));
}
#[tokio::test]
async fn verbose_toggle_flips_context_state() {
let off =
DispatchContext::new(None, zero_engine_client::EngineState::shared()).with_verbose(false);
let out = dispatch(&off, "/verbose").await.unwrap().unwrap();
assert_eq!(out.verbose_toggle, Some(true));
assert_eq!(out.lines.len(), 1);
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert_eq!(s, "verbose on");
let on =
DispatchContext::new(None, zero_engine_client::EngineState::shared()).with_verbose(true);
let out = dispatch(&on, "/verbose").await.unwrap().unwrap();
assert_eq!(out.verbose_toggle, Some(false));
let OutputLine::System(s) = &out.lines[0] else {
unreachable!();
};
assert_eq!(s, "verbose off");
}
#[tokio::test]
async fn verbose_on_and_off_are_idempotent_but_still_confirm() {
let on =
DispatchContext::new(None, zero_engine_client::EngineState::shared()).with_verbose(true);
let out = dispatch(&on, "/verbose on").await.unwrap().unwrap();
assert_eq!(out.verbose_toggle, Some(true));
let off =
DispatchContext::new(None, zero_engine_client::EngineState::shared()).with_verbose(false);
let out = dispatch(&off, "/verbose off").await.unwrap().unwrap();
assert_eq!(out.verbose_toggle, Some(false));
}
#[tokio::test]
async fn verbose_unknown_argument_is_warn_with_usage() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/verbose maybe").await.unwrap().unwrap();
assert_eq!(out.verbose_toggle, None, "no intent on bad input");
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.contains("'maybe'"));
assert!(s.contains("on|off|toggle"));
}
use zero_commands::{FrictionDecision, StaticLabel};
use zero_operator_state::Label;
fn ctx_steady() -> DispatchContext {
DispatchContext::new(None, zero_engine_client::EngineState::shared())
.with_state(Arc::new(StaticLabel(Label::Steady)))
}
fn ctx_tilt() -> DispatchContext {
DispatchContext::new(None, zero_engine_client::EngineState::shared())
.with_state(Arc::new(StaticLabel(Label::Tilt)))
}
#[tokio::test]
async fn state_override_under_steady_proceeds_and_names_label() {
let ctx = ctx_steady();
let out = dispatch(&ctx, "/state-override STEADY")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Increases));
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("STEADY"));
assert!(s.contains("pending"));
}
#[tokio::test]
async fn state_override_under_tilt_holds_at_friction_gate() {
let ctx = ctx_tilt();
let out = dispatch(&ctx, "/state-override STEADY")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Increases));
assert!(matches!(
out.friction,
Some(FrictionDecision::TypedConfirm { .. })
));
assert!(out.pending_command.is_some());
}
#[tokio::test]
async fn state_override_without_label_emits_usage_hint() {
let ctx = ctx_steady();
let out = dispatch(&ctx, "/state-override").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn usage, got {:?}", out.lines);
};
assert!(s.contains("FRESH"));
assert!(s.contains("STEADY"));
assert!(s.contains("TILT"));
}
#[tokio::test]
async fn state_override_with_unknown_label_emits_usage_hint() {
let ctx = ctx_steady();
let out = dispatch(&ctx, "/state-override slurpy")
.await
.unwrap()
.unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn usage, got {:?}", out.lines);
};
assert!(s.contains("STEADY"));
}
#[tokio::test]
async fn continue_acknowledges_without_queue() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/continue").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("acknowledged"));
assert!(s.contains("pending"));
}
#[tokio::test]
async fn close_requires_a_coin() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/close").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Reduces));
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.contains("<coin>"));
assert!(s.contains("/flatten-all"));
}
#[tokio::test]
async fn close_with_coin_acknowledges_and_tags_pending() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/close BTC").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Reduces));
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("BTC"));
assert!(s.contains("pending"));
assert!(
s.contains("no order was placed"),
"must be honest the order did not go out"
);
}
#[tokio::test]
async fn close_is_friction_exempt_even_at_tilt() {
let ctx = ctx_tilt();
let out = dispatch(&ctx, "/close BTC").await.unwrap().unwrap();
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
assert!(out.pending_command.is_none());
}
#[tokio::test]
async fn wrap_off_emits_absolute_toggle_and_confirmation() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/wrap-off").await.unwrap().unwrap();
assert_eq!(out.wrap_off_toggle, Some(true));
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("this session"));
assert!(
s.contains("next session"),
"honest about the non-sticky nature",
);
}
#[tokio::test]
async fn coaching_reset_signals_buffer_clear() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/coaching reset").await.unwrap().unwrap();
assert!(out.coaching_reset);
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("cleared"));
}
#[tokio::test]
async fn coaching_without_subcommand_is_unknown() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/coaching").await.unwrap().unwrap();
assert!(matches!(out.lines[0], OutputLine::Warn(_)));
}
#[tokio::test]
async fn zero_prefix_teaches_instead_of_unknown_command() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "zero doctor").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(
s.contains("/doctor"),
"expected hint to name /doctor, got {s:?}"
);
assert!(
s.contains("already inside zero"),
"expected teaching voice, got {s:?}"
);
let out = dispatch(&ctx, "zero --version").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(
s.contains("/quit"),
"expected version hint to route operator back to shell, got {s:?}"
);
let out = dispatch(&ctx, "zero init --force").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(
s.contains("init --force"),
"expected hint to echo typed tail, got {s:?}"
);
assert!(
!s.contains("/init"),
"hint must not invent a /init command, got {s:?}"
);
let out = dispatch(&ctx, "zero").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(
s.contains("/help"),
"bare `zero` must point at /help, got {s:?}"
);
}
#[tokio::test]
async fn disclosure_override_without_phrase_alerts_and_names_it() {
let ctx = ctx_steady();
let out = dispatch(&ctx, "/disclosure-override")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Increases));
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
let OutputLine::Alert(s) = &out.lines[0] else {
panic!("expected Alert, got {:?}", out.lines);
};
assert!(s.contains("--i-know-what-i-am-doing"));
}
#[tokio::test]
async fn disclosure_override_with_phrase_under_steady_proceeds() {
let ctx = ctx_steady();
let out = dispatch(&ctx, "/disclosure-override --i-know-what-i-am-doing")
.await
.unwrap()
.unwrap();
assert_eq!(out.risk, Some(RiskDirection::Increases));
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("bypassed"));
assert!(s.contains("pending"), "disclosure store not wired yet");
}
#[tokio::test]
async fn disclosure_override_at_tilt_is_held_by_friction_not_phrase() {
let ctx = ctx_tilt();
let out = dispatch(&ctx, "/disclosure-override --i-know-what-i-am-doing")
.await
.unwrap()
.unwrap();
assert!(matches!(
out.friction,
Some(FrictionDecision::TypedConfirm { .. })
));
assert!(out.pending_command.is_some());
}
#[tokio::test]
async fn rate_without_any_arguments_emits_usage_hint() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/rate").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.contains("<trade_id>"));
assert!(s.contains("1..=10"));
}
#[tokio::test]
async fn rate_with_id_only_asks_for_a_rating() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/rate t-001").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.contains("t-001"));
assert!(s.contains("1..=10"));
}
#[tokio::test]
async fn rate_out_of_range_at_parser_surfaces_usage_hint() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/rate 11").await.unwrap().unwrap();
let OutputLine::Warn(s) = &out.lines[0] else {
panic!("expected Warn, got {:?}", out.lines);
};
assert!(s.contains("1..=10"));
assert!(s.contains("11"));
}
#[tokio::test]
async fn rate_happy_path_without_http_is_honest_about_missing_client() {
let ctx = DispatchContext::new(None, zero_engine_client::EngineState::shared());
let out = dispatch(&ctx, "/rate t-001 8").await.unwrap().unwrap();
assert_eq!(out.risk, Some(RiskDirection::Neutral));
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("t-001"));
assert!(s.contains('8'));
assert!(s.contains("recorded"));
assert!(
s.contains("engine client unavailable"),
"must be honest about the absent engine client, got: {s:?}"
);
assert!(
!s.contains("posted to engine"),
"must not claim a POST happened when none did, got: {s:?}"
);
}
#[tokio::test]
async fn rate_is_friction_exempt_even_at_tilt() {
let ctx = ctx_tilt();
let out = dispatch(&ctx, "/rate t-001 3").await.unwrap().unwrap();
assert!(matches!(out.friction, Some(FrictionDecision::Proceed)));
assert!(out.pending_command.is_none());
}
#[tokio::test]
async fn rate_with_mock_engine_posts_conviction_event() {
use chrono::DateTime;
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/rate t-042 7").await.unwrap().unwrap();
let OutputLine::Command(s) = &out.lines[0] else {
panic!("expected Command, got {:?}", out.lines);
};
assert!(s.contains("t-042"));
assert!(s.contains('7'));
assert!(
s.contains("posted to engine"),
"must confirm the POST succeeded, got: {s:?}"
);
assert!(
!s.contains("pending"),
"must not repeat the pre-rewire 'pending' tag after a successful POST, got: {s:?}"
);
let received = mock.received_operator_events();
assert_eq!(received.len(), 1, "mock saw: {received:?}");
let body = &received[0];
assert_eq!(body["kind"], "conviction");
assert_eq!(body["trade_id"], "t-042");
assert_eq!(body["rating"], 7);
let ts = body["ts"].as_str().expect("ts present as string");
DateTime::parse_from_rfc3339(ts).expect("ts parses as RFC-3339");
mock.shutdown().await;
}
#[tokio::test]
async fn break_with_mock_engine_posts_break_started_event() {
use chrono::DateTime;
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/break 15").await.unwrap().unwrap();
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("/break 15m"));
assert!(
s.contains("posted to engine"),
"must confirm the POST succeeded, got: {s:?}"
);
let received = mock.received_operator_events();
assert_eq!(received.len(), 1, "mock saw: {received:?}");
let body = &received[0];
assert_eq!(body["kind"], "break_started");
assert_eq!(body["planned_ms"], 15 * 60_000);
let ts = body["ts"].as_str().expect("ts present as string");
DateTime::parse_from_rfc3339(ts).expect("ts parses as RFC-3339");
mock.shutdown().await;
}
#[tokio::test]
async fn break_without_minutes_posts_null_planned_ms() {
let (mock, ctx) = ctx_with_mock().await;
let out = dispatch(&ctx, "/break").await.unwrap().unwrap();
let OutputLine::System(s) = &out.lines[0] else {
panic!("expected System, got {:?}", out.lines);
};
assert!(s.contains("posted to engine"), "got: {s:?}");
let received = mock.received_operator_events();
assert_eq!(received.len(), 1);
let body = &received[0];
assert_eq!(body["kind"], "break_started");
assert!(
body.get("planned_ms")
.is_some_and(serde_json::Value::is_null),
"planned_ms must be explicit null when no duration given, got: {body:?}"
);
mock.shutdown().await;
}