From 1712f7ac260abe8e3fa3907a036fb08bb6734ebd Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Sun, 8 Mar 2026 09:29:48 +0530 Subject: [PATCH 1/2] test(ui): add dummy test data generator --- .gitignore | 1 + Cargo.lock | 71 ++++++-- Cargo.toml | 1 + src/ui/issue_data.rs | 75 ++++----- src/ui/mod.rs | 3 + src/ui/testing.rs | 390 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 484 insertions(+), 57 deletions(-) create mode 100644 src/ui/testing.rs diff --git a/.gitignore b/.gitignore index c9d1920..a15b0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /*.log /*.gif /AGENTS.md +/**/.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 8284a52..bd58f57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -914,6 +914,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1054,7 +1060,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1104,6 +1110,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fake" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b0902eb36fbab51c14eda1c186bda119fcff91e5e4e7fc2dd2077298197ce8" +dependencies = [ + "deunicode", + "either", + "rand 0.9.2", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1153,7 +1170,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1432,6 +1449,7 @@ dependencies = [ "crossterm", "directories", "edit", + "fake", "futures", "hyperrat", "inquire", @@ -2207,7 +2225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2740,7 +2758,7 @@ dependencies = [ "p256", "p384", "pem", - "rand", + "rand 0.8.5", "rsa", "serde", "serde_json", @@ -3091,7 +3109,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -3448,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -3683,8 +3701,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3694,7 +3722,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3706,6 +3744,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rat-cursor" version = "2.0.0" @@ -4103,7 +4150,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -4470,7 +4517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f296ad0..18783af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ vergen-gix = { version = "9.1.0", features = ["build", "cargo"] } [dev-dependencies] criterion = { version = "0.5.1", features = ["html_reports"] } +fake = "4.4.0" [[bench]] name = "ui_hotspots" diff --git a/src/ui/issue_data.rs b/src/ui/issue_data.rs index 72c7888..3fcf0b4 100644 --- a/src/ui/issue_data.rs +++ b/src/ui/issue_data.rs @@ -176,9 +176,11 @@ impl TrieStringInterner { #[cfg(test)] mod tests { - use super::{AuthorId, TrieStringInterner, UiAuthor, UiIssue, UiIssuePool}; + use super::TrieStringInterner; use octocrab::models::IssueState; + use crate::ui::testing::{DummyDataConfig, dummy_ui_data_with}; + #[test] fn trie_interner_reuses_existing_string_ids() { let mut interner = TrieStringInterner::default(); @@ -198,54 +200,25 @@ mod tests { assert_eq!(interner.resolve(long), "opened"); } - fn ensure_author(pool: &mut UiIssuePool) -> AuthorId { - if let Some(existing) = pool.author_by_github_id.get(&1).copied() { - return existing; - } - let login = pool.intern_str("octo"); - let key = pool.authors.insert(UiAuthor { - github_id: 1, - login, - }); - pool.author_by_github_id.insert(1, key); - key - } - - fn make_issue(pool: &mut UiIssuePool, number: u64, title: &str, state: IssueState) -> UiIssue { - let author = ensure_author(pool); - let created_at_short = pool.intern_str("2024-01-01 00:00"); - let created_at_full = pool.intern_str("2024-01-01 00:00:00"); - let updated_at_short = pool.intern_str("2024-01-01 00:00"); - UiIssue { - number, - state, - title: pool.intern_str(title), - body: Some(pool.intern_str("body")), - author, - created_ts: 0, - created_at_short, - created_at_full, - updated_at_short, - comments: 0, - assignees: Vec::new(), - milestone: None, - is_pull_request: false, - pull_request_url: None, - labels: Vec::new(), - } - } - #[test] fn upsert_issue_reuses_existing_id() { - let mut pool = UiIssuePool::default(); - let first = make_issue(&mut pool, 42, "open issue", IssueState::Open); - let first_id = pool.upsert_issue(first); - let second = make_issue(&mut pool, 42, "closed issue", IssueState::Closed); - let second_id = pool.upsert_issue(second); + let mut data = dummy_ui_data_with(DummyDataConfig { + issue_count: 1, + author_count: 2, + comments_per_issue: 0, + timeline_events_per_issue: 0, + seed: 7, + }); + let first_id = data.issue_ids[0]; + let mut second = data.pool.get_issue(first_id).clone(); + second.state = IssueState::Closed; + second.title = data.pool.intern_str("closed issue"); + let second_id = data.pool.upsert_issue(second); + assert_eq!(first_id, second_id); - let stored = pool.get_issue(second_id); + let stored = data.pool.get_issue(second_id); assert_eq!(stored.state, IssueState::Closed); - assert_eq!(pool.resolve_str(stored.title), "closed issue"); + assert_eq!(data.pool.resolve_str(stored.title), "closed issue"); } } @@ -303,6 +276,18 @@ impl UiIssuePool { self.resolve_str(author.login) } + #[cfg(test)] + pub(crate) fn intern_test_author(&mut self, github_id: u64, login: &str) -> AuthorId { + if let Some(existing) = self.author_by_github_id.get(&github_id).copied() { + return existing; + } + + let login = self.intern_str(login); + let key = self.authors.insert(UiAuthor { github_id, login }); + self.author_by_github_id.insert(github_id, key); + key + } + pub fn insert_issue(&mut self, issue: UiIssue) -> IssueId { self.upsert_issue(issue) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4ae9ccf..73ccdfd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,9 @@ pub mod theme; pub mod utils; pub mod widgets; +#[cfg(test)] +pub(crate) mod testing; + use crate::{ app::GITHUB_CLIENT, bookmarks::{Bookmarks, read_bookmarks}, diff --git a/src/ui/testing.rs b/src/ui/testing.rs new file mode 100644 index 0000000..bcb649c --- /dev/null +++ b/src/ui/testing.rs @@ -0,0 +1,390 @@ +use std::collections::HashMap; + +use fake::{ + Fake, + faker::{ + internet::en::Username, + lorem::en::{Paragraph, Sentence, Words}, + }, + rand::{SeedableRng, prelude::IndexedRandom, rngs::StdRng}, +}; +use octocrab::models::{Event as IssueEvent, IssueState}; + +use crate::{ + bench_support::{issue_body_fixture, markdown_fixture}, + ui::{ + components::{ + issue_conversation::{CommentView, IssueConversationSeed, TimelineEventView}, + issue_detail::IssuePreviewSeed, + }, + issue_data::{AuthorId, IssueId, UiIssue, UiIssuePool}, + }, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DummyDataConfig { + pub issue_count: usize, + pub author_count: usize, + pub comments_per_issue: usize, + pub timeline_events_per_issue: usize, + pub seed: u64, +} + +impl Default for DummyDataConfig { + fn default() -> Self { + Self { + issue_count: 32, + author_count: 8, + comments_per_issue: 4, + timeline_events_per_issue: 3, + seed: 42, + } + } +} + +#[derive(Debug)] +pub(crate) struct DummyUiData { + pub pool: UiIssuePool, + pub issue_ids: Vec, + pub issue_numbers: Vec, + pub preview_seeds: HashMap, + pub conversation_seeds: HashMap, + pub comments_by_issue_number: HashMap>, + pub timeline_by_issue_number: HashMap>, +} + +impl DummyUiData { + pub fn issue(&self, issue_id: IssueId) -> &UiIssue { + self.pool.get_issue(issue_id) + } + + pub fn preview_seed(&self, issue_id: IssueId) -> IssuePreviewSeed { + self.preview_seeds + .get(&issue_id) + .cloned() + .expect("dummy preview seed missing for issue") + } + + pub fn conversation_seed(&self, issue_id: IssueId) -> IssueConversationSeed { + self.conversation_seeds + .get(&issue_id) + .cloned() + .expect("dummy conversation seed missing for issue") + } + + pub fn comments_for_number(&self, number: u64) -> &[CommentView] { + self.comments_by_issue_number + .get(&number) + .map(Vec::as_slice) + .expect("dummy comments missing for issue number") + } + + pub fn timeline_for_number(&self, number: u64) -> &[TimelineEventView] { + self.timeline_by_issue_number + .get(&number) + .map(Vec::as_slice) + .expect("dummy timeline missing for issue number") + } +} + +pub(crate) fn dummy_ui_data() -> DummyUiData { + dummy_ui_data_with(DummyDataConfig::default()) +} + +pub(crate) fn dummy_ui_data_with(config: DummyDataConfig) -> DummyUiData { + assert!( + config.issue_count > 0, + "issue_count must be greater than zero" + ); + assert!( + config.author_count > 0, + "author_count must be greater than zero" + ); + + let mut rng = StdRng::seed_from_u64(config.seed); + let mut pool = UiIssuePool::default(); + + let authors: Vec = (0..config.author_count) + .map(|idx| { + let login: String = Username().fake_with_rng(&mut rng); + let github_id = 10_000 + idx as u64; + let author_id = pool.intern_test_author(github_id, &login); + AuthorFixture { + github_id, + login, + author_id, + } + }) + .collect(); + + let milestones: Vec = (0..(config.author_count / 2).max(2)) + .map(|_| Sentence(2..4).fake_with_rng(&mut rng)) + .collect(); + + let mut issue_ids = Vec::with_capacity(config.issue_count); + let mut issue_numbers = Vec::with_capacity(config.issue_count); + let mut preview_seeds = HashMap::with_capacity(config.issue_count); + let mut conversation_seeds = HashMap::with_capacity(config.issue_count); + let mut comments_by_issue_number = HashMap::with_capacity(config.issue_count); + let mut timeline_by_issue_number = HashMap::with_capacity(config.issue_count); + + for idx in 0..config.issue_count { + let issue_number = 1000 + idx as u64; + let issue = make_issue( + &mut pool, + &authors, + &milestones, + issue_number, + idx, + &mut rng, + ); + let issue_id = pool.insert_issue(issue); + let stored = pool.get_issue(issue_id); + + issue_ids.push(issue_id); + issue_numbers.push(issue_number); + preview_seeds.insert(issue_id, IssuePreviewSeed::from_ui_issue(stored, &pool)); + conversation_seeds.insert( + issue_id, + IssueConversationSeed::from_ui_issue(stored, &pool), + ); + comments_by_issue_number.insert( + issue_number, + make_comments( + &authors, + issue_number, + config.comments_per_issue, + idx, + &mut rng, + ), + ); + timeline_by_issue_number.insert( + issue_number, + make_timeline_events( + &authors, + issue_number, + config.timeline_events_per_issue, + idx, + &mut rng, + ), + ); + } + + DummyUiData { + pool, + issue_ids, + issue_numbers, + preview_seeds, + conversation_seeds, + comments_by_issue_number, + timeline_by_issue_number, + } +} + +#[derive(Debug, Clone)] +struct AuthorFixture { + github_id: u64, + login: String, + author_id: AuthorId, +} + +fn make_issue( + pool: &mut UiIssuePool, + authors: &[AuthorFixture], + milestones: &[String], + issue_number: u64, + idx: usize, + rng: &mut StdRng, +) -> UiIssue { + let author = authors + .choose(rng) + .map(|author| author.author_id) + .expect("author fixture list should not be empty"); + let state = if idx % 5 == 0 { + IssueState::Closed + } else { + IssueState::Open + }; + let title = format!( + "{} #{issue_number}", + Sentence(3..6).fake_with_rng::(rng) + ); + let shared_fragment = if idx % 2 == 0 { + issue_body_fixture(1) + } else { + markdown_fixture(1) + }; + let tags: Vec = Words(2..5).fake_with_rng(rng); + let body = format!( + "{}\n\n{}\n\nTags: {}", + Paragraph(2..4).fake_with_rng::(rng), + shared_fragment, + tags.join(", ") + ); + let created_ts = 1_704_067_200_i64 + (idx as i64 * 3_600); + let created_at_short = format_timestamp(created_ts, false); + let created_at_full = format_timestamp(created_ts, true); + let updated_at_short = format_timestamp(created_ts + 1_800, false); + let milestone = (idx % 3 == 0).then(|| { + let milestone = milestones + .choose(rng) + .expect("milestone fixture list should not be empty"); + pool.intern_str(milestone) + }); + let assignee_count = 1 + (idx % authors.len().min(3).max(1)); + let assignees = authors + .iter() + .cycle() + .skip(idx % authors.len()) + .take(assignee_count) + .map(|author| author.author_id) + .collect(); + let is_pull_request = idx % 4 == 0; + let pull_request_url = if is_pull_request { + let url = format!("https://github.com/example/repo/pull/{issue_number}"); + Some(pool.intern_str(&url)) + } else { + None + }; + + UiIssue { + number: issue_number, + state, + title: pool.intern_str(&title), + body: Some(pool.intern_str(&body)), + author, + created_ts, + created_at_short: pool.intern_str(&created_at_short), + created_at_full: pool.intern_str(&created_at_full), + updated_at_short: pool.intern_str(&updated_at_short), + comments: 2 + (idx % 8) as u32, + assignees, + milestone, + is_pull_request, + pull_request_url, + labels: Vec::new(), + } +} + +fn make_comments( + authors: &[AuthorFixture], + issue_number: u64, + comment_count: usize, + issue_idx: usize, + rng: &mut StdRng, +) -> Vec { + (0..comment_count) + .map(|comment_idx| { + let author = &authors[(issue_idx + comment_idx) % authors.len()]; + let created_ts = 1_704_067_200_i64 + (issue_idx as i64 * 7_200) + comment_idx as i64; + CommentView { + id: issue_number * 100 + comment_idx as u64, + author: author.login.clone().into(), + created_at: format_timestamp(created_ts, false).into(), + created_ts, + body: format!( + "{}\n\n{}", + Paragraph(1..3).fake_with_rng::(rng), + issue_body_fixture(1) + ) + .into(), + reactions: None, + my_reactions: None, + } + }) + .collect() +} + +fn make_timeline_events( + authors: &[AuthorFixture], + issue_number: u64, + event_count: usize, + issue_idx: usize, + rng: &mut StdRng, +) -> Vec { + const EVENTS: &[(IssueEvent, &str, &str)] = &[ + (IssueEvent::Assigned, "@", "assigned this issue"), + (IssueEvent::Labeled, "#", "updated labels"), + (IssueEvent::Closed, "x", "closed this issue"), + (IssueEvent::Reopened, "+", "reopened this issue"), + (IssueEvent::Referenced, "~", "referenced this issue"), + ]; + + (0..event_count) + .map(|event_idx| { + let author = &authors[(issue_idx + event_idx) % authors.len()]; + let (event, icon, action) = &EVENTS[(issue_idx + event_idx) % EVENTS.len()]; + let created_ts = 1_704_067_200_i64 + (issue_idx as i64 * 10_800) + event_idx as i64; + let details = format!( + "{}\n\n{}\n\nActor id: {}", + Sentence(4..8).fake_with_rng::(rng), + markdown_fixture(1), + author.github_id + ); + + TimelineEventView { + id: issue_number * 1_000 + event_idx as u64, + created_at: format_timestamp(created_ts, false).into(), + created_ts, + actor: author.login.clone().into(), + event: event.clone(), + icon, + summary: format!("{} {}", author.login, action).into(), + details: details.into(), + } + }) + .collect() +} + +fn format_timestamp(ts: i64, include_seconds: bool) -> String { + let day = 1 + (ts.div_euclid(86_400).rem_euclid(28) as u32); + let hour = ts.div_euclid(3_600).rem_euclid(24) as u32; + let minute = ts.div_euclid(60).rem_euclid(60) as u32; + let second = ts.rem_euclid(60) as u32; + + if include_seconds { + format!("2024-01-{day:02} {hour:02}:{minute:02}:{second:02}") + } else { + format!("2024-01-{day:02} {hour:02}:{minute:02}") + } +} + +#[cfg(test)] +mod tests { + use super::{DummyDataConfig, dummy_ui_data, dummy_ui_data_with}; + + #[test] + fn dummy_data_builds_expected_shapes() { + let data = dummy_ui_data(); + let issue_id = data.issue_ids[0]; + let issue = data.issue(issue_id); + + assert_eq!(data.issue_ids.len(), 32); + assert_eq!(data.issue_numbers.len(), 32); + assert!(data.preview_seed(issue_id).number >= 1000); + assert_eq!(data.conversation_seed(issue_id).number, issue.number); + assert_eq!(data.comments_for_number(issue.number).len(), 4); + assert_eq!(data.timeline_for_number(issue.number).len(), 3); + } + + #[test] + fn dummy_data_reuses_interned_author_strings() { + let data = dummy_ui_data_with(DummyDataConfig { + issue_count: 6, + author_count: 2, + comments_per_issue: 1, + timeline_events_per_issue: 1, + seed: 11, + }); + + let first = data.issue(data.issue_ids[0]); + let second = data.issue(data.issue_ids[1]); + let first_author = data.pool.author_login(first.author); + let second_author = data.pool.author_login(second.author); + + assert!(!first_author.is_empty()); + if first.author == second.author { + assert_eq!(first_author, second_author); + } + } +} From 00d175ad00ee208d0ccfa5a54246ef8dcd20efc6 Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Sun, 8 Mar 2026 10:07:53 +0530 Subject: [PATCH 2/2] test(ui): add UI snapshot tests for the TUI components --- Cargo.lock | 37 ++++++ Cargo.toml | 1 + src/ui/components/issue_detail.rs | 2 +- src/ui/components/label_list.rs | 1 + src/ui/components/search_bar.rs | 4 +- src/ui/components/status_bar.rs | 4 +- src/ui/testing.rs | 30 +++-- tests/help.rs | 60 +++++++++ tests/issue_preview.rs | 115 ++++++++++++++++++ .../snapshots/help__help_component_empty.snap | 11 ++ .../help__help_elements_keybinds_only.snap | 17 +++ .../help__help_elements_mixed_content.snap | 18 +++ .../help__help_elements_text_wrapping.snap | 17 +++ ...e_preview__issue_preview_closed_issue.snap | 23 ++++ ...preview__issue_preview_many_assignees.snap | 23 ++++ ...e_preview__issue_preview_no_selection.snap | 23 ++++ ...sue_preview__issue_preview_open_issue.snap | 23 ++++ ...e_preview__issue_preview_pull_request.snap | 23 ++++ .../status_bar__status_bar_with_count.snap | 7 ++ .../text_search__text_search_both_inputs.snap | 11 ++ .../text_search__text_search_label_input.snap | 11 ++ ...text_search__text_search_loaded_state.snap | 11 ++ .../text_search__text_search_with_input.snap | 11 ++ tests/status_bar.rs | 33 +++++ tests/support/mod.rs | 25 ++++ tests/text_search.rs | 59 +++++++++ 26 files changed, 587 insertions(+), 13 deletions(-) create mode 100644 tests/help.rs create mode 100644 tests/issue_preview.rs create mode 100644 tests/snapshots/help__help_component_empty.snap create mode 100644 tests/snapshots/help__help_elements_keybinds_only.snap create mode 100644 tests/snapshots/help__help_elements_mixed_content.snap create mode 100644 tests/snapshots/help__help_elements_text_wrapping.snap create mode 100644 tests/snapshots/issue_preview__issue_preview_closed_issue.snap create mode 100644 tests/snapshots/issue_preview__issue_preview_many_assignees.snap create mode 100644 tests/snapshots/issue_preview__issue_preview_no_selection.snap create mode 100644 tests/snapshots/issue_preview__issue_preview_open_issue.snap create mode 100644 tests/snapshots/issue_preview__issue_preview_pull_request.snap create mode 100644 tests/snapshots/status_bar__status_bar_with_count.snap create mode 100644 tests/snapshots/text_search__text_search_both_inputs.snap create mode 100644 tests/snapshots/text_search__text_search_label_input.snap create mode 100644 tests/snapshots/text_search__text_search_loaded_state.snap create mode 100644 tests/snapshots/text_search__text_search_with_input.snap create mode 100644 tests/status_bar.rs create mode 100644 tests/support/mod.rs create mode 100644 tests/text_search.rs diff --git a/Cargo.lock b/Cargo.lock index bd58f57..618443b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -507,6 +507,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1066,6 +1078,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1453,6 +1471,7 @@ dependencies = [ "futures", "hyperrat", "inquire", + "insta", "keyring", "octocrab", "pulldown-cmark", @@ -2623,6 +2642,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "insta" +version = "1.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.11" @@ -4526,6 +4557,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 18783af..b84caf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ vergen-gix = { version = "9.1.0", features = ["build", "cargo"] } [dev-dependencies] criterion = { version = "0.5.1", features = ["html_reports"] } fake = "4.4.0" +insta = "1.40" [[bench]] name = "ui_hotspots" diff --git a/src/ui/components/issue_detail.rs b/src/ui/components/issue_detail.rs index 81a9b90..255126c 100644 --- a/src/ui/components/issue_detail.rs +++ b/src/ui/components/issue_detail.rs @@ -99,7 +99,7 @@ pub struct PrSummary { } pub struct IssuePreview { - current: Option, + pub current: Option, action_tx: Option>, area: Rect, } diff --git a/src/ui/components/label_list.rs b/src/ui/components/label_list.rs index dc63b3d..4110162 100644 --- a/src/ui/components/label_list.rs +++ b/src/ui/components/label_list.rs @@ -1262,3 +1262,4 @@ impl HasFocus for LabelList { self.state.focus() } } + diff --git a/src/ui/components/search_bar.rs b/src/ui/components/search_bar.rs index 15006cf..f4e1a11 100644 --- a/src/ui/components/search_bar.rs +++ b/src/ui/components/search_bar.rs @@ -41,8 +41,8 @@ pub const HELP: &[HelpElementKind] = &[ ]; pub struct TextSearch { - search_state: rat_widget::text_input::TextInputState, - label_state: rat_widget::text_input::TextInputState, + pub search_state: rat_widget::text_input::TextInputState, + pub label_state: rat_widget::text_input::TextInputState, cstate: ChoiceState, state: State, action_tx: Option>, diff --git a/src/ui/components/status_bar.rs b/src/ui/components/status_bar.rs index e346c1d..cef55b3 100644 --- a/src/ui/components/status_bar.rs +++ b/src/ui/components/status_bar.rs @@ -5,9 +5,9 @@ use ratatui::widgets::Widget; use ratatui_macros::{line, span}; use std::sync::atomic::Ordering; -use crate::ui::components::DumbComponent; use crate::ui::components::issue_list::LOADED_ISSUE_COUNT; -use crate::ui::{AppState, layout::Layout}; +use crate::ui::components::DumbComponent; +use crate::ui::{layout::Layout, AppState}; pub struct StatusBar { repo_label: String, diff --git a/src/ui/testing.rs b/src/ui/testing.rs index bcb649c..73f025f 100644 --- a/src/ui/testing.rs +++ b/src/ui/testing.rs @@ -10,15 +10,29 @@ use fake::{ }; use octocrab::models::{Event as IssueEvent, IssueState}; -use crate::{ - bench_support::{issue_body_fixture, markdown_fixture}, - ui::{ - components::{ - issue_conversation::{CommentView, IssueConversationSeed, TimelineEventView}, - issue_detail::IssuePreviewSeed, - }, - issue_data::{AuthorId, IssueId, UiIssue, UiIssuePool}, +#[cfg(feature = "benches")] +use crate::bench_support::{issue_body_fixture, markdown_fixture}; + +#[cfg(not(feature = "benches"))] +mod bench_support { + pub fn issue_body_fixture(_seed: u64) -> String { + String::new() + } + pub fn markdown_fixture(_seed: u64) -> String { + String::new() + } +} + +#[allow(unused_imports)] +#[cfg(not(feature = "benches"))] +use bench_support::{issue_body_fixture, markdown_fixture}; + +use crate::ui::{ + components::{ + issue_conversation::{CommentView, IssueConversationSeed, TimelineEventView}, + issue_detail::IssuePreviewSeed, }, + issue_data::{AuthorId, IssueId, UiIssue, UiIssuePool}, }; #[derive(Debug, Clone, Copy)] diff --git a/tests/help.rs b/tests/help.rs new file mode 100644 index 0000000..c50137b --- /dev/null +++ b/tests/help.rs @@ -0,0 +1,60 @@ +mod support; +use crate::support::buffer_to_string; +use gitv_tui::ui::components::help::{HelpComponent, HelpElementKind}; +use insta::assert_snapshot; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Widget}; + +fn render_help_component(elements: &[HelpElementKind], width: u16, height: u16) -> String { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + let component = HelpComponent::new(elements).set_constraint(50).block( + Block::bordered() + .title("Help") + .padding(ratatui::widgets::Padding::horizontal(2)) + .border_type(ratatui::widgets::BorderType::Rounded), + ); + component.render(area, &mut buf); + buffer_to_string(&buf) +} + +#[test] +fn help_elements_keybinds_only() { + let elements = &[ + HelpElementKind::Keybind("Up", "navigate up"), + HelpElementKind::Keybind("Down", "navigate down"), + HelpElementKind::Keybind("Enter", "select item"), + ]; + let result = render_help_component(elements, 60, 20); + assert_snapshot!(result); +} + +#[test] +fn help_elements_text_wrapping() { + let elements = &[HelpElementKind::Text( + "This is a very long description that should wrap properly across multiple lines when rendered in the help component.", + )]; + let result = render_help_component(elements, 50, 15); + assert_snapshot!(result); +} + +#[test] +fn help_elements_mixed_content() { + let elements = &[ + HelpElementKind::Text("Global Help"), + HelpElementKind::Keybind("q", "quit application"), + HelpElementKind::Text(""), + HelpElementKind::Keybind("?", "toggle this help"), + HelpElementKind::Keybind("Esc", "close dialog"), + ]; + let result = render_help_component(elements, 55, 20); + assert_snapshot!(result); +} + +#[test] +fn help_component_empty() { + let elements: &[HelpElementKind] = &[]; + let result = render_help_component(elements, 40, 10); + assert_snapshot!(result); +} diff --git a/tests/issue_preview.rs b/tests/issue_preview.rs new file mode 100644 index 0000000..b73282b --- /dev/null +++ b/tests/issue_preview.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +mod support; + +use crate::support::buffer_to_string; +use gitv_tui::ui::AppState; +use gitv_tui::ui::components::issue_detail::{IssuePreview, IssuePreviewSeed}; +use gitv_tui::ui::layout::Layout; +use insta::assert_snapshot; +use octocrab::models::IssueState; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +fn render_issue_preview(seed: Option) -> String { + let area = Rect::new(0, 0, 40, 20); + let layout = Layout::new(area); + let mut buf = Buffer::empty(area); + + let mut preview = IssuePreview::new(AppState::new( + "owner".to_string(), + "repo".to_string(), + "user".to_string(), + )); + + if let Some(s) = seed { + preview.current = Some(s); + } + + preview.render(layout, &mut buf); + buffer_to_string(&buf) +} + +#[test] +fn issue_preview_open_issue() { + let seed = IssuePreviewSeed { + number: 42, + state: IssueState::Open, + author: Arc::from("johndoe"), + created_at: Arc::from("2024-01-15 10:30"), + updated_at: Arc::from("2024-01-16 14:45"), + comments: 5, + assignees: vec![Arc::from("alice"), Arc::from("bob")], + milestone: Some(Arc::from("v1.0")), + is_pull_request: false, + pull_request_url: None, + }; + let result = render_issue_preview(Some(seed)); + assert_snapshot!(result); +} + +#[test] +fn issue_preview_closed_issue() { + let seed = IssuePreviewSeed { + number: 123, + state: IssueState::Closed, + author: Arc::from("janedoe"), + created_at: Arc::from("2023-12-01 09:00"), + updated_at: Arc::from("2023-12-05 16:30"), + comments: 12, + assignees: vec![Arc::from("charlie")], + milestone: None, + is_pull_request: false, + pull_request_url: None, + }; + let result = render_issue_preview(Some(seed)); + assert_snapshot!(result); +} + +#[test] +fn issue_preview_pull_request() { + let seed = IssuePreviewSeed { + number: 456, + state: IssueState::Open, + author: Arc::from("devuser"), + created_at: Arc::from("2024-02-01 11:00"), + updated_at: Arc::from("2024-02-02 09:15"), + comments: 8, + assignees: vec![Arc::from("reviewer1"), Arc::from("reviewer2")], + milestone: Some(Arc::from("Sprint 5")), + is_pull_request: true, + pull_request_url: Some(Arc::from("https://github.com/owner/repo/pull/456")), + }; + let result = render_issue_preview(Some(seed)); + assert_snapshot!(result); +} + +#[test] +fn issue_preview_no_selection() { + let result = render_issue_preview(None); + assert_snapshot!(result); +} + +#[test] +fn issue_preview_many_assignees() { + let seed = IssuePreviewSeed { + number: 789, + state: IssueState::Open, + author: Arc::from("teamlead"), + created_at: Arc::from("2024-03-01 08:00"), + updated_at: Arc::from("2024-03-02 10:00"), + comments: 3, + assignees: vec![ + Arc::from("dev1"), + Arc::from("dev2"), + Arc::from("dev3"), + Arc::from("dev4"), + Arc::from("dev5"), + ], + milestone: Some(Arc::from("v2.0")), + is_pull_request: false, + pull_request_url: None, + }; + let result = render_issue_preview(Some(seed)); + assert_snapshot!(result); +} diff --git a/tests/snapshots/help__help_component_empty.snap b/tests/snapshots/help__help_component_empty.snap new file mode 100644 index 0000000..d9afe67 --- /dev/null +++ b/tests/snapshots/help__help_component_empty.snap @@ -0,0 +1,11 @@ +--- +source: tests/help.rs +expression: result +--- + + + + + + ╭Help──────────────╮ + ╰──────────────────╯ diff --git a/tests/snapshots/help__help_elements_keybinds_only.snap b/tests/snapshots/help__help_elements_keybinds_only.snap new file mode 100644 index 0000000..fddc3ae --- /dev/null +++ b/tests/snapshots/help__help_elements_keybinds_only.snap @@ -0,0 +1,17 @@ +--- +source: tests/help.rs +expression: result +--- + + + + + + + + + ╭Help────────────────────────╮ + │ Up navigate up │ + │ Down navigate down │ + │ Enter select item │ + ╰────────────────────────────╯ diff --git a/tests/snapshots/help__help_elements_mixed_content.snap b/tests/snapshots/help__help_elements_mixed_content.snap new file mode 100644 index 0000000..ab7942e --- /dev/null +++ b/tests/snapshots/help__help_elements_mixed_content.snap @@ -0,0 +1,18 @@ +--- +source: tests/help.rs +expression: result +--- + + + + + + + + ╭Help─────────────────────╮ + │ Global Help │ + │ q quit application │ + │ │ + │ ? toggle this help │ + │ Esc close dialog │ + ╰─────────────────────────╯ diff --git a/tests/snapshots/help__help_elements_text_wrapping.snap b/tests/snapshots/help__help_elements_text_wrapping.snap new file mode 100644 index 0000000..cda6f25 --- /dev/null +++ b/tests/snapshots/help__help_elements_text_wrapping.snap @@ -0,0 +1,17 @@ +--- +source: tests/help.rs +expression: result +--- + + + + + ╭Help───────────────────╮ + │ This is a very │ + │ long description │ + │ that should wrap │ + │ properly across │ + │ multiple lines when │ + │ rendered in the │ + │ help component. │ + ╰───────────────────────╯ diff --git a/tests/snapshots/issue_preview__issue_preview_closed_issue.snap b/tests/snapshots/issue_preview__issue_preview_closed_issue.snap new file mode 100644 index 0000000..9feb229 --- /dev/null +++ b/tests/snapshots/issue_preview__issue_preview_closed_issue.snap @@ -0,0 +1,23 @@ +--- +source: tests/issue_preview.rs +expression: result +--- + + + + + + + + + + + ╭Issue Info╮ + │Type: │ + │Issue │ + │State: │ + │Closed │ + │Author: │ + │janedoe │ + │Created: │ + ╰──────────╯ diff --git a/tests/snapshots/issue_preview__issue_preview_many_assignees.snap b/tests/snapshots/issue_preview__issue_preview_many_assignees.snap new file mode 100644 index 0000000..431fa51 --- /dev/null +++ b/tests/snapshots/issue_preview__issue_preview_many_assignees.snap @@ -0,0 +1,23 @@ +--- +source: tests/issue_preview.rs +expression: result +--- + + + + + + + + + + + ╭Issue Info╮ + │Type: │ + │Issue │ + │State: │ + │Open │ + │Author: │ + │teamlead │ + │Created: │ + ╰──────────╯ diff --git a/tests/snapshots/issue_preview__issue_preview_no_selection.snap b/tests/snapshots/issue_preview__issue_preview_no_selection.snap new file mode 100644 index 0000000..9f41a91 --- /dev/null +++ b/tests/snapshots/issue_preview__issue_preview_no_selection.snap @@ -0,0 +1,23 @@ +--- +source: tests/issue_preview.rs +expression: result +--- + + + + + + + + + + + ╭Issue Info╮ + │Select an │ + │issue to │ + │see │ + │details. │ + │ │ + │ │ + │ │ + ╰──────────╯ diff --git a/tests/snapshots/issue_preview__issue_preview_open_issue.snap b/tests/snapshots/issue_preview__issue_preview_open_issue.snap new file mode 100644 index 0000000..189ff80 --- /dev/null +++ b/tests/snapshots/issue_preview__issue_preview_open_issue.snap @@ -0,0 +1,23 @@ +--- +source: tests/issue_preview.rs +expression: result +--- + + + + + + + + + + + ╭Issue Info╮ + │Type: │ + │Issue │ + │State: │ + │Open │ + │Author: │ + │johndoe │ + │Created: │ + ╰──────────╯ diff --git a/tests/snapshots/issue_preview__issue_preview_pull_request.snap b/tests/snapshots/issue_preview__issue_preview_pull_request.snap new file mode 100644 index 0000000..58625a1 --- /dev/null +++ b/tests/snapshots/issue_preview__issue_preview_pull_request.snap @@ -0,0 +1,23 @@ +--- +source: tests/issue_preview.rs +expression: result +--- + + + + + + + + + + + ╭Issue Info╮ + │Type: Pull│ + │Request │ + │State: │ + │Open │ + │Author: │ + │devuser │ + │]8;;https://github.com/owner/repo/pull/456\Open #4...]8;;\ │ + ╰──────────╯ diff --git a/tests/snapshots/status_bar__status_bar_with_count.snap b/tests/snapshots/status_bar__status_bar_with_count.snap new file mode 100644 index 0000000..f64ea20 --- /dev/null +++ b/tests/snapshots/status_bar__status_bar_with_count.snap @@ -0,0 +1,7 @@ +--- +source: tests/status_bar.rs +expression: result +--- + + + Logged in as testuser repo/owner P ? HELP q// String { + LOADED_ISSUE_COUNT.store(issue_count, Ordering::Relaxed); + + let area = Layout::new(Rect::new(0, 0, 80, 3)); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3)); + + let mut status_bar = StatusBar::new(AppState::new( + "owner".to_string(), + "repo".to_string(), + "testuser".to_string(), + )); + + status_bar.render(area, &mut buf); + buffer_to_string(&buf) +} + +#[test] +fn status_bar_with_count() { + let result = render_status_bar(42); + assert_snapshot!(result); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..5a7d62d --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,25 @@ +use ratatui::buffer::Buffer; + +pub fn buffer_to_string(buf: &Buffer) -> String { + let mut lines = Vec::new(); + + for y in 0..buf.area.height { + let mut line = String::new(); + for x in 0..buf.area.width { + #[allow(deprecated)] + let cell = buf.get(x, y); + line.push_str(cell.symbol()); + } + lines.push(line); + } + + while let Some(last) = lines.last() { + if last.trim().is_empty() { + lines.pop(); + } else { + break; + } + } + + lines.join("\n") +} diff --git a/tests/text_search.rs b/tests/text_search.rs new file mode 100644 index 0000000..a2ed942 --- /dev/null +++ b/tests/text_search.rs @@ -0,0 +1,59 @@ +mod support; +use crate::support::buffer_to_string; +use gitv_tui::ui::AppState; +use gitv_tui::ui::components::Component; +use gitv_tui::ui::components::search_bar::TextSearch; +use gitv_tui::ui::layout::Layout; +use insta::assert_snapshot; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +fn render_text_search(setup: F) -> String +where + F: FnOnce(&mut TextSearch), +{ + let area = Layout::new(Rect::new(0, 0, 80, 10)); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10)); + + let mut search = TextSearch::new(AppState::new( + "owner".to_string(), + "repo".to_string(), + "user".to_string(), + )); + + setup(&mut search); + + search.render(area, &mut buf); + buffer_to_string(&buf) +} + +#[test] +fn text_search_loaded_state() { + let result = render_text_search(|_| {}); + assert_snapshot!(result); +} + +#[test] +fn text_search_with_input() { + let result = render_text_search(|search| { + search.search_state.set_text("bug fix"); + }); + assert_snapshot!(result); +} + +#[test] +fn text_search_label_input() { + let result = render_text_search(|search| { + search.label_state.set_text("priority:high"); + }); + assert_snapshot!(result); +} + +#[test] +fn text_search_both_inputs() { + let result = render_text_search(|search| { + search.search_state.set_text("authentication"); + search.label_state.set_text("security;bug"); + }); + assert_snapshot!(result); +}