From fe666c5abaa1a78acd4f8b396ed68dbbad09afc1 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:01:03 +0000 Subject: [PATCH 1/2] added ci --- .github/workflows/ci.yml | 95 +++++++++ Cargo.lock | 114 +++++++++++ Cargo.toml | 5 + src/app.rs | 200 ++++++++++++++++++ src/plan/app.rs | 416 ++++++++++++++++++++++++++++++++++++++ src/plan/phases.rs | 91 +++++++++ src/plan/prompts.rs | 71 +++++++ src/plan/protocol.rs | 196 ++++++++++++++++++ src/plan/session.rs | 236 +++++++++++++++++++++ src/prd.rs | 132 ++++++++++++ src/prompt.rs | 36 ++++ tests/cli_integration.rs | 139 +++++++++++++ tests/prd_workflow.rs | 149 ++++++++++++++ tests/session_workflow.rs | 192 ++++++++++++++++++ 14 files changed, 2072 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/cli_integration.rs create mode 100644 tests/prd_workflow.rs create mode 100644 tests/session_workflow.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2d27e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - run: cargo check --all-targets + + test: + name: Test + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - run: cargo test --all-targets + - run: cargo test --doc + + lint: + name: Lint + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: clippy + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - run: cargo clippy --all-targets -- -D warnings + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - run: cargo fmt --all -- --check + + build: + name: Build + runs-on: ${{ matrix.os }} + needs: [test, lint] + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - run: cargo build --release + - uses: actions/upload-artifact@v4 + with: + name: ralph-${{ matrix.os }} + path: target/release/ralph diff --git a/Cargo.lock b/Cargo.lock index c212bc7..5d9941a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,21 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atomic" version = "0.6.1" @@ -139,6 +154,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -398,6 +424,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -458,6 +490,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -487,6 +525,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -773,6 +820,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-conv" version = "0.1.0" @@ -965,6 +1018,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1268,13 +1351,16 @@ dependencies = [ name = "simple-ralph" version = "0.1.0" dependencies = [ + "assert_cmd", "chrono", "clap", "crossterm", "pathbuf", + "predicates", "ratatui", "serde", "serde_json", + "tempfile", "thiserror 1.0.69", "uuid", ] @@ -1346,6 +1432,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1367,6 +1466,12 @@ dependencies = [ "libc", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "termwiz" version = "0.23.3" @@ -1545,6 +1650,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 3ea9f5c..9ef3c2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,8 @@ serde_json = "1.0.149" uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "1.0" + +[dev-dependencies] +tempfile = "3.16" +assert_cmd = "2.0" +predicates = "3.1" diff --git a/src/app.rs b/src/app.rs index 4c4d76e..ecd0b3b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -358,3 +358,203 @@ impl App { self.iteration_logs.last().map(|s| s.as_str()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_app_initialization() { + let app = App::new("Test PRD", 5, 3); + assert_eq!(app.prd_name, "Test PRD"); + assert_eq!(app.remaining_tasks, 5); + assert_eq!(app.completed_tasks, 3); + assert_eq!(app.loop_count, 0); + assert!(!app.should_quit); + assert_eq!(app.status_message, "Initialising..."); + assert!(app.iteration_logs.is_empty()); + assert_eq!(app.current_log_index, 0); + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn increment_loop() { + let mut app = App::new("Test", 1, 0); + assert_eq!(app.loop_count, 0); + + app.increment_loop(); + assert_eq!(app.loop_count, 1); + + app.increment_loop(); + app.increment_loop(); + assert_eq!(app.loop_count, 3); + } + + #[test] + fn set_status() { + let mut app = App::new("Test", 1, 0); + assert_eq!(app.status_message, "Initialising..."); + + app.set_status("Running task..."); + assert_eq!(app.status_message, "Running task..."); + + app.set_status("Complete!"); + assert_eq!(app.status_message, "Complete!"); + } + + #[test] + fn reload_progress() { + let mut app = App::new("Test", 5, 2); + assert_eq!(app.remaining_tasks, 5); + assert_eq!(app.completed_tasks, 2); + + app.reload_progress(3, 4); + assert_eq!(app.remaining_tasks, 3); + assert_eq!(app.completed_tasks, 4); + } + + #[test] + fn push_log_adds_and_switches() { + let mut app = App::new("Test", 1, 0); + assert!(app.iteration_logs.is_empty()); + assert_eq!(app.current_log_index, 0); + + app.push_log("First log".to_string()); + assert_eq!(app.iteration_logs.len(), 1); + assert_eq!(app.current_log_index, 0); + + app.push_log("Second log".to_string()); + assert_eq!(app.iteration_logs.len(), 2); + assert_eq!(app.current_log_index, 1); // Switches to newest + + app.push_log("Third log".to_string()); + assert_eq!(app.iteration_logs.len(), 3); + assert_eq!(app.current_log_index, 2); + } + + #[test] + fn push_log_resets_scroll() { + let mut app = App::new("Test", 1, 0); + app.push_log("First log".to_string()); + app.log_scroll_offset = 10; + + app.push_log("Second log".to_string()); + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn prev_log_navigation() { + let mut app = App::new("Test", 1, 0); + app.push_log("Log 1".to_string()); + app.push_log("Log 2".to_string()); + app.push_log("Log 3".to_string()); + assert_eq!(app.current_log_index, 2); + + app.prev_log(); + assert_eq!(app.current_log_index, 1); + + app.prev_log(); + assert_eq!(app.current_log_index, 0); + + // Can't go below 0 + app.prev_log(); + assert_eq!(app.current_log_index, 0); + } + + #[test] + fn next_log_navigation() { + let mut app = App::new("Test", 1, 0); + app.push_log("Log 1".to_string()); + app.push_log("Log 2".to_string()); + app.push_log("Log 3".to_string()); + + // Go back first + app.current_log_index = 0; + + app.next_log(); + assert_eq!(app.current_log_index, 1); + + app.next_log(); + assert_eq!(app.current_log_index, 2); + + // Can't go past last + app.next_log(); + assert_eq!(app.current_log_index, 2); + } + + #[test] + fn log_navigation_resets_scroll() { + let mut app = App::new("Test", 1, 0); + app.push_log("Log 1".to_string()); + app.push_log("Log 2".to_string()); + app.log_scroll_offset = 5; + + app.prev_log(); + assert_eq!(app.log_scroll_offset, 0); + + app.log_scroll_offset = 5; + app.next_log(); + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn scroll_up() { + let mut app = App::new("Test", 1, 0); + app.push_log("Line 1\nLine 2\nLine 3".to_string()); + app.log_scroll_offset = 5; + + app.scroll_up(2); + assert_eq!(app.log_scroll_offset, 3); + + app.scroll_up(10); // More than offset, should saturate at 0 + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn scroll_down() { + let mut app = App::new("Test", 1, 0); + app.push_log("Line 1\nLine 2\nLine 3".to_string()); + app.log_scroll_offset = 0; + + app.scroll_down(1); + assert_eq!(app.log_scroll_offset, 1); + + app.scroll_down(10); // Should cap at content height (3 lines) + assert_eq!(app.log_scroll_offset, 3); + } + + #[test] + fn latest_log_returns_correct_value() { + let mut app = App::new("Test", 1, 0); + assert!(app.latest_log().is_none()); + + app.push_log("First".to_string()); + assert_eq!(app.latest_log(), Some("First")); + + app.push_log("Second".to_string()); + assert_eq!(app.latest_log(), Some("Second")); + + // Even if viewing old log, latest_log returns the newest + app.current_log_index = 0; + assert_eq!(app.latest_log(), Some("Second")); + } + + #[test] + fn current_log_empty_when_no_logs() { + let app = App::new("Test", 1, 0); + assert_eq!(app.current_log(), ""); + } + + #[test] + fn current_log_returns_indexed_log() { + let mut app = App::new("Test", 1, 0); + app.push_log("Log A".to_string()); + app.push_log("Log B".to_string()); + + app.current_log_index = 0; + assert_eq!(app.current_log(), "Log A"); + + app.current_log_index = 1; + assert_eq!(app.current_log(), "Log B"); + } +} diff --git a/src/plan/app.rs b/src/plan/app.rs index 465072d..d9b153b 100644 --- a/src/plan/app.rs +++ b/src/plan/app.rs @@ -771,3 +771,419 @@ impl Default for PlanApp { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::protocol::QuestionOption; + + fn create_test_question(id: &str, with_options: bool) -> Question { + Question { + id: id.to_string(), + category: "scope".to_string(), + text: format!("Question {id}?"), + context: Some("Context".to_string()), + options: if with_options { + Some(vec![ + QuestionOption { + key: "A".to_string(), + label: "Option A".to_string(), + description: None, + }, + QuestionOption { + key: "B".to_string(), + label: "Option B".to_string(), + description: Some("With description".to_string()), + }, + ]) + } else { + None + }, + allow_freeform: true, + } + } + + #[test] + fn new_app_initialization() { + let app = PlanApp::new(); + assert_eq!(app.phase, PlanPhase::Exploring); + assert_eq!(app.status, "Starting..."); + assert!(!app.awaiting_idea); + assert!(app.idea_input.is_empty()); + assert!(app.questions.is_empty()); + assert_eq!(app.current_question, 0); + assert!(app.answers.is_empty()); + assert_eq!(app.turn_count, 0); + assert!(!app.should_quit); + assert!(!app.should_submit); + assert_eq!(app.input_mode, InputMode::Normal); + } + + #[test] + fn default_same_as_new() { + let default_app = PlanApp::default(); + let new_app = PlanApp::new(); + assert_eq!(default_app.phase, new_app.phase); + assert_eq!(default_app.status, new_app.status); + assert_eq!(default_app.turn_count, new_app.turn_count); + } + + #[test] + fn update_from_response_changes_phase_and_status() { + let mut app = PlanApp::new(); + let response = PlanResponse { + phase: PlanPhase::Asking, + status: Some("Need input".to_string()), + questions: None, + context: None, + prd: None, + }; + + app.update_from_response(&response); + assert_eq!(app.phase, PlanPhase::Asking); + assert_eq!(app.status, "Need input"); + assert_eq!(app.turn_count, 1); + } + + #[test] + fn update_from_response_sets_questions() { + let mut app = PlanApp::new(); + let response = PlanResponse { + phase: PlanPhase::Asking, + status: None, + questions: Some(vec![ + create_test_question("q1", true), + create_test_question("q2", false), + ]), + context: None, + prd: None, + }; + + app.update_from_response(&response); + assert_eq!(app.questions.len(), 2); + assert_eq!(app.current_question, 0); + assert!(app.freeform_input.is_empty()); + } + + #[test] + fn next_question_navigation() { + let mut app = PlanApp::new(); + app.set_questions(vec![ + create_test_question("q1", true), + create_test_question("q2", true), + create_test_question("q3", true), + ]); + + assert_eq!(app.current_question, 0); + app.next_question(); + assert_eq!(app.current_question, 1); + app.next_question(); + assert_eq!(app.current_question, 2); + // Can't go past last + app.next_question(); + assert_eq!(app.current_question, 2); + } + + #[test] + fn prev_question_navigation() { + let mut app = PlanApp::new(); + app.set_questions(vec![ + create_test_question("q1", true), + create_test_question("q2", true), + ]); + app.current_question = 1; + + app.prev_question(); + assert_eq!(app.current_question, 0); + // Can't go below 0 + app.prev_question(); + assert_eq!(app.current_question, 0); + } + + #[test] + fn question_navigation_resets_state() { + let mut app = PlanApp::new(); + app.set_questions(vec![ + create_test_question("q1", true), + create_test_question("q2", true), + ]); + + app.freeform_input = "some text".to_string(); + app.cursor_position = 5; + app.option_list_state.select(Some(1)); + + app.next_question(); + assert!(app.freeform_input.is_empty()); + assert_eq!(app.cursor_position, 0); + assert_eq!(app.option_list_state.selected(), Some(0)); + } + + #[test] + fn next_option_cycles() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", true)]); + app.option_list_state.select(Some(0)); + + app.next_option(); + assert_eq!(app.option_list_state.selected(), Some(1)); + + app.next_option(); + assert_eq!(app.option_list_state.selected(), Some(0)); // Wraps around + } + + #[test] + fn prev_option_cycles() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", true)]); + app.option_list_state.select(Some(0)); + + app.prev_option(); + assert_eq!(app.option_list_state.selected(), Some(1)); // Wraps to end + + app.prev_option(); + assert_eq!(app.option_list_state.selected(), Some(0)); + } + + #[test] + fn submit_answer_from_option() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", true)]); + app.option_list_state.select(Some(1)); // Select option B + app.input_mode = InputMode::Normal; + + app.submit_answer(); + assert_eq!(app.answers.len(), 1); + assert_eq!(app.answers[0].question_id, "q1"); + assert_eq!(app.answers[0].value, "B"); + } + + #[test] + fn submit_answer_from_freeform() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", true)]); + app.input_mode = InputMode::Editing; + app.freeform_input = "Custom answer".to_string(); + + app.submit_answer(); + assert_eq!(app.answers.len(), 1); + assert_eq!(app.answers[0].value, "Custom answer"); + } + + #[test] + fn submit_answer_no_options_uses_freeform() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", false)]); // No options + app.input_mode = InputMode::Normal; + app.freeform_input = "Freeform only".to_string(); + + app.submit_answer(); + assert_eq!(app.answers.len(), 1); + assert_eq!(app.answers[0].value, "Freeform only"); + } + + #[test] + fn submit_empty_answer_not_added() { + let mut app = PlanApp::new(); + app.set_questions(vec![create_test_question("q1", false)]); + app.freeform_input = String::new(); + + app.submit_answer(); + assert!(app.answers.is_empty()); + } + + #[test] + fn enter_exit_editing_mode() { + let mut app = PlanApp::new(); + assert_eq!(app.input_mode, InputMode::Normal); + + app.enter_editing(); + assert_eq!(app.input_mode, InputMode::Editing); + + app.exit_editing(); + assert_eq!(app.input_mode, InputMode::Normal); + } + + #[test] + fn enter_char_inserts_at_cursor() { + let mut app = PlanApp::new(); + app.enter_editing(); + + app.enter_char('H'); + app.enter_char('i'); + assert_eq!(app.freeform_input, "Hi"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn enter_char_middle_of_string() { + let mut app = PlanApp::new(); + app.freeform_input = "Hllo".to_string(); + app.cursor_position = 1; + + app.enter_char('e'); + assert_eq!(app.freeform_input, "Hello"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn delete_char_removes_before_cursor() { + let mut app = PlanApp::new(); + app.freeform_input = "Hello".to_string(); + app.cursor_position = 5; + + app.delete_char(); + assert_eq!(app.freeform_input, "Hell"); + assert_eq!(app.cursor_position, 4); + } + + #[test] + fn delete_char_at_start_does_nothing() { + let mut app = PlanApp::new(); + app.freeform_input = "Hello".to_string(); + app.cursor_position = 0; + + app.delete_char(); + assert_eq!(app.freeform_input, "Hello"); + assert_eq!(app.cursor_position, 0); + } + + #[test] + fn move_cursor_left() { + let mut app = PlanApp::new(); + app.freeform_input = "Hello".to_string(); + app.cursor_position = 3; + + app.move_cursor_left(); + assert_eq!(app.cursor_position, 2); + + app.cursor_position = 0; + app.move_cursor_left(); + assert_eq!(app.cursor_position, 0); // Can't go below 0 + } + + #[test] + fn move_cursor_right() { + let mut app = PlanApp::new(); + app.freeform_input = "Hello".to_string(); + app.cursor_position = 3; + + app.move_cursor_right(); + assert_eq!(app.cursor_position, 4); + + app.cursor_position = 5; + app.move_cursor_right(); + assert_eq!(app.cursor_position, 5); // Can't go past end + } + + #[test] + fn take_answers_consumes_and_clears() { + let mut app = PlanApp::new(); + app.answers.push(Answer { + question_id: "q1".to_string(), + value: "A".to_string(), + }); + app.answers.push(Answer { + question_id: "q2".to_string(), + value: "B".to_string(), + }); + + let taken = app.take_answers(); + assert_eq!(taken.len(), 2); + assert!(app.answers.is_empty()); + } + + #[test] + fn all_answered_check() { + let mut app = PlanApp::new(); + app.set_questions(vec![ + create_test_question("q1", true), + create_test_question("q2", true), + ]); + + assert!(!app.all_answered()); + + app.answers.push(Answer { + question_id: "q1".to_string(), + value: "A".to_string(), + }); + assert!(!app.all_answered()); + + app.answers.push(Answer { + question_id: "q2".to_string(), + value: "B".to_string(), + }); + assert!(app.all_answered()); + } + + #[test] + fn all_answered_false_when_no_questions() { + let app = PlanApp::new(); + assert!(!app.all_answered()); // No questions means not all answered + } + + #[test] + fn answered_count() { + let mut app = PlanApp::new(); + assert_eq!(app.answered_count(), 0); + + app.answers.push(Answer { + question_id: "q1".to_string(), + value: "A".to_string(), + }); + assert_eq!(app.answered_count(), 1); + } + + #[test] + fn current_question_returns_correct_question() { + let mut app = PlanApp::new(); + app.set_questions(vec![ + create_test_question("q1", true), + create_test_question("q2", true), + ]); + + assert_eq!(app.current_question().unwrap().id, "q1"); + app.current_question = 1; + assert_eq!(app.current_question().unwrap().id, "q2"); + } + + #[test] + fn current_question_none_when_empty() { + let app = PlanApp::new(); + assert!(app.current_question().is_none()); + } + + #[test] + fn push_log_and_scroll() { + let mut app = PlanApp::new(); + app.push_log("Log 1".to_string()); + assert_eq!(app.response_logs.len(), 1); + assert_eq!(app.current_log_index, 0); + + app.push_log("Log 2".to_string()); + assert_eq!(app.current_log_index, 1); + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn scroll_operations() { + let mut app = PlanApp::new(); + app.push_log("Line 1\nLine 2\nLine 3\nLine 4".to_string()); + + app.scroll_down(2); + assert_eq!(app.log_scroll_offset, 2); + + app.scroll_up(1); + assert_eq!(app.log_scroll_offset, 1); + + app.scroll_up(10); // Saturates at 0 + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn reset_submit() { + let mut app = PlanApp::new(); + app.should_submit = true; + app.reset_submit(); + assert!(!app.should_submit); + } +} diff --git a/src/plan/phases.rs b/src/plan/phases.rs index 490cb04..79d0edb 100644 --- a/src/plan/phases.rs +++ b/src/plan/phases.rs @@ -25,3 +25,94 @@ impl std::fmt::Display for PlanPhase { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_exploring() { + assert_eq!(PlanPhase::Exploring.to_string(), "Exploring"); + } + + #[test] + fn display_asking() { + assert_eq!(PlanPhase::Asking.to_string(), "Asking"); + } + + #[test] + fn display_working() { + assert_eq!(PlanPhase::Working.to_string(), "Working"); + } + + #[test] + fn display_complete() { + assert_eq!(PlanPhase::Complete.to_string(), "Complete"); + } + + #[test] + fn serde_roundtrip() { + for phase in [ + PlanPhase::Exploring, + PlanPhase::Asking, + PlanPhase::Working, + PlanPhase::Complete, + ] { + let json = serde_json::to_string(&phase).unwrap(); + let deserialized: PlanPhase = serde_json::from_str(&json).unwrap(); + assert_eq!(phase, deserialized); + } + } + + #[test] + fn serde_snake_case_serialization() { + assert_eq!( + serde_json::to_string(&PlanPhase::Exploring).unwrap(), + "\"exploring\"" + ); + assert_eq!( + serde_json::to_string(&PlanPhase::Asking).unwrap(), + "\"asking\"" + ); + assert_eq!( + serde_json::to_string(&PlanPhase::Working).unwrap(), + "\"working\"" + ); + assert_eq!( + serde_json::to_string(&PlanPhase::Complete).unwrap(), + "\"complete\"" + ); + } + + #[test] + fn serde_deserialization_from_snake_case() { + assert_eq!( + serde_json::from_str::("\"exploring\"").unwrap(), + PlanPhase::Exploring + ); + assert_eq!( + serde_json::from_str::("\"asking\"").unwrap(), + PlanPhase::Asking + ); + assert_eq!( + serde_json::from_str::("\"working\"").unwrap(), + PlanPhase::Working + ); + assert_eq!( + serde_json::from_str::("\"complete\"").unwrap(), + PlanPhase::Complete + ); + } + + #[test] + fn equality_same_variants() { + assert_eq!(PlanPhase::Exploring, PlanPhase::Exploring); + assert_eq!(PlanPhase::Asking, PlanPhase::Asking); + } + + #[test] + fn inequality_different_variants() { + assert_ne!(PlanPhase::Exploring, PlanPhase::Asking); + assert_ne!(PlanPhase::Working, PlanPhase::Complete); + } +} diff --git a/src/plan/prompts.rs b/src/plan/prompts.rs index 793a4d1..5019e46 100644 --- a/src/plan/prompts.rs +++ b/src/plan/prompts.rs @@ -104,3 +104,74 @@ pub fn build_resume_prompt(turn_count: u32, last_phase: &str) -> String { Continue from where we left off. Respond with your current phase and any questions or the final PRD."# ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn system_prompt_contains_phase_keywords() { + assert!(SYSTEM_PROMPT.contains("exploring")); + assert!(SYSTEM_PROMPT.contains("asking")); + assert!(SYSTEM_PROMPT.contains("working")); + assert!(SYSTEM_PROMPT.contains("complete")); + } + + #[test] + fn system_prompt_contains_json_format() { + assert!(SYSTEM_PROMPT.contains("JSON")); + assert!(SYSTEM_PROMPT.contains("phase")); + } + + #[test] + fn build_initial_prompt_includes_user_request() { + let request = "Add user authentication"; + let prompt = build_initial_prompt(request); + assert!(prompt.contains(request)); + assert!(prompt.contains(SYSTEM_PROMPT)); + assert!(prompt.contains("User Request")); + } + + #[test] + fn build_continuation_prompt_empty_answers() { + let prompt = build_continuation_prompt(&[]); + assert_eq!(prompt, "Continue with the PRD generation."); + } + + #[test] + fn build_continuation_prompt_with_answers() { + let answers = vec![ + Answer { + question_id: "q1".to_string(), + value: "React".to_string(), + }, + Answer { + question_id: "q2".to_string(), + value: "PostgreSQL".to_string(), + }, + ]; + let prompt = build_continuation_prompt(&answers); + assert!(prompt.contains("q1: React")); + assert!(prompt.contains("q2: PostgreSQL")); + assert!(prompt.contains("User provided the following answers")); + assert!(prompt.contains("Continue with the PRD generation based on these answers")); + } + + #[test] + fn build_resume_prompt_includes_turn_count() { + let prompt = build_resume_prompt(5, "asking"); + assert!(prompt.contains("Turns completed: 5")); + assert!(prompt.contains("Last phase: asking")); + assert!(prompt.contains("resumed session")); + } + + #[test] + fn build_resume_prompt_different_phases() { + let prompt = build_resume_prompt(0, "exploring"); + assert!(prompt.contains("Last phase: exploring")); + + let prompt = build_resume_prompt(10, "working"); + assert!(prompt.contains("Turns completed: 10")); + assert!(prompt.contains("Last phase: working")); + } +} diff --git a/src/plan/protocol.rs b/src/plan/protocol.rs index 37c5657..64e1db1 100644 --- a/src/plan/protocol.rs +++ b/src/plan/protocol.rs @@ -191,3 +191,199 @@ pub const PLAN_RESPONSE_SCHEMA: &str = r#"{ } } }"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_exploring_response() { + let json = r#"{"phase": "exploring", "status": "Reading files..."}"#; + let response: PlanResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.phase, PlanPhase::Exploring); + assert_eq!(response.status, Some("Reading files...".to_string())); + assert!(response.questions.is_none()); + assert!(response.prd.is_none()); + } + + #[test] + fn parse_asking_response_with_questions() { + let json = r#"{ + "phase": "asking", + "status": "Need clarification", + "questions": [{ + "id": "q1", + "category": "scope", + "text": "What framework?", + "allow_freeform": true, + "options": [ + {"key": "A", "label": "React"}, + {"key": "B", "label": "Vue", "description": "Progressive framework"} + ] + }] + }"#; + let response: PlanResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.phase, PlanPhase::Asking); + let questions = response.questions.unwrap(); + assert_eq!(questions.len(), 1); + assert_eq!(questions[0].id, "q1"); + assert_eq!(questions[0].category, "scope"); + assert!(questions[0].allow_freeform); + let opts = questions[0].options.as_ref().unwrap(); + assert_eq!(opts.len(), 2); + assert_eq!(opts[0].key, "A"); + assert_eq!( + opts[1].description, + Some("Progressive framework".to_string()) + ); + } + + #[test] + fn parse_complete_response_with_prd() { + let json = r#"{ + "phase": "complete", + "prd": { + "name": "Test PRD", + "quality_gates": ["cargo test", "cargo clippy"], + "tasks": [{ + "category": "feature", + "description": "Add login", + "steps": ["Create form", "Add validation"], + "passes": false + }] + } + }"#; + let response: PlanResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.phase, PlanPhase::Complete); + let prd = response.prd.unwrap(); + assert_eq!(prd.name, "Test PRD"); + assert_eq!(prd.quality_gates.len(), 2); + assert_eq!(prd.tasks.len(), 1); + assert!(!prd.tasks[0].passes); + } + + #[test] + fn parse_minimal_response_phase_only() { + let json = r#"{"phase": "working"}"#; + let response: PlanResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.phase, PlanPhase::Working); + assert!(response.status.is_none()); + assert!(response.questions.is_none()); + assert!(response.context.is_none()); + assert!(response.prd.is_none()); + } + + #[test] + fn question_serialization_roundtrip() { + let question = Question { + id: "q1".to_string(), + category: "technical".to_string(), + text: "Which database?".to_string(), + context: Some("Important for scalability".to_string()), + options: Some(vec![QuestionOption { + key: "A".to_string(), + label: "PostgreSQL".to_string(), + description: None, + }]), + allow_freeform: false, + }; + let json = serde_json::to_string(&question).unwrap(); + let deserialized: Question = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, question.id); + assert_eq!(deserialized.context, question.context); + } + + #[test] + fn question_option_without_description() { + let json = r#"{"key": "A", "label": "Option A"}"#; + let opt: QuestionOption = serde_json::from_str(json).unwrap(); + assert_eq!(opt.key, "A"); + assert_eq!(opt.label, "Option A"); + assert!(opt.description.is_none()); + } + + #[test] + fn phase_context_defaults() { + let context = PhaseContext::default(); + assert!(context.codebase_summary.is_none()); + assert!(context.requirements.is_none()); + assert!(context.quality_gates.is_none()); + assert!(context.tasks.is_none()); + } + + #[test] + fn phase_context_from_empty_json() { + let json = r#"{}"#; + let context: PhaseContext = serde_json::from_str(json).unwrap(); + assert!(context.codebase_summary.is_none()); + assert!(context.requirements.is_none()); + } + + #[test] + fn plan_response_schema_is_valid_json() { + let parsed: serde_json::Value = serde_json::from_str(PLAN_RESPONSE_SCHEMA).unwrap(); + assert_eq!(parsed["type"], "object"); + assert!( + parsed["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("phase")) + ); + } + + #[test] + fn answer_serialization() { + let answer = Answer { + question_id: "q1".to_string(), + value: "Option A".to_string(), + }; + let json = serde_json::to_string(&answer).unwrap(); + assert!(json.contains("q1")); + assert!(json.contains("Option A")); + } + + #[test] + fn malformed_json_missing_phase_fails() { + let json = r#"{"status": "test"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn invalid_phase_value_fails() { + let json = r#"{"phase": "invalid_phase"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn codebase_summary_partial_fields() { + let json = r#"{"languages": ["Rust", "Python"]}"#; + let summary: CodebaseSummary = serde_json::from_str(json).unwrap(); + assert_eq!( + summary.languages, + Some(vec!["Rust".to_string(), "Python".to_string()]) + ); + assert!(summary.frameworks.is_none()); + assert!(summary.structure.is_none()); + } + + #[test] + fn requirement_with_optional_priority() { + let json = r#"{"category": "feature", "description": "Add auth"}"#; + let req: Requirement = serde_json::from_str(json).unwrap(); + assert!(req.priority.is_none()); + + let json_with_priority = + r#"{"category": "feature", "description": "Add auth", "priority": "high"}"#; + let req_with_priority: Requirement = serde_json::from_str(json_with_priority).unwrap(); + assert_eq!(req_with_priority.priority, Some("high".to_string())); + } + + #[test] + fn task_passes_defaults_to_false() { + let json = r#"{"category": "test", "description": "Add tests", "steps": ["step1"]}"#; + let task: Task = serde_json::from_str(json).unwrap(); + assert!(!task.passes); + } +} diff --git a/src/plan/session.rs b/src/plan/session.rs index 546675d..7f2d3c9 100644 --- a/src/plan/session.rs +++ b/src/plan/session.rs @@ -169,3 +169,239 @@ impl PlanSession { self.turn_count == 0 } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn new_session_has_uuid() { + let session = PlanSession::new("/tmp/prd.json"); + assert!(!session.id.is_empty()); + // UUID v4 format check (basic) + assert!(session.id.contains('-')); + assert_eq!(session.id.len(), 36); + } + + #[test] + fn new_session_starts_fresh() { + let session = PlanSession::new("/tmp/prd.json"); + assert!(session.is_fresh()); + assert_eq!(session.turn_count, 0); + assert_eq!(session.last_phase, PlanPhase::Exploring); + assert!(session.answers.is_empty()); + } + + #[test] + fn new_session_stores_output_path() { + let session = PlanSession::new("/custom/path/prd.json"); + assert_eq!(session.output_path, "/custom/path/prd.json"); + } + + #[test] + fn session_file_path_calculation() { + let path = PlanSession::session_file_path("/some/dir/prd.json"); + assert_eq!(path.to_str().unwrap(), "/some/dir/.ralph-session.json"); + } + + #[test] + fn session_file_path_current_dir() { + let path = PlanSession::session_file_path("prd.json"); + // When there's no parent dir, Path returns "" which becomes "." joined with filename + assert_eq!( + path.file_name().unwrap().to_str().unwrap(), + ".ralph-session.json" + ); + } + + #[test] + fn advance_increments_turn_and_updates_phase() { + let mut session = PlanSession::new("/tmp/prd.json"); + assert_eq!(session.turn_count, 0); + assert_eq!(session.last_phase, PlanPhase::Exploring); + + session.advance(PlanPhase::Asking); + assert_eq!(session.turn_count, 1); + assert_eq!(session.last_phase, PlanPhase::Asking); + + session.advance(PlanPhase::Working); + assert_eq!(session.turn_count, 2); + assert_eq!(session.last_phase, PlanPhase::Working); + } + + #[test] + fn add_answer_stores_answer() { + let mut session = PlanSession::new("/tmp/prd.json"); + assert!(session.answers.is_empty()); + + session.add_answer(Answer { + question_id: "q1".to_string(), + value: "React".to_string(), + }); + assert_eq!(session.answers.len(), 1); + assert_eq!(session.answers[0].question_id, "q1"); + + session.add_answer(Answer { + question_id: "q2".to_string(), + value: "PostgreSQL".to_string(), + }); + assert_eq!(session.answers.len(), 2); + } + + #[test] + fn merge_context_replaces_codebase_summary() { + let mut session = PlanSession::new("/tmp/prd.json"); + assert!(session.context.codebase_summary.is_none()); + + let context = PhaseContext { + codebase_summary: Some(super::super::protocol::CodebaseSummary { + languages: Some(vec!["Rust".to_string()]), + frameworks: None, + structure: None, + key_files: None, + }), + ..Default::default() + }; + session.merge_context(context); + assert!(session.context.codebase_summary.is_some()); + assert_eq!( + session.context.codebase_summary.as_ref().unwrap().languages, + Some(vec!["Rust".to_string()]) + ); + } + + #[test] + fn merge_context_appends_requirements() { + let mut session = PlanSession::new("/tmp/prd.json"); + + let context1 = PhaseContext { + requirements: Some(vec![super::super::protocol::Requirement { + category: "feature".to_string(), + description: "Add auth".to_string(), + priority: None, + }]), + ..Default::default() + }; + session.merge_context(context1); + assert_eq!(session.context.requirements.as_ref().unwrap().len(), 1); + + let context2 = PhaseContext { + requirements: Some(vec![super::super::protocol::Requirement { + category: "test".to_string(), + description: "Add tests".to_string(), + priority: None, + }]), + ..Default::default() + }; + session.merge_context(context2); + assert_eq!(session.context.requirements.as_ref().unwrap().len(), 2); + } + + #[test] + fn save_and_load_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let prd_path_str = prd_path.to_str().unwrap(); + + let mut session = PlanSession::new(prd_path_str); + session.advance(PlanPhase::Asking); + session.add_answer(Answer { + question_id: "q1".to_string(), + value: "test value".to_string(), + }); + + session.save().unwrap(); + + // Load it back + let loaded = PlanSession::load_or_create(prd_path_str, true, false).unwrap(); + assert_eq!(loaded.id, session.id); + assert_eq!(loaded.turn_count, 1); + assert_eq!(loaded.last_phase, PlanPhase::Asking); + assert_eq!(loaded.answers.len(), 1); + } + + #[test] + fn load_or_create_without_resume_or_force_errors() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let prd_path_str = prd_path.to_str().unwrap(); + + // Create and save a session + let session = PlanSession::new(prd_path_str); + session.save().unwrap(); + + // Try to load without resume or force + let result = PlanSession::load_or_create(prd_path_str, false, false); + assert!(matches!(result, Err(SessionError::SessionExists))); + } + + #[test] + fn load_or_create_with_force_creates_new() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let prd_path_str = prd_path.to_str().unwrap(); + + // Create and save a session with some turns + let mut session = PlanSession::new(prd_path_str); + session.advance(PlanPhase::Asking); + session.advance(PlanPhase::Working); + let old_id = session.id.clone(); + session.save().unwrap(); + + // Force create new session + let new_session = PlanSession::load_or_create(prd_path_str, false, true).unwrap(); + assert_ne!(new_session.id, old_id); + assert!(new_session.is_fresh()); + assert_eq!(new_session.turn_count, 0); + } + + #[test] + fn load_or_create_without_existing_creates_new() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let prd_path_str = prd_path.to_str().unwrap(); + + // No existing session file + let session = PlanSession::load_or_create(prd_path_str, false, false).unwrap(); + assert!(session.is_fresh()); + } + + #[test] + fn cleanup_removes_session_file() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let prd_path_str = prd_path.to_str().unwrap(); + let session_path = PlanSession::session_file_path(prd_path_str); + + let session = PlanSession::new(prd_path_str); + session.save().unwrap(); + assert!(session_path.exists()); + + session.cleanup().unwrap(); + assert!(!session_path.exists()); + } + + #[test] + fn cleanup_handles_missing_file() { + let session = PlanSession::new("/tmp/nonexistent/prd.json"); + // Should not error even if file doesn't exist + let result = session.cleanup(); + assert!(result.is_ok()); + } + + #[test] + fn is_fresh_returns_false_after_advance() { + let mut session = PlanSession::new("/tmp/prd.json"); + assert!(session.is_fresh()); + + session.advance(PlanPhase::Exploring); + assert!(!session.is_fresh()); + } + + #[test] + fn timestamps_are_set() { + let session = PlanSession::new("/tmp/prd.json"); + assert!(session.created_at <= session.updated_at); + } +} diff --git a/src/prd.rs b/src/prd.rs index 391600b..5760d3d 100644 --- a/src/prd.rs +++ b/src/prd.rs @@ -59,3 +59,135 @@ pub fn load_prd_from_file(prd_path: &str) -> Prd { serde_json::from_str(&file_content) .unwrap_or_else(|_| panic!("Invalid JSON formatting in prd {}", prd_path)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_prd_json() -> &'static str { + r#"{ + "name": "Test PRD", + "quality_gates": ["cargo test", "cargo clippy"], + "tasks": [ + { + "category": "feature", + "description": "Add login", + "steps": ["Create form", "Add validation"], + "passes": false + }, + { + "category": "test", + "description": "Add tests", + "steps": ["Unit tests"], + "passes": true + } + ] + }"# + } + + fn create_test_completed_json() -> &'static str { + r#"[ + { + "category": "setup", + "description": "Initial setup", + "steps": ["Create project"], + "completed_at": "2024-01-15" + } + ]"# + } + + #[test] + fn load_prd_from_valid_file() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + fs::write(&prd_path, create_test_prd_json()).unwrap(); + + let prd = load_prd_from_file(prd_path.to_str().unwrap()); + assert_eq!(prd.name, "Test PRD"); + assert_eq!(prd.quality_gates.len(), 2); + assert_eq!(prd.tasks.len(), 2); + assert!(!prd.tasks[0].passes); + assert!(prd.tasks[1].passes); + } + + #[test] + #[should_panic(expected = "PRD file not found")] + fn load_prd_nonexistent_file_panics() { + load_prd_from_file("/nonexistent/path/prd.json"); + } + + #[test] + #[should_panic(expected = "Invalid JSON formatting")] + fn load_prd_invalid_json_panics() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + fs::write(&prd_path, "not valid json {{{").unwrap(); + + load_prd_from_file(prd_path.to_str().unwrap()); + } + + #[test] + #[should_panic(expected = "Invalid JSON formatting")] + fn load_prd_wrong_schema_panics() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + fs::write(&prd_path, r#"{"wrong": "schema"}"#).unwrap(); + + load_prd_from_file(prd_path.to_str().unwrap()); + } + + #[test] + fn load_completed_tasks_returns_none_when_missing() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + fs::write(&prd_path, create_test_prd_json()).unwrap(); + + let result = load_completed_tasks_from_file(prd_path.to_str().unwrap()); + assert!(result.is_none()); + } + + #[test] + fn load_completed_tasks_returns_tasks_when_exists() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let completed_path = temp_dir.path().join("completed.json"); + + fs::write(&prd_path, create_test_prd_json()).unwrap(); + fs::write(&completed_path, create_test_completed_json()).unwrap(); + + let result = load_completed_tasks_from_file(prd_path.to_str().unwrap()); + assert!(result.is_some()); + let tasks = result.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].category, "setup"); + assert_eq!(tasks[0].completed_at, "2024-01-15"); + } + + #[test] + #[should_panic(expected = "Invalid JSON formatting")] + fn load_completed_tasks_invalid_json_panics() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let completed_path = temp_dir.path().join("completed.json"); + + fs::write(&prd_path, create_test_prd_json()).unwrap(); + fs::write(&completed_path, "invalid json").unwrap(); + + load_completed_tasks_from_file(prd_path.to_str().unwrap()); + } + + #[test] + fn prd_task_fields() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + fs::write(&prd_path, create_test_prd_json()).unwrap(); + + let prd = load_prd_from_file(prd_path.to_str().unwrap()); + let task = &prd.tasks[0]; + assert_eq!(task.category, "feature"); + assert_eq!(task.description, "Add login"); + assert_eq!(task.steps, vec!["Create form", "Add validation"]); + } +} diff --git a/src/prompt.rs b/src/prompt.rs index c78ff66..c573168 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -23,3 +23,39 @@ const MASTER_PROMPT: &str = r#" const _REGRETS_PROMPT: &str = r#" hello "#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn make_prompt_includes_prd_path() { + let prompt = make_prompt("/path/to/prd.json"); + assert!(prompt.starts_with("@/path/to/prd.json")); + } + + #[test] + fn make_prompt_includes_progress_reference() { + let prompt = make_prompt("prd.json"); + assert!(prompt.contains("@progress.txt")); + } + + #[test] + fn make_prompt_includes_master_instructions() { + let prompt = make_prompt("prd.json"); + assert!(prompt.contains("Find the highest priority feature")); + assert!(prompt.contains("quality gates")); + assert!(prompt.contains("git commit")); + } + + #[test] + fn make_prompt_includes_completed_json_reference() { + let prompt = make_prompt("prd.json"); + assert!(prompt.contains("completed.json")); + } + + #[test] + fn master_prompt_contains_complete_marker() { + assert!(MASTER_PROMPT.contains("COMPLETE")); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs new file mode 100644 index 0000000..f4cc249 --- /dev/null +++ b/tests/cli_integration.rs @@ -0,0 +1,139 @@ +//! CLI integration tests using assert_cmd + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn ralph_cmd() -> Command { + #[allow(deprecated)] + Command::cargo_bin("ralph").unwrap() +} + +#[test] +fn cli_no_args_shows_help() { + ralph_cmd() + .assert() + .failure() + .stderr(predicate::str::contains("Usage:")) + .stderr(predicate::str::contains("ralph")); +} + +#[test] +fn cli_help_flag() { + ralph_cmd() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("AI-powered PRD execution")) + .stdout(predicate::str::contains("build")) + .stdout(predicate::str::contains("plan")); +} + +#[test] +fn cli_version_flag() { + ralph_cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("ralph")) + .stdout(predicate::str::contains("0.1.0")); +} + +#[test] +fn cli_build_help() { + ralph_cmd() + .args(["build", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Execute tasks from an existing PRD", + )) + .stdout(predicate::str::contains("--prd-path")); +} + +#[test] +fn cli_plan_help() { + ralph_cmd() + .args(["plan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Generate a new PRD")) + .stdout(predicate::str::contains("--output")) + .stdout(predicate::str::contains("--resume")) + .stdout(predicate::str::contains("--force")); +} + +#[test] +fn cli_build_nonexistent_prd_fails() { + ralph_cmd() + .args(["build", "--prd-path", "/nonexistent/path/prd.json"]) + .assert() + .failure(); +} + +#[test] +fn cli_build_invalid_prd_fails() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("invalid.json"); + std::fs::write(&prd_path, "not valid json {{{").unwrap(); + + ralph_cmd() + .args(["build", "--prd-path", prd_path.to_str().unwrap()]) + .assert() + .failure(); +} + +#[test] +fn cli_plan_output_flag_accepted() { + // Just test that the flag is recognized - actual execution would require Claude + ralph_cmd() + .args(["plan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("-o, --output")); +} + +#[test] +fn cli_plan_resume_flag_accepted() { + ralph_cmd() + .args(["plan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("-r, --resume")); +} + +#[test] +fn cli_plan_force_flag_accepted() { + ralph_cmd() + .args(["plan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("-f, --force")); +} + +#[test] +fn cli_plan_description_flag_accepted() { + ralph_cmd() + .args(["plan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("-d, --description")); +} + +#[test] +fn cli_invalid_subcommand_fails() { + ralph_cmd() + .arg("invalid-command") + .assert() + .failure() + .stderr(predicate::str::contains("error")); +} + +#[test] +fn cli_build_max_loops_flag() { + ralph_cmd() + .args(["build", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("-l, --max-loops")); +} diff --git a/tests/prd_workflow.rs b/tests/prd_workflow.rs new file mode 100644 index 0000000..730c402 --- /dev/null +++ b/tests/prd_workflow.rs @@ -0,0 +1,149 @@ +//! Integration tests for PRD file lifecycle + +use std::fs; +use tempfile::TempDir; + +/// Test complete PRD file lifecycle +#[test] +fn prd_lifecycle_create_load_verify() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + + // Create a valid PRD + let prd_content = r#"{ + "name": "Integration Test PRD", + "quality_gates": ["cargo test", "cargo clippy", "cargo fmt --check"], + "tasks": [ + { + "category": "feature", + "description": "Add user authentication", + "steps": ["Create login form", "Add JWT validation", "Implement logout"], + "passes": false + }, + { + "category": "test", + "description": "Add unit tests for auth", + "steps": ["Test login", "Test JWT expiry"], + "passes": false + } + ] + }"#; + + fs::write(&prd_path, prd_content).unwrap(); + + // Verify the file exists and is readable + assert!(prd_path.exists()); + let read_content = fs::read_to_string(&prd_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&read_content).unwrap(); + + assert_eq!(parsed["name"], "Integration Test PRD"); + assert_eq!(parsed["quality_gates"].as_array().unwrap().len(), 3); + assert_eq!(parsed["tasks"].as_array().unwrap().len(), 2); +} + +/// Test PRD with completed tasks +#[test] +fn prd_with_completed_json() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + let completed_path = temp_dir.path().join("completed.json"); + + // Create PRD with remaining tasks + let prd_content = r#"{ + "name": "Test PRD", + "quality_gates": ["cargo test"], + "tasks": [ + { + "category": "feature", + "description": "Remaining task", + "steps": ["Step 1"], + "passes": false + } + ] + }"#; + + // Create completed.json with finished tasks + let completed_content = r#"[ + { + "category": "setup", + "description": "Project setup", + "steps": ["Init repo", "Add deps"], + "completed_at": "2024-01-15" + }, + { + "category": "feature", + "description": "Basic structure", + "steps": ["Create modules"], + "completed_at": "2024-01-16" + } + ]"#; + + fs::write(&prd_path, prd_content).unwrap(); + fs::write(&completed_path, completed_content).unwrap(); + + // Verify both files exist + assert!(prd_path.exists()); + assert!(completed_path.exists()); + + // Verify completed tasks + let completed: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&completed_path).unwrap()).unwrap(); + assert_eq!(completed.as_array().unwrap().len(), 2); + assert_eq!(completed[0]["completed_at"], "2024-01-15"); +} + +/// Test PRD validation - missing required fields +#[test] +fn prd_validation_missing_fields() { + let invalid_prds = vec![ + // Missing name + r#"{"quality_gates": [], "tasks": []}"#, + // Missing quality_gates + r#"{"name": "Test", "tasks": []}"#, + // Missing tasks + r#"{"name": "Test", "quality_gates": []}"#, + ]; + + for invalid in invalid_prds { + let parsed: Result = serde_json::from_str(invalid); + // These parse as JSON but don't match our schema + assert!(parsed.is_ok()); + let value = parsed.unwrap(); + // At least one required field should be missing + let has_all = value.get("name").is_some() + && value.get("quality_gates").is_some() + && value.get("tasks").is_some(); + assert!(!has_all); + } +} + +/// Test task state progression +#[test] +fn task_passes_state_tracking() { + let temp_dir = TempDir::new().unwrap(); + let prd_path = temp_dir.path().join("prd.json"); + + // Create PRD with tasks in different states + let prd_content = r#"{ + "name": "State Test", + "quality_gates": ["cargo test"], + "tasks": [ + {"category": "a", "description": "Not started", "steps": ["1"], "passes": false}, + {"category": "b", "description": "In progress", "steps": ["1"], "passes": false}, + {"category": "c", "description": "Completed", "steps": ["1"], "passes": true} + ] + }"#; + + fs::write(&prd_path, prd_content).unwrap(); + + let prd: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&prd_path).unwrap()).unwrap(); + let tasks = prd["tasks"].as_array().unwrap(); + + // Count passes states + let completed_count = tasks.iter().filter(|t| t["passes"] == true).count(); + let pending_count = tasks.iter().filter(|t| t["passes"] == false).count(); + + assert_eq!(completed_count, 1); + assert_eq!(pending_count, 2); +} diff --git a/tests/session_workflow.rs b/tests/session_workflow.rs new file mode 100644 index 0000000..8e13576 --- /dev/null +++ b/tests/session_workflow.rs @@ -0,0 +1,192 @@ +//! Integration tests for session lifecycle and context merging + +use std::fs; +use tempfile::TempDir; + +/// Test session JSON structure +#[test] +fn session_json_structure() { + let session = serde_json::json!({ + "id": "550e8400-e29b-41d4-a716-446655440000", + "output_path": "/path/to/prd.json", + "last_phase": "exploring", + "turn_count": 0, + "context": {}, + "answers": [], + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" + }); + + // Verify all required fields + assert!(session["id"].is_string()); + assert!(session["output_path"].is_string()); + assert!(session["last_phase"].is_string()); + assert!(session["turn_count"].is_number()); + assert!(session["context"].is_object()); + assert!(session["answers"].is_array()); +} + +/// Test session file persistence +#[test] +fn session_file_persistence() { + let temp_dir = TempDir::new().unwrap(); + let session_path = temp_dir.path().join(".ralph-session.json"); + + let session = serde_json::json!({ + "id": "test-session-123", + "output_path": "prd.json", + "last_phase": "asking", + "turn_count": 3, + "context": { + "codebase_summary": { + "languages": ["Rust"], + "frameworks": ["tokio", "serde"] + } + }, + "answers": [ + {"question_id": "q1", "value": "React"}, + {"question_id": "q2", "value": "PostgreSQL"} + ], + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T11:00:00Z" + }); + + // Write session + fs::write( + &session_path, + serde_json::to_string_pretty(&session).unwrap(), + ) + .unwrap(); + + // Read it back + let loaded: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&session_path).unwrap()).unwrap(); + + assert_eq!(loaded["id"], "test-session-123"); + assert_eq!(loaded["turn_count"], 3); + assert_eq!(loaded["answers"].as_array().unwrap().len(), 2); +} + +/// Test context merging behavior +#[test] +fn context_merging_accumulation() { + // Simulate how context would accumulate across turns + let mut context = serde_json::json!({}); + + // Turn 1: Add codebase summary + let turn1_context = serde_json::json!({ + "codebase_summary": { + "languages": ["Rust", "TypeScript"], + "structure": "Monorepo" + } + }); + + // Merge turn 1 + if let Some(summary) = turn1_context.get("codebase_summary") { + context["codebase_summary"] = summary.clone(); + } + + assert_eq!( + context["codebase_summary"]["languages"] + .as_array() + .unwrap() + .len(), + 2 + ); + + // Turn 2: Add requirements + let turn2_context = serde_json::json!({ + "requirements": [ + {"category": "feature", "description": "Add auth"} + ] + }); + + if let Some(reqs) = turn2_context.get("requirements") { + context["requirements"] = reqs.clone(); + } + + // Turn 3: Add more requirements (should append) + let turn3_context = serde_json::json!({ + "requirements": [ + {"category": "test", "description": "Add tests"} + ] + }); + + if let Some(new_reqs) = turn3_context["requirements"].as_array() { + let existing = context["requirements"] + .as_array_mut() + .expect("requirements should be array"); + existing.extend(new_reqs.iter().cloned()); + } + + assert_eq!(context["requirements"].as_array().unwrap().len(), 2); +} + +/// Test answer collection across questions +#[test] +fn answer_collection_workflow() { + let mut answers: Vec = Vec::new(); + + // Simulate answering questions + let questions = [ + serde_json::json!({"id": "q1", "text": "Framework?", "options": [{"key": "A", "label": "React"}]}), + serde_json::json!({"id": "q2", "text": "Database?", "options": [{"key": "A", "label": "Postgres"}]}), + serde_json::json!({"id": "q3", "text": "Custom input?", "allow_freeform": true}), + ]; + + // Answer each question + answers.push(serde_json::json!({"question_id": "q1", "value": "A"})); + answers.push(serde_json::json!({"question_id": "q2", "value": "A"})); + answers.push(serde_json::json!({"question_id": "q3", "value": "Custom answer here"})); + + // All questions answered + assert_eq!(answers.len(), questions.len()); + + // Verify each answer references a valid question + for answer in &answers { + let qid = answer["question_id"].as_str().unwrap(); + let matching_question = questions.iter().find(|q| q["id"].as_str().unwrap() == qid); + assert!(matching_question.is_some()); + } +} + +/// Test session cleanup +#[test] +fn session_cleanup_removes_file() { + let temp_dir = TempDir::new().unwrap(); + let session_path = temp_dir.path().join(".ralph-session.json"); + + // Create session file + fs::write(&session_path, "{}").unwrap(); + assert!(session_path.exists()); + + // Cleanup + fs::remove_file(&session_path).unwrap(); + assert!(!session_path.exists()); +} + +/// Test session resume detection +#[test] +fn session_resume_detection() { + let temp_dir = TempDir::new().unwrap(); + let session_path = temp_dir.path().join(".ralph-session.json"); + + // No session exists - should allow new creation + assert!(!session_path.exists()); + + // Create a session + let session = serde_json::json!({ + "id": "existing-session", + "turn_count": 5 + }); + fs::write(&session_path, serde_json::to_string(&session).unwrap()).unwrap(); + + // Session exists - need resume flag + assert!(session_path.exists()); + + // Simulate loading for resume + let loaded: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&session_path).unwrap()).unwrap(); + assert_eq!(loaded["id"], "existing-session"); + assert_eq!(loaded["turn_count"], 5); +} From a72a80453468139c7137cb792e51ecc7c73b18d6 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:04:47 +0000 Subject: [PATCH 2/2] fix(ci): remove doc tests for binary-only crate cargo test --doc fails on binary crates with no library target. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d27e9..db4fdcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,6 @@ jobs: target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: cargo test --all-targets - - run: cargo test --doc lint: name: Lint