返回 CodeWhale
debug_diagnostics_surface_tests.rs
根目录 / crates / tui / src / commands / debug_diagnostics_surface_tests.rs
1 //! FEAT-029: public command-surface and localization parity for the
2 //! `debug::diagnostics` slice.
3 //!
4 //! The host regressions prove handler/rendering parity. This module proves the
5 //! *observable command surface* is unchanged: registry metadata (name, aliases,
6 //! usage), registry position, the `description_key` -> catalog bridge, palette
7 //! and discovery classification, and canonical-name/alias dispatch equivalence
8 //! through the public `execute` seam.
9 //!
10 //! It lives at the `commands` root for the same FEAT-045 extraction reason as
11 //! the regression suite. The metadata fixture was captured from the
12 //! untouched implementation and compared byte-for-byte.
13
14 use codewhale_localization::{Locale, MessageId};
15
16 use crate::commands::debug_diagnostics_test_support::{DiagnosticsHarness, assert_fixture};
17 use crate::commands::{CommandResult, execute};
18 use crate::config::ProviderKind;
19 use crate::tui::app::AppAction;
20
21 /// The eight declared diagnostics commands in registry order, with their exact
22 /// aliases and catalog description ids.
23 const DIAGNOSTICS: &[(&str, &[&str], MessageId)] = &[
24 ("tokens", &[], MessageId::CmdTokensDescription),
25 ("cost", &[], MessageId::CmdCostDescription),
26 ("balance", &[], MessageId::CmdBalanceDescription),
27 ("cache", &[], MessageId::CmdCacheDescription),
28 (
29 "preview-request",
30 &["dryrun", "preview_request"],
31 MessageId::CmdPreviewRequestDescription,
32 ),
33 ("tools", &["tool-studio"], MessageId::CmdToolsDescription),
34 ("system", &["xitong"], MessageId::CmdSystemDescription),
35 ("context", &["ctx"], MessageId::CmdContextDescription),
36 ];
37
38 fn info(name: &str) -> &'static crate::commands::CommandInfo {
39 crate::commands::get_command_info(name)
40 .unwrap_or_else(|| panic!("/{name} must remain registered"))
41 }
42
43 /// Freeze the complete registry metadata surface (name, aliases, usage,
44 /// English description, palette text, discovery flags) for all eight commands.
45 #[test]
46 fn diagnostics_surface_metadata_matches_baseline() {
47 let _harness = DiagnosticsHarness::new();
48 let mut out = String::new();
49 for (name, _, _) in DIAGNOSTICS {
50 let info = info(name);
51 out.push_str(&format!("name: {}\n", info.name));
52 out.push_str(&format!("aliases: {:?}\n", info.aliases));
53 out.push_str(&format!("usage: {}\n", info.usage));
54 out.push_str(&format!(
55 "description_en: {}\n",
56 info.description_for(Locale::En)
57 ));
58 out.push_str(&format!(
59 "palette_en: {}\n",
60 info.palette_description_for(Locale::En)
61 ));
62 out.push_str(&format!(
63 "requires_argument: {}\n",
64 info.requires_argument()
65 ));
66 out.push_str(&format!("unlisted: {}\n", info.is_unlisted()));
67 out.push_str(&format!(
68 "show_in_empty_discovery: {}\n",
69 info.show_in_empty_discovery()
70 ));
71 out.push_str("---\n");
72 }
73 assert_fixture("surface_metadata.txt", &out);
74 }
75
76 /// Every canonical name and alias resolves to the same registry entry, and the
77 /// `description_key` bridge maps to the expected catalog `MessageId`.
78 #[test]
79 fn diagnostics_names_aliases_and_description_bridge_are_exact() {
80 let registry = crate::commands::registry();
81 for (name, aliases, description_id) in DIAGNOSTICS {
82 let canonical = info(name);
83 assert_eq!(canonical.name, *name, "canonical name");
84 assert_eq!(canonical.aliases, *aliases, "/{name} alias list");
85 assert_eq!(
86 canonical.description_id, *description_id,
87 "/{name} catalog bridge"
88 );
89 assert!(
90 !canonical.description_for(Locale::En).trim().is_empty(),
91 "/{name} must resolve an English description"
92 );
93
94 let entry = registry
95 .get(name)
96 .unwrap_or_else(|| panic!("/{name} must be registered"));
97 assert_eq!(entry.info().name, *name);
98 for alias in *aliases {
99 let via_alias = registry
100 .get(alias)
101 .unwrap_or_else(|| panic!("/{alias} must resolve"));
102 assert_eq!(
103 via_alias.info().name,
104 *name,
105 "/{alias} must resolve to /{name}"
106 );
107 assert_eq!(via_alias.info().usage, canonical.usage, "/{alias} usage");
108 }
109 }
110 }
111
112 /// Portable description keys resolve to the exact original catalog ids for
113 /// every member; metadata does not grant a runtime presentation facet.
114 #[test]
115 fn diagnostics_description_keys_resolve_to_the_original_catalog() {
116 let keys = [
117 ("tokens", "cmd_tokens_description"),
118 ("cost", "cmd_cost_description"),
119 ("balance", "cmd_balance_description"),
120 ("cache", "cmd_cache_description"),
121 ("preview-request", "cmd_preview_request_description"),
122 ("tools", "cmd_tools_description"),
123 ("system", "cmd_system_description"),
124 ("context", "cmd_context_description"),
125 ];
126 for ((name, _, id), (expected_name, key)) in DIAGNOSTICS.iter().zip(keys) {
127 assert_eq!(*name, expected_name);
128 assert_eq!(super::contract::key_to_message_id(key), Some(*id));
129 assert_eq!(info(name).description_id, *id);
130 assert!(!info(name).description_for(Locale::Ja).is_empty());
131 }
132 assert_eq!(
133 super::contract::key_to_message_id("cmd_not_registered_description"),
134 None
135 );
136 }
137
138 /// Runtime presentation uses the existing catalog and exact named-placeholder
139 /// contract for only the three commands that need it. Literal metadata is
140 /// separately bridged above; an unknown key or incomplete replacements fail.
141 #[test]
142 fn diagnostics_runtime_translation_uses_original_catalog_and_placeholder_contract() {
143 let mut harness = DiagnosticsHarness::new();
144 harness.app.ui_locale = Locale::Ja;
145 let mut bundle = harness.app.command_contexts();
146 let mut parts = bundle
147 .contexts(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION)
148 .into_parts();
149 let presentation = parts.presentation.as_deref_mut().unwrap();
150 assert_eq!(
151 presentation.translate("cmd_cost_coverage", &[("priced", "2"), ("turns", "3")]),
152 Ok("対象: 課金対象ターン 3 件のうち 2 件を算定しました。".into())
153 );
154 assert_eq!(
155 presentation.translate(
156 "cmd_cache_totals",
157 &[
158 ("sum_in", "4"),
159 ("sum_hit", "2"),
160 ("sum_miss", "1"),
161 ("avg", "50%")
162 ],
163 ),
164 Ok("Σ 入力: 4 Σ ヒット: 2 Σ ミス: 1 平均ヒット率: 50%\n".into())
165 );
166 assert_eq!(
167 presentation.translate(
168 "cmd_tokens_context_with_window",
169 &[("used", "6"), ("window", "12"), ("percent", "50.0")],
170 ),
171 Ok("~6 / 12 (50.0%)".into())
172 );
173 assert_eq!(
174 presentation.translate("cmd_cost_coverage", &[("priced", "2")]),
175 Err("invalid translation replacement contract".into())
176 );
177 assert_eq!(
178 presentation.translate("cmd_not_registered", &[]),
179 Err("unknown translation key".into())
180 );
181 }
182
183 /// The production registry now exposes exactly the declared facets, including
184 /// each alias. Preview stays a pure function, not a contextual empty envelope.
185 #[test]
186 fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() {
187 use codewhale_command_contract::handler::{
188 CommandCapabilities as Caps, CommandContexts, CommandHandler,
189 };
190
191 let mut harness = DiagnosticsHarness::new();
192 for (name, aliases, _) in DIAGNOSTICS {
193 let expected = match *name {
194 "tokens" | "cost" | "cache" => Caps::DEBUG_DIAGNOSTICS | Caps::PRESENTATION,
195 "preview-request" => Caps::NONE,
196 _ => Caps::DEBUG_DIAGNOSTICS,
197 };
198 for spelling in std::iter::once(*name).chain(aliases.iter().copied()) {
199 let registered = crate::commands::registry()
200 .get(spelling)
201 .expect("registered spelling");
202 match registered
203 .contextual_handler()
204 .expect("portable registration")
205 {
206 CommandHandler::Pure(pure) => {
207 assert_eq!(*name, "preview-request", "only preview is pure");
208 assert_eq!(expected, Caps::NONE);
209 assert!(matches!(
210 pure(Some("json")).action,
211 Some(AppAction::PreviewOutboundRequest { json: true, .. })
212 ));
213 }
214 CommandHandler::Contextual {
215 capabilities,
216 handler,
217 } => {
218 assert_ne!(*name, "preview-request");
219 assert_eq!(capabilities, expected, "/{spelling} declaration");
220 let result = handler(CommandContexts::empty(), None);
221 assert!(result.is_error);
222 assert_eq!(
223 result.message.as_deref(),
224 Some("Error: Command capability unavailable: debug_diagnostics")
225 );
226 assert!(result.action.is_none());
227
228 let mut bundle = harness.app.command_contexts();
229 let parts = bundle.contexts(capabilities).into_parts();
230 let codewhale_command_contract::handler::ContextParts {
231 session,
232 model,
233 cost,
234 mode_policy,
235 system_prompt,
236 skills,
237 workspace,
238 presentation,
239 media,
240 memory,
241 project,
242 skill_group,
243 plugin,
244 lifecycle,
245 control,
246 export,
247 structcopy,
248 debug_receipts,
249 debug_change,
250 debug_history,
251 debug_diff,
252 debug_undo,
253 debug_diagnostics,
254 } = parts;
255 assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics");
256 assert_eq!(
257 presentation.is_some(),
258 expected.contains(Caps::PRESENTATION),
259 "/{spelling} presentation"
260 );
261 for (facet, exposed) in [
262 ("session", session.is_some()),
263 ("model", model.is_some()),
264 ("cost", cost.is_some()),
265 ("mode_policy", mode_policy.is_some()),
266 ("system_prompt", system_prompt.is_some()),
267 ("skills", skills.is_some()),
268 ("workspace", workspace.is_some()),
269 ("media", media.is_some()),
270 ("memory", memory.is_some()),
271 ("project", project.is_some()),
272 ("skill_group", skill_group.is_some()),
273 ("plugin", plugin.is_some()),
274 ("lifecycle", lifecycle.is_some()),
275 ("control", control.is_some()),
276 ("export", export.is_some()),
277 ("structcopy", structcopy.is_some()),
278 ("debug_receipts", debug_receipts.is_some()),
279 ("debug_change", debug_change.is_some()),
280 ("debug_history", debug_history.is_some()),
281 ("debug_diff", debug_diff.is_some()),
282 ("debug_undo", debug_undo.is_some()),
283 ] {
284 assert!(!exposed, "/{spelling} must not expose {facet}");
285 }
286 }
287 }
288 }
289 }
290 for name in ["receipts", "change", "edit", "diff", "undo", "retry"] {
291 assert!(
292 crate::commands::registry()
293 .get(name)
294 .unwrap()
295 .contextual_handler()
296 .is_some(),
297 "/{name} must use its portable registration"
298 );
299 }
300 }
301
302 /// Registry position inside the debug group is preserved: the eight
303 /// diagnostics commands stay in their original relative order and the five
304 /// The other debug commands remain registered around these diagnostics.
305 #[test]
306 fn diagnostics_registry_position_matches_baseline() {
307 let names: Vec<&str> = crate::commands::command_infos()
308 .iter()
309 .map(|info| info.name)
310 .collect();
311 let position = |name: &str| {
312 names
313 .iter()
314 .position(|candidate| *candidate == name)
315 .unwrap_or_else(|| panic!("/{name} must be registered; found {names:?}"))
316 };
317
318 let order = [
319 "tokens",
320 "cost",
321 "receipts",
322 "balance",
323 "cache",
324 "preview-request",
325 "tools",
326 "change",
327 "system",
328 "context",
329 "edit",
330 "diff",
331 "undo",
332 "retry",
333 ];
334 for pair in order.windows(2) {
335 assert!(
336 position(pair[0]) < position(pair[1]),
337 "/{} must stay before /{}",
338 pair[0],
339 pair[1]
340 );
341 }
342 }
343
344 fn render(result: &CommandResult) -> String {
345 let mut out = String::new();
346 out.push_str(&format!("is_error: {}\n", result.is_error));
347 match &result.message {
348 Some(message) => out.push_str(&format!("message:\n{message}\n")),
349 None => out.push_str("message: <none>\n"),
350 }
351 match &result.action {
352 Some(action) => out.push_str(&format!("action: {action:?}\n")),
353 None => out.push_str("action: <none>\n"),
354 }
355 out
356 }
357
358 /// Canonical names and every compatibility alias dispatch to byte-identical
359 /// results through the public seam.
360 #[test]
361 fn public_dispatch_canonical_and_alias_are_byte_equivalent() {
362 let preview_cases = [
363 "/preview-request json",
364 "/preview-request json --prompt keep the bytes",
365 "/preview-request base-prompt",
366 ];
367 for command in preview_cases {
368 let canonical = {
369 let mut canonical_harness = DiagnosticsHarness::new();
370 execute(command, &mut canonical_harness.app)
371 };
372 for alias in ["dryrun", "preview_request"] {
373 let aliased = command.replacen("preview-request", alias, 1);
374 let mut alias_harness = DiagnosticsHarness::new();
375 let aliased_result = execute(&aliased, &mut alias_harness.app);
376 assert_eq!(
377 render(&canonical),
378 render(&aliased_result),
379 "{aliased} must match {command}"
380 );
381 }
382 }
383
384 // /tools and /tool-studio share the prepared-snapshot payload.
385 let canonical = {
386 let mut canonical_harness = DiagnosticsHarness::new();
387 canonical_harness.app.session.last_tool_request_snapshot = Some(snapshot());
388 execute("/tools json", &mut canonical_harness.app)
389 };
390 let mut alias_harness = DiagnosticsHarness::new();
391 alias_harness.app.session.last_tool_request_snapshot = Some(snapshot());
392 let aliased = execute("/tool-studio json", &mut alias_harness.app);
393 assert_eq!(render(&canonical), render(&aliased));
394
395 // /system and /xitong share the exact message.
396 let canonical = {
397 let mut canonical_harness = DiagnosticsHarness::new();
398 canonical_harness.app.system_prompt =
399 Some(codewhale_models::SystemPrompt::Text("aliased".to_string()));
400 execute("/system", &mut canonical_harness.app)
401 };
402 let mut alias_harness = DiagnosticsHarness::new();
403 alias_harness.app.system_prompt =
404 Some(codewhale_models::SystemPrompt::Text("aliased".to_string()));
405 let aliased = execute("/xitong", &mut alias_harness.app);
406 assert_eq!(render(&canonical), render(&aliased));
407
408 // /context and /ctx share the bare inspector action.
409 let canonical = {
410 let mut canonical_harness = DiagnosticsHarness::new();
411 execute("/context", &mut canonical_harness.app)
412 };
413 let mut alias_harness = DiagnosticsHarness::new();
414 let aliased = execute("/ctx", &mut alias_harness.app);
415 assert!(matches!(
416 canonical.action,
417 Some(AppAction::OpenContextInspector)
418 ));
419 assert_eq!(render(&canonical), render(&aliased));
420 }
421
422 /// `/preview-request` remains a pure action-only leaf through the public
423 /// dispatch path; no prompt, route or provider state is read.
424 #[test]
425 fn preview_request_remains_the_pure_registration() {
426 // The public dispatcher still uses the staged function wrapper until
427 // Phase 6 wires the portable registrations. Even here preview creates no
428 // facet bundle and parses the action without provider/session state.
429 let mut harness = DiagnosticsHarness::new();
430 harness.app.api_provider = ProviderKind::Ollama;
431 let result = execute("/preview-request", &mut harness.app);
432 assert!(matches!(
433 result.action,
434 Some(AppAction::PreviewOutboundRequest { .. })
435 ));
436 }
437
438 fn snapshot() -> crate::tool_inspection::ToolInspectionSnapshot {
439 crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request(
440 "turn-1",
441 2,
442 Some(&[tool("read_file"), tool("write_file")]),
443 )
444 }
445
446 fn tool(name: &str) -> codewhale_models::Tool {
447 codewhale_models::Tool {
448 tool_type: Some("function".to_string()),
449 name: name.to_string(),
450 description: format!("{name} test tool"),
451 input_schema: serde_json::json!({
452 "type": "object",
453 "properties": {"path": {"type": "string"}}
454 }),
455 allowed_callers: None,
456 defer_loading: Some(false),
457 input_examples: None,
458 strict: Some(true),
459 cache_control: None,
460 }
461 }
462
462 lines RUST