diff --git a/Cargo.lock b/Cargo.lock index 64e8082f..5c3d06c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2349,6 +2349,7 @@ dependencies = [ "notify", "obfstr", "opendal", + "parking_lot", "pbkdf2", "rand 0.9.4", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index f7068c11..7273c651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,7 @@ mach2 = "0.6.0" [dev-dependencies] insta = "1" mockall = "0.13" +parking_lot = "0.12" tempfile = "3" anyhow = "1" diff --git a/src/tui/components/generator_panel.rs b/src/tui/components/generator_panel.rs index 34276c28..d2b3c744 100644 --- a/src/tui/components/generator_panel.rs +++ b/src/tui/components/generator_panel.rs @@ -297,6 +297,7 @@ mod tests { #[test] fn render_embedded_has_no_style_selector() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = GeneratorState::new(); let lines = render_generator_panel(&state, true, 56, true); let style_label = t!("tui.generator.style_label").to_string(); diff --git a/src/tui/components/text_input.rs b/src/tui/components/text_input.rs index 65f27716..cc173335 100644 --- a/src/tui/components/text_input.rs +++ b/src/tui/components/text_input.rs @@ -284,6 +284,7 @@ mod tests { #[test] fn render_text_input_shows_label() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let lines = render_text_input("name", "GitHub", false, false, true, false, 60); assert_eq!(lines.len(), 1); } @@ -298,6 +299,7 @@ mod tests { #[test] fn render_masked_input_shows_dots() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let lines = render_text_input("pass", "secret", false, false, true, true, 60); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}")); @@ -305,6 +307,7 @@ mod tests { #[test] fn render_password_input_visible_shows_plaintext() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let buttons = [ PasswordButton { label: "显示".to_string(), @@ -324,6 +327,7 @@ mod tests { #[test] fn render_password_input_masked_shows_bullets() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let buttons = [PasswordButton { label: "显示".to_string(), focus_variant: PasswordFieldFocus::Show, diff --git a/src/tui/i18n/mod.rs b/src/tui/i18n/mod.rs index 6255a40f..f7209b25 100644 --- a/src/tui/i18n/mod.rs +++ b/src/tui/i18n/mod.rs @@ -71,20 +71,22 @@ pub fn switch_locale(locale: &str) { } #[cfg(test)] -static LOCALE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static LOCALE_LOCK: parking_lot::ReentrantMutex<()> = parking_lot::const_reentrant_mutex(()); /// RAII guard that serializes locale-dependent tests and restores locale on drop. -/// Uses a process-wide mutex so no two locale-sensitive tests run concurrently. +/// Uses a process-wide reentrant mutex so no two locale-sensitive tests run +/// concurrently, while still allowing a single test to nest guards (e.g. an +/// outer manual guard plus an inner render-helper guard) without deadlocking. #[cfg(test)] pub struct LocaleGuard { original: String, - _lock: std::sync::MutexGuard<'static, ()>, + _lock: parking_lot::ReentrantMutexGuard<'static, ()>, } #[cfg(test)] impl LocaleGuard { pub fn new(locale: &str) -> Self { - let lock = LOCALE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let lock = LOCALE_LOCK.lock(); let original = rust_i18n::locale().to_string(); init(locale); Self { diff --git a/src/tui/screens/database_recovery.rs b/src/tui/screens/database_recovery.rs index d74d0a63..c17a48c5 100644 --- a/src/tui/screens/database_recovery.rs +++ b/src/tui/screens/database_recovery.rs @@ -1342,6 +1342,7 @@ mod tests { #[test] fn enter_on_okb_without_path_sets_error() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut screen = DatabaseRecoveryScreen::new(DatabaseRecoveryOrigin::StartupKeyOnly); screen.focus = DatabaseRecoveryFocus::Okb; screen.mode = DatabaseRecoveryMode::OkbPathInput; diff --git a/src/tui/screens/form/render.rs b/src/tui/screens/form/render.rs index 9fb7bcdb..2a643920 100644 --- a/src/tui/screens/form/render.rs +++ b/src/tui/screens/form/render.rs @@ -938,6 +938,7 @@ mod tests { #[test] fn weak_password_dialog_renders_actions_and_newlook_warning_icon() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); terminal @@ -955,6 +956,7 @@ mod tests { #[test] fn unfocused_notes_textarea_does_not_render_cursor_style() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut state = FormState::new_edit(uuid::Uuid::nil(), CredentialType::Login); state.fields.name = "Example".into(); state.fields.set_notes_text("internal notes"); diff --git a/src/tui/screens/key_recovery.rs b/src/tui/screens/key_recovery.rs index 77ab9225..5f61d294 100644 --- a/src/tui/screens/key_recovery.rs +++ b/src/tui/screens/key_recovery.rs @@ -461,6 +461,7 @@ mod tests { #[test] fn confirm_with_incomplete_words_sets_inline_error() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut screen = KeyRecoveryScreen::new(KeyRecoveryOrigin::OnboardingRestore); screen.focus = KeyRecoveryFocus::Confirm; let result = screen.handle_key_for_test(key(KeyCode::Enter)); diff --git a/src/tui/screens/main/detail.rs b/src/tui/screens/main/detail.rs index 47280bcb..d746357a 100644 --- a/src/tui/screens/main/detail.rs +++ b/src/tui/screens/main/detail.rs @@ -1974,6 +1974,7 @@ mod tests { #[test] fn render_trash_detail_empty() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut state = DetailPanelState::default(); state.set_trash_context(true, 30); let result = render_detail_snapshot(&state, 60, 20, true, true); diff --git a/src/tui/screens/main/list/tests.rs b/src/tui/screens/main/list/tests.rs index 6212e6e6..671340f1 100644 --- a/src/tui/screens/main/list/tests.rs +++ b/src/tui/screens/main/list/tests.rs @@ -149,6 +149,10 @@ fn make_record_with_expired(id: Uuid, name: &str) -> TuiRecord { } /// Render into a TestBackend and return the buffer as a string snapshot. +/// +/// Uses the default `en` locale under a `LocaleGuard` so the render is isolated +/// from concurrent locale mutation by other tests. For other locales use +/// [`render_snapshot_locale`]. fn render_snapshot( state: &ListPanelState, width: u16, @@ -157,6 +161,22 @@ fn render_snapshot( unicode: bool, filter: RecordFilter, ) -> String { + render_snapshot_locale(state, width, height, focused, unicode, filter, "en") +} + +/// Render into a TestBackend under a specific locale and return the buffer as +/// a string snapshot. The locale is held under a `LocaleGuard` for the duration +/// of the render, isolating it from concurrent locale mutation by other tests. +fn render_snapshot_locale( + state: &ListPanelState, + width: u16, + height: u16, + focused: bool, + unicode: bool, + filter: RecordFilter, + locale: &str, +) -> String { + let _guard = LocaleGuard::new(locale); let backend = TestBackend::new(width, height); let mut terminal = ratatui::Terminal::new(backend).unwrap(); terminal @@ -168,6 +188,8 @@ fn render_snapshot( format!("{:?}", buf) } +/// Render into a TestBackend under the default `en` locale and return the +/// buffer. For other locales use [`render_buffer_locale`]. fn render_buffer( state: &ListPanelState, width: u16, @@ -176,6 +198,20 @@ fn render_buffer( unicode: bool, filter: RecordFilter, ) -> ratatui::buffer::Buffer { + render_buffer_locale(state, width, height, focused, unicode, filter, "en") +} + +/// Render into a TestBackend under a specific locale and return the buffer. +fn render_buffer_locale( + state: &ListPanelState, + width: u16, + height: u16, + focused: bool, + unicode: bool, + filter: RecordFilter, + locale: &str, +) -> ratatui::buffer::Buffer { + let _guard = LocaleGuard::new(locale); let backend = TestBackend::new(width, height); let mut terminal = ratatui::Terminal::new(backend).unwrap(); terminal @@ -434,13 +470,12 @@ fn selected_record_has_no_marker() { #[test] fn selected_chinese_timestamp_keeps_compact_right_margin() { - let _guard = LocaleGuard::zh_cn(); let mut record = make_record(Uuid::new_v4(), "Github Page", "p1024k"); record.updated_at = Utc::now() - chrono::Duration::try_days(3).unwrap(); let mut state = ListPanelState::with_records(vec![record]); state.selected_index = Some(0); - let buffer = render_buffer(&state, 64, 8, true, true, RecordFilter::All); + let buffer = render_buffer_locale(&state, 64, 8, true, true, RecordFilter::All, "zh-CN"); let title_line = (0..64) .map(|x| buffer.cell((x, 2)).expect("title row cell").symbol()) .collect::(); @@ -559,13 +594,12 @@ fn selected_record_uses_newlook_background() { #[test] fn selected_chinese_timestamp_is_not_split_by_char_width_math() { - let _guard = LocaleGuard::zh_cn(); let mut record = make_record(Uuid::new_v4(), "dd1", "ddddddd"); record.updated_at = Utc::now() - chrono::Duration::try_days(1).unwrap(); let mut state = ListPanelState::with_records(vec![record]); state.selected_index = Some(0); - let buffer = render_buffer(&state, 30, 8, true, true, RecordFilter::All); + let buffer = render_buffer_locale(&state, 30, 8, true, true, RecordFilter::All, "zh-CN"); let title_line = (0..30) .map(|x| buffer.cell((x, 2)).expect("title row cell").symbol()) .collect::(); @@ -578,6 +612,7 @@ fn selected_chinese_timestamp_is_not_split_by_char_width_math() { #[test] fn render_zero_area() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); // Should not panic let backend = TestBackend::new(0, 0); @@ -634,6 +669,7 @@ fn sort_direction_labels_ascii() { #[test] fn build_sort_bar_contains_field_name() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let line = build_sort_bar(&SortField::Name, &SortDirection::Asc, true); let combined: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(combined.contains("Name")); @@ -641,6 +677,7 @@ fn build_sort_bar_contains_field_name() { #[test] fn build_search_bar_has_cursor() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let line = build_search_bar("hello", true); let combined: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(combined.contains("hello_")); @@ -648,6 +685,7 @@ fn build_search_bar_has_cursor() { #[test] fn build_visual_bar_shows_count() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let line = build_visual_bar(3); let combined: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(combined.contains("3")); @@ -658,6 +696,7 @@ fn build_visual_bar_shows_count() { #[test] fn render_visual_mode_bar() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let line = build_visual_bar(5); assert_eq!( line.spans.len(), @@ -727,6 +766,7 @@ fn render_visual_mode_with_selections() { #[test] fn render_visual_bar_zero_selections() { + let _guard = crate::tui::i18n::LocaleGuard::en(); // Visual mode with no selections should show "(0 selected)" let line = build_visual_bar(0); let combined: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); @@ -775,6 +815,7 @@ fn exiting_visual_mode_returns_to_sort_bar() { #[test] fn build_record_item_login_type() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "MyLogin", "user@site.com"); let item = build_record_item(&record, false, false, true, true, 50, None); assert!(item.height() >= 3); // title + subtitle + separator @@ -782,6 +823,7 @@ fn build_record_item_login_type() { #[test] fn build_record_item_api_type() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record_with_type(Uuid::new_v4(), "AWS", CredentialType::Api); let item = build_record_item(&record, false, false, true, true, 50, None); assert!(item.height() >= 3); @@ -789,6 +831,7 @@ fn build_record_item_api_type() { #[test] fn build_record_item_ssh_type() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record_with_type(Uuid::new_v4(), "Server", CredentialType::Ssh); let item = build_record_item(&record, false, false, true, true, 50, None); assert!(item.height() >= 3); @@ -796,6 +839,7 @@ fn build_record_item_ssh_type() { #[test] fn build_record_item_selected_indicator() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "Test", "sub"); // With unicode and selected=true, should have ◀ let item = build_record_item(&record, true, false, true, true, 50, None); @@ -808,6 +852,7 @@ fn build_record_item_selected_indicator() { #[test] fn build_record_item_visual_selected() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "Test", "sub"); let item = build_record_item(&record, false, true, true, true, 50, None); assert!(item.height() >= 3); @@ -879,6 +924,7 @@ fn highlight_match_no_match() { #[test] fn build_record_item_with_search_highlight() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "GitHub", "user@github.com"); let item = build_record_item(&record, false, false, true, true, 50, Some("git")); assert!(item.height() >= 3); @@ -923,6 +969,7 @@ fn render_empty_state_tag() { #[test] fn build_empty_state_variant_all() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::All); assert!(matches!(variant, EmptyStateVariant::NoPasswords)); @@ -930,6 +977,7 @@ fn build_empty_state_variant_all() { #[test] fn build_empty_state_variant_favorites() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::Favorites); assert!(matches!(variant, EmptyStateVariant::NoFavorites)); @@ -937,6 +985,7 @@ fn build_empty_state_variant_favorites() { #[test] fn build_empty_state_variant_expired() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::Expired); assert!(matches!(variant, EmptyStateVariant::NoExpired)); @@ -944,6 +993,7 @@ fn build_empty_state_variant_expired() { #[test] fn build_empty_state_variant_health_issues() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::HealthIssues); assert!(matches!(variant, EmptyStateVariant::NoHealthIssues)); @@ -951,6 +1001,7 @@ fn build_empty_state_variant_health_issues() { #[test] fn build_empty_state_variant_trash() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::Trash); assert!(matches!(variant, EmptyStateVariant::EmptyTrash)); @@ -958,6 +1009,7 @@ fn build_empty_state_variant_trash() { #[test] fn build_empty_state_variant_tag() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::Tag("personal".to_string())); match variant { @@ -970,6 +1022,7 @@ fn build_empty_state_variant_tag() { #[test] fn build_empty_state_variant_search_filter() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = ListPanelState::default(); let variant = build_empty_state_variant(&state, &RecordFilter::Search("query".to_string())); match variant { @@ -982,6 +1035,7 @@ fn build_empty_state_variant_search_filter() { #[test] fn build_empty_state_variant_search_mode_overrides_filter() { + let _guard = crate::tui::i18n::LocaleGuard::en(); // When in search mode with a non-empty query, it should use NoSearchResults // from the list mode search state, regardless of the filter let state = ListPanelState { @@ -1005,6 +1059,7 @@ fn build_empty_state_variant_search_mode_overrides_filter() { #[test] fn health_badge_compromised() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Compromised), true).unwrap(); let text = span.content.as_ref(); assert!(text.contains('\u{F06BD}')); // Nerd Font leaked icon @@ -1014,6 +1069,7 @@ fn health_badge_compromised() { #[test] fn health_badge_compromised_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Compromised), false).unwrap(); let text = span.content.as_ref(); assert!( @@ -1025,6 +1081,7 @@ fn health_badge_compromised_ascii() { #[test] fn health_badge_weak() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Weak), true).unwrap(); let text = span.content.as_ref(); assert!(text.contains('\u{26A0}')); // ⚠ @@ -1034,6 +1091,7 @@ fn health_badge_weak() { #[test] fn health_badge_weak_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Weak), false).unwrap(); let text = span.content.as_ref(); assert!(text.contains('!')); @@ -1042,6 +1100,7 @@ fn health_badge_weak_ascii() { #[test] fn health_badge_duplicate() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Duplicate { group_size: 3 }), true).unwrap(); let text = span.content.as_ref(); assert!(text.contains('\u{26A0}')); // ⚠ @@ -1052,6 +1111,7 @@ fn health_badge_duplicate() { #[test] fn health_badge_duplicate_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Duplicate { group_size: 5 }), false).unwrap(); let text = span.content.as_ref(); assert!(text.contains('5')); @@ -1060,6 +1120,7 @@ fn health_badge_duplicate_ascii() { #[test] fn health_badge_expired() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Expired), true).unwrap(); let text = span.content.as_ref(); assert!(text.contains('\u{2717}')); // ✗ @@ -1069,6 +1130,7 @@ fn health_badge_expired() { #[test] fn health_badge_expired_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let span = health_badge(Some(&HealthIssue::Expired), false).unwrap(); let text = span.content.as_ref(); assert!(text.contains('x')); @@ -1077,6 +1139,7 @@ fn health_badge_expired_ascii() { #[test] fn health_badge_none() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let result: Option> = health_badge(None, true); assert!(result.is_none()); } @@ -1204,6 +1267,7 @@ fn render_visual_selected_weak_and_expired_uses_weak_color() { #[test] fn separator_is_blank_line() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "Test", "sub"); let item = build_record_item(&record, false, false, true, true, 50, None); // Item has 3 lines: title, subtitle, blank separator @@ -1236,6 +1300,7 @@ fn make_trash_record(id: Uuid, name: &str, days_ago: i64) -> TuiRecord { #[test] fn build_trash_item_has_three_lines() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "DeletedSite", 5); let item = build_trash_item(&record, false, false, true, true, 50, 30); assert!( @@ -1246,6 +1311,7 @@ fn build_trash_item_has_three_lines() { #[test] fn build_trash_item_selected_indicator() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "TestTrash", 2); let item = build_trash_item(&record, true, false, true, true, 50, 30); assert!(item.height() >= 3); @@ -1285,6 +1351,7 @@ fn trash_warning_tier_colors_applied() { #[test] fn trash_item_never_auto_delete_retention_zero() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "NeverDelete", 10); let item = build_trash_item(&record, false, false, true, true, 50, 0); assert!(item.height() >= 3); @@ -1312,6 +1379,7 @@ fn acceptance_trash_list_with_deleted_records() { #[test] fn acceptance_trash_item_warning_progressive() { + let _guard = crate::tui::i18n::LocaleGuard::en(); // Critical: deleted 28 days ago with 30-day retention = 2 days remaining let critical = make_trash_record(Uuid::new_v4(), "Critical", 28); let item = build_trash_item(&critical, false, false, true, true, 50, 30); @@ -1330,6 +1398,7 @@ fn acceptance_trash_item_warning_progressive() { #[test] fn acceptance_never_auto_delete_no_remaining_line() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "NeverDelete", 100); let item = build_trash_item(&record, false, false, true, true, 50, 0); assert!(item.height() >= 3); @@ -1376,6 +1445,7 @@ fn acceptance_trash_ascii_mode() { #[test] fn record_item_3_lines_at_full_width() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "TestRecord", "test@example.com"); let item = build_record_item(&record, false, false, true, true, 120, None); // Should have 3 lines: title, subtitle, separator @@ -1384,6 +1454,7 @@ fn record_item_3_lines_at_full_width() { #[test] fn record_item_2_lines_at_minimum_width() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_record(Uuid::new_v4(), "TestRecord", "test@example.com"); let item = build_record_item(&record, false, false, true, true, 90, None); // Should have 2 lines at minimum width: title, separator (no subtitle) @@ -1392,6 +1463,7 @@ fn record_item_2_lines_at_minimum_width() { #[test] fn trash_item_2_lines_at_minimum_width() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "TestRecord", 5); let item = build_trash_item(&record, false, false, true, true, 90, 30); // Should have 2 lines at minimum width: title, separator (no meta) @@ -1400,6 +1472,7 @@ fn trash_item_2_lines_at_minimum_width() { #[test] fn trash_item_3_lines_at_full_width() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let record = make_trash_record(Uuid::new_v4(), "TestRecord", 5); let item = build_trash_item(&record, false, false, true, true, 120, 30); // Should have 3 lines at full width: title, metadata, separator @@ -1524,3 +1597,73 @@ fn render_trash_at_minimum_width() { let result = render_snapshot(&state, 80, 24, true, true, RecordFilter::Trash); assert!(!result.is_empty()); } + +#[test] +fn list_render_isolates_en_locale_from_concurrent_zh_cn_guards() { + // Reproduces the flake root cause: en list renders (no guard) race with + // concurrent zh-CN guard holders that mutate the process-global locale. + // Before the fix, `render_snapshot` had no guard, so victims could read + // zh-CN and render "已过期" instead of "Expired". + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + + let errors = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let mut polluters = Vec::new(); + let mut victims = Vec::new(); + + // Polluters continuously hold a zh-CN guard and render. + for _ in 0..4 { + let stop = stop.clone(); + polluters.push(thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let _guard = LocaleGuard::zh_cn(); + let record = make_record_with_expired(Uuid::new_v4(), "Z"); + let state = ListPanelState::with_records(vec![record]); + let _ = render_snapshot(&state, 50, 10, true, true, RecordFilter::All); + } + })); + } + + // Victims render in en and must always contain "Expired". + for _ in 0..4 { + let errors = errors.clone(); + victims.push(thread::spawn(move || { + let record = make_record_with_expired(Uuid::new_v4(), "E"); + let state = ListPanelState::with_records(vec![record]); + for _ in 0..50 { + let result = render_snapshot(&state, 50, 10, true, true, RecordFilter::All); + if !result.contains("Expired") { + errors.fetch_add(1, Ordering::Relaxed); + } + } + })); + } + + for handle in victims { + let _ = handle.join(); + } + stop.store(true, Ordering::Relaxed); + for handle in polluters { + let _ = handle.join(); + } + + assert_eq!( + errors.load(Ordering::Relaxed), + 0, + "en list render must be isolated from concurrent zh-CN locale mutation" + ); +} + +#[test] +fn render_snapshot_locale_renders_localized_health_badge() { + let record = make_record_with_expired(Uuid::new_v4(), "OldSite"); + let state = ListPanelState::with_records(vec![record]); + + let en = render_snapshot_locale(&state, 50, 10, true, true, RecordFilter::All, "en"); + let zh = render_snapshot_locale(&state, 50, 10, true, true, RecordFilter::All, "zh-CN"); + + assert!(en.contains("Expired"), "en health badge: {en:?}"); + assert!(zh.contains("已过期"), "zh-CN health badge: {zh:?}"); +} diff --git a/src/tui/screens/main/mod.rs b/src/tui/screens/main/mod.rs index b1a667b7..6a704fe7 100644 --- a/src/tui/screens/main/mod.rs +++ b/src/tui/screens/main/mod.rs @@ -898,6 +898,7 @@ mod tests { #[test] fn vertical_separators_are_drawn_on_every_content_row() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let backend = ratatui::backend::TestBackend::new(80, 12); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/src/tui/screens/main/overlay/batch_tag.rs b/src/tui/screens/main/overlay/batch_tag.rs index 04528c8f..180c1d86 100644 --- a/src/tui/screens/main/overlay/batch_tag.rs +++ b/src/tui/screens/main/overlay/batch_tag.rs @@ -563,6 +563,7 @@ mod tests { #[test] fn render_batch_tag_is_centered_in_available_area() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let state = test_state(); let backend = ratatui::backend::TestBackend::new(100, 30); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/src/tui/screens/main/overlay/confirm.rs b/src/tui/screens/main/overlay/confirm.rs index 83bacb50..7dfa0e9c 100644 --- a/src/tui/screens/main/overlay/confirm.rs +++ b/src/tui/screens/main/overlay/confirm.rs @@ -567,6 +567,7 @@ mod tests { #[test] fn build_dialog_hard_delete_includes_irreversible_warning() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let variant = ConfirmVariant::HardDelete { record_id: Uuid::new_v4(), record_name: "GitHub".to_string(), diff --git a/src/tui/screens/main/sidebar.rs b/src/tui/screens/main/sidebar.rs index c2b83797..9583d644 100644 --- a/src/tui/screens/main/sidebar.rs +++ b/src/tui/screens/main/sidebar.rs @@ -1139,6 +1139,7 @@ mod tests { #[test] fn category_labels_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let all_label = category_label(&SidebarCategory::All, false); assert!(!all_label.is_empty()); @@ -1248,6 +1249,7 @@ mod tests { #[test] fn tag_management_mode_changes_header() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::types::Tag; let mut state = SidebarState { @@ -1280,6 +1282,7 @@ mod tests { #[test] fn tag_management_shows_edit_icon() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::types::Tag; let mut state = SidebarState { @@ -1446,6 +1449,7 @@ mod tests { #[test] fn tag_management_sort_indicator() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::tui::state::tag_management::{TagManagementState, TagSortOrder}; use crate::types::Tag; @@ -1483,6 +1487,7 @@ mod tests { #[test] fn inline_rename_accounts_for_scroll_offset() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::tui::state::tag_management::InlineEditState; use crate::types::Tag; diff --git a/src/tui/screens/main/status_bar.rs b/src/tui/screens/main/status_bar.rs index bfa679b4..6ef67c52 100644 --- a/src/tui/screens/main/status_bar.rs +++ b/src/tui/screens/main/status_bar.rs @@ -382,12 +382,14 @@ mod tests { #[test] fn shortcuts_sidebar_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Sidebar, true, false, DetailShortcutContext::Login); assert!(text.contains("Ctrl+K")); } #[test] fn shortcuts_list_same_as_sidebar() { + let _guard = crate::tui::i18n::LocaleGuard::en(); assert_eq!( shortcuts_text(PanelId::Sidebar, true, false, DetailShortcutContext::Login), shortcuts_text(PanelId::List, true, false, DetailShortcutContext::Login) @@ -396,24 +398,28 @@ mod tests { #[test] fn shortcuts_detail_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, true, false, DetailShortcutContext::Login); assert!(text.contains('c')); } #[test] fn shortcuts_sidebar_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Sidebar, false, false, DetailShortcutContext::Login); assert!(text.contains("Search")); } #[test] fn shortcuts_detail_ascii() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, false, false, DetailShortcutContext::Login); assert!(text.contains("CopyPwd") || text.contains("复制密码")); } #[test] fn secure_note_detail_shortcuts_omit_copy_and_toggle_actions() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text( PanelId::Detail, true, @@ -431,6 +437,7 @@ mod tests { #[test] fn api_detail_shortcuts_use_api_field_names() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, true, false, DetailShortcutContext::Api); assert!(text.contains("Secret Key")); assert!(text.contains("App ID")); @@ -440,6 +447,7 @@ mod tests { #[test] fn ssh_detail_shortcuts_use_ssh_field_names() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, true, false, DetailShortcutContext::Ssh); assert!(text.contains("\u{79C1}\u{94A5}") || text.contains("Private Key")); assert!(text.contains("\u{516C}\u{94A5}") || text.contains("Public Key")); @@ -449,42 +457,49 @@ mod tests { #[test] fn sync_indicator_synced_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::Synced, true); assert!(text.starts_with('\u{2713}')); // ✓ } #[test] fn sync_indicator_configured_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::Configured, true); assert!(text.contains(t!("tui.status_bar.sync_configured").as_ref())); } #[test] fn sync_indicator_syncing_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::Syncing, true); assert!(text.starts_with('\u{27F3}')); // ⟳ } #[test] fn sync_indicator_failed_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::Failed, true); assert!(text.starts_with('\u{2717}')); // ✗ } #[test] fn sync_indicator_offline_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::Offline, true); assert!(text.starts_with('\u{25D0}')); // ◐ } #[test] fn sync_indicator_not_configured_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = sync_indicator_text(&SyncIndicator::NotConfigured, true); assert!(text.starts_with('\u{2014}')); // — } #[test] fn sync_indicator_ascii_fallbacks() { + let _guard = crate::tui::i18n::LocaleGuard::en(); assert_eq!( sync_indicator_text(&SyncIndicator::Synced, false), "+ Synced" @@ -575,6 +590,7 @@ mod tests { #[test] fn trash_list_shortcuts_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::List, true, true, DetailShortcutContext::Login); assert!( text.contains('r'), @@ -592,6 +608,7 @@ mod tests { #[test] fn trash_detail_shortcuts_unicode() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, true, true, DetailShortcutContext::Login); assert!( text.contains('c'), @@ -609,6 +626,7 @@ mod tests { #[test] fn normal_list_shortcuts_no_trash() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::List, true, false, DetailShortcutContext::Login); assert!( !text.contains("\u{6E05}\u{7A7A}\u{56DE}\u{6536}\u{7AD9}"), @@ -618,6 +636,7 @@ mod tests { #[test] fn normal_detail_shortcuts_no_trash() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::Detail, true, false, DetailShortcutContext::Login); assert!( !text.contains("\u{6062}\u{590D}"), @@ -627,6 +646,7 @@ mod tests { #[test] fn visual_mode_shortcuts_shown_when_visual_active() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = visual_shortcuts_text(true, false); assert!( text.contains("Space") || text.contains("Space\u{9009}\u{62E9}"), @@ -636,6 +656,7 @@ mod tests { #[test] fn trash_visual_shortcuts_show_restore_and_hard_delete() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = visual_shortcuts_text(true, true); assert!( !text.contains("BatchDel") && !text.contains("BatchTag"), @@ -645,6 +666,7 @@ mod tests { #[test] fn normal_shortcuts_shown_when_not_visual() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let text = shortcuts_text(PanelId::List, true, false, DetailShortcutContext::Login); assert!( text.is_empty() || !text.contains("Space\u{9009}\u{62E9}"), diff --git a/src/tui/screens/set_password.rs b/src/tui/screens/set_password.rs index de6fddca..69a366cd 100644 --- a/src/tui/screens/set_password.rs +++ b/src/tui/screens/set_password.rs @@ -1164,6 +1164,7 @@ mod tests { #[test] fn enter_rejects_short_password() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut screen = SetPasswordScreen::new(SetPasswordContext::PostRecovery); let mut ctx = dummy_ctx(); // Type a 5-character password @@ -1209,6 +1210,7 @@ mod tests { #[test] fn enter_rejects_mismatched_passwords() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let mut screen = SetPasswordScreen::new(SetPasswordContext::PostRecovery); let mut ctx = dummy_ctx(); // Type password in new field diff --git a/src/tui/state/detail_state/tests.rs b/src/tui/state/detail_state/tests.rs index 169b2ae5..4f18f0bb 100644 --- a/src/tui/state/detail_state/tests.rs +++ b/src/tui/state/detail_state/tests.rs @@ -73,6 +73,7 @@ fn detail_state_clear() { #[test] fn field_navigation_skips_non_interactive() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let data = DetailViewData { fields: vec![ DetailField { @@ -175,6 +176,7 @@ fn password_strength_colors() { #[test] fn field_display_value() { + let _guard = crate::tui::i18n::LocaleGuard::en(); let plain = DetailField { label: t!("tui.entry.username_label").to_string(), value: FieldValue::Plain("alice".into()), @@ -220,6 +222,7 @@ fn username_field_per_type() { #[test] fn build_from_login_record() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::types::record::DecryptedRecord; use crate::types::sensitive::SecureStr; @@ -289,6 +292,7 @@ fn build_from_api_record() { #[test] fn build_from_ssh_record() { + let _guard = crate::tui::i18n::LocaleGuard::en(); use crate::types::record::DecryptedRecord; use crate::types::sensitive::SecureStr;