返回 CodeWhale
portable_tests.rs
根目录 / crates / tui / src / commands / groups / debug / portable_tests.rs
1 //! Facet-only checks for the portable diagnostics leaf closure.
2 //! No App, provider, file system or request builder is used by these tests.
3
4 use std::cell::RefCell;
5 use std::rc::Rc;
6
7 use codewhale_command_contract::facets::{
8 CommandDebugDiagnosticsContext, CommandPresentationContext, DebugBalanceProjection,
9 DebugCacheInspectionObservation, DebugCacheInspectionUnavailable, DebugCacheTelemetry,
10 DebugCostProjection, DebugPromptContext, DebugPromptInspection, DebugPromptSourceMap,
11 DebugSystemProjection, DebugTokenProjection, DebugToolSnapshot, DebugWarmupKey,
12 };
13 use codewhale_command_contract::handler::CommandContexts;
14
15 use super::{balance, cache, preview_request, tokens, tool_inspection};
16
17 #[derive(Default)]
18 struct FakeDiagnostics {
19 events: Rc<RefCell<Vec<&'static str>>>,
20 observation: Option<Result<DebugCacheInspectionObservation, DebugCacheInspectionUnavailable>>,
21 remembered: Option<DebugPromptInspection>,
22 balance_supported: bool,
23 }
24
25 impl CommandDebugDiagnosticsContext for FakeDiagnostics {
26 fn balance_projection(&self) -> DebugBalanceProjection {
27 self.events.borrow_mut().push("balance");
28 DebugBalanceProjection {
29 provider_display_name: "Example Provider".into(),
30 supports_balance_api: self.balance_supported,
31 }
32 }
33 fn system_projection(&self) -> DebugSystemProjection {
34 panic!("system authority is unrelated to cache inspection")
35 }
36 fn token_projection(&self) -> DebugTokenProjection {
37 panic!("usage authority is unrelated to cache inspection")
38 }
39 fn cost_projection(&self) -> DebugCostProjection {
40 panic!("cost authority is unrelated to cache inspection")
41 }
42 fn cache_telemetry(&self) -> DebugCacheTelemetry {
43 panic!("telemetry authority is unrelated to cache inspection")
44 }
45 fn context_source_map(&self) -> DebugPromptSourceMap {
46 panic!("context authority is unrelated to cache inspection")
47 }
48 fn prompt_context(&self) -> DebugPromptContext {
49 panic!("prompt authority is unrelated to cache inspection")
50 }
51 fn tool_snapshot(&self) -> Option<DebugToolSnapshot> {
52 self.events.borrow_mut().push("tool_snapshot");
53 None
54 }
55 fn inspect_cache(
56 &self,
57 ) -> Result<DebugCacheInspectionObservation, DebugCacheInspectionUnavailable> {
58 self.events.borrow_mut().push("inspect");
59 self.observation.clone().expect("configured inspection")
60 }
61 fn remember_cache_inspection(&mut self, inspection: DebugPromptInspection) {
62 self.events.borrow_mut().push("remember");
63 assert!(
64 self.remembered.replace(inspection).is_none(),
65 "remember exactly once"
66 );
67 }
68 }
69
70 struct FakePresentation;
71 impl CommandPresentationContext for FakePresentation {
72 fn translate(&self, _key: &str, _replacements: &[(&str, &str)]) -> Result<String, String> {
73 panic!("inspection and warmup do not need translation")
74 }
75 }
76
77 fn inspection(hash: &str) -> DebugPromptInspection {
78 DebugPromptInspection {
79 base_static_prefix_hash: hash.into(),
80 full_request_prefix_hash: format!("full-{hash}"),
81 tool_catalog_hash: String::new(),
82 layers: Vec::new(),
83 }
84 }
85
86 fn observation() -> DebugCacheInspectionObservation {
87 DebugCacheInspectionObservation {
88 current: inspection("current"),
89 previous: Some(inspection("previous")),
90 current_warmup_key: DebugWarmupKey {
91 provider: "test".into(),
92 model: "test-model".into(),
93 base_url: "https://invalid.example/v1".into(),
94 static_prefix_hash: "current".into(),
95 tool_catalog_hash: String::new(),
96 project_pack_hash: String::new(),
97 skills_hash: String::new(),
98 },
99 last_warmup_key: None,
100 current_warmup_hash_short: "current-key".into(),
101 last_warmup_hash_short: None,
102 }
103 }
104
105 fn cache_with(fake: &mut FakeDiagnostics, arg: Option<&str>) -> super::CommandResult {
106 let mut presentation = FakePresentation;
107 cache::cache(
108 CommandContexts::empty()
109 .with_debug_diagnostics(fake)
110 .with_presentation(&mut presentation),
111 arg,
112 )
113 }
114
115 #[test]
116 fn missing_diagnostics_facet_fails_before_any_action() {
117 for result in [
118 balance::balance(CommandContexts::empty()),
119 tokens::system_prompt(CommandContexts::empty()),
120 tokens::context(CommandContexts::empty(), None),
121 tool_inspection::tools(CommandContexts::empty(), Some("invalid")),
122 tokens::tokens(CommandContexts::empty()),
123 tokens::cost(CommandContexts::empty()),
124 cache::cache(CommandContexts::empty(), Some("warmup")),
125 ] {
126 assert!(result.is_error, "missing authority must be an error");
127 assert_eq!(
128 result.message.as_deref(),
129 Some("Error: Command capability unavailable: debug_diagnostics")
130 );
131 assert!(result.action.is_none());
132 }
133 let result = preview_request::preview_request(Some("--json --prompt keep trailing "));
134 assert!(
135 !result.is_error,
136 "pure preview never constructs a host bundle"
137 );
138 assert!(result.action.is_some());
139 }
140
141 #[test]
142 fn missing_presentation_is_not_an_implicit_host_fallback() {
143 let mut fake = FakeDiagnostics::default();
144 for result in [
145 tokens::tokens(CommandContexts::empty().with_debug_diagnostics(&mut fake)),
146 tokens::cost(CommandContexts::empty().with_debug_diagnostics(&mut fake)),
147 cache::cache(
148 CommandContexts::empty().with_debug_diagnostics(&mut fake),
149 Some("warmup"),
150 ),
151 ] {
152 assert!(result.is_error);
153 assert_eq!(
154 result.message.as_deref(),
155 Some("Error: Command capability unavailable: presentation")
156 );
157 assert!(result.action.is_none());
158 }
159 assert!(fake.events.borrow().is_empty());
160 }
161
162 #[test]
163 fn cache_inspection_rejections_and_argument_only_warmup_never_store() {
164 let mut fake = FakeDiagnostics::default();
165 let conflict = cache_with(&mut fake, Some("inspect --json --verbose"));
166 assert_eq!(
167 conflict.message.as_deref(),
168 Some("cache inspect: --json and --verbose cannot be combined")
169 );
170 assert!(fake.events.borrow().is_empty());
171 let warmup = cache_with(&mut fake, Some("warmup"));
172 assert!(warmup.action.is_some());
173 assert!(fake.events.borrow().is_empty());
174
175 for (failure, expected) in [
176 (
177 DebugCacheInspectionUnavailable::NoConcreteRoute,
178 "cache inspect: Auto has no concrete route yet; send a turn first",
179 ),
180 (
181 DebugCacheInspectionUnavailable::MissingCapturedEndpoint,
182 "cache inspect: the restored Auto route has no captured endpoint; send a turn first",
183 ),
184 ] {
185 fake.observation = Some(Err(failure));
186 let result = cache_with(&mut fake, Some("inspect --json"));
187 assert_eq!(result.message.as_deref(), Some(expected));
188 assert_eq!(fake.events.borrow().last().unwrap(), &"inspect");
189 assert!(
190 fake.remembered.is_none(),
191 "failure must preserve previous state"
192 );
193 }
194 }
195
196 #[test]
197 fn cache_observes_then_renders_then_stores_the_same_snapshot_once() {
198 let mut fake = FakeDiagnostics {
199 observation: Some(Ok(observation())),
200 ..FakeDiagnostics::default()
201 };
202 let result = cache_with(&mut fake, Some("inspect --json"));
203 let rendered: serde_json::Value =
204 serde_json::from_str(result.message.as_deref().unwrap()).unwrap();
205 assert_eq!(rendered["base_static_prefix_hash"], "current");
206 assert_eq!(rendered["current_warmup_key"]["model"], "test-model");
207 assert_eq!(
208 rendered["warmup_status"],
209 "Warmup status: no previous warmup (current key: current-key)"
210 );
211 assert_eq!(&*fake.events.borrow(), &["inspect", "remember"]);
212 assert_eq!(fake.remembered.as_ref(), Some(&inspection("current")));
213 }
214
215 #[test]
216 fn balance_and_tool_snapshot_branches_observe_only_their_declared_facts() {
217 let mut fake = FakeDiagnostics::default();
218 let unsupported = balance::balance(CommandContexts::empty().with_debug_diagnostics(&mut fake));
219 assert_eq!(
220 unsupported.message.as_deref(),
221 Some(
222 "Balance check is not supported for Example Provider yet. Check the provider dashboard for account balance details."
223 )
224 );
225 assert!(unsupported.action.is_none());
226 fake.balance_supported = true;
227 let supported = balance::balance(CommandContexts::empty().with_debug_diagnostics(&mut fake));
228 assert!(supported.message.is_none());
229 assert!(matches!(
230 supported.action,
231 Some(super::DebugAction::FetchBalance)
232 ));
233 let unavailable = tool_inspection::tools(
234 CommandContexts::empty().with_debug_diagnostics(&mut fake),
235 Some("yaml"),
236 );
237 assert_eq!(
238 unavailable.message.as_deref(),
239 Some(
240 "Tool request snapshot unavailable — no model request has been captured for the latest turn."
241 )
242 );
243 assert!(
244 !unavailable.is_error,
245 "availability precedes format validation"
246 );
247 assert_eq!(
248 &*fake.events.borrow(),
249 &["balance", "balance", "tool_snapshot"]
250 );
251 }
252
253 #[test]
254 fn json_serialization_fallback_still_commits_the_observed_inspection_once() {
255 // Real semantic inspection data cannot make serde_json fail. Inject only
256 // the serializer result, not a production callback or host state, then
257 // exercise the same fallback/commit helpers as the live command.
258 let output = cache::json_or_fallback(Err(serde_json::Error::io(std::io::Error::other(
259 "forced serializer failure",
260 ))));
261 assert_eq!(
262 output,
263 "{\"error\":\"cache inspection serialization failed\"}"
264 );
265 let mut fake = FakeDiagnostics::default();
266 let delivered = cache::commit_rendered_inspection(&mut fake, observation(), output);
267 assert_eq!(
268 delivered,
269 "{\"error\":\"cache inspection serialization failed\"}"
270 );
271 assert_eq!(&*fake.events.borrow(), &["remember"]);
272 assert_eq!(fake.remembered.as_ref(), Some(&inspection("current")));
273 }
274
274 lines RUST