返回 CodeWhale
debug_change_host_tests.rs
根目录 / crates / tui / src / commands / debug_change_host_tests.rs
1 //! Preserved changelog parsing and host parity tests, outside the portable group.
2
3 use crate::commands::CommandResult;
4 use crate::commands::groups::debug::change::*;
5 use crate::tui::app::AppAction;
6 use codewhale_localization::{MessageId, tr};
7 const CODEWHALE_CHANGELOG: &str = include_str!("../../CHANGELOG.md");
8 fn change(app: &mut App, version: Option<&str>) -> CommandResult {
9 crate::commands::execute(
10 &version.map_or_else(|| "/change".to_string(), |arg| format!("/change {arg}")),
11 app,
12 )
13 }
14 use crate::config::Config;
15 use crate::test_support::{EnvVarGuard, lock_test_env};
16 use crate::tui::app::{App, TuiOptions};
17 use codewhale_localization::Locale;
18 fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App {
19 let mut config = Config::default();
20 if has_api_key {
21 config.set_legacy_root(Some("test-key".to_string()), None);
22 }
23 let mut app = App::new(
24 TuiOptions {
25 skills_dir: tmpdir.path().join("skills"),
26 memory_path: tmpdir.path().join("memory.md"),
27 notes_path: tmpdir.path().join("notes.txt"),
28 mcp_config_path: tmpdir.path().join("mcp.json"),
29 ..crate::test_support::test_tui_options(tmpdir.path())
30 },
31 &config,
32 );
33 app.ui_locale = locale;
34 app.api_provider = crate::config::ProviderKind::Deepseek;
35 app.model_ids_passthrough = false;
36 app.onboarding_needs_api_key = !has_api_key;
37 app
38 }
39
40 #[test]
41 fn extract_latest_section_finds_first_version() {
42 let content = "\n\
43 ## [0.8.26] - 2026-05-09\n\
44 \n\
45 A security + polish release.\n\
46 \n\
47 ### Fixed\n\
48 \n\
49 - Fixed something\n\
50 \n\
51 ## [0.8.25] - 2026-05-09\n\
52 \n\
53 A stabilization release.\n";
54 let section = extract_latest_changelog_section(content).expect("should find a section");
55 assert!(section.contains("0.8.26"));
56 assert!(section.contains("Fixed something"));
57 assert!(!section.contains("0.8.25"));
58 }
59
60 #[test]
61 fn extract_latest_section_handles_0_8_29_style_fixture() {
62 let content = "\n\
63 # Changelog\n\
64 \n\
65 ## [0.8.29] - 2026-05-11\n\
66 \n\
67 Release candidate polish.\n\
68 \n\
69 ### Added\n\
70 - New note-management command.\n\
71 \n\
72 ## [0.8.28] - 2026-05-10\n\
73 \n\
74 Previous release.\n";
75 let section = extract_latest_changelog_section(content).expect("should find a section");
76 assert!(section.contains("0.8.29"));
77 assert!(section.contains("2026-05-11"));
78 assert!(section.contains("New note-management command"));
79 assert!(!section.contains("0.8.28"));
80 }
81
82 #[test]
83 fn extract_latest_section_returns_none_for_empty_content() {
84 assert!(extract_latest_changelog_section("").is_none());
85 }
86
87 #[test]
88 fn extract_latest_section_returns_none_for_no_version_headers() {
89 let content = "# Just a heading\n\nSome text\n";
90 assert!(extract_latest_changelog_section(content).is_none());
91 }
92
93 #[test]
94 fn extract_latest_section_handles_single_version() {
95 let content = "\n## [0.8.26] - 2026-05-09\n\nOnly one version.\n";
96 let section = extract_latest_changelog_section(content).expect("should find a section");
97 assert!(section.contains("0.8.26"));
98 assert!(section.contains("Only one version"));
99 }
100
101 #[test]
102 fn extract_latest_section_handles_subheadings() {
103 let content = "\n\
104 ## [0.8.26] - 2026-05-09\n\
105 \n\
106 ### Added\n\
107 - New feature A\n\
108 \n\
109 ### Fixed\n\
110 - Fixed bug B\n\
111 \n\
112 ## [0.8.25] - 2026-05-09\n\
113 ";
114 let section = extract_latest_changelog_section(content).expect("should find a section");
115 assert!(section.contains("New feature A"));
116 assert!(section.contains("Fixed bug B"));
117 assert!(!section.contains("0.8.25"));
118 }
119
120 #[test]
121 fn change_uses_bundled_release_notes_without_workspace_changelog() {
122 let tmp = tempfile::TempDir::new().unwrap();
123 let mut app = make_app(&tmp, Locale::En, false);
124 let result = change(&mut app, None);
125 assert!(!result.is_error);
126 let msg = result.message.expect("should have a message");
127 let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG)
128 .expect("bundled changelog should have a release section");
129 assert!(msg.contains(expected.lines().next().unwrap()));
130 }
131
132 #[test]
133 fn change_ignores_workspace_changelog() {
134 let tmp = tempfile::TempDir::new().unwrap();
135 std::fs::write(
136 tmp.path().join("CHANGELOG.md"),
137 "\n## [9.9.9] - 2099-01-01\n\nWorkspace changelog.\n",
138 )
139 .unwrap();
140 let mut app = make_app(&tmp, Locale::En, false);
141 let result = change(&mut app, None);
142 assert!(!result.is_error);
143 let msg = result.message.expect("should have a message");
144 assert!(!msg.contains("9.9.9"));
145 assert!(!msg.contains("Workspace changelog"));
146 }
147
148 #[test]
149 fn change_in_english_returns_message_without_action() {
150 let tmp = tempfile::TempDir::new().unwrap();
151 let mut app = make_app(&tmp, Locale::En, true);
152 let result = change(&mut app, None);
153 assert!(!result.is_error);
154 let msg = result.message.expect("should have a message");
155 let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG)
156 .expect("bundled changelog should have a release section");
157 assert!(msg.contains(expected.lines().next().unwrap()));
158 assert!(
159 result.action.is_none(),
160 "English locale should not send translation"
161 );
162 }
163
164 #[test]
165 fn change_in_non_english_also_sends_translation_action() {
166 for (locale, _label) in [
167 (Locale::ZhHans, "zh-Hans"),
168 (Locale::Ja, "ja"),
169 (Locale::PtBr, "pt-BR"),
170 ] {
171 let tmp = tempfile::TempDir::new().unwrap();
172 let mut app = make_app(&tmp, locale, true);
173 let result = change(&mut app, None);
174 assert!(!result.is_error, "Failed for locale {locale:?}");
175 let msg = result.message.expect("should have a message");
176 assert!(msg.contains(&*tr(locale, MessageId::CmdChangeTranslationQueued)));
177 assert!(
178 matches!(result.action, Some(AppAction::SendMessage(_))),
179 "Non-English locale should send translation, got {:?}",
180 result.action
181 );
182 if let Some(AppAction::SendMessage(prompt)) = &result.action {
183 let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG)
184 .expect("bundled changelog should have a release section");
185 assert!(prompt.contains(expected.lines().next().unwrap()));
186 let prev_ver = extract_previous_version_number(CODEWHALE_CHANGELOG)
187 .expect("bundled changelog should have a previous release");
188 assert!(
189 prompt.contains(&prev_ver),
190 "translation prompt should include previous-version hint: {prompt}"
191 );
192 }
193 }
194 }
195
196 #[test]
197 fn change_in_non_english_without_api_key_uses_explicit_fallback() {
198 let tmp = tempfile::TempDir::new().unwrap();
199 let _lock = lock_test_env();
200 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
201 let _deepseek_key = EnvVarGuard::remove("DEEPSEEK_API_KEY");
202 let _deepseek_provider = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
203 let _codewhale_provider = EnvVarGuard::remove("CODEWHALE_PROVIDER");
204 let mut app = make_app(&tmp, Locale::ZhHans, false);
205 let result = change(&mut app, None);
206 assert!(!result.is_error);
207 let msg = result.message.expect("should have a message");
208 assert!(msg.contains(&*tr(
209 Locale::ZhHans,
210 MessageId::CmdChangeTranslationUnavailable
211 )));
212 assert!(
213 result.action.is_none(),
214 "missing API key should not send translation"
215 );
216 }
217
218 #[test]
219 fn change_in_non_english_offline_uses_explicit_fallback() {
220 let tmp = tempfile::TempDir::new().unwrap();
221 let mut app = make_app(&tmp, Locale::Ja, true);
222 app.offline_mode = true;
223 let result = change(&mut app, None);
224 assert!(!result.is_error);
225 let msg = result.message.expect("should have a message");
226 assert!(msg.contains(&*tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable)));
227 assert!(
228 result.action.is_none(),
229 "offline mode should not send translation"
230 );
231 }
232
233 #[test]
234 fn extract_latest_ignores_lines_before_first_version() {
235 let content = "\n\
236 # Changelog\n\
237 \n\
238 Some intro text.\n\
239 \n\
240 ## [0.8.26] - 2026-05-09\n\
241 \n\
242 Content\n\
243 ";
244 let section = extract_latest_changelog_section(content).expect("should find a section");
245 assert!(section.contains("0.8.26"));
246 assert!(!section.contains("Changelog"));
247 assert!(!section.contains("intro text"));
248 }
249
250 #[test]
251 fn extract_latest_skips_empty_unreleased_section() {
252 let content = "\n\
253 ## [Unreleased]\n\
254 \n\
255 ## [0.8.32] - 2026-05-12\n\
256 \n\
257 A release with content.\n\
258 \n\
259 ### Fixed\n\
260 - Something fixed\n\
261 \n\
262 ## [0.8.31] - 2026-05-11\n\
263 \n\
264 Previous release.\n";
265 let section = extract_latest_changelog_section(content).expect("should skip Unreleased");
266 assert!(section.contains("0.8.32"));
267 assert!(section.contains("Something fixed"));
268 assert!(!section.contains("Unreleased"));
269 assert!(!section.contains("0.8.31"));
270 }
271
272 #[test]
273 fn extract_latest_skips_entirely_empty_unreleased() {
274 // `## [Unreleased]` followed immediately by the next version heading.
275 let content = "\n\
276 ## [Unreleased]\n\
277 ## [0.8.32] - 2026-05-12\n\
278 \n\
279 Content here.\n";
280 let section = extract_latest_changelog_section(content).expect("should find 0.8.32");
281 assert!(section.contains("0.8.32"));
282 assert!(!section.contains("Unreleased"));
283 }
284
285 #[test]
286 fn extract_latest_returns_none_when_all_sections_empty() {
287 let content = "\n\
288 ## [Unreleased]\n\
289 ## [Future]\n";
290 assert!(extract_latest_changelog_section(content).is_none());
291 }
292
293 #[test]
294 fn extract_latest_skips_multiple_empty_sections() {
295 let content = "\n\
296 ## [Unreleased]\n\
297 \n\
298 ## [Next]\n\
299 \n\
300 ## [0.8.32] - 2026-05-12\n\
301 \n\
302 Real content.\n";
303 let section = extract_latest_changelog_section(content).expect("should find 0.8.32");
304 assert!(section.contains("0.8.32"));
305 assert!(section.contains("Real content"));
306 }
307
308 #[test]
309 fn extract_by_version_finds_exact_version() {
310 let content = "\n\
311 ## [0.8.32] - 2026-05-12\n\
312 \n\
313 Release content.\n\
314 \n\
315 ## [0.8.31] - 2026-05-11\n\
316 \n\
317 Earlier release.\n";
318 let section =
319 extract_changelog_section_by_version(content, "0.8.31").expect("should find 0.8.31");
320 assert!(section.contains("0.8.31"));
321 assert!(section.contains("Earlier release"));
322 assert!(!section.contains("0.8.32"));
323 }
324
325 #[test]
326 fn extract_by_version_returns_none_for_missing_version() {
327 let content = "\n\
328 ## [0.8.32] - 2026-05-12\n\
329 \n\
330 Content.\n";
331 assert!(extract_changelog_section_by_version(content, "9.9.9").is_none());
332 }
333
334 #[test]
335 fn extract_by_version_finds_version_without_date() {
336 let content = "\n\
337 ## [Unreleased]\n\
338 \n\
339 Nothing.\n";
340 let section = extract_changelog_section_by_version(content, "Unreleased")
341 .expect("should find Unreleased");
342 assert!(section.contains("Unreleased"));
343 assert!(section.contains("Nothing"));
344 }
345
346 #[test]
347 fn extract_by_version_respects_empty_sections() {
348 // `## [0.8.32]` is empty, should return None for it
349 let content = "\n\
350 ## [0.8.32] - 2026-05-12\n\
351 ## [0.8.31] - 2026-05-11\n\
352 \n\
353 Content.\n";
354 assert!(extract_changelog_section_by_version(content, "0.8.32").is_none());
355 }
356
357 #[test]
358 fn change_with_version_arg_shows_older_release() {
359 let tmp = tempfile::TempDir::new().unwrap();
360 let mut app = make_app(&tmp, Locale::En, false);
361 let result = change(&mut app, Some("0.8.1"));
362 // 0.8.1 is a very old release; if it exists, the result should not be an error.
363 // If that exact version doesn't exist in the bundled changelog, we still
364 // expect a proper error message referencing the version.
365 if result.is_error {
366 let msg = result.message.as_deref().unwrap_or("");
367 assert!(msg.contains("0.8.1"), "error should mention version: {msg}");
368 } else {
369 let msg = result.message.expect("should have a message");
370 assert!(msg.contains("0.8.1"));
371 }
372 }
373
374 #[test]
375 fn change_with_empty_version_arg_acts_as_default() {
376 let tmp = tempfile::TempDir::new().unwrap();
377 let mut app = make_app(&tmp, Locale::En, false);
378 let result_default = change(&mut app, None);
379 assert!(!result_default.is_error);
380
381 let mut app2 = make_app(&tmp, Locale::En, false);
382 let result_empty = change(&mut app2, Some(""));
383 assert!(!result_empty.is_error);
384
385 // Both should have the same message content
386 let msg_default = result_default.message.as_deref().unwrap_or("");
387 let msg_empty = result_empty.message.as_deref().unwrap_or("");
388 assert_eq!(msg_default, msg_empty);
389 }
390
391 #[test]
392 fn change_with_nonexistent_version_returns_error() {
393 let tmp = tempfile::TempDir::new().unwrap();
394 let mut app = make_app(&tmp, Locale::En, false);
395 let result = change(&mut app, Some("99.99.99"));
396 assert!(result.is_error);
397 let msg = result.message.as_deref().unwrap_or("");
398 assert!(
399 msg.contains("99.99.99"),
400 "error should mention version: {msg}"
401 );
402 }
403
404 #[test]
405 fn extract_by_version_ignores_substring_matches() {
406 let content =
407 "\n## [0.8.1] - 2026-01-01\n\nContent A.\n\n## [0.8.10] - 2026-01-10\n\nContent B.\n";
408 let section =
409 extract_changelog_section_by_version(content, "0.8.1").expect("should find 0.8.1");
410 assert!(section.contains("Content A"));
411 assert!(!section.contains("Content B"));
412 }
413
414 // --- extract_previous_version_number tests ---
415
416 #[test]
417 fn prev_version_finds_second_heading() {
418 let content = "\n\
419 ## [0.8.32] - 2026-05-12\n\
420 \n\
421 Release content.\n\
422 \n\
423 ## [0.8.31] - 2026-05-11\n\
424 \n\
425 Earlier release.\n";
426 let prev = extract_previous_version_number(content).expect("should find 0.8.31");
427 assert_eq!(prev, "0.8.31");
428 }
429
430 #[test]
431 fn prev_version_skips_empty_unreleased_section() {
432 let content = "\n\
433 ## [Unreleased]\n\
434 \n\
435 ## [0.8.32] - 2026-05-12\n\
436 \n\
437 Actual release.\n\
438 \n\
439 ## [0.8.31] - 2026-05-11\n\
440 \n\
441 Older release.\n";
442 let prev =
443 extract_previous_version_number(content).expect("should skip Unreleased and find 0.8.31");
444 assert_eq!(prev, "0.8.31");
445 }
446
447 #[test]
448 fn prev_version_returns_none_for_single_version() {
449 let content = "\n## [0.8.32] - 2026-05-12\n\nOnly one version.\n";
450 assert!(extract_previous_version_number(content).is_none());
451 }
452
453 #[test]
454 fn prev_version_returns_none_for_empty_content() {
455 assert!(extract_previous_version_number("").is_none());
456 }
457
458 #[test]
459 fn prev_version_returns_none_for_no_version_headers() {
460 let content = "# Just a heading\n\nNo versions here.\n";
461 assert!(extract_previous_version_number(content).is_none());
462 }
463
464 #[test]
465 fn prev_version_handles_adjacent_headings() {
466 let content = "\n\
467 ## [0.8.32] - 2026-05-12\n\
468 \n\
469 Content.\n\
470 ## [0.8.31] - 2026-05-11\n\
471 \n\
472 Older content.\n";
473 let prev = extract_previous_version_number(content)
474 .expect("should find 0.8.31 even with no blank line after section");
475 assert_eq!(prev, "0.8.31");
476 }
477
478 #[test]
479 fn prev_version_skips_multiple_empty_sections() {
480 let content = "\n\
481 ## [Unreleased]\n\
482 \n\
483 ## [Future]\n\
484 \n\
485 ## [0.8.32] - 2026-05-12\n\
486 \n\
487 Real release.\n\
488 \n\
489 ## [0.8.31] - 2026-05-11\n\
490 \n\
491 Older release.\n";
492 let prev = extract_previous_version_number(content)
493 .expect("should skip Unreleased and Future, find 0.8.31");
494 assert_eq!(prev, "0.8.31");
495 }
496
497 #[test]
498 fn prev_version_after_explicit_version_finds_next_older_release() {
499 let content = "\n\
500 ## [0.8.32] - 2026-05-12\n\
501 \n\
502 Current release.\n\
503 \n\
504 ## [0.8.31] - 2026-05-11\n\
505 \n\
506 Requested release.\n\
507 \n\
508 ## [0.8.30] - 2026-05-10\n\
509 \n\
510 Older release.\n";
511 let prev = extract_previous_version_number_after_version(content, "0.8.31")
512 .expect("should find 0.8.30");
513 assert_eq!(prev, "0.8.30");
514 }
515
516 #[test]
517 fn prev_version_after_explicit_version_skips_empty_sections() {
518 let content = "\n\
519 ## [0.8.32] - 2026-05-12\n\
520 \n\
521 Current release.\n\
522 \n\
523 ## [0.8.31] - 2026-05-11\n\
524 \n\
525 Requested release.\n\
526 \n\
527 ## [Future]\n\
528 \n\
529 ## [0.8.30] - 2026-05-10\n\
530 \n\
531 Older release.\n";
532 let prev = extract_previous_version_number_after_version(content, "0.8.31")
533 .expect("should skip Future and find 0.8.30");
534 assert_eq!(prev, "0.8.30");
535 }
536
537 // --- change() output hint tests ---
538
539 #[test]
540 fn change_without_args_includes_previous_version_hint() {
541 let tmp = tempfile::TempDir::new().unwrap();
542 let mut app = make_app(&tmp, Locale::En, false);
543 let result = change(&mut app, None);
544 assert!(!result.is_error);
545 let msg = result.message.expect("should have a message");
546 // The previous version hint should be part of the output.
547 // We can't assert an exact version number since the changelog changes,
548 // but the hint message key should appear.
549 assert!(
550 msg.contains("Previous version:") || msg.contains("run `/change"),
551 "expected previous-version hint in output, got: {msg}"
552 );
553 }
554
555 #[test]
556 fn change_with_explicit_version_includes_previous_hint() {
557 let tmp = tempfile::TempDir::new().unwrap();
558 let mut app = make_app(&tmp, Locale::En, false);
559 // Derive versions from the bundled changelog: it only embeds a recent
560 // slice of releases, so hardcoded versions would age out of it.
561 let explicit = extract_previous_version_number(CODEWHALE_CHANGELOG)
562 .expect("bundled changelog should have a previous release");
563 let expected_prev =
564 extract_previous_version_number_after_version(CODEWHALE_CHANGELOG, &explicit)
565 .expect("bundled changelog should have at least three releases");
566 let result = change(&mut app, Some(&explicit));
567 assert!(!result.is_error);
568 let msg = result.message.as_deref().unwrap_or("");
569 assert!(
570 msg.contains("Previous version:") && msg.contains(&expected_prev),
571 "explicit version should show previous-version hint: {msg}"
572 );
573 }
574
575 #[test]
576 fn change_hint_uses_localized_template() {
577 let tmp = tempfile::TempDir::new().unwrap();
578 let mut app = make_app(&tmp, Locale::ZhHans, true);
579 let result = change(&mut app, None);
580 assert!(!result.is_error);
581 let msg = result.message.expect("should have a message");
582 // zh-Hans template: "上一个版本:"
583 assert!(
584 msg.contains("上一个版本"),
585 "zh-Hans output should contain localized hint: {msg}"
586 );
587 }
588
589 #[test]
590 fn change_hint_in_japanese() {
591 let tmp = tempfile::TempDir::new().unwrap();
592 let mut app = make_app(&tmp, Locale::Ja, true);
593 let result = change(&mut app, None);
594 assert!(!result.is_error);
595 let msg = result.message.expect("should have a message");
596 assert!(
597 msg.contains("前のバージョン"),
598 "ja output should contain localized hint: {msg}"
599 );
600 }
601
602 #[test]
603 fn change_hint_in_portuguese() {
604 let tmp = tempfile::TempDir::new().unwrap();
605 let mut app = make_app(&tmp, Locale::PtBr, true);
606 let result = change(&mut app, None);
607 assert!(!result.is_error);
608 let msg = result.message.expect("should have a message");
609 assert!(
610 msg.contains("Versão anterior"),
611 "pt-BR output should contain localized hint: {msg}"
612 );
613 }
614
614 lines RUST